Compare commits

...

24 Commits

Author SHA1 Message Date
syc0123 7853aeede9 Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into ycshin-node 2026-03-05 09:03:24 +09:00
syc0123 8f3231d5a1 Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into ycshin-node 2026-03-04 16:30:42 +09:00
syc0123 6ceed2acd0 feat: Implement button iconization feature for screen designer
- Added a comprehensive plan for expanding button display modes in the screen designer, allowing for text, icon, and icon+text modes.
- Introduced a new `ButtonIconRenderer` component to handle dynamic rendering of buttons based on selected display modes and actions.
- Enhanced the `ButtonConfigPanel` to include UI for selecting display modes and managing icon settings, including size, color, and position.
- Implemented functionality for custom icon addition via lucide search and SVG paste, ensuring flexibility for users.
- Updated relevant components to utilize the new button rendering logic, improving the overall user experience and visual consistency.

Made-with: Cursor
2026-03-04 16:30:05 +09:00
syc0123 a0cf9db6e8 feat: Update DropdownSelect component to display selected items based on dropdown option order
- Modified the selection display logic in the DropdownSelect component to show selected items in the order of the dropdown options, rather than the order of user selection.
- This change aims to provide a consistent and predictable user experience, reducing confusion caused by varying display orders.
- Updated the relevant documentation to reflect this new behavior and its rationale.

Made-with: Cursor
2026-03-04 11:08:42 +09:00
syc0123 ec5a980c41 feat: Add documentation for V2Select multi-select dropdown improvements
- Created a comprehensive plan document detailing the enhancements made to the V2Select multi-select dropdown, including the new display format for selected items and tooltip functionality.
- Added context notes and a checklist to track implementation progress and ensure all aspects of the feature are covered.
- Documented the rationale behind design decisions, including the need for improved user experience and visibility of selected items.

Made-with: Cursor
2026-03-04 10:12:52 +09:00
syc0123 2b324d083b feat: Improve V2Select multi-select dropdown item display
- Enhanced the display of selected items in the V2Select component to show labels in a comma-separated format, improving user visibility without needing to open the dropdown.
- Implemented tooltip functionality that activates only when the text is truncated, allowing users to see all selected items at a glance.
- Updated the DropdownSelect component to ensure consistent behavior across all screens using this component.
- Added necessary imports and state management for tooltip visibility and text truncation detection.

Made-with: Cursor
2026-03-04 10:11:48 +09:00
syc0123 cfd49020a0 feat: Implement validation error message display for required fields
- Added a new CSS class for displaying validation error messages below required input fields without affecting layout.
- Enhanced the `useDialogAutoValidation` hook to insert error messages dynamically when required fields are empty.
- Implemented logic to remove error messages when the input is cleared, improving user feedback during form validation.

Made-with: Cursor
2026-03-04 09:23:09 +09:00
syc0123 35dfe5bd79 feat: Update modal validation design and behavior
- Changed the modal validation mechanism to focus on the first empty required field and display a toast notification prompting the user to fill it.
- Removed the disabling of the save button, ensuring it remains active regardless of validation state.
- Enhanced visual feedback with a shake animation for empty fields and a red border to indicate errors.
- Updated the documentation to reflect the new validation flow and requirements.

Made-with: Cursor
2026-03-03 18:30:56 +09:00
syc0123 dfc495d32b Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into ycshin-node 2026-03-03 17:12:27 +09:00
syc0123 52c6af472d fix: Improve TabBar pointer handling and state management
- Added logic to clear the settle timer when pointer events are triggered, enhancing responsiveness during drag-and-drop interactions.
- Implemented checks to ensure pointer events are only processed if they match the current drag state, preventing unintended actions.
- Introduced a new callback for handling lost pointer capture, ensuring proper state reset and cleanup when pointer capture is lost.

Made-with: Cursor
2026-03-03 17:07:04 +09:00
syc0123 2647031ef7 feat: Enhance TabBar component with drag-and-drop functionality and drop ghost animation
- Added support for drag-and-drop functionality in the TabBar component, allowing users to reorder tabs seamlessly.
- Introduced a drop ghost feature that visually represents the target position of a dragged tab, enhancing user experience during tab reordering.
- Updated the timing for settling animations to improve responsiveness and visual feedback.
- Refactored state management to accommodate new drag-and-drop logic, ensuring smooth interactions and animations.

Made-with: Cursor
2026-03-03 16:43:56 +09:00
syc0123 7989305963 Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into ycshin-node 2026-03-03 14:55:11 +09:00
syc0123 eb2bd8f10f feat: Enhance modal button behavior and validation feedback
- Updated modal button handling to disable all buttons by default, with exceptions for specific button types (e.g., cancel, close, delete).
- Introduced a new validation mechanism that visually indicates empty required fields with red borders and error messages after a delay.
- Improved the `useDialogAutoValidation` hook to manage button states based on field validation, ensuring a smoother user experience.
- Added CSS animations to prevent flickering during validation state changes.

Made-with: Cursor
2026-03-03 14:54:41 +09:00
syc0123 dca89a698f Merge remote-tracking branch 'origin/ycshin-node' into ycshin-node
Resolve conflict in InteractiveScreenViewerDynamic.tsx:
- Keep horizontal label code (fd5c61b side)
- Remove old inline required field validation (replaced by useDialogAutoValidation hook)
- Clean up checkAllRequiredFieldsFilled usage from SaveModal, ButtonPrimaryComponent
- Remove isFieldEmpty, isInputComponent, checkAllRequiredFieldsFilled from formValidation.ts

Made-with: Cursor
2026-03-03 13:12:48 +09:00
syc0123 aa020bfdd8 feat: Implement automatic validation for modal forms
- Introduced a new hook `useDialogAutoValidation` to handle automatic validation of required fields in modals.
- Added visual feedback for empty required fields, including red borders and error messages.
- Disabled action buttons when required fields are not filled, enhancing user experience.
- Updated `DialogContent` to integrate the new validation logic, ensuring that only user mode modals are validated.

Made-with: Cursor
2026-03-03 12:07:12 +09:00
syc0123 eb471d087f refactor: Update TabBar component for improved styling and functionality
- Replaced `RefreshCw` icon with `RotateCw` for better visual representation of refresh action.
- Adjusted tab height and padding for a more compact design.
- Updated text sizes for tab titles and buttons to enhance readability.
- Improved button sizes and hover effects for a more consistent user experience.
- Enhanced layout structure to ensure proper alignment and spacing of elements.

Made-with: Cursor
2026-03-03 10:23:07 +09:00
syc0123 83437e76dd feat: Enhance form validation and modal handling in various components
- Added `isInModal` prop to `ScreenModal` and `InteractiveScreenViewerDynamic` for improved modal context awareness.
- Implemented `isFieldEmpty` and `checkAllRequiredFieldsFilled` utility functions to validate required fields in forms.
- Updated `SaveModal` and `ButtonPrimaryComponent` to disable save actions when required fields are missing, enhancing user feedback.
- Introduced error messages for required fields in modals to guide users in completing necessary inputs.

Made-with: Cursor
2026-02-27 18:11:59 +09:00
syc0123 dc04bd162a refactor: Enhance modal and tab handling in ScreenModal and TabContent components
- Removed unnecessary variable `isTabActive` in ScreenModal for cleaner state management.
- Updated `useEffect` dependencies to include `tabId` for accurate modal behavior.
- Improved tab content caching logic to ensure scroll positions and form states are correctly saved and restored.
- Enhanced dialog handling to prevent unintended closures when tabs are inactive, ensuring a smoother user experience.

Made-with: Cursor
2026-02-27 16:01:23 +09:00
syc0123 a0e3147b47 Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into ycshin-node 2026-02-27 14:26:16 +09:00
syc0123 d04dc4c050 feat: Add Zustand for state management and enhance modal handling
- Integrated Zustand for improved state management across components.
- Updated modal components to handle visibility based on active tabs, ensuring better user experience.
- Refactored various components to utilize the new tab store for managing active tab states.
- Enhanced number formatting utility to streamline number and currency display across the application.

Made-with: Cursor
2026-02-27 14:25:53 +09:00
syc0123 7acdd852a5 feat: F5 새로고침 시 다중 스크롤 영역 위치 저장/복원 지원
split panel 등 여러 스크롤 영역이 있는 화면에서 F5 새로고침 시
우측 패널 스크롤 위치가 복원되지 않던 문제 해결.

- DOM 경로 기반 다중 스크롤 위치 캡처/복원 (ScrollSnapshot)
- 실시간 스크롤 추적을 요소별 Map으로 전환
- 미사용 레거시 단일 스크롤 함수 제거 (약 130줄 정리)

Made-with: Cursor
2026-02-27 14:21:15 +09:00
syc0123 3db8a8a276 Merge remote-tracking branch 'origin/jskim-node' into ycshin-node
Made-with: Cursor

# Conflicts:
#	frontend/lib/registry/components/index.ts
2026-02-26 18:19:48 +09:00
syc0123 6d40c3ea1c Merge ycshin-node with gbpark-node: resolve component index conflict
- 양쪽 브랜치의 V2 컴포넌트 import를 모두 포함하여 충돌 해결
- gbpark-node: v2-split-line, v2-bom-tree, v2-bom-item-editor
- ycshin-node: v2-process-work-standard

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 12:45:34 +09:00
syc0123 5976e96ed8 Merge branch 'gbpark-node' of http://39.117.244.52:3000/kjs/ERP-node into ycshin-node 2026-02-24 12:43:09 +09:00
54 changed files with 5947 additions and 367 deletions

View File

@ -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 정화

View File

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

View File

@ -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 | 프리셋 라벨 한글화 (작게/보통/크게/매우 크게), 라벨 "아이콘 크기 비율"로 변경 |

View File

@ -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 컴포넌트 사용으로 프로젝트 일관성 유지

View File

@ -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(", ")`로 결합하면 됨

View File

@ -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 | 선택 항목 표시 순서를 옵션 순서 기준으로 변경 |

View File

@ -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` | 관리자 페이지 레지스트리 |

View File

@ -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 지연) | 낮음 (클릭 이벤트 하나) |

View File

