Merge remote-tracking branch 'upstream/main'
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
# WACE 반응형 컴포넌트 전략
|
||||
|
||||
## 개요
|
||||
|
||||
WACE 프로젝트의 모든 반응형 UI는 **3개의 레이아웃 프리미티브 + 1개의 훅**으로 통일한다.
|
||||
컴포넌트마다 새로 타입을 정의하거나 리사이저를 구현하지 않는다.
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ useResponsive() 훅 │
|
||||
│ isMobile | isTablet | isDesktop | width │
|
||||
└──────────┬──────────┬──────────┬────────────────┘
|
||||
│ │ │
|
||||
┌───────▼──┐ ┌────▼─────┐ ┌─▼──────────────┐
|
||||
│ 데이터 │ │ 좌우분할 │ │ 캔버스(디자이너)│
|
||||
│ 목록 │ │ 패널 │ │ 화면 │
|
||||
└──────────┘ └──────────┘ └────────────────┘
|
||||
ResponsiveDataView ResponsiveSplitPanel ResponsiveGridRenderer
|
||||
```
|
||||
|
||||
## 1. useResponsive (훅)
|
||||
|
||||
**위치**: `frontend/lib/hooks/useResponsive.ts`
|
||||
|
||||
모든 반응형 판단의 기반. 직접 breakpoint 분기가 필요할 때만 사용.
|
||||
가능하면 아래 레이아웃 컴포넌트를 쓰고, 훅 직접 사용은 최소화.
|
||||
|
||||
| 반환값 | 브레이크포인트 | 해상도 |
|
||||
|--------|---------------|--------|
|
||||
| isMobile | xs, sm | < 768px |
|
||||
| isTablet | md | 768 ~ 1023px |
|
||||
| isDesktop | lg, xl, 2xl | >= 1024px |
|
||||
|
||||
## 2. ResponsiveDataView (데이터 목록)
|
||||
|
||||
**위치**: `frontend/components/common/ResponsiveDataView.tsx`
|
||||
**패턴**: 데스크톱 = 테이블, 모바일 = 카드 리스트
|
||||
**적용 대상**: 모든 목록/리스트 화면
|
||||
|
||||
```tsx
|
||||
<ResponsiveDataView<User>
|
||||
data={users}
|
||||
columns={columns}
|
||||
keyExtractor={(u) => u.id}
|
||||
cardTitle={(u) => u.name}
|
||||
cardFields={[
|
||||
{ label: "이메일", render: (u) => u.email },
|
||||
{ label: "부서", render: (u) => u.dept },
|
||||
]}
|
||||
renderActions={(u) => <Button>편집</Button>}
|
||||
/>
|
||||
```
|
||||
|
||||
**적용 완료 (12개 화면)**:
|
||||
- UserTable, CompanyTable, UserAuthTable
|
||||
- DataFlowList, ScreenList
|
||||
- system-notices, approvalTemplate, standards
|
||||
- batch-management, mail/receive, flowMgmtList
|
||||
- exconList, exCallConfList
|
||||
|
||||
## 3. ResponsiveSplitPanel (좌우 분할)
|
||||
|
||||
**위치**: `frontend/components/common/ResponsiveSplitPanel.tsx`
|
||||
**패턴**: 데스크톱 = 좌우 분할(리사이저 포함), 모바일 = 세로 스택(접기/펼치기)
|
||||
**적용 대상**: 카테고리관리, 메뉴관리, 부서관리, BOM 등 좌우 분할 레이아웃
|
||||
|
||||
```tsx
|
||||
<ResponsiveSplitPanel
|
||||
left={<TreeView />}
|
||||
right={<DetailPanel />}
|
||||
leftTitle="카테고리"
|
||||
leftWidth={25}
|
||||
minLeftWidth={10}
|
||||
maxLeftWidth={40}
|
||||
height="calc(100vh - 120px)"
|
||||
/>
|
||||
```
|
||||
|
||||
**Props**:
|
||||
| Prop | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| left | ReactNode | 필수 | 좌측 패널 콘텐츠 |
|
||||
| right | ReactNode | 필수 | 우측 패널 콘텐츠 |
|
||||
| leftTitle | string | "목록" | 모바일 접기 헤더 |
|
||||
| leftWidth | number | 25 | 초기 좌측 너비(%) |
|
||||
| minLeftWidth | number | 10 | 최소 좌측 너비(%) |
|
||||
| maxLeftWidth | number | 50 | 최대 좌측 너비(%) |
|
||||
| showResizer | boolean | true | 리사이저 표시 |
|
||||
| collapsedOnMobile | boolean | true | 모바일 기본 접힘 |
|
||||
| height | string | "100%" | 컨테이너 높이 |
|
||||
|
||||
**동작**:
|
||||
- 데스크톱(>= 1024px): 좌우 분할 + 드래그 리사이저 + 좌측 접기 버튼
|
||||
- 모바일(< 1024px): 세로 스택, 좌측 패널 40vh 제한, 접기/펼치기
|
||||
|
||||
**마이그레이션 후보**:
|
||||
- `V2CategoryManagerComponent` (완료)
|
||||
- `SplitPanelLayoutComponent` (v1, v2)
|
||||
- `BomTreeComponent`
|
||||
- `ScreenSplitPanel`
|
||||
- menu/page.tsx (메뉴 관리)
|
||||
- departments/page.tsx (부서 관리)
|
||||
|
||||
## 4. ResponsiveGridRenderer (디자이너 캔버스)
|
||||
|
||||
**위치**: `frontend/components/screen/ResponsiveGridRenderer.tsx`
|
||||
**패턴**: 데스크톱(비전폭 컴포넌트) = 캔버스 스케일링, 그 외 = Flex 그리드
|
||||
**적용 대상**: 화면 디자이너로 만든 동적 화면
|
||||
|
||||
이 컴포넌트는 화면 디자이너 시스템 전용. 일반 개발에서 직접 사용하지 않음.
|
||||
|
||||
## 사용 가이드
|
||||
|
||||
### 새 화면 만들 때
|
||||
|
||||
| 화면 유형 | 사용 컴포넌트 |
|
||||
|-----------|--------------|
|
||||
| 데이터 목록 (테이블) | `ResponsiveDataView` |
|
||||
| 좌우 분할 (트리+상세) | `ResponsiveSplitPanel` |
|
||||
| 디자이너 화면 | `ResponsiveGridRenderer` (자동) |
|
||||
| 단순 레이아웃 | Tailwind 반응형 (`flex-col lg:flex-row`) |
|
||||
|
||||
### 금지 사항
|
||||
|
||||
1. 컴포넌트 내부에 `isDraggingRef`, `handleMouseDown/Move/Up` 직접 구현 금지
|
||||
-> `ResponsiveSplitPanel` 사용
|
||||
2. `hidden lg:block` / `lg:hidden` 패턴으로 테이블/카드 이중 렌더링 금지
|
||||
-> `ResponsiveDataView` 사용
|
||||
3. `window.innerWidth` 직접 체크 금지
|
||||
-> `useResponsive()` 훅 사용
|
||||
4. 반응형 분기를 위한 새로운 타입/인터페이스 정의 금지
|
||||
-> 기존 프리미티브의 Props 사용
|
||||
|
||||
### 폐기 예정 컴포넌트
|
||||
|
||||
| 컴포넌트 | 대체 | 상태 |
|
||||
|----------|------|------|
|
||||
| `ResponsiveContainer` | Tailwind 또는 `useResponsive` | 미사용, 삭제 예정 |
|
||||
| `ResponsiveGrid` | Tailwind `grid-cols-*` | 미사용, 삭제 예정 |
|
||||
| `ResponsiveText` | Tailwind `text-sm lg:text-lg` | 미사용, 삭제 예정 |
|
||||
|
||||
## 파일 구조
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── lib/hooks/
|
||||
│ └── useResponsive.ts # 브레이크포인트 훅 (기반)
|
||||
├── components/common/
|
||||
│ ├── ResponsiveDataView.tsx # 테이블/카드 전환
|
||||
│ └── ResponsiveSplitPanel.tsx # 좌우 분할 반응형
|
||||
└── components/screen/
|
||||
└── ResponsiveGridRenderer.tsx # 디자이너 캔버스 렌더러
|
||||
```
|
||||
@@ -0,0 +1,340 @@
|
||||
# [계획서] 버튼 아이콘화 - 화면 디자이너 버튼 표시 모드 확장
|
||||
|
||||
> 관련 문서: [맥락노트](./BIC[맥락]-버튼-아이콘화.md) | [체크리스트](./BIC[체크]-버튼-아이콘화.md)
|
||||
|
||||
## 개요
|
||||
|
||||
화면 디자이너에서 버튼을 텍스트 모드(현행), 아이콘 모드, 아이콘+텍스트 모드 중 선택할 수 있도록 확장한다.
|
||||
아이콘 모드 선택 시 버튼 액션에 맞는 아이콘 후보군이 제시되고, 관리자가 원하는 아이콘을 선택한다.
|
||||
아이콘 크기 비율(버튼 높이 대비 4단계 프리셋), 아이콘 색상, 텍스트 위치(4방향), 아이콘-텍스트 간격 설정을 제공한다.
|
||||
관리자가 lucide 검색 또는 외부 SVG 붙여넣기로 커스텀 아이콘을 추가/삭제할 수 있다.
|
||||
|
||||
---
|
||||
|
||||
## 현재 동작
|
||||
|
||||
- 버튼은 항상 **텍스트 모드**로만 표시됨
|
||||
- `ButtonConfigPanel.tsx`에서 "버튼 텍스트" 입력 → 실제 화면에서 해당 텍스트가 버튼에 표시
|
||||
- 아이콘 표시 기능 없음
|
||||
|
||||
### 현재 코드 위치
|
||||
|
||||
| 구분 | 파일 | 설명 |
|
||||
|------|------|------|
|
||||
| 설정 패널 | `frontend/components/screen/config-panels/ButtonConfigPanel.tsx` | 버튼 텍스트, 액션 설정 (784~854행) |
|
||||
| 뷰어 렌더링 | `frontend/components/screen/InteractiveScreenViewerDynamic.tsx` | 실제 버튼 렌더링 (961~983행) |
|
||||
| 뷰어 렌더링 | `frontend/components/screen/InteractiveScreenViewer.tsx` | 실제 버튼 렌더링 (2041~2059행) |
|
||||
| 위젯 렌더링 | `frontend/components/screen/widgets/types/ButtonWidget.tsx` | 위젯 기반 버튼 렌더링 (67~86행) |
|
||||
| 최적화 컴포넌트 | `frontend/components/screen/OptimizedButtonComponent.tsx` | 최적화된 버튼 컴포넌트 (643~674행) |
|
||||
|
||||
---
|
||||
|
||||
## 변경 후 동작
|
||||
|
||||
### 1. 표시 모드 선택 (라디오 그룹)
|
||||
|
||||
ButtonConfigPanel에 "버튼 텍스트" 입력 위에 표시 모드 선택 UI 추가:
|
||||
|
||||
- **텍스트 모드** (기본값, 현행 유지): 버튼에 텍스트만 표시
|
||||
- **아이콘 모드**: 버튼에 아이콘만 표시
|
||||
- **아이콘+텍스트 모드**: 버튼에 아이콘과 텍스트를 함께 표시
|
||||
|
||||
```
|
||||
[ 텍스트 | 아이콘 | 아이콘+텍스트 ] ← 라디오 그룹 (토글 형태)
|
||||
```
|
||||
|
||||
### 2. 텍스트 모드 선택 시
|
||||
|
||||
- 현재와 동일하게 "버튼 텍스트" 입력 필드 표시
|
||||
- 변경 사항 없음
|
||||
|
||||
### 2-1. 아이콘+텍스트 모드 선택 시
|
||||
|
||||
- 아이콘 선택 UI (3장과 동일) + 버튼 텍스트 입력 필드 **둘 다 표시**
|
||||
- 렌더링: 텍스트 위치에 따라 아이콘과 텍스트 배치 방향이 달라짐
|
||||
- 텍스트 위치 4방향: 오른쪽(기본), 왼쪽, 위쪽, 아래쪽
|
||||
- 예시: `[ ✓ 저장 ]` (오른쪽), `[ 저장 ✓ ]` (왼쪽), 세로 배치 (위쪽/아래쪽)
|
||||
- 아이콘과 텍스트 사이 간격: 기본 6px, 관리자가 0~무제한 조절 가능 (슬라이더 0~32px + 직접 입력)
|
||||
|
||||
### 3. 아이콘 모드 선택 시
|
||||
|
||||
#### 3-1. 버튼 액션별 추천 아이콘 목록
|
||||
|
||||
버튼 액션(`action.type`)에 따라 해당 액션에 어울리는 아이콘 후보군을 그리드로 표시:
|
||||
|
||||
| 버튼 액션 | 값 | 추천 아이콘 (lucide-react) |
|
||||
|-----------|-----|---------------------------|
|
||||
| 저장 | `save` | Check, Save, CheckCircle, CircleCheck, FileCheck, ShieldCheck |
|
||||
| 삭제 | `delete` | Trash2, Trash, XCircle, X, Eraser, CircleX |
|
||||
| 편집 | `edit` | Pencil, PenLine, Edit, SquarePen, FilePen, PenTool |
|
||||
| 페이지 이동 | `navigate` | ArrowRight, ExternalLink, MoveRight, Navigation, CornerUpRight, Link |
|
||||
| 모달 열기 | `modal` | Maximize2, PanelTop, AppWindow, LayoutGrid, Layers, FolderOpen |
|
||||
| 데이터 전달 | `transferData` | SendHorizontal, ArrowRightLeft, Repeat, PackageCheck, Upload, Share2 |
|
||||
| 엑셀 다운로드 | `excel_download` | Download, FileDown, FileSpreadsheet, Sheet, Table, FileOutput |
|
||||
| 엑셀 업로드 | `excel_upload` | Upload, FileUp, FileSpreadsheet, Sheet, ImportIcon, FileInput |
|
||||
| 즉시 저장 | `quickInsert` | Zap, Plus, PlusCircle, SquarePlus, FilePlus, BadgePlus |
|
||||
| 제어 흐름 | `control` | Settings, SlidersHorizontal, ToggleLeft, Workflow, GitBranch, Cog |
|
||||
| 바코드 스캔 | `barcode_scan` | ScanLine, QrCode, Camera, Scan, ScanBarcode, Focus |
|
||||
| 운행알림 및 종료 | `operation_control` | Truck, Car, MapPin, Navigation2, Route, Bell |
|
||||
| 이벤트 발송 | `event` | Send, Bell, Radio, Megaphone, Podcast, BellRing |
|
||||
| 복사 | `copy` | Copy, ClipboardCopy, Files, CopyPlus, Duplicate, ClipboardList |
|
||||
|
||||
**적절한 아이콘이 없는 액션 (숨김 처리된 deprecated 액션들):**
|
||||
|
||||
| 버튼 액션 | 값 | 안내 문구 |
|
||||
|-----------|-----|----------|
|
||||
| 연관 데이터 버튼 모달 열기 | `openRelatedModal` | 안내 문구 표시 + 커스텀 아이콘 추가 가능 |
|
||||
| (deprecated) 데이터 전달 + 모달 | `openModalWithData` | 안내 문구 표시 + 커스텀 아이콘 추가 가능 |
|
||||
| 테이블 이력 보기 | `view_table_history` | 안내 문구 표시 + 커스텀 아이콘 추가 가능 |
|
||||
| 코드 병합 | `code_merge` | 안내 문구 표시 + 커스텀 아이콘 추가 가능 |
|
||||
| 공차등록 | `empty_vehicle` | 안내 문구 표시 + 커스텀 아이콘 추가 가능 |
|
||||
|
||||
> 안내 문구: "적절한 추천 아이콘이 없습니다. 텍스트 모드를 사용하거나 아래에서 아이콘을 직접 추가하세요."
|
||||
> 안내 문구 아래에 커스텀 아이콘 목록 + lucide 검색/SVG 붙여넣기 버튼이 표시됨
|
||||
|
||||
#### 3-2. 아이콘 선택 UI
|
||||
|
||||
- 액션별 추천 아이콘을 4~6열 그리드로 표시
|
||||
- 각 아이콘은 32x32 크기, 호버 시 하이라이트, 선택 시 ring 표시
|
||||
- 아이콘 아래에 이름 표시 (`text-[10px]`)
|
||||
- 관리자가 추가한 커스텀 아이콘이 있으면 "커스텀 아이콘" 구분선 아래 함께 표시
|
||||
|
||||
#### 3-3. 아이콘 크기 비율 설정
|
||||
|
||||
버튼 높이 대비 비율로 아이콘 크기를 설정 (정사각형 유지):
|
||||
|
||||
**프리셋 (ToggleGroup, 4단계):**
|
||||
|
||||
| 이름 | 버튼 높이 대비 | 설명 |
|
||||
|------|--------------|------|
|
||||
| 작게 | 40% | 컴팩트한 아이콘 |
|
||||
| 보통 | 55% | 기본값, 대부분의 버튼에 적합 |
|
||||
| 크게 | 70% | 존재감 있는 크기 |
|
||||
| 매우 크게 | 85% | 아이콘 강조, 버튼에 꽉 차는 느낌 |
|
||||
|
||||
- px 직접 입력은 제거 (비율 기반이므로 버튼 크기 변경 시 아이콘도 자동 비례)
|
||||
- 저장: `icon.size`에 프리셋 문자열(`"보통"`) 저장
|
||||
- 렌더링: `height: N%` + `aspect-ratio: 1/1`로 정사각형 유지
|
||||
|
||||
#### 3-4. 아이콘 색상 설정
|
||||
|
||||
아이콘 크기 아래에 아이콘 전용 색상 설정:
|
||||
|
||||
- **컬러 피커**: 기존 버튼 색상 설정과 동일한 UI 사용
|
||||
- **기본값**: 미설정 (= `textColor` 상속, 기존 동작과 동일)
|
||||
- **설정 시**: lucide 아이콘은 지정한 색상으로 덮어쓰기
|
||||
- **외부 SVG**: 고유 색상이 하드코딩된 SVG는 이 설정의 영향을 받지 않음 (원본 유지)
|
||||
- **초기화 버튼**: "텍스트 색상과 동일" 버튼으로 별도 색상 해제 가능
|
||||
|
||||
| 상황 | iconColor 설정 | 결과 |
|
||||
|------|---------------|------|
|
||||
| lucide 아이콘, iconColor 미설정 | 없음 | textColor 상속 (기존 동작) |
|
||||
| lucide 아이콘, iconColor 설정 | `#22c55e` | 초록색 아이콘 |
|
||||
| 외부 SVG (고유 색상), iconColor 설정 | `#22c55e` | SVG 원본 색상 유지 (무시) |
|
||||
| 외부 SVG (currentColor), iconColor 설정 | `#22c55e` | 초록색 아이콘 |
|
||||
|
||||
#### 3-5. 텍스트 위치 설정 (아이콘+텍스트 모드 전용)
|
||||
|
||||
아이콘 대비 텍스트의 배치 방향을 4방향으로 설정:
|
||||
|
||||
| 위치 | 값 | 레이아웃 | 설명 |
|
||||
|------|-----|---------|------|
|
||||
| 왼쪽 | `left` | `텍스트 ← 아이콘` | 텍스트가 아이콘 왼쪽 (가로) |
|
||||
| 오른쪽 | `right` | `아이콘 → 텍스트` | 기본값, 아이콘 뒤에 텍스트 (가로) |
|
||||
| 위쪽 | `top` | 텍스트 위, 아이콘 아래 | 세로 배치 |
|
||||
| 아래쪽 | `bottom` | 아이콘 위, 텍스트 아래 | 세로 배치 |
|
||||
|
||||
- 기본값: `"right"` (아이콘 오른쪽에 텍스트)
|
||||
- 저장: `componentConfig.iconTextPosition`
|
||||
- 아이콘 모드에서는 이 옵션이 숨겨짐 (텍스트가 없으므로 불필요)
|
||||
|
||||
#### 3-6. 아이콘-텍스트 간격 설정 (아이콘+텍스트 모드 전용)
|
||||
|
||||
아이콘+텍스트 모드에서 아이콘과 텍스트 사이 간격을 조절:
|
||||
|
||||
- **슬라이더**: 0~32px 범위 시각적 조절
|
||||
- **직접 입력**: px 수치 직접 입력 (최솟값 0, 최댓값 제한 없음)
|
||||
- **기본값**: 6px
|
||||
- 아이콘 모드에서는 이 옵션이 숨겨짐 (텍스트가 없으므로 불필요)
|
||||
|
||||
#### 3-7. 아이콘 모드 레이아웃 안내
|
||||
|
||||
아이콘만 표시하면 텍스트보다 좁은 공간으로 충분하므로 안내 문구 표시:
|
||||
|
||||
```
|
||||
ℹ 아이콘만 표시할 때는 버튼 영역의 가로 폭을 줄여 정사각형에 가깝게 만들면 더 깔끔합니다.
|
||||
```
|
||||
|
||||
- `bg-blue-50 dark:bg-blue-950/20` 배경의 안내 박스
|
||||
- 아이콘 모드(`"icon"`)에서만 표시, 아이콘+텍스트 모드에서는 숨김
|
||||
|
||||
#### 3-8. 디폴트 아이콘 자동 부여
|
||||
|
||||
아이콘/아이콘+텍스트 모드 전환 시 아이콘이 미선택 상태이면 **디폴트 아이콘을 자동으로 부여**한다.
|
||||
|
||||
| 상황 | 디폴트 아이콘 |
|
||||
|------|-------------|
|
||||
| 추천 아이콘이 있는 액션 (save, delete 등) | 해당 액션의 **첫 번째 추천 아이콘** (예: save → Check) |
|
||||
| 추천 아이콘이 없는 액션 (deprecated 등) | 범용 폴백 아이콘: `SquareMousePointer` |
|
||||
|
||||
**커스텀 아이콘 삭제 시:**
|
||||
- 현재 선택된 커스텀 아이콘을 삭제하면 **디폴트 아이콘으로 자동 복귀** (텍스트 모드로 빠지지 않음)
|
||||
- 아이콘 모드를 유지한 채 디폴트 아이콘이 캔버스에 즉시 반영됨
|
||||
|
||||
#### 3-9. 커스텀 아이콘 추가/삭제
|
||||
|
||||
**방법 1: lucide 아이콘 검색으로 추가**
|
||||
- "아이콘 추가" 버튼 클릭 시 lucide 아이콘 전체 검색 가능한 모달/팝오버 표시
|
||||
- 검색 입력 → 아이콘 이름으로 필터링 → 선택하면 커스텀 목록에 추가
|
||||
|
||||
**방법 2: 외부 SVG 붙여넣기로 추가**
|
||||
- "SVG 붙여넣기" 버튼 클릭 시 텍스트 입력 영역(textarea) 표시
|
||||
- 외부에서 복사한 SVG 코드를 붙여넣기 → 미리보기로 확인 → "추가" 버튼으로 등록
|
||||
- SVG 유효성 검사: `<svg` 태그가 포함된 올바른 SVG인지 확인, 아니면 에러 메시지
|
||||
- 추가 시 관리자가 아이콘 이름을 직접 입력 (목록에서 구분용)
|
||||
- 저장 형태: SVG 문자열을 `customSvgIcons` 배열에 `{ name, svg }` 객체로 저장
|
||||
|
||||
**공통 규칙:**
|
||||
- 추가된 커스텀 아이콘(lucide/SVG 모두)은 **모든 버튼 액션의 아이콘 후보에 공통으로 노출**
|
||||
- 커스텀 아이콘에 X 버튼으로 삭제 가능
|
||||
|
||||
---
|
||||
|
||||
## 데이터 구조
|
||||
|
||||
### componentConfig 확장
|
||||
|
||||
```typescript
|
||||
interface ButtonComponentConfig {
|
||||
text: string; // 기존: 버튼 텍스트
|
||||
displayMode: "text" | "icon" | "icon-text"; // 신규: 표시 모드 (기본값: "text")
|
||||
icon?: {
|
||||
name: string; // lucide 아이콘 이름 또는 커스텀 SVG 아이콘 이름
|
||||
type: "lucide" | "svg"; // 아이콘 출처 구분 (기본값: "lucide")
|
||||
size: "작게" | "보통" | "크게" | "매우 크게"; // 버튼 높이 대비 비율 프리셋 (기본값: "보통")
|
||||
color?: string; // 아이콘 색상 (미설정 시 textColor 상속)
|
||||
};
|
||||
iconGap?: number; // 아이콘-텍스트 간격 px (기본값: 6, 아이콘+텍스트 모드 전용)
|
||||
iconTextPosition?: "right" | "left" | "top" | "bottom"; // 텍스트 위치 (기본값: "right", 아이콘+텍스트 모드 전용)
|
||||
customIcons?: string[]; // 관리자가 추가한 lucide 커스텀 아이콘 이름 목록
|
||||
customSvgIcons?: Array<{ // 관리자가 붙여넣기한 외부 SVG 아이콘 목록
|
||||
name: string; // 관리자가 지정한 아이콘 이름
|
||||
svg: string; // SVG 문자열 원본
|
||||
}>;
|
||||
action: {
|
||||
type: string; // 기존: 버튼 액션 타입
|
||||
// ...기존 action 속성들 유지
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 저장 예시
|
||||
|
||||
```json
|
||||
{
|
||||
"text": "저장",
|
||||
"displayMode": "icon",
|
||||
"icon": {
|
||||
"name": "Check",
|
||||
"type": "lucide",
|
||||
"size": "보통",
|
||||
"color": "#22c55e"
|
||||
},
|
||||
"customIcons": ["Rocket", "Star"],
|
||||
"customSvgIcons": [
|
||||
{
|
||||
"name": "회사로고",
|
||||
"svg": "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'>...</svg>"
|
||||
}
|
||||
],
|
||||
"action": {
|
||||
"type": "save"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 시각적 동작 예시
|
||||
|
||||
### ButtonConfigPanel (디자이너 편집 모드)
|
||||
|
||||
```
|
||||
표시 모드: [ 텍스트 | (아이콘) | 아이콘+텍스트 ] ← 아이콘 선택됨
|
||||
|
||||
아이콘 선택:
|
||||
┌──────────────────────────────────┐
|
||||
│ 추천 아이콘 (저장) │
|
||||
│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │
|
||||
│ │ ✓ │ │ 💾 │ │ ✓○ │ │ ○✓ │ │
|
||||
│ │Check│ │Save│ │Chk○│ │○Chk│ │
|
||||
│ └────┘ └────┘ └────┘ └────┘ │
|
||||
│ ┌────┐ ┌────┐ │
|
||||
│ │📄✓│ │🛡✓│ │
|
||||
│ │FChk│ │ShCk│ │
|
||||
│ └────┘ └────┘ │
|
||||
│ │
|
||||
│ ── 커스텀 아이콘 ── │
|
||||
│ ┌────┐ ┌────┐ ┌────┐ │
|
||||
│ │ 🚀 │ │ ⭐ │ │[로고]│ │
|
||||
│ │Rckt │ │Star│ │회사 │ │
|
||||
│ │ ✕ │ │ ✕│ │ ✕ │ │
|
||||
│ └────┘ └────┘ └────┘ │
|
||||
│ [+ lucide 검색] [+ SVG 붙여넣기]│
|
||||
└──────────────────────────────────┘
|
||||
|
||||
아이콘 크기 비율: [ 작게 | (보통) | 크게 | 매우 크게 ]
|
||||
텍스트 위치: [ 왼쪽 | (오른쪽) | 위쪽 | 아래쪽 ] ← 아이콘+텍스트 모드에서만 표시
|
||||
아이콘-텍스트 간격: [━━━━━○━━] [6] px ← 아이콘+텍스트 모드에서만 표시
|
||||
아이콘 색상: [■ #22c55e] [텍스트 색상과 동일]
|
||||
|
||||
ℹ 아이콘만 표시할 때는 버튼 영역의 가로 폭을 줄여 정사각형에 가깝게 만들면 더 깔끔합니다.
|
||||
```
|
||||
|
||||
### 실제 화면 렌더링
|
||||
|
||||
| 모드 | 표시 |
|
||||
|------|------|
|
||||
| 텍스트 모드 | `[ 저장 ]` |
|
||||
| 아이콘 모드 (보통, 55%) | `[ ✓ ]` |
|
||||
| 아이콘 모드 (매우 크게, 85%) | `[ ✓ ]` |
|
||||
| 아이콘+텍스트 (텍스트 오른쪽) | `[ ✓ 저장 ]` (간격 6px) |
|
||||
| 아이콘+텍스트 (텍스트 왼쪽) | `[ 저장 ✓ ]` |
|
||||
| 아이콘+텍스트 (텍스트 아래쪽) | 아이콘 위, 텍스트 아래 (세로) |
|
||||
| 아이콘+텍스트 (색상 분리) | `[ 초록✓ 검정저장 ]` |
|
||||
|
||||
---
|
||||
|
||||
## 변경 대상
|
||||
|
||||
### 수정 파일
|
||||
|
||||
| 파일 | 변경 내용 |
|
||||
|------|----------|
|
||||
| `ButtonConfigPanel.tsx` | 표시 모드 3종 라디오, 아이콘 그리드, 크기, 색상, 간격 설정, 레이아웃 안내, 커스텀 아이콘 UI |
|
||||
| `InteractiveScreenViewerDynamic.tsx` | `displayMode` 3종 분기 → 아이콘/아이콘+텍스트/텍스트 렌더링 |
|
||||
| `InteractiveScreenViewer.tsx` | 동일 분기 추가 |
|
||||
| `ButtonWidget.tsx` | 동일 분기 추가 |
|
||||
| `OptimizedButtonComponent.tsx` | 동일 분기 추가 |
|
||||
| `ScreenDesigner.tsx` | 입력 필드 포커스 시 키보드 단축키 기본 동작 허용 (Ctrl+A/C/V/Z) |
|
||||
| `RealtimePreviewDynamic.tsx` | 버튼 컴포넌트 position wrapper에서 border 속성 분리 (이중 테두리 방지) |
|
||||
|
||||
### 신규 파일
|
||||
|
||||
| 파일 | 내용 |
|
||||
|------|------|
|
||||
| `frontend/lib/button-icon-map.ts` | 버튼 액션별 추천 아이콘 매핑 + 아이콘 동적 렌더링 유틸 |
|
||||
|
||||
---
|
||||
|
||||
## 설계 원칙
|
||||
|
||||
- 기본값은 `"text"` 모드 → 기존 모든 버튼은 변경 없이 동작
|
||||
- `displayMode`가 없거나 `"text"`이면 현행 텍스트 렌더링 유지
|
||||
- 아이콘/아이콘+텍스트 모드 전환 시 아이콘 미선택이면 **디폴트 아이콘 자동 부여** (빈 상태 방지)
|
||||
- 커스텀 아이콘 삭제 시 텍스트 모드로 빠지지 않고 **디폴트 아이콘으로 자동 복귀**
|
||||
- 아이콘 모드에서도 `text` 값은 유지 (접근성 aria-label로 활용)
|
||||
- 기본 아이콘은 lucide-react 사용 (프로젝트 일관성)
|
||||
- 외부 SVG 붙여넣기도 지원 → 관리자가 회사 로고 등 자체 아이콘을 등록 가능
|
||||
- lucide 커스텀 아이콘은 `componentConfig.customIcons`에, SVG 아이콘은 `componentConfig.customSvgIcons`에 저장
|
||||
- lucide 아이콘 렌더링: 아이콘 이름 → 컴포넌트 매핑, SVG 아이콘 렌더링: `dangerouslySetInnerHTML` + DOMPurify 정화
|
||||
@@ -0,0 +1,263 @@
|
||||
# [맥락노트] 버튼 아이콘화 - 화면 디자이너 버튼 표시 모드 확장
|
||||
|
||||
> 관련 문서: [계획서](./BIC[계획]-버튼-아이콘화.md) | [체크리스트](./BIC[체크]-버튼-아이콘화.md)
|
||||
|
||||
---
|
||||
|
||||
## 왜 이 작업을 하는가
|
||||
|
||||
- 현재 모든 버튼은 텍스트로만 표시 → 버튼 영역이 넓어야 하고, 모바일/태블릿에서 공간 효율이 낮음
|
||||
- "저장", "삭제", "추가" 같은 자주 쓰는 버튼은 아이콘만으로 충분히 인식 가능
|
||||
- 관리자가 화면 레이아웃을 더 컴팩트하게 구성할 수 있도록 선택권 제공
|
||||
- 단, "출하 계획" 같이 아이콘화가 어려운 특수 버튼이 존재하므로 텍스트 모드도 반드시 유지
|
||||
|
||||
---
|
||||
|
||||
## 핵심 결정 사항과 근거
|
||||
|
||||
### 1. 표시 모드는 3종 라디오 그룹(토글 형태)으로 구현
|
||||
|
||||
- **결정**: `ToggleGroup` 형태의 세 개 옵션 (텍스트 / 아이콘 / 아이콘+텍스트)
|
||||
- **근거**: 세 모드는 상호 배타적. 아이콘+텍스트 병합 모드가 있어야 `[ + 추가 ]`, `[ 💾 저장 ]` 같은 실무 패턴을 지원. 아이콘만으로 의미 전달이 부족한 경우 텍스트를 병기하면 사용자 인식 속도가 빨라짐
|
||||
- **대안 검토**: Switch(토글) → 기각 ("무엇이 켜지는지" 직관적이지 않음, 3종 불가)
|
||||
|
||||
### 2. 기본값은 텍스트 모드
|
||||
|
||||
- **결정**: `displayMode` 기본값 = `"text"`
|
||||
- **근거**: 기존 모든 버튼은 텍스트로 동작 중. 아이콘 모드는 명시적으로 선택해야만 적용되어야 하위 호환성이 보장됨
|
||||
- **중요**: `displayMode`가 `undefined`이거나 `"text"`이면 현행 동작 그대로 유지
|
||||
|
||||
### 3. 아이콘은 버튼 액션(action.type)에 연동
|
||||
|
||||
- **결정**: 버튼 액션을 변경하면 해당 액션에 맞는 추천 아이콘 목록이 자동으로 갱신됨
|
||||
- **근거**: 관리자가 "저장" 아이콘을 고른 뒤 액션을 "삭제"로 바꾸면 혼란 발생. 액션별로 적절한 아이콘 후보를 보여주는 것이 자연스러움
|
||||
- **주의**: 액션 변경 시 이전에 선택한 아이콘이 새 액션의 추천 목록에 없으면 선택 초기화
|
||||
|
||||
### 4. 액션별 아이콘은 6개씩 제공, 적절한 아이콘이 없으면 안내 문구
|
||||
|
||||
- **결정**: 활성 액션 14개 각각에 6개의 lucide-react 아이콘 후보 제공
|
||||
- **근거**: 너무 적으면 선택지 부족, 너무 많으면 선택 피로. 6개가 2행 그리드로 깔끔하게 표시됨
|
||||
- **deprecated/숨김 액션**: UI에서 숨김 처리된 액션은 추천 아이콘 없이 안내 문구만 표시
|
||||
|
||||
### 5. 커스텀 아이콘 추가는 2가지 방법 제공
|
||||
|
||||
- **결정**: (1) lucide 아이콘 검색 + (2) 외부 SVG 붙여넣기 두 가지 경로 제공
|
||||
- **근거**: lucide 내장 아이콘만으로는 부족한 경우 존재 (회사 로고, 업종별 특수 아이콘 등). 외부에서 가져온 SVG를 직접 붙여넣기로 등록할 수 있어야 실무 유연성 확보
|
||||
- **lucide 추가**: "lucide 검색" 버튼 → 팝오버에서 검색 → 선택 → `customIcons` 배열에 이름 추가
|
||||
- **SVG 추가**: "SVG 붙여넣기" 버튼 → textarea에 SVG 코드 붙여넣기 → 미리보기 확인 → 이름 입력 → `customSvgIcons` 배열에 `{ name, svg }` 저장
|
||||
- **SVG 유효성**: `<svg` 태그 포함 여부로 기본 검증, XSS 방지를 위해 DOMPurify로 정화 후 저장
|
||||
- **범위**: 모든 커스텀 아이콘은 **해당 버튼 컴포넌트에 저장** (lucide: `customIcons`, SVG: `customSvgIcons`)
|
||||
- **노출**: 커스텀 아이콘(lucide/SVG 모두)은 어떤 버튼 액션에서도 추천 아이콘 아래에 함께 노출됨
|
||||
- **삭제**: 커스텀 아이콘 위에 X 버튼으로 개별 삭제 가능
|
||||
|
||||
### 5-1. 외부 SVG 붙여넣기의 보안 고려
|
||||
|
||||
- **결정**: SVG 문자열을 DOMPurify로 정화(sanitize)한 뒤 저장
|
||||
- **근거**: SVG에 `<script>`, `onload` 같은 악성 코드가 포함될 수 있으므로 XSS 방지 필수
|
||||
- **렌더링**: 정화된 SVG를 `dangerouslySetInnerHTML`로 렌더링 (정화 후이므로 안전)
|
||||
- **대안 검토**: SVG를 이미지 파일로 업로드 → 기각 (관리자 입장에서 복사-붙여넣기가 훨씬 간편)
|
||||
|
||||
### 6. 아이콘 색상은 별도 설정, 기본값은 textColor 상속
|
||||
|
||||
- **결정**: `icon.color` 옵션 추가. 미설정 시 `textColor`를 상속, 설정하면 아이콘만 해당 색상 적용
|
||||
- **근거**: 아이콘+텍스트 모드에서 `[ 초록✓ 검정저장 ]` 같이 아이콘과 텍스트 색을 분리하고 싶은 경우 존재. 삭제 버튼에 빨간 아이콘 + 흰 텍스트 같은 세밀한 디자인도 가능
|
||||
- **기본값**: 미설정 (= `textColor` 상속) → 설정하지 않으면 기존 동작과 100% 동일
|
||||
- **외부 SVG**: `fill`이 하드코딩된 SVG는 이 설정 무시 (SVG 원본 색상 유지가 의도). `currentColor`를 사용하는 SVG만 영향받음
|
||||
- **구현**: 아이콘을 `<span style={{ color: icon.color }}>`으로 감싸서 아이콘만 색상 분리
|
||||
- **초기화**: "텍스트 색상과 동일" 버튼으로 별도 색상 해제 → `icon.color` 삭제
|
||||
|
||||
### 7. 아이콘 크기는 버튼 높이 대비 비율(%) 프리셋 4단계
|
||||
|
||||
- **결정**: 작게(40%) / 보통(55%) / 크게(70%) / 매우 크게(85%) — 버튼 높이 대비 비율
|
||||
- **근거**: 절대 px 값은 버튼 크기가 바뀌면 비율이 깨짐. 비율 기반이면 버튼 크기를 조정해도 아이콘이 자동으로 비례하여 일관된 시각적 균형 유지
|
||||
- **기본값**: `"보통"` (55%) — 대부분의 버튼 크기에 적합
|
||||
- **px 직접 입력 제거**: 관리자에게 과도한 선택지를 주면 오히려 일관성이 깨짐. 4단계 프리셋만으로 충분
|
||||
- **구현**: CSS `height: N%` + `aspect-ratio: 1/1`로 정사각형 유지, lucide 아이콘은 래핑 span으로 크기 제어
|
||||
- **레거시 호환**: 기존 `"sm"`, `"md"` 등 레거시 값은 55%(보통)로 자동 폴백
|
||||
|
||||
### 8. 아이콘 동적 렌더링은 매핑 객체 방식
|
||||
|
||||
- **결정**: lucide-react 아이콘 이름(string) → 실제 컴포넌트 매핑 객체를 별도 파일로 관리
|
||||
- **근거**: `import * from 'lucide-react'`는 번들 크기에 영향. 사용하는 아이콘만 명시적으로 매핑
|
||||
- **파일**: `frontend/lib/button-icon-map.ts`
|
||||
- **구현**: `Record<string, React.ComponentType>` 형태의 매핑 + `renderIcon(name, size)` 유틸 함수
|
||||
|
||||
### 9. 아이콘 모드에서도 text 값은 유지
|
||||
|
||||
- **결정**: `displayMode === "icon"`이어도 `text` 필드는 삭제하지 않음
|
||||
- **근거**: 접근성(`aria-label`), 검색/필터링 등에 텍스트가 필요할 수 있음
|
||||
- **렌더링**: 아이콘 모드에서는 `text`를 `aria-label` 용도로만 보존
|
||||
- **아이콘+텍스트 모드**: `text`가 아이콘 오른쪽에 함께 렌더링됨
|
||||
|
||||
### 10. 아이콘-텍스트 간격 설정 추가
|
||||
|
||||
- **결정**: 아이콘+텍스트 모드에서 아이콘과 텍스트 사이 간격을 관리자가 조절 가능 (`iconGap`)
|
||||
- **근거**: 고정 `gap-1.5`(6px)로는 다양한 버튼 크기/디자인에 대응 불가. 간격이 좁으면 답답하고, 넓으면 분리되어 보이는 경우가 있어 관리자에게 조절 권한 제공
|
||||
- **기본값**: 6px (기존 `gap-1.5`와 동일)
|
||||
- **UI**: 슬라이더(0~32px) + 숫자 직접 입력(최댓값 제한 없음)
|
||||
- **저장**: `componentConfig.iconGap` (숫자)
|
||||
|
||||
### 11. 키보드 단축키 입력 필드 충돌 해결
|
||||
|
||||
- **결정**: `ScreenDesigner`의 글로벌 키보드 핸들러에서 입력 필드 포커스 시 앱 단축키를 무시하도록 수정
|
||||
- **근거**: SVG 붙여넣기 textarea에서 Ctrl+V/A/C/Z가 작동하지 않는 치명적 UX 문제 발견. 글로벌 `keydown` 핸들러가 `{ capture: true }`로 모든 키보드 이벤트를 가로채고 있었음
|
||||
- **수정**: `browserShortcuts` 일괄 차단과 앱 전용 단축키 처리 앞에 `e.target`/`document.activeElement` 기반 입력 필드 감지 가드 추가
|
||||
- **영향**: input, textarea, select, contentEditable 요소에서 텍스트 편집 단축키가 정상 동작
|
||||
|
||||
### 12. noIconAction에서 커스텀 아이콘 추가 허용
|
||||
|
||||
- **결정**: 추천 아이콘이 없는 deprecated 액션에서도 커스텀 아이콘(lucide 검색, SVG 붙여넣기) 추가 가능
|
||||
- **근거**: "적절한 아이콘이 없습니다" 문구만 표시하고 아이콘 추가를 완전 차단하면 관리자가 필요한 아이콘을 직접 등록할 방법이 없음. 추천은 없지만 직접 추가는 허용해야 유연성 확보
|
||||
- **안내 문구**: "적절한 추천 아이콘이 없습니다. 텍스트 모드를 사용하거나 아래에서 아이콘을 직접 추가하세요."
|
||||
|
||||
### 13. 아이콘 모드 레이아웃 안내 문구
|
||||
|
||||
- **결정**: 아이콘 모드(`"icon"`) 선택 시 "버튼 영역의 가로 폭을 줄여 정사각형에 가깝게 만들면 더 깔끔합니다" 안내 표시
|
||||
- **근거**: 아이콘 자체는 항상 정사각형(24x24 viewBox)이지만, 디자이너에서 버튼 컨테이너는 가로로 넓은 직사각형이 기본. 아이콘만 넣으면 좌우 여백이 과다해 보이므로 버튼 영역을 줄이라는 안내가 필요. 자동 크기 조정은 기존 레이아웃을 깨뜨릴 위험이 있어 도입하지 않되, 관리자에게 팁을 제공하면 스스로 최적화할 수 있음
|
||||
- **표시 조건**: `displayMode === "icon"`일 때만 (아이콘+텍스트 모드는 가로 공간이 필요하므로 해당 안내 불필요)
|
||||
- **대안 검토**: 자동 정사각형 조정 → 기각 (관리자 수동 레이아웃 파괴 위험)
|
||||
|
||||
### 14. 디폴트 아이콘 자동 부여
|
||||
|
||||
- **결정**: 아이콘/아이콘+텍스트 모드 전환 시 아이콘이 미선택이면 디폴트 아이콘을 자동으로 부여. 커스텀 아이콘 삭제 시에도 텍스트 모드로 빠지지 않고 디폴트 아이콘으로 복귀
|
||||
- **근거**: 아이콘 모드로 전환했는데 아무것도 안 보이면 "기능이 작동하지 않는다"는 착각을 유발. 또한 커스텀 아이콘을 삭제했을 때 갑자기 텍스트로 빠지면 관리자가 의도치 않은 모드 변경을 경험하게 됨
|
||||
- **디폴트 선택 기준**: 해당 액션의 첫 번째 추천 아이콘 (예: save → Check). 추천 아이콘이 없는 액션은 범용 폴백 `SquareMousePointer` 사용
|
||||
- **구현**: `getDefaultIconForAction(actionType)` 유틸 함수로 중앙화 (`button-icon-map.tsx`)
|
||||
- **폴백 아이콘**: `SquareMousePointer` — 마우스 포인터 + 사각형 형태로 "버튼 클릭 동작"을 범용적으로 표현
|
||||
|
||||
### 15. 아이콘+텍스트 모드에서 텍스트 위치 4방향 지원
|
||||
|
||||
- **결정**: 아이콘 대비 텍스트 위치를 왼쪽/오른쪽/위쪽/아래쪽 4방향으로 설정 가능
|
||||
- **근거**: 기존에는 아이콘 오른쪽에 텍스트 고정이었으나, 세로 배치(위/아래)가 필요한 경우도 존재 (좁고 높은 버튼, 툴바 스타일). 4방향을 제공하면 관리자가 버튼 모양에 맞게 레이아웃 선택 가능
|
||||
- **기본값**: `"right"` (아이콘 오른쪽에 텍스트) — 가장 자연스러운 좌→우 읽기 방향
|
||||
- **구현**: `flexDirection` (row/column) + 요소 순서 (textFirst) 조합으로 4방향 구현
|
||||
- **저장**: `componentConfig.iconTextPosition`
|
||||
- **표시 조건**: 아이콘+텍스트 모드에서만 표시 (아이콘 모드, 텍스트 모드에서는 숨김)
|
||||
|
||||
### 16. 버튼 컴포넌트 테두리 이중 적용 문제 해결
|
||||
|
||||
- **결정**: `RealtimePreviewDynamic`의 position wrapper에서 버튼 컴포넌트의 border 속성을 분리(strip)
|
||||
- **근거**: StyleEditor에서 설정한 border가 (1) position wrapper와 (2) 내부 버튼 요소 두 곳에 모두 적용되어 이중 테두리 발생. border는 내부 버튼(`buttonElementStyle`)에서만 렌더링해야 함
|
||||
- **수정 파일**: `RealtimePreviewDynamic.tsx` — `isButtonComponent` 조건에 `v2-button-primary` 추가하여 border strip 대상에 포함
|
||||
- **수정 파일**: `ButtonPrimaryComponent.tsx` — 외부 wrapper(`componentStyle`)에서 border 속성 destructure로 제거, `border: "none"` shorthand 대신 개별 longhand 속성으로 변경 (borderStyle 미설정 시 기본 `"solid"` 적용)
|
||||
|
||||
### 17. 커스텀 아이콘 검색은 lucide 전체 목록 기반
|
||||
|
||||
- **결정**: lucide-react에서 export되는 전체 아이콘 이름 목록을 검색 가능
|
||||
- **근거**: 관리자가 "어떤 아이콘이 있는지" 모르므로 검색 기능이 필수
|
||||
- **구현**: lucide 아이콘 이름 배열을 상수로 관리하고, CommandInput으로 필터링
|
||||
- **주의**: 전체 아이콘 컴포넌트를 import하지 않고, 이름 배열만 관리 → 선택 시에만 해당 아이콘을 매핑에 추가
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일 위치
|
||||
|
||||
| 구분 | 파일 경로 | 설명 |
|
||||
|------|----------|------|
|
||||
| 설정 패널 (수정) | `frontend/components/screen/config-panels/ButtonConfigPanel.tsx` | 버튼 텍스트/액션 설정 (784~854행에 모드 선택 추가) |
|
||||
| 뷰어 렌더링 (수정) | `frontend/components/screen/InteractiveScreenViewerDynamic.tsx` | 버튼 렌더링 분기 (961~983행) |
|
||||
| 뷰어 렌더링 (수정) | `frontend/components/screen/InteractiveScreenViewer.tsx` | 버튼 렌더링 분기 (2041~2059행) |
|
||||
| 위젯 (수정) | `frontend/components/screen/widgets/types/ButtonWidget.tsx` | 위젯 기반 버튼 렌더링 (67~86행) |
|
||||
| 최적화 버튼 (수정) | `frontend/components/screen/OptimizedButtonComponent.tsx` | 최적화된 버튼 렌더링 (643~674행) |
|
||||
| 아이콘 매핑 (신규) | `frontend/lib/button-icon-map.ts` | 액션별 추천 아이콘 + 동적 렌더링 유틸 |
|
||||
| 타입 정의 (참고) | `frontend/types/screen.ts` | ComponentData, componentConfig 타입 |
|
||||
|
||||
---
|
||||
|
||||
## 기술 참고
|
||||
|
||||
### lucide-react 아이콘 동적 렌더링
|
||||
|
||||
```typescript
|
||||
// button-icon-map.ts
|
||||
import { Check, Save, Trash2, Pencil, ... } from "lucide-react";
|
||||
|
||||
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
Check, Save, Trash2, Pencil, ...
|
||||
};
|
||||
|
||||
export function renderButtonIcon(name: string, size: string | number) {
|
||||
const IconComponent = iconMap[name];
|
||||
if (!IconComponent) return null;
|
||||
return <IconComponent style={getIconSizeStyle(size)} />;
|
||||
}
|
||||
```
|
||||
|
||||
### 아이콘 크기 비율 매핑 (버튼 높이 대비 %)
|
||||
|
||||
```typescript
|
||||
const iconSizePresets: Record<string, number> = {
|
||||
"작게": 40,
|
||||
"보통": 55,
|
||||
"크게": 70,
|
||||
"매우 크게": 85,
|
||||
};
|
||||
|
||||
// 프리셋 문자열 → 비율(%) 반환. 레거시 값은 55(보통)로 폴백
|
||||
export function getIconPercent(size: string | number): number {
|
||||
if (typeof size === "number") return size;
|
||||
return iconSizePresets[size] ?? 55;
|
||||
}
|
||||
|
||||
// 버튼 높이 대비 비율 + 정사각형 유지
|
||||
export function getIconSizeStyle(size: string | number): React.CSSProperties {
|
||||
const pct = getIconPercent(size);
|
||||
return { height: `${pct}%`, width: "auto", aspectRatio: "1 / 1" };
|
||||
}
|
||||
```
|
||||
|
||||
### 외부 SVG 아이콘 렌더링
|
||||
|
||||
```typescript
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
export function renderSvgIcon(svgString: string, size: string | number) {
|
||||
const clean = DOMPurify.sanitize(svgString, { USE_PROFILES: { svg: true } });
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center justify-center"
|
||||
style={getIconSizeStyle(size)}
|
||||
dangerouslySetInnerHTML={{ __html: clean }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 버튼 액션별 추천 아이콘 구조
|
||||
|
||||
```typescript
|
||||
const actionIconMap: Record<string, string[]> = {
|
||||
save: ["Check", "Save", "CheckCircle", "CircleCheck", "FileCheck", "ShieldCheck"],
|
||||
delete: ["Trash2", "Trash", "XCircle", "X", "Eraser", "CircleX"],
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
### 현재 버튼 액션 목록 (활성)
|
||||
|
||||
| 값 | 표시명 | 아이콘화 가능 |
|
||||
|-----|--------|-------------|
|
||||
| `save` | 저장 | O |
|
||||
| `delete` | 삭제 | O |
|
||||
| `edit` | 편집 | O |
|
||||
| `navigate` | 페이지 이동 | O |
|
||||
| `modal` | 모달 열기 | O |
|
||||
| `transferData` | 데이터 전달 | O |
|
||||
| `excel_download` | 엑셀 다운로드 | O |
|
||||
| `excel_upload` | 엑셀 업로드 | O |
|
||||
| `quickInsert` | 즉시 저장 | O |
|
||||
| `control` | 제어 흐름 | O |
|
||||
| `barcode_scan` | 바코드 스캔 | O |
|
||||
| `operation_control` | 운행알림 및 종료 | O |
|
||||
| `event` | 이벤트 발송 | O |
|
||||
| `copy` | 복사 (품목코드 초기화) | O |
|
||||
|
||||
### 현재 버튼 액션 목록 (숨김/deprecated)
|
||||
|
||||
| 값 | 표시명 | 아이콘화 가능 |
|
||||
|-----|--------|-------------|
|
||||
| `openRelatedModal` | 연관 데이터 버튼 모달 열기 | X (적절한 아이콘 없음) |
|
||||
| `openModalWithData` | (deprecated) 데이터 전달 + 모달 | X |
|
||||
| `view_table_history` | 테이블 이력 보기 | X |
|
||||
| `code_merge` | 코드 병합 | X |
|
||||
| `empty_vehicle` | 공차등록 | X |
|
||||
@@ -0,0 +1,158 @@
|
||||
# [체크리스트] 버튼 아이콘화 - 화면 디자이너 버튼 표시 모드 확장
|
||||
|
||||
> 관련 문서: [계획서](./BIC[계획]-버튼-아이콘화.md) | [맥락노트](./BIC[맥락]-버튼-아이콘화.md)
|
||||
|
||||
---
|
||||
|
||||
## 공정 상태
|
||||
|
||||
- 전체 진행률: **100%** (전 단계 구현 및 검증 완료)
|
||||
- 현재 단계: 완료
|
||||
|
||||
---
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
### 1단계: 아이콘 매핑 파일 생성
|
||||
|
||||
- [x] `frontend/lib/button-icon-map.tsx` 생성
|
||||
- [x] 버튼 액션별 추천 아이콘 매핑 (`actionIconMap`) 정의 (14개 액션 x 6개 아이콘)
|
||||
- [x] 아이콘 크기 비율 매핑 (`iconSizePresets`) 정의 (작게/보통/크게/매우 크게, 버튼 높이 대비 %) + `getIconSizeStyle()` 유틸
|
||||
- [x] lucide 아이콘 동적 렌더링 포함 `getButtonDisplayContent()` 구현
|
||||
- [x] SVG 아이콘 렌더링 (DOMPurify 정화 via `isomorphic-dompurify`)
|
||||
- [x] 아이콘 이름 → 컴포넌트 매핑 객체 (`iconMap`) + `addToIconMap()` 동적 추가
|
||||
- [x] deprecated 액션용 안내 문구 상수 (`NO_ICON_MESSAGE`) 정의
|
||||
- [x] `isomorphic-dompurify` 기존 설치 확인 (추가 설치 불필요)
|
||||
- [x] `ButtonIconRenderer` 공용 컴포넌트 추가 (모든 렌더러에서 재사용)
|
||||
- [x] `getDefaultIconForAction()` 디폴트 아이콘 유틸 함수 추가 (액션별 첫 번째 추천 / 범용 폴백)
|
||||
- [x] `FALLBACK_ICON_NAME` 상수 + `SquareMousePointer` import/매핑 추가
|
||||
|
||||
### 2단계: ButtonConfigPanel 수정
|
||||
|
||||
- [x] 표시 모드 버튼 그룹 UI 추가 (텍스트 / 아이콘 / 아이콘+텍스트)
|
||||
- [x] `displayMode` 상태 관리 및 `onUpdateProperty` 연동
|
||||
- [x] 아이콘 모드 선택 시 조건부 UI 분기 (텍스트 입력 숨김 → 아이콘 선택 표시)
|
||||
- [x] 아이콘+텍스트 모드 선택 시 아이콘 선택 + 텍스트 입력 **동시** 표시
|
||||
- [x] 버튼 액션별 추천 아이콘 그리드 렌더링 (4열 그리드)
|
||||
- [x] 선택된 아이콘 하이라이트 (`ring-2 ring-primary/30 border-primary`)
|
||||
- [x] 아이콘 크기 비율 프리셋 버튼 그룹 (작게/보통/크게/매우 크게, 한글 라벨)
|
||||
- [x] px 직접 입력 필드 제거 (비율 프리셋만 제공)
|
||||
- [x] `icon.name`, `icon.size` 를 `onUpdateProperty`로 저장
|
||||
- [x] 아이콘 색상 컬러 피커 구현 (`ColorPickerWithTransparent` 재사용)
|
||||
- [x] "텍스트 색상과 동일" 초기화 버튼 구현
|
||||
- [x] 텍스트 위치 4방향 설정 추가 (`iconTextPosition`, 왼쪽/오른쪽/위쪽/아래쪽)
|
||||
- [x] 아이콘-텍스트 간격 설정 추가 (`iconGap`, 슬라이더 + 직접 입력, 아이콘+텍스트 모드 전용)
|
||||
- [x] 아이콘 모드 레이아웃 안내 문구 표시 (Info 아이콘 + bg-blue-50 박스)
|
||||
- [x] 액션 변경 시 선택 아이콘 자동 초기화 로직 (추천 목록에 없으면 해제)
|
||||
- [x] deprecated 액션에서 안내 문구 + 커스텀 아이콘 추가 버튼 표시
|
||||
- [x] 아이콘/아이콘+텍스트 모드 전환 시 아이콘 미선택이면 디폴트 아이콘 자동 부여
|
||||
- [x] 커스텀 아이콘 삭제 시 디폴트 아이콘으로 자동 복귀 (텍스트 모드 전환 방지)
|
||||
|
||||
### 3단계: 커스텀 아이콘 추가/삭제 (lucide 검색)
|
||||
|
||||
- [x] "lucide 검색" 버튼 UI
|
||||
- [x] lucide 아이콘 검색 팝오버 (Popover + Command + CommandInput)
|
||||
- [x] `import { icons } from "lucide-react"` 기반 전체 아이콘 검색/필터링
|
||||
- [x] 선택 시 `componentConfig.customIcons` 배열 추가 + `addToIconMap` 동적 등록
|
||||
- [x] lucide 커스텀 아이콘 그리드 렌더링 (추천 아이콘 아래, 구분선 포함)
|
||||
- [x] lucide 커스텀 아이콘 X 버튼으로 개별 삭제
|
||||
|
||||
### 3-1단계: 커스텀 아이콘 추가/삭제 (SVG 붙여넣기)
|
||||
|
||||
- [x] "SVG 붙여넣기" 버튼 UI (Popover)
|
||||
- [x] SVG 입력 textarea + DOMPurify 실시간 미리보기
|
||||
- [x] SVG 유효성 검사 (`<svg` 태그 포함 여부)
|
||||
- [x] 아이콘 이름 입력 필드 (관리자가 구분용 이름 지정)
|
||||
- [x] DOMPurify로 SVG 정화(sanitize) 후 저장
|
||||
- [x] `componentConfig.customSvgIcons` 배열에 `{ name, svg }` 추가
|
||||
- [x] SVG 커스텀 아이콘 그리드 렌더링 (lucide 커스텀 아이콘과 함께 표시)
|
||||
- [x] SVG 커스텀 아이콘 X 버튼으로 개별 삭제
|
||||
- [x] 커스텀 아이콘(lucide + SVG 모두)이 모든 버튼 액션에서 공통 노출
|
||||
|
||||
### 4단계: 버튼 렌더링 수정 (뷰어/위젯)
|
||||
|
||||
- [x] `InteractiveScreenViewerDynamic.tsx` - `ButtonIconRenderer` 적용
|
||||
- [x] `InteractiveScreenViewer.tsx` - `ButtonIconRenderer` 적용
|
||||
- [x] `ButtonWidget.tsx` - `ButtonIconRenderer` 적용 (디자인/실행 모드 모두)
|
||||
- [x] `OptimizedButtonComponent.tsx` - `ButtonIconRenderer` 적용 (실행 중 "처리 중..." 유지)
|
||||
- [x] `ButtonPrimaryComponent.tsx` - `ButtonIconRenderer` 적용 (v2-button-primary 캔버스 렌더링)
|
||||
- [x] lucide 아이콘 렌더링 (`icon.type === "lucide"`, `getLucideIcon` 조회)
|
||||
- [x] SVG 아이콘 렌더링 (`icon.type === "svg"`, DOMPurify 정화 후 innerHTML)
|
||||
- [x] 아이콘+텍스트 모드: `inline-flex items-center` + 동적 `gap` (iconGap px)
|
||||
- [x] `icon.color` 설정 시 아이콘만 별도 색상 적용 (inline style)
|
||||
- [x] `icon.color` 미설정 시 textColor 상속 (currentColor 기본)
|
||||
- [x] 아이콘 크기 비율 프리셋 `getIconSizeStyle()` 처리 (버튼 높이 대비 %)
|
||||
- [x] 텍스트 위치 4방향 렌더링 (`flexDirection` + 요소 순서 조합)
|
||||
|
||||
### 4-2단계: 버튼 테두리 이중 적용 수정
|
||||
|
||||
- [x] `RealtimePreviewDynamic.tsx` — position wrapper에서 버튼 컴포넌트 border strip 추가
|
||||
- [x] `ButtonPrimaryComponent.tsx` — 외부 wrapper에서 border 속성 destructure 제거
|
||||
- [x] `ButtonPrimaryComponent.tsx` — `border: "none"` shorthand 제거, 개별 longhand 속성으로 변경
|
||||
- [x] `isButtonComponent` 조건에 `"v2-button-primary"` 추가
|
||||
|
||||
### 4-1단계: 키보드 단축키 충돌 수정
|
||||
|
||||
- [x] `ScreenDesigner.tsx` 글로벌 keydown 핸들러에 입력 필드 감지 가드 추가
|
||||
- [x] `browserShortcuts` 배열에서 `Ctrl+V` 제거
|
||||
- [x] 입력 필드(input/textarea/select/contentEditable) 포커스 시 Ctrl+A/C/V/Z 기본 동작 허용
|
||||
- [x] SVG 붙여넣기 textarea에 `onPaste`/`onKeyDown` stopPropagation 핸들러 추가
|
||||
|
||||
### 5단계: 검증
|
||||
|
||||
- [x] 텍스트 모드: 기존 동작 변화 없음 확인 (하위 호환성)
|
||||
- [x] `displayMode` 없는 기존 버튼: 텍스트 모드로 정상 동작
|
||||
- [x] 아이콘 모드 선택 → 추천 아이콘 6개 그리드 표시
|
||||
- [x] 아이콘 선택 → 캔버스(오른쪽 프리뷰) 및 실제 화면에서 아이콘 렌더링 확인
|
||||
- [x] 아이콘 크기 비율 프리셋 변경 → 버튼 높이 대비 비율 반영 확인
|
||||
- [x] 텍스트 위치 4방향(왼/오른/위/아래) 변경 → 레이아웃 방향 반영 확인
|
||||
- [x] 버튼 테두리 설정 → 내부 버튼에만 적용, 외부 wrapper에 이중 적용 없음 확인
|
||||
- [x] 버튼 액션 변경 → 추천 아이콘 목록 갱신 확인
|
||||
- [x] lucide 커스텀 아이콘 추가 → 모든 액션에서 노출 확인
|
||||
- [x] SVG 커스텀 아이콘 붙여넣기 → 미리보기 → 추가 → 모든 액션에서 노출 확인
|
||||
- [x] SVG에 악성 코드 삽입 시도 → DOMPurify 정화 후 안전 렌더링 확인
|
||||
- [x] 커스텀 아이콘 삭제 → 목록에서 제거 확인
|
||||
- [x] deprecated 액션에서 안내 문구 + 커스텀 아이콘 추가 가능 확인
|
||||
- [x] 아이콘+텍스트 모드: 아이콘 + 텍스트 나란히 렌더링 확인
|
||||
- [x] 아이콘+텍스트 간격 조절: 슬라이더/직접 입력으로 간격 변경 → 실시간 반영 확인
|
||||
- [x] 아이콘 색상 미설정 → textColor와 동일한 색상 확인
|
||||
- [x] 아이콘 색상 설정 → 아이콘만 해당 색상, 텍스트는 textColor 유지 확인
|
||||
- [x] 외부 SVG (고유 색상) → icon.color 설정해도 SVG 원본 색상 유지 확인
|
||||
- [x] "텍스트 색상과 동일" 버튼 → icon.color 해제되고 textColor 상속 복원 확인
|
||||
- [x] 레이아웃 안내 문구: 아이콘 모드에서만 표시, 다른 모드에서 숨김 확인
|
||||
- [x] 입력 필드에서 Ctrl+A/C/V/Z 단축키 정상 동작 확인
|
||||
- [x] 아이콘 모드 전환 시 디폴트 아이콘 자동 선택 → 캔버스에 즉시 반영 확인
|
||||
- [x] 커스텀 아이콘 삭제 시 디폴트 아이콘으로 복귀 → 아이콘 모드 유지 확인
|
||||
- [x] deprecated 액션에서 디폴트 폴백 아이콘(SquareMousePointer) 표시 확인
|
||||
|
||||
### 6단계: 정리
|
||||
|
||||
- [x] TypeScript 컴파일 에러 없음 확인 (우리 파일 6개 모두 0 에러)
|
||||
- [x] 불필요한 import 없음 확인
|
||||
- [x] 이 체크리스트 완료 표시 업데이트
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-03-04 | 계획서, 맥락노트, 체크리스트 작성 완료 |
|
||||
| 2026-03-04 | 외부 SVG 붙여넣기 기능 추가 (3개 문서 모두 반영) |
|
||||
| 2026-03-04 | 아이콘+텍스트 모드, 레이아웃 안내 추가 |
|
||||
| 2026-03-04 | 설정 패널 내 미리보기 제거 (오른쪽 캔버스 프리뷰로 대체) |
|
||||
| 2026-03-04 | 아이콘 색상 설정 추가 (icon.color, 기본값 textColor 상속) |
|
||||
| 2026-03-04 | 3개 문서 교차 검토 — 개요 누락 보완, 시각 예시 문구 통일, 렌더 함수 px 대응, 용어 명확화 |
|
||||
| 2026-03-04 | 구현 완료 — 1~4단계 코드 작성, 6단계 린트/타입 검증 통과 |
|
||||
| 2026-03-04 | 아이콘-텍스트 간격 설정 추가 (iconGap, 슬라이더+직접 입력) |
|
||||
| 2026-03-04 | noIconAction에서 커스텀 아이콘 추가 허용 + 안내 문구 변경 |
|
||||
| 2026-03-04 | ScreenDesigner 키보드 단축키 수정 — 입력 필드에서 텍스트 편집 단축키 허용 |
|
||||
| 2026-03-04 | SVG 붙여넣기 textarea에 onPaste/onKeyDown 핸들러 추가 |
|
||||
| 2026-03-04 | SVG 커스텀 아이콘 이름 중복 방지 (자동 넘버링) |
|
||||
| 2026-03-04 | 디폴트 아이콘 자동 부여 — 모드 전환 시 자동 선택, 커스텀 삭제 시 디폴트 복귀 |
|
||||
| 2026-03-04 | `getDefaultIconForAction()` 유틸 + `SquareMousePointer` 폴백 아이콘 추가 |
|
||||
| 2026-03-04 | 3개 문서 변경사항 동기화 및 코드 정리 |
|
||||
| 2026-03-04 | 아이콘 크기: 절대 px → 버튼 높이 대비 비율(%) 4단계 프리셋으로 변경, px 직접 입력 제거 |
|
||||
| 2026-03-04 | 텍스트 위치 4방향 설정 추가 (왼쪽/오른쪽/위쪽/아래쪽) |
|
||||
| 2026-03-04 | 버튼 테두리 이중 적용 수정 — position wrapper에서 border strip, border shorthand 제거 |
|
||||
| 2026-03-04 | 프리셋 라벨 한글화 (작게/보통/크게/매우 크게), 라벨 "아이콘 크기 비율"로 변경 |
|
||||
@@ -0,0 +1,146 @@
|
||||
# [계획서] V2Select 다중 선택 드롭다운 - 선택 항목 표시 개선
|
||||
|
||||
> 관련 문서: [맥락노트](./MST[맥락노트]-v2select-multiselect-tooltip.md) | [체크리스트](./MST[체크리스트]-v2select-multiselect-tooltip.md)
|
||||
|
||||
## 개요
|
||||
|
||||
모든 화면에서 다중 선택 가능한 드롭다운(`V2Select` - `DropdownSelect`)의 선택 항목 표시 방식을 개선합니다.
|
||||
|
||||
---
|
||||
|
||||
## 현재 동작
|
||||
|
||||
- 다중 선택 시 `"3개 선택됨"` 같은 텍스트만 표시
|
||||
- 어떤 항목이 선택되었는지 드롭다운을 열어야만 확인 가능
|
||||
|
||||
### 현재 코드 (V2Select.tsx - DropdownSelect, 174~178행)
|
||||
|
||||
```tsx
|
||||
{selectedLabels.length > 0
|
||||
? multiple
|
||||
? `${selectedLabels.length}개 선택됨`
|
||||
: selectedLabels[0]
|
||||
: placeholder}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 변경 후 동작
|
||||
|
||||
### 1. 선택된 항목 라벨을 쉼표로 연결하여 한 줄로 표시
|
||||
|
||||
- 예: `"구매품, 판매품, 재고품"`
|
||||
- `truncate` (text-overflow: ellipsis)로 필드 너비를 넘으면 말줄임(`...`) 처리
|
||||
- 무조건 한 줄 표시, 넘치면 `...`으로 숨김
|
||||
|
||||
### 2. 텍스트가 말줄임(`...`) 처리될 때만 호버 툴팁 표시
|
||||
|
||||
- 필드 너비를 넘어서 `...`으로 잘릴 때만 툴팁 활성화
|
||||
- 필드 내에 전부 보이면 툴팁 불필요
|
||||
- 툴팁 내용은 세로 나열로 각 항목을 한눈에 확인 가능
|
||||
- 툴팁은 딜레이 없이 즉시 표시
|
||||
|
||||
---
|
||||
|
||||
## 시각적 동작 예시
|
||||
|
||||
| 상태 | 필드 내 표시 | 호버 시 툴팁 |
|
||||
|------|-------------|-------------|
|
||||
| 미선택 | `선택` (placeholder) | 없음 |
|
||||
| 1개 선택 | `구매품` | 없음 |
|
||||
| 3개 선택 (필드 내 수용) | `구매품, 판매품, 재고품` | 없음 (잘리지 않으므로) |
|
||||
| 5개 선택 (필드 넘침) | `구매품, 판매품, 재고품, 외...` | 구매품 / 판매품 / 재고품 / 외주품 / 반제품 (세로 나열) |
|
||||
|
||||
---
|
||||
|
||||
## 변경 대상
|
||||
|
||||
- **파일**: `frontend/components/v2/V2Select.tsx`
|
||||
- **컴포넌트**: `DropdownSelect` 내부 표시 텍스트 부분 (170~178행)
|
||||
- **적용 범위**: `DropdownSelect`를 사용하는 모든 화면 (품목정보, 기타 모든 모달 포함)
|
||||
- **변경 규모**: 약 30줄 내외 소규모 변경
|
||||
|
||||
---
|
||||
|
||||
## 코드 설계
|
||||
|
||||
### 추가 import
|
||||
|
||||
```tsx
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
```
|
||||
|
||||
### 말줄임 감지 로직
|
||||
|
||||
```tsx
|
||||
// 텍스트가 잘리는지(truncated) 감지
|
||||
const textRef = useRef<HTMLSpanElement>(null);
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = textRef.current;
|
||||
if (el) {
|
||||
setIsTruncated(el.scrollWidth > el.clientWidth);
|
||||
}
|
||||
}, [selectedLabels]);
|
||||
```
|
||||
|
||||
### 수정 코드 (DropdownSelect 내부, 170~178행 대체)
|
||||
|
||||
```tsx
|
||||
const displayText = selectedLabels.length > 0
|
||||
? (multiple ? selectedLabels.join(", ") : selectedLabels[0])
|
||||
: placeholder;
|
||||
|
||||
const isPlaceholder = selectedLabels.length === 0;
|
||||
|
||||
// 렌더링 부분
|
||||
{isTruncated && multiple ? (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
ref={textRef}
|
||||
className={cn("truncate flex-1 text-left", isPlaceholder && "text-muted-foreground")}
|
||||
{...(isPlaceholder ? { "data-placeholder": placeholder } : {})}
|
||||
>
|
||||
{displayText}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[300px]">
|
||||
<div className="space-y-0.5 text-xs">
|
||||
{selectedLabels.map((label, i) => (
|
||||
<div key={i}>{label}</div>
|
||||
))}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<span
|
||||
ref={textRef}
|
||||
className={cn("truncate flex-1 text-left", isPlaceholder && "text-muted-foreground")}
|
||||
{...(isPlaceholder ? { "data-placeholder": placeholder } : {})}
|
||||
>
|
||||
{displayText}
|
||||
</span>
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 설계 원칙
|
||||
|
||||
- 기존 단일 선택 동작은 변경하지 않음
|
||||
- `DropdownSelect` 공통 컴포넌트 수정이므로 모든 화면에 자동 적용
|
||||
- 무조건 한 줄 표시, 넘치면 `...`으로 말줄임
|
||||
- 툴팁은 텍스트가 실제로 잘릴 때(`scrollWidth > clientWidth`)만 표시
|
||||
- 툴팁 내용은 세로 나열로 각 항목 확인 용이
|
||||
- 툴팁 딜레이 없음 (`delayDuration={0}`)
|
||||
- shadcn 표준 Tooltip 컴포넌트 사용으로 프로젝트 일관성 유지
|
||||
@@ -0,0 +1,95 @@
|
||||
# [맥락노트] V2Select 다중 선택 드롭다운 - 선택 항목 표시 개선
|
||||
|
||||
> 관련 문서: [계획서](./MST[계획서]-v2select-multiselect-tooltip.md) | [체크리스트](./MST[체크리스트]-v2select-multiselect-tooltip.md)
|
||||
|
||||
---
|
||||
|
||||
## 왜 이 작업을 하는가
|
||||
|
||||
- 사용자가 수정 모달에서 다중 선택 드롭다운을 사용할 때 `"3개 선택됨"` 만 보임
|
||||
- 드롭다운을 다시 열어봐야만 무엇이 선택됐는지 확인 가능 → UX 불편
|
||||
- 선택 항목을 직접 보여주고, 넘치면 툴팁으로 확인할 수 있게 개선
|
||||
|
||||
---
|
||||
|
||||
## 핵심 결정 사항과 근거
|
||||
|
||||
### 1. "n개 선택됨" → 라벨 쉼표 나열
|
||||
|
||||
- **결정**: `"구매품, 판매품, 재고품"` 형태로 표시
|
||||
- **근거**: 사용자가 드롭다운을 열지 않아도 선택 내용을 바로 확인 가능
|
||||
|
||||
### 2. 무조건 한 줄, 넘치면 말줄임(`...`)
|
||||
|
||||
- **결정**: 여러 줄 줄바꿈 없이 한 줄 고정, `truncate`로 오버플로우 처리
|
||||
- **근거**: 드롭다운 필드 높이가 고정되어 있어 여러 줄 표시 시 레이아웃이 깨짐
|
||||
|
||||
### 3. 텍스트가 잘릴 때만 툴팁 표시
|
||||
|
||||
- **결정**: `scrollWidth > clientWidth` 비교로 실제 잘림 여부 감지 후 툴팁 활성화
|
||||
- **근거**: 전부 보이는데 툴팁이 뜨면 오히려 방해. 필요할 때만 보여야 함
|
||||
- **대안 검토**: "2개 이상이면 항상 툴팁" → 기각 (불필요한 툴팁 발생)
|
||||
|
||||
### 4. 툴팁 내용은 세로 나열
|
||||
|
||||
- **결정**: 툴팁 안에서 항목을 줄바꿈으로 세로 나열
|
||||
- **근거**: 가로 나열 시 툴팁도 길어져서 읽기 어려움. 세로가 한눈에 파악하기 좋음
|
||||
|
||||
### 5. 툴팁 딜레이 0ms
|
||||
|
||||
- **결정**: `delayDuration={0}` 즉시 표시
|
||||
- **근거**: 사용자가 "무엇을 선택했는지" 확인하려는 의도적 행동이므로 즉시 응답해야 함
|
||||
|
||||
### 6. Radix Tooltip 대신 커스텀 호버 툴팁 사용
|
||||
|
||||
- **결정**: Radix Tooltip을 사용하지 않고 `onMouseEnter`/`onMouseLeave`로 직접 제어
|
||||
- **근거**: Radix Tooltip + Popover 조합은 이벤트 충돌 발생. 내부 배치든 외부 래핑이든 Popover가 호버를 가로챔
|
||||
- **시도 1**: Tooltip을 Button 안에 배치 → Popover가 이벤트 가로챔 (실패)
|
||||
- **시도 2**: Radix 공식 패턴 (TooltipTrigger > PopoverTrigger > Button 체이닝) → 여전히 동작 안 함 (실패)
|
||||
- **최종**: wrapper div에 마우스 이벤트 + 절대 위치 div로 툴팁 렌더링 (성공)
|
||||
- **추가**: Popover 열릴 때 `setHoverTooltip(false)`로 툴팁 자동 숨김
|
||||
|
||||
### 8. 선택 항목 표시 순서는 드롭다운 옵션 순서 기준
|
||||
|
||||
- **결정**: 사용자가 클릭한 순서가 아닌 드롭다운 옵션 목록 순서대로 표시
|
||||
- **근거**: 선택 순서대로 보여주면 매번 순서가 달라져서 혼란. 옵션 순서 기준이 일관적이고 예측 가능
|
||||
- **구현**: `selectedValues.map(...)` → `safeOptions.filter(...).map(...)` 으로 변경
|
||||
|
||||
### 9. DropdownSelect 공통 컴포넌트 수정
|
||||
|
||||
- **결정**: 특정 화면이 아닌 `DropdownSelect` 자체를 수정
|
||||
- **근거**: 품목정보뿐 아니라 모든 화면에서 동일한 문제가 있으므로 공통 해결
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일 위치
|
||||
|
||||
| 구분 | 파일 경로 | 설명 |
|
||||
|------|----------|------|
|
||||
| 수정 대상 | `frontend/components/v2/V2Select.tsx` | DropdownSelect 컴포넌트 (170~178행) |
|
||||
| 타입 정의 | `frontend/types/v2-components.ts` | V2SelectProps, SelectOption 타입 |
|
||||
| UI 컴포넌트 | `frontend/components/ui/tooltip.tsx` | shadcn Tooltip 컴포넌트 |
|
||||
| 렌더러 | `frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx` | V2Select를 레지스트리에 연결 |
|
||||
| 수정 모달 | `frontend/components/screen/EditModal.tsx` | 공통 편집 모달 |
|
||||
|
||||
---
|
||||
|
||||
## 기술 참고
|
||||
|
||||
### truncate 감지 방식
|
||||
|
||||
```
|
||||
scrollWidth: 텍스트의 실제 전체 너비 (보이지 않는 부분 포함)
|
||||
clientWidth: 요소의 보이는 너비
|
||||
|
||||
scrollWidth > clientWidth → 텍스트가 잘리고 있음 (... 표시 중)
|
||||
```
|
||||
|
||||
### selectedLabels 계산 흐름
|
||||
|
||||
```
|
||||
value (string[]) → selectedValues → safeOptions에서 label 매칭 → selectedLabels (string[])
|
||||
```
|
||||
|
||||
- `selectedLabels`는 이미 `DropdownSelect` 내부에서 `useMemo`로 계산됨 (126~130행)
|
||||
- 추가 데이터 fetching 불필요, 기존 값을 `.join(", ")`로 결합하면 됨
|
||||
@@ -0,0 +1,54 @@
|
||||
# [체크리스트] V2Select 다중 선택 드롭다운 - 선택 항목 표시 개선
|
||||
|
||||
> 관련 문서: [계획서](./MST[계획서]-v2select-multiselect-tooltip.md) | [맥락노트](./MST[맥락노트]-v2select-multiselect-tooltip.md)
|
||||
|
||||
---
|
||||
|
||||
## 공정 상태
|
||||
|
||||
- 전체 진행률: **100%** (완료)
|
||||
- 현재 단계: 전체 완료
|
||||
|
||||
---
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
### 1단계: 코드 수정
|
||||
|
||||
- [x] `V2Select.tsx`에 Tooltip 관련 import 추가
|
||||
- [x] `DropdownSelect` 내부에 `textRef`, `isTruncated` 상태 추가
|
||||
- [x] `useEffect`로 `scrollWidth > clientWidth` 감지 로직 추가
|
||||
- [x] 표시 텍스트를 `selectedLabels.join(", ")`로 변경
|
||||
- [x] `isTruncated && multiple` 조건으로 Tooltip 래핑
|
||||
- [x] 툴팁 내용을 세로 나열 (`space-y-0.5`)로 구성
|
||||
- [x] `delayDuration={0}` 설정
|
||||
- [x] Radix Tooltip → 커스텀 호버 툴팁으로 변경 (onMouseEnter/onMouseLeave + 절대 위치 div)
|
||||
- [x] 선택 항목 표시 순서를 드롭다운 옵션 순서 기준으로 변경
|
||||
|
||||
### 2단계: 검증
|
||||
|
||||
- [x] 단일 선택 모드: 기존 동작 변화 없음 확인
|
||||
- [x] 다중 선택 1개: 라벨 정상 표시, 툴팁 없음
|
||||
- [x] 다중 선택 3개 (필드 내 수용): 쉼표 나열 표시, 툴팁 없음
|
||||
- [x] 다중 선택 5개+ (필드 넘침): 말줄임 표시, 호버 시 툴팁 세로 나열
|
||||
- [x] 품목정보 수정 모달에서 동작 확인
|
||||
- [x] 다른 화면의 다중 선택 드롭다운에서도 동작 확인
|
||||
|
||||
### 3단계: 정리
|
||||
|
||||
- [x] 린트 에러 없음 확인
|
||||
- [x] 이 체크리스트 완료 표시 업데이트
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-03-04 | 설계 문서 작성 완료 |
|
||||
| 2026-03-04 | 맥락노트, 체크리스트 작성 완료 |
|
||||
| 2026-03-04 | 파일명 MST 접두사 적용 |
|
||||
| 2026-03-04 | 1단계 코드 수정 완료 (V2Select.tsx) |
|
||||
| 2026-03-04 | Radix Tooltip이 Popover와 충돌 → 커스텀 호버 툴팁으로 변경 |
|
||||
| 2026-03-04 | 사용자 검증 완료, 전체 작업 완료 |
|
||||
| 2026-03-04 | 선택 항목 표시 순서를 옵션 순서 기준으로 변경 |
|
||||
@@ -0,0 +1,241 @@
|
||||
# 탭 시스템 아키텍처 및 구현 계획
|
||||
|
||||
## 1. 개요
|
||||
|
||||
사이드바 메뉴 클릭 시 `router.push()` 페이지 이동 방식에서 **탭 기반 멀티 화면 시스템**으로 전환한다.
|
||||
|
||||
```
|
||||
┌──────────────────────────┐
|
||||
│ Tab Data Layer (중앙) │
|
||||
API 응답 ────────→│ │
|
||||
│ 탭별 상태 저장소 │
|
||||
│ ├─ formData │
|
||||
│ ├─ selectedRows │
|
||||
│ ├─ scrollPosition │
|
||||
│ ├─ modalState │
|
||||
│ ├─ sortState │
|
||||
│ └─ cacheState │
|
||||
│ │
|
||||
│ 공통 규칙 엔진 │
|
||||
│ ├─ 날짜 포맷 규칙 │
|
||||
│ ├─ 숫자/통화 포맷 규칙 │
|
||||
│ ├─ 로케일 처리 규칙 │
|
||||
│ ├─ 유효성 검증 규칙 │
|
||||
│ └─ 데이터 타입 변환 규칙 │
|
||||
│ │
|
||||
│ F5 복원 / 캐시 관리 │
|
||||
│ (sessionStorage 중앙관리) │
|
||||
└────────────┬─────────────┘
|
||||
│
|
||||
가공 완료된 데이터
|
||||
│
|
||||
┌────────────────┼────────────────┐
|
||||
│ │ │
|
||||
화면 A (경량) 화면 B (경량) 화면 C (경량)
|
||||
렌더링만 담당 렌더링만 담당 렌더링만 담당
|
||||
```
|
||||
|
||||
## 2. 레이어 구조
|
||||
|
||||
| 레이어 | 책임 |
|
||||
|---|---|
|
||||
| **Tab Data Layer** | 탭별 상태 보관, 캐시, 복원, 데이터 가공 |
|
||||
| **공통 규칙 엔진** | 날짜/숫자/로케일 포맷, 유효성 검증 |
|
||||
| **화면 컴포넌트** | 가공된 데이터를 받아서 렌더링만 담당 |
|
||||
|
||||
## 3. 파일 구성
|
||||
|
||||
| 파일 | 역할 |
|
||||
|---|---|
|
||||
| `stores/tabStore.ts` | Zustand 기반 탭 상태 관리 |
|
||||
| `components/layout/TabBar.tsx` | 탭 바 UI (드래그, 우클릭, 오버플로우) |
|
||||
| `components/layout/TabContent.tsx` | 탭별 콘텐츠 렌더링 (컨테이너) |
|
||||
| `components/layout/EmptyDashboard.tsx` | 탭 없을 때 안내 화면 |
|
||||
| `components/layout/AppLayout.tsx` | 전체 레이아웃 (사이드바 + 탭 + 콘텐츠) |
|
||||
| `lib/tabStateCache.ts` | 탭별 상태 캐싱 엔진 |
|
||||
| `lib/formatting/rules.ts` | 포맷 규칙 정의 |
|
||||
| `lib/formatting/index.ts` | formatDate, formatNumber, formatCurrency |
|
||||
| `app/(main)/screens/[screenId]/page.tsx` | 화면별 렌더링 |
|
||||
|
||||
## 4. 기술 스택
|
||||
|
||||
- Next.js 15, React 19, Zustand
|
||||
- Tailwind CSS, shadcn/ui
|
||||
|
||||
---
|
||||
|
||||
## 5. Phase 1: 탭 껍데기
|
||||
|
||||
### 5-1. Zustand 탭 Store (`stores/tabStore.ts`)
|
||||
- [ ] zustand 직접 의존성 추가
|
||||
- [ ] Tab 인터페이스: id, type, title, screenId, menuObjid, adminUrl
|
||||
- [ ] 탭 목록, 활성 탭 ID
|
||||
- [ ] openTab, closeTab, switchTab, refreshTab
|
||||
- [ ] closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs
|
||||
- [ ] updateTabOrder (드래그 순서 변경)
|
||||
- [ ] 중복 방지: 같은 탭이면 해당 탭으로 이동
|
||||
- [ ] 닫기 후 왼쪽 탭으로 이동, 왼쪽 없으면 오른쪽
|
||||
- [ ] sessionStorage 영속화 (persist middleware)
|
||||
- [ ] 탭 ID 생성 규칙: V2 화면 `tab-{screenId}-{menuObjid}`, URL 탭 `tab-url-{menuObjid}`
|
||||
|
||||
### 5-2. TabBar 컴포넌트 (`components/layout/TabBar.tsx`)
|
||||
- [ ] 고정 너비 탭, 화면 너비에 맞게 동적 개수
|
||||
- [ ] 활성 탭: 새로고침 버튼 + X 버튼
|
||||
- [ ] 비활성 탭: X 버튼만
|
||||
- [ ] 오버플로우 시 +N 드롭다운 (ResizeObserver 감시)
|
||||
- [ ] 드래그 순서 변경 (mousedown/move/up, DOM transform 직접 조작)
|
||||
- [ ] 사이드바 메뉴 드래그 드롭 수신 (`application/tab-menu` 커스텀 데이터, 마우스 위치에 삽입)
|
||||
- [ ] 우클릭 컨텍스트 메뉴 (새로고침/왼쪽닫기/오른쪽닫기/다른탭닫기/모든탭닫기)
|
||||
- [ ] 휠 클릭: 탭 즉시 닫기
|
||||
|
||||
### 5-3. TabContent 컴포넌트 (`components/layout/TabContent.tsx`)
|
||||
- [ ] display:none 방식 (비활성 탭 DOM 유지, 상태 보존)
|
||||
- [ ] 지연 마운트 (한 번 활성화된 탭만 마운트)
|
||||
- [ ] 안정적 순서 유지 (탭 순서 변경 시 리마운트 방지)
|
||||
- [ ] 탭별 모달 격리 (DialogPortalContainerContext)
|
||||
- [ ] tab.type === "screen" -> ScreenViewPageWrapper 임베딩
|
||||
- [ ] tab.type === "admin" -> 동적 import로 관리자 페이지 렌더링
|
||||
|
||||
### 5-4. EmptyDashboard 컴포넌트 (`components/layout/EmptyDashboard.tsx`)
|
||||
- [ ] 탭이 없을 때 "사이드바에서 메뉴를 선택하여 탭을 추가하세요" 표시
|
||||
|
||||
### 5-5. AppLayout 수정 (`components/layout/AppLayout.tsx`)
|
||||
- [ ] handleMenuClick: router.push -> tabStore.openTab 호출
|
||||
- [ ] 레이아웃: main 영역을 TabBar + TabContent로 교체
|
||||
- [ ] children prop 제거 (탭이 콘텐츠 관리)
|
||||
- [ ] 사이드바 메뉴 드래그 가능하게 (draggable)
|
||||
|
||||
### 5-6. 라우팅 연동
|
||||
- [ ] `app/(main)/layout.tsx` 수정 - children 대신 탭 시스템
|
||||
- [ ] URL 직접 접근 시 탭으로 열기 (북마크/공유 링크 대응)
|
||||
|
||||
---
|
||||
|
||||
## 6. Phase 2: F5 최대 복원
|
||||
|
||||
### 6-1. 탭 상태 캐싱 엔진 (`lib/tabStateCache.ts`)
|
||||
- [ ] 탭별 상태 저장/복원 (sessionStorage)
|
||||
- [ ] 저장 대상: formData, selectedRows, sortState, scrollPosition, modalState, checkboxState
|
||||
- [ ] debounce 적용 (상태 변경마다 저장하지 않음)
|
||||
|
||||
### 6-2. 복원 로직
|
||||
- [ ] 활성 탭: fresh API 호출 (캐시 데이터 무시)
|
||||
- [ ] 비활성 탭: 캐시에서 복원
|
||||
- [ ] 탭 닫기 시 해당 탭의 캐시 키 일괄 삭제
|
||||
|
||||
### 6-3. 캐시 키 관리 (clearTabStateCache)
|
||||
|
||||
탭 닫기/새로고침 시 관련 sessionStorage 키 일괄 제거:
|
||||
- `tab-cache-{screenId}-{menuObjid}`
|
||||
- `page-scroll-{screenId}-{menuObjid}`
|
||||
- `tsp-{screenId}-*`, `table-state-{screenId}-*`
|
||||
- `split-sel-{screenId}-*`, `catval-sel-{screenId}-*`
|
||||
- `bom-tree-{screenId}-*`
|
||||
- URL 탭: `tsp-{urlHash}-*`, `admin-scroll-{url}`
|
||||
|
||||
---
|
||||
|
||||
## 7. Phase 3: 포맷팅 중앙화
|
||||
|
||||
### 7-1. 포맷팅 규칙 엔진
|
||||
|
||||
```typescript
|
||||
// lib/formatting/rules.ts
|
||||
|
||||
interface FormatRules {
|
||||
date: {
|
||||
display: string; // "YYYY-MM-DD"
|
||||
datetime: string; // "YYYY-MM-DD HH:mm:ss"
|
||||
input: string; // "YYYY-MM-DD"
|
||||
};
|
||||
number: {
|
||||
locale: string; // 사용자 로케일 기반
|
||||
decimals: number; // 기본 소수점 자릿수
|
||||
};
|
||||
currency: {
|
||||
code: string; // 회사 설정 기반
|
||||
locale: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function formatValue(value: any, dataType: string, rules: FormatRules): string;
|
||||
export function formatDate(value: any, format?: string): string;
|
||||
export function formatNumber(value: any, locale?: string): string;
|
||||
export function formatCurrency(value: any, currencyCode?: string): string;
|
||||
```
|
||||
|
||||
### 7-2. 하드코딩 교체 대상
|
||||
- [ ] V2DateRenderer.tsx
|
||||
- [ ] EditModal.tsx
|
||||
- [ ] InteractiveDataTable.tsx
|
||||
- [ ] FlowWidget.tsx
|
||||
- [ ] AggregationWidgetComponent.tsx
|
||||
- [ ] aggregation.ts (피벗)
|
||||
- [ ] 기타 하드코딩 파일들
|
||||
|
||||
---
|
||||
|
||||
## 8. Phase 4: ScreenViewPage 경량화
|
||||
- [ ] 탭 데이터 레이어에서 받은 데이터로 렌더링만 담당
|
||||
- [ ] API 호출, 캐시, 복원 로직 제거 (탭 레이어가 담당)
|
||||
- [ ] 관리자 페이지도 동일한 데이터 레이어 패턴 적용
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 구현 완료: 다중 스크롤 영역 F5 복원
|
||||
|
||||
### 개요
|
||||
|
||||
split panel 등 한 탭 안에 **스크롤 영역이 여러 개**인 화면에서, F5 새로고침 후에도 각 영역의 스크롤 위치가 복원된다.
|
||||
|
||||
탭 전환 시에는 `display: none` 방식으로 DOM이 유지되므로 브라우저가 스크롤을 자연 보존한다. 이 기능은 **F5 새로고침** 전용이다.
|
||||
|
||||
### 동작 방식
|
||||
|
||||
탭 내 모든 스크롤 가능한 요소를 DOM 경로(`"0/1/0/2"` 형태)와 함께 저장한다.
|
||||
|
||||
```
|
||||
scrollPositions: [
|
||||
{ path: "0/1/0/2", top: 150, left: 0 }, // 예: 좌측 패널
|
||||
{ path: "0/1/1/3/1", top: 420, left: 0 }, // 예: 우측 패널
|
||||
]
|
||||
```
|
||||
|
||||
- **실시간 추적**: 스크롤 이벤트 발생 시 해당 요소의 경로와 위치를 Map에 기록
|
||||
- **저장 시점**: 탭 전환 시 + `beforeunload`(F5/닫기) 시 sessionStorage에 저장
|
||||
- **복원 시점**: 탭 활성화 시 경로를 기반으로 각 요소를 찾아 개별 복원
|
||||
|
||||
### 관련 파일 및 주요 함수
|
||||
|
||||
| 파일 | 역할 |
|
||||
|---|---|
|
||||
| `lib/tabStateCache.ts` | 스크롤 캡처/복원 핵심 로직 |
|
||||
| `components/layout/TabContent.tsx` | 스크롤 이벤트 감지, 저장/복원 호출 |
|
||||
|
||||
**`tabStateCache.ts` 핵심 함수**:
|
||||
|
||||
| 함수 | 설명 |
|
||||
|---|---|
|
||||
| `getElementPath(element, container)` | 요소의 DOM 경로를 자식 인덱스 문자열로 생성 |
|
||||
| `captureAllScrollPositions(container)` | TreeWalker로 컨테이너 하위 모든 스크롤 요소의 위치를 일괄 캡처 |
|
||||
| `restoreAllScrollPositions(container, positions)` | 경로 기반으로 각 요소를 찾아 스크롤 위치 복원 (콘텐츠 렌더링 대기 폴링 포함) |
|
||||
|
||||
**`TabContent.tsx` 핵심 Ref**:
|
||||
|
||||
| Ref | 설명 |
|
||||
|---|---|
|
||||
| `lastScrollMapRef` | `Map<tabId, Map<path, {top, left}>>` - 탭 내 요소별 최신 스크롤 위치 |
|
||||
| `pathCacheRef` | `WeakMap<HTMLElement, string>` - 동일 요소의 경로 재계산 방지용 캐시 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 참고 파일
|
||||
|
||||
| 파일 | 비고 |
|
||||
|---|---|
|
||||
| `frontend/components/layout/AppLayout.tsx` | 사이드바 + 콘텐츠 레이아웃 |
|
||||
| `frontend/app/(main)/screens/[screenId]/page.tsx` | 화면 렌더링 (건드리지 않음) |
|
||||
| `frontend/stores/modalDataStore.ts` | Zustand store 참고 패턴 |
|
||||
| `frontend/lib/adminPageRegistry.tsx` | 관리자 페이지 레지스트리 |
|
||||
@@ -0,0 +1,231 @@
|
||||
# 모달 필수 입력 검증 설계
|
||||
|
||||
## 1. 목표
|
||||
|
||||
모든 모달에서 필수 입력값이 빈 상태로 저장 버튼을 클릭하면:
|
||||
- 첫 번째 빈 필수 필드로 포커스 이동 + 하이라이트
|
||||
- 우측 상단에 토스트 알림 ("○○ 항목을 입력해주세요")
|
||||
- 버튼은 항상 활성 상태 (비활성화하지 않음)
|
||||
|
||||
---
|
||||
|
||||
## 2. 전체 구조
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ DialogContent (모든 모달의 공통 래퍼) │
|
||||
│ │
|
||||
│ useDialogAutoValidation(contentRef) │
|
||||
│ │ │
|
||||
│ ├─ 0단계: 모드 확인 │
|
||||
│ │ └─ useTabStore.mode === "user" 일 때만 실행 │
|
||||
│ │ │
|
||||
│ ├─ 1단계: 필수 필드 탐지 │
|
||||
│ │ └─ Label 내부 <span> 안에 * 문자 존재 여부 │
|
||||
│ │ (라벨 텍스트 직접 매칭 X → span 태그 안의 * 만 감지) │
|
||||
│ │ │
|
||||
│ └─ 2단계: 저장 버튼 클릭 인터셉트 │
|
||||
│ │ │
|
||||
│ ├─ 저장/수정/확인 버튼 클릭 감지 │
|
||||
│ │ (data-action-type="save"/"submit" │
|
||||
│ │ 또는 data-variant="default") │
|
||||
│ │ │
|
||||
│ ├─ 빈 필수 필드 있음: │
|
||||
│ │ ├─ 클릭 이벤트 차단 (stopPropagation + preventDefault) │
|
||||
│ │ ├─ 첫 번째 빈 필드로 포커스 이동 │
|
||||
│ │ ├─ 해당 필드 빨간 테두리 + 하이라이트 애니메이션 │
|
||||
│ │ └─ 토스트 알림: "{필드명} 항목을 입력해주세요" │
|
||||
│ │ │
|
||||
│ └─ 모든 필수 필드 입력됨: │
|
||||
│ └─ 클릭 이벤트 통과 (정상 저장 진행) │
|
||||
│ │
|
||||
│ 제외 조건: │
|
||||
│ └─ 필수 필드가 0개인 모달 → 인터셉트 없음 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 필수 필드 감지: span 기반 * 감지
|
||||
|
||||
### 원리
|
||||
|
||||
화면 관리에서 필드를 "필수"로 체크하면 `component.required = true`가 저장된다.
|
||||
V2 컴포넌트가 렌더링할 때 `required = true`이면 Label 안에 `<span>*</span>`을 추가한다.
|
||||
훅은 이 span 안의 `*`를 감지하여 필수 필드를 식별한다.
|
||||
|
||||
### 오탐 방지
|
||||
|
||||
관리자가 라벨 텍스트에 직접 `*`를 입력해도 span 안에 들어가지 않으므로 오탐이 발생하지 않는다.
|
||||
|
||||
```
|
||||
required = true → <label>품목코드<span class="text-orange-500">*</span></label>
|
||||
→ span 안에 * 있음 → 감지 O
|
||||
|
||||
required = false → <label>품목코드</label>
|
||||
→ span 없음 → 감지 X
|
||||
|
||||
라벨에 * 직접 입력 → <label>품목코드*</label>
|
||||
→ span 없이 텍스트에 * → 감지 X (오탐 방지)
|
||||
```
|
||||
|
||||
### 지원 필드 타입
|
||||
|
||||
| V2 컴포넌트 | 렌더링 요소 | 빈값 판정 |
|
||||
|---|---|---|
|
||||
| V2Input | `<input>`, `<textarea>` | `value.trim() === ""` |
|
||||
| V2Select | `<button role="combobox">` | `querySelector("[data-placeholder]")` 존재 |
|
||||
| V2Date | `<input>` (날짜/시간) | `value.trim() === ""` |
|
||||
|
||||
---
|
||||
|
||||
## 4. 저장 버튼 클릭 인터셉트
|
||||
|
||||
### 원리
|
||||
|
||||
버튼을 비활성화하지 않고, 클릭 이벤트를 캡처링 단계에서 가로챈다.
|
||||
빈 필수 필드가 있으면 이벤트를 차단하고, 없으면 통과시킨다.
|
||||
|
||||
### 인터셉트 대상 버튼
|
||||
|
||||
| 조건 | 예시 |
|
||||
|------|------|
|
||||
| `data-action-type="save"` | ButtonPrimary 저장 버튼 |
|
||||
| `data-action-type="submit"` | ButtonPrimary 제출 버튼 |
|
||||
| `data-variant="default"` | shadcn Button 기본 (저장/확인/등록) |
|
||||
|
||||
### 인터셉트하지 않는 버튼
|
||||
|
||||
| 조건 | 예시 |
|
||||
|------|------|
|
||||
| `data-variant` = outline/ghost/destructive/secondary | 취소, 닫기, 삭제 |
|
||||
| `role` = combobox/tab/switch 등 | 폼 컨트롤 |
|
||||
| `data-action-type` != save/submit | 기타 액션 버튼 |
|
||||
| `data-dialog-close` | 모달 닫기 X 버튼 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 시각적 피드백
|
||||
|
||||
### 포커스 이동
|
||||
|
||||
첫 번째 빈 필수 필드로 커서를 이동한다.
|
||||
- `<input>`, `<textarea>`: `input.focus()`
|
||||
- `<button role="combobox">` (V2Select): `button.click()` → 드롭다운 열기
|
||||
|
||||
### 하이라이트 애니메이션
|
||||
|
||||
빈 필수 필드에 빨간 테두리 + 흔들림 효과를 준다.
|
||||
|
||||
```css
|
||||
@keyframes validationShake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20%, 60% { transform: translateX(-4px); }
|
||||
40%, 80% { transform: translateX(4px); }
|
||||
}
|
||||
|
||||
[data-validation-highlight] {
|
||||
border-color: hsl(var(--destructive)) !important;
|
||||
animation: validationShake 400ms ease-in-out;
|
||||
}
|
||||
```
|
||||
|
||||
애니메이션 종료 후 `data-validation-highlight` 속성 제거 (일회성).
|
||||
|
||||
### 토스트 알림
|
||||
|
||||
우측 상단에 토스트 메시지를 표시한다.
|
||||
|
||||
```typescript
|
||||
toast.error(`${fieldLabel} 항목을 입력해주세요`);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 동작 흐름
|
||||
|
||||
```
|
||||
모달 열림
|
||||
│
|
||||
▼
|
||||
DialogContent 마운트
|
||||
│
|
||||
▼
|
||||
useDialogAutoValidation 실행
|
||||
│
|
||||
▼
|
||||
모드 확인 (useTabStore.mode)
|
||||
│
|
||||
├─ mode !== "user"? → return
|
||||
│
|
||||
▼
|
||||
필수 필드 탐지 (Label 내 span에서 * 감지)
|
||||
│
|
||||
├─ 필수 필드 0개? → return
|
||||
│
|
||||
▼
|
||||
클릭 이벤트 리스너 등록 (캡처링 단계)
|
||||
│
|
||||
▼
|
||||
사용자가 저장 버튼 클릭
|
||||
│
|
||||
▼
|
||||
인터셉트 대상 버튼인가?
|
||||
│
|
||||
├─ 아니오 → 클릭 통과
|
||||
│
|
||||
▼
|
||||
빈 필수 필드 검사
|
||||
│
|
||||
├─ 모두 입력됨 → 클릭 통과 (정상 저장)
|
||||
│
|
||||
├─ 빈 필드 있음:
|
||||
│ ├─ e.stopPropagation() + e.preventDefault()
|
||||
│ ├─ 첫 번째 빈 필드에 포커스 이동
|
||||
│ ├─ 해당 필드에 data-validation-highlight 속성 추가
|
||||
│ ├─ 애니메이션 종료 후 속성 제거
|
||||
│ └─ toast.error("{필드명} 항목을 입력해주세요")
|
||||
│
|
||||
▼
|
||||
모달 닫힘
|
||||
│
|
||||
▼
|
||||
클린업
|
||||
├─ 이벤트 리스너 제거
|
||||
└─ 하이라이트 속성 제거
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 관련 파일
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `frontend/lib/hooks/useDialogAutoValidation.ts` | 검증 훅 본체 |
|
||||
| `frontend/components/ui/dialog.tsx` | DialogContent에서 훅 호출 |
|
||||
| `frontend/components/ui/button.tsx` | data-variant 속성 노출 |
|
||||
| `frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx` | data-action-type 속성 노출 |
|
||||
| `frontend/app/globals.css` | 하이라이트 애니메이션 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 적용 범위
|
||||
|
||||
### 현재 (1단계): 사용자 모드만
|
||||
|
||||
| 모달 유형 | 동작 여부 | 이유 |
|
||||
|---------------------------------------|:---:|-------------------------------|
|
||||
| 사용자 모드 모달 (SaveModal 등) | O | mode === "user" + span * 있음 |
|
||||
| 관리자 모드 모달 (CodeFormModal 등) | X | mode !== "user" → return |
|
||||
| 확인/삭제 다이얼로그 (필수 필드 없음) | X | 필수 필드 0개 → 자동 제외 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 이전 방식과 비교
|
||||
|
||||
| 항목 | 이전 (버튼 비활성화) | 현재 (클릭 인터셉트) |
|
||||
|------|---|---|
|
||||
| 버튼 상태 | 빈 필드 있으면 비활성화 | 항상 활성 |
|
||||
| 피드백 시점 | 모달 열릴 때부터 | 저장 버튼 클릭 시 |
|
||||
| 피드백 방식 | 빨간 테두리 + 에러 문구 | 포커스 이동 + 하이라이트 + 토스트 |
|
||||
| 복잡도 | 높음 (MutationObserver, 폴링, CSS 지연) | 낮음 (클릭 이벤트 하나) |
|
||||
@@ -0,0 +1,380 @@
|
||||
# 결재 시스템 v2 사용 가이드
|
||||
|
||||
## 개요
|
||||
|
||||
결재 시스템 v2는 기존 순차결재(escalation) 외에 다양한 결재 방식을 지원합니다.
|
||||
|
||||
| 결재 유형 | 코드 | 설명 |
|
||||
|-----------|------|------|
|
||||
| 순차결재 (기본) | `escalation` | 결재선 순서대로 한 명씩 처리 |
|
||||
| 전결 (자기결재) | `self` | 상신자 본인이 직접 승인 (결재자 불필요) |
|
||||
| 합의결재 | `consensus` | 같은 단계에 여러 결재자 → 전원 승인 필요 |
|
||||
| 후결 | `post` | 먼저 실행 후 나중에 결재 (결재 전 상태에서도 업무 진행) |
|
||||
|
||||
추가 기능:
|
||||
- **대결 위임**: 부재 시 다른 사용자에게 결재 위임
|
||||
- **통보 단계**: 결재선에 통보만 하는 단계 (자동 승인 처리)
|
||||
- **긴급도**: `normal` / `urgent` / `critical`
|
||||
- **혼합형 결재선**: 한 결재선에 결재/합의/통보 단계를 자유롭게 조합
|
||||
|
||||
---
|
||||
|
||||
## DB 스키마 변경사항
|
||||
|
||||
### 마이그레이션 적용
|
||||
|
||||
```bash
|
||||
# 개발 DB에 마이그레이션 적용
|
||||
psql -h 39.117.244.52 -p 11132 -U postgres -d plm -f db/migrations/1051_approval_system_v2.sql
|
||||
psql -h 39.117.244.52 -p 11132 -U postgres -d plm -f db/migrations/1052_rename_proxy_id_to_id.sql
|
||||
```
|
||||
|
||||
### 변경된 테이블
|
||||
|
||||
#### approval_requests (추가 컬럼)
|
||||
|
||||
| 컬럼 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| approval_type | VARCHAR(20) | 'escalation' | self/escalation/consensus/post |
|
||||
| is_post_approved | BOOLEAN | FALSE | 후결 처리 완료 여부 |
|
||||
| post_approved_at | TIMESTAMPTZ | NULL | 후결 처리 시각 |
|
||||
| urgency | VARCHAR(20) | 'normal' | normal/urgent/critical |
|
||||
|
||||
#### approval_lines (추가 컬럼)
|
||||
|
||||
| 컬럼 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| step_type | VARCHAR(20) | 'approval' | approval/consensus/notification |
|
||||
| proxy_for | VARCHAR(50) | NULL | 대결 시 원래 결재자 ID |
|
||||
| proxy_reason | TEXT | NULL | 대결 사유 |
|
||||
| is_required | BOOLEAN | TRUE | 필수 결재 여부 |
|
||||
|
||||
#### approval_proxy_settings (신규)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | SERIAL PK | |
|
||||
| company_code | VARCHAR(20) NOT NULL | |
|
||||
| original_user_id | VARCHAR(50) | 원래 결재자 |
|
||||
| proxy_user_id | VARCHAR(50) | 대결자 |
|
||||
| start_date | DATE | 위임 시작일 |
|
||||
| end_date | DATE | 위임 종료일 |
|
||||
| reason | TEXT | 위임 사유 |
|
||||
| is_active | CHAR(1) | 'Y'/'N' |
|
||||
|
||||
---
|
||||
|
||||
## API 엔드포인트
|
||||
|
||||
모든 API는 `/api/approval` 접두사 + JWT 인증 필수.
|
||||
|
||||
### 결재 요청 (Requests)
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/requests` | 목록 조회 |
|
||||
| GET | `/requests/:id` | 상세 조회 (lines 포함) |
|
||||
| POST | `/requests` | 결재 요청 생성 |
|
||||
| POST | `/requests/:id/cancel` | 결재 회수 |
|
||||
| POST | `/requests/:id/post-approve` | 후결 처리 |
|
||||
|
||||
#### 결재 요청 생성 Body
|
||||
|
||||
```typescript
|
||||
{
|
||||
title: string;
|
||||
target_table: string;
|
||||
target_record_id: string;
|
||||
approval_type?: "self" | "escalation" | "consensus" | "post"; // 기본: escalation
|
||||
urgency?: "normal" | "urgent" | "critical"; // 기본: normal
|
||||
definition_id?: number;
|
||||
target_record_data?: Record<string, any>;
|
||||
approvers: Array<{
|
||||
approver_id: string;
|
||||
step_order: number;
|
||||
step_type?: "approval" | "consensus" | "notification"; // 기본: approval
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
#### 결재 유형별 요청 예시
|
||||
|
||||
**전결 (self)**: 결재자 없이 본인 즉시 승인
|
||||
|
||||
```typescript
|
||||
await createApprovalRequest({
|
||||
title: "긴급 출장비 전결",
|
||||
target_table: "expense",
|
||||
target_record_id: "123",
|
||||
approval_type: "self",
|
||||
approvers: [],
|
||||
});
|
||||
```
|
||||
|
||||
**합의결재 (consensus)**: 같은 step_order에 여러 결재자
|
||||
|
||||
```typescript
|
||||
await createApprovalRequest({
|
||||
title: "프로젝트 예산안 합의",
|
||||
target_table: "budget",
|
||||
target_record_id: "456",
|
||||
approval_type: "consensus",
|
||||
approvers: [
|
||||
{ approver_id: "user1", step_order: 1, step_type: "consensus" },
|
||||
{ approver_id: "user2", step_order: 1, step_type: "consensus" },
|
||||
{ approver_id: "user3", step_order: 1, step_type: "consensus" },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
**혼합형 결재선**: 결재 → 합의 → 통보 조합
|
||||
|
||||
```typescript
|
||||
await createApprovalRequest({
|
||||
title: "신규 채용 승인",
|
||||
target_table: "recruitment",
|
||||
target_record_id: "789",
|
||||
approval_type: "escalation",
|
||||
approvers: [
|
||||
{ approver_id: "teamLead", step_order: 1, step_type: "approval" },
|
||||
{ approver_id: "hrManager", step_order: 2, step_type: "consensus" },
|
||||
{ approver_id: "cfo", step_order: 2, step_type: "consensus" },
|
||||
{ approver_id: "ceo", step_order: 3, step_type: "approval" },
|
||||
{ approver_id: "secretary", step_order: 4, step_type: "notification" },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
**후결 (post)**: 먼저 실행 후 나중에 결재
|
||||
|
||||
```typescript
|
||||
await createApprovalRequest({
|
||||
title: "긴급 자재 발주",
|
||||
target_table: "purchase_order",
|
||||
target_record_id: "101",
|
||||
approval_type: "post",
|
||||
urgency: "urgent",
|
||||
approvers: [
|
||||
{ approver_id: "manager", step_order: 1, step_type: "approval" },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### 결재 처리 (Lines)
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/my-pending` | 내 결재 대기 목록 |
|
||||
| POST | `/lines/:lineId/process` | 승인/반려 처리 |
|
||||
|
||||
#### 승인/반려 Body
|
||||
|
||||
```typescript
|
||||
{
|
||||
action: "approved" | "rejected";
|
||||
comment?: string;
|
||||
proxy_reason?: string; // 대결 시 사유
|
||||
}
|
||||
```
|
||||
|
||||
대결 처리: 원래 결재자가 아닌 사용자가 처리하면 자동으로 대결 설정 확인 후 `proxy_for`, `proxy_reason` 기록.
|
||||
|
||||
### 대결 위임 설정 (Proxy Settings)
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/proxy-settings` | 위임 목록 |
|
||||
| POST | `/proxy-settings` | 위임 생성 |
|
||||
| PUT | `/proxy-settings/:id` | 위임 수정 |
|
||||
| DELETE | `/proxy-settings/:id` | 위임 삭제 |
|
||||
| GET | `/proxy-settings/check/:userId` | 활성 대결자 확인 |
|
||||
|
||||
#### 대결 생성 Body
|
||||
|
||||
```typescript
|
||||
{
|
||||
original_user_id: string;
|
||||
proxy_user_id: string;
|
||||
start_date: string; // "2026-03-10"
|
||||
end_date: string; // "2026-03-20"
|
||||
reason?: string;
|
||||
is_active?: "Y" | "N";
|
||||
}
|
||||
```
|
||||
|
||||
### 템플릿 (Templates)
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/templates` | 템플릿 목록 |
|
||||
| GET | `/templates/:id` | 템플릿 상세 (steps 포함) |
|
||||
| POST | `/templates` | 템플릿 생성 |
|
||||
| PUT | `/templates/:id` | 템플릿 수정 |
|
||||
| DELETE | `/templates/:id` | 템플릿 삭제 |
|
||||
|
||||
---
|
||||
|
||||
## 프론트엔드 화면
|
||||
|
||||
### 1. 결재 요청 모달 (`ApprovalRequestModal`)
|
||||
|
||||
경로: `frontend/components/approval/ApprovalRequestModal.tsx`
|
||||
|
||||
- 결재 유형 선택: 상신결재 / 전결 / 합의결재 / 후결
|
||||
- 템플릿 불러오기: 등록된 템플릿에서 결재선 자동 세팅
|
||||
- 전결 시 결재자 섹션 숨김 + "본인이 직접 승인합니다" 안내
|
||||
- 합의결재 시 결재자 레이블 "합의 결재자"로 변경
|
||||
- 후결 시 안내 배너 표시
|
||||
- 혼합형 step_type 뱃지 표시 (결재/합의/통보)
|
||||
|
||||
### 2. 결재함 (`/admin/approvalBox`)
|
||||
|
||||
경로: `frontend/app/(main)/admin/approvalBox/page.tsx`
|
||||
|
||||
탭 구성:
|
||||
- **수신함**: 내가 결재할 건 목록
|
||||
- **상신함**: 내가 요청한 건 목록
|
||||
- **대결 설정**: 대결 위임 CRUD
|
||||
|
||||
대결 설정 기능:
|
||||
- 위임자/대결자 사용자 검색 (디바운스 300ms)
|
||||
- 시작일/종료일 설정
|
||||
- 활성/비활성 토글
|
||||
- 기간 중복 체크 (서버 측)
|
||||
- 등록/수정/삭제 모달
|
||||
|
||||
### 3. 결재 템플릿 관리 (`/admin/approvalTemplate`)
|
||||
|
||||
경로: `frontend/app/(main)/admin/approvalTemplate/page.tsx`
|
||||
|
||||
- 템플릿 목록/검색
|
||||
- 등록/수정 Dialog
|
||||
- 단계별 결재 유형 설정 (결재/합의/통보)
|
||||
- 합의 단계: "합의자 추가" 버튼으로 같은 step_order에 복수 결재자
|
||||
- 결재자 사용자 검색
|
||||
|
||||
### 4. 결재 단계 컴포넌트 (`v2-approval-step`)
|
||||
|
||||
경로: `frontend/lib/registry/components/v2-approval-step/`
|
||||
|
||||
화면 디자이너에서 사용하는 결재 단계 시각화 컴포넌트:
|
||||
- 가로형/세로형 스테퍼
|
||||
- step_order 기준 그룹핑 (합의결재 시 가로 나열)
|
||||
- step_type 아이콘: 결재(CheckCircle), 합의(Users), 통보(Bell)
|
||||
- 상태별 색상: 승인(success), 반려(destructive), 대기(warning)
|
||||
- 대결/후결/전결 뱃지
|
||||
- 긴급도 표시 (urgent: 주황 dot, critical: 빨강 배경)
|
||||
|
||||
---
|
||||
|
||||
## API 클라이언트 사용법
|
||||
|
||||
```typescript
|
||||
import {
|
||||
// 결재 요청
|
||||
createApprovalRequest,
|
||||
getApprovalRequests,
|
||||
getApprovalRequest,
|
||||
cancelApprovalRequest,
|
||||
postApproveRequest,
|
||||
|
||||
// 대결 위임
|
||||
getProxySettings,
|
||||
createProxySetting,
|
||||
updateProxySetting,
|
||||
deleteProxySetting,
|
||||
checkActiveProxy,
|
||||
|
||||
// 템플릿 단계
|
||||
getTemplateSteps,
|
||||
createTemplateStep,
|
||||
updateTemplateStep,
|
||||
deleteTemplateStep,
|
||||
|
||||
// 타입
|
||||
type ApprovalProxySetting,
|
||||
type CreateApprovalRequestInput,
|
||||
type ApprovalLineTemplateStep,
|
||||
} from "@/lib/api/approval";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 핵심 로직 설명
|
||||
|
||||
### 동시성 보호 (FOR UPDATE)
|
||||
|
||||
결재 처리(`processApproval`)에서 동시 승인/반려 방지:
|
||||
|
||||
```sql
|
||||
SELECT * FROM approval_lines WHERE line_id = $1 FOR UPDATE
|
||||
SELECT * FROM approval_requests WHERE request_id = $1 FOR UPDATE
|
||||
```
|
||||
|
||||
### 대결 자동 감지
|
||||
|
||||
결재자가 아닌 사용자가 결재 처리하면:
|
||||
1. `approval_proxy_settings`에서 활성 대결 설정 확인
|
||||
2. 대결 설정이 있으면 → `proxy_for`, `proxy_reason` 자동 기록
|
||||
3. 없으면 → 403 에러
|
||||
|
||||
### 통보 단계 자동 처리
|
||||
|
||||
`step_type = 'notification'`인 단계가 활성화되면:
|
||||
1. 해당 단계의 모든 결재자를 자동 `approved` 처리
|
||||
2. `comment = '자동 통보 처리'` 기록
|
||||
3. `activateNextStep()` 재귀 호출로 다음 단계 진행
|
||||
|
||||
### 합의결재 단계 완료 판정
|
||||
|
||||
같은 `step_order`의 모든 결재자가 `approved`여야 다음 단계로:
|
||||
|
||||
```sql
|
||||
SELECT COUNT(*) FROM approval_lines
|
||||
WHERE request_id = $1 AND step_order = $2
|
||||
AND status NOT IN ('approved', 'skipped')
|
||||
```
|
||||
|
||||
하나라도 `rejected`면 전체 결재 반려.
|
||||
|
||||
---
|
||||
|
||||
## 메뉴 등록
|
||||
|
||||
결재 관련 화면을 메뉴에 등록하려면:
|
||||
|
||||
| 화면 | URL | 메뉴명 예시 |
|
||||
|------|-----|-------------|
|
||||
| 결재함 | `/admin/approvalBox` | 결재함 |
|
||||
| 결재 템플릿 관리 | `/admin/approvalTemplate` | 결재 템플릿 |
|
||||
| 결재 유형 관리 | `/admin/approvalMng` | 결재 유형 (기존) |
|
||||
|
||||
---
|
||||
|
||||
## 파일 구조
|
||||
|
||||
```
|
||||
backend-node/src/
|
||||
├── controllers/
|
||||
│ ├── approvalController.ts # 결재 유형/템플릿/요청/라인 처리
|
||||
│ └── approvalProxyController.ts # 대결 위임 CRUD
|
||||
└── routes/
|
||||
└── approvalRoutes.ts # 라우트 등록
|
||||
|
||||
frontend/
|
||||
├── app/(main)/admin/
|
||||
│ ├── approvalBox/page.tsx # 결재함 (수신/상신/대결)
|
||||
│ ├── approvalTemplate/page.tsx # 템플릿 관리
|
||||
│ └── approvalMng/page.tsx # 결재 유형 관리 (기존)
|
||||
├── components/approval/
|
||||
│ └── ApprovalRequestModal.tsx # 결재 요청 모달
|
||||
└── lib/
|
||||
├── api/approval.ts # API 클라이언트
|
||||
└── registry/components/v2-approval-step/
|
||||
├── ApprovalStepComponent.tsx # 결재 단계 시각화
|
||||
└── types.ts # 확장 타입
|
||||
|
||||
db/migrations/
|
||||
├── 1051_approval_system_v2.sql # v2 스키마 확장
|
||||
└── 1052_rename_proxy_id_to_id.sql # PK 컬럼명 통일
|
||||
```
|
||||
@@ -0,0 +1,759 @@
|
||||
# WACE 시스템 문제점 분석 및 개선 계획
|
||||
|
||||
> **작성일**: 2026-03-01
|
||||
> **상태**: 분석 완료, 계획 수립
|
||||
> **목적**: 반복적으로 발생하는 시스템 문제의 근본 원인 분석 및 구조적 개선 방안
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [문제 요약](#1-문제-요약)
|
||||
2. [문제 1: AI(Cursor) 대화 길어질수록 정확도 저하](#2-문제-1-aicursor-대화-길어질수록-정확도-저하)
|
||||
3. [문제 2: 컴포넌트가 일관되지 않게 생성됨](#3-문제-2-컴포넌트가-일관되지-않게-생성됨)
|
||||
4. [문제 3: 코드 수정 시 다른 곳에 사이드 이펙트 발생](#4-문제-3-코드-수정-시-다른-곳에-사이드-이펙트-발생)
|
||||
5. [근본 원인 종합](#5-근본-원인-종합)
|
||||
6. [개선 계획](#6-개선-계획)
|
||||
7. [우선순위 로드맵](#7-우선순위-로드맵)
|
||||
|
||||
---
|
||||
|
||||
## 1. 문제 요약
|
||||
|
||||
| # | 증상 | 빈도 | 심각도 |
|
||||
|---|------|------|--------|
|
||||
| 1 | Cursor로 오래 작업하면 정확도 떨어짐 | 매 세션 | 중 |
|
||||
| 2 | 로우코드 컴포넌트 생성 시 오류, 비일관성 | 매 컴포넌트 | 높 |
|
||||
| 3 | 수정/신규 코드가 다른 곳에 영향 (저장 안됨, 특정 기능 깨짐) | 수시 | 높 |
|
||||
|
||||
세 문제는 독립적으로 보이지만, **하나의 구조적 원인**에서 파생된다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 문제 1: AI(Cursor) 대화 길어질수록 정확도 저하
|
||||
|
||||
### 2.1. 증상
|
||||
|
||||
- 대화 초반에는 정확한 코드를 생성하다가, 30분~1시간 이상 작업하면 엉뚱한 코드 생성
|
||||
- 이전 맥락을 잊고 같은 질문을 반복하거나, 이미 수정한 부분을 되돌림
|
||||
- 관련 없는 파일을 수정하거나, 존재하지 않는 함수/변수를 참조
|
||||
|
||||
### 2.2. 원인 분석
|
||||
|
||||
AI의 컨텍스트 윈도우는 유한하다. 우리 코드베이스의 핵심 파일들이 **비정상적으로 거대**해서, AI가 한 번에 파악해야 할 정보량이 폭발한다.
|
||||
|
||||
#### 거대 파일 목록 (상위 10개)
|
||||
|
||||
| 파일 | 줄 수 | 역할 |
|
||||
|------|-------|------|
|
||||
| `frontend/lib/utils/buttonActions.ts` | **7,609줄** | 버튼 액션 전체 로직 |
|
||||
| `frontend/components/screen/ScreenDesigner.tsx` | **7,559줄** | 화면 설계기 |
|
||||
| `frontend/lib/registry/components/v2-table-list/TableListComponent.tsx` | **6,867줄** | V2 테이블 컴포넌트 |
|
||||
| `frontend/lib/registry/components/table-list/TableListComponent.tsx` | **6,829줄** | 레거시 테이블 컴포넌트 |
|
||||
| `frontend/components/screen/EditModal.tsx` | **1,648줄** | 편집 모달 |
|
||||
| `frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx` | **1,524줄** | 버튼 컴포넌트 |
|
||||
| `frontend/components/v2/V2Repeater.tsx` | **1,442줄** | 리피터 컴포넌트 |
|
||||
| `frontend/components/screen/InteractiveScreenViewerDynamic.tsx` | **1,435줄** | 화면 뷰어 |
|
||||
| `frontend/lib/utils/improvedButtonActionExecutor.ts` | **1,063줄** | 버튼 실행기 |
|
||||
| `frontend/lib/registry/DynamicComponentRenderer.tsx` | **980줄** | 컴포넌트 렌더러 |
|
||||
|
||||
**상위 3개 파일만 합쳐도 22,035줄**이다. AI가 이 파일 하나를 읽는 것만으로도 컨텍스트의 상당 부분을 소모한다.
|
||||
|
||||
#### 타입 안전성 부재
|
||||
|
||||
```typescript
|
||||
// frontend/types/component.ts:37-39
|
||||
export interface ComponentConfig {
|
||||
[key: string]: any; // 사실상 타입 검증 없음
|
||||
}
|
||||
|
||||
// frontend/types/component.ts:56-78
|
||||
export interface ComponentRendererProps {
|
||||
component: any; // ComponentData인데 any로 선언
|
||||
// ... 중략 ...
|
||||
[key: string]: any; // 여기도 any
|
||||
}
|
||||
```
|
||||
|
||||
`any` 타입이 핵심 인터페이스에 사용되어, AI가 "이 prop에 뭘 넣어야 하는지" 추론 불가.
|
||||
사람이 봐도 모르는데 AI가 알 리가 없다.
|
||||
|
||||
#### 이벤트 이름이 문자열 상수
|
||||
|
||||
```typescript
|
||||
// 이 이벤트들이 코드 전체에 흩어져 있음
|
||||
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||||
window.dispatchEvent(new CustomEvent("closeEditModal"));
|
||||
window.dispatchEvent(new CustomEvent("saveSuccessInModal"));
|
||||
window.dispatchEvent(new CustomEvent("repeaterSaveComplete"));
|
||||
window.dispatchEvent(new CustomEvent("refreshCardDisplay"));
|
||||
window.dispatchEvent(new CustomEvent("refreshTableData"));
|
||||
window.dispatchEvent(new CustomEvent("saveSuccess"));
|
||||
window.dispatchEvent(new CustomEvent("closeScreenModal"));
|
||||
```
|
||||
|
||||
문자열 기반이라 AI가 이벤트 흐름을 추적할 수 없다. 어떤 이벤트가 어디서 발생하고 어디서 수신되는지 **정적 분석이 불가능**하다.
|
||||
|
||||
### 2.3. 영향
|
||||
|
||||
- AI가 파일 하나를 읽으면 다른 파일의 맥락을 잊음
|
||||
- 함수 시그니처를 추론하지 못하고 잘못된 파라미터를 넣음
|
||||
- 이벤트 기반 로직을 이해하지 못해 부정확한 코드 생성
|
||||
|
||||
---
|
||||
|
||||
## 3. 문제 2: 컴포넌트가 일관되지 않게 생성됨
|
||||
|
||||
### 3.1. 증상
|
||||
|
||||
- 새 컴포넌트를 만들 때마다 구조가 다름
|
||||
- Config 패널의 UI 패턴이 컴포넌트마다 제각각
|
||||
- 같은 기능인데 어떤 컴포넌트는 동작하고 어떤 컴포넌트는 안 됨
|
||||
|
||||
### 3.2. 원인 분석
|
||||
|
||||
#### 컴포넌트 수량과 중복
|
||||
|
||||
현재 등록된 컴포넌트 디렉토리: **81개**
|
||||
|
||||
이 중 V2와 레거시가 병존하는 **중복 쌍**:
|
||||
|
||||
| V2 버전 | 레거시 버전 | 기능 |
|
||||
|---------|------------|------|
|
||||
| `v2-table-list` (6,867줄) | `table-list` (6,829줄) | 테이블 |
|
||||
| `v2-button-primary` (1,524줄) | `button-primary` | 버튼 |
|
||||
| `v2-card-display` | `card-display` | 카드 표시 |
|
||||
| `v2-aggregation-widget` | `aggregation-widget` | 집계 위젯 |
|
||||
| `v2-file-upload` | `file-upload` | 파일 업로드 |
|
||||
| `v2-split-panel-layout` | `split-panel-layout` | 분할 패널 |
|
||||
| `v2-section-card` | `section-card` | 섹션 카드 |
|
||||
| `v2-section-paper` | `section-paper` | 섹션 페이퍼 |
|
||||
| `v2-category-manager` | `category-manager` | 카테고리 |
|
||||
| `v2-repeater` | `repeater-field-group` | 리피터 |
|
||||
| `v2-pivot-grid` | `pivot-grid` | 피벗 그리드 |
|
||||
| `v2-rack-structure` | `rack-structure` | 랙 구조 |
|
||||
| `v2-repeat-container` | `repeat-container` | 반복 컨테이너 |
|
||||
|
||||
**13쌍이 중복** 존재. `v2-table-list`와 `table-list`는 각각 6,800줄 이상으로, 거의 같은 코드가 두 벌 있다.
|
||||
|
||||
#### 패턴은 있지만 강제되지 않음
|
||||
|
||||
컴포넌트 표준 구조:
|
||||
```
|
||||
v2-example/
|
||||
├── index.ts # createComponentDefinition()
|
||||
├── ExampleRenderer.tsx # AutoRegisteringComponentRenderer 상속
|
||||
├── ExampleComponent.tsx # 실제 UI
|
||||
├── ExampleConfigPanel.tsx # 설정 패널 (선택)
|
||||
└── types.ts # ExampleConfig extends ComponentConfig
|
||||
```
|
||||
|
||||
이 패턴을 **문서(`.cursor/rules/component-development-guide.mdc`)에서 설명**하고 있지만:
|
||||
|
||||
1. **런타임 검증 없음**: `createComponentDefinition()`이 ID 형식만 검증, 나머지는 자유
|
||||
2. **Config 타입이 `any`**: `ComponentConfig = { [key: string]: any }` → 아무 값이나 들어감
|
||||
3. **테스트 0개**: 전체 프론트엔드에 테스트 파일 **1개** (`buttonDataflowPerformance.test.ts`), 컴포넌트 테스트는 **0개**
|
||||
4. **스캐폴딩 도구 없음**: 수동으로 파일을 만들고 index.ts에 import를 추가해야 함
|
||||
|
||||
#### 컴포넌트 간 복잡도 격차
|
||||
|
||||
| 분류 | 예시 | 줄 수 | 외부 의존 | Error Boundary |
|
||||
|------|------|-------|-----------|----------------|
|
||||
| 단순 표시형 | `v2-text-display` | ~100줄 | 거의 없음 | 없음 |
|
||||
| 입력형 | `v2-input` | ~500줄 | formData, eventBus | 없음 |
|
||||
| 버튼 | `v2-button-primary` | 1,524줄 | buttonActions, apiClient, context, eventBus, modalDataStore | 있음 |
|
||||
| 테이블 | `v2-table-list` | 6,867줄 | 거의 모든 것 | 있음 |
|
||||
|
||||
100줄짜리와 6,867줄짜리가 같은 "컴포넌트"로 취급된다. AI에게 "컴포넌트 만들어"라고 하면 어떤 수준으로 만들어야 하는지 기준이 없다.
|
||||
|
||||
#### POP 컴포넌트는 완전 별도 시스템
|
||||
|
||||
```
|
||||
frontend/lib/registry/
|
||||
├── ComponentRegistry.ts # 웹 컴포넌트 레지스트리
|
||||
├── PopComponentRegistry.ts # POP 컴포넌트 레지스트리 (별도 인터페이스)
|
||||
```
|
||||
|
||||
같은 "컴포넌트"인데 등록 방식, 인터페이스, 설정 구조가 완전히 다르다.
|
||||
|
||||
### 3.3. 영향
|
||||
|
||||
- 새 컴포넌트를 만들 때 "어떤 컴포넌트를 참고해야 하는지" 불명확
|
||||
- AI가 참조하는 컴포넌트에 따라 결과물이 달라짐
|
||||
- Config 구조가 제각각이라 설정 패널 UI도 불일치
|
||||
|
||||
---
|
||||
|
||||
## 4. 문제 3: 코드 수정 시 다른 곳에 사이드 이펙트 발생
|
||||
|
||||
### 4.1. 증상
|
||||
|
||||
- 저장 로직 수정했더니 다른 화면에서 저장이 안 됨
|
||||
- 테이블 관련 코드 수정했더니 모달에서 특정 기능이 깨짐
|
||||
- 리피터 수정했더니 버튼 동작이 달라짐
|
||||
|
||||
### 4.2. 원인 분석
|
||||
|
||||
#### 원인 A: window 전역 상태 오염
|
||||
|
||||
코드베이스 전체에서 `window.__*` 패턴 사용: **8개 파일, 32회 참조**
|
||||
|
||||
| 전역 변수 | 정의 위치 | 사용 위치 | 위험도 |
|
||||
|-----------|-----------|-----------|--------|
|
||||
| `window.__v2RepeaterInstances` | `V2Repeater.tsx` (220줄) | `EditModal.tsx`, `buttonActions.ts` (4곳) | **높음** |
|
||||
| `window.__relatedButtonsTargetTables` | `RelatedDataButtonsComponent.tsx` (25줄) | `v2-table-list`, `table-list`, `buttonActions.ts` | **높음** |
|
||||
| `window.__relatedButtonsSelectedData` | `RelatedDataButtonsComponent.tsx` (51줄) | `buttonActions.ts` (3113줄) | **높음** |
|
||||
| `window.__unifiedRepeaterInstances` | `UnifiedRepeater.tsx` (110줄) | `UnifiedRepeater.tsx` | 중간 |
|
||||
| `window.__AUTH_LOG` | `authLogger.ts` | 디버깅용 | 낮음 |
|
||||
|
||||
**사이드 이펙트 시나리오 예시**:
|
||||
|
||||
```
|
||||
1. V2Repeater 마운트 → window.__v2RepeaterInstances에 등록
|
||||
2. EditModal이 저장 시 → window.__v2RepeaterInstances 체크
|
||||
3. 만약 Repeater가 언마운트 타이밍에 늦게 정리되면?
|
||||
→ EditModal은 "리피터가 있다"고 판단
|
||||
→ 리피터 저장 로직 실행
|
||||
→ 실제로는 리피터 데이터 없음
|
||||
→ 저장 실패 또는 빈 데이터 저장
|
||||
```
|
||||
|
||||
#### 원인 B: 이벤트 스파게티
|
||||
|
||||
`window.dispatchEvent(new CustomEvent(...))` 사용: **43개 파일, 총 120회 이상**
|
||||
|
||||
주요 이벤트와 발신/수신 관계:
|
||||
|
||||
```
|
||||
[refreshTable 이벤트]
|
||||
발신 (8곳):
|
||||
- buttonActions.ts (5회)
|
||||
- BomItemEditorComponent.tsx
|
||||
- SelectedItemsDetailInputComponent.tsx
|
||||
- BomTreeComponent.tsx (2회)
|
||||
- ButtonPrimaryComponent.tsx (레거시)
|
||||
- ScreenModal.tsx (2회)
|
||||
- InteractiveScreenViewerDynamic.tsx
|
||||
|
||||
수신 (5곳):
|
||||
- v2-table-list/TableListComponent.tsx
|
||||
- table-list/TableListComponent.tsx
|
||||
- SplitPanelLayoutComponent.tsx
|
||||
- InteractiveScreenViewerDynamic.tsx
|
||||
- InteractiveScreenViewer.tsx
|
||||
```
|
||||
|
||||
```
|
||||
[closeEditModal 이벤트]
|
||||
발신 (4곳):
|
||||
- buttonActions.ts (4회)
|
||||
|
||||
수신 (2곳):
|
||||
- EditModal.tsx
|
||||
- screens/[screenId]/page.tsx
|
||||
```
|
||||
|
||||
```
|
||||
[beforeFormSave 이벤트]
|
||||
수신 (6곳):
|
||||
- V2Input.tsx
|
||||
- V2Repeater.tsx
|
||||
- BomItemEditorComponent.tsx
|
||||
- SelectedItemsDetailInputComponent.tsx
|
||||
- UniversalFormModalComponent.tsx
|
||||
- V2FormContext.tsx
|
||||
```
|
||||
|
||||
**문제**: 이벤트 이름이 **문자열 상수**이고, 발신과 수신이 **타입으로 연결되지 않음**.
|
||||
`refreshTable` 이벤트를 `refreshTableData`로 오타내도 컴파일 에러 없이 런타임에서만 발견된다.
|
||||
|
||||
#### 원인 C: 이중/삼중 이벤트 시스템
|
||||
|
||||
동시에 3개의 이벤트 시스템이 공존:
|
||||
|
||||
| 시스템 | 위치 | 방식 | 타입 안전 |
|
||||
|--------|------|------|-----------|
|
||||
| `window.dispatchEvent` | 전역 | CustomEvent 문자열 | 없음 |
|
||||
| `v2EventBus` | `lib/v2-core/events/EventBus.ts` | 타입 기반 pub/sub | 있음 |
|
||||
| `LegacyEventAdapter` | `lib/v2-core/adapters/LegacyEventAdapter.ts` | 1번↔2번 브릿지 | 부분적 |
|
||||
|
||||
어떤 컴포넌트는 `window.dispatchEvent`를 쓰고, 어떤 컴포넌트는 `v2EventBus`를 쓰고, 또 어떤 컴포넌트는 둘 다 쓴다. **같은 이벤트가 두 시스템에서 동시에 발생**할 수 있어 예측 불가능한 동작이 발생한다.
|
||||
|
||||
#### 원인 D: SplitPanelContext 이름 충돌
|
||||
|
||||
같은 이름의 Context가 2개 존재:
|
||||
|
||||
| 위치 | 용도 | 제공하는 것 |
|
||||
|------|------|------------|
|
||||
| `frontend/contexts/SplitPanelContext.tsx` | 데이터 전달 | `selectedLeftData`, `transfer()`, `registerReceiver()` |
|
||||
| `frontend/lib/registry/components/split-panel-layout/SplitPanelContext.tsx` | 리사이즈/좌표 | `getAdjustedX()`, `dividerX`, `leftWidthPercent` |
|
||||
|
||||
import 경로에 따라 **완전히 다른 Context**를 가져온다. AI가 자동완성으로 잘못된 Context를 import하면 런타임에 `undefined` 에러가 발생한다.
|
||||
|
||||
#### 원인 E: buttonActions.ts - 7,609줄의 신(God) 파일
|
||||
|
||||
이 파일 하나가 다음 기능을 전부 담당:
|
||||
|
||||
- 저장 (INSERT/UPDATE/DELETE)
|
||||
- 모달 열기/닫기
|
||||
- 리피터 데이터 수집
|
||||
- 테이블 새로고침
|
||||
- 파일 업로드
|
||||
- 외부 API 호출
|
||||
- 화면 전환
|
||||
- 데이터 검증
|
||||
- 이벤트 발송 (33회)
|
||||
- window 전역 상태 읽기 (5회)
|
||||
|
||||
**이 파일의 한 줄을 수정하면, 위의 모든 기능이 영향을 받을 수 있다.**
|
||||
|
||||
#### 원인 F: 레거시-V2 코드 동시 존재
|
||||
|
||||
```
|
||||
v2-table-list/TableListComponent.tsx (6,867줄)
|
||||
table-list/TableListComponent.tsx (6,829줄)
|
||||
```
|
||||
|
||||
거의 같은 코드가 두 벌. 한쪽을 수정하면 다른 쪽은 수정 안 되어 동작이 달라진다.
|
||||
또한 두 컴포넌트가 **같은 전역 이벤트를 수신**하므로, 한 화면에 둘 다 있으면 이중으로 반응할 수 있다.
|
||||
|
||||
#### 원인 G: Error Boundary 미적용
|
||||
|
||||
| 컴포넌트 | Error Boundary |
|
||||
|----------|----------------|
|
||||
| `v2-button-primary` | 있음 |
|
||||
| `v2-table-list` | 있음 |
|
||||
| `v2-repeater` | 있음 |
|
||||
| `v2-input` | **없음** |
|
||||
| `v2-select` | **없음** |
|
||||
| `v2-card-display` | **없음** |
|
||||
| `v2-text-display` | **없음** |
|
||||
| 기타 대부분 | **없음** |
|
||||
|
||||
Error Boundary가 없는 컴포넌트에서 에러가 발생하면, **상위 컴포넌트까지 전파**되어 화면 전체가 깨진다.
|
||||
|
||||
### 4.3. 사이드 이펙트 발생 위험 지도
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ buttonActions.ts │
|
||||
│ (7,609줄) │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ 저장 로직 │ │ 모달 로직 │ │ 이벤트 │ │
|
||||
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||||
└───────┼──────────────┼─────────────┼─────────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────────┐ ┌──────────┐ ┌─────────────────┐
|
||||
│ window.__v2 │ │EditModal │ │ CustomEvent │
|
||||
│ RepeaterInst │ │(1,648줄) │ │ "refreshTable" │
|
||||
│ ances │ │ │ │ "closeEditModal" │
|
||||
└──────┬───────┘ └────┬─────┘ │ "saveSuccess" │
|
||||
│ │ └───────┬─────────┘
|
||||
▼ │ │
|
||||
┌──────────────┐ │ ┌──────▼───────┐
|
||||
│ V2Repeater │◄─────┘ │ TableList │
|
||||
│ (1,442줄) │ │ (6,867줄) │
|
||||
└──────────────┘ │ + 레거시 │
|
||||
│ (6,829줄) │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
**위 그래프에서 어디를 수정하든 화살표를 따라 다른 곳에 영향이 전파된다.**
|
||||
|
||||
---
|
||||
|
||||
## 5. 근본 원인 종합
|
||||
|
||||
세 가지 문제의 근본 원인은 하나다: **경계(Boundary)가 없는 아키텍처**
|
||||
|
||||
| 근본 원인 | 문제 1 영향 | 문제 2 영향 | 문제 3 영향 |
|
||||
|-----------|-------------|-------------|-------------|
|
||||
| 거대 파일 (God File) | AI 컨텍스트 소모 | 참조할 기준 불명확 | 수정 영향 범위 광범위 |
|
||||
| `any` 타입 남발 | AI 타입 추론 불가 | Config 검증 없음 | 런타임 에러 |
|
||||
| 문자열 이벤트 | AI 이벤트 흐름 추적 불가 | 이벤트 패턴 불일치 | 이벤트 누락/오타 |
|
||||
| window 전역 상태 | AI 상태 추적 불가 | 컴포넌트 간 의존 증가 | 상태 오염 |
|
||||
| 테스트 부재 (0개) | 변경 검증 불가 | 컴포넌트 계약 불명 | 사이드 이펙트 감지 불가 |
|
||||
| 레거시-V2 중복 (13쌍) | AI 혼동 | 어느 쪽을 기준으로? | 한쪽만 수정 시 불일치 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 개선 계획
|
||||
|
||||
### Phase 1: 즉시 효과 (1~2주) - 안전장치 설치
|
||||
|
||||
#### 1-1. 이벤트 이름 상수화
|
||||
|
||||
**현재**:
|
||||
```typescript
|
||||
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||||
```
|
||||
|
||||
**개선**:
|
||||
```typescript
|
||||
// frontend/lib/constants/events.ts
|
||||
export const EVENTS = {
|
||||
REFRESH_TABLE: "refreshTable",
|
||||
CLOSE_EDIT_MODAL: "closeEditModal",
|
||||
SAVE_SUCCESS: "saveSuccess",
|
||||
SAVE_SUCCESS_IN_MODAL: "saveSuccessInModal",
|
||||
REPEATER_SAVE_COMPLETE: "repeaterSaveComplete",
|
||||
REFRESH_CARD_DISPLAY: "refreshCardDisplay",
|
||||
REFRESH_TABLE_DATA: "refreshTableData",
|
||||
CLOSE_SCREEN_MODAL: "closeScreenModal",
|
||||
BEFORE_FORM_SAVE: "beforeFormSave",
|
||||
} as const;
|
||||
|
||||
// 사용
|
||||
window.dispatchEvent(new CustomEvent(EVENTS.REFRESH_TABLE));
|
||||
```
|
||||
|
||||
**효과**: 오타 방지, AI가 이벤트 흐름 추적 가능, IDE 자동완성 지원
|
||||
**위험도**: 낮음 (기능 변경 없음, 리팩토링만)
|
||||
**소요 예상**: 2~3시간
|
||||
|
||||
#### 1-2. window 전역 변수 타입 선언
|
||||
|
||||
**현재**: `window.__v2RepeaterInstances`를 사용하지만 타입 선언 없음
|
||||
|
||||
**개선**:
|
||||
```typescript
|
||||
// frontend/types/global.d.ts
|
||||
declare global {
|
||||
interface Window {
|
||||
__v2RepeaterInstances?: Set<string>;
|
||||
__unifiedRepeaterInstances?: Set<string>;
|
||||
__relatedButtonsTargetTables?: Set<string>;
|
||||
__relatedButtonsSelectedData?: {
|
||||
tableName: string;
|
||||
selectedRows: any[];
|
||||
};
|
||||
__AUTH_LOG?: { show: () => void };
|
||||
__COMPONENT_REGISTRY__?: Map<string, any>;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**효과**: 타입 안전성 확보, AI가 전역 상태 구조 이해 가능
|
||||
**위험도**: 낮음 (타입 선언만, 런타임 변경 없음)
|
||||
**소요 예상**: 1시간
|
||||
|
||||
#### 1-3. ComponentConfig에 제네릭 타입 적용
|
||||
|
||||
**현재**:
|
||||
```typescript
|
||||
export interface ComponentConfig {
|
||||
[key: string]: any;
|
||||
}
|
||||
```
|
||||
|
||||
**개선**:
|
||||
```typescript
|
||||
export interface ComponentConfig {
|
||||
[key: string]: unknown; // any → unknown으로 변경하여 타입 체크 강제
|
||||
}
|
||||
|
||||
// 각 컴포넌트에서
|
||||
export interface ButtonPrimaryConfig extends ComponentConfig {
|
||||
text: string; // 구체적 타입
|
||||
action: ButtonAction; // 구체적 타입
|
||||
variant?: "default" | "destructive" | "outline";
|
||||
}
|
||||
```
|
||||
|
||||
**효과**: 잘못된 config 값 사전 차단
|
||||
**위험도**: 중간 (기존 `any` 사용처에서 타입 에러 발생 가능, 점진적 적용 필요)
|
||||
**소요 예상**: 3~5일 (점진적)
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 구조 개선 (2~4주) - 핵심 분리
|
||||
|
||||
#### 2-1. buttonActions.ts 분할
|
||||
|
||||
**현재**: 7,609줄, 1개 파일
|
||||
|
||||
**개선 목표**: 도메인별 분리
|
||||
|
||||
```
|
||||
frontend/lib/actions/
|
||||
├── index.ts # re-export
|
||||
├── types.ts # 공통 타입
|
||||
├── saveActions.ts # INSERT/UPDATE 저장 로직
|
||||
├── deleteActions.ts # DELETE 로직
|
||||
├── modalActions.ts # 모달 열기/닫기
|
||||
├── tableActions.ts # 테이블 새로고침, 데이터 조작
|
||||
├── repeaterActions.ts # 리피터 데이터 수집/저장
|
||||
├── fileActions.ts # 파일 업로드/다운로드
|
||||
├── navigationActions.ts # 화면 전환
|
||||
├── validationActions.ts # 데이터 검증
|
||||
└── externalActions.ts # 외부 API 호출
|
||||
```
|
||||
|
||||
**효과**:
|
||||
- 저장 로직 수정 시 `saveActions.ts`만 영향
|
||||
- AI가 관련 파일만 읽으면 됨 (7,600줄 → 평균 500줄)
|
||||
- import 관계로 의존성 명확화
|
||||
|
||||
**위험도**: 높음 (가장 많이 사용되는 파일, 신중한 분리 필요)
|
||||
**소요 예상**: 1~2주
|
||||
|
||||
#### 2-2. 이벤트 시스템 통일
|
||||
|
||||
**현재**: 3개 시스템 공존 (window CustomEvent, v2EventBus, LegacyEventAdapter)
|
||||
|
||||
**개선**:
|
||||
```typescript
|
||||
// v2EventBus로 통일, 타입 안전한 이벤트 정의
|
||||
interface EventMap {
|
||||
"table:refresh": { tableId?: string };
|
||||
"modal:close": { modalId: string };
|
||||
"form:save": { formData: Record<string, any> };
|
||||
"form:saveComplete": { success: boolean; message?: string };
|
||||
"repeater:saveComplete": { repeaterId: string };
|
||||
}
|
||||
|
||||
// 사용
|
||||
v2EventBus.emit("table:refresh", { tableId: "order_table" });
|
||||
v2EventBus.on("table:refresh", (data) => { /* data.tableId 타입 안전 */ });
|
||||
```
|
||||
|
||||
**마이그레이션 전략**:
|
||||
1. `v2EventBus`에 `EventMap` 타입 추가
|
||||
2. 새 코드는 반드시 `v2EventBus` 사용
|
||||
3. 기존 `window.dispatchEvent` → `v2EventBus`로 점진적 교체
|
||||
4. `LegacyEventAdapter`에서 양방향 브릿지 유지 (과도기)
|
||||
5. 모든 교체 완료 후 `LegacyEventAdapter` 제거
|
||||
|
||||
**효과**: 이벤트 흐름 추적 가능, 타입 안전, 디버깅 용이
|
||||
**위험도**: 중간 (과도기 브릿지로 안전하게 전환)
|
||||
**소요 예상**: 2~3주
|
||||
|
||||
#### 2-3. window 전역 상태 → Zustand 스토어 전환
|
||||
|
||||
**현재**:
|
||||
```typescript
|
||||
window.__v2RepeaterInstances = new Set();
|
||||
window.__relatedButtonsSelectedData = { tableName, selectedRows };
|
||||
```
|
||||
|
||||
**개선**:
|
||||
```typescript
|
||||
// frontend/lib/stores/componentInstanceStore.ts
|
||||
import { create } from "zustand";
|
||||
|
||||
interface ComponentInstanceState {
|
||||
repeaterInstances: Set<string>;
|
||||
relatedButtonsTargetTables: Set<string>;
|
||||
relatedButtonsSelectedData: {
|
||||
tableName: string;
|
||||
selectedRows: any[];
|
||||
} | null;
|
||||
|
||||
registerRepeater: (key: string) => void;
|
||||
unregisterRepeater: (key: string) => void;
|
||||
setRelatedData: (data: { tableName: string; selectedRows: any[] }) => void;
|
||||
clearRelatedData: () => void;
|
||||
}
|
||||
|
||||
export const useComponentInstanceStore = create<ComponentInstanceState>((set) => ({
|
||||
repeaterInstances: new Set(),
|
||||
relatedButtonsTargetTables: new Set(),
|
||||
relatedButtonsSelectedData: null,
|
||||
|
||||
registerRepeater: (key) =>
|
||||
set((state) => {
|
||||
const next = new Set(state.repeaterInstances);
|
||||
next.add(key);
|
||||
return { repeaterInstances: next };
|
||||
}),
|
||||
unregisterRepeater: (key) =>
|
||||
set((state) => {
|
||||
const next = new Set(state.repeaterInstances);
|
||||
next.delete(key);
|
||||
return { repeaterInstances: next };
|
||||
}),
|
||||
setRelatedData: (data) => set({ relatedButtonsSelectedData: data }),
|
||||
clearRelatedData: () => set({ relatedButtonsSelectedData: null }),
|
||||
}));
|
||||
```
|
||||
|
||||
**효과**:
|
||||
- 상태 변경 추적 가능 (Zustand devtools)
|
||||
- 컴포넌트 리렌더링 최적화 (selector 사용)
|
||||
- window 오염 제거
|
||||
|
||||
**위험도**: 중간
|
||||
**소요 예상**: 1주
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 품질 강화 (4~8주) - 예방 체계
|
||||
|
||||
#### 3-1. 레거시 컴포넌트 제거
|
||||
|
||||
**목표**: V2-레거시 중복 13쌍 → V2만 유지
|
||||
|
||||
**전략**:
|
||||
1. 각 중복 쌍에서 레거시 사용처 검색
|
||||
2. 사용처가 없는 레거시 컴포넌트 즉시 제거
|
||||
3. 사용처가 있는 경우 V2로 교체 후 제거
|
||||
4. `components/index.ts`에서 import 제거
|
||||
|
||||
**효과**: 코드베이스 ~15,000줄 감소, AI 혼동 제거
|
||||
**소요 예상**: 2~3주
|
||||
|
||||
#### 3-2. 컴포넌트 스캐폴딩 CLI
|
||||
|
||||
**목표**: `npx create-v2-component my-component` 실행 시 표준 구조 자동 생성
|
||||
|
||||
```bash
|
||||
$ npx create-v2-component my-widget --category data
|
||||
|
||||
생성 완료:
|
||||
frontend/lib/registry/components/v2-my-widget/
|
||||
├── index.ts # 자동 생성
|
||||
├── MyWidgetRenderer.tsx # 자동 생성
|
||||
├── MyWidgetComponent.tsx # 템플릿
|
||||
├── MyWidgetConfigPanel.tsx # 템플릿
|
||||
└── types.ts # Config 인터페이스 템플릿
|
||||
|
||||
components/index.ts에 import 자동 추가 완료
|
||||
```
|
||||
|
||||
**효과**: 컴포넌트 구조 100% 일관성 보장
|
||||
**소요 예상**: 3~5일
|
||||
|
||||
#### 3-3. 핵심 컴포넌트 통합 테스트
|
||||
|
||||
**목표**: 사이드 이펙트 감지용 테스트 작성
|
||||
|
||||
```typescript
|
||||
// __tests__/integration/save-flow.test.ts
|
||||
describe("저장 플로우", () => {
|
||||
it("버튼 저장 → refreshTable 이벤트 발생", async () => {
|
||||
const listener = vi.fn();
|
||||
v2EventBus.on("table:refresh", listener);
|
||||
|
||||
await executeSaveAction({ tableName: "test_table", data: mockData });
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("리피터가 있을 때 저장 → 리피터 데이터도 포함", async () => {
|
||||
useComponentInstanceStore.getState().registerRepeater("detail_table");
|
||||
|
||||
const result = await executeSaveAction({ tableName: "master_table", data: mockData });
|
||||
|
||||
expect(result.repeaterDataCollected).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**대상**: 저장/삭제/모달/리피터 흐름 (가장 빈번하게 깨지는 부분)
|
||||
**효과**: 코드 수정 후 즉시 사이드 이펙트 감지
|
||||
**소요 예상**: 2~3주
|
||||
|
||||
#### 3-4. SplitPanelContext 통합
|
||||
|
||||
**목표**: 이름이 같은 2개의 Context → 1개로 통합 또는 명확히 분리
|
||||
|
||||
**방안 A - 통합**:
|
||||
```typescript
|
||||
// frontend/contexts/SplitPanelContext.tsx에 통합
|
||||
interface SplitPanelContextValue {
|
||||
// 데이터 전달 (기존 contexts/ 버전)
|
||||
selectedLeftData: any;
|
||||
transfer: (data: any) => void;
|
||||
registerReceiver: (handler: (data: any) => void) => void;
|
||||
// 리사이즈 (기존 components/ 버전)
|
||||
getAdjustedX: (x: number) => number;
|
||||
dividerX: number;
|
||||
leftWidthPercent: number;
|
||||
}
|
||||
```
|
||||
|
||||
**방안 B - 명확 분리**:
|
||||
```typescript
|
||||
// SplitPanelDataContext.tsx → 데이터 전달용
|
||||
// SplitPanelResizeContext.tsx → 리사이즈용
|
||||
```
|
||||
|
||||
**효과**: import 혼동 제거
|
||||
**소요 예상**: 2~3일
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: 장기 개선 (8주+) - 아키텍처 전환
|
||||
|
||||
#### 4-1. 거대 컴포넌트 분할
|
||||
|
||||
| 대상 파일 | 현재 줄 수 | 분할 목표 |
|
||||
|-----------|-----------|-----------|
|
||||
| `v2-table-list/TableListComponent.tsx` | 6,867줄 | 훅 분리, 렌더링 분리 → 각 1,000줄 이하 |
|
||||
| `ScreenDesigner.tsx` | 7,559줄 | 패널별 분리 → 각 1,500줄 이하 |
|
||||
| `EditModal.tsx` | 1,648줄 | 저장/폼/UI 분리 → 각 500줄 이하 |
|
||||
| `ButtonPrimaryComponent.tsx` | 1,524줄 | 액션 실행 분리 → 각 500줄 이하 |
|
||||
|
||||
#### 4-2. Config 스키마 검증 (Zod)
|
||||
|
||||
```typescript
|
||||
// v2-button-primary/types.ts
|
||||
import { z } from "zod";
|
||||
|
||||
export const ButtonPrimaryConfigSchema = z.object({
|
||||
text: z.string().default("버튼"),
|
||||
variant: z.enum(["default", "destructive", "outline", "secondary", "ghost"]).default("default"),
|
||||
action: z.object({
|
||||
type: z.enum(["save", "delete", "navigate", "custom"]),
|
||||
targetTable: z.string().optional(),
|
||||
// ...
|
||||
}),
|
||||
});
|
||||
|
||||
export type ButtonPrimaryConfig = z.infer<typeof ButtonPrimaryConfigSchema>;
|
||||
```
|
||||
|
||||
`createComponentDefinition()`에서 스키마 검증을 강제하여 잘못된 config가 등록 시점에 차단되도록 한다.
|
||||
|
||||
---
|
||||
|
||||
## 7. 우선순위 로드맵
|
||||
|
||||
### 즉시 (이번 주)
|
||||
|
||||
- [ ] **1-1**: 이벤트 이름 상수 파일 생성 (`frontend/lib/constants/events.ts`)
|
||||
- [ ] **1-2**: window 전역 변수 타입 선언 (`frontend/types/global.d.ts`)
|
||||
|
||||
### 단기 (1~2주)
|
||||
|
||||
- [ ] **2-3**: window 전역 상태 → Zustand 스토어 전환
|
||||
- [ ] **1-3**: ComponentConfig `any` → `unknown` 점진적 적용
|
||||
|
||||
### 중기 (2~4주)
|
||||
|
||||
- [ ] **2-1**: buttonActions.ts 분할 (7,609줄 → 도메인별)
|
||||
- [ ] **2-2**: 이벤트 시스템 통일 (v2EventBus 기반)
|
||||
- [ ] **3-4**: SplitPanelContext 통합/분리
|
||||
|
||||
### 장기 (4~8주)
|
||||
|
||||
- [ ] **3-1**: 레거시 컴포넌트 13쌍 제거
|
||||
- [ ] **3-2**: 컴포넌트 스캐폴딩 CLI
|
||||
- [ ] **3-3**: 핵심 플로우 통합 테스트
|
||||
- [ ] **4-1**: 거대 컴포넌트 분할
|
||||
- [ ] **4-2**: Config 스키마 Zod 검증
|
||||
|
||||
---
|
||||
|
||||
## 부록: 수치 요약
|
||||
|
||||
| 지표 | 현재 | 목표 |
|
||||
|------|------|------|
|
||||
| 최대 파일 크기 | 7,609줄 | 1,500줄 이하 |
|
||||
| 컴포넌트 수 | 81개 (13쌍 중복) | ~55개 (중복 제거) |
|
||||
| window 전역 변수 | 5개 | 0개 |
|
||||
| 이벤트 시스템 | 3개 공존 | 1개 (v2EventBus) |
|
||||
| 테스트 파일 | 1개 | 핵심 플로우 최소 10개 |
|
||||
| `any` 타입 사용 (핵심 인터페이스) | 3곳 | 0곳 |
|
||||
| SplitPanelContext 중복 | 2개 | 1개 (또는 명확 분리) |
|
||||
@@ -0,0 +1,361 @@
|
||||
# Agent Pipeline 한계점 분석
|
||||
|
||||
> 결재 시스템 같은 대규모 크로스도메인 프로젝트에서 현재 파이프라인이 왜 제대로 동작할 수 없는가
|
||||
|
||||
---
|
||||
|
||||
## 1. 에이전트 컨텍스트 격리 문제
|
||||
|
||||
### 현상
|
||||
`executor.ts`의 `spawnAgent()`는 매번 새로운 Cursor Agent CLI 프로세스를 생성한다. 각 에이전트는 `systemPrompt + taskDescription + fileContext`만 받고, 이전 대화/결정/아키텍처 논의는 전혀 알지 못한다.
|
||||
|
||||
```typescript
|
||||
// executor.ts:64-118
|
||||
function spawnAgent(agentType, prompt, model, workspacePath, timeoutMs) {
|
||||
const child = spawn(agentPath, ['--model', model, '--print', '--trust'], {
|
||||
cwd: workspacePath,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
child.stdin.write(prompt); // 이게 에이전트가 받는 전부
|
||||
child.stdin.end();
|
||||
}
|
||||
```
|
||||
|
||||
### 문제 본질
|
||||
- 에이전트는 **"왜 이렇게 만들어야 하는지"** 모른다. 단지 task description에 적힌 대로 만든다
|
||||
- 결재 시스템의 **설계 의도** (한국 기업 결재 문화, 자기결재/상신결재/합의결재/대결/후결)는 task description에 다 담을 수 없다
|
||||
- PM과 사용자 사이에 오간 **아키텍처 논의** (이벤트 훅 시스템, 제어관리 연동, 엔티티 조인으로 결재 상태 표시) 같은 결정 사항이 전달되지 않는다
|
||||
|
||||
### 결재 시스템에서의 구체적 영향
|
||||
- "ApprovalRequestModal에 결재 유형 선택을 추가해라"라고 지시하면, 에이전트는 기존 모달 코드를 읽겠지만, **왜 그 UI가 그렇게 생겼는지, 다른 패널(TableListConfigPanel)의 Combobox 패턴을 왜 따라야 하는지** 모른다
|
||||
- 실제로 이 대화에서 Combobox UI가 4번 수정됐다. 매번 "다른 패널 참고해서 만들라"고 해도 패턴을 정확히 못 따라했다
|
||||
|
||||
---
|
||||
|
||||
## 2. 파일 컨텍스트 3000자 절삭
|
||||
|
||||
### 현상
|
||||
```typescript
|
||||
// executor.ts:124-138
|
||||
async function readFileContexts(files, workspacePath) {
|
||||
for (const file of files) {
|
||||
const content = await readFile(fullPath, 'utf-8');
|
||||
contents.push(`--- ${file} ---\n${content.substring(0, 3000)}`); // 3000자 잘림
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 문제 본질
|
||||
주요 파일들의 실제 크기:
|
||||
- `approvalController.ts`: ~800줄 (3000자로는 약 100줄, 12.5%만 보인다)
|
||||
- `improvedButtonActionExecutor.ts`: ~1500줄
|
||||
- `ButtonConfigPanel.tsx`: ~600줄
|
||||
- `ApprovalStepConfigPanel.tsx`: ~300줄
|
||||
|
||||
에이전트가 수정해야 할 파일의 **전체 구조를 이해할 수 없다**. 앞부분만 보고 import 구문이나 초기 코드만 파악하고, 실제 수정 지점에 도달하지 못한다.
|
||||
|
||||
### 결재 시스템에서의 구체적 영향
|
||||
- `approvalController.ts`를 수정하려면 기존 함수 구조, DB 쿼리 패턴, 에러 처리 방식, 멀티테넌시 적용 패턴을 전부 알아야 한다. 3000자로는 불가능
|
||||
- `improvedButtonActionExecutor.ts`의 제어관리 연동 패턴을 이해하려면 파일 전체를 봐야 한다
|
||||
- V2 컴포넌트 표준을 따르려면 기존 컴포넌트(`v2-table-list/` 등)의 전체 구조를 참고해야 한다
|
||||
|
||||
---
|
||||
|
||||
## 3. 에이전트 간 실시간 소통 부재
|
||||
|
||||
### 현상
|
||||
병렬 실행 시 에이전트들은 **서로의 작업 결과를 실시간으로 공유하지 못한다**:
|
||||
|
||||
```typescript
|
||||
// executor.ts:442-454
|
||||
if (state.config.parallel) {
|
||||
const promises = readyTasks.map(async (task, index) => {
|
||||
if (index > 0) await sleep(index * STAGGER_DELAY); // 500ms 딜레이뿐
|
||||
return executeAndTrack(task);
|
||||
});
|
||||
await Promise.all(promises); // 완료까지 기다린 후 PM이 리뷰
|
||||
}
|
||||
```
|
||||
|
||||
PM 에이전트가 라운드 후에 리뷰하지만, 이것도 **round-N.md의 텍스트 기반 리뷰**일 뿐이다.
|
||||
|
||||
### 문제 본질
|
||||
- DB 에이전트가 스키마를 변경하면, Backend 에이전트가 그 결과를 **같은 라운드에서 즉시 반영할 수 없다**
|
||||
- Frontend 에이전트가 "이 API 응답 구조 좀 바꿔줘"라고 Backend에 요청할 수 없다
|
||||
- 협업 모드(`CollabMessage`)가 존재하지만, 이것도 **라운드 단위의 비동기 메시지**이지 실시간 대화가 아니다
|
||||
|
||||
### 결재 시스템에서의 구체적 영향
|
||||
- DB가 `approval_proxy_settings` 테이블을 만들고, Backend가 대결 API를 만들고, Frontend가 대결 설정 UI를 만드는 과정이 **최소 3라운드**가 필요하다 (각 의존성 해소를 위해)
|
||||
- 실제로는 Backend가 DB 스키마를 보고 쿼리를 짜는 과정에서 "이 컬럼 타입이 좀 다른 것 같은데"라는 이슈가 생기면, 즉시 수정 불가하고 다음 라운드로 넘어간다
|
||||
- 라운드당 에이전트 호출 1~3분 + PM 리뷰 1~2분 = **라운드당 최소 3~5분**. 8개 phase를 3라운드씩 = **최소 72~120분 (1~2시간)**
|
||||
|
||||
---
|
||||
|
||||
## 4. 시스템 프롬프트의 한계 (프로젝트 특수 패턴 부재)
|
||||
|
||||
### 현상
|
||||
`prompts.ts`의 시스템 프롬프트는 **범용적**이다:
|
||||
|
||||
```typescript
|
||||
// prompts.ts:75-118
|
||||
export const BACKEND_PROMPT = `
|
||||
# Role
|
||||
You are a Backend specialist for ERP-node project.
|
||||
Stack: Node.js + Express + TypeScript + PostgreSQL Raw Query.
|
||||
// ... 멀티테넌시, 기본 코드 패턴만 포함
|
||||
`;
|
||||
```
|
||||
|
||||
### 프로젝트 특수 패턴 중 프롬프트에 없는 것들
|
||||
|
||||
| 필수 패턴 | 프롬프트 포함 여부 | 영향 |
|
||||
|-----------|:------------------:|------|
|
||||
| V2 컴포넌트 레지스트리 (`createComponentDefinition`, `AutoRegisteringComponentRenderer`) | 프론트엔드 프롬프트에 기본 구조만 | 컴포넌트 등록 방식 오류 가능 |
|
||||
| ConfigPanelBuilder / ConfigSection | 언급만 | 직접 JSX로 패널 만드는 실수 반복 |
|
||||
| Combobox UI 패턴 (Popover + Command) | 없음 | 실제로 4번 재수정 필요했음 |
|
||||
| 엔티티 조인 시스템 | 없음 | 결재 상태를 대상 테이블에 표시하는 핵심 기능 구현 불가 |
|
||||
| 제어관리(Node Flow) 연동 | 없음 | 결재 후 자동 액션 트리거 구현 불가 |
|
||||
| ButtonActionExecutor 패턴 | 없음 | 결재 버튼 액션 구현 시 기존 패턴 미준수 |
|
||||
| apiClient 사용법 (frontend/lib/api/) | 간략한 언급 | fetch 직접 사용 가능성 |
|
||||
| CustomEvent 기반 모달 오픈 | 없음 | approval-modal 열기 방식 이해 불가 |
|
||||
| 화면 디자이너 컨텍스트 | 없음 | screenTableName 같은 설계 시 컨텍스트 활용 불가 |
|
||||
|
||||
### 결재 시스템에서의 구체적 영향
|
||||
- **이벤트 훅 시스템**을 만들려면 기존 `NodeFlowExecutionService`의 실행 패턴, 액션 타입 enum, 입력/출력 구조를 알아야 하는데, 프롬프트에 전혀 없다
|
||||
- **엔티티 조인으로 결재 상태 표시**하려면 기존 엔티티 조인 시스템이 어떻게 작동하는지(reverse lookup, join config) 알아야 하는데, 에이전트가 이 시스템 자체를 모른다
|
||||
|
||||
---
|
||||
|
||||
## 5. 단일 패스 실행 + 재시도의 비효율
|
||||
|
||||
### 현상
|
||||
```typescript
|
||||
// executor.ts:240-288
|
||||
async function executeTaskWithRetry(task, state) {
|
||||
while (task.attempts < task.maxRetries) {
|
||||
const result = await executeTaskOnce(task, state, retryContext);
|
||||
task.attempts++;
|
||||
if (result.success) break;
|
||||
// 검증 실패 → retryContext에 에러 메시지만 전달
|
||||
retryContext = failResult.retryContext || `이전 시도 실패: ${result.agentOutput.substring(0, 500)}`;
|
||||
await sleep(2000);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 문제 본질
|
||||
- 재시도 시 에이전트가 받는 건 **이전 에러 메시지 500자**뿐이다
|
||||
- "Combobox 패턴 대신 Select 박스를 썼다" 같은 **UI/UX 품질 문제**는 L1~L6 검증으로 잡을 수 없다 (빌드는 통과하니까)
|
||||
- 사용자의 실시간 피드백("이거 다른 패널이랑 UI가 다른데?")을 반영할 수 없다
|
||||
|
||||
### 검증 피라미드(L1~L6)가 못 잡는 것들
|
||||
|
||||
| 검증 레벨 | 잡을 수 있는 것 | 못 잡는 것 |
|
||||
|-----------|----------------|-----------|
|
||||
| L1 (TS 빌드) | 타입 에러, import 오류 | 로직 오류, 패턴 미준수 |
|
||||
| L2 (앱 빌드) | Next.js 빌드 에러 | 런타임 에러 |
|
||||
| L3 (API 호출) | 엔드포인트 존재 여부, 기본 응답 | 복잡한 비즈니스 로직 (다단계 결재 플로우) |
|
||||
| L4 (DB 검증) | 테이블 존재, 기본 CRUD | 결재 상태 전이 로직, 병렬 결재 집계 |
|
||||
| L5 (브라우저 E2E) | 화면 렌더링, 기본 클릭 | 결재 모달 Combobox UX, 대결 설정 UI 일관성 |
|
||||
| L6 (커스텀) | 명시적 조건 | 비명시적 품질 요구사항 |
|
||||
|
||||
### 결재 시스템에서의 구체적 영향
|
||||
- "자기결재 시 즉시 approved로 처리"가 올바르게 동작하는지 L3/L4로 검증 가능하지만, **"자기결재 선택 시 결재자 선택 UI가 숨겨지고 즉시 처리된다"는 UX**는 L5 자연어로는 불충분
|
||||
- "합의결재(병렬)에서 3명 중 2명 승인 + 1명 반려 시 전체 반려" 같은 **엣지 케이스 비즈니스 로직**은 자동 검증이 어렵다
|
||||
- 결재 완료 후 이벤트 훅 → Node Flow 실행 → 이메일 발송 같은 **체이닝된 비동기 로직**은 E2E로 검증 불가
|
||||
|
||||
---
|
||||
|
||||
## 6. 태스크 분할의 구조적 한계
|
||||
|
||||
### 현상: 파이프라인이 잘 되는 경우
|
||||
```
|
||||
[DB 테이블 생성] → [Backend CRUD API] → [Frontend 화면] → [UI 개선]
|
||||
```
|
||||
각 태스크가 **독립적**이고, 새 파일을 만들고, 의존성이 단방향이다.
|
||||
|
||||
### 현상: 파이프라인이 안 되는 경우 (결재 시스템)
|
||||
```
|
||||
[DB 스키마 변경]
|
||||
↓ ↘
|
||||
[Controller 수정] [새 API 추가] ← 기존 코드 500줄 이해 필요
|
||||
↓ ↓ ↑
|
||||
[모달 수정] [새 화면] ← 기존 UI 패턴 준수 필요 + 엔티티 조인 시스템 이해
|
||||
↓
|
||||
[V2 컴포넌트 수정] ← 레지스트리 시스템 + ConfigPanelBuilder 패턴 이해
|
||||
↓
|
||||
[이벤트 훅 시스템] ← NodeFlowExecutionService 전체 이해 + 새 시스템 설계
|
||||
↓
|
||||
[엔티티 조인 등록] ← 기존 엔티티 조인 시스템 전체 이해
|
||||
```
|
||||
|
||||
### 문제 본질
|
||||
- **기존 파일 수정**이 대부분이다. 새 파일 생성이 아니라 기존 코드에 기능을 끼워넣어야 한다
|
||||
- **패턴 준수**가 필수다. "돌아가기만 하면" 안 되고, 기존 시스템과 **일관된 방식**으로 구현해야 한다
|
||||
- **설계 결정**이 코드 작성보다 중요하다. "이벤트 훅을 어떻게 설계할까?"는 에이전트가 task description만 보고 결정할 수 없다
|
||||
|
||||
---
|
||||
|
||||
## 7. PM 에이전트의 역할 한계
|
||||
|
||||
### 현상
|
||||
```typescript
|
||||
// pm-agent.ts:21-70
|
||||
const PM_SYSTEM_PROMPT = `
|
||||
# 판단 기준
|
||||
- 빌드만 통과하면 "complete" 아니다 -- 기능이 실제로 동작해야 "complete"
|
||||
- 같은 에러 2회 반복 -> instruction에 구체적 해결책 제시
|
||||
- 같은 에러 3회 반복 -> "fail" 판정
|
||||
`;
|
||||
```
|
||||
|
||||
PM은 `round-N.md`(에이전트 응답 + git diff + 테스트 결과)와 `progress.md`만 보고 판단한다.
|
||||
|
||||
### PM이 할 수 없는 것
|
||||
|
||||
| 역할 | PM 가능 여부 | 이유 |
|
||||
|------|:----------:|------|
|
||||
| 빌드 실패 원인 파악 | 가능 | 에러 로그가 round-N.md에 있음 |
|
||||
| 비즈니스 로직 검증 | 불가 | 실제 코드를 읽지 않고 git diff만 봄 |
|
||||
| UI/UX 품질 판단 | 불가 | 스크린샷 없음, 렌더링 결과 못 봄 |
|
||||
| 아키텍처 일관성 검증 | 불가 | 전체 시스템 구조를 모름 |
|
||||
| 기존 패턴 준수 여부 | 불가 | 기존 코드를 참조하지 않음 |
|
||||
| 사용자 의도 반영 여부 | 불가 | 사용자와 대화 맥락 없음 |
|
||||
|
||||
### 결재 시스템에서의 구체적 영향
|
||||
- PM이 "Backend task 성공, Frontend task 실패"라고 판정할 수는 있지만, **"Backend가 만든 API 응답 구조가 Frontend가 기대하는 것과 다르다"**를 파악할 수 없다
|
||||
- "이 모달의 Combobox가 다른 패널과 UI가 다르다"는 사용자만 판단 가능
|
||||
- "이벤트 훅 시스템의 트리거 타이밍이 잘못됐다"는 전체 아키텍처를 이해해야 판단 가능
|
||||
|
||||
---
|
||||
|
||||
## 8. 안전성 리스크
|
||||
|
||||
### 역사적 사고
|
||||
> "과거 에이전트가 범위 밖 파일 50000줄 삭제하여 2800+ TS 에러 발생"
|
||||
> — user rules
|
||||
|
||||
### 결재 시스템의 리스크
|
||||
수정 대상 파일이 **시스템 핵심 파일**들이다:
|
||||
|
||||
| 파일 | 리스크 |
|
||||
|------|--------|
|
||||
| `improvedButtonActionExecutor.ts` (~1500줄) | 모든 버튼 동작의 핵심. 잘못 건드리면 시스템 전체 버튼 동작 불능 |
|
||||
| `approvalController.ts` (~800줄) | 기존 결재 API 깨질 수 있음 |
|
||||
| `ButtonConfigPanel.tsx` (~600줄) | 화면 디자이너 설정 패널 전체에 영향 |
|
||||
| `v2-approval-step/` (5개 파일) | V2 컴포넌트 레지스트리 손상 가능 |
|
||||
| `AppLayout.tsx` | 전체 레이아웃 메뉴 깨질 수 있음 |
|
||||
| `UserDropdown.tsx` | 사용자 프로필 메뉴 깨질 수 있음 |
|
||||
|
||||
`files` 필드로 범위를 제한하더라도, **에이전트가 `--trust` 모드로 실행**되기 때문에 실제로는 모든 파일에 접근 가능하다:
|
||||
|
||||
```typescript
|
||||
// executor.ts:78
|
||||
const child = spawn(agentPath, ['--model', model, '--print', '--trust'], {
|
||||
```
|
||||
|
||||
code-guard가 일부 보호하지만, **구조적 파괴(잘못된 import 삭제, 함수 시그니처 변경)는 코드 가드가 감지 불가**하다.
|
||||
|
||||
---
|
||||
|
||||
## 9. 종합: 파이프라인이 적합한 경우 vs 부적합한 경우
|
||||
|
||||
### 적합한 경우 (현재 파이프라인)
|
||||
|
||||
| 특성 | 예시 |
|
||||
|------|------|
|
||||
| 새 파일 생성 위주 | 새 CRUD 화면 만들기 |
|
||||
| 독립적 태스크 | 테이블 → API → 화면 순차 |
|
||||
| 패턴이 단순/반복적 | 표준 CRUD, 표준 Form |
|
||||
| 검증이 명확 | 빌드 + API 호출 + 브라우저 기본 확인 |
|
||||
| 컨텍스트 최소 | 기존 시스템 이해 불필요 |
|
||||
|
||||
### 부적합한 경우 (결재 시스템)
|
||||
|
||||
| 특성 | 결재 시스템 해당 여부 |
|
||||
|------|:-------------------:|
|
||||
| 기존 파일 대규모 수정 | 해당 (10+ 파일 수정) |
|
||||
| 크로스도메인 의존성 | 해당 (DB ↔ BE ↔ FE ↔ 기존 시스템) |
|
||||
| 복잡한 비즈니스 로직 | 해당 (5가지 결재 유형, 상태 전이, 이벤트 훅) |
|
||||
| 기존 시스템 깊은 이해 필요 | 해당 (제어관리, 엔티티 조인, 컴포넌트 레지스트리) |
|
||||
| UI/UX 일관성 필수 | 해당 (Combobox, 모달, 설정 패널 패턴 통일) |
|
||||
| 설계 결정이 선행 필요 | 해당 (이벤트 훅 아키텍처, 결재 타입 상태 머신) |
|
||||
| 사용자 피드백 반복 필요 | 해당 (실제로 4회 UI 수정 반복) |
|
||||
|
||||
---
|
||||
|
||||
## 10. 개선 방향 제안
|
||||
|
||||
현재 파이프라인을 결재 시스템 같은 대규모 프로젝트에서 사용하려면 다음이 필요하다:
|
||||
|
||||
### 10.1 컨텍스트 전달 강화
|
||||
- **프로젝트 컨텍스트 파일**: `.cursor/rules/` 수준의 프로젝트 규칙을 에이전트 프롬프트에 동적 주입
|
||||
- **아키텍처 결정 기록**: PM-사용자 간 논의된 설계 결정을 구조화된 형태로 에이전트에 전달
|
||||
- **패턴 레퍼런스 파일**: "이 파일을 참고해서 만들어라"를 task description이 아닌 시스템 차원에서 지원
|
||||
|
||||
### 10.2 파일 컨텍스트 확대
|
||||
- 3000자 절삭 → **전체 파일 전달** 또는 최소 10000자 이상
|
||||
- 관련 파일 자동 탐지 (import 그래프 기반)
|
||||
- 참고 파일(reference files)과 수정 파일(target files) 구분
|
||||
|
||||
### 10.3 에이전트 간 소통 채널
|
||||
- 라운드 내에서도 에이전트 간 **중간 결과 공유** 가능
|
||||
- "Backend가 API 스펙을 먼저 정의 → Frontend가 그 스펙 기반으로 구현" 같은 **단계적 소통**
|
||||
- 질문-응답 프로토콜 (현재 CollabMessage가 있지만 실질적으로 사용 안 됨)
|
||||
|
||||
### 10.4 PM 에이전트 강화
|
||||
- **코드 리뷰 기능**: git diff만 보지 말고 실제 파일을 읽어서 패턴 준수 여부 확인
|
||||
- **아키텍처 검증**: 전체 시스템 구조와의 일관성 검증
|
||||
- **사용자 피드백 루프**: PM이 사용자에게 "이 부분 확인 필요합니다" 알림 가능
|
||||
|
||||
### 10.5 검증 시스템 확장
|
||||
- **비즈니스 로직 검증**: 상태 전이 테스트 (결재 플로우 시나리오 자동 실행)
|
||||
- **UI 일관성 검증**: 스크린샷 비교, 컴포넌트 패턴 분석
|
||||
- **통합 테스트**: 단일 API 호출이 아닌 시나리오 기반 E2E
|
||||
|
||||
### 10.6 안전성 강화
|
||||
- `--trust` 모드 대신 **파일 범위 제한된 실행 모드**
|
||||
- 라운드별 git diff 자동 리뷰 (의도치 않은 파일 변경 감지)
|
||||
- 롤백 자동화 (검증 실패 시 자동 `git checkout`)
|
||||
|
||||
---
|
||||
|
||||
## 부록: 결재 시스템 파이프라인 실행 시 예상 시나리오
|
||||
|
||||
### 시도할 경우 예상되는 실패 패턴
|
||||
|
||||
```
|
||||
Round 1: DB 마이그레이션 (task-1)
|
||||
→ 성공 가능 (신규 파일 생성이므로)
|
||||
|
||||
Round 2: Backend Controller 수정 (task-2)
|
||||
→ approvalController.ts 3000자만 보고 수정 시도
|
||||
→ 기존 함수 구조 파악 실패
|
||||
→ L1 빌드 에러 (import 누락, 타입 불일치)
|
||||
→ 재시도 1: 에러 메시지 보고 고치지만, 기존 패턴과 다른 방식으로 구현
|
||||
→ L3 API 테스트 통과 (기능은 동작)
|
||||
→ 하지만 코드 품질/패턴 불일치 (PM이 감지 불가)
|
||||
|
||||
Round 3: Frontend 모달 수정 (task-4)
|
||||
→ 기존 ApprovalRequestModal 3000자만 보고 수정
|
||||
→ Combobox 패턴 대신 기본 Select 사용 (다른 패널 참고 불가)
|
||||
→ L1 빌드 통과, L5 브라우저 테스트도 기본 동작 통과
|
||||
→ 하지만 UI 일관성 미달 (사용자가 보면 즉시 지적)
|
||||
|
||||
Round 4-6: 이벤트 훅 시스템 (task-7)
|
||||
→ NodeFlowExecutionService 전체 이해 필요한데 3000자만 봄
|
||||
→ 기존 시스템과 연동 불가능한 독립적 구현 생산
|
||||
→ PM이 "빌드 통과했으니 complete" 판정
|
||||
→ 실제로는 기존 제어관리와 전혀 연결 안 됨
|
||||
|
||||
최종: 8/8 task "성공" 판정
|
||||
→ 사용자가 확인: "이거 다 뜯어 고쳐야 하는데?"
|
||||
→ 파이프라인 2시간 + 사용자 수동 수정 3시간 = 5시간 낭비
|
||||
→ PM이 직접 했으면 2~3시간에 끝
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*작성일: 2026-03-03*
|
||||
*대상: Agent Pipeline v3.0 (`_local/agent-pipeline/`)*
|
||||
*맥락: 결재 시스템 v2 재설계 프로젝트 (`docs/결재시스템_구현_현황.md`)*
|
||||
Reference in New Issue
Block a user