@ -29,17 +29,24 @@ import { evaluateConditional } from "@/lib/utils/conditionalEvaluator"; // 조
import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext"; // 화면 다국어
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; // V2 Zod 기반 변환
import { useScheduleGenerator, ScheduleConfirmDialog } from "@/lib/v2-core/services/ScheduleGeneratorService"; // 스케줄 자동 생성
import { useTabId } from "@/contexts/TabIdContext";
import { useTabStore } from "@/stores/tabStore";
function ScreenViewPage() {
export interface ScreenViewPageProps {
screenIdProp?: number;
menuObjidProp?: number;
}
function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {}) {
// 스케줄 자동 생성 서비스 활성화
const { showConfirmDialog, previewResult, handleConfirm, closeDialog, isLoading: scheduleLoading } = useScheduleGenerator();
const params = useParams();
const searchParams = useSearchParams();
const router = useRouter();
const screenId = parseInt(params.screenId as string);
const screenId = screenIdProp ?? parseInt(params.screenId as string);
// URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프)
const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined;
// props 우선, 없으면 URL 쿼리에서 menuObjid 가져오기
const menuObjid = menuObjidProp ?? (searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined);
// URL 쿼리에서 프리뷰용 company_code 가져오기
const previewCompanyCode = searchParams.get("company_code");
@ -125,10 +132,13 @@ function ScreenViewPage() {
initComponents();
}, []);
// 편집 모달 이벤트 리스너 등록
// 편집 모달 이벤트 리스너 등록 (활성 탭에서만 처리)
const tabId = useTabId();
useEffect(() => {
const handleOpenEditModal = (event: CustomEvent) => {
// console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail);
const state = useTabStore.getState();
const currentActiveTabId = state[state.mode].activeTabId;
if (tabId && tabId !== currentActiveTabId) return;
setEditModalConfig({
screenId: event.detail.screenId,
@ -148,7 +158,7 @@ function ScreenViewPage() {
// @ts-expect-error - CustomEvent type
window.removeEventListener("openEditModal", handleOpenEditModal);
};
}, []);
}, [tabId]);
useEffect(() => {
const loadScreen = async () => {
@ -1344,16 +1354,17 @@ function ScreenViewPage() {
}
// 실제 컴포넌트를 Provider로 감싸기
function ScreenViewPageWrapper() {
function ScreenViewPageWrapper({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {}) {
return (
<TableSearchWidgetHeightProvider>
<ScreenContextProvider>
<SplitPanelProvider>
<ScreenViewPage />
<ScreenViewPage screenIdProp={screenIdProp} menuObjidProp={menuObjidProp} />
</SplitPanelProvider>
</ScreenContextProvider>
</TableSearchWidgetHeightProvider>
);
}
export { ScreenViewPageWrapper };
export default ScreenViewPageWrapper;

View File

@ -424,4 +424,38 @@ select {
}
}
/* ===== 모달 필수 입력 검증 ===== */
@keyframes validationShake {
0%, 100% { transform: translateX(0); }
20%, 60% { transform: translateX(-4px); }
40%, 80% { transform: translateX(4px); }
}
/* 흔들림 애니메이션 (일회성) */
[data-validation-highlight] {
animation: validationShake 400ms ease-in-out;
}
/* 빨간 테두리 (값 입력 전까지 유지) */
[data-validation-error] {
border-color: hsl(var(--destructive)) !important;
}
/* 필수 입력 경고 문구 (입력 필드 아래, 레이아웃 영향 없음) */
.validation-error-msg-wrapper {
height: 0;
overflow: visible;
position: relative;
}
.validation-error-msg-wrapper > p {
position: absolute;
top: 1px;
left: 0;
font-size: 11px;
color: hsl(var(--destructive));
white-space: nowrap;
pointer-events: none;
}
/* ===== End of Global Styles ===== */

View File

@ -4,7 +4,7 @@ import "./globals.css";
import { QueryProvider } from "@/providers/QueryProvider";
import { RegistryProvider } from "./registry-provider";
import { Toaster } from "sonner";
import ScreenModal from "@/components/common/ScreenModal";
const inter = Inter({
subsets: ["latin"],
@ -45,7 +45,6 @@ export default function RootLayout({
<QueryProvider>
<RegistryProvider>{children}</RegistryProvider>
<Toaster position="top-right" />
<ScreenModal />
</QueryProvider>
{/* Portal 컨테이너 */}
<div id="portal-root" data-radix-portal="true" />

View File

@ -27,6 +27,8 @@ import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
import { ScreenContextProvider } from "@/contexts/ScreenContext";
import { useTabStore } from "@/stores/tabStore";
import { useTabId } from "@/contexts/TabIdContext";
interface ScreenModalState {
isOpen: boolean;
@ -43,6 +45,8 @@ interface ScreenModalProps {
export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
const { userId, userName, user } = useAuth();
const splitPanelContext = useSplitPanelContext();
const tabId = useTabId();
const activeTabId = useTabStore((s) => s[s.mode].activeTabId);
const [modalState, setModalState] = useState<ScreenModalState>({
isOpen: false,
@ -170,6 +174,11 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 전역 모달 이벤트 리스너
useEffect(() => {
const handleOpenModal = (event: CustomEvent) => {
// 활성 탭에서만 이벤트 처리 (다른 탭의 ScreenModal 인스턴스는 무시)
const storeState = useTabStore.getState();
const currentActiveTabId = storeState[storeState.mode].activeTabId;
if (tabId && tabId !== currentActiveTabId) return;
const {
screenId,
title,
@ -191,7 +200,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
isCreateMode,
});
// 🆕 모달 열린 시간 기록
// 모달 열린 시간 기록
modalOpenedAtRef.current = Date.now();
// 폼 변경 추적 초기화
@ -443,7 +452,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
window.removeEventListener("closeSaveModal", handleCloseModal);
window.removeEventListener("saveSuccessInModal", handleSaveSuccess);
};
}, [continuousMode]); // continuousMode 의존성 추가
}, [tabId, continuousMode]);
// 화면 데이터 로딩
useEffect(() => {
@ -852,7 +861,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
} else {
handleCloseInternal();
}
}, []);
}, [tabId]);
// 확인 후 실제로 모달을 닫는 함수
const handleConfirmClose = useCallback(() => {
@ -990,7 +999,6 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
<Dialog
open={modalState.isOpen}
onOpenChange={(open) => {
// X 버튼 클릭 시에도 확인 다이얼로그 표시
if (!open) {
handleCloseAttempt();
}
@ -1217,6 +1225,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
userId={userId}
userName={userName}
companyCode={user?.companyCode}
isInModal={true}
/>
);
});
@ -1260,6 +1269,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
userId={userId}
userName={userName}
companyCode={user?.companyCode}
isInModal={true}
/>
);
})}

View File

@ -0,0 +1,109 @@
"use client";
import React, { useMemo } from "react";
import dynamic from "next/dynamic";
import { Loader2 } from "lucide-react";
const LoadingFallback = () => (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
/**
* URL .
* .
* URL은 catch-all fallback으로 .
*/
const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
// 관리자 메인
"/admin": dynamic(() => import("@/app/(main)/admin/page"), { ssr: false, loading: LoadingFallback }),
// 메뉴 관리
"/admin/menu": dynamic(() => import("@/app/(main)/admin/menu/page"), { ssr: false, loading: LoadingFallback }),
// 사용자 관리
"/admin/userMng/userMngList": dynamic(() => import("@/app/(main)/admin/userMng/userMngList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/userMng/rolesList": dynamic(() => import("@/app/(main)/admin/userMng/rolesList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/userMng/userAuthList": dynamic(() => import("@/app/(main)/admin/userMng/userAuthList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/userMng/companyList": dynamic(() => import("@/app/(main)/admin/userMng/companyList/page"), { ssr: false, loading: LoadingFallback }),
// 화면 관리
"/admin/screenMng/screenMngList": dynamic(() => import("@/app/(main)/admin/screenMng/screenMngList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/screenMng/popScreenMngList": dynamic(() => import("@/app/(main)/admin/screenMng/popScreenMngList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/screenMng/dashboardList": dynamic(() => import("@/app/(main)/admin/screenMng/dashboardList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/screenMng/reportList": dynamic(() => import("@/app/(main)/admin/screenMng/reportList/page"), { ssr: false, loading: LoadingFallback }),
// 시스템 관리
"/admin/systemMng/commonCodeList": dynamic(() => import("@/app/(main)/admin/systemMng/commonCodeList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/systemMng/tableMngList": dynamic(() => import("@/app/(main)/admin/systemMng/tableMngList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/systemMng/i18nList": dynamic(() => import("@/app/(main)/admin/systemMng/i18nList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/systemMng/collection-managementList": dynamic(() => import("@/app/(main)/admin/systemMng/collection-managementList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/systemMng/dataflow": dynamic(() => import("@/app/(main)/admin/systemMng/dataflow/page"), { ssr: false, loading: LoadingFallback }),
// 자동화 관리
"/admin/automaticMng/flowMgmtList": dynamic(() => import("@/app/(main)/admin/automaticMng/flowMgmtList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/batchmngList": dynamic(() => import("@/app/(main)/admin/automaticMng/batchmngList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/exconList": dynamic(() => import("@/app/(main)/admin/automaticMng/exconList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/exCallConfList": dynamic(() => import("@/app/(main)/admin/automaticMng/exCallConfList/page"), { ssr: false, loading: LoadingFallback }),
// 메일
"/admin/automaticMng/mail/send": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/send/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/receive": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/receive/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/sent": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/sent/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/drafts": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/drafts/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/trash": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/trash/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/accounts": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/accounts/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/templates": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/templates/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/dashboardList": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/dashboardList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/bulk-send": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/bulk-send/page"), { ssr: false, loading: LoadingFallback }),
// 배치 관리
"/admin/batch-management": dynamic(() => import("@/app/(main)/admin/batch-management/page"), { ssr: false, loading: LoadingFallback }),
"/admin/batch-management-new": dynamic(() => import("@/app/(main)/admin/batch-management-new/page"), { ssr: false, loading: LoadingFallback }),
// 기타
"/admin/cascading-management": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }),
"/admin/cascading-relations": dynamic(() => import("@/app/(main)/admin/cascading-relations/page"), { ssr: false, loading: LoadingFallback }),
"/admin/layouts": dynamic(() => import("@/app/(main)/admin/layouts/page"), { ssr: false, loading: LoadingFallback }),
"/admin/templates": dynamic(() => import("@/app/(main)/admin/templates/page"), { ssr: false, loading: LoadingFallback }),
"/admin/monitoring": dynamic(() => import("@/app/(main)/admin/monitoring/page"), { ssr: false, loading: LoadingFallback }),
"/admin/standards": dynamic(() => import("@/app/(main)/admin/standards/page"), { ssr: false, loading: LoadingFallback }),
"/admin/flow-external-db": dynamic(() => import("@/app/(main)/admin/flow-external-db/page"), { ssr: false, loading: LoadingFallback }),
"/admin/auto-fill": dynamic(() => import("@/app/(main)/admin/auto-fill/page"), { ssr: false, loading: LoadingFallback }),
};
// 매핑되지 않은 URL용 Fallback
function AdminPageFallback({ url }: { url: string }) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<p className="text-lg font-semibold text-foreground"> </p>
<p className="mt-1 text-sm text-muted-foreground">
: {url}
</p>
<p className="mt-2 text-xs text-muted-foreground">
AdminPageRenderer URL을 .
</p>
</div>
</div>
);
}
interface AdminPageRendererProps {
url: string;
}
export function AdminPageRenderer({ url }: AdminPageRendererProps) {
const PageComponent = useMemo(() => {
// URL에서 쿼리스트링/해시 제거 후 매칭
const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, "");
return ADMIN_PAGE_REGISTRY[cleanUrl] || null;
}, [url]);
if (!PageComponent) {
return <AdminPageFallback url={url} />;
}
return <PageComponent />;
}

View File

@ -29,6 +29,9 @@ import { toast } from "sonner";
import { ProfileModal } from "./ProfileModal";
import { Logo } from "./Logo";
import { SideMenu } from "./SideMenu";
import { TabBar } from "./TabBar";
import { TabContent } from "./TabContent";
import { useTabStore } from "@/stores/tabStore";
import {
DropdownMenu,
DropdownMenuContent,
@ -96,7 +99,8 @@ const getMenuIcon = (menuName: string, dbIconName?: string | null) => {
};
// 메뉴 데이터를 UI용으로 변환하는 함수 (최상위 "사용자", "관리자" 제외)
const convertMenuToUI = (menus: MenuItem[], userInfo: ExtendedUserInfo | null, parentId: string = "0"): any[] => {
// parentPath: 탭 제목에 "기준정보 - 회사관리" 형태로 상위 카테고리를 포함하기 위한 경로
const convertMenuToUI = (menus: MenuItem[], userInfo: ExtendedUserInfo | null, parentId: string = "0", parentPath: string = ""): any[] => {
const filteredMenus = menus
.filter((menu) => (menu.parent_obj_id || menu.PARENT_OBJ_ID) === parentId)
.filter((menu) => (menu.status || menu.STATUS) === "active")
@ -109,40 +113,34 @@ const convertMenuToUI = (menus: MenuItem[], userInfo: ExtendedUserInfo | null, p
for (const menu of filteredMenus) {
const menuName = (menu.menu_name_kor || menu.MENU_NAME_KOR || "").toLowerCase();
// "사용자" 또는 "관리자" 카테고리면 하위 메뉴들을 직접 추가
if (menuName.includes("사용자") || menuName.includes("관리자")) {
const childMenus = convertMenuToUI(menus, userInfo, menu.objid || menu.OBJID);
const childMenus = convertMenuToUI(menus, userInfo, menu.objid || menu.OBJID, "");
allMenus.push(...childMenus);
} else {
// 일반 메뉴는 그대로 추가
allMenus.push(convertSingleMenu(menu, menus, userInfo));
allMenus.push(convertSingleMenu(menu, menus, userInfo, ""));
}
}
return allMenus;
}
return filteredMenus.map((menu) => convertSingleMenu(menu, menus, userInfo));
return filteredMenus.map((menu) => convertSingleMenu(menu, menus, userInfo, parentPath));
};
// 단일 메뉴 변환 함수
const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: ExtendedUserInfo | null): any => {
const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: ExtendedUserInfo | null, parentPath: string = ""): any => {
const menuId = menu.objid || menu.OBJID;
// 사용자 locale 기준으로 번역 처리
const getDisplayText = (menu: MenuItem) => {
// 다국어 텍스트가 있으면 사용, 없으면 기본 텍스트 사용
if (menu.translated_name || menu.TRANSLATED_NAME) {
return menu.translated_name || menu.TRANSLATED_NAME;
const getDisplayText = (m: MenuItem) => {
if (m.translated_name || m.TRANSLATED_NAME) {
return m.translated_name || m.TRANSLATED_NAME;
}
const baseName = menu.menu_name_kor || menu.MENU_NAME_KOR || "메뉴명 없음";
// 사용자 정보에서 locale 가져오기
const baseName = m.menu_name_kor || m.MENU_NAME_KOR || "메뉴명 없음";
const userLocale = userInfo?.locale || "ko";
if (userLocale === "EN") {
// 영어 번역
const translations: { [key: string]: string } = {
: "Administrator",
: "User Management",
@ -162,7 +160,6 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten
}
}
} else if (userLocale === "JA") {
// 일본어 번역
const translations: { [key: string]: string } = {
: "管理者",
: "ユーザー管理",
@ -182,7 +179,6 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten
}
}
} else if (userLocale === "ZH") {
// 중국어 번역
const translations: { [key: string]: string } = {
: "管理员",
: "用户管理",
@ -206,11 +202,15 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten
return baseName;
};
const children = convertMenuToUI(allMenus, userInfo, menuId);
const displayName = getDisplayText(menu);
const tabTitle = parentPath ? `${parentPath} - ${displayName}` : displayName;
const children = convertMenuToUI(allMenus, userInfo, menuId, tabTitle);
return {
id: menuId,
name: getDisplayText(menu),
name: displayName,
tabTitle,
icon: getMenuIcon(menu.menu_name_kor || menu.MENU_NAME_KOR || "", menu.menu_icon || menu.MENU_ICON),
url: menu.menu_url || menu.MENU_URL || "#",
children: children.length > 0 ? children : undefined,
@ -230,6 +230,28 @@ function AppLayoutInner({ children }: AppLayoutProps) {
const [showCompanySwitcher, setShowCompanySwitcher] = useState(false);
const [currentCompanyName, setCurrentCompanyName] = useState<string>("");
// URL 직접 접근 시 탭 자동 열기 (북마크/공유 링크 대응)
useEffect(() => {
const store = useTabStore.getState();
const currentModeTabs = store[store.mode].tabs;
if (currentModeTabs.length > 0) return;
// /screens/[screenId] 패턴 감지
const screenMatch = pathname.match(/^\/screens\/(\d+)/);
if (screenMatch) {
const screenId = parseInt(screenMatch[1]);
const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined;
store.openTab({ type: "screen", title: `화면 ${screenId}`, screenId, menuObjid });
return;
}
// /admin/* 패턴 감지 -> admin 모드로 전환 후 탭 열기
if (pathname.startsWith("/admin") && pathname !== "/admin") {
store.setMode("admin");
store.openTab({ type: "admin", title: pathname.split("/").pop() || "관리자", adminUrl: pathname });
}
}, []); // 마운트 시 1회만 실행
// 현재 회사명 조회 (SUPER_ADMIN 전용)
useEffect(() => {
const fetchCurrentCompanyName = async () => {
@ -305,8 +327,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
handleRegisterVehicle,
} = useProfile(user, refreshUserData, refreshMenus);
// 현재 경로에 따라 어드민 모드인지 판단 (쿼리 파라미터도 고려)
const isAdminMode = pathname.startsWith("/admin") || searchParams.get("mode") === "admin";
// 탭 스토어에서 현재 모드 가져오기
const tabMode = useTabStore((s) => s.mode);
const setTabMode = useTabStore((s) => s.setMode);
const isAdminMode = tabMode === "admin";
// 프리뷰 모드: 사이드바/헤더 없이 화면만 표시 (iframe 임베드용)
const isPreviewMode = searchParams.get("preview") === "true";
@ -326,67 +350,55 @@ function AppLayoutInner({ children }: AppLayoutProps) {
setExpandedMenus(newExpanded);
};
// 메뉴 클릭 핸들러
const { openTab } = useTabStore();
// 메뉴 클릭 핸들러 (탭으로 열기)
const handleMenuClick = async (menu: any) => {
if (menu.hasChildren) {
toggleMenu(menu.id);
} else {
// 메뉴 이름 저장 (엑셀 다운로드 파일명에 사용)
const menuName = menu.label || menu.name || "메뉴";
// tabTitle: "기준정보 - 회사관리" 형태의 상위 포함 이름
const menuName = menu.tabTitle || menu.label || menu.name || "메뉴";
if (typeof window !== "undefined") {
localStorage.setItem("currentMenuName", menuName);
}
// 먼저 할당된 화면이 있는지 확인 (URL 유무와 관계없이)
try {
const menuObjid = menu.objid || menu.id;
const assignedScreens = await menuScreenApi.getScreensByMenu(menuObjid);
if (assignedScreens.length > 0) {
// 할당된 화면이 있으면 첫 번째 화면으로 이동
const firstScreen = assignedScreens[0];
// 관리자 모드 상태와 menuObjid를 쿼리 파라미터로 전달
const params = new URLSearchParams();
if (isAdminMode) {
params.set("mode", "admin");
}
params.set("menuObjid", menuObjid.toString());
const screenPath = `/screens/${firstScreen.screenId}?${params.toString()}`;
router.push(screenPath);
if (isMobile) {
setSidebarOpen(false);
}
openTab({
type: "screen",
title: menuName,
screenId: firstScreen.screenId,
menuObjid: parseInt(menuObjid),
});
if (isMobile) setSidebarOpen(false);
return;
}
} catch (error) {
console.warn("할당된 화면 조회 실패:", error);
}
// 할당된 화면이 없고 URL이 있으면 기존 URL로 이동
if (menu.url && menu.url !== "#") {
router.push(menu.url);
if (isMobile) {
setSidebarOpen(false);
}
openTab({
type: "admin",
title: menuName,
adminUrl: menu.url,
});
if (isMobile) setSidebarOpen(false);
} else {
// URL도 없고 할당된 화면도 없으면 경고 메시지
toast.warning("이 메뉴에는 연결된 페이지나 화면이 없습니다.");
}
}
};
// 모드 전환 핸들러
// 모드 전환: 탭 스토어의 모드만 변경 (각 모드 탭은 독립 보존)
const handleModeSwitch = () => {
if (isAdminMode) {
// 관리자 → 사용자 모드: 선택한 회사 유지
router.push("/main");
} else {
// 사용자 → 관리자 모드: 선택한 회사 유지 (회사 전환 없음)
router.push("/admin");
}
setTabMode(isAdminMode ? "user" : "admin");
};
// 로그아웃 핸들러
@ -399,13 +411,57 @@ function AppLayoutInner({ children }: AppLayoutProps) {
}
};
// 메뉴 트리 렌더링 (기존 MainLayout 스타일 적용)
// 사이드바 메뉴 -> 탭 바 드래그용 데이터 생성
const buildMenuDragData = async (menu: any): Promise<string | null> => {
const menuName = menu.label || menu.name || "메뉴";
const menuObjid = menu.objid || menu.id;
try {
const assignedScreens = await menuScreenApi.getScreensByMenu(menuObjid);
if (assignedScreens.length > 0) {
return JSON.stringify({
type: "screen" as const,
title: menuName,
screenId: assignedScreens[0].screenId,
menuObjid: parseInt(menuObjid),
});
}
} catch { /* ignore */ }
if (menu.url && menu.url !== "#") {
return JSON.stringify({
type: "admin" as const,
title: menuName,
adminUrl: menu.url,
});
}
return null;
};
const handleMenuDragStart = (e: React.DragEvent, menu: any) => {
if (menu.hasChildren) {
e.preventDefault();
return;
}
e.dataTransfer.effectAllowed = "copy";
const menuName = menu.tabTitle || menu.label || menu.name || "메뉴";
const menuObjid = menu.objid || menu.id;
const dragPayload = JSON.stringify({ menuName, menuObjid, url: menu.url });
e.dataTransfer.setData("application/tab-menu-pending", dragPayload);
e.dataTransfer.setData("text/plain", menuName);
};
// 메뉴 트리 렌더링 (드래그 가능)
const renderMenu = (menu: any, level: number = 0) => {
const isExpanded = expandedMenus.has(menu.id);
const isLeaf = !menu.hasChildren;
return (
<div key={menu.id}>
<div
draggable={isLeaf}
onDragStart={(e) => handleMenuDragStart(e, menu)}
className={`group flex h-10 cursor-pointer items-center justify-between rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-200 ease-in-out ${
pathname === menu.url
? "border-primary border-l-4 bg-gradient-to-br from-slate-100 to-blue-100/40 text-slate-900"
@ -434,6 +490,8 @@ function AppLayoutInner({ children }: AppLayoutProps) {
{menu.children?.map((child: any) => (
<div
key={child.id}
draggable={!child.hasChildren}
onDragStart={(e) => handleMenuDragStart(e, child)}
className={`flex cursor-pointer items-center rounded-lg px-3 py-2 text-sm transition-colors hover:cursor-pointer ${
pathname === child.url
? "border-primary border-l-4 bg-gradient-to-br from-slate-100 to-blue-100/40 text-slate-900"
@ -701,9 +759,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
</div>
</aside>
{/* 가운데 컨텐츠 영역 - 스크롤 가능 */}
<main className={`min-w-0 flex-1 overflow-auto bg-white ${isMobile ? "h-[calc(100vh-56px)]" : "h-screen"}`}>
{children}
{/* 가운데 컨텐츠 영역 - 탭 시스템 */}
<main className={`flex min-w-0 flex-1 flex-col overflow-hidden bg-white ${isMobile ? "h-[calc(100vh-56px)]" : "h-screen"}`}>
<TabBar />
<TabContent />
</main>
</div>

View File

@ -0,0 +1,23 @@
"use client";
import { LayoutGrid } from "lucide-react";
export function EmptyDashboard() {
return (
<div className="flex h-full items-center justify-center bg-white">
<div className="flex flex-col items-center gap-4 text-center">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted">
<LayoutGrid className="h-10 w-10 text-muted-foreground" />
</div>
<div className="space-y-2">
<h2 className="text-xl font-semibold text-foreground">
</h2>
<p className="max-w-sm text-sm text-muted-foreground">
.
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,698 @@
"use client";
import React, { useRef, useState, useEffect, useLayoutEffect, useCallback } from "react";
import { X, RotateCw, ChevronDown } from "lucide-react";
import { useTabStore, selectTabs, selectActiveTabId, Tab } from "@/stores/tabStore";
import { menuScreenApi } from "@/lib/api/screen";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
const TAB_WIDTH = 180;
const TAB_GAP = 2;
const TAB_UNIT = TAB_WIDTH + TAB_GAP;
const OVERFLOW_BTN_WIDTH = 48;
const DRAG_THRESHOLD = 5;
const SETTLE_MS = 70;
const DROP_SETTLE_MS = 180;
const BAR_PAD_X = 8;
interface DragState {
tabId: string;
pointerId: number;
startX: number;
currentX: number;
tabRect: DOMRect;
fromIndex: number;
targetIndex: number;
activated: boolean;
settling: boolean;
}
interface DropGhost {
title: string;
startX: number;
startY: number;
targetIdx: number;
tabCountAtCreation: number;
}
export function TabBar() {
const tabs = useTabStore(selectTabs);
const activeTabId = useTabStore(selectActiveTabId);
const {
switchTab,
closeTab,
refreshTab,
closeOtherTabs,
closeTabsToLeft,
closeTabsToRight,
closeAllTabs,
updateTabOrder,
openTab,
} = useTabStore();
// --- Refs ---
const containerRef = useRef<HTMLDivElement>(null);
const settleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const dragActiveRef = useRef(false);
const dragLeaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const dropGhostRef = useRef<HTMLDivElement>(null);
const prevTabCountRef = useRef(tabs.length);
// --- State ---
const [visibleCount, setVisibleCount] = useState(tabs.length);
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
tabId: string;
} | null>(null);
const [dragState, setDragState] = useState<DragState | null>(null);
const [externalDragIdx, setExternalDragIdx] = useState<number | null>(null);
const [dropGhost, setDropGhost] = useState<DropGhost | null>(null);
dragActiveRef.current = !!dragState;
// --- 타이머 정리 ---
useEffect(() => {
return () => {
if (settleTimer.current) clearTimeout(settleTimer.current);
if (dragLeaveTimerRef.current) clearTimeout(dragLeaveTimerRef.current);
};
}, []);
// --- 드롭 고스트: Web Animations API로 드롭 위치 → 목표 슬롯 이동 ---
useEffect(() => {
if (!dropGhost) return;
const el = dropGhostRef.current;
const bar = containerRef.current?.getBoundingClientRect();
if (!el || !bar) return;
const targetX = bar.left + BAR_PAD_X + dropGhost.targetIdx * TAB_UNIT;
const targetY = bar.bottom - 28;
const dx = dropGhost.startX - targetX;
const dy = dropGhost.startY - targetY;
const anim = el.animate(
[
{ transform: `translate(${dx}px, ${dy}px)`, opacity: 0.85 },
{ transform: "translate(0, 0)", opacity: 1 },
],
{
duration: DROP_SETTLE_MS,
easing: "cubic-bezier(0.25, 1, 0.5, 1)",
fill: "forwards",
},
);
anim.onfinish = () => {
setDropGhost(null);
setExternalDragIdx(null);
};
const safety = setTimeout(() => {
setDropGhost(null);
setExternalDragIdx(null);
}, DROP_SETTLE_MS + 500);
return () => {
anim.cancel();
clearTimeout(safety);
};
}, [dropGhost]);
// --- 오버플로우 계산 (드래그 중 재계산 방지) ---
const recalcVisible = useCallback(() => {
if (dragActiveRef.current) return;
if (!containerRef.current) return;
const w = containerRef.current.clientWidth;
setVisibleCount(Math.max(1, Math.floor((w - OVERFLOW_BTN_WIDTH) / TAB_UNIT)));
}, []);
useEffect(() => {
recalcVisible();
const obs = new ResizeObserver(recalcVisible);
if (containerRef.current) obs.observe(containerRef.current);
return () => obs.disconnect();
}, [recalcVisible]);
useLayoutEffect(() => {
recalcVisible();
}, [tabs.length, recalcVisible]);
const visibleTabs = tabs.slice(0, visibleCount);
const overflowTabs = tabs.slice(visibleCount);
const hasOverflow = overflowTabs.length > 0;
const activeInOverflow = activeTabId && overflowTabs.some((t) => t.id === activeTabId);
let displayVisible = visibleTabs;
let displayOverflow = overflowTabs;
if (activeInOverflow && activeTabId) {
const activeTab = tabs.find((t) => t.id === activeTabId)!;
displayVisible = [...visibleTabs.slice(0, -1), activeTab];
displayOverflow = overflowTabs.filter((t) => t.id !== activeTabId);
if (visibleTabs.length > 0) {
displayOverflow = [visibleTabs[visibleTabs.length - 1], ...displayOverflow];
}
}
// ============================================================
// 사이드바 -> 탭 바 드롭 (네이티브 DnD + 삽입 위치 애니메이션)
// ============================================================
useLayoutEffect(() => {
if (tabs.length !== prevTabCountRef.current && externalDragIdx !== null) {
setExternalDragIdx(null);
}
prevTabCountRef.current = tabs.length;
}, [tabs.length, externalDragIdx]);
const resolveMenuAndOpenTab = async (
menuName: string,
menuObjid: string | number,
url: string,
insertIndex?: number,
) => {
const numericObjid = typeof menuObjid === "string" ? parseInt(menuObjid) : menuObjid;
try {
const screens = await menuScreenApi.getScreensByMenu(numericObjid);
if (screens.length > 0) {
openTab(
{ type: "screen", title: menuName, screenId: screens[0].screenId, menuObjid: numericObjid },
insertIndex,
);
return;
}
} catch {
/* ignore */
}
if (url && url !== "#") {
openTab({ type: "admin", title: menuName, adminUrl: url }, insertIndex);
} else {
setExternalDragIdx(null);
}
};
const handleBarDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
if (dragLeaveTimerRef.current) {
clearTimeout(dragLeaveTimerRef.current);
dragLeaveTimerRef.current = null;
}
const bar = containerRef.current?.getBoundingClientRect();
if (bar) {
const idx = Math.max(
0,
Math.min(Math.round((e.clientX - bar.left - BAR_PAD_X) / TAB_UNIT), displayVisible.length),
);
setExternalDragIdx(idx);
}
};
const handleBarDragLeave = (e: React.DragEvent) => {
if (!containerRef.current?.contains(e.relatedTarget as Node)) {
dragLeaveTimerRef.current = setTimeout(() => {
setExternalDragIdx(null);
dragLeaveTimerRef.current = null;
}, 50);
}
};
const createDropGhost = (e: React.DragEvent, title: string, targetIdx: number) => {
setDropGhost({
title,
startX: e.clientX - TAB_WIDTH / 2,
startY: e.clientY - 14,
targetIdx,
tabCountAtCreation: tabs.length,
});
};
const handleBarDrop = (e: React.DragEvent) => {
e.preventDefault();
if (dragLeaveTimerRef.current) {
clearTimeout(dragLeaveTimerRef.current);
dragLeaveTimerRef.current = null;
}
const insertIdx = externalDragIdx ?? undefined;
const ghostIdx = insertIdx ?? displayVisible.length;
const pending = e.dataTransfer.getData("application/tab-menu-pending");
if (pending) {
try {
const { menuName, menuObjid, url } = JSON.parse(pending);
createDropGhost(e, menuName, ghostIdx);
resolveMenuAndOpenTab(menuName, menuObjid, url, insertIdx);
} catch {
setExternalDragIdx(null);
}
return;
}
const menuData = e.dataTransfer.getData("application/tab-menu");
if (menuData && menuData.length > 2) {
try {
const parsed = JSON.parse(menuData);
createDropGhost(e, parsed.title || "새 탭", ghostIdx);
setExternalDragIdx(null);
openTab(parsed, insertIdx);
} catch {
setExternalDragIdx(null);
}
} else {
setExternalDragIdx(null);
}
};
// ============================================================
// 탭 드래그 (Pointer Events) - 임계값 + settling 애니메이션
// ============================================================
const calcTarget = useCallback(
(clientX: number, startX: number, fromIndex: number): number => {
const delta = Math.round((clientX - startX) / TAB_UNIT);
return Math.max(0, Math.min(fromIndex + delta, displayVisible.length - 1));
},
[displayVisible.length],
);
const handlePointerDown = (e: React.PointerEvent, tabId: string, idx: number) => {
if ((e.target as HTMLElement).closest("button")) return;
if (dragState?.settling) return;
if (settleTimer.current) {
clearTimeout(settleTimer.current);
settleTimer.current = null;
}
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
setDragState({
tabId,
pointerId: e.pointerId,
startX: e.clientX,
currentX: e.clientX,
tabRect: (e.currentTarget as HTMLElement).getBoundingClientRect(),
fromIndex: idx,
targetIndex: idx,
activated: false,
settling: false,
});
};
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (!dragState || dragState.settling) return;
if (e.pointerId !== dragState.pointerId) return;
const bar = containerRef.current?.getBoundingClientRect();
if (!bar) return;
const clampedX = Math.max(bar.left, Math.min(e.clientX, bar.right));
if (!dragState.activated) {
if (Math.abs(clampedX - dragState.startX) < DRAG_THRESHOLD) return;
setDragState((p) =>
p
? {
...p,
activated: true,
currentX: clampedX,
targetIndex: calcTarget(clampedX, p.startX, p.fromIndex),
}
: null,
);
return;
}
setDragState((p) =>
p ? { ...p, currentX: clampedX, targetIndex: calcTarget(clampedX, p.startX, p.fromIndex) } : null,
);
},
[dragState, calcTarget],
);
const handlePointerUp = useCallback(
(e: React.PointerEvent) => {
if (!dragState || dragState.settling) return;
if (e.pointerId !== dragState.pointerId) return;
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
if (!dragState.activated) {
switchTab(dragState.tabId);
setDragState(null);
return;
}
const { fromIndex, targetIndex, tabId } = dragState;
setDragState((p) => (p ? { ...p, settling: true } : null));
if (targetIndex === fromIndex) {
settleTimer.current = setTimeout(() => setDragState(null), SETTLE_MS + 10);
return;
}
const actualFrom = tabs.findIndex((t) => t.id === tabId);
const tgtTab = displayVisible[targetIndex];
const actualTo = tgtTab ? tabs.findIndex((t) => t.id === tgtTab.id) : actualFrom;
settleTimer.current = setTimeout(() => {
setDragState(null);
if (actualFrom !== -1 && actualTo !== -1 && actualFrom !== actualTo) {
updateTabOrder(actualFrom, actualTo);
}
}, SETTLE_MS + 10);
},
[dragState, tabs, displayVisible, switchTab, updateTabOrder],
);
const handleLostPointerCapture = useCallback(() => {
if (dragState && !dragState.settling) {
setDragState(null);
if (settleTimer.current) {
clearTimeout(settleTimer.current);
settleTimer.current = null;
}
}
}, [dragState]);
// ============================================================
// 스타일 계산
// ============================================================
const getTabAnimStyle = (tabId: string, index: number): React.CSSProperties => {
if (externalDragIdx !== null && !dragState) {
return {
transform: index >= externalDragIdx ? `translateX(${TAB_UNIT}px)` : "none",
transition: `transform ${DROP_SETTLE_MS}ms cubic-bezier(0.25, 1, 0.5, 1)`,
};
}
if (!dragState || !dragState.activated) return {};
const { fromIndex, targetIndex, tabId: draggedId } = dragState;
if (tabId === draggedId) {
return { opacity: 0, transition: "none" };
}
let shift = 0;
if (fromIndex < targetIndex) {
if (index > fromIndex && index <= targetIndex) shift = -TAB_UNIT;
} else if (fromIndex > targetIndex) {
if (index >= targetIndex && index < fromIndex) shift = TAB_UNIT;
}
return {
transform: shift !== 0 ? `translateX(${shift}px)` : "none",
transition: `transform ${SETTLE_MS}ms cubic-bezier(0.25, 1, 0.5, 1)`,
};
};
const getGhostStyle = (): React.CSSProperties | null => {
if (!dragState || !dragState.activated) return null;
const bar = containerRef.current?.getBoundingClientRect();
if (!bar) return null;
const base: React.CSSProperties = {
position: "fixed",
top: dragState.tabRect.top,
width: TAB_WIDTH,
height: dragState.tabRect.height,
zIndex: 100,
pointerEvents: "none",
opacity: 0.9,
};
if (dragState.settling) {
return {
...base,
left: bar.left + BAR_PAD_X + dragState.targetIndex * TAB_UNIT,
opacity: 1,
boxShadow: "none",
transition: `left ${SETTLE_MS}ms cubic-bezier(0.25, 1, 0.5, 1), box-shadow 80ms ease-out`,
};
}
const offsetX = dragState.currentX - dragState.startX;
const rawLeft = dragState.tabRect.left + offsetX;
return {
...base,
left: Math.max(bar.left, Math.min(rawLeft, bar.right - TAB_WIDTH)),
transition: "none",
};
};
const ghostStyle = getGhostStyle();
const draggedTab = dragState ? tabs.find((t) => t.id === dragState.tabId) : null;
// ============================================================
// 우클릭 컨텍스트 메뉴
// ============================================================
const handleContextMenu = (e: React.MouseEvent, tabId: string) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, tabId });
};
useEffect(() => {
if (!contextMenu) return;
const close = () => setContextMenu(null);
window.addEventListener("click", close);
window.addEventListener("scroll", close);
return () => {
window.removeEventListener("click", close);
window.removeEventListener("scroll", close);
};
}, [contextMenu]);
// ============================================================
// 렌더링
// ============================================================
const renderTab = (tab: Tab, displayIndex: number) => {
const isActive = tab.id === activeTabId;
const animStyle = getTabAnimStyle(tab.id, displayIndex);
const hiddenByGhost =
!!dropGhost && displayIndex === dropGhost.targetIdx && tabs.length > dropGhost.tabCountAtCreation;
return (
<div
key={tab.id}
onPointerDown={(e) => handlePointerDown(e, tab.id, displayIndex)}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onLostPointerCapture={handleLostPointerCapture}
onContextMenu={(e) => handleContextMenu(e, tab.id)}
className={cn(
"group relative flex h-7 shrink-0 cursor-pointer items-center gap-0.5 rounded-t-md border border-b-0 px-3 select-none",
isActive
? "text-foreground z-10 -mb-px h-[30px] bg-white"
: "bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground border-transparent",
)}
style={{
width: TAB_WIDTH,
touchAction: "none",
...animStyle,
...(hiddenByGhost ? { opacity: 0 } : {}),
...(isActive ? { boxShadow: "0 -1px 28px rgba(0,0,0,0.9)" } : {}),
}}
title={tab.title}
>
<span className="min-w-0 flex-1 truncate text-[11px] font-medium">{tab.title}</span>
<div className="flex shrink-0 items-center">
{isActive && (
<button
onClick={(e) => {
e.stopPropagation();
refreshTab(tab.id);
}}
className="text-muted-foreground hover:bg-accent hover:text-foreground flex h-4 w-4 items-center justify-center rounded-sm transition-colors"
>
<RotateCw className="h-2.5 w-2.5" />
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
closeTab(tab.id);
}}
className={cn(
"text-muted-foreground hover:bg-destructive/10 hover:text-destructive flex h-4 w-4 items-center justify-center rounded-sm transition-colors",
!isActive && "opacity-0 group-hover:opacity-100",
)}
>
<X className="h-2.5 w-2.5" />
</button>
</div>
</div>
);
};
if (tabs.length === 0) return null;
return (
<>
<div
ref={containerRef}
className="border-border bg-muted/30 relative flex h-[33px] shrink-0 items-end gap-[2px] overflow-hidden px-1.5"
onDragOver={handleBarDragOver}
onDragLeave={handleBarDragLeave}
onDrop={handleBarDrop}
>
<div className="border-border pointer-events-none absolute inset-x-0 bottom-0 z-0 border-b" />
<div className="pointer-events-none absolute inset-0 z-5 bg-black/15" />
{displayVisible.map((tab, i) => renderTab(tab, i))}
{hasOverflow && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground flex h-7 shrink-0 items-center gap-0.5 rounded-t-md border border-b-0 border-transparent px-2 text-[11px] font-medium transition-colors">
+{displayOverflow.length}
<ChevronDown className="h-2.5 w-2.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-[300px] overflow-y-auto">
{displayOverflow.map((tab) => (
<DropdownMenuItem
key={tab.id}
onClick={() => switchTab(tab.id)}
className="flex items-center justify-between gap-2"
>
<span className="min-w-0 flex-1 truncate text-xs">{tab.title}</span>
<button
onClick={(e) => {
e.stopPropagation();
closeTab(tab.id);
}}
className="hover:bg-destructive/10 hover:text-destructive flex h-4 w-4 shrink-0 items-center justify-center rounded-sm"
>
<X className="h-3 w-3" />
</button>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{/* 탭 드래그 고스트 (내부 재정렬) */}
{ghostStyle && draggedTab && (
<div
style={ghostStyle}
className="border-primary/50 bg-background rounded-t-md border border-b-0 px-3 shadow-lg"
>
<div className="flex h-full items-center">
<span className="truncate text-[11px] font-medium">{draggedTab.title}</span>
</div>
</div>
)}
{/* 사이드바 드롭 고스트 (드롭 지점 → 탭 슬롯 이동) */}
{dropGhost &&
(() => {
const bar = containerRef.current?.getBoundingClientRect();
if (!bar) return null;
const targetX = bar.left + BAR_PAD_X + dropGhost.targetIdx * TAB_UNIT;
const targetY = bar.bottom - 28;
return (
<div
ref={dropGhostRef}
style={{
position: "fixed",
left: targetX,
top: targetY,
width: TAB_WIDTH,
height: 28,
zIndex: 100,
pointerEvents: "none",
}}
className="border-border bg-background rounded-t-md border border-b-0 px-3"
>
<div className="flex h-full items-center">
<span className="truncate text-[11px] font-medium">{dropGhost.title}</span>
</div>
</div>
);
})()}
{/* 우클릭 컨텍스트 메뉴 */}
{contextMenu && (
<div
className="border-border bg-popover fixed z-50 min-w-[180px] rounded-md border p-1 shadow-md"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
<ContextMenuItem
label="새로고침"
onClick={() => {
refreshTab(contextMenu.tabId);
setContextMenu(null);
}}
/>
<div className="bg-border my-1 h-px" />
<ContextMenuItem
label="왼쪽 탭 닫기"
onClick={() => {
closeTabsToLeft(contextMenu.tabId);
setContextMenu(null);
}}
/>
<ContextMenuItem
label="오른쪽 탭 닫기"
onClick={() => {
closeTabsToRight(contextMenu.tabId);
setContextMenu(null);
}}
/>
<ContextMenuItem
label="다른 탭 모두 닫기"
onClick={() => {
closeOtherTabs(contextMenu.tabId);
setContextMenu(null);
}}
/>
<div className="bg-border my-1 h-px" />
<ContextMenuItem
label="모든 탭 닫기"
onClick={() => {
closeAllTabs();
setContextMenu(null);
}}
destructive
/>
</div>
)}
</>
);
}
function ContextMenuItem({
label,
onClick,
destructive,
}: {
label: string;
onClick: () => void;
destructive?: boolean;
}) {
return (
<button
onClick={onClick}
className={cn(
"flex w-full items-center rounded-sm px-2 py-1.5 text-xs transition-colors",
destructive ? "text-destructive hover:bg-destructive/10" : "text-foreground hover:bg-accent",
)}
>
{label}
</button>
);
}

View File

@ -0,0 +1,248 @@
"use client";
import React, { useRef, useEffect, useCallback } from "react";
import { useTabStore, selectTabs, selectActiveTabId } from "@/stores/tabStore";
import { ScreenViewPageWrapper } from "@/app/(main)/screens/[screenId]/page";
import { AdminPageRenderer } from "./AdminPageRenderer";
import { EmptyDashboard } from "./EmptyDashboard";
import { TabIdProvider } from "@/contexts/TabIdContext";
import { registerModalPortal } from "@/lib/modalPortalRef";
import ScreenModal from "@/components/common/ScreenModal";
import {
saveTabCacheImmediate,
loadTabCache,
captureAllScrollPositions,
restoreAllScrollPositions,
getElementPath,
captureFormState,
restoreFormState,
clearTabCache,
} from "@/lib/tabStateCache";
export function TabContent() {
const tabs = useTabStore(selectTabs);
const activeTabId = useTabStore(selectActiveTabId);
const refreshKeys = useTabStore((s) => s.refreshKeys);
// 한 번이라도 활성화된 탭만 마운트 (지연 마운트)
const mountedTabIdsRef = useRef<Set<string>>(new Set());
// 각 탭의 스크롤 컨테이너 ref
const scrollRefsMap = useRef<Map<string, HTMLDivElement | null>>(new Map());
// 이전 활성 탭 ID 추적
const prevActiveTabIdRef = useRef<string | null>(null);
// 활성 탭의 스크롤 위치를 실시간 추적 (display:none 전에 캡처하기 위함)
// Map<tabId, Map<elementPath, {top, left}>> - 탭 내 여러 스크롤 영역을 각각 추적
const lastScrollMapRef = useRef<Map<string, Map<string, { top: number; left: number }>>>(new Map());
// 요소 → 경로 캐시 (매 스크롤 이벤트마다 경로를 재계산하지 않기 위함)
const pathCacheRef = useRef<WeakMap<HTMLElement, string | null>>(new WeakMap());
if (activeTabId) {
mountedTabIdsRef.current.add(activeTabId);
}
// 활성 탭의 scroll 이벤트를 감지하여 요소별 위치를 실시간 저장
useEffect(() => {
if (!activeTabId) return;
const container = scrollRefsMap.current.get(activeTabId);
if (!container) return;
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement;
let path = pathCacheRef.current.get(target);
if (path === undefined) {
path = getElementPath(target, container);
pathCacheRef.current.set(target, path);
}
if (path === null) return;
let tabMap = lastScrollMapRef.current.get(activeTabId);
if (!tabMap) {
tabMap = new Map();
lastScrollMapRef.current.set(activeTabId, tabMap);
}
if (target.scrollTop > 0 || target.scrollLeft > 0) {
tabMap.set(path, { top: target.scrollTop, left: target.scrollLeft });
} else {
tabMap.delete(path);
}
};
container.addEventListener("scroll", handleScroll, true);
return () => container.removeEventListener("scroll", handleScroll, true);
}, [activeTabId]);
// 복원 관련 cleanup ref
const scrollRestoreCleanupRef = useRef<(() => void) | null>(null);
const formRestoreCleanupRef = useRef<(() => void) | null>(null);
// 탭 전환 시: 이전 탭 상태 캐싱, 새 탭 상태 복원
useEffect(() => {
// 이전 복원 작업 취소
if (scrollRestoreCleanupRef.current) {
scrollRestoreCleanupRef.current();
scrollRestoreCleanupRef.current = null;
}
if (formRestoreCleanupRef.current) {
formRestoreCleanupRef.current();
formRestoreCleanupRef.current = null;
}
const prevId = prevActiveTabIdRef.current;
// 이전 활성 탭의 스크롤 + 폼 상태 저장
// 키를 항상 포함하여 이전 캐시의 오래된 값이 병합으로 살아남지 않도록 함
if (prevId && prevId !== activeTabId) {
const tabMap = lastScrollMapRef.current.get(prevId);
const scrollPositions =
tabMap && tabMap.size > 0
? Array.from(tabMap.entries()).map(([path, pos]) => ({ path, ...pos }))
: undefined;
const prevEl = scrollRefsMap.current.get(prevId);
const formFields = captureFormState(prevEl ?? null);
saveTabCacheImmediate(prevId, {
scrollPositions,
domFormFields: formFields ?? undefined,
});
}
// 새 활성 탭의 스크롤 + 폼 상태 복원
if (activeTabId) {
const cache = loadTabCache(activeTabId);
if (cache) {
const el = scrollRefsMap.current.get(activeTabId);
if (cache.scrollPositions) {
const cleanup = restoreAllScrollPositions(el ?? null, cache.scrollPositions);
if (cleanup) scrollRestoreCleanupRef.current = cleanup;
}
if (cache.domFormFields) {
const cleanup = restoreFormState(el ?? null, cache.domFormFields ?? null);
if (cleanup) formRestoreCleanupRef.current = cleanup;
}
}
}
prevActiveTabIdRef.current = activeTabId;
}, [activeTabId]);
// F5 새로고침 직전에 활성 탭의 스크롤/폼 상태를 저장
useEffect(() => {
const handleBeforeUnload = () => {
const currentActiveId = prevActiveTabIdRef.current;
if (!currentActiveId) return;
const el = scrollRefsMap.current.get(currentActiveId);
// 활성 탭은 display:block이므로 DOM에서 직접 캡처 (가장 정확)
const scrollPositions = captureAllScrollPositions(el ?? null);
// DOM 캡처 실패 시 실시간 추적 데이터 fallback
const tabMap = lastScrollMapRef.current.get(currentActiveId);
const trackedPositions =
!scrollPositions && tabMap && tabMap.size > 0
? Array.from(tabMap.entries()).map(([path, pos]) => ({ path, ...pos }))
: undefined;
const finalPositions = scrollPositions || trackedPositions;
const formFields = captureFormState(el ?? null);
saveTabCacheImmediate(currentActiveId, {
scrollPositions: finalPositions,
domFormFields: formFields ?? undefined,
});
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
if (scrollRestoreCleanupRef.current) scrollRestoreCleanupRef.current();
if (formRestoreCleanupRef.current) formRestoreCleanupRef.current();
};
}, []);
// 탭 닫기 시 캐시 정리 (tabs 배열 변화 감지)
useEffect(() => {
const currentTabIds = new Set(tabs.map((t) => t.id));
const mountedIds = mountedTabIdsRef.current;
mountedIds.forEach((id) => {
if (!currentTabIds.has(id)) {
clearTabCache(id);
scrollRefsMap.current.delete(id);
mountedIds.delete(id);
}
});
}, [tabs]);
const setScrollRef = useCallback((tabId: string, el: HTMLDivElement | null) => {
scrollRefsMap.current.set(tabId, el);
}, []);
// 포탈 컨테이너 ref callback: 전역 레퍼런스에 등록
const portalRefCallback = useCallback((el: HTMLDivElement | null) => {
registerModalPortal(el);
}, []);
if (tabs.length === 0) {
return <EmptyDashboard />;
}
const tabLookup = new Map(tabs.map((t) => [t.id, t]));
const stableIds = Array.from(mountedTabIdsRef.current);
return (
<div ref={portalRefCallback} className="relative min-h-0 flex-1 overflow-hidden">
{stableIds.map((tabId) => {
const tab = tabLookup.get(tabId);
if (!tab) return null;
const isActive = tab.id === activeTabId;
const refreshKey = refreshKeys[tab.id] || 0;
return (
<div
key={tab.id}
ref={(el) => setScrollRef(tab.id, el)}
className="absolute inset-0 overflow-hidden"
style={{ display: isActive ? "block" : "none" }}
>
<TabIdProvider value={tab.id}>
<TabPageRenderer tab={tab} refreshKey={refreshKey} />
<ScreenModal key={`modal-${tab.id}-${refreshKey}`} />
</TabIdProvider>
</div>
);
})}
</div>
);
}
function TabPageRenderer({
tab,
refreshKey,
}: {
tab: { id: string; type: string; screenId?: number; menuObjid?: number; adminUrl?: string };
refreshKey: number;
}) {
if (tab.type === "screen" && tab.screenId != null) {
return (
<ScreenViewPageWrapper
key={`${tab.id}-${refreshKey}`}
screenIdProp={tab.screenId}
menuObjidProp={tab.menuObjid}
/>
);
}
if (tab.type === "admin" && tab.adminUrl) {
return (
<div key={`${tab.id}-${refreshKey}`} className="h-full">
<AdminPageRenderer url={tab.adminUrl} />
</div>
);
}
return null;
}

View File

@ -18,6 +18,8 @@ import { useAuth } from "@/hooks/useAuth";
import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
import { ScreenContextProvider } from "@/contexts/ScreenContext";
import { useTabId } from "@/contexts/TabIdContext";
import { useTabStore } from "@/stores/tabStore";
interface EditModalState {
isOpen: boolean;
@ -82,6 +84,9 @@ const findSaveButtonInComponents = (components: any[]): any | null => {
export const EditModal: React.FC<EditModalProps> = ({ className }) => {
const { user } = useAuth();
const tabId = useTabId();
const activeTabId = useTabStore((s) => s[s.mode].activeTabId);
const isTabActive = !tabId || tabId === activeTabId;
const [modalState, setModalState] = useState<EditModalState>({
isOpen: false,
screenId: null,
@ -244,9 +249,13 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
}
};
// 전역 모달 이벤트 리스너
// 전역 모달 이벤트 리스너 (활성 탭에서만 처리)
useEffect(() => {
const handleOpenEditModal = async (event: CustomEvent) => {
const storeState = useTabStore.getState();
const currentActiveTabId = storeState[storeState.mode].activeTabId;
if (tabId && tabId !== currentActiveTabId) return;
const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName, isCreateMode, buttonConfig, buttonContext, menuObjid } = event.detail;
// 🆕 모달 내부 저장 버튼의 제어로직 설정 조회
@ -331,7 +340,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
window.removeEventListener("openEditModal", handleOpenEditModal as EventListener);
window.removeEventListener("closeEditModal", handleCloseEditModal);
};
}, [modalState.onSave]); // modalState.onSave를 의존성에 추가하여 최신 콜백 참조
}, [tabId, modalState.onSave]);
// 화면 데이터 로딩
useEffect(() => {

View File

@ -47,6 +47,7 @@ import { useFormValidation } from "@/hooks/useFormValidation";
import { V2ColumnInfo as ColumnInfo } from "@/types";
import { isFileComponent } from "@/lib/utils/componentTypeUtils";
import { buildGridClasses } from "@/lib/constants/columnSpans";
import { ButtonIconRenderer } from "@/lib/button-icon-map";
import { cn } from "@/lib/utils";
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
@ -2054,7 +2055,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
borderColor: config?.borderColor,
}}
>
{label || "버튼"}
<ButtonIconRenderer componentConfig={(component as any).componentConfig} fallbackLabel={label || "버튼"} />
</button>
);
}

View File

@ -26,6 +26,7 @@ import {
getServerSnapshot as canvasSplitGetServerSnapshot,
subscribeDom as canvasSplitSubscribeDom,
} from "@/lib/registry/components/v2-split-line/canvasSplitStore";
import { ButtonIconRenderer } from "@/lib/button-icon-map";
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
import "@/lib/registry/components/ButtonRenderer";
@ -447,6 +448,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
onClose={() => {
// buttonActions.ts가 이미 처리함
}}
isInModal={isInModal}
// 탭 관련 정보 전달
parentTabId={parentTabId}
parentTabsComponentId={parentTabsComponentId}
@ -956,7 +958,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// 커스텀 색상이 있으면 Tailwind 클래스 대신 직접 스타일 적용
const hasCustomColors = config?.backgroundColor || config?.textColor || comp.style?.backgroundColor || comp.style?.color;
return (
<button
onClick={handleClick}
@ -977,7 +979,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
height: "100%",
}}
>
{label || "버튼"}
<ButtonIconRenderer componentConfig={(comp as any).componentConfig} fallbackLabel={label || "버튼"} />
</button>
);
};
@ -1285,6 +1287,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
const externalLabelComponent = needsExternalLabel ? (
<label
htmlFor={component.id}
className="text-sm font-medium leading-none"
style={{
fontSize: style?.labelFontSize || "14px",
@ -1335,6 +1338,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
isHorizLabel ? (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
<label
htmlFor={component.id}
className="text-sm font-medium leading-none"
style={{
position: "absolute",

View File

@ -15,6 +15,7 @@ import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { executeButtonWithFlow, handleFlowExecutionResult } from "@/lib/utils/nodeFlowButtonExecutor";
import { useCurrentFlowStep } from "@/stores/flowStepStore";
import { ButtonIconRenderer, getButtonDisplayContent } from "@/lib/button-icon-map";
interface OptimizedButtonProps {
component: ComponentData;
@ -645,17 +646,14 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
<Button
onClick={handleClick}
disabled={isExecuting || disabled}
// 색상이 설정되어 있으면 variant를 적용하지 않아서 Tailwind 색상 클래스가 덮어씌우지 않도록 함
variant={hasCustomColors ? undefined : (config?.variant || "default")}
className={cn(
"transition-all duration-200",
isExecuting && "cursor-wait opacity-75",
backgroundJobs.size > 0 && "border-primary/20 bg-accent",
// 커스텀 색상이 없을 때만 기본 스타일 적용
!hasCustomColors && "bg-primary text-primary-foreground hover:bg-primary/90",
)}
style={{
// 커스텀 색상이 있을 때만 인라인 스타일 적용
...(config?.backgroundColor && { backgroundColor: config.backgroundColor }),
...(config?.textColor && { color: config.textColor }),
...(config?.borderColor && { borderColor: config.borderColor }),
@ -664,7 +662,14 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
{/* 메인 버튼 내용 */}
<div className="flex items-center space-x-2">
{getStatusIcon()}
<span>{isExecuting ? "처리 중..." : buttonLabel}</span>
<span>
{isExecuting ? "처리 중..." : (
<ButtonIconRenderer
componentConfig={component.componentConfig}
fallbackLabel={buttonLabel}
/>
)}
</span>
</div>
{/* 개발 모드에서 성능 정보 표시 */}

View File

@ -407,8 +407,8 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
const isButtonComponent =
(type === "widget" && widgetType === "button") ||
(type === "component" &&
(["button-primary", "button-secondary"].includes(componentType) ||
["button-primary", "button-secondary"].includes(componentId)));
(["button-primary", "button-secondary", "v2-button-primary"].includes(componentType) ||
["button-primary", "button-secondary", "v2-button-primary"].includes(componentId)));
// 레거시 분할 패널용 refs
const initialPanelRatioRef = React.useRef<number | null>(null);
@ -548,13 +548,16 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
const origWidth = size?.width || 100;
const isSplitShrunk = splitAdjustedWidth !== null && splitAdjustedWidth < origWidth;
// v2 수평 라벨 컴포넌트: position wrapper에서 border 제거 (DynamicComponentRenderer가 내부에서 처리)
// position wrapper에서 border 제거 (내부 컴포넌트가 자체적으로 border를 렌더링하는 경우)
// - v2 수평 라벨 컴포넌트: DynamicComponentRenderer가 내부에서 처리
// - 버튼 컴포넌트: buttonElementStyle에서 자체 border 적용
const isV2HorizLabel = !!(
componentStyle &&
(componentStyle.labelDisplay === true || componentStyle.labelDisplay === "true") &&
(componentStyle.labelPosition === "left" || componentStyle.labelPosition === "right")
);
const safeComponentStyle = isV2HorizLabel
const needsStripBorder = isV2HorizLabel || isButtonComponent;
const safeComponentStyle = needsStripBorder
? (() => {
const { borderWidth, borderColor, borderStyle, border, borderRadius, ...rest } = componentStyle as any;
return rest;

View File

@ -12,7 +12,6 @@ import { screenApi } from "@/lib/api/screen";
import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
import { ComponentData } from "@/lib/types/screen";
import { useAuth } from "@/hooks/useAuth";
interface SaveModalProps {
isOpen: boolean;
onClose: () => void;
@ -106,46 +105,6 @@ export const SaveModal: React.FC<SaveModalProps> = ({
};
}, [onClose]);
// 필수 항목 검증
const validateRequiredFields = (): { isValid: boolean; missingFields: string[] } => {
const missingFields: string[] = [];
components.forEach((component) => {
// 컴포넌트의 required 속성 확인 (여러 위치에서 체크)
const isRequired =
component.required === true ||
component.style?.required === true ||
component.componentConfig?.required === true;
const columnName = component.columnName || component.style?.columnName;
const label = component.label || component.style?.label || columnName;
console.log("🔍 필수 항목 검증:", {
componentId: component.id,
columnName,
label,
isRequired,
"component.required": component.required,
"style.required": component.style?.required,
"componentConfig.required": component.componentConfig?.required,
value: formData[columnName || ""],
});
if (isRequired && columnName) {
const value = formData[columnName];
// 값이 비어있는지 확인 (null, undefined, 빈 문자열, 공백만 있는 문자열)
if (value === null || value === undefined || (typeof value === "string" && value.trim() === "")) {
missingFields.push(label || columnName);
}
}
});
return {
isValid: missingFields.length === 0,
missingFields,
};
};
// 저장 핸들러
const handleSave = async () => {
if (!screenData || !screenId) return;
@ -156,13 +115,6 @@ export const SaveModal: React.FC<SaveModalProps> = ({
return;
}
// ✅ 필수 항목 검증
const validation = validateRequiredFields();
if (!validation.isValid) {
toast.error(`필수 항목을 입력해주세요: ${validation.missingFields.join(", ")}`);
return;
}
try {
setIsSaving(true);
@ -325,7 +277,12 @@ export const SaveModal: React.FC<SaveModalProps> = ({
<div className="flex items-center justify-between">
<DialogTitle className="text-lg font-semibold">{initialData ? "데이터 수정" : "데이터 등록"}</DialogTitle>
<div className="flex items-center gap-2">
<Button onClick={handleSave} disabled={isSaving} size="sm" className="gap-2">
<Button
onClick={handleSave}
disabled={isSaving}
size="sm"
className="gap-2"
>
{isSaving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />

View File

@ -5475,7 +5475,6 @@ export default function ScreenDesigner({
{ ctrl: true, key: "s" }, // 저장 (필요시 차단 해제)
{ ctrl: true, key: "p" }, // 인쇄
{ ctrl: true, key: "o" }, // 파일 열기
{ ctrl: true, key: "v" }, // 붙여넣기 (브라우저 기본 동작 차단)
// 개발자 도구
{ key: "F12" }, // 개발자 도구
@ -5500,7 +5499,20 @@ export default function ScreenDesigner({
return ctrlMatch && shiftMatch && keyMatch;
});
// 입력 필드(input, textarea 등)에 포커스 시 편집 단축키는 기본 동작 허용
const _target = e.target as HTMLElement;
const _activeEl = document.activeElement as HTMLElement;
const _isEditable = (el: HTMLElement | null) =>
el instanceof HTMLInputElement ||
el instanceof HTMLTextAreaElement ||
el instanceof HTMLSelectElement ||
el?.isContentEditable;
const isEditableFieldFocused = _isEditable(_target) || _isEditable(_activeEl);
if (isBrowserShortcut) {
if (isEditableFieldFocused) {
return;
}
// console.log("🚫 브라우저 기본 단축키 차단:", e.key);
e.preventDefault();
e.stopPropagation();
@ -5509,6 +5521,11 @@ export default function ScreenDesigner({
// ✅ 애플리케이션 전용 단축키 처리
// 입력 필드 포커스 시 앱 단축키 무시 (텍스트 편집 우선)
if (isEditableFieldFocused && (e.ctrlKey || e.metaKey)) {
return;
}
// 1. 그룹 관련 단축키
if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "g" && !e.shiftKey) {
// console.log("🔄 그룹 생성 단축키");
@ -5585,7 +5602,6 @@ export default function ScreenDesigner({
// 5. 붙여넣기 (컴포넌트 붙여넣기)
if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "v") {
// console.log("🔄 컴포넌트 붙여넣기");
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();

View File

@ -10,7 +10,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Check, ChevronsUpDown, Search, Plus, X, ChevronUp, ChevronDown, Type, Database } from "lucide-react";
import { Check, ChevronsUpDown, Search, Plus, X, ChevronUp, ChevronDown, Type, Database, Info, RotateCcw } from "lucide-react";
import { cn } from "@/lib/utils";
import { ComponentData } from "@/types/screen";
import { apiClient } from "@/lib/api/client";
@ -18,6 +18,18 @@ import { ButtonDataflowConfigPanel } from "./ButtonDataflowConfigPanel";
import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel";
import { FlowVisibilityConfigPanel } from "./FlowVisibilityConfigPanel";
import { QuickInsertConfigSection } from "./QuickInsertConfigSection";
import DOMPurify from "isomorphic-dompurify";
import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent";
import { icons as allLucideIcons } from "lucide-react";
import {
actionIconMap,
noIconActions,
NO_ICON_MESSAGE,
iconSizePresets,
getLucideIcon,
addToIconMap,
getDefaultIconForAction,
} from "@/lib/button-icon-map";
// 🆕 제목 블록 타입
interface TitleBlock {
@ -70,6 +82,29 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
groupByColumn: String(config.action?.groupByColumns?.[0] || ""),
});
// 아이콘 설정 상태
const [displayMode, setDisplayMode] = useState<"text" | "icon" | "icon-text">(
config.displayMode || "text",
);
const [selectedIcon, setSelectedIcon] = useState<string>(config.icon?.name || "");
const [selectedIconType, setSelectedIconType] = useState<"lucide" | "svg">(
config.icon?.type || "lucide",
);
const [iconSize, setIconSize] = useState<string>(config.icon?.size || "보통");
const [iconColor, setIconColor] = useState<string>(config.icon?.color || "");
const [iconGap, setIconGap] = useState<number>(config.iconGap ?? 6);
const [iconTextPosition, setIconTextPosition] = useState<"right" | "left" | "bottom" | "top">(
config.iconTextPosition || "right",
);
// 커스텀 아이콘 UI 상태
const [lucideSearchOpen, setLucideSearchOpen] = useState(false);
const [lucideSearchTerm, setLucideSearchTerm] = useState("");
const [svgPasteOpen, setSvgPasteOpen] = useState(false);
const [svgInput, setSvgInput] = useState("");
const [svgName, setSvgName] = useState("");
const [svgError, setSvgError] = useState("");
const [screens, setScreens] = useState<ScreenOption[]>([]);
const [screensLoading, setScreensLoading] = useState(false);
const [modalScreenOpen, setModalScreenOpen] = useState(false);
@ -778,38 +813,144 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
);
};
// 아이콘 선택 핸들러
const handleSelectIcon = (iconName: string, iconType: "lucide" | "svg" = "lucide") => {
setSelectedIcon(iconName);
setSelectedIconType(iconType);
onUpdateProperty("componentConfig.icon", {
name: iconName,
type: iconType,
size: iconSize,
...(iconColor ? { color: iconColor } : {}),
});
};
// 선택 중인 아이콘이 삭제되었을 때 디폴트 아이콘으로 복귀
const revertToDefaultIcon = () => {
const def = getDefaultIconForAction(localInputs.actionType);
setSelectedIcon(def.name);
setSelectedIconType(def.type);
handleSelectIcon(def.name, def.type);
};
// 표시 모드 변경 핸들러 — icon/icon-text 전환 시 아이콘 미선택이면 디폴트 부여
const handleDisplayModeChange = (mode: "text" | "icon" | "icon-text") => {
setDisplayMode(mode);
onUpdateProperty("componentConfig.displayMode", mode);
if ((mode === "icon" || mode === "icon-text") && !selectedIcon) {
revertToDefaultIcon();
}
};
// 아이콘 크기 프리셋 변경 (아이콘 미선택 시 로컬만 업데이트)
const handleIconSizePreset = (preset: string) => {
setIconSize(preset);
if (selectedIcon) {
onUpdateProperty("componentConfig.icon.size", preset);
}
};
// 아이콘 색상 변경
const handleIconColorChange = (color: string | undefined) => {
const val = color || "";
setIconColor(val);
if (selectedIcon) {
if (val) {
onUpdateProperty("componentConfig.icon.color", val);
} else {
onUpdateProperty("componentConfig.icon.color", undefined);
}
}
};
// 현재 액션의 추천 아이콘 목록
const currentActionIcons = actionIconMap[localInputs.actionType] || [];
const isNoIconAction = noIconActions.has(localInputs.actionType);
const customIcons: string[] = config.customIcons || [];
const customSvgIcons: Array<{ name: string; svg: string }> = config.customSvgIcons || [];
const showIconSettings = displayMode === "icon" || displayMode === "icon-text";
return (
<div className="space-y-4">
{/* 표시 모드 선택 */}
<div>
<Label htmlFor="button-text"> </Label>
<Input
id="button-text"
value={localInputs.text}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, text: newValue }));
onUpdateProperty("componentConfig.text", newValue);
}}
placeholder="버튼 텍스트를 입력하세요"
/>
<Label className="mb-1.5 block text-xs sm:text-sm"> </Label>
<div className="flex rounded-md border">
{(
[
{ value: "text", label: "텍스트" },
{ value: "icon", label: "아이콘" },
{ value: "icon-text", label: "아이콘+텍스트" },
] as const
).map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => handleDisplayModeChange(opt.value)}
className={cn(
"flex-1 px-2 py-1.5 text-xs font-medium transition-colors first:rounded-l-md last:rounded-r-md",
displayMode === opt.value
? "bg-primary text-primary-foreground"
: "hover:bg-muted text-muted-foreground",
)}
>
{opt.label}
</button>
))}
</div>
</div>
{/* 아이콘 모드 레이아웃 안내 */}
{displayMode === "icon" && (
<div className="flex items-start gap-2 rounded-md bg-blue-50 p-2.5 text-xs text-blue-700 dark:bg-blue-950/20 dark:text-blue-300">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span> .</span>
</div>
)}
{/* 버튼 텍스트 (텍스트 / 아이콘+텍스트 모드에서 표시) */}
{(displayMode === "text" || displayMode === "icon-text") && (
<div>
<Label htmlFor="button-text"> </Label>
<Input
id="button-text"
value={localInputs.text}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, text: newValue }));
onUpdateProperty("componentConfig.text", newValue);
}}
placeholder="버튼 텍스트를 입력하세요"
/>
</div>
)}
<div>
<Label htmlFor="button-action"> </Label>
<Label htmlFor="button-action" className="mb-1.5 block"> </Label>
<Select
key={`action-${component.id}`}
value={localInputs.actionType}
onValueChange={(value) => {
// 🔥 로컬 상태 먼저 업데이트
setLocalInputs((prev) => ({ ...prev, actionType: value }));
// 🔥 action.type 업데이트
onUpdateProperty("componentConfig.action.type", value);
// 🔥 색상 업데이트는 충분히 지연 (React 리렌더링 완료 후)
// 액션 변경 시: 선택된 아이콘이 새 액션의 추천 목록에 없으면 초기화
const newActionIcons = actionIconMap[value] || [];
if (
selectedIcon &&
selectedIconType === "lucide" &&
!newActionIcons.includes(selectedIcon) &&
!customIcons.includes(selectedIcon)
) {
setSelectedIcon("");
onUpdateProperty("componentConfig.icon", undefined);
}
setTimeout(() => {
const newColor = value === "delete" ? "#ef4444" : "#212121";
onUpdateProperty("style.labelColor", newColor);
}, 100); // 0 → 100ms로 증가
}, 100);
}}
>
<SelectTrigger>
@ -842,10 +983,12 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
{/* 복사 */}
<SelectItem value="copy"> ( )</SelectItem>
{/* 🔒 - , UI
{/* 🔒 숨김 처리 - 기존 시스템 호환성 유지, UI에서만 숨김 */}
{/* 테스트용 임시 노출 */}
<SelectItem value="view_table_history"> </SelectItem>
{/*
<SelectItem value="openRelatedModal"> </SelectItem>
<SelectItem value="openModalWithData">(deprecated) + </SelectItem>
<SelectItem value="view_table_history"> </SelectItem>
<SelectItem value="code_merge"> </SelectItem>
<SelectItem value="empty_vehicle"></SelectItem>
*/}
@ -853,6 +996,584 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</Select>
</div>
{/* ────────────────── 아이콘 설정 영역 ────────────────── */}
{showIconSettings && (
<div className="space-y-4">
{/* 추천 아이콘 / 안내 문구 */}
{isNoIconAction ? (
<div>
<div className="rounded-md border border-dashed p-3 text-center text-xs text-muted-foreground">
{NO_ICON_MESSAGE}
</div>
{/* 커스텀 아이콘이 있으면 표시 */}
{(customIcons.length > 0 || customSvgIcons.length > 0) && (
<>
<div className="my-2 flex items-center gap-2">
<div className="h-px flex-1 bg-border" />
<span className="text-[10px] text-muted-foreground"> </span>
<div className="h-px flex-1 bg-border" />
</div>
<div className="grid grid-cols-4 gap-1.5">
{customIcons.map((iconName) => {
const Icon = getLucideIcon(iconName);
if (!Icon) return null;
return (
<div key={`custom-${iconName}`} className="relative">
<button
type="button"
onClick={() => handleSelectIcon(iconName, "lucide")}
className={cn(
"flex w-full flex-col items-center gap-1 rounded-md border p-2 transition-colors hover:bg-muted",
selectedIcon === iconName && selectedIconType === "lucide"
? "border-primary ring-2 ring-primary/30 bg-primary/5"
: "border-transparent",
)}
>
<Icon className="h-6 w-6" />
<span className="truncate text-[10px] text-muted-foreground">{iconName}</span>
</button>
<button
type="button"
onClick={() => {
const next = customIcons.filter((n) => n !== iconName);
onUpdateProperty("componentConfig.customIcons", next);
if (selectedIcon === iconName) revertToDefaultIcon();
}}
className="absolute -top-1 -right-1 rounded-full bg-destructive p-0.5 text-destructive-foreground hover:bg-destructive/80"
>
<X className="h-3 w-3" />
</button>
</div>
);
})}
{customSvgIcons.map((svgIcon) => (
<div key={`svg-${svgIcon.name}`} className="relative">
<button
type="button"
onClick={() => handleSelectIcon(svgIcon.name, "svg")}
className={cn(
"flex w-full flex-col items-center gap-1 rounded-md border p-2 transition-colors hover:bg-muted",
selectedIcon === svgIcon.name && selectedIconType === "svg"
? "border-primary ring-2 ring-primary/30 bg-primary/5"
: "border-transparent",
)}
>
<span
className="flex h-6 w-6 items-center justify-center [&>svg]:h-full [&>svg]:w-full"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(svgIcon.svg, { USE_PROFILES: { svg: true } }),
}}
/>
<span className="truncate text-[10px] text-muted-foreground">{svgIcon.name}</span>
</button>
<button
type="button"
onClick={() => {
const next = customSvgIcons.filter((s) => s.name !== svgIcon.name);
onUpdateProperty("componentConfig.customSvgIcons", next);
if (selectedIcon === svgIcon.name) revertToDefaultIcon();
}}
className="absolute -top-1 -right-1 rounded-full bg-destructive p-0.5 text-destructive-foreground hover:bg-destructive/80"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
</>
)}
{/* 커스텀 아이콘 추가 버튼 */}
<div className="mt-2 flex gap-2">
<Popover open={lucideSearchOpen} onOpenChange={setLucideSearchOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-7 flex-1 text-xs">
<Plus className="mr-1 h-3 w-3" />
lucide
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-0" align="start">
<Command>
<CommandInput
placeholder="아이콘 이름 검색..."
value={lucideSearchTerm}
onValueChange={setLucideSearchTerm}
className="text-xs"
/>
<CommandList className="max-h-48">
<CommandEmpty className="py-3 text-xs"> .</CommandEmpty>
<CommandGroup>
{Object.keys(allLucideIcons)
.filter((name) => name.toLowerCase().includes(lucideSearchTerm.toLowerCase()))
.slice(0, 30)
.map((iconName) => {
const Icon = allLucideIcons[iconName as keyof typeof allLucideIcons];
return (
<CommandItem
key={iconName}
value={iconName}
onSelect={() => {
const next = [...customIcons];
if (!next.includes(iconName)) {
next.push(iconName);
onUpdateProperty("componentConfig.customIcons", next);
if (Icon) addToIconMap(iconName, Icon);
}
setLucideSearchOpen(false);
setLucideSearchTerm("");
}}
className="flex items-center gap-2 text-xs"
>
{Icon ? <Icon className="h-4 w-4" /> : <span className="h-4 w-4" />}
{iconName}
{customIcons.includes(iconName) && <Check className="ml-auto h-3 w-3 text-primary" />}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Popover open={svgPasteOpen} onOpenChange={setSvgPasteOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-7 flex-1 text-xs">
<Plus className="mr-1 h-3 w-3" />
SVG
</Button>
</PopoverTrigger>
<PopoverContent className="w-72 space-y-2 p-3" align="start">
<Label className="text-xs"> </Label>
<Input
value={svgName}
onChange={(e) => setSvgName(e.target.value)}
placeholder="예: 회사로고"
className="h-7 text-xs"
/>
<Label className="text-xs">SVG </Label>
<textarea
value={svgInput}
onChange={(e) => {
setSvgInput(e.target.value);
setSvgError("");
}}
onPaste={(e) => {
e.stopPropagation();
const text = e.clipboardData.getData("text/plain");
if (text) {
e.preventDefault();
setSvgInput(text);
setSvgError("");
}
}}
onKeyDown={(e) => e.stopPropagation()}
placeholder={'<svg xmlns="http://www.w3.org/2000/svg" ...>...</svg>'}
className="h-20 w-full rounded-md border bg-background px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-ring"
/>
{svgInput && (
<div className="flex items-center justify-center rounded border bg-muted/50 p-2">
<span
className="flex h-8 w-8 items-center justify-center [&>svg]:h-full [&>svg]:w-full"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(svgInput, { USE_PROFILES: { svg: true } }),
}}
/>
</div>
)}
{svgError && <p className="text-xs text-destructive">{svgError}</p>}
<Button
size="sm"
className="h-7 w-full text-xs"
onClick={() => {
if (!svgName.trim()) {
setSvgError("아이콘 이름을 입력하세요.");
return;
}
if (!svgInput.trim().includes("<svg")) {
setSvgError("유효한 SVG 코드가 아닙니다.");
return;
}
const sanitized = DOMPurify.sanitize(svgInput, { USE_PROFILES: { svg: true } });
let finalName = svgName.trim();
const existingNames = new Set(customSvgIcons.map((s) => s.name));
if (existingNames.has(finalName)) {
let counter = 2;
while (existingNames.has(`${svgName.trim()}(${counter})`)) counter++;
finalName = `${svgName.trim()}(${counter})`;
}
const next = [...customSvgIcons, { name: finalName, svg: sanitized }];
onUpdateProperty("componentConfig.customSvgIcons", next);
setSvgInput("");
setSvgName("");
setSvgError("");
setSvgPasteOpen(false);
}}
>
</Button>
</PopoverContent>
</Popover>
</div>
</div>
) : (
<div>
<Label className="mb-1.5 block text-xs sm:text-sm"> </Label>
<div className="grid grid-cols-4 gap-1.5">
{currentActionIcons.map((iconName) => {
const Icon = getLucideIcon(iconName);
if (!Icon) return null;
return (
<button
key={iconName}
type="button"
onClick={() => handleSelectIcon(iconName, "lucide")}
className={cn(
"flex flex-col items-center gap-1 rounded-md border p-2 transition-colors hover:bg-muted",
selectedIcon === iconName && selectedIconType === "lucide"
? "border-primary ring-2 ring-primary/30 bg-primary/5"
: "border-transparent",
)}
>
<Icon className="h-6 w-6" />
<span className="truncate text-[10px] text-muted-foreground">{iconName}</span>
</button>
);
})}
</div>
{/* 커스텀 아이콘 영역 */}
{(customIcons.length > 0 || customSvgIcons.length > 0) && (
<>
<div className="my-2 flex items-center gap-2">
<div className="h-px flex-1 bg-border" />
<span className="text-[10px] text-muted-foreground"> </span>
<div className="h-px flex-1 bg-border" />
</div>
<div className="grid grid-cols-4 gap-1.5">
{customIcons.map((iconName) => {
const Icon = getLucideIcon(iconName);
if (!Icon) return null;
return (
<div key={`custom-${iconName}`} className="relative">
<button
type="button"
onClick={() => handleSelectIcon(iconName, "lucide")}
className={cn(
"flex w-full flex-col items-center gap-1 rounded-md border p-2 transition-colors hover:bg-muted",
selectedIcon === iconName && selectedIconType === "lucide"
? "border-primary ring-2 ring-primary/30 bg-primary/5"
: "border-transparent",
)}
>
<Icon className="h-6 w-6" />
<span className="truncate text-[10px] text-muted-foreground">{iconName}</span>
</button>
<button
type="button"
onClick={() => {
const next = customIcons.filter((n) => n !== iconName);
onUpdateProperty("componentConfig.customIcons", next);
if (selectedIcon === iconName) revertToDefaultIcon();
}}
className="absolute -top-1 -right-1 rounded-full bg-destructive p-0.5 text-destructive-foreground hover:bg-destructive/80"
>
<X className="h-3 w-3" />
</button>
</div>
);
})}
{customSvgIcons.map((svgIcon) => (
<div key={`svg-${svgIcon.name}`} className="relative">
<button
type="button"
onClick={() => handleSelectIcon(svgIcon.name, "svg")}
className={cn(
"flex w-full flex-col items-center gap-1 rounded-md border p-2 transition-colors hover:bg-muted",
selectedIcon === svgIcon.name && selectedIconType === "svg"
? "border-primary ring-2 ring-primary/30 bg-primary/5"
: "border-transparent",
)}
>
<span
className="flex h-6 w-6 items-center justify-center [&>svg]:h-full [&>svg]:w-full"
dangerouslySetInnerHTML={{ __html: svgIcon.svg }}
/>
<span className="truncate text-[10px] text-muted-foreground">{svgIcon.name}</span>
</button>
<button
type="button"
onClick={() => {
const next = customSvgIcons.filter((s) => s.name !== svgIcon.name);
onUpdateProperty("componentConfig.customSvgIcons", next);
if (selectedIcon === svgIcon.name) revertToDefaultIcon();
}}
className="absolute -top-1 -right-1 rounded-full bg-destructive p-0.5 text-destructive-foreground hover:bg-destructive/80"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
</>
)}
{/* 커스텀 아이콘 추가 버튼 */}
<div className="mt-2 flex gap-2">
<Popover open={lucideSearchOpen} onOpenChange={setLucideSearchOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-7 flex-1 text-xs">
<Plus className="mr-1 h-3 w-3" />
lucide
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-0" align="start">
<Command>
<CommandInput
placeholder="아이콘 이름 검색..."
value={lucideSearchTerm}
onValueChange={setLucideSearchTerm}
className="text-xs"
/>
<CommandList className="max-h-48">
<CommandEmpty className="py-3 text-xs"> .</CommandEmpty>
<CommandGroup>
{Object.keys(allLucideIcons)
.filter((name) => name.toLowerCase().includes(lucideSearchTerm.toLowerCase()))
.slice(0, 30)
.map((iconName) => {
const Icon = allLucideIcons[iconName as keyof typeof allLucideIcons];
return (
<CommandItem
key={iconName}
value={iconName}
onSelect={() => {
const next = [...customIcons];
if (!next.includes(iconName)) {
next.push(iconName);
onUpdateProperty("componentConfig.customIcons", next);
// iconMap에 동적 추가 (렌더링용)
if (Icon) addToIconMap(iconName, Icon);
}
setLucideSearchOpen(false);
setLucideSearchTerm("");
}}
className="flex items-center gap-2 text-xs"
>
{Icon ? <Icon className="h-4 w-4" /> : <span className="h-4 w-4" />}
{iconName}
{customIcons.includes(iconName) && <Check className="ml-auto h-3 w-3 text-primary" />}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Popover open={svgPasteOpen} onOpenChange={setSvgPasteOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-7 flex-1 text-xs">
<Plus className="mr-1 h-3 w-3" />
SVG
</Button>
</PopoverTrigger>
<PopoverContent className="w-72 space-y-2 p-3" align="start">
<Label className="text-xs"> </Label>
<Input
value={svgName}
onChange={(e) => setSvgName(e.target.value)}
placeholder="예: 회사로고"
className="h-7 text-xs"
/>
<Label className="text-xs">SVG </Label>
<textarea
value={svgInput}
onChange={(e) => {
setSvgInput(e.target.value);
setSvgError("");
}}
onPaste={(e) => {
e.stopPropagation();
const text = e.clipboardData.getData("text/plain");
if (text) {
e.preventDefault();
setSvgInput(text);
setSvgError("");
}
}}
onKeyDown={(e) => e.stopPropagation()}
placeholder={'<svg xmlns="http://www.w3.org/2000/svg" ...>...</svg>'}
className="h-20 w-full rounded-md border bg-background px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-ring"
/>
{svgInput && (
<div className="flex items-center justify-center rounded border bg-muted/50 p-2">
<span
className="flex h-8 w-8 items-center justify-center [&>svg]:h-full [&>svg]:w-full"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(svgInput, { USE_PROFILES: { svg: true } }),
}}
/>
</div>
)}
{svgError && <p className="text-xs text-destructive">{svgError}</p>}
<Button
size="sm"
className="h-7 w-full text-xs"
onClick={() => {
if (!svgName.trim()) {
setSvgError("아이콘 이름을 입력하세요.");
return;
}
if (!svgInput.trim().includes("<svg")) {
setSvgError("유효한 SVG 코드가 아닙니다.");
return;
}
const sanitized = DOMPurify.sanitize(svgInput, { USE_PROFILES: { svg: true } });
let finalName = svgName.trim();
const existingNames = new Set(customSvgIcons.map((s) => s.name));
if (existingNames.has(finalName)) {
let counter = 2;
while (existingNames.has(`${svgName.trim()}(${counter})`)) counter++;
finalName = `${svgName.trim()}(${counter})`;
}
const next = [...customSvgIcons, { name: finalName, svg: sanitized }];
onUpdateProperty("componentConfig.customSvgIcons", next);
setSvgInput("");
setSvgName("");
setSvgError("");
setSvgPasteOpen(false);
}}
>
</Button>
</PopoverContent>
</Popover>
</div>
</div>
)}
{/* 아이콘 크기 비율 */}
<div>
<Label className="mb-1.5 block text-xs sm:text-sm"> </Label>
<div className="flex rounded-md border">
{Object.keys(iconSizePresets).map((preset) => (
<button
key={preset}
type="button"
onClick={() => handleIconSizePreset(preset)}
className={cn(
"flex-1 px-1 py-1 text-xs font-medium whitespace-nowrap transition-colors first:rounded-l-md last:rounded-r-md",
iconSize === preset
? "bg-primary text-primary-foreground"
: "hover:bg-muted text-muted-foreground",
)}
>
{preset}
</button>
))}
</div>
</div>
{/* 텍스트 위치 (icon-text 모드 전용) */}
{displayMode === "icon-text" && (
<div>
<Label className="mb-1.5 block text-xs sm:text-sm"> </Label>
<div className="flex rounded-md border">
{(
[
{ value: "left", label: "왼쪽" },
{ value: "right", label: "오른쪽" },
{ value: "top", label: "위쪽" },
{ value: "bottom", label: "아래쪽" },
] as const
).map((pos) => (
<button
key={pos.value}
type="button"
onClick={() => {
setIconTextPosition(pos.value);
onUpdateProperty("componentConfig.iconTextPosition", pos.value);
}}
className={cn(
"flex-1 px-2 py-1 text-xs font-medium transition-colors first:rounded-l-md last:rounded-r-md",
iconTextPosition === pos.value
? "bg-primary text-primary-foreground"
: "hover:bg-muted text-muted-foreground",
)}
>
{pos.label}
</button>
))}
</div>
</div>
)}
{/* 아이콘-텍스트 간격 (icon-text 모드 전용) */}
{displayMode === "icon-text" && (
<div>
<Label className="mb-1.5 block text-xs sm:text-sm">- </Label>
<div className="flex items-center gap-2">
<input
type="range"
min={0}
max={32}
step={1}
value={Math.min(iconGap, 32)}
onChange={(e) => {
const val = Number(e.target.value);
setIconGap(val);
onUpdateProperty("componentConfig.iconGap", val);
}}
className="h-1.5 flex-1 cursor-pointer accent-primary"
/>
<div className="flex items-center gap-1">
<Input
type="number"
min={0}
value={iconGap}
onChange={(e) => {
const val = Math.max(0, Number(e.target.value) || 0);
setIconGap(val);
onUpdateProperty("componentConfig.iconGap", val);
}}
className="h-7 w-14 text-center text-xs"
/>
<span className="text-xs text-muted-foreground">px</span>
</div>
</div>
</div>
)}
{/* 아이콘 색상 */}
<div>
<Label className="mb-1.5 block text-xs sm:text-sm"> </Label>
<div className="flex items-center gap-2">
<ColorPickerWithTransparent
value={iconColor || undefined}
onChange={handleIconColorChange}
placeholder="텍스트 색상 상속"
className="flex-1"
/>
{iconColor && (
<Button
variant="ghost"
size="sm"
className="h-7 shrink-0 text-xs"
onClick={() => handleIconColorChange(undefined)}
>
<RotateCcw className="mr-1 h-3 w-3" />
</Button>
)}
</div>
</div>
</div>
)}
{/* 모달 열기 액션 설정 */}
{localInputs.actionType === "modal" && (
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">

View File

@ -2,6 +2,7 @@
import React from "react";
import { WebTypeComponentProps } from "@/lib/registry/types";
import { ButtonIconRenderer } from "@/lib/button-icon-map";
export const ButtonWidget: React.FC<WebTypeComponentProps> = ({
config,
@ -14,38 +15,34 @@ export const ButtonWidget: React.FC<WebTypeComponentProps> = ({
required,
className,
style,
isDesignMode = false, // 디자인 모드 플래그
isDesignMode = false,
...restProps
}) => {
const handleClick = (e: React.MouseEvent) => {
// 디자인 모드에서는 아무것도 하지 않고 그냥 이벤트 전파
if (isDesignMode) {
return;
}
// 버튼 클릭 시 동작 (추후 버튼 액션 시스템과 연동)
console.log("Button clicked:", config);
// onChange를 통해 클릭 이벤트 전달
if (onChange) {
onChange("clicked");
}
};
// 커스텀 색상 확인 (config 또는 style에서)
const hasCustomBg = config?.backgroundColor || style?.backgroundColor;
const hasCustomColor = config?.textColor || style?.color;
const hasCustomColors = hasCustomBg || hasCustomColor;
// 실제 적용할 배경색과 글자색
const bgColor = config?.backgroundColor || style?.backgroundColor;
const textColor = config?.textColor || style?.color;
// 디자인 모드에서는 div로 렌더링하여 버튼 동작 완전 차단
const fallbackLabel = config?.label || config?.text || (value as string) || placeholder || "버튼";
if (isDesignMode) {
return (
<div
onClick={handleClick} // 클릭 핸들러 추가하여 이벤트 전파
onClick={handleClick}
className={`flex items-center justify-center rounded-md px-4 text-sm font-medium ${
hasCustomColors ? '' : 'bg-blue-600 text-white'
} ${className || ""}`}
@ -55,11 +52,10 @@ export const ButtonWidget: React.FC<WebTypeComponentProps> = ({
color: textColor,
width: "100%",
height: "100%",
cursor: "pointer", // 선택 가능하도록 포인터 표시
cursor: "pointer",
}}
title={config?.tooltip || placeholder}
>
{config?.label || config?.text || value || placeholder || "버튼"}
<ButtonIconRenderer componentConfig={config} fallbackLabel={fallbackLabel} />
</div>
);
}
@ -79,9 +75,8 @@ export const ButtonWidget: React.FC<WebTypeComponentProps> = ({
width: "100%",
height: "100%",
}}
title={config?.tooltip || placeholder}
>
{config?.label || config?.text || value || placeholder || "버튼"}
<ButtonIconRenderer componentConfig={config} fallbackLabel={fallbackLabel} />
</button>
);
};

View File

@ -4,6 +4,7 @@ import React from "react";
import { Input } from "@/components/ui/input";
import { WebTypeComponentProps } from "@/lib/registry/types";
import { WidgetComponent, NumberTypeConfig } from "@/types/screen";
import { formatNumber as formatNum, formatCurrency } from "@/lib/formatting";
export const NumberWidget: React.FC<WebTypeComponentProps> = ({ component, value, onChange, readonly = false }) => {
const widget = component as WidgetComponent;
@ -21,10 +22,7 @@ export const NumberWidget: React.FC<WebTypeComponentProps> = ({ component, value
if (isNaN(numValue)) return "";
if (config?.format === "currency") {
return new Intl.NumberFormat("ko-KR", {
style: "currency",
currency: "KRW",
}).format(numValue);
return formatCurrency(numValue);
}
if (config?.format === "percentage") {
@ -32,7 +30,7 @@ export const NumberWidget: React.FC<WebTypeComponentProps> = ({ component, value
}
if (config?.thousandSeparator) {
return new Intl.NumberFormat("ko-KR").format(numValue);
return formatNum(numValue);
}
return numValue.toString();

View File

@ -2,11 +2,64 @@
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
import { useModalPortal } from "@/lib/modalPortalRef";
import { useTabId } from "@/contexts/TabIdContext";
import { useTabStore } from "@/stores/tabStore";
const AlertDialog = AlertDialogPrimitive.Root;
/**
* Context.
* scoped=true AlertDialogPrimitive DialogPrimitive .
*/
const ScopedAlertCtx = React.createContext(false);
const AlertDialog: React.FC<React.ComponentProps<typeof AlertDialogPrimitive.Root>> = ({
open,
children,
onOpenChange,
...props
}) => {
const autoContainer = useModalPortal();
const scoped = !!autoContainer;
const tabId = useTabId();
const activeTabId = useTabStore((s) => s[s.mode].activeTabId);
const isTabActive = !tabId || tabId === activeTabId;
const isTabActiveRef = React.useRef(isTabActive);
isTabActiveRef.current = isTabActive;
const effectiveOpen = open != null ? open && isTabActive : undefined;
const guardedOnOpenChange = React.useCallback(
(newOpen: boolean) => {
if (scoped && !newOpen && !isTabActiveRef.current) return;
onOpenChange?.(newOpen);
},
[scoped, onOpenChange],
);
if (scoped) {
return (
<ScopedAlertCtx.Provider value={true}>
<DialogPrimitive.Root open={effectiveOpen} onOpenChange={guardedOnOpenChange} modal={false}>
{children}
</DialogPrimitive.Root>
</ScopedAlertCtx.Provider>
);
}
return (
<ScopedAlertCtx.Provider value={false}>
<AlertDialogPrimitive.Root {...props} open={effectiveOpen} onOpenChange={guardedOnOpenChange}>
{children}
</AlertDialogPrimitive.Root>
</ScopedAlertCtx.Provider>
);
};
AlertDialog.displayName = "AlertDialog";
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
@ -18,7 +71,7 @@ const AlertDialogOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-[1050] bg-black/80",
"fixed inset-0 z-1050 bg-black/80",
className,
)}
{...props}
@ -27,22 +80,82 @@ const AlertDialogOverlay = React.forwardRef<
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
interface ScopedAlertDialogContentProps
extends React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content> {
container?: HTMLElement | null;
hidden?: boolean;
}
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"bg-background fixed top-[50%] left-[50%] z-[1100] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
));
ScopedAlertDialogContentProps
>(({ className, container: explicitContainer, hidden: hiddenProp, style, ...props }, ref) => {
const autoContainer = useModalPortal();
const container = explicitContainer !== undefined ? explicitContainer : autoContainer;
const scoped = React.useContext(ScopedAlertCtx);
const adjustedStyle = scoped && style
? { ...style, maxHeight: undefined, maxWidth: undefined }
: style;
const handleInteractOutside = React.useCallback(
(e: any) => {
if (scoped && container) {
const target = (e.detail?.originalEvent?.target ?? e.target) as HTMLElement | null;
if (target && !container.contains(target)) {
e.preventDefault();
return;
}
}
e.preventDefault();
},
[scoped, container],
);
if (scoped) {
return (
<DialogPrimitive.Portal container={container ?? undefined}>
<div
className="absolute inset-0 z-1050 flex items-center justify-center overflow-hidden p-4"
style={hiddenProp ? { display: "none" } : undefined}
>
<div className="absolute inset-0 bg-black/80" />
<DialogPrimitive.Content
ref={ref}
onInteractOutside={handleInteractOutside}
onFocusOutside={(e: any) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
className={cn(
"bg-background relative z-1 grid w-full max-w-lg max-h-full gap-4 border p-6 shadow-lg sm:rounded-lg",
className,
)}
style={adjustedStyle}
{...props}
/>
</div>
</DialogPrimitive.Portal>
);
}
return (
<AlertDialogPortal container={container ?? undefined}>
<div
style={hiddenProp ? { display: "none" } : undefined}
>
<AlertDialogPrimitive.Overlay className="fixed inset-0 z-1050 bg-black/80" />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"bg-background fixed top-[50%] left-[50%] z-1100 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg",
className,
)}
style={adjustedStyle}
{...props}
/>
</div>
</AlertDialogPortal>
);
});
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
@ -58,37 +171,47 @@ AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
));
>(({ className, ...props }, ref) => {
const scoped = React.useContext(ScopedAlertCtx);
const Comp = scoped ? DialogPrimitive.Title : AlertDialogPrimitive.Title;
return <Comp ref={ref} className={cn("text-lg font-semibold", className)} {...props} />;
});
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description ref={ref} className={cn("text-muted-foreground text-sm", className)} {...props} />
));
>(({ className, ...props }, ref) => {
const scoped = React.useContext(ScopedAlertCtx);
const Comp = scoped ? DialogPrimitive.Description : AlertDialogPrimitive.Description;
return <Comp ref={ref} className={cn("text-muted-foreground text-sm", className)} {...props} />;
});
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
));
>(({ className, ...props }, ref) => {
const scoped = React.useContext(ScopedAlertCtx);
const Comp = scoped ? DialogPrimitive.Close : AlertDialogPrimitive.Action;
return <Comp ref={ref} className={cn(buttonVariants(), className)} {...props} />;
});
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
{...props}
/>
));
>(({ className, ...props }, ref) => {
const scoped = React.useContext(ScopedAlertCtx);
const Comp = scoped ? DialogPrimitive.Close : AlertDialogPrimitive.Cancel;
return (
<Comp
ref={ref}
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
{...props}
/>
);
});
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {

View File

@ -44,7 +44,14 @@ function Button({
}) {
const Comp = asChild ? Slot : "button";
return <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />;
return (
<Comp
data-slot="button"
data-variant={variant || "default"}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@ -5,8 +5,46 @@ import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { useModalPortal } from "@/lib/modalPortalRef";
import { useTabId } from "@/contexts/TabIdContext";
import { useTabStore } from "@/stores/tabStore";
import { useDialogAutoValidation } from "@/lib/hooks/useDialogAutoValidation";
const Dialog = DialogPrimitive.Root;
// Dialog: 탭 시스템 내에서 자동으로 modal={false} + 비활성 탭이면 open={false} 처리
const Dialog: React.FC<React.ComponentProps<typeof DialogPrimitive.Root>> = ({
modal,
open,
onOpenChange,
...props
}) => {
const autoContainer = useModalPortal();
const scoped = !!autoContainer;
const tabId = useTabId();
const activeTabId = useTabStore((s) => s[s.mode].activeTabId);
const isTabActive = !tabId || tabId === activeTabId;
// ref로 최신 isTabActive를 동기적으로 추적 (useEffect보다 빠르게 업데이트)
const isTabActiveRef = React.useRef(isTabActive);
isTabActiveRef.current = isTabActive;
const effectiveModal = modal !== undefined ? modal : !scoped ? undefined : false;
const effectiveOpen = open != null ? open && isTabActive : undefined;
// 비활성 탭에서 발생하는 onOpenChange(false) 차단
// (탭 전환 시 content unmount → focus 이동 → Radix가 onOpenChange(false)를 호출하는 것을 방지)
const guardedOnOpenChange = React.useCallback(
(newOpen: boolean) => {
if (scoped && !newOpen && !isTabActiveRef.current) {
return;
}
onOpenChange?.(newOpen);
},
[scoped, onOpenChange, tabId],
);
return <DialogPrimitive.Root {...props} open={effectiveOpen} onOpenChange={guardedOnOpenChange} modal={effectiveModal} />;
};
Dialog.displayName = "Dialog";
const DialogTrigger = DialogPrimitive.Trigger;
@ -21,7 +59,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-[999] bg-black/60",
"fixed inset-0 z-999 bg-black/60",
className,
)}
{...props}
@ -29,28 +67,100 @@ const DialogOverlay = React.forwardRef<
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
interface ScopedDialogContentProps
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
/** 포탈 대상 컨테이너. 명시적으로 전달하면 해당 값 사용, 미전달 시 탭 시스템 자동 감지 */
container?: HTMLElement | null;
/** 탭 비활성 시 포탈 내용 숨김 */
hidden?: boolean;
}
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"bg-background fixed top-[50%] left-[50%] z-[1000] flex w-full max-w-lg translate-x-[-50%] translate-y-[-50%] flex-col gap-4 border p-6 shadow-lg sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 cursor-pointer rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
ScopedDialogContentProps
>(({ className, children, container: explicitContainer, hidden: hiddenProp, onInteractOutside, onFocusOutside, style, ...props }, ref) => {
const autoContainer = useModalPortal();
const container = explicitContainer !== undefined ? explicitContainer : autoContainer;
const scoped = !!container;
// state 기반 ref: DialogPrimitive.Content 마운트/언마운트 시 useEffect 재실행 보장
const [contentNode, setContentNode] = React.useState<HTMLDivElement | null>(null);
const mergedRef = React.useCallback(
(node: HTMLDivElement | null) => {
setContentNode(node);
if (typeof ref === "function") ref(node);
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
},
[ref],
);
useDialogAutoValidation(contentNode);
const handleInteractOutside = React.useCallback(
(e: any) => {
if (scoped && container) {
const target = (e.detail?.originalEvent?.target ?? e.target) as HTMLElement | null;
if (target && !container.contains(target)) {
e.preventDefault();
return;
}
}
onInteractOutside?.(e);
},
[scoped, container, onInteractOutside],
);
// scoped 모드: content unmount 시 포커스 이동으로 인한 onOpenChange(false) 방지
const handleFocusOutside = React.useCallback(
(e: any) => {
if (scoped) {
e.preventDefault();
return;
}
onFocusOutside?.(e);
},
[scoped, onFocusOutside],
);
// scoped 모드: 뷰포트 기반 maxHeight/maxWidth 제거 → className의 max-h-full이 컨테이너 기준으로 적용됨
const adjustedStyle = scoped && style
? { ...style, maxHeight: undefined, maxWidth: undefined }
: style;
return (
<DialogPortal container={container ?? undefined}>
<div
className={scoped ? "absolute inset-0 z-999 flex items-center justify-center overflow-hidden p-4" : undefined}
style={hiddenProp ? { display: "none" } : undefined}
>
{scoped ? (
<div className="absolute inset-0 bg-black/60" />
) : (
<DialogPrimitive.Overlay className="fixed inset-0 z-999 bg-black/60" />
)}
<DialogPrimitive.Content
ref={mergedRef}
onInteractOutside={handleInteractOutside}
onFocusOutside={handleFocusOutside}
className={cn(
scoped
? "bg-background relative z-1 flex w-full max-w-lg max-h-full flex-col gap-4 border p-6 shadow-lg sm:rounded-lg"
: "bg-background fixed top-[50%] left-[50%] z-1000 flex w-full max-w-lg translate-x-[-50%] translate-y-[-50%] flex-col gap-4 border p-6 shadow-lg sm:rounded-lg",
className,
scoped && "max-h-full",
)}
style={adjustedStyle}
{...props}
>
{children}
<DialogPrimitive.Close data-dialog-close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 cursor-pointer rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</div>
</DialogPortal>
);
});
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
@ -59,7 +169,7 @@ const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivEleme
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 shrink-0", className)} {...props} />
<div data-slot="dialog-footer" className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 shrink-0", className)} {...props} />
);
DialogFooter.displayName = "DialogFooter";

View File

@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@ -196,7 +196,7 @@ const TextInput = forwardRef<
const hasError = hasBlurred && !!validationError;
return (
<div className="flex h-full w-full flex-col">
<div className="relative h-full w-full">
<Input
ref={ref}
type="text"
@ -214,7 +214,7 @@ const TextInput = forwardRef<
style={inputStyle}
/>
{hasError && (
<p className="text-destructive mt-1 text-[11px]">{validationError}</p>
<p className="text-destructive absolute left-0 top-full mt-0.5 text-[11px]">{validationError}</p>
)}
</div>
);

View File

@ -12,7 +12,7 @@
* - swap: 스왑 ( )
*/
import React, { forwardRef, useCallback, useContext, useEffect, useMemo, useState } from "react";
import React, { forwardRef, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Checkbox } from "@/components/ui/checkbox";
@ -57,6 +57,9 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
style,
}, ref) => {
const [open, setOpen] = useState(false);
const textRef = useRef<HTMLSpanElement>(null);
const [isTruncated, setIsTruncated] = useState(false);
const [hoverTooltip, setHoverTooltip] = useState(false);
// 현재 선택된 값 존재 여부
const hasValue = useMemo(() => {
@ -124,11 +127,19 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
}, [value]);
const selectedLabels = useMemo(() => {
return selectedValues
.map((v) => safeOptions.find((o) => o.value === v)?.label)
return safeOptions
.filter((o) => selectedValues.includes(o.value))
.map((o) => o.label)
.filter(Boolean) as string[];
}, [selectedValues, safeOptions]);
useEffect(() => {
const el = textRef.current;
if (el) {
setIsTruncated(el.scrollWidth > el.clientWidth);
}
}, [selectedLabels]);
const handleSelect = useCallback((selectedValue: string) => {
if (multiple) {
const newValues = selectedValues.includes(selectedValue)
@ -148,88 +159,109 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
onChange?.(multiple ? [] : "");
}, [multiple, onChange]);
const displayText = selectedLabels.length > 0
? (multiple ? selectedLabels.join(", ") : selectedLabels[0])
: placeholder;
const isPlaceholder = selectedLabels.length === 0;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
{/* Button에 style로 직접 height 전달 (Popover도 DOM 체인 끊김) */}
<Button
ref={ref}
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn(
"w-full justify-between font-normal",
"bg-transparent hover:bg-transparent", // 표준 Select와 동일한 투명 배경
"border-input shadow-xs", // 표준 Select와 동일한 테두리
"h-6 px-2 py-0 text-sm", // 표준 Select xs와 동일한 높이
className,
)}
style={style}
>
<span className="truncate flex-1 text-left">
{selectedLabels.length > 0
? multiple
? `${selectedLabels.length}개 선택됨`
: selectedLabels[0]
: placeholder}
</span>
<div className="flex items-center gap-1 ml-2">
{allowClear && selectedValues.length > 0 && (
<span
role="button"
tabIndex={-1}
onClick={handleClear}
onPointerDown={(e) => {
// Radix Popover가 onPointerDown으로 팝오버를 여는 것을 방지
e.stopPropagation();
e.preventDefault();
}}
>
<X className="h-4 w-4 opacity-50 hover:opacity-100" />
</span>
<div
className="relative w-full"
onMouseEnter={() => { if (isTruncated && multiple) setHoverTooltip(true); }}
onMouseLeave={() => setHoverTooltip(false)}
>
<Popover open={open} onOpenChange={(isOpen) => {
setOpen(isOpen);
if (isOpen) setHoverTooltip(false);
}}>
<PopoverTrigger asChild>
<Button
ref={ref}
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn(
"w-full justify-between font-normal",
"bg-transparent hover:bg-transparent",
"border-input shadow-xs",
"h-6 px-2 py-0 text-sm",
className,
)}
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
style={style}
>
<span
ref={textRef}
className="truncate flex-1 text-left"
{...(isPlaceholder ? { "data-placeholder": placeholder } : {})}
>
{displayText}
</span>
<div className="flex items-center gap-1 ml-2">
{allowClear && selectedValues.length > 0 && (
<span
role="button"
tabIndex={-1}
onClick={handleClear}
onPointerDown={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<X className="h-4 w-4 opacity-50 hover:opacity-100" />
</span>
)}
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command
filter={(itemValue, search) => {
if (!search) return 1;
const option = safeOptions.find((o) => o.value === itemValue);
const label = (option?.label || option?.value || "").toLowerCase();
if (label.includes(search.toLowerCase())) return 1;
return 0;
}}
>
{searchable && <CommandInput placeholder="검색..." className="h-9" />}
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
{safeOptions.map((option) => {
const displayLabel = option.label || option.value || "(빈 값)";
return (
<CommandItem
key={option.value}
value={option.value}
onSelect={() => handleSelect(option.value)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedValues.includes(option.value) ? "opacity-100" : "opacity-0"
)}
/>
{displayLabel}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{hoverTooltip && !open && (
<div className="absolute bottom-full left-0 z-50 mb-1 rounded-md border bg-popover px-3 py-1.5 shadow-md animate-in fade-in-0 zoom-in-95">
<div className="space-y-0.5 text-xs">
{selectedLabels.map((label, i) => (
<div key={i}>{label}</div>
))}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command
filter={(itemValue, search) => {
if (!search) return 1;
const option = safeOptions.find((o) => o.value === itemValue);
const label = (option?.label || option?.value || "").toLowerCase();
if (label.includes(search.toLowerCase())) return 1;
return 0;
}}
>
{searchable && <CommandInput placeholder="검색..." className="h-9" />}
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
{safeOptions.map((option) => {
const displayLabel = option.label || option.value || "(빈 값)";
return (
<CommandItem
key={option.value}
value={option.value}
onSelect={() => handleSelect(option.value)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedValues.includes(option.value) ? "opacity-100" : "opacity-0"
)}
/>
{displayLabel}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
</div>
);
});
DropdownSelect.displayName = "DropdownSelect";

View File

@ -0,0 +1,11 @@
"use client";
import { createContext, useContext } from "react";
const TabIdContext = createContext<string | null>(null);
export const TabIdProvider = TabIdContext.Provider;
export function useTabId(): string | null {
return useContext(TabIdContext);
}

View File

@ -0,0 +1,213 @@
import React from "react";
import DOMPurify from "isomorphic-dompurify";
import {
Check, Save, CheckCircle, CircleCheck, FileCheck, ShieldCheck,
Trash2, Trash, XCircle, X, Eraser, CircleX,
Pencil, PenLine, Pen, SquarePen, FilePen, PenTool,
ArrowRight, ExternalLink, MoveRight, Navigation, CornerUpRight, Link,
Maximize2, PanelTop, AppWindow, LayoutGrid, Layers, FolderOpen,
SendHorizontal, ArrowRightLeft, Repeat, PackageCheck, Upload, Share2,
Download, FileDown, FileSpreadsheet, Sheet, Table, FileOutput,
FileUp, FileInput,
Zap, Plus, PlusCircle, SquarePlus, FilePlus, BadgePlus,
Settings, SlidersHorizontal, ToggleLeft, Workflow, GitBranch, Settings2,
ScanLine, QrCode, Camera, Scan, ScanBarcode, Focus,
Truck, Car, MapPin, Navigation2, Route, Bell,
Send, Radio, Megaphone, Podcast, BellRing,
Copy, ClipboardCopy, Files, CopyPlus, ClipboardList, Clipboard,
SquareMousePointer,
type LucideIcon,
} from "lucide-react";
// ---------------------------------------------------------------------------
// 아이콘 이름 → 컴포넌트 매핑 (추천 아이콘만 명시적 import)
// ---------------------------------------------------------------------------
export const iconMap: Record<string, LucideIcon> = {
Check, Save, CheckCircle, CircleCheck, FileCheck, ShieldCheck,
Trash2, Trash, XCircle, X, Eraser, CircleX,
Pencil, PenLine, Pen, SquarePen, FilePen, PenTool,
ArrowRight, ExternalLink, MoveRight, Navigation, CornerUpRight, Link,
Maximize2, PanelTop, AppWindow, LayoutGrid, Layers, FolderOpen,
SendHorizontal, ArrowRightLeft, Repeat, PackageCheck, Upload, Share2,
Download, FileDown, FileSpreadsheet, Sheet, Table, FileOutput,
FileUp, FileInput,
Zap, Plus, PlusCircle, SquarePlus, FilePlus, BadgePlus,
Settings, SlidersHorizontal, ToggleLeft, Workflow, GitBranch, Settings2,
ScanLine, QrCode, Camera, Scan, ScanBarcode, Focus,
Truck, Car, MapPin, Navigation2, Route, Bell,
Send, Radio, Megaphone, Podcast, BellRing,
Copy, ClipboardCopy, Files, CopyPlus, ClipboardList, Clipboard,
SquareMousePointer,
};
// ---------------------------------------------------------------------------
// 버튼 액션 → 추천 아이콘 이름 매핑
// ---------------------------------------------------------------------------
export const actionIconMap: Record<string, string[]> = {
save: ["Check", "Save", "CheckCircle", "CircleCheck", "FileCheck", "ShieldCheck"],
delete: ["Trash2", "Trash", "XCircle", "X", "Eraser", "CircleX"],
edit: ["Pencil", "PenLine", "Pen", "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", "FileInput", "FileOutput"],
quickInsert: ["Zap", "Plus", "PlusCircle", "SquarePlus", "FilePlus", "BadgePlus"],
control: ["Settings", "SlidersHorizontal", "ToggleLeft", "Workflow", "GitBranch", "Settings2"],
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", "ClipboardList", "Clipboard"],
};
// 아이콘 추천이 불가능한 deprecated/숨김 액션
export const noIconActions = new Set([
"openRelatedModal",
"openModalWithData",
"view_table_history",
"code_merge",
"empty_vehicle",
]);
export const NO_ICON_MESSAGE = "적절한 추천 아이콘이 없습니다. 텍스트 모드를 사용하거나 아래에서 아이콘을 직접 추가하세요.";
// 범용 폴백 아이콘 (추천 아이콘이 없는 액션용)
export const FALLBACK_ICON_NAME = "SquareMousePointer";
/** 액션 타입에 대한 디폴트 아이콘(첫 번째 추천)을 반환. 없으면 범용 폴백. */
export function getDefaultIconForAction(actionType?: string): { name: string; type: "lucide" } {
if (actionType && actionIconMap[actionType]?.length) {
return { name: actionIconMap[actionType][0], type: "lucide" };
}
return { name: FALLBACK_ICON_NAME, type: "lucide" };
}
// ---------------------------------------------------------------------------
// 아이콘 크기 (버튼 높이 대비 비율)
// ---------------------------------------------------------------------------
export 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;
}
/** 아이콘 크기를 CSS로 변환 (버튼 높이 대비 비율, 정사각형 유지) */
export function getIconSizeStyle(size: string | number): React.CSSProperties {
const pct = getIconPercent(size);
return { height: `${pct}%`, width: "auto", aspectRatio: "1 / 1" };
}
// ---------------------------------------------------------------------------
// 아이콘 조회 / 동적 등록
// ---------------------------------------------------------------------------
export function getLucideIcon(name: string): LucideIcon | undefined {
return iconMap[name];
}
export function addToIconMap(name: string, component: LucideIcon): void {
iconMap[name] = component;
}
// ---------------------------------------------------------------------------
// SVG 정화
// ---------------------------------------------------------------------------
export function sanitizeSvg(svgString: string): string {
return DOMPurify.sanitize(svgString, { USE_PROFILES: { svg: true } });
}
// ---------------------------------------------------------------------------
// 버튼 아이콘 렌더러 컴포넌트 (모든 뷰어/위젯에서 공용)
// ---------------------------------------------------------------------------
export function ButtonIconRenderer({
componentConfig,
fallbackLabel,
}: {
componentConfig: any;
fallbackLabel: string;
}) {
const cfg = componentConfig || {};
const displayMode = cfg.displayMode || "text";
if (displayMode === "text" || !cfg.icon?.name) {
return <>{cfg.text || fallbackLabel}</>;
}
return <>{getButtonDisplayContent(cfg)}</>;
}
// ---------------------------------------------------------------------------
// 버튼 표시 콘텐츠 계산 (모든 렌더러 공용)
// ---------------------------------------------------------------------------
export function getButtonDisplayContent(componentConfig: any): React.ReactNode {
const displayMode = componentConfig?.displayMode || "text";
const text = componentConfig?.text || componentConfig?.label || "버튼";
const icon = componentConfig?.icon;
if (displayMode === "text" || !icon?.name) {
return text;
}
// 아이콘 노드 생성
const sizeStyle = getIconSizeStyle(icon.size || "보통");
const colorStyle: React.CSSProperties = icon.color ? { color: icon.color } : {};
let iconNode: React.ReactNode = null;
if (icon.type === "svg") {
const svgIcon = componentConfig?.customSvgIcons?.find(
(s: { name: string; svg: string }) => s.name === icon.name,
);
if (svgIcon) {
const clean = sanitizeSvg(svgIcon.svg);
iconNode = (
<span
className="inline-flex items-center justify-center [&>svg]:h-full [&>svg]:w-full"
style={{ ...sizeStyle, ...colorStyle }}
dangerouslySetInnerHTML={{ __html: clean }}
/>
);
}
} else {
const IconComponent = getLucideIcon(icon.name);
if (IconComponent) {
iconNode = (
<span className="inline-flex items-center justify-center" style={sizeStyle}>
<IconComponent className="h-full w-full" style={colorStyle} />
</span>
);
}
}
if (!iconNode) {
return text;
}
if (displayMode === "icon") {
return iconNode;
}
// icon-text 모드
const gap = componentConfig?.iconGap ?? 6;
const textPos = componentConfig?.iconTextPosition || "right";
const isVertical = textPos === "top" || textPos === "bottom";
const textFirst = textPos === "left" || textPos === "top";
return (
<span
className="inline-flex items-center justify-center"
style={{
gap: `${gap}px`,
flexDirection: isVertical ? "column" : "row",
}}
>
{textFirst ? <span>{text}</span> : iconNode}
{textFirst ? iconNode : <span>{text}</span>}
</span>
);
}

View File

@ -0,0 +1,137 @@
/**
* .
* // .
*
* :
* import { formatDate, formatNumber, formatCurrency } from "@/lib/formatting";
* formatDate("2025-01-01") // "2025-01-01"
* formatDate("2025-01-01T14:30:00Z", "datetime") // "2025-01-01 14:30:00"
* formatNumber(1234567) // "1,234,567"
* formatCurrency(50000) // "₩50,000"
*/
export { getFormatRules, setFormatRules, DEFAULT_FORMAT_RULES } from "./rules";
export type { FormatRules, DateFormatRules, NumberFormatRules, CurrencyFormatRules } from "./rules";
import { getFormatRules } from "./rules";
// --- 날짜 포맷 ---
type DateFormatType = "display" | "datetime" | "input" | "time";
/**
* .
* @param value - ISO , Date,
* @param type - "display" | "datetime" | "input" | "time"
* @returns ( )
*/
export function formatDate(value: unknown, type: DateFormatType = "display"): string {
if (value == null || value === "") return "";
const rules = getFormatRules();
const format = rules.date[type];
try {
const date = value instanceof Date ? value : new Date(String(value));
if (isNaN(date.getTime())) return String(value);
return applyDateFormat(date, format);
} catch {
return String(value);
}
}
/**
* YYYY-MM-DD HH:mm:ss Date
*/
function applyDateFormat(date: Date, pattern: string): string {
const y = date.getFullYear();
const M = date.getMonth() + 1;
const d = date.getDate();
const H = date.getHours();
const m = date.getMinutes();
const s = date.getSeconds();
return pattern
.replace("YYYY", String(y))
.replace("MM", String(M).padStart(2, "0"))
.replace("DD", String(d).padStart(2, "0"))
.replace("HH", String(H).padStart(2, "0"))
.replace("mm", String(m).padStart(2, "0"))
.replace("ss", String(s).padStart(2, "0"));
}
// --- 숫자 포맷 ---
/**
* ( ).
* @param value -
* @param decimals - 릿 ( )
* @returns
*/
export function formatNumber(value: unknown, decimals?: number): string {
if (value == null || value === "") return "";
const rules = getFormatRules();
const num = typeof value === "number" ? value : parseFloat(String(value));
if (isNaN(num)) return String(value);
const dec = decimals ?? rules.number.decimals;
return new Intl.NumberFormat(rules.number.locale, {
minimumFractionDigits: dec,
maximumFractionDigits: dec,
}).format(num);
}
// --- 통화 포맷 ---
/**
* .
* @param value -
* @param currencyCode - ( )
* @returns (: "₩50,000")
*/
export function formatCurrency(value: unknown, currencyCode?: string): string {
if (value == null || value === "") return "";
const rules = getFormatRules();
const num = typeof value === "number" ? value : parseFloat(String(value));
if (isNaN(num)) return String(value);
const code = currencyCode ?? rules.currency.code;
return new Intl.NumberFormat(rules.currency.locale, {
style: "currency",
currency: code,
maximumFractionDigits: code === "KRW" ? 0 : 2,
}).format(num);
}
// --- 범용 포맷 ---
/**
* .
* @param value -
* @param dataType - "date" | "datetime" | "number" | "currency" | "text"
*/
export function formatValue(value: unknown, dataType: string): string {
switch (dataType) {
case "date":
return formatDate(value, "display");
case "datetime":
return formatDate(value, "datetime");
case "time":
return formatDate(value, "time");
case "number":
case "integer":
case "float":
case "decimal":
return formatNumber(value);
case "currency":
case "money":
return formatCurrency(value);
default:
return value == null ? "" : String(value);
}
}

View File

@ -0,0 +1,71 @@
/**
* .
* // .
* .
*/
export interface DateFormatRules {
/** 날짜만 표시 (예: "2025-01-01") */
display: string;
/** 날짜+시간 표시 (예: "2025-01-01 14:30:00") */
datetime: string;
/** 입력 필드용 (예: "YYYY-MM-DD") */
input: string;
/** 시간만 표시 (예: "14:30") */
time: string;
}
export interface NumberFormatRules {
/** 숫자 로케일 (천단위 구분자 등) */
locale: string;
/** 기본 소수점 자릿수 */
decimals: number;
}
export interface CurrencyFormatRules {
/** 통화 코드 (예: "KRW", "USD") */
code: string;
/** 통화 로케일 */
locale: string;
}
export interface FormatRules {
date: DateFormatRules;
number: NumberFormatRules;
currency: CurrencyFormatRules;
}
/** 기본 포맷 규칙 (한국어 기준) */
export const DEFAULT_FORMAT_RULES: FormatRules = {
date: {
display: "YYYY-MM-DD",
datetime: "YYYY-MM-DD HH:mm:ss",
input: "YYYY-MM-DD",
time: "HH:mm",
},
number: {
locale: "ko-KR",
decimals: 0,
},
currency: {
code: "KRW",
locale: "ko-KR",
},
};
/** 현재 적용 중인 포맷 규칙 (런타임에 변경 가능) */
let currentRules: FormatRules = { ...DEFAULT_FORMAT_RULES };
export function getFormatRules(): FormatRules {
return currentRules;
}
export function setFormatRules(rules: Partial<FormatRules>): void {
currentRules = {
...currentRules,
...rules,
date: { ...currentRules.date, ...rules.date },
number: { ...currentRules.number, ...rules.number },
currency: { ...currentRules.currency, ...rules.currency },
};
}

View File

@ -0,0 +1,228 @@
"use client";
import { useEffect } from "react";
import { useTabStore } from "@/stores/tabStore";
import { toast } from "sonner";
const HIGHLIGHT_ATTR = "data-validation-highlight";
const ERROR_ATTR = "data-validation-error";
const MSG_WRAPPER_CLASS = "validation-error-msg-wrapper";
type TargetEl = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | HTMLButtonElement;
/**
* ( )
*
* // :
* 1.
* 2. +
* 3. ( )
* 4.
*
* 설계: docs/ycshin-node/_자동검증_설계.md
*/
export function useDialogAutoValidation(contentEl: HTMLElement | null) {
const mode = useTabStore((s) => s.mode);
useEffect(() => {
if (mode !== "user") return;
const el = contentEl;
if (!el) return;
const errorFields = new Set<TargetEl>();
let activated = false; // 첫 저장 시도 이후 true
function findRequiredFields(): Map<TargetEl, string> {
const fields = new Map<TargetEl, string>();
if (!el) return fields;
el.querySelectorAll("label").forEach((label) => {
const hasRequiredMark = Array.from(label.querySelectorAll("span")).some(
(span) => span.textContent?.trim() === "*",
);
if (!hasRequiredMark) return;
const forId = label.getAttribute("for") || (label as HTMLLabelElement).htmlFor;
let target: TargetEl | null = null;
if (forId) {
try {
const found = el!.querySelector(`#${CSS.escape(forId)}`);
if (isFormElement(found)) {
target = found;
} else if (found) {
const inner = found.querySelector("input, textarea, select");
if (isFormElement(inner) && !isHiddenRadixSelect(inner)) {
target = inner;
}
// 숨겨진 Radix select이거나 폼 요소가 없으면 → 트리거 버튼 탐색
if (!target) {
const btn = found.querySelector('button[role="combobox"], button[data-slot="select-trigger"]');
if (btn instanceof HTMLButtonElement) target = btn;
}
}
} catch {
/* invalid id */
}
}
if (!target) {
const parent = label.closest('[class*="space-y"]') || label.parentElement;
if (parent) {
const inner = parent.querySelector("input, textarea, select");
if (isFormElement(inner) && !isHiddenRadixSelect(inner)) {
target = inner;
}
if (!target) {
const btn = parent.querySelector('button[role="combobox"], button[data-slot="select-trigger"]');
if (btn instanceof HTMLButtonElement) target = btn;
}
}
}
if (target) {
const labelText = label.textContent?.replace(/\*/g, "").trim() || "";
fields.set(target, labelText);
}
});
return fields;
}
function isFormElement(el: Element | null): el is HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement {
return el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement;
}
function isHiddenRadixSelect(el: Element): boolean {
return el instanceof HTMLSelectElement && el.hasAttribute("aria-hidden");
}
function isEmpty(input: TargetEl): boolean {
if (input instanceof HTMLButtonElement) {
// Radix Select: data-placeholder 속성이 자식 span에 있으면 미선택 상태
return !!input.querySelector("[data-placeholder]");
}
return input.value.trim() === "";
}
function isSaveButton(target: HTMLElement): boolean {
const btn = target.closest("button");
if (!btn) return false;
const actionType = btn.getAttribute("data-action-type");
if (actionType === "save" || actionType === "submit") return true;
const variant = btn.getAttribute("data-variant");
if (variant === "default") return true;
return false;
}
function markError(input: TargetEl) {
input.setAttribute(ERROR_ATTR, "true");
errorFields.add(input);
showErrorMsg(input);
}
function clearError(input: TargetEl) {
input.removeAttribute(ERROR_ATTR);
errorFields.delete(input);
removeErrorMsg(input);
}
// 빈 필수 필드 아래에 경고 문구 삽입 (레이아웃 영향 없는 zero-height wrapper)
function showErrorMsg(input: TargetEl) {
if (input.parentElement?.querySelector(`.${MSG_WRAPPER_CLASS}`)) return;
const wrapper = document.createElement("div");
wrapper.className = MSG_WRAPPER_CLASS;
const msg = document.createElement("p");
msg.textContent = "필수 입력 항목입니다";
wrapper.appendChild(msg);
input.insertAdjacentElement("afterend", wrapper);
}
function removeErrorMsg(input: TargetEl) {
const wrapper = input.parentElement?.querySelector(`.${MSG_WRAPPER_CLASS}`);
if (wrapper) wrapper.remove();
}
function highlightField(input: TargetEl) {
input.setAttribute(HIGHLIGHT_ATTR, "true");
input.addEventListener("animationend", () => input.removeAttribute(HIGHLIGHT_ATTR), { once: true });
if (input instanceof HTMLButtonElement) {
input.click();
} else {
input.focus();
}
}
// 첫 저장 시도 이후: 빈 필드 → 에러 유지/재적용, 값 있으면 해제
function syncErrors() {
if (!activated) return;
const fields = findRequiredFields();
for (const [input] of fields) {
if (isEmpty(input)) {
markError(input);
} else {
clearError(input);
}
}
}
function handleClick(e: Event) {
const target = e.target as HTMLElement;
if (!isSaveButton(target)) return;
const fields = findRequiredFields();
if (fields.size === 0) return;
let firstEmpty: TargetEl | null = null;
let firstEmptyLabel = "";
for (const [input, label] of fields) {
if (isEmpty(input)) {
markError(input);
if (!firstEmpty) {
firstEmpty = input;
firstEmptyLabel = label;
}
} else {
clearError(input);
}
}
if (!firstEmpty) return;
activated = true;
e.stopPropagation();
e.preventDefault();
highlightField(firstEmpty);
toast.error(`${firstEmptyLabel} 항목을 입력해주세요`);
}
// V2Select는 input/change 이벤트가 없으므로 DOM 변경 감지로 에러 동기화
const observer = new MutationObserver(syncErrors);
observer.observe(el, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-placeholder"] });
el.addEventListener("click", handleClick, true);
el.addEventListener("input", syncErrors);
el.addEventListener("change", syncErrors);
return () => {
el.removeEventListener("click", handleClick, true);
el.removeEventListener("input", syncErrors);
el.removeEventListener("change", syncErrors);
observer.disconnect();
el.querySelectorAll(`[${HIGHLIGHT_ATTR}]`).forEach((node) => node.removeAttribute(HIGHLIGHT_ATTR));
el.querySelectorAll(`[${ERROR_ATTR}]`).forEach((node) => node.removeAttribute(ERROR_ATTR));
el.querySelectorAll(`.${MSG_WRAPPER_CLASS}`).forEach((node) => node.remove());
};
}, [mode, contentEl]);
}

View File

@ -0,0 +1,31 @@
"use client";
import { useState, useEffect } from "react";
/**
* .
* TabContent가 registerModalPortal(el) ,
* useModalPortal() .
* React .
*/
let _container: HTMLElement | null = null;
const _subscribers = new Set<(el: HTMLElement | null) => void>();
export function registerModalPortal(el: HTMLElement | null) {
_container = el;
_subscribers.forEach((fn) => fn(el));
}
export function useModalPortal(): HTMLElement | null {
const [el, setEl] = useState<HTMLElement | null>(_container);
useEffect(() => {
setEl(_container);
_subscribers.add(setEl);
return () => {
_subscribers.delete(setEl);
};
}, []);
return el;
}

View File

@ -3,6 +3,7 @@
import React, { useState, useEffect, useMemo } from "react";
import { ComponentRendererProps } from "@/types/component";
import { AggregationWidgetConfig, AggregationItem, AggregationResult, AggregationType } from "./types";
import { formatNumber } from "@/lib/formatting";
import { Calculator, TrendingUp, Hash, ArrowUp, ArrowDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
@ -136,11 +137,11 @@ export function AggregationWidgetComponent({
let formattedValue = value.toFixed(item.decimalPlaces ?? 0);
if (item.format === "currency") {
formattedValue = new Intl.NumberFormat("ko-KR").format(value);
formattedValue = formatNumber(value);
} else if (item.format === "percent") {
formattedValue = `${(value * 100).toFixed(item.decimalPlaces ?? 1)}%`;
} else if (item.format === "number") {
formattedValue = new Intl.NumberFormat("ko-KR").format(value);
formattedValue = formatNumber(value);
}
if (item.prefix) {

View File

@ -28,7 +28,6 @@ import { useScreenContextOptional } from "@/contexts/ScreenContext";
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
import { applyMappingRules } from "@/lib/utils/dataMapping";
import { apiClient } from "@/lib/api/client";
export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
config?: ButtonPrimaryConfig;
// 추가 props
@ -1259,7 +1258,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
}
}
// 🆕 최종 비활성화 상태 (설정 + 조건부 비활성화 + 행 선택 필수)
const finalDisabled =
componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading;

View File

@ -112,11 +112,11 @@ import "./v2-input/V2InputRenderer"; // V2 통합 입력 컴포넌트
import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트
import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트
import "./v2-file-upload/V2FileUploadRenderer"; // V2 파일 업로드 컴포넌트
import "./v2-process-work-standard/ProcessWorkStandardRenderer"; // 공정 작업기준
import "./v2-item-routing/ItemRoutingRenderer"; // 품목별 라우팅
import "./v2-split-line/SplitLineRenderer"; // V2 캔버스 분할선
import "./v2-bom-tree/BomTreeRenderer"; // BOM 트리 뷰
import "./v2-bom-item-editor/BomItemEditorRenderer"; // BOM 하위품목 편집기
import "./v2-process-work-standard/ProcessWorkStandardRenderer"; // 공정 작업기준
import "./v2-item-routing/ItemRoutingRenderer"; // 품목별 라우팅
/**
*

View File

@ -3,6 +3,8 @@
* .
*/
import { getFormatRules } from "@/lib/formatting";
import { AggregationType, PivotFieldFormat } from "../types";
// ==================== 집계 함수 ====================
@ -102,16 +104,18 @@ export function formatNumber(
let formatted: string;
const locale = getFormatRules().number.locale;
switch (type) {
case "currency":
formatted = value.toLocaleString("ko-KR", {
formatted = value.toLocaleString(locale, {
minimumFractionDigits: precision,
maximumFractionDigits: precision,
});
break;
case "percent":
formatted = (value * 100).toLocaleString("ko-KR", {
formatted = (value * 100).toLocaleString(locale, {
minimumFractionDigits: precision,
maximumFractionDigits: precision,
});
@ -120,7 +124,7 @@ export function formatNumber(
case "number":
default:
if (thousandSeparator) {
formatted = value.toLocaleString("ko-KR", {
formatted = value.toLocaleString(locale, {
minimumFractionDigits: precision,
maximumFractionDigits: precision,
});
@ -138,7 +142,7 @@ export function formatNumber(
*/
export function formatDate(
value: Date | string | null | undefined,
format: string = "YYYY-MM-DD"
format: string = getFormatRules().date.display
): string {
if (!value) return "-";

View File

@ -48,7 +48,7 @@ function getFieldValue(
const weekNum = getWeekNumber(date);
return `${date.getFullYear()}-W${String(weekNum).padStart(2, "0")}`;
case "day":
return formatDate(date, "YYYY-MM-DD");
return formatDate(date);
default:
return String(rawValue);
}

View File

@ -6,6 +6,7 @@ import { AggregationWidgetConfig, AggregationItem, AggregationResult, Aggregatio
import { Calculator, TrendingUp, Hash, ArrowUp, ArrowDown, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
import { formatNumber } from "@/lib/formatting";
import { apiClient } from "@/lib/api/client";
import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core";
@ -566,11 +567,11 @@ export function AggregationWidgetComponent({
let formattedValue = value.toFixed(item.decimalPlaces ?? 0);
if (item.format === "currency") {
formattedValue = new Intl.NumberFormat("ko-KR").format(value);
formattedValue = formatNumber(value);
} else if (item.format === "percent") {
formattedValue = `${(value * 100).toFixed(item.decimalPlaces ?? 1)}%`;
} else if (item.format === "number") {
formattedValue = new Intl.NumberFormat("ko-KR").format(value);
formattedValue = formatNumber(value);
}
if (item.prefix) {

View File

@ -22,6 +22,7 @@ import {
import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
import { ButtonIconRenderer } from "@/lib/button-icon-map";
import { useCurrentFlowStep } from "@/stores/flowStepStore";
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
import { useScreenContextOptional } from "@/contexts/ScreenContext";
@ -29,7 +30,6 @@ import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelC
import { applyMappingRules } from "@/lib/utils/dataMapping";
import { apiClient } from "@/lib/api/client";
import { V2ErrorBoundary, v2EventBus, V2_EVENTS } from "@/lib/v2-core";
export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
config?: ButtonPrimaryConfig;
// 추가 props
@ -556,13 +556,23 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
}
// 스타일 계산
// 🔧 사용자가 설정한 크기가 있으면 그대로 사용
const componentStyle: React.CSSProperties = {
// 외부 wrapper는 부모 컨테이너(RealtimePreviewDynamic)에 맞춰 100% 채움
// border는 내부 버튼에서만 적용 (wrapper에 적용되면 이중 테두리 발생)
const {
border: _border, borderWidth: _bw, borderStyle: _bs, borderColor: _bc, borderRadius: _br,
...restComponentStyle
} = {
...component.style,
...style,
} as React.CSSProperties & Record<string, any>;
const componentStyle: React.CSSProperties = {
...restComponentStyle,
width: "100%",
height: "100%",
};
// 디자인 모드 스타일 (border 속성 분리하여 충돌 방지)
// 디자인 모드 스타일
if (isDesignMode) {
componentStyle.borderWidth = "1px";
componentStyle.borderStyle = "dashed";
@ -1217,15 +1227,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
effectiveFormData = { ...splitPanelParentData };
}
console.log("🔴 [ButtonPrimary] 저장 시 formData 디버그:", {
propsFormDataKeys: Object.keys(propsFormData),
screenContextFormDataKeys: Object.keys(screenContextFormData),
effectiveFormDataKeys: Object.keys(effectiveFormData),
process_code: effectiveFormData.process_code,
equipment_code: effectiveFormData.equipment_code,
fullData: JSON.stringify(effectiveFormData),
});
const context: ButtonActionContext = {
formData: effectiveFormData,
originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용)
@ -1382,31 +1383,29 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
}
}
// 🆕 최종 비활성화 상태 (설정 + 조건부 비활성화 + 행 선택 필수)
const finalDisabled =
componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading;
// 공통 버튼 스타일
// 🔧 component.style에서 background/backgroundColor 충돌 방지 (width/height는 허용)
// 크기는 부모 컨테이너(RealtimePreviewDynamic)에서 관리하므로 width/height 제외
const userStyle = component.style
? Object.fromEntries(
Object.entries(component.style).filter(([key]) => !["background", "backgroundColor"].includes(key)),
Object.entries(component.style).filter(([key]) => !["background", "backgroundColor", "width", "height"].includes(key)),
)
: {};
// 🔧 사용자가 설정한 크기 우선 사용, 없으면 100%
const buttonWidth = component.size?.width ? `${component.size.width}px` : style?.width || "100%";
const buttonHeight = component.size?.height ? `${component.size.height}px` : style?.height || "100%";
// 버튼은 부모 컨테이너를 꽉 채움 (크기는 RealtimePreviewDynamic에서 관리)
const buttonWidth = "100%";
const buttonHeight = "100%";
const buttonElementStyle: React.CSSProperties = {
width: buttonWidth,
height: buttonHeight,
minHeight: "32px", // 🔧 최소 높이를 32px로 줄임
// 🔧 커스텀 테두리 스타일 (StyleEditor에서 설정한 값 우선)
border: style?.border || (style?.borderWidth ? undefined : "none"),
borderWidth: style?.borderWidth || undefined,
borderStyle: (style?.borderStyle as React.CSSProperties["borderStyle"]) || undefined,
borderColor: style?.borderColor || undefined,
// 커스텀 테두리 스타일 (StyleEditor 설정 우선, shorthand 사용 안 함)
borderWidth: style?.borderWidth || "0",
borderStyle: (style?.borderStyle as React.CSSProperties["borderStyle"]) || (style?.borderWidth ? "solid" : "none"),
borderColor: style?.borderColor || "transparent",
borderRadius: style?.borderRadius || "0.5rem",
backgroundColor: finalDisabled ? "#e5e7eb" : buttonColor,
color: finalDisabled ? "#9ca3af" : (style?.color || buttonTextColor), // 🔧 StyleEditor 텍스트 색상도 지원
@ -1444,7 +1443,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
cancel: "취소",
};
const buttonContent =
const buttonTextContent =
processedConfig.text ||
component.webTypeConfig?.text ||
component.componentConfig?.text ||
@ -1458,16 +1457,17 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
<>
<div style={componentStyle} className={className} {...safeDomProps}>
{isDesignMode ? (
// 디자인 모드: div로 렌더링하여 선택 가능하게 함
<div
className="transition-colors duration-150 hover:opacity-90"
style={buttonElementStyle}
onClick={handleClick}
>
{buttonContent}
<ButtonIconRenderer
componentConfig={componentConfig}
fallbackLabel={buttonTextContent as string}
/>
</div>
) : (
// 일반 모드: button으로 렌더링
<button
type={componentConfig.actionType || "button"}
disabled={finalDisabled}
@ -1476,8 +1476,12 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
onClick={handleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
{...(actionType ? { "data-action-type": actionType } : {})}
>
{buttonContent}
<ButtonIconRenderer
componentConfig={componentConfig}
fallbackLabel={buttonTextContent as string}
/>
</button>
)}
</div>

View File

@ -4,6 +4,7 @@ import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { V2DateDefinition } from "./index";
import { V2Date } from "@/components/v2/V2Date";
import { getFormatRules } from "@/lib/formatting";
/**
* V2Date
@ -45,7 +46,7 @@ export class V2DateRenderer extends AutoRegisteringComponentRenderer {
onChange={handleChange}
config={{
dateType: config.dateType || config.webType || "date",
format: config.format || "YYYY-MM-DD",
format: config.format || getFormatRules().date.display,
placeholder: config.placeholder || style.placeholder || "날짜 선택",
showTime: config.showTime || false,
use24Hours: config.use24Hours ?? true,

View File

@ -3,6 +3,8 @@
* .
*/
import { getFormatRules } from "@/lib/formatting";
import { AggregationType, PivotFieldFormat } from "../types";
// ==================== 집계 함수 ====================
@ -102,16 +104,18 @@ export function formatNumber(
let formatted: string;
const locale = getFormatRules().number.locale;
switch (type) {
case "currency":
formatted = value.toLocaleString("ko-KR", {
formatted = value.toLocaleString(locale, {
minimumFractionDigits: precision,
maximumFractionDigits: precision,
});
break;
case "percent":
formatted = (value * 100).toLocaleString("ko-KR", {
formatted = (value * 100).toLocaleString(locale, {
minimumFractionDigits: precision,
maximumFractionDigits: precision,
});
@ -120,7 +124,7 @@ export function formatNumber(
case "number":
default:
if (thousandSeparator) {
formatted = value.toLocaleString("ko-KR", {
formatted = value.toLocaleString(locale, {
minimumFractionDigits: precision,
maximumFractionDigits: precision,
});
@ -138,7 +142,7 @@ export function formatNumber(
*/
export function formatDate(
value: Date | string | null | undefined,
format: string = "YYYY-MM-DD"
format: string = getFormatRules().date.display
): string {
if (!value) return "-";

View File

@ -47,7 +47,7 @@ function getFieldValue(
const weekNum = getWeekNumber(date);
return `${date.getFullYear()}-W${String(weekNum).padStart(2, "0")}`;
case "day":
return formatDate(date, "YYYY-MM-DD");
return formatDate(date);
default:
return String(rawValue);
}

View File

@ -0,0 +1,428 @@
/**
* sessionStorage에 / .
* F5 .
*
* : `tab-cache-{tabId}`
* : JSON TabCacheData
*/
const CACHE_PREFIX = "tab-cache-";
// --- 캐싱할 상태 구조 ---
export interface FormFieldSnapshot {
idx: number;
tag: string;
type: string;
name: string;
id: string;
value?: string;
checked?: boolean;
}
/** 개별 스크롤 요소의 위치 스냅샷 (DOM 경로 기반) */
export interface ScrollSnapshot {
/** 탭 컨테이너 기준 자식 인덱스 경로 (예: "0/2/1/3") */
path: string;
top: number;
left: number;
}
export interface TabCacheData {
/** DOM 폼 필드 스냅샷 (F5 복원용) */
domFormFields?: FormFieldSnapshot[];
/** 다중 스크롤 위치 (split panel 등 여러 스크롤 영역 지원) */
scrollPositions?: ScrollSnapshot[];
/** 캐싱 시각 */
cachedAt: number;
}
// --- 공개 API ---
/**
* sessionStorage에
*/
export function saveTabCacheImmediate(tabId: string, data: Partial<Omit<TabCacheData, "cachedAt">>): void {
if (typeof window === "undefined") return;
try {
const key = CACHE_PREFIX + tabId;
const current = loadTabCache(tabId);
const merged: TabCacheData = {
...current,
...data,
cachedAt: Date.now(),
};
sessionStorage.setItem(key, JSON.stringify(merged));
} catch (e) {
console.warn("[TabCache] 저장 실패:", tabId, e);
}
}
/**
* sessionStorage에서
*/
export function loadTabCache(tabId: string): TabCacheData | null {
if (typeof window === "undefined") return null;
try {
const key = CACHE_PREFIX + tabId;
const raw = sessionStorage.getItem(key);
if (!raw) return null;
return JSON.parse(raw) as TabCacheData;
} catch {
return null;
}
}
/**
*
*/
export function clearTabCache(tabId: string): void {
if (typeof window === "undefined") return;
try {
sessionStorage.removeItem(CACHE_PREFIX + tabId);
} catch {
// ignore
}
}
/**
*
*/
export function clearAllTabCaches(): void {
if (typeof window === "undefined") return;
try {
const keysToRemove: string[] = [];
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i);
if (key?.startsWith(CACHE_PREFIX)) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((key) => sessionStorage.removeItem(key));
} catch {
// ignore
}
}
// ============================================================
// DOM 폼 상태 캡처/복원
// ============================================================
/**
*
*/
export function captureFormState(container: HTMLElement | null): FormFieldSnapshot[] | null {
if (!container) return null;
const fields: FormFieldSnapshot[] = [];
const elements = container.querySelectorAll<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(
"input, textarea, select",
);
elements.forEach((el, idx) => {
const field: FormFieldSnapshot = {
idx,
tag: el.tagName.toLowerCase(),
type: (el as HTMLInputElement).type || "",
name: el.name || "",
id: el.id || "",
};
if (el instanceof HTMLInputElement) {
if (el.type === "checkbox" || el.type === "radio") {
field.checked = el.checked;
} else if (el.type !== "file" && el.type !== "password") {
field.value = el.value;
}
} else if (el instanceof HTMLTextAreaElement) {
field.value = el.value;
} else if (el instanceof HTMLSelectElement) {
field.value = el.value;
}
fields.push(field);
});
return fields.length > 0 ? fields : null;
}
/**
* React onChange를
*/
function applyFieldValue(el: Element, field: FormFieldSnapshot): void {
if (el instanceof HTMLInputElement) {
if (field.type === "checkbox" || field.type === "radio") {
if (el.checked !== field.checked) {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "checked")?.set;
setter?.call(el, field.checked);
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
}
} else if (field.value !== undefined && el.value !== field.value) {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
setter?.call(el, field.value);
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
}
} else if (el instanceof HTMLTextAreaElement) {
if (field.value !== undefined && el.value !== field.value) {
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
setter?.call(el, field.value);
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
}
} else if (el instanceof HTMLSelectElement) {
if (field.value !== undefined && el.value !== field.value) {
el.value = field.value;
el.dispatchEvent(new Event("change", { bubbles: true }));
}
}
}
/**
* DOM
*/
function findFieldElement(
container: HTMLElement,
field: FormFieldSnapshot,
allElements: NodeListOf<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>,
): Element | null {
// 1순위: id로 검색
if (field.id) {
try {
const el = container.querySelector(`#${CSS.escape(field.id)}`);
if (el) return el;
} catch {
/* ignore */
}
}
// 2순위: name으로 검색 (유일한 경우)
if (field.name) {
try {
const candidates = container.querySelectorAll(`[name="${CSS.escape(field.name)}"]`);
if (candidates.length === 1) return candidates[0];
} catch {
/* ignore */
}
}
// 3순위: 인덱스 + tag/type 일치 검증
if (field.idx < allElements.length) {
const candidate = allElements[field.idx];
if (candidate.tagName.toLowerCase() === field.tag && ((candidate as HTMLInputElement).type || "") === field.type) {
return candidate;
}
}
return null;
}
/**
* DOM에 React onChange를 .
* DOM에 .
* cleanup .
*/
export function restoreFormState(
container: HTMLElement | null,
fields: FormFieldSnapshot[] | null,
): (() => void) | undefined {
if (!container || !fields || fields.length === 0) return undefined;
let cleaned = false;
const cleanup = () => {
if (cleaned) return;
cleaned = true;
clearInterval(pollId);
clearTimeout(timeoutId);
};
const tryRestore = (): boolean => {
const elements = container.querySelectorAll<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(
"input, textarea, select",
);
if (elements.length === 0) return false;
let restoredCount = 0;
for (const field of fields) {
const el = findFieldElement(container, field, elements);
if (el) {
applyFieldValue(el, field);
restoredCount++;
}
}
return restoredCount > 0;
};
// 즉시 시도
if (tryRestore()) return undefined;
// 다음 프레임에서 재시도
requestAnimationFrame(() => {
if (cleaned) return;
if (tryRestore()) {
cleanup();
return;
}
});
// 폼 필드가 DOM에 나타날 때까지 폴링 (API 데이터 로드 대기)
const pollId = setInterval(() => {
if (tryRestore()) cleanup();
}, 100);
// 최대 5초 대기 후 포기
const timeoutId = setTimeout(() => {
tryRestore();
cleanup();
}, 5000);
return cleanup;
}
// ============================================================
// DOM 경로 기반 스크롤 위치 캡처/복원 (다중 스크롤 영역 지원)
// ============================================================
/**
* .
* : container > div(2) > div(1) > div(3) "2/1/3"
*/
export function getElementPath(element: HTMLElement, container: HTMLElement): string | null {
const indices: number[] = [];
let current: HTMLElement | null = element;
while (current && current !== container) {
const parent: HTMLElement | null = current.parentElement;
if (!parent) return null;
const children = parent.children;
let idx = -1;
for (let i = 0; i < children.length; i++) {
if (children[i] === current) {
idx = i;
break;
}
}
if (idx === -1) return null;
indices.unshift(idx);
current = parent;
}
if (current !== container) return null;
return indices.join("/");
}
/**
* .
*/
function findElementByPath(container: HTMLElement, path: string): HTMLElement | null {
if (!path) return container;
const indices = path.split("/").map(Number);
let current: HTMLElement = container;
for (const idx of indices) {
if (!current.children || idx >= current.children.length) return null;
const child = current.children[idx];
if (!(child instanceof HTMLElement)) return null;
current = child;
}
return current;
}
/**
* .
* F5 (beforeunload) - display:block이므로 DOM .
*/
export function captureAllScrollPositions(container: HTMLElement | null): ScrollSnapshot[] | undefined {
if (!container) return undefined;
const snapshots: ScrollSnapshot[] = [];
const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT);
let node: Node | null;
while ((node = walker.nextNode())) {
const el = node as HTMLElement;
if (el.scrollTop > 0 || el.scrollLeft > 0) {
const path = getElementPath(el, container);
if (path) {
snapshots.push({ path, top: el.scrollTop, left: el.scrollLeft });
}
}
}
return snapshots.length > 0 ? snapshots : undefined;
}
/**
* DOM .
* .
*/
export function restoreAllScrollPositions(
container: HTMLElement | null,
positions?: ScrollSnapshot[],
): (() => void) | undefined {
if (!container || !positions || positions.length === 0) return undefined;
let cleaned = false;
const cleanup = () => {
if (cleaned) return;
cleaned = true;
clearInterval(pollId);
clearTimeout(timeoutId);
};
const tryRestore = (): boolean => {
let restoredCount = 0;
for (const pos of positions) {
const el = findElementByPath(container, pos.path);
if (!el) continue;
if (el.scrollHeight >= pos.top + el.clientHeight) {
el.scrollTop = pos.top;
el.scrollLeft = pos.left;
restoredCount++;
}
}
return restoredCount === positions.length;
};
if (tryRestore()) return undefined;
requestAnimationFrame(() => {
if (cleaned) return;
if (tryRestore()) {
cleanup();
return;
}
});
const pollId = setInterval(() => {
if (tryRestore()) cleanup();
}, 50);
// 최대 5초 대기 후 강제 복원
const timeoutId = setTimeout(() => {
for (const pos of positions) {
const el = findElementByPath(container, pos.path);
if (el) {
el.scrollTop = pos.top;
el.scrollLeft = pos.left;
}
}
cleanup();
}, 5000);
return cleanup;
}

View File

@ -661,3 +661,4 @@ const calculateStringSimilarity = (str1: string, str2: string): number => {
return maxLen === 0 ? 1 : (maxLen - distance) / maxLen;
};

View File

@ -72,6 +72,7 @@
"mammoth": "^1.11.0",
"next": "^15.4.8",
"qrcode": "^1.5.4",
"radix-ui": "^1.4.3",
"react": "19.1.0",
"react-day-picker": "^9.11.1",
"react-dnd": "^16.0.1",
@ -94,7 +95,8 @@
"three": "^0.180.0",
"uuid": "^13.0.0",
"xlsx": "^0.18.5",
"zod": "^4.1.5"
"zod": "^4.1.5",
"zustand": "^5.0.11"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@ -1491,6 +1493,29 @@
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-accessible-icon": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.7.tgz",
"integrity": "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-visually-hidden": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-accordion": {
"version": "1.2.12",
"resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz",
@ -1573,6 +1598,29 @@
}
}
},
"node_modules/@radix-ui/react-aspect-ratio": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz",
"integrity": "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-avatar": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz",
@ -1891,6 +1939,65 @@
}
}
},
"node_modules/@radix-ui/react-form": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.8.tgz",
"integrity": "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-label": "2.1.7",
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-hover-card": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz",
"integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
@ -1972,6 +2079,38 @@
}
}
},
"node_modules/@radix-ui/react-menubar": {
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.16.tgz",
"integrity": "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-menu": "2.1.16",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-navigation-menu": {
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz",
@ -2008,6 +2147,70 @@
}
}
},
"node_modules/@radix-ui/react-one-time-password-field": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.8.tgz",
"integrity": "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-is-hydrated": "0.1.0",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-password-toggle-field": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.3.tgz",
"integrity": "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-is-hydrated": "0.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
@ -2332,6 +2535,39 @@
}
}
},
"node_modules/@radix-ui/react-slider": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz",
"integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
@ -2409,6 +2645,157 @@
}
}
},
"node_modules/@radix-ui/react-toast": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz",
"integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-visually-hidden": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toggle": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz",
"integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toggle-group": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz",
"integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-toggle": "1.1.10",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toolbar": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.11.tgz",
"integrity": "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-separator": "1.1.7",
"@radix-ui/react-toggle-group": "1.1.11"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
"integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-visually-hidden": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
@ -13126,6 +13513,83 @@
"integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==",
"license": "ISC"
},
"node_modules/radix-ui": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz",
"integrity": "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-accessible-icon": "1.1.7",
"@radix-ui/react-accordion": "1.2.12",
"@radix-ui/react-alert-dialog": "1.1.15",
"@radix-ui/react-arrow": "1.1.7",
"@radix-ui/react-aspect-ratio": "1.1.7",
"@radix-ui/react-avatar": "1.1.10",
"@radix-ui/react-checkbox": "1.3.3",
"@radix-ui/react-collapsible": "1.1.12",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-context-menu": "2.2.16",
"@radix-ui/react-dialog": "1.1.15",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-dropdown-menu": "2.1.16",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-form": "0.1.8",
"@radix-ui/react-hover-card": "1.1.15",
"@radix-ui/react-label": "2.1.7",
"@radix-ui/react-menu": "2.1.16",
"@radix-ui/react-menubar": "1.1.16",
"@radix-ui/react-navigation-menu": "1.2.14",
"@radix-ui/react-one-time-password-field": "0.1.8",
"@radix-ui/react-password-toggle-field": "0.1.3",
"@radix-ui/react-popover": "1.1.15",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-progress": "1.1.7",
"@radix-ui/react-radio-group": "1.3.8",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-scroll-area": "1.2.10",
"@radix-ui/react-select": "2.2.6",
"@radix-ui/react-separator": "1.1.7",
"@radix-ui/react-slider": "1.3.6",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-switch": "1.2.6",
"@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-toast": "1.2.15",
"@radix-ui/react-toggle": "1.1.10",
"@radix-ui/react-toggle-group": "1.1.11",
"@radix-ui/react-toolbar": "1.1.11",
"@radix-ui/react-tooltip": "1.2.8",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-escape-keydown": "1.1.1",
"@radix-ui/react-use-is-hydrated": "0.1.0",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-size": "1.1.1",
"@radix-ui/react-visually-hidden": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
@ -15843,9 +16307,9 @@
}
},
"node_modules/zustand": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
"integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==",
"version": "5.0.11",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",
"integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"

View File

@ -81,6 +81,7 @@
"mammoth": "^1.11.0",
"next": "^15.4.8",
"qrcode": "^1.5.4",
"radix-ui": "^1.4.3",
"react": "19.1.0",
"react-day-picker": "^9.11.1",
"react-dnd": "^16.0.1",
@ -103,7 +104,8 @@
"three": "^0.180.0",
"uuid": "^13.0.0",
"xlsx": "^0.18.5",
"zod": "^4.1.5"
"zod": "^4.1.5",
"zustand": "^5.0.11"
},
"devDependencies": {
"@eslint/eslintrc": "^3",

224
frontend/stores/tabStore.ts Normal file
View File

@ -0,0 +1,224 @@
"use client";
import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
import { clearTabCache } from "@/lib/tabStateCache";
// --- 타입 정의 ---
export type AppMode = "user" | "admin";
export interface Tab {
id: string;
type: "screen" | "admin";
title: string;
screenId?: number;
menuObjid?: number;
adminUrl?: string;
}
interface ModeTabData {
tabs: Tab[];
activeTabId: string | null;
}
interface TabState {
mode: AppMode;
user: ModeTabData;
admin: ModeTabData;
refreshKeys: Record<string, number>;
setMode: (mode: AppMode) => void;
openTab: (tab: Omit<Tab, "id">, insertIndex?: number) => void;
closeTab: (tabId: string) => void;
switchTab: (tabId: string) => void;
refreshTab: (tabId: string) => void;
closeOtherTabs: (tabId: string) => void;
closeTabsToLeft: (tabId: string) => void;
closeTabsToRight: (tabId: string) => void;
closeAllTabs: () => void;
updateTabOrder: (fromIndex: number, toIndex: number) => void;
}
// --- 헬퍼 함수 ---
function generateTabId(tab: Omit<Tab, "id">): string {
if (tab.type === "screen" && tab.screenId != null) {
return `tab-screen-${tab.screenId}-${tab.menuObjid ?? 0}`;
}
if (tab.type === "admin" && tab.adminUrl) {
return `tab-admin-${tab.adminUrl.replace(/[^a-zA-Z0-9]/g, "-")}`;
}
return `tab-${Date.now()}`;
}
function findDuplicateTab(tabs: Tab[], newTab: Omit<Tab, "id">): Tab | undefined {
if (newTab.type === "screen" && newTab.screenId != null) {
return tabs.find(
(t) => t.type === "screen" && t.screenId === newTab.screenId && t.menuObjid === newTab.menuObjid,
);
}
if (newTab.type === "admin" && newTab.adminUrl) {
return tabs.find((t) => t.type === "admin" && t.adminUrl === newTab.adminUrl);
}
return undefined;
}
function getNextActiveTabId(tabs: Tab[], closedTabId: string, currentActiveId: string | null): string | null {
if (currentActiveId !== closedTabId) return currentActiveId;
const idx = tabs.findIndex((t) => t.id === closedTabId);
if (idx === -1) return null;
const remaining = tabs.filter((t) => t.id !== closedTabId);
if (remaining.length === 0) return null;
if (idx > 0) return remaining[Math.min(idx - 1, remaining.length - 1)].id;
return remaining[0].id;
}
// 현재 모드의 데이터 키 반환
function modeKey(state: TabState): AppMode {
return state.mode;
}
// --- 셀렉터 (컴포넌트에서 사용) ---
export function selectTabs(state: TabState): Tab[] {
return state[state.mode].tabs;
}
export function selectActiveTabId(state: TabState): string | null {
return state[state.mode].activeTabId;
}
// --- Store ---
const EMPTY_MODE: ModeTabData = { tabs: [], activeTabId: null };
export const useTabStore = create<TabState>()(
devtools(
persist(
(set, get) => ({
mode: "user" as AppMode,
user: { ...EMPTY_MODE },
admin: { ...EMPTY_MODE },
refreshKeys: {},
setMode: (mode) => {
set({ mode });
},
openTab: (tabData, insertIndex) => {
const mk = modeKey(get());
const modeData = get()[mk];
const existing = findDuplicateTab(modeData.tabs, tabData);
if (existing) {
set({ [mk]: { ...modeData, activeTabId: existing.id } });
return;
}
const id = generateTabId(tabData);
const newTab: Tab = { ...tabData, id };
const newTabs = [...modeData.tabs];
if (insertIndex != null && insertIndex >= 0 && insertIndex <= newTabs.length) {
newTabs.splice(insertIndex, 0, newTab);
} else {
newTabs.push(newTab);
}
set({ [mk]: { tabs: newTabs, activeTabId: id } });
},
closeTab: (tabId) => {
clearTabCache(tabId);
const mk = modeKey(get());
const modeData = get()[mk];
const nextActive = getNextActiveTabId(modeData.tabs, tabId, modeData.activeTabId);
const newTabs = modeData.tabs.filter((t) => t.id !== tabId);
const { [tabId]: _, ...restKeys } = get().refreshKeys;
set({ [mk]: { tabs: newTabs, activeTabId: nextActive }, refreshKeys: restKeys });
},
switchTab: (tabId) => {
const mk = modeKey(get());
const modeData = get()[mk];
set({ [mk]: { ...modeData, activeTabId: tabId } });
},
refreshTab: (tabId) => {
set((state) => ({
refreshKeys: { ...state.refreshKeys, [tabId]: (state.refreshKeys[tabId] || 0) + 1 },
}));
},
closeOtherTabs: (tabId) => {
const mk = modeKey(get());
const modeData = get()[mk];
modeData.tabs.filter((t) => t.id !== tabId).forEach((t) => clearTabCache(t.id));
set({ [mk]: { tabs: modeData.tabs.filter((t) => t.id === tabId), activeTabId: tabId } });
},
closeTabsToLeft: (tabId) => {
const mk = modeKey(get());
const modeData = get()[mk];
const idx = modeData.tabs.findIndex((t) => t.id === tabId);
if (idx === -1) return;
modeData.tabs.slice(0, idx).forEach((t) => clearTabCache(t.id));
set({ [mk]: { tabs: modeData.tabs.slice(idx), activeTabId: tabId } });
},
closeTabsToRight: (tabId) => {
const mk = modeKey(get());
const modeData = get()[mk];
const idx = modeData.tabs.findIndex((t) => t.id === tabId);
if (idx === -1) return;
modeData.tabs.slice(idx + 1).forEach((t) => clearTabCache(t.id));
set({ [mk]: { tabs: modeData.tabs.slice(0, idx + 1), activeTabId: tabId } });
},
closeAllTabs: () => {
const mk = modeKey(get());
const modeData = get()[mk];
modeData.tabs.forEach((t) => clearTabCache(t.id));
set({ [mk]: { tabs: [], activeTabId: null } });
},
updateTabOrder: (fromIndex, toIndex) => {
const mk = modeKey(get());
const modeData = get()[mk];
const newTabs = [...modeData.tabs];
const [moved] = newTabs.splice(fromIndex, 1);
newTabs.splice(toIndex, 0, moved);
set({ [mk]: { ...modeData, tabs: newTabs } });
},
}),
{
name: "erp-tab-store",
storage: {
getItem: (name) => {
if (typeof window === "undefined") return null;
const raw = sessionStorage.getItem(name);
return raw ? JSON.parse(raw) : null;
},
setItem: (name, value) => {
if (typeof window === "undefined") return;
sessionStorage.setItem(name, JSON.stringify(value));
},
removeItem: (name) => {
if (typeof window === "undefined") return;
sessionStorage.removeItem(name);
},
},
partialize: (state) => ({
mode: state.mode,
user: state.user,
admin: state.admin,
}) as unknown as TabState,
},
),
{ name: "TabStore" },
),
);