Compare commits

..

91 Commits

Author SHA1 Message Date
kjs 29b9cbdc90 Merge pull request 'jskim-node' (#414) from jskim-node into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/414
2026-03-13 11:47:07 +09:00
kmh bd08b341f0 Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into mhkim-node 2026-03-13 11:35:07 +09:00
kmh 4fe023a813 feat: enhance column width configuration and rendering
- Updated the column width handling in various components to support percentage-based widths, improving layout flexibility.
- Adjusted input fields to enforce minimum and maximum width constraints, ensuring better user experience and preventing layout issues.
- Enhanced the SortableColumnRow and related components to dynamically display width units, allowing for clearer configuration options.

Made-with: Cursor
2026-03-13 11:29:32 +09:00
kjs 3df9a39ebe feat: implement registered items management in process work standard
- Added new endpoints for managing registered items, including retrieval, registration, and batch registration.
- Enhanced the existing processWorkStandardController to support filtering and additional columns in item queries.
- Updated the processWorkStandardRoutes to include routes for registered items management.
- Introduced a new documentation file detailing the design and structure of the POP 작업진행 관리 system.

These changes aim to improve the management of registered items within the process work standard, enhancing usability and functionality.

Made-with: Cursor
2026-03-13 11:26:59 +09:00
kjs 772a10258c fix: update V2PropertiesPanel to use V2FieldConfigPanel for input and select components
- Replaced references to V2InputConfigPanel and V2SelectConfigPanel with V2FieldConfigPanel in the V2PropertiesPanel.
- This change ensures consistent configuration handling for both input and select components, improving maintainability and usability.

Made-with: Cursor
2026-03-12 15:53:30 +09:00
kjs 7da04c6a09 feat: enhance V2InputConfigPanel with additional UI components
- Added Separator and Checkbox components to the V2InputConfigPanel for improved layout and functionality.
- This enhancement aims to provide better user interaction and organization within the input configuration settings.

Made-with: Cursor
2026-03-12 15:53:23 +09:00
kjs 808c23b341 chore: remove peer dependencies from package-lock.json
- Removed unnecessary "peer" entries from various packages in package-lock.json to streamline dependency management and avoid potential conflicts.
- This cleanup helps maintain a cleaner and more efficient package structure.

Made-with: Cursor
2026-03-12 15:53:23 +09:00
kjs 4c95e883ce Merge commit '80be7c5a7600c080f897823837890fbdef58f565' into jskim-node
Made-with: Cursor

# Conflicts:
#	.cursor/agents/pipeline-common-rules.md
#	.cursor/agents/pipeline-frontend.md
#	.cursor/agents/pipeline-ui.md
2026-03-12 15:53:20 +09:00
kmh f28cb5c2f6 Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into mhkim-node 2026-03-12 15:47:09 +09:00
kjs efd3e2a0cd fix: update V2PropertiesPanel to use V2FieldConfigPanel for input and select components
- Replaced references to V2InputConfigPanel and V2SelectConfigPanel with V2FieldConfigPanel in the V2PropertiesPanel.
- This change ensures consistent configuration handling for both input and select components, improving maintainability and usability.

Made-with: Cursor
2026-03-12 15:46:54 +09:00
kjs 884cde463f feat: enhance V2InputConfigPanel with additional UI components
- Added Separator and Checkbox components to the V2InputConfigPanel for improved layout and functionality.
- This enhancement aims to provide better user interaction and organization within the input configuration settings.

Made-with: Cursor
2026-03-12 15:43:06 +09:00
kmh d22b8f7f07 Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into mhkim-node 2026-03-12 15:12:35 +09:00
kjs c757ea1733 chore: remove peer dependencies from package-lock.json
- Removed unnecessary "peer" entries from various packages in package-lock.json to streamline dependency management and avoid potential conflicts.
- This cleanup helps maintain a cleaner and more efficient package structure.

Made-with: Cursor
2026-03-12 15:12:26 +09:00
kjs ca390bb191 Merge branch 'gbpark-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-03-12 15:09:55 +09:00
DDD1542 80be7c5a76 feat: enhance component configuration and rendering
- Updated the RealtimePreviewDynamic component to display selected component information more clearly.
- Added dynamic field type labels in the RealtimePreviewDynamic component for better user understanding.
- Introduced a table refresh counter in the ScreenDesigner component to handle table column updates effectively.
- Improved the V2PropertiesPanel and V2SelectConfigPanel to support additional properties and enhance usability.
- Refactored the DynamicComponentRenderer to better handle field types and improve component configuration merging.

Made-with: Cursor
2026-03-12 15:01:05 +09:00
kmh 9fdcde3e8c Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into mhkim-node 2026-03-12 14:33:14 +09:00
kjs dc37ad4471 Merge branch 'mhkim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-03-12 14:31:31 +09:00
kmh a46d2e0510 Merge branch 'mhkim-node' of http://39.117.244.52:3000/kjs/ERP-node into mhkim-node 2026-03-12 13:27:10 +09:00
kmh 62513ad2f0 feat: enhance responsive grid rendering and filter width management
- Introduced a new ProportionalRenderer component to improve the layout of components in the ResponsiveGridRenderer based on the canvas width.
- Implemented dynamic resizing of components using ResizeObserver to ensure proper rendering across different screen sizes.
- Updated filter width handling in FilterPanel and TableSettingsModal to restrict width values between 10% and 100%, enhancing usability and consistency.
- Adjusted the TableSearchWidget to reflect the new percentage-based width for filters, improving the overall layout and responsiveness.

These changes aim to enhance the user experience by providing a more flexible and responsive design for grid layouts and filter components.
2026-03-12 13:27:01 +09:00
DDD1542 83aa8f3250 chore: remove obsolete agent pipeline documentation files
- Deleted outdated documentation files for pipeline-backend, pipeline-common-rules, pipeline-db, pipeline-frontend, pipeline-ui, and pipeline-verifier.
- These files contained critical project rules and guidelines that are no longer applicable or have been consolidated elsewhere.

Made-with: Cursor
2026-03-12 10:25:52 +09:00
DDD1542 45740c1457 feat: add responsive tests for Option Settings and Split Panel
- Implemented responsive testing scripts for Option Settings and ResponsiveSplitPanel components.
- Added tests to verify layout behavior across various screen sizes (desktop, tablet, mobile).
- Generated screenshots and reports for visual validation of responsive designs.

Made-with: Cursor
2026-03-12 10:04:26 +09:00
DDD1542 00e15dd9f2 [agent-pipeline] pipe-20260311225813-8hmk round-2 2026-03-12 08:27:47 +09:00
DDD1542 bb442f5478 [agent-pipeline] pipe-20260311225813-8hmk round-1 2026-03-12 08:18:34 +09:00
DDD1542 db3ad9d639 [agent-pipeline] pipe-20260311221723-l7a9 round-2 2026-03-12 07:54:21 +09:00
DDD1542 8ad0c8797d [agent-pipeline] pipe-20260311221723-l7a9 round-1 2026-03-12 07:35:09 +09:00
DDD1542 5093863e08 [agent-pipeline] pipe-20260311204151-c4wy round-2 2026-03-12 06:00:48 +09:00
DDD1542 08ed6b0b53 [agent-pipeline] pipe-20260311204151-c4wy round-1 2026-03-12 05:51:39 +09:00
DDD1542 e45f2d6dc6 [agent-pipeline] pipe-20260311193053-kk9w round-2 2026-03-12 04:45:02 +09:00
DDD1542 196be8c926 [agent-pipeline] pipe-20260311193053-kk9w round-1 2026-03-12 04:39:57 +09:00
DDD1542 42be94ad36 [agent-pipeline] pipe-20260311185722-je7c round-5 2026-03-12 04:25:34 +09:00
DDD1542 624ce6b046 [agent-pipeline] pipe-20260311185722-je7c round-4 2026-03-12 04:15:58 +09:00
DDD1542 111023191e [agent-pipeline] pipe-20260311185722-je7c round-3 2026-03-12 04:12:04 +09:00
DDD1542 19b078cf02 [agent-pipeline] pipe-20260311185722-je7c round-2 2026-03-12 04:04:47 +09:00
DDD1542 ff94c5c42d [agent-pipeline] pipe-20260311185722-je7c round-1 2026-03-12 04:00:25 +09:00
DDD1542 6974a38df2 [agent-pipeline] pipe-20260311182531-f443 round-6 2026-03-12 03:56:15 +09:00
DDD1542 43edc16486 [agent-pipeline] pipe-20260311182531-f443 round-5 2026-03-12 03:49:27 +09:00
DDD1542 465395c162 [agent-pipeline] pipe-20260311182531-f443 round-4 2026-03-12 03:45:41 +09:00
DDD1542 eb354f1918 [agent-pipeline] pipe-20260311182531-f443 round-3 2026-03-12 03:41:33 +09:00
DDD1542 a99a59951e [agent-pipeline] pipe-20260311182531-f443 round-2 2026-03-12 03:35:23 +09:00
DDD1542 5baf5842b4 [agent-pipeline] pipe-20260311182531-f443 round-1 2026-03-12 03:30:18 +09:00
DDD1542 3c2c1764fc [agent-pipeline] pipe-20260311174249-88g7 round-4 2026-03-12 03:01:31 +09:00
DDD1542 0a0a175fcd [agent-pipeline] pipe-20260311174249-88g7 round-3 2026-03-12 02:55:20 +09:00
DDD1542 38cf617226 [agent-pipeline] pipe-20260311174249-88g7 round-2 2026-03-12 02:50:18 +09:00
DDD1542 9dbedbe42c [agent-pipeline] pipe-20260311174249-88g7 round-1 2026-03-12 02:45:25 +09:00
DDD1542 5eb794177e [agent-pipeline] pipe-20260311155325-udmh round-5 2026-03-12 01:40:47 +09:00
DDD1542 a3ebed48d0 [agent-pipeline] rollback to 0277b6ba 2026-03-12 01:29:07 +09:00
DDD1542 950518431a [agent-pipeline] pipe-20260311155325-udmh round-4 2026-03-12 01:29:07 +09:00
DDD1542 0277b6ba69 [agent-pipeline] pipe-20260311155325-udmh round-3 2026-03-12 01:17:51 +09:00
DDD1542 e1508e9087 [agent-pipeline] pipe-20260311155325-udmh round-2 2026-03-12 01:06:01 +09:00
DDD1542 52a73e8cda [agent-pipeline] pipe-20260311155325-udmh round-1 2026-03-12 01:00:03 +09:00
DDD1542 d6c7900e00 [agent-pipeline] pipe-20260311151253-nyk7 round-10 2026-03-12 00:49:11 +09:00
DDD1542 33e8f6e8f1 [agent-pipeline] pipe-20260311151253-nyk7 round-9 2026-03-12 00:45:49 +09:00
DDD1542 fcae946a3f [agent-pipeline] pipe-20260311151253-nyk7 round-8 2026-03-12 00:41:59 +09:00
DDD1542 baa1d40bc4 [agent-pipeline] pipe-20260311151253-nyk7 round-7 2026-03-12 00:37:17 +09:00
DDD1542 b4aec05512 [agent-pipeline] pipe-20260311151253-nyk7 round-6 2026-03-12 00:34:36 +09:00
DDD1542 2feab9cde8 [agent-pipeline] pipe-20260311151253-nyk7 round-5 2026-03-12 00:31:29 +09:00
DDD1542 460776b48e [agent-pipeline] pipe-20260311151253-nyk7 round-4 2026-03-12 00:27:35 +09:00
DDD1542 08402bf730 [agent-pipeline] pipe-20260311151253-nyk7 round-3 2026-03-12 00:24:05 +09:00
DDD1542 5eb10fd9a9 [agent-pipeline] pipe-20260311151253-nyk7 round-2 2026-03-12 00:19:59 +09:00
DDD1542 0bb024ca05 [agent-pipeline] pipe-20260311151253-nyk7 round-1 2026-03-12 00:16:20 +09:00
DDD1542 6f311148a5 [agent-pipeline] pipe-20260311130333-zqic round-3 2026-03-11 22:26:52 +09:00
DDD1542 d2c8f5f8f5 [agent-pipeline] pipe-20260311130333-zqic round-2 2026-03-11 22:20:59 +09:00
DDD1542 ae852ed4ad [agent-pipeline] pipe-20260311130333-zqic round-1 2026-03-11 22:13:58 +09:00
DDD1542 1d9ed6b36b [agent-pipeline] pipe-20260311124806-lfrk round-3 2026-03-11 21:58:53 +09:00
DDD1542 f72649291c [agent-pipeline] pipe-20260311124806-lfrk round-2 2026-03-11 21:55:21 +09:00
DDD1542 1bbce43ec1 [agent-pipeline] pipe-20260311124806-lfrk round-1 2026-03-11 21:51:37 +09:00
DDD1542 24630dd60b [agent-pipeline] pipe-20260311122226-4dkx round-5 2026-03-11 21:44:12 +09:00
DDD1542 eaa893a01a [agent-pipeline] pipe-20260311122226-4dkx round-4 2026-03-11 21:40:33 +09:00
DDD1542 a36bcceef3 [agent-pipeline] pipe-20260311122226-4dkx round-3 2026-03-11 21:37:01 +09:00
DDD1542 b2d3c6c688 [agent-pipeline] pipe-20260311122226-4dkx round-2 2026-03-11 21:29:28 +09:00
DDD1542 49f7bb9937 [agent-pipeline] pipe-20260311122226-4dkx round-1 2026-03-11 21:25:37 +09:00
DDD1542 5f6f2203ae [agent-pipeline] pipe-20260311115150-4sye round-1 2026-03-11 20:54:38 +09:00
DDD1542 2da5cd9637 [agent-pipeline] pipe-20260311104251-xw7c round-2 2026-03-11 19:49:17 +09:00
DDD1542 6be07e5e88 [agent-pipeline] pipe-20260311104251-xw7c round-1 2026-03-11 19:46:44 +09:00
DDD1542 44c5dc87b4 [agent-pipeline] pipe-20260311102309-26hg round-2 2026-03-11 19:30:01 +09:00
DDD1542 175c19a79a [agent-pipeline] pipe-20260311102309-26hg round-1 2026-03-11 19:26:17 +09:00
DDD1542 ff54e48ede [agent-pipeline] pipe-20260311080625-8a1t round-1 2026-03-11 17:15:15 +09:00
DDD1542 615bd8e2bf [agent-pipeline] pipe-20260311071246-rhvz round-7 2026-03-11 16:49:44 +09:00
DDD1542 f3eca6b02c [agent-pipeline] pipe-20260311071246-rhvz round-6 2026-03-11 16:42:06 +09:00
DDD1542 ca001408f8 [agent-pipeline] pipe-20260311071246-rhvz round-5 2026-03-11 16:36:06 +09:00
DDD1542 506475e5cc [agent-pipeline] pipe-20260311071246-rhvz round-4 2026-03-11 16:31:52 +09:00
DDD1542 d1d5f651cc [agent-pipeline] pipe-20260311071246-rhvz round-3 2026-03-11 16:27:18 +09:00
DDD1542 d4f2a3cf04 [agent-pipeline] pipe-20260311071246-rhvz round-1 2026-03-11 16:17:32 +09:00
DDD1542 0eba11e047 [agent-pipeline] pipe-20260311064710-y8yf round-9 2026-03-11 16:04:46 +09:00
DDD1542 3f8e995c87 [agent-pipeline] pipe-20260311064710-y8yf round-8 2026-03-11 15:57:02 +09:00
DDD1542 040c746334 [agent-pipeline] pipe-20260311052455-y968 round-6 2026-03-11 15:10:14 +09:00
DDD1542 9125d04345 [agent-pipeline] pipe-20260311052455-y968 round-5 2026-03-11 14:54:47 +09:00
DDD1542 f071777131 [agent-pipeline] pipe-20260311052455-y968 round-4 2026-03-11 14:50:02 +09:00
DDD1542 834c52a2b2 [agent-pipeline] pipe-20260311052455-y968 round-3 2026-03-11 14:46:07 +09:00
DDD1542 d358de60d6 [agent-pipeline] pipe-20260311052455-y968 round-2 2026-03-11 14:41:14 +09:00
DDD1542 b329b52036 [agent-pipeline] pipe-20260311052455-y968 round-1 2026-03-11 14:35:02 +09:00
147 changed files with 34679 additions and 11524 deletions

View File

@ -1,66 +0,0 @@
---
name: pipeline-backend
description: Agent Pipeline 백엔드 전문가. Express + TypeScript + PostgreSQL Raw Query 기반 API 구현. 멀티테넌시(company_code) 필터링 필수.
model: inherit
---
# Role
You are a Backend specialist for ERP-node project.
Stack: Node.js + Express + TypeScript + PostgreSQL Raw Query.
# CRITICAL PROJECT RULES
## 1. Multi-tenancy (ABSOLUTE MUST!)
- ALL queries MUST include company_code filter
- Use req.user!.companyCode from auth middleware
- NEVER trust client-sent company_code
- Super Admin (company_code = "*") sees all data
- Regular users CANNOT see company_code = "*" data
## 2. Required Code Pattern
```typescript
const companyCode = req.user!.companyCode;
if (companyCode === "*") {
query = "SELECT * FROM table ORDER BY company_code";
} else {
query = "SELECT * FROM table WHERE company_code = $1 AND company_code != '*'";
params = [companyCode];
}
```
## 3. Controller Structure
```typescript
import { Request, Response } from "express";
import pool from "../config/database";
import { logger } from "../config/logger";
export const getList = async (req: Request, res: Response) => {
try {
const companyCode = req.user!.companyCode;
// ... company_code 분기 처리
const result = await pool.query(query, params);
res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("조회 실패", error);
res.status(500).json({ success: false, message: error.message });
}
};
```
## 4. Route Registration
- backend-node/src/routes/index.ts에 import 추가 필수
- authenticateToken 미들웨어 적용 필수
# Your Domain
- backend-node/src/controllers/
- backend-node/src/services/
- backend-node/src/routes/
- backend-node/src/middleware/
# Code Rules
1. TypeScript strict mode
2. Error handling with try/catch
3. Comments in Korean
4. Follow existing code patterns
5. Use logger for important operations
6. Parameter binding ($1, $2) for SQL injection prevention

View File

@ -1,182 +0,0 @@
# WACE ERP 파이프라인 공통 룰 (모든 에이전트 필수 준수)
## 1. 화면 유형 구분 (절대 규칙!)
이 시스템은 **관리자 메뉴**와 **사용자 메뉴**가 완전히 다른 방식으로 동작한다.
기능 구현 시 반드시 어느 유형인지 먼저 판단하라.
### 관리자 메뉴 (Admin)
- **구현 방식**: React 코드 기반 페이지 (`.tsx` 파일)
- **경로**: `frontend/app/(main)/admin/{기능명}/page.tsx`
- **메뉴 등록**: `menu_info` 테이블에 INSERT 필수 (코드만 만들고 메뉴 등록 안 하면 미완성!)
- **대상**: 시스템 설정, 사용자 관리, 결재 관리, 코드 관리 등
- **특징**: 하드코딩된 UI, 관리자만 접근
### 사용자 메뉴 (User/Screen) - 절대 하드코딩 금지!!!
- **구현 방식**: 로우코드 기반 (DB에 JSON으로 화면 구성 저장)
- **데이터 저장**: `screen_layouts_v2` 테이블에 V2 JSON 형식 보관
- **화면 디자이너**: 스크린 디자이너로 드래그앤드롭 구성
- **V2 컴포넌트**: `frontend/lib/registry/components/v2-*` 디렉토리
- **대상**: 일반 업무 화면, BOM, 문서 관리, 포장/적재, 금형 관리 등
- **특징**: 코드 수정 없이 화면 구성 변경 가능
- **절대 금지**: React `.tsx` 페이지 파일로 직접 UI를 하드코딩하는 것!
### 판단 기준
| 질문 | 관리자 메뉴 | 사용자 메뉴 |
|------|-------------|-------------|
| 누가 쓰나? | 시스템 관리자 | 일반 사용자 |
| 화면 구조 고정? | 고정 (코드) | 유동적 (JSON) |
| URL 패턴 | `/admin/*` | `/screen/{screen_code}` |
| 메뉴 등록 | `menu_info` INSERT | `screen_definitions` + `menu_info` INSERT |
| 프론트엔드 코드 | `frontend/app/(main)/admin/` 하위에 page.tsx 작성 | **코드 작성 금지!** DB에 스크린 정의만 등록 |
### 사용자 메뉴 구현 방법 (반드시 이 방식으로!)
**절대 규칙: 사용자 메뉴는 React 페이지(.tsx)를 직접 만들지 않는다!**
이미 `/screen/[screenCode]/page.tsx``/screens/[screenId]/page.tsx` 렌더링 시스템이 존재한다.
새 화면이 필요하면 DB에 등록만 하면 자동으로 렌더링된다.
#### Step 1: screen_definitions에 화면 등록
```sql
INSERT INTO screen_definitions (screen_name, screen_code, table_name, company_code, is_active)
VALUES ('포장/적재정보 관리', 'COMPANY_7_PKG', 'pkg_unit', 'COMPANY_7', 'Y')
RETURNING screen_id;
```
- `screen_code`: `{company_code}_{기능약어}` 형식 (예: COMPANY_7_PKG)
- `table_name`: 메인 테이블명 (V2 컴포넌트가 이 테이블 기준으로 동작)
- `company_code`: 대상 회사 코드
#### Step 2: screen_layouts_v2에 V2 레이아웃 JSON 등록
```sql
INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data)
VALUES (
{screen_id},
'COMPANY_7',
1,
'기본 레이어',
'{
"version": "2.0",
"components": [
{
"id": "comp_split_1",
"url": "@/lib/registry/components/v2-split-panel-layout",
"position": {"x": 0, "y": 0},
"size": {"width": 1200, "height": 800},
"displayOrder": 0,
"overrides": {
"leftTitle": "포장단위 목록",
"rightTitle": "상세 정보",
"splitRatio": 40,
"leftTableName": "pkg_unit",
"rightTableName": "pkg_unit",
"tabs": [
{"id": "basic", "label": "기본정보"},
{"id": "items", "label": "매칭품목"}
]
}
}
]
}'::jsonb
);
```
- V2 컴포넌트 목록: v2-split-panel-layout, v2-table-list, v2-table-search-widget, v2-repeater, v2-button-primary, v2-tabs-widget 등
- 상세 컴포넌트 가이드: `.cursor/rules/component-development-guide.mdc` 참조
#### Step 3: menu_info에 메뉴 등록
```sql
-- 먼저 부모 메뉴 objid 조회
-- SELECT objid, menu_name_kor FROM menu_info WHERE company_code = '{회사코드}' AND menu_name_kor LIKE '%물류%';
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, seq, menu_url, screen_code, company_code, status)
VALUES (
(SELECT COALESCE(MAX(objid), 0) + 1 FROM menu_info),
2, -- 2 = 메뉴 항목
{부모_objid}, -- 상위 메뉴의 objid
'포장/적재정보',
10, -- 정렬 순서
'/screen/COMPANY_7_PKG', -- /screen/{screen_code} 형식 (절대!)
'COMPANY_7_PKG', -- screen_definitions.screen_code와 일치
'COMPANY_7',
'Y'
);
```
**핵심**: `menu_url`은 반드시 `/screen/{screen_code}` 형식이어야 한다!
프론트엔드가 이 URL을 받아 `screen_definitions`에서 screen_id를 찾고, `screen_layouts_v2`에서 레이아웃을 로드한다.
## 2. 관리자 메뉴 등록 (코드 구현 후 필수!)
관리자 기능을 코드로 만들었으면 반드시 `menu_info`에 등록해야 한다.
```sql
-- 예시: 결재 템플릿 관리 메뉴 등록
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, seq, menu_url, company_code, status)
VALUES (
(SELECT COALESCE(MAX(objid), 0) + 1 FROM menu_info),
2, {부모_objid}, '결재 템플릿', 40, '/admin/approvalTemplate', '대상회사코드', 'Y'
);
```
- 기존 메뉴 구조를 먼저 조회해서 parent_obj_id, seq 등을 맞춰라
- company_code 별로 등록이 필요할 수 있다
- menu_auth_group 권한 매핑도 필요하면 추가
## 3. 하드코딩 금지 / 범용성 필수
- 특정 회사에만 동작하는 코드 금지
- 특정 사용자 ID에 의존하는 로직 금지
- 매직 넘버 사용 금지 (상수 또는 설정 파일로 관리)
- 하드코딩 색상 금지 (CSS 변수 사용: bg-primary, text-destructive 등)
- 하드코딩 URL 금지 (환경 변수 또는 API 클라이언트 사용)
## 4. 테스트 환경 정보
- **테스트 계정**: userId=`wace`, password=`qlalfqjsgh11`
- **역할**: SUPER_ADMIN (company_code = "*")
- **개발 프론트엔드**: http://localhost:9771
- **개발 백엔드 API**: http://localhost:8080
- **개발 DB**: postgresql://postgres:ph0909!!@39.117.244.52:11132/plm
## 5. 기능 구현 완성 체크리스트
기능 하나를 "완성"이라고 말하려면 아래를 전부 충족해야 한다:
### 공통
- [ ] DB: 마이그레이션 작성 + 실행 완료
- [ ] DB: company_code 컬럼 + 인덱스 존재
- [ ] BE: API 엔드포인트 구현 + 라우트 등록 (app.ts에 import + use 추가!)
- [ ] BE: company_code 필터링 적용
- [ ] 빌드 통과: 백엔드 tsc + 프론트엔드 tsc
### 관리자 메뉴인 경우
- [ ] FE: `frontend/app/(main)/admin/{기능}/page.tsx` 작성
- [ ] FE: API 클라이언트 함수 작성 (lib/api/)
- [ ] DB: `menu_info` INSERT (menu_url = `/admin/{기능}`)
### 사용자 메뉴인 경우 (코드 작성 금지!)
- [ ] DB: `screen_definitions` INSERT (screen_code, table_name, company_code)
- [ ] DB: `screen_layouts_v2` INSERT (V2 레이아웃 JSON)
- [ ] DB: `menu_info` INSERT (menu_url = `/screen/{screen_code}`)
- [ ] BE: 필요한 경우 전용 API 작성 (범용 table-management API로 커버 안 되는 경우만)
- [ ] FE: .tsx 페이지 파일 만들지 않음 (이미 `/screens/[screenId]/page.tsx`가 렌더링)
## 6. 절대 하지 말 것
1. 페이지 파일만 만들고 메뉴 등록 안 하기 (미완성!)
2. fetch() 직접 사용 (lib/api/ 클라이언트 필수)
3. company_code 필터링 빠뜨리기
4. 하드코딩 색상/URL/사용자ID 사용
5. Card 안에 Card 중첩 (중첩 박스 금지)
6. 백엔드 재실행하기 (nodemon이 자동 재시작)
7. **사용자 메뉴를 React 하드코딩(.tsx)으로 만들기 (절대 금지!!!)**
- `frontend/app/(main)/production/`, `frontend/app/(main)/warehouse/` 등에 page.tsx 파일을 만들어서 직접 UI를 코딩하는 것은 절대 금지
- 사용자 메뉴는 반드시 `screen_definitions` + `screen_layouts_v2` + `menu_info` DB 등록 방식으로 구현
- 이미 `/screen/[screenCode]``/screens/[screenId]` 렌더링 시스템이 존재함
- 백엔드 API(controller/routes)와 프론트엔드 API 클라이언트(lib/api/)는 필요하면 코드로 작성 가능
- 하지만 프론트엔드 화면 UI 자체는 DB의 V2 레이아웃 JSON으로만 구성

View File

@ -1,50 +0,0 @@
---
name: pipeline-db
description: Agent Pipeline DB 전문가. PostgreSQL 스키마 설계, 마이그레이션 작성 및 실행. 모든 테이블에 company_code 필수.
model: inherit
---
# Role
You are a Database specialist for ERP-node project.
Stack: PostgreSQL + Raw Query (no ORM). Migrations in db/migrations/.
# CRITICAL PROJECT RULES
## 1. Multi-tenancy (ABSOLUTE MUST!)
- ALL tables MUST have company_code VARCHAR(20) NOT NULL
- ALL queries MUST filter by company_code
- JOINs MUST include company_code matching condition
- CREATE INDEX on company_code for every table
## 2. Migration Rules
- File naming: NNN_description.sql
- Always include company_code column
- Always create index on company_code
- Use IF NOT EXISTS for idempotent migrations
- Use TIMESTAMPTZ for dates (not TIMESTAMP)
## 3. MIGRATION EXECUTION (절대 규칙!)
마이그레이션 SQL 파일을 생성한 후, 반드시 직접 실행해서 테이블을 생성해라.
절대 사용자에게 "직접 실행해주세요"라고 떠넘기지 마라.
Docker 환경:
```bash
DOCKER_HOST=unix:///Users/gbpark/.orbstack/run/docker.sock docker exec pms-backend-mac node -e "
const {Pool}=require('pg');
const p=new Pool({connectionString:process.env.DATABASE_URL,ssl:false});
const fs=require('fs');
const sql=fs.readFileSync('/app/db/migrations/파일명.sql','utf8');
p.query(sql).then(()=>{console.log('OK');p.end()}).catch(e=>{console.error(e.message);p.end();process.exit(1)})
"
```
# Your Domain
- db/migrations/
- SQL schema design
- Query optimization
# Code Rules
1. PostgreSQL syntax only
2. Parameter binding ($1, $2)
3. Use COALESCE for NULL handling
4. Use TIMESTAMPTZ for dates

View File

@ -1,92 +0,0 @@
---
name: pipeline-frontend
description: Agent Pipeline 프론트엔드 전문가. Next.js 14 + React + TypeScript + shadcn/ui 기반 화면 구현. fetch 직접 사용 금지, lib/api/ 클라이언트 필수.
model: inherit
---
# Role
You are a Frontend specialist for ERP-node project.
Stack: Next.js 14 + React + TypeScript + Tailwind CSS + shadcn/ui.
# CRITICAL PROJECT RULES
## 1. API Client (ABSOLUTE RULE!)
- NEVER use fetch() directly!
- ALWAYS use lib/api/ clients (Axios-based)
- 환경별 URL 자동 처리: v1.vexplor.com → api.vexplor.com, localhost → localhost:8080
## 2. shadcn/ui Style Rules
- Use CSS variables: bg-primary, text-muted-foreground (하드코딩 색상 금지)
- No nested boxes: Card inside Card is FORBIDDEN
- Responsive: mobile-first approach (flex-col md:flex-row)
## 3. V2 Component Standard
V2 컴포넌트를 만들거나 수정할 때 반드시 이 규격을 따라야 한다.
### 폴더 구조 (필수)
```
frontend/lib/registry/components/v2-{name}/
├── index.ts # createComponentDefinition() 호출
├── types.ts # Config extends ComponentConfig
├── {Name}Component.tsx # React 함수 컴포넌트
├── {Name}Renderer.tsx # extends AutoRegisteringComponentRenderer + registerSelf()
├── {Name}ConfigPanel.tsx # ConfigPanelBuilder 사용
└── config.ts # 기본 설정값 상수
```
### ConfigPanel 규칙 (절대!)
- 반드시 ConfigPanelBuilder 또는 ConfigSection 사용
- 직접 JSX로 설정 UI 작성 금지
## 4. API Client 생성 패턴
```typescript
// frontend/lib/api/yourModule.ts
import apiClient from "@/lib/api/client";
export async function getYourData(id: number) {
const response = await apiClient.get(`/api/your-endpoint/${id}`);
return response.data;
}
```
# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다!!!
**이 프로젝트는 로우코드 스크린 디자이너 시스템을 사용한다.**
사용자 업무 화면(포장관리, 금형관리, BOM, 문서관리 등)은 절대 React 페이지(.tsx)로 직접 UI를 하드코딩하지 않는다!
## 금지 패턴 (절대 하지 말 것)
```
frontend/app/(main)/production/packaging/page.tsx ← 이런 파일 만들지 마라!
frontend/app/(main)/warehouse/something/page.tsx ← 이런 파일 만들지 마라!
```
## 올바른 패턴
사용자 화면은 DB에 등록만 하면 자동으로 렌더링된다:
1. `screen_definitions` 테이블에 화면 등록 (screen_code, table_name 등)
2. `screen_layouts_v2` 테이블에 V2 레이아웃 JSON 등록 (v2-split-panel-layout, v2-table-list 등)
3. `menu_info` 테이블에 메뉴 등록 (menu_url = `/screen/{screen_code}`)
이미 존재하는 렌더링 시스템:
- `/screen/[screenCode]/page.tsx` → screenCode를 screenId로 변환
- `/screens/[screenId]/page.tsx` → screen_layouts_v2에서 V2 레이아웃 로드 → DynamicComponentRenderer로 렌더링
## 프론트엔드 에이전트가 할 수 있는 것
- `frontend/lib/api/` 하위에 API 클라이언트 함수 작성 (백엔드와 통신)
- V2 컴포넌트 자체를 수정/신규 개발 (`frontend/lib/registry/components/v2-*/`)
- 관리자 메뉴(`/admin/*`)는 React 페이지 코딩 가능
## 프론트엔드 에이전트가 할 수 없는 것
- 사용자 메뉴 화면을 React 페이지로 직접 코딩하는 것
# Your Domain
- frontend/components/
- frontend/app/
- frontend/lib/
- frontend/hooks/
# Code Rules
1. TypeScript strict mode
2. React functional components with hooks
3. Prefer shadcn/ui components
4. Use cn() utility for conditional classes
5. Comments in Korean

View File

@ -1,64 +0,0 @@
---
name: pipeline-ui
description: Agent Pipeline UI/UX 디자인 전문가. 모던 엔터프라이즈 UI 구현. CSS 변수 필수, 하드코딩 색상 금지, 반응형 필수.
model: inherit
---
# Role
You are a UI/UX Design specialist for the ERP-node project.
Stack: Next.js 14 + React + TypeScript + Tailwind CSS + shadcn/ui + lucide-react icons.
# Design Philosophy
- Apple-level polish with enterprise functionality
- Consistent spacing, typography, color usage
- Subtle animations and micro-interactions
- Dark mode compatible using CSS variables
# CRITICAL STYLE RULES
## 1. Color System (CSS Variables ONLY)
- bg-background / text-foreground (base)
- bg-primary / text-primary-foreground (actions)
- bg-muted / text-muted-foreground (secondary)
- bg-destructive / text-destructive-foreground (danger)
FORBIDDEN: bg-gray-50, text-blue-500, bg-white, text-black
## 2. Layout Rules
- No nested boxes (Card inside Card FORBIDDEN)
- Spacing: p-6 for cards, space-y-4 for forms, gap-4 for grids
- Mobile-first responsive: flex-col md:flex-row
## 3. Typography
- Page title: text-3xl font-bold
- Section: text-xl font-semibold
- Body: text-sm
- Helper: text-xs text-muted-foreground
## 4. Components
- ALWAYS use shadcn/ui components
- Use cn() for conditional classes
- Use lucide-react for ALL icons
# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다!!!
사용자 업무 화면(포장관리, 금형관리, BOM 등)의 UI는 DB의 `screen_layouts_v2` 테이블에 V2 레이아웃 JSON으로 정의된다.
React 페이지(.tsx)로 직접 UI를 하드코딩하는 것은 절대 금지!
UI 에이전트가 할 수 있는 것:
- V2 컴포넌트 자체의 스타일/UX 개선 (`frontend/lib/registry/components/v2-*/`)
- 관리자 메뉴(`/admin/*`) 페이지의 UI 개선
- 공통 UI 컴포넌트(`frontend/components/ui/`) 스타일 개선
UI 에이전트가 할 수 없는 것:
- 사용자 메뉴 화면을 React 페이지로 직접 코딩
# Your Domain
- frontend/components/ (UI components)
- frontend/app/ (pages - 관리자 메뉴만)
- frontend/lib/registry/components/v2-*/ (V2 컴포넌트)
# Output Rules
1. TypeScript strict mode
2. "use client" for client components
3. Comments in Korean
4. MINIMAL targeted changes when modifying existing files

View File

@ -1,57 +0,0 @@
---
name: pipeline-verifier
description: Agent Pipeline 검증 전문가. 구현 완료 후 실제 동작 검증. 빈 껍데기 탐지, 패턴 준수 확인, 멀티테넌시 검증.
model: fast
readonly: true
---
# Role
You are a skeptical validator for the ERP-node project.
Your job is to verify that work claimed as complete actually works.
# Verification Checklist
## 1. Multi-tenancy (최우선)
- [ ] 모든 SQL에 company_code 필터 존재
- [ ] req.user!.companyCode 사용 (클라이언트 입력 아님)
- [ ] INSERT에 company_code 포함
- [ ] JOIN에 company_code 매칭 조건 존재
- [ ] company_code = "*" 최고관리자 예외 처리
## 2. Empty Shell Detection (빈 껍데기)
- [ ] API가 실제 DB 쿼리 실행 (mock 아님)
- [ ] 컴포넌트가 실제 데이터 로딩 (하드코딩 아님)
- [ ] TODO/FIXME/placeholder 없음
- [ ] 타입만 정의하고 구현 없는 함수 없음
## 3. Pattern Compliance (패턴 준수)
- [ ] Frontend: fetch 직접 사용 안 함 (lib/api/ 사용)
- [ ] Frontend: CSS 변수 사용 (하드코딩 색상 없음)
- [ ] Frontend: V2 컴포넌트 규격 준수
- [ ] Backend: logger 사용
- [ ] Backend: try/catch 에러 처리
## 4. Integration Check
- [ ] Route가 index.ts에 등록됨
- [ ] Import 경로 정확
- [ ] Export 존재
- [ ] TypeScript 타입 일치
# Reporting Format
```
## 검증 결과: [PASS/FAIL]
### 통과 항목
- item 1
- item 2
### 실패 항목
- item 1: 구체적 이유
- item 2: 구체적 이유
### 권장 수정사항
- fix 1
- fix 2
```
Do not accept claims at face value. Check the actual code.

View File

@ -0,0 +1,98 @@
# 화면 디자이너 E2E 테스트 접근 가이드
## 화면 디자이너 접근 방법 (Playwright)
화면 디자이너는 SPA 탭 기반 시스템이라 URL 직접 접근이 안 된다.
다음 3단계를 반드시 따라야 한다.
### 1단계: 로그인
```typescript
await page.goto('http://localhost:9771/login');
await page.waitForLoadState('networkidle');
await page.getByPlaceholder('사용자 ID를 입력하세요').fill('wace');
await page.getByPlaceholder('비밀번호를 입력하세요').fill('qlalfqjsgh11');
await page.getByRole('button', { name: '로그인' }).click();
await page.waitForTimeout(8000);
```
### 2단계: sessionStorage 탭 상태 주입 + openDesigner 쿼리
```typescript
await page.evaluate(() => {
sessionStorage.setItem('erp-tab-store', JSON.stringify({
state: {
tabs: [{
id: 'tab-screenmng',
title: '화면 관리',
path: '/admin/screenMng/screenMngList',
isActive: true,
isPinned: false
}],
activeTabId: 'tab-screenmng'
},
version: 0
}));
});
// openDesigner 쿼리 파라미터로 화면 디자이너 자동 열기
await page.goto('http://localhost:9771/admin/screenMng/screenMngList?openDesigner=' + screenId);
await page.waitForTimeout(10000);
```
### 3단계: 컴포넌트 클릭 + 설정 패널 확인
```typescript
// 패널 버튼 클릭 (설정 패널 열기)
const panelBtn = page.locator('button:has-text("패널")');
if (await panelBtn.count() > 0) {
await panelBtn.first().click();
await page.waitForTimeout(2000);
}
// 편집 탭 확인
const editTab = page.locator('button:has-text("편집")');
// editTab.count() > 0 이면 설정 패널 열림 확인
```
## 화면 ID 찾기 (API)
특정 컴포넌트를 포함한 화면을 API로 검색:
```typescript
const screenId = await page.evaluate(async () => {
const token = localStorage.getItem('authToken') || '';
const h = { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
const resp = await fetch('http://localhost:8080/api/screen-management/screens?page=1&size=50', { headers: h });
const data = await resp.json();
const items = data.data || [];
for (const s of items) {
try {
const lr = await fetch('http://localhost:8080/api/screen-management/screens/' + s.screenId + '/layout-v2', { headers: h });
const ld = await lr.json();
const raw = JSON.stringify(ld);
// 원하는 컴포넌트 타입 검색
if (raw.includes('v2-select')) return s.screenId;
} catch {}
}
return items[0]?.screenId || null;
});
```
## 검증 포인트
| 확인 항목 | Locator | 기대값 |
|----------|---------|--------|
| 디자이너 열림 | `button:has-text("패널")` | count > 0 |
| 편집 탭 | `button:has-text("편집")` | count > 0 |
| 카드 선택 | `text=이 필드는 어떤 데이터를 선택하나요?` | visible |
| 고급 설정 | `text=고급 설정` | visible |
| JS 에러 없음 | `page.on('pageerror')` | 0건 |
## 테스트 계정
- ID: `wace`
- PW: `qlalfqjsgh11`
- 권한: SUPER_ADMIN (최고 관리자)

View File

@ -30,26 +30,68 @@ export async function getItemsWithRouting(req: AuthenticatedRequest, res: Respon
routingTable = "item_routing_version",
routingFkColumn = "item_code",
search = "",
extraColumns = "",
filterConditions = "",
} = req.query as Record<string, string>;
const searchCondition = search
? `AND (i.${nameColumn} ILIKE $2 OR i.${codeColumn} ILIKE $2)`
: "";
const params: any[] = [companyCode];
if (search) params.push(`%${search}%`);
let paramIndex = 2;
// 검색 조건
let searchCondition = "";
if (search) {
searchCondition = `AND (i.${nameColumn} ILIKE $${paramIndex} OR i.${codeColumn} ILIKE $${paramIndex})`;
params.push(`%${search}%`);
paramIndex++;
}
// 추가 컬럼 SELECT
const extraColumnNames: string[] = extraColumns
? extraColumns.split(",").map((c: string) => c.trim()).filter(Boolean)
: [];
const extraSelect = extraColumnNames.map((col) => `i.${col}`).join(", ");
const extraGroupBy = extraColumnNames.map((col) => `i.${col}`).join(", ");
// 사전 필터 조건
let filterWhere = "";
if (filterConditions) {
try {
const filters = JSON.parse(filterConditions) as Array<{
column: string;
operator: string;
value: string;
}>;
for (const f of filters) {
if (!f.column || !f.value) continue;
if (f.operator === "equals") {
filterWhere += ` AND i.${f.column} = $${paramIndex}`;
params.push(f.value);
} else if (f.operator === "contains") {
filterWhere += ` AND i.${f.column} ILIKE $${paramIndex}`;
params.push(`%${f.value}%`);
} else if (f.operator === "not_equals") {
filterWhere += ` AND i.${f.column} != $${paramIndex}`;
params.push(f.value);
}
paramIndex++;
}
} catch { /* 파싱 실패 시 무시 */ }
}
const query = `
SELECT
i.id,
i.${nameColumn} AS item_name,
i.${codeColumn} AS item_code,
i.${codeColumn} AS item_code
${extraSelect ? ", " + extraSelect : ""},
COUNT(rv.id) AS routing_count
FROM ${tableName} i
LEFT JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn}
AND rv.company_code = i.company_code
WHERE i.company_code = $1
${searchCondition}
GROUP BY i.id, i.${nameColumn}, i.${codeColumn}, i.created_date
${filterWhere}
GROUP BY i.id, i.${nameColumn}, i.${codeColumn}${extraGroupBy ? ", " + extraGroupBy : ""}, i.created_date
ORDER BY i.created_date DESC NULLS LAST
`;
@ -711,3 +753,184 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) {
client.release();
}
}
// ============================================================
// 등록 품목 관리 (item_routing_registered)
// ============================================================
/**
*
*/
export async function getRegisteredItems(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { screenCode } = req.params;
const {
tableName = "item_info",
nameColumn = "item_name",
codeColumn = "item_number",
routingTable = "item_routing_version",
routingFkColumn = "item_code",
search = "",
extraColumns = "",
} = req.query as Record<string, string>;
const params: any[] = [companyCode, screenCode];
let paramIndex = 3;
let searchCondition = "";
if (search) {
searchCondition = `AND (i.${nameColumn} ILIKE $${paramIndex} OR i.${codeColumn} ILIKE $${paramIndex})`;
params.push(`%${search}%`);
paramIndex++;
}
const extraColumnNames: string[] = extraColumns
? extraColumns.split(",").map((c: string) => c.trim()).filter(Boolean)
: [];
const extraSelect = extraColumnNames.map((col) => `i.${col}`).join(", ");
const extraGroupBy = extraColumnNames.map((col) => `i.${col}`).join(", ");
const query = `
SELECT
irr.id AS registered_id,
irr.sort_order,
i.id,
i.${nameColumn} AS item_name,
i.${codeColumn} AS item_code
${extraSelect ? ", " + extraSelect : ""},
COUNT(rv.id) AS routing_count
FROM item_routing_registered irr
JOIN ${tableName} i ON irr.item_id = i.id
AND i.company_code = irr.company_code
LEFT JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn}
AND rv.company_code = i.company_code
WHERE irr.company_code = $1
AND irr.screen_code = $2
${searchCondition}
GROUP BY irr.id, irr.sort_order, i.id, i.${nameColumn}, i.${codeColumn}${extraGroupBy ? ", " + extraGroupBy : ""}
ORDER BY CAST(irr.sort_order AS int) ASC, irr.created_date ASC
`;
const result = await getPool().query(query, params);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("등록 품목 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
/**
* ( )
*/
export async function registerItem(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { screenCode, itemId, itemCode } = req.body;
if (!screenCode || !itemId) {
return res.status(400).json({ success: false, message: "screenCode, itemId 필수" });
}
const query = `
INSERT INTO item_routing_registered (screen_code, item_id, item_code, company_code, writer)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (screen_code, item_id, company_code) DO NOTHING
RETURNING *
`;
const result = await getPool().query(query, [
screenCode, itemId, itemCode || null, companyCode, req.user?.userId || null,
]);
if (result.rowCount === 0) {
return res.json({ success: true, message: "이미 등록된 품목입니다", data: null });
}
logger.info("품목 등록", { companyCode, screenCode, itemId });
return res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("품목 등록 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
/**
*
*/
export async function registerItemsBatch(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { screenCode, items } = req.body;
if (!screenCode || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ success: false, message: "screenCode, items[] 필수" });
}
const client = await getPool().connect();
try {
await client.query("BEGIN");
const inserted: any[] = [];
for (const item of items) {
const result = await client.query(
`INSERT INTO item_routing_registered (screen_code, item_id, item_code, company_code, writer)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (screen_code, item_id, company_code) DO NOTHING
RETURNING *`,
[screenCode, item.itemId, item.itemCode || null, companyCode, req.user?.userId || null]
);
if (result.rows[0]) inserted.push(result.rows[0]);
}
await client.query("COMMIT");
logger.info("품목 일괄 등록", { companyCode, screenCode, count: inserted.length });
return res.json({ success: true, data: inserted });
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
} catch (error: any) {
logger.error("품목 일괄 등록 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
/**
*
*/
export async function unregisterItem(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { id } = req.params;
const result = await getPool().query(
`DELETE FROM item_routing_registered WHERE id = $1 AND company_code = $2 RETURNING id`,
[id, companyCode]
);
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다" });
}
logger.info("등록 품목 제거", { companyCode, id });
return res.json({ success: true });
} catch (error: any) {
logger.error("등록 품목 제거 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}

View File

@ -33,4 +33,10 @@ router.delete("/work-item-details/:id", ctrl.deleteWorkItemDetail);
// 전체 저장 (일괄)
router.put("/save-all", ctrl.saveAll);
// 등록 품목 관리 (화면별 품목 목록)
router.get("/registered-items/:screenCode", ctrl.getRegisteredItems);
router.post("/registered-items", ctrl.registerItem);
router.post("/registered-items/batch", ctrl.registerItemsBatch);
router.delete("/registered-items/:id", ctrl.unregisterItem);
export default router;

View File

@ -0,0 +1,620 @@
# POP 작업진행 관리 설계서
> 작성일: 2026-03-13
> 목적: POP 시스템에서 작업지시 기반으로 라우팅/작업기준정보를 조회하고, 공정별 작업 진행 상태를 관리하는 구조 설계
---
## 1. 핵심 설계 원칙
**작업지시에 라우팅ID, 작업기준정보ID 등을 별도 컬럼으로 넣지 않는다.**
- 작업지시(`work_instruction`)에는 `item_id`(품목 ID)만 있으면 충분
- 품목 → 라우팅 → 작업기준정보는 마스터 데이터 체인으로 조회
- 작업 진행 상태만 별도 테이블에서 관리
---
## 2. 기존 테이블 구조 (마스터 데이터)
### 2-1. ER 다이어그램
> GitHub / VSCode Mermaid 플러그인에서 렌더링됩니다.
```mermaid
erDiagram
%% ========== 마스터 데이터 (변경 없음) ==========
item_info {
varchar id PK "UUID"
varchar item_number "품번"
varchar item_name "품명"
varchar company_code "회사코드"
}
item_routing_version {
varchar id PK "UUID"
varchar item_code "품번 (= item_info.item_number)"
varchar version_name "버전명"
boolean is_default "기본버전 여부"
varchar company_code "회사코드"
}
item_routing_detail {
varchar id PK "UUID"
varchar routing_version_id FK "→ item_routing_version.id"
varchar seq_no "공정순서 10,20,30..."
varchar process_code FK "→ process_mng.process_code"
varchar is_required "필수/선택"
varchar is_fixed_order "고정/선택"
varchar standard_time "표준시간(분)"
varchar company_code "회사코드"
}
process_mng {
varchar id PK "UUID"
varchar process_code "공정코드"
varchar process_name "공정명"
varchar process_type "공정유형"
varchar company_code "회사코드"
}
process_work_item {
varchar id PK "UUID"
varchar routing_detail_id FK "→ item_routing_detail.id"
varchar work_phase "PRE / IN / POST"
varchar title "작업항목명"
varchar is_required "Y/N"
int sort_order "정렬순서"
varchar company_code "회사코드"
}
process_work_item_detail {
varchar id PK "UUID"
varchar work_item_id FK "→ process_work_item.id"
varchar detail_type "check/inspect/input/procedure/info"
varchar content "내용"
varchar input_type "입력타입"
varchar inspection_code "검사코드"
varchar unit "단위"
varchar lower_limit "하한값"
varchar upper_limit "상한값"
varchar company_code "회사코드"
}
%% ========== 트랜잭션 데이터 ==========
work_instruction {
varchar id PK "UUID"
varchar work_instruction_no "작업지시번호"
varchar item_id FK "→ item_info.id ★핵심★"
varchar status "waiting/in_progress/completed/cancelled"
varchar qty "지시수량"
varchar completed_qty "완성수량"
varchar worker "작업자"
varchar company_code "회사코드"
}
work_order_process {
varchar id PK "UUID"
varchar wo_id FK "→ work_instruction.id"
varchar routing_detail_id FK "→ item_routing_detail.id ★추가★"
varchar seq_no "공정순서"
varchar process_code "공정코드"
varchar process_name "공정명"
varchar status "waiting/in_progress/completed/skipped"
varchar plan_qty "계획수량"
varchar good_qty "양품수량"
varchar defect_qty "불량수량"
timestamp started_at "시작시간"
timestamp completed_at "완료시간"
varchar company_code "회사코드"
}
work_order_work_item {
varchar id PK "UUID ★신규★"
varchar company_code "회사코드"
varchar work_order_process_id FK "→ work_order_process.id"
varchar work_item_id FK "→ process_work_item.id"
varchar work_phase "PRE/IN/POST"
varchar status "pending/completed/skipped/failed"
varchar completed_by "완료자"
timestamp completed_at "완료시간"
}
work_order_work_item_result {
varchar id PK "UUID ★신규★"
varchar company_code "회사코드"
varchar work_order_work_item_id FK "→ work_order_work_item.id"
varchar work_item_detail_id FK "→ process_work_item_detail.id"
varchar detail_type "check/inspect/input/procedure"
varchar result_value "결과값"
varchar is_passed "Y/N/null"
varchar recorded_by "기록자"
timestamp recorded_at "기록시간"
}
%% ========== 관계 ==========
%% 마스터 체인: 품목 → 라우팅 → 작업기준정보
item_info ||--o{ item_routing_version : "item_number = item_code"
item_routing_version ||--o{ item_routing_detail : "id = routing_version_id"
item_routing_detail }o--|| process_mng : "process_code"
item_routing_detail ||--o{ process_work_item : "id = routing_detail_id"
process_work_item ||--o{ process_work_item_detail : "id = work_item_id"
%% 트랜잭션: 작업지시 → 공정진행 → 작업기준정보 진행
work_instruction }o--|| item_info : "item_id = id"
work_instruction ||--o{ work_order_process : "id = wo_id"
work_order_process }o--|| item_routing_detail : "routing_detail_id = id"
work_order_process ||--o{ work_order_work_item : "id = work_order_process_id"
work_order_work_item }o--|| process_work_item : "work_item_id = id"
work_order_work_item ||--o{ work_order_work_item_result : "id = work_order_work_item_id"
work_order_work_item_result }o--|| process_work_item_detail : "work_item_detail_id = id"
```
### 2-1-1. 관계 요약 (텍스트)
```
[마스터 데이터 체인 - 조회용, 변경 없음]
item_info ─── 1:N ───→ item_routing_version ─── 1:N ───→ item_routing_detail
(품목) item_number (라우팅 버전) routing_ (공정별 상세)
= item_code version_id
process_mng ◄───┘ process_code (공정 마스터)
├── 1:N ───→ process_work_item ─── 1:N ───→ process_work_item_detail
│ (작업기준정보) (작업기준정보 상세)
│ routing_detail_id work_item_id
[트랜잭션 데이터 - 상태 관리] │
work_instruction ─── 1:N ───→ work_order_process ─┘ routing_detail_id (★추가★)
(작업지시) wo_id (공정별 진행)
item_id → item_info │
├── 1:N ───→ work_order_work_item ─── 1:N ───→ work_order_work_item_result
│ (작업기준정보 진행) (상세 결과값)
│ work_order_process_id work_order_work_item_id
│ work_item_id → process_work_item work_item_detail_id → process_work_item_detail
│ ★신규 테이블★ ★신규 테이블★
```
### 2-2. 마스터 테이블 상세
#### item_info (품목 마스터)
| 컬럼 | 설명 | 비고 |
|------|------|------|
| id | PK (UUID) | |
| item_number | 품번 | item_routing_version.item_code와 매칭 |
| item_name | 품명 | |
| company_code | 회사코드 | 멀티테넌시 |
#### item_routing_version (라우팅 버전)
| 컬럼 | 설명 | 비고 |
|------|------|------|
| id | PK (UUID) | |
| item_code | 품번 | item_info.item_number와 매칭 |
| version_name | 버전명 | 예: "기본 라우팅", "버전2" |
| is_default | 기본 버전 여부 | true/false, 기본 버전을 사용 |
| company_code | 회사코드 | |
#### item_routing_detail (라우팅 상세 - 공정별)
| 컬럼 | 설명 | 비고 |
|------|------|------|
| id | PK (UUID) | |
| routing_version_id | FK → item_routing_version.id | |
| seq_no | 공정 순서 | 10, 20, 30... |
| process_code | 공정코드 | FK → process_mng.process_code |
| is_required | 필수/선택 | "필수" / "선택" |
| is_fixed_order | 순서고정 여부 | "고정" / "선택" |
| work_type | 작업유형 | |
| standard_time | 표준시간(분) | |
| outsource_supplier | 외주업체 | |
| company_code | 회사코드 | |
#### process_work_item (작업기준정보)
| 컬럼 | 설명 | 비고 |
|------|------|------|
| id | PK (UUID) | |
| routing_detail_id | FK → item_routing_detail.id | |
| work_phase | 작업단계 | PRE(작업전) / IN(작업중) / POST(작업후) |
| title | 작업항목명 | 예: "장비 체크", "소재 준비" |
| is_required | 필수여부 | Y/N |
| sort_order | 정렬순서 | |
| description | 설명 | |
| company_code | 회사코드 | |
#### process_work_item_detail (작업기준정보 상세)
| 컬럼 | 설명 | 비고 |
|------|------|------|
| id | PK (UUID) | |
| work_item_id | FK → process_work_item.id | |
| detail_type | 상세유형 | check(체크) / inspect(검사) / input(입력) / procedure(절차) / info(정보) |
| content | 내용 | 예: "소음검사", "치수검사" |
| input_type | 입력타입 | select, text 등 |
| inspection_code | 검사코드 | |
| inspection_method | 검사방법 | |
| unit | 단위 | |
| lower_limit | 하한값 | |
| upper_limit | 상한값 | |
| is_required | 필수여부 | Y/N |
| sort_order | 정렬순서 | |
| company_code | 회사코드 | |
---
## 3. 작업 진행 테이블 (트랜잭션 데이터)
### 3-1. work_instruction (작업지시) - 기존 테이블
| 컬럼 | 설명 | 비고 |
|------|------|------|
| id | PK (UUID) | |
| work_instruction_no | 작업지시번호 | 예: WO-2026-001 |
| **item_id** | **FK → item_info.id** | **이것만으로 라우팅/작업기준정보 전부 조회 가능** |
| status | 작업지시 상태 | waiting / in_progress / completed / cancelled |
| qty | 지시수량 | |
| completed_qty | 완성수량 | |
| work_team | 작업팀 | |
| worker | 작업자 | |
| equipment_id | 설비 | |
| start_date | 시작일 | |
| end_date | 종료일 | |
| remark | 비고 | |
| company_code | 회사코드 | |
> **routing 컬럼**: 현재 존재하지만 사용하지 않음 (null). 라우팅 버전을 지정하고 싶으면 이 컬럼에 `item_routing_version.id`를 넣어 특정 버전을 지정할 수 있음. 없으면 `is_default=true` 버전 자동 사용.
### 3-2. work_order_process (공정별 진행) - 기존 테이블, 변경 필요
작업지시가 생성될 때, 해당 품목의 라우팅 공정을 복사해서 이 테이블에 INSERT.
| 컬럼 | 설명 | 비고 |
|------|------|------|
| id | PK (UUID) | |
| wo_id | FK → work_instruction.id | 작업지시 참조 |
| **routing_detail_id** | **FK → item_routing_detail.id** | **추가 필요 - 라우팅 상세 참조** |
| seq_no | 공정 순서 | 라우팅에서 복사 |
| process_code | 공정코드 | 라우팅에서 복사 |
| process_name | 공정명 | 라우팅에서 복사 (비정규화, 조회 편의) |
| is_required | 필수여부 | 라우팅에서 복사 |
| is_fixed_order | 순서고정 | 라우팅에서 복사 |
| standard_time | 표준시간 | 라우팅에서 복사 |
| **status** | **공정 상태** | **waiting / in_progress / completed / skipped** |
| plan_qty | 계획수량 | |
| input_qty | 투입수량 | |
| good_qty | 양품수량 | |
| defect_qty | 불량수량 | |
| equipment_code | 사용설비 | |
| accepted_by | 접수자 | |
| accepted_at | 접수시간 | |
| started_at | 시작시간 | |
| completed_at | 완료시간 | |
| remark | 비고 | |
| company_code | 회사코드 | |
### 3-3. work_order_work_item (작업기준정보별 진행) - 신규 테이블
POP에서 작업자가 각 작업기준정보 항목을 체크/입력할 때 사용.
| 컬럼 | 설명 | 비고 |
|------|------|------|
| id | PK (UUID) | gen_random_uuid() |
| company_code | 회사코드 | 멀티테넌시 |
| work_order_process_id | FK → work_order_process.id | 어떤 작업지시의 어떤 공정인지 |
| work_item_id | FK → process_work_item.id | 어떤 작업기준정보인지 |
| work_phase | 작업단계 | PRE / IN / POST (마스터에서 복사) |
| status | 완료상태 | pending / completed / skipped / failed |
| completed_by | 완료자 | 작업자 ID |
| completed_at | 완료시간 | |
| created_date | 생성일 | |
| updated_date | 수정일 | |
| writer | 작성자 | |
### 3-4. work_order_work_item_result (작업기준정보 상세 결과) - 신규 테이블
작업기준정보의 상세 항목(체크, 검사, 입력 등)에 대한 실제 결과값 저장.
| 컬럼 | 설명 | 비고 |
|------|------|------|
| id | PK (UUID) | gen_random_uuid() |
| company_code | 회사코드 | 멀티테넌시 |
| work_order_work_item_id | FK → work_order_work_item.id | |
| work_item_detail_id | FK → process_work_item_detail.id | 어떤 상세항목인지 |
| detail_type | 상세유형 | check / inspect / input / procedure (마스터에서 복사) |
| result_value | 결과값 | 체크: "Y"/"N", 검사: 측정값, 입력: 입력값 |
| is_passed | 합격여부 | Y / N / null(해당없음) |
| remark | 비고 | 불합격 사유 등 |
| recorded_by | 기록자 | |
| recorded_at | 기록시간 | |
| created_date | 생성일 | |
| updated_date | 수정일 | |
| writer | 작성자 | |
---
## 4. POP 데이터 플로우
### 4-1. 작업지시 등록 시 (ERP 측)
```
[작업지시 생성]
├── 1. work_instruction INSERT (item_id, qty, status='waiting' 등)
├── 2. item_id → item_info.item_number 조회
├── 3. item_number → item_routing_version 조회 (is_default=true 또는 지정 버전)
├── 4. routing_version_id → item_routing_detail 조회 (공정 목록)
└── 5. 각 공정별로 work_order_process INSERT
├── wo_id = work_instruction.id
├── routing_detail_id = item_routing_detail.id ← 핵심!
├── seq_no, process_code, process_name 복사
├── status = 'waiting'
└── plan_qty = work_instruction.qty
```
### 4-2. POP 작업 조회 시
```
[POP 화면: 작업지시 선택]
├── 1. work_instruction 목록 조회 (status = 'waiting' or 'in_progress')
├── 2. 선택한 작업지시의 공정 목록 조회
│ SELECT wop.*, pm.process_name
│ FROM work_order_process wop
│ LEFT JOIN process_mng pm ON wop.process_code = pm.process_code
│ WHERE wop.wo_id = {작업지시ID}
│ ORDER BY CAST(wop.seq_no AS int)
└── 3. 선택한 공정의 작업기준정보 조회 (마스터 데이터 참조)
SELECT pwi.*, pwid.*
FROM process_work_item pwi
LEFT JOIN process_work_item_detail pwid ON pwi.id = pwid.work_item_id
WHERE pwi.routing_detail_id = {work_order_process.routing_detail_id}
ORDER BY pwi.work_phase, pwi.sort_order, pwid.sort_order
```
### 4-3. POP 작업 실행 시
```
[작업자가 공정 시작]
├── 1. work_order_process UPDATE
│ SET status = 'in_progress', started_at = NOW(), accepted_by = {작업자}
├── 2. work_instruction UPDATE (첫 공정 시작 시)
│ SET status = 'in_progress'
├── 3. 작업기준정보 항목별 체크/입력 시
│ ├── work_order_work_item UPSERT (항목별 상태)
│ └── work_order_work_item_result UPSERT (상세 결과값)
└── 4. 공정 완료 시
├── work_order_process UPDATE
│ SET status = 'completed', completed_at = NOW(),
│ good_qty = {양품}, defect_qty = {불량}
└── (모든 공정 완료 시)
work_instruction UPDATE
SET status = 'completed', completed_qty = {최종양품}
```
---
## 5. 핵심 조회 쿼리
### 5-1. 작업지시 → 전체 공정 + 작업기준정보 한방 조회
```sql
-- 작업지시의 공정별 진행 현황 + 작업기준정보
SELECT
wi.work_instruction_no,
wi.qty,
wi.status as wi_status,
ii.item_number,
ii.item_name,
wop.id as process_id,
wop.seq_no,
wop.process_code,
wop.process_name,
wop.status as process_status,
wop.plan_qty,
wop.good_qty,
wop.defect_qty,
wop.started_at,
wop.completed_at,
wop.routing_detail_id,
-- 작업기준정보는 routing_detail_id로 마스터 조회
pwi.id as work_item_id,
pwi.work_phase,
pwi.title as work_item_title,
pwi.is_required as work_item_required
FROM work_instruction wi
JOIN item_info ii ON wi.item_id = ii.id
JOIN work_order_process wop ON wi.id = wop.wo_id
LEFT JOIN process_work_item pwi ON wop.routing_detail_id = pwi.routing_detail_id
WHERE wi.id = $1
AND wi.company_code = $2
ORDER BY CAST(wop.seq_no AS int), pwi.work_phase, pwi.sort_order;
```
### 5-2. 특정 공정의 작업기준정보 + 진행 상태 조회
```sql
-- POP에서 특정 공정 선택 시: 마스터 + 진행 상태 조인
SELECT
pwi.id as work_item_id,
pwi.work_phase,
pwi.title,
pwi.is_required,
pwid.id as detail_id,
pwid.detail_type,
pwid.content,
pwid.input_type,
pwid.inspection_code,
pwid.inspection_method,
pwid.unit,
pwid.lower_limit,
pwid.upper_limit,
-- 진행 상태
wowi.status as item_status,
wowi.completed_by,
wowi.completed_at,
-- 결과값
wowir.result_value,
wowir.is_passed,
wowir.remark as result_remark
FROM process_work_item pwi
LEFT JOIN process_work_item_detail pwid
ON pwi.id = pwid.work_item_id
LEFT JOIN work_order_work_item wowi
ON wowi.work_item_id = pwi.id
AND wowi.work_order_process_id = $1 -- work_order_process.id
LEFT JOIN work_order_work_item_result wowir
ON wowir.work_order_work_item_id = wowi.id
AND wowir.work_item_detail_id = pwid.id
WHERE pwi.routing_detail_id = $2 -- work_order_process.routing_detail_id
ORDER BY
CASE pwi.work_phase WHEN 'PRE' THEN 1 WHEN 'IN' THEN 2 WHEN 'POST' THEN 3 END,
pwi.sort_order,
pwid.sort_order;
```
---
## 6. 변경사항 요약
### 6-1. 기존 테이블 변경
| 테이블 | 변경내용 |
|--------|---------|
| work_order_process | `routing_detail_id VARCHAR(500)` 컬럼 추가 |
### 6-2. 신규 테이블
| 테이블 | 용도 |
|--------|------|
| work_order_work_item | 작업지시 공정별 작업기준정보 진행 상태 |
| work_order_work_item_result | 작업기준정보 상세 항목의 실제 결과값 |
### 6-3. 건드리지 않는 것
| 테이블 | 이유 |
|--------|------|
| work_instruction | item_id만 있으면 충분. 라우팅/작업기준정보 ID 추가 불필요 |
| item_routing_version | 마스터 데이터, 변경 없음 |
| item_routing_detail | 마스터 데이터, 변경 없음 |
| process_work_item | 마스터 데이터, 변경 없음 |
| process_work_item_detail | 마스터 데이터, 변경 없음 |
---
## 7. DDL (마이그레이션 SQL)
```sql
-- 1. work_order_process에 routing_detail_id 추가
ALTER TABLE work_order_process
ADD COLUMN IF NOT EXISTS routing_detail_id VARCHAR(500);
CREATE INDEX IF NOT EXISTS idx_wop_routing_detail_id
ON work_order_process(routing_detail_id);
-- 2. 작업기준정보별 진행 상태 테이블
CREATE TABLE IF NOT EXISTS work_order_work_item (
id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
company_code VARCHAR(500) NOT NULL,
work_order_process_id VARCHAR(500) NOT NULL,
work_item_id VARCHAR(500) NOT NULL,
work_phase VARCHAR(500),
status VARCHAR(500) DEFAULT 'pending',
completed_by VARCHAR(500),
completed_at TIMESTAMP,
created_date TIMESTAMP DEFAULT NOW(),
updated_date TIMESTAMP DEFAULT NOW(),
writer VARCHAR(500)
);
CREATE INDEX idx_wowi_process_id ON work_order_work_item(work_order_process_id);
CREATE INDEX idx_wowi_work_item_id ON work_order_work_item(work_item_id);
CREATE INDEX idx_wowi_company_code ON work_order_work_item(company_code);
-- 3. 작업기준정보 상세 결과 테이블
CREATE TABLE IF NOT EXISTS work_order_work_item_result (
id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
company_code VARCHAR(500) NOT NULL,
work_order_work_item_id VARCHAR(500) NOT NULL,
work_item_detail_id VARCHAR(500) NOT NULL,
detail_type VARCHAR(500),
result_value VARCHAR(500),
is_passed VARCHAR(500),
remark TEXT,
recorded_by VARCHAR(500),
recorded_at TIMESTAMP DEFAULT NOW(),
created_date TIMESTAMP DEFAULT NOW(),
updated_date TIMESTAMP DEFAULT NOW(),
writer VARCHAR(500)
);
CREATE INDEX idx_wowir_work_order_work_item_id ON work_order_work_item_result(work_order_work_item_id);
CREATE INDEX idx_wowir_detail_id ON work_order_work_item_result(work_item_detail_id);
CREATE INDEX idx_wowir_company_code ON work_order_work_item_result(company_code);
```
---
## 8. 상태값 정의
### work_instruction.status (작업지시 상태)
| 값 | 의미 |
|----|------|
| waiting | 대기 |
| in_progress | 진행중 |
| completed | 완료 |
| cancelled | 취소 |
### work_order_process.status (공정 상태)
| 값 | 의미 |
|----|------|
| waiting | 대기 (아직 시작 안 함) |
| in_progress | 진행중 (작업자가 시작) |
| completed | 완료 |
| skipped | 건너뜀 (선택 공정인 경우) |
### work_order_work_item.status (작업기준정보 항목 상태)
| 값 | 의미 |
|----|------|
| pending | 미완료 |
| completed | 완료 |
| skipped | 건너뜀 |
| failed | 실패 (검사 불합격 등) |
### work_order_work_item_result.is_passed (검사 합격여부)
| 값 | 의미 |
|----|------|
| Y | 합격 |
| N | 불합격 |
| null | 해당없음 (체크/입력 항목) |
---
## 9. 설계 의도 요약
1. **마스터와 트랜잭션 분리**: 라우팅/작업기준정보는 마스터(템플릿), 실제 진행은 트랜잭션 테이블에서 관리
2. **조회 경로**: `work_instruction.item_id``item_info.item_number``item_routing_version``item_routing_detail``process_work_item``process_work_item_detail`
3. **진행 경로**: `work_order_process.routing_detail_id`로 마스터 작업기준정보를 참조하되, 실제 진행/결과는 `work_order_work_item` + `work_order_work_item_result`에 저장
4. **중복 저장 최소화**: 작업지시에 공정/작업기준정보 ID를 넣지 않음. 품목만 있으면 전부 파생 조회 가능
5. **work_order_process**: 작업지시 생성 시 라우팅 공정을 복사하는 이유는 진행 중 수량/상태/시간 등 트랜잭션 데이터를 기록해야 하기 때문 (마스터가 변경되어도 이미 발행된 작업지시의 공정은 유지)
---
## 10. 주의사항
- `work_order_process`에 공정 정보를 복사(스냅샷)하는 이유: 마스터 라우팅이 나중에 변경되어도 이미 진행 중인 작업지시의 공정 구성은 영향받지 않아야 함
- `routing_detail_id`는 "이 공정이 어떤 마스터 라우팅에서 왔는지" 추적용. 작업기준정보 조회 키로 사용
- POP에서 작업기준정보를 표시할 때는 항상 마스터(`process_work_item`)를 조회하고, 결과만 트랜잭션 테이블에 저장
- 모든 테이블에 `company_code` 필수 (멀티테넌시)

View File

@ -774,7 +774,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
/>
</div>
{/* 선택된 컴포넌트 정보 표시 - 🔧 오른쪽으로 이동 (라벨과 겹치지 않도록) */}
{/* 선택된 컴포넌트 정보 표시 */}
{isSelected && (
<div className="bg-primary text-primary-foreground absolute -top-7 right-0 rounded-md px-2.5 py-1 text-xs font-medium shadow-sm">
{type === "widget" && (
@ -785,7 +785,18 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
)}
{type !== "widget" && (
<div className="flex items-center gap-1.5">
<span>{component.componentConfig?.type || type}</span>
<span>{(() => {
const ft = (component as any).componentConfig?.fieldType;
if (ft) {
const labels: Record<string, string> = {
text: "텍스트", number: "숫자", textarea: "여러줄",
select: "셀렉트", category: "카테고리", entity: "엔티티",
numbering: "채번",
};
return labels[ft] || ft;
}
return (component as any).componentConfig?.type || componentType || type;
})()}</span>
</div>
)}
</div>

View File

@ -204,6 +204,66 @@ function FullWidthOverlayRow({
);
}
function ProportionalRenderer({
components,
canvasWidth,
canvasHeight,
renderComponent,
}: ResponsiveGridRendererProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [containerW, setContainerW] = useState(0);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width;
if (w && w > 0) setContainerW(w);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const topLevel = components.filter((c) => !c.parentId);
const ratio = containerW > 0 ? containerW / canvasWidth : 1;
const maxBottom = topLevel.reduce((max, c) => {
const bottom = c.position.y + (c.size?.height || 40);
return Math.max(max, bottom);
}, 0);
return (
<div
ref={containerRef}
data-screen-runtime="true"
className="bg-background relative w-full overflow-x-hidden"
style={{ minHeight: containerW > 0 ? `${maxBottom * ratio}px` : "200px" }}
>
{containerW > 0 &&
topLevel.map((component) => {
const typeId = getComponentTypeId(component);
return (
<div
key={component.id}
data-component-id={component.id}
data-component-type={typeId}
style={{
position: "absolute",
left: `${(component.position.x / canvasWidth) * 100}%`,
top: `${component.position.y * ratio}px`,
width: `${((component.size?.width || 100) / canvasWidth) * 100}%`,
height: `${(component.size?.height || 40) * ratio}px`,
zIndex: component.position.z || 1,
}}
>
{renderComponent(component)}
</div>
);
})}
</div>
);
}
export function ResponsiveGridRenderer({
components,
canvasWidth,
@ -213,6 +273,18 @@ export function ResponsiveGridRenderer({
const { isMobile } = useResponsive();
const topLevel = components.filter((c) => !c.parentId);
const hasFullWidthComponent = topLevel.some((c) => isFullWidthComponent(c));
if (!isMobile && !hasFullWidthComponent) {
return (
<ProportionalRenderer
components={components}
canvasWidth={canvasWidth}
canvasHeight={canvasHeight}
renderComponent={renderComponent}
/>
);
}
const rows = groupComponentsIntoRows(topLevel);
const processedRows: ProcessedRow[] = [];
@ -298,7 +370,46 @@ export function ResponsiveGridRenderer({
const { normalComps } = processedRow;
const allButtons = normalComps.every((c) => isButtonComponent(c));
const gap = isMobile ? 8 : allButtons ? 8 : getRowGap(normalComps, canvasWidth);
// 데스크톱에서 버튼만 있는 행: 디자이너의 x, width를 비율로 적용
if (allButtons && normalComps.length > 0 && !isMobile) {
const rowHeight = Math.max(...normalComps.map(c => c.size?.height || 40));
return (
<div
key={`row-${rowIndex}`}
className="relative w-full flex-shrink-0"
style={{
height: `${rowHeight}px`,
marginTop: rowMarginTop > 0 ? `${rowMarginTop}px` : undefined,
}}
>
{normalComps.map((component) => {
const typeId = getComponentTypeId(component);
const leftPct = (component.position.x / canvasWidth) * 100;
const widthPct = ((component.size?.width || 90) / canvasWidth) * 100;
return (
<div
key={component.id}
data-component-id={component.id}
data-component-type={typeId}
style={{
position: "absolute",
left: `${leftPct}%`,
width: `${widthPct}%`,
height: `${component.size?.height || 40}px`,
}}
>
{renderComponent(component)}
</div>
);
})}
</div>
);
}
const gap = isMobile ? 8 : getRowGap(normalComps, canvasWidth);
const hasFlexHeightComp = normalComps.some((c) => {
const h = c.size?.height || 0;
@ -310,7 +421,6 @@ export function ResponsiveGridRenderer({
key={`row-${rowIndex}`}
className={cn(
"flex w-full flex-wrap overflow-hidden",
allButtons && "justify-end px-2 py-1",
hasFlexHeightComp ? "min-h-0 flex-1" : "flex-shrink-0"
)}
style={{ gap: `${gap}px`, marginTop: rowMarginTop > 0 ? `${rowMarginTop}px` : undefined }}
@ -357,7 +467,7 @@ export function ResponsiveGridRenderer({
style={{
width: isFullWidth ? "100%" : undefined,
flexBasis: useFlexHeight ? undefined : flexBasis,
flexGrow: 1,
flexGrow: percentWidth,
flexShrink: 1,
minWidth: isMobile ? "100%" : undefined,
minHeight: useFlexHeight ? "300px" : (component.size?.height

View File

@ -475,6 +475,7 @@ export default function ScreenDesigner({
// 테이블 데이터
const [tables, setTables] = useState<TableInfo[]>([]);
const [tableRefreshCounter, setTableRefreshCounter] = useState(0);
const [searchTerm, setSearchTerm] = useState("");
// 🆕 검색어로 필터링된 테이블 목록
@ -1434,8 +1435,16 @@ export default function ScreenDesigner({
selectedScreen?.restApiConnectionId,
selectedScreen?.restApiEndpoint,
selectedScreen?.restApiJsonPath,
tableRefreshCounter,
]);
// 필드 타입 변경 시 테이블 컬럼 정보 갱신 (화면 디자이너에서 input_type 변경 반영)
useEffect(() => {
const handler = () => setTableRefreshCounter((c) => c + 1);
window.addEventListener("table-columns-refresh", handler);
return () => window.removeEventListener("table-columns-refresh", handler);
}, []);
// 테이블 선택 핸들러 - 사이드바에서 테이블 선택 시 호출
const handleTableSelect = useCallback(
async (tableName: string) => {

View File

@ -1,70 +1,80 @@
"use client";
import React from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { ComponentData } from "@/types/screen";
import { ConfigPanelBuilder } from "@/lib/registry/components/common/ConfigPanelBuilder";
import { ConfigSectionDefinition } from "@/lib/registry/components/common/ConfigPanelTypes";
interface AlertConfigPanelProps {
component: ComponentData;
onUpdateProperty: (path: string, value: any) => void;
config?: Record<string, any>;
onChange?: (key: string, value: any) => void;
component?: any;
onUpdateProperty?: (path: string, value: any) => void;
}
export const AlertConfigPanel: React.FC<AlertConfigPanelProps> = ({ component, onUpdateProperty }) => {
const config = component.componentConfig || {};
const sections: ConfigSectionDefinition[] = [
{
id: "content",
title: "콘텐츠",
fields: [
{
key: "title",
label: "제목",
type: "text",
placeholder: "알림 제목을 입력하세요",
},
{
key: "message",
label: "메시지",
type: "textarea",
placeholder: "알림 메시지를 입력하세요",
},
],
},
{
id: "style",
title: "스타일",
fields: [
{
key: "type",
label: "알림 타입",
type: "select",
options: [
{ label: "정보 (Info)", value: "info" },
{ label: "경고 (Warning)", value: "warning" },
{ label: "성공 (Success)", value: "success" },
{ label: "오류 (Error)", value: "error" },
],
},
{
key: "showIcon",
label: "아이콘 표시",
type: "switch",
},
],
},
];
export const AlertConfigPanel: React.FC<AlertConfigPanelProps> = ({
config: directConfig,
onChange: directOnChange,
component,
onUpdateProperty,
}) => {
const config = directConfig || component?.componentConfig || {};
const handleChange = (key: string, value: any) => {
if (directOnChange) {
directOnChange(key, value);
} else if (onUpdateProperty) {
onUpdateProperty(`componentConfig.${key}`, value);
}
};
return (
<div className="space-y-4">
<div>
<Label htmlFor="alert-title"></Label>
<Input
id="alert-title"
value={config.title || "알림 제목"}
onChange={(e) => onUpdateProperty("componentConfig.title", e.target.value)}
placeholder="알림 제목을 입력하세요"
/>
</div>
<div>
<Label htmlFor="alert-message"></Label>
<Textarea
id="alert-message"
value={config.message || "알림 메시지입니다."}
onChange={(e) => onUpdateProperty("componentConfig.message", e.target.value)}
placeholder="알림 메시지를 입력하세요"
rows={3}
/>
</div>
<div>
<Label htmlFor="alert-type"> </Label>
<Select
value={config.type || "info"}
onValueChange={(value) => onUpdateProperty("componentConfig.type", value)}
>
<SelectTrigger>
<SelectValue placeholder="알림 타입 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="info"> (Info)</SelectItem>
<SelectItem value="warning"> (Warning)</SelectItem>
<SelectItem value="success"> (Success)</SelectItem>
<SelectItem value="error"> (Error)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-2">
<Switch
id="show-icon"
checked={config.showIcon ?? true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.showIcon", checked)}
/>
<Label htmlFor="show-icon"> </Label>
</div>
</div>
<ConfigPanelBuilder
config={config}
onChange={handleChange}
sections={sections}
/>
);
};

View File

@ -1,65 +1,79 @@
"use client";
import React from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ComponentData } from "@/types/screen";
import { ConfigPanelBuilder } from "@/lib/registry/components/common/ConfigPanelBuilder";
import { ConfigSectionDefinition } from "@/lib/registry/components/common/ConfigPanelTypes";
interface BadgeConfigPanelProps {
component: ComponentData;
onUpdateProperty: (path: string, value: any) => void;
config?: Record<string, any>;
onChange?: (key: string, value: any) => void;
component?: any;
onUpdateProperty?: (path: string, value: any) => void;
}
export const BadgeConfigPanel: React.FC<BadgeConfigPanelProps> = ({ component, onUpdateProperty }) => {
const config = component.componentConfig || {};
const sections: ConfigSectionDefinition[] = [
{
id: "content",
title: "콘텐츠",
fields: [
{
key: "text",
label: "뱃지 텍스트",
type: "text",
placeholder: "뱃지 텍스트를 입력하세요",
},
],
},
{
id: "style",
title: "스타일",
fields: [
{
key: "variant",
label: "뱃지 스타일",
type: "select",
options: [
{ label: "기본 (Default)", value: "default" },
{ label: "보조 (Secondary)", value: "secondary" },
{ label: "위험 (Destructive)", value: "destructive" },
{ label: "외곽선 (Outline)", value: "outline" },
],
},
{
key: "size",
label: "뱃지 크기",
type: "select",
options: [
{ label: "작음 (Small)", value: "small" },
{ label: "기본 (Default)", value: "default" },
{ label: "큼 (Large)", value: "large" },
],
},
],
},
];
export const BadgeConfigPanel: React.FC<BadgeConfigPanelProps> = ({
config: directConfig,
onChange: directOnChange,
component,
onUpdateProperty,
}) => {
const config = directConfig || component?.componentConfig || {};
const handleChange = (key: string, value: any) => {
if (directOnChange) {
directOnChange(key, value);
} else if (onUpdateProperty) {
onUpdateProperty(`componentConfig.${key}`, value);
}
};
return (
<div className="space-y-4">
<div>
<Label htmlFor="badge-text"> </Label>
<Input
id="badge-text"
value={config.text || "상태"}
onChange={(e) => onUpdateProperty("componentConfig.text", e.target.value)}
placeholder="뱃지 텍스트를 입력하세요"
/>
</div>
<div>
<Label htmlFor="badge-variant"> </Label>
<Select
value={config.variant || "default"}
onValueChange={(value) => onUpdateProperty("componentConfig.variant", value)}
>
<SelectTrigger>
<SelectValue placeholder="뱃지 스타일 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default"> (Default)</SelectItem>
<SelectItem value="secondary"> (Secondary)</SelectItem>
<SelectItem value="destructive"> (Destructive)</SelectItem>
<SelectItem value="outline"> (Outline)</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="badge-size"> </Label>
<Select
value={config.size || "default"}
onValueChange={(value) => onUpdateProperty("componentConfig.size", value)}
>
<SelectTrigger>
<SelectValue placeholder="뱃지 크기 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="small"> (Small)</SelectItem>
<SelectItem value="default"> (Default)</SelectItem>
<SelectItem value="large"> (Large)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<ConfigPanelBuilder
config={config}
onChange={handleChange}
sections={sections}
/>
);
};

File diff suppressed because it is too large Load Diff

View File

@ -1,119 +1,108 @@
"use client";
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { ComponentData } from "@/types/screen";
import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent";
import { ConfigPanelBuilder } from "@/lib/registry/components/common/ConfigPanelBuilder";
import { ConfigSectionDefinition } from "@/lib/registry/components/common/ConfigPanelTypes";
interface CardConfigPanelProps {
component: ComponentData;
onUpdateProperty: (path: string, value: any) => void;
config?: Record<string, any>;
onChange?: (key: string, value: any) => void;
component?: any;
onUpdateProperty?: (path: string, value: any) => void;
}
export const CardConfigPanel: React.FC<CardConfigPanelProps> = ({ component, onUpdateProperty }) => {
const config = component.componentConfig || {};
const sections: ConfigSectionDefinition[] = [
{
id: "content",
title: "콘텐츠",
fields: [
{
key: "title",
label: "카드 제목",
type: "text",
placeholder: "카드 제목을 입력하세요",
},
{
key: "content",
label: "카드 내용",
type: "textarea",
placeholder: "카드 내용을 입력하세요",
},
],
},
{
id: "style",
title: "스타일",
fields: [
{
key: "variant",
label: "카드 스타일",
type: "select",
options: [
{ label: "기본 (Default)", value: "default" },
{ label: "테두리 (Outlined)", value: "outlined" },
{ label: "그림자 (Elevated)", value: "elevated" },
{ label: "채움 (Filled)", value: "filled" },
],
},
{
key: "padding",
label: "패딩",
type: "select",
options: [
{ label: "없음 (None)", value: "none" },
{ label: "작게 (Small)", value: "small" },
{ label: "기본 (Default)", value: "default" },
{ label: "크게 (Large)", value: "large" },
],
},
{
key: "backgroundColor",
label: "배경색",
type: "color",
},
{
key: "borderRadius",
label: "테두리 반경",
type: "text",
placeholder: "8px",
},
],
},
{
id: "display",
title: "표시 옵션",
fields: [
{
key: "showHeader",
label: "헤더 표시",
type: "switch",
},
],
},
];
const handleConfigChange = (key: string, value: any) => {
onUpdateProperty(`componentConfig.${key}`, value);
export const CardConfigPanel: React.FC<CardConfigPanelProps> = ({
config: directConfig,
onChange: directOnChange,
component,
onUpdateProperty,
}) => {
const config = directConfig || component?.componentConfig || {};
const handleChange = (key: string, value: any) => {
if (directOnChange) {
directOnChange(key, value);
} else if (onUpdateProperty) {
onUpdateProperty(`componentConfig.${key}`, value);
}
};
return (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 카드 제목 */}
<div className="space-y-2">
<Label htmlFor="card-title"> </Label>
<Input
id="card-title"
placeholder="카드 제목을 입력하세요"
value={config.title || "카드 제목"}
onChange={(e) => handleConfigChange("title", e.target.value)}
/>
</div>
{/* 카드 내용 */}
<div className="space-y-2">
<Label htmlFor="card-content"> </Label>
<Textarea
id="card-content"
placeholder="카드 내용을 입력하세요"
value={config.content || "카드 내용 영역"}
onChange={(e) => handleConfigChange("content", e.target.value)}
rows={3}
/>
</div>
{/* 카드 스타일 */}
<div className="space-y-2">
<Label htmlFor="card-variant"> </Label>
<Select value={config.variant || "default"} onValueChange={(value) => handleConfigChange("variant", value)}>
<SelectTrigger>
<SelectValue placeholder="카드 스타일 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default"> (Default)</SelectItem>
<SelectItem value="outlined"> (Outlined)</SelectItem>
<SelectItem value="elevated"> (Elevated)</SelectItem>
<SelectItem value="filled"> (Filled)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 헤더 표시 여부 */}
<div className="flex items-center space-x-2">
<Switch
id="show-header"
checked={config.showHeader !== false}
onCheckedChange={(checked) => handleConfigChange("showHeader", checked)}
/>
<Label htmlFor="show-header"> </Label>
</div>
{/* 패딩 설정 */}
<div className="space-y-2">
<Label htmlFor="card-padding"></Label>
<Select value={config.padding || "default"} onValueChange={(value) => handleConfigChange("padding", value)}>
<SelectTrigger>
<SelectValue placeholder="패딩 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> (None)</SelectItem>
<SelectItem value="small"> (Small)</SelectItem>
<SelectItem value="default"> (Default)</SelectItem>
<SelectItem value="large"> (Large)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 배경색 */}
<div className="space-y-2">
<Label htmlFor="background-color"></Label>
<ColorPickerWithTransparent
id="background-color"
value={config.backgroundColor}
onChange={(value) => handleConfigChange("backgroundColor", value)}
defaultColor="#ffffff"
placeholder="#ffffff"
/>
</div>
{/* 테두리 반경 */}
<div className="space-y-2">
<Label htmlFor="border-radius"> </Label>
<Input
id="border-radius"
placeholder="8px"
value={config.borderRadius || "8px"}
onChange={(e) => handleConfigChange("borderRadius", e.target.value)}
/>
</div>
</CardContent>
</Card>
<ConfigPanelBuilder
config={config}
onChange={handleChange}
sections={sections}
/>
);
};

View File

@ -1,150 +1,131 @@
"use client";
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { ComponentData } from "@/types/screen";
import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent";
import { ConfigPanelBuilder } from "@/lib/registry/components/common/ConfigPanelBuilder";
import { ConfigSectionDefinition } from "@/lib/registry/components/common/ConfigPanelTypes";
interface DashboardConfigPanelProps {
component: ComponentData;
onUpdateProperty: (path: string, value: any) => void;
config?: Record<string, any>;
onChange?: (key: string, value: any) => void;
component?: any;
onUpdateProperty?: (path: string, value: any) => void;
}
export const DashboardConfigPanel: React.FC<DashboardConfigPanelProps> = ({ component, onUpdateProperty }) => {
const config = component.componentConfig || {};
const sections: ConfigSectionDefinition[] = [
{
id: "content",
title: "콘텐츠",
fields: [
{
key: "title",
label: "그리드 제목",
type: "text",
placeholder: "그리드 제목을 입력하세요",
},
],
},
{
id: "grid",
title: "그리드 설정",
fields: [
{
key: "rows",
label: "행 개수",
type: "select",
options: [
{ label: "1행", value: "1" },
{ label: "2행", value: "2" },
{ label: "3행", value: "3" },
{ label: "4행", value: "4" },
],
},
{
key: "columns",
label: "열 개수",
type: "select",
options: [
{ label: "1열", value: "1" },
{ label: "2열", value: "2" },
{ label: "3열", value: "3" },
{ label: "4열", value: "4" },
{ label: "6열", value: "6" },
],
},
{
key: "gap",
label: "그리드 간격",
type: "select",
options: [
{ label: "없음 (0px)", value: "none" },
{ label: "작게 (8px)", value: "small" },
{ label: "보통 (16px)", value: "medium" },
{ label: "크게 (24px)", value: "large" },
],
},
{
key: "itemHeight",
label: "아이템 높이",
type: "text",
placeholder: "120px",
},
],
},
{
id: "style",
title: "스타일",
fields: [
{
key: "backgroundColor",
label: "배경색",
type: "color",
},
{
key: "borderRadius",
label: "테두리 반경",
type: "text",
placeholder: "8px",
},
],
},
{
id: "display",
title: "표시 옵션",
fields: [
{
key: "responsive",
label: "반응형 레이아웃",
type: "switch",
},
{
key: "showBorders",
label: "그리드 테두리 표시",
type: "switch",
},
],
},
];
const handleConfigChange = (key: string, value: any) => {
onUpdateProperty(`componentConfig.${key}`, value);
export const DashboardConfigPanel: React.FC<DashboardConfigPanelProps> = ({
config: directConfig,
onChange: directOnChange,
component,
onUpdateProperty,
}) => {
const config = directConfig || component?.componentConfig || {};
const handleChange = (key: string, value: any) => {
if (directOnChange) {
directOnChange(key, value);
} else if (onUpdateProperty) {
onUpdateProperty(`componentConfig.${key}`, value);
}
};
return (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 그리드 제목 */}
<div className="space-y-2">
<Label htmlFor="grid-title"> </Label>
<Input
id="grid-title"
placeholder="그리드 제목을 입력하세요"
value={config.title || "대시보드 그리드"}
onChange={(e) => handleConfigChange("title", e.target.value)}
/>
</div>
{/* 행 개수 */}
<div className="space-y-2">
<Label htmlFor="grid-rows"> </Label>
<Select
value={String(config.rows || 2)}
onValueChange={(value) => handleConfigChange("rows", parseInt(value))}
>
<SelectTrigger>
<SelectValue placeholder="행 개수 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1</SelectItem>
<SelectItem value="2">2</SelectItem>
<SelectItem value="3">3</SelectItem>
<SelectItem value="4">4</SelectItem>
</SelectContent>
</Select>
</div>
{/* 열 개수 */}
<div className="space-y-2">
<Label htmlFor="grid-columns"> </Label>
<Select
value={String(config.columns || 3)}
onValueChange={(value) => handleConfigChange("columns", parseInt(value))}
>
<SelectTrigger>
<SelectValue placeholder="열 개수 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1</SelectItem>
<SelectItem value="2">2</SelectItem>
<SelectItem value="3">3</SelectItem>
<SelectItem value="4">4</SelectItem>
<SelectItem value="6">6</SelectItem>
</SelectContent>
</Select>
</div>
{/* 간격 설정 */}
<div className="space-y-2">
<Label htmlFor="grid-gap"> </Label>
<Select value={config.gap || "medium"} onValueChange={(value) => handleConfigChange("gap", value)}>
<SelectTrigger>
<SelectValue placeholder="간격 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> (0px)</SelectItem>
<SelectItem value="small"> (8px)</SelectItem>
<SelectItem value="medium"> (16px)</SelectItem>
<SelectItem value="large"> (24px)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 그리드 아이템 높이 */}
<div className="space-y-2">
<Label htmlFor="item-height"> </Label>
<Input
id="item-height"
placeholder="120px"
value={config.itemHeight || "120px"}
onChange={(e) => handleConfigChange("itemHeight", e.target.value)}
/>
</div>
{/* 반응형 설정 */}
<div className="flex items-center space-x-2">
<Switch
id="responsive"
checked={config.responsive !== false}
onCheckedChange={(checked) => handleConfigChange("responsive", checked)}
/>
<Label htmlFor="responsive"> </Label>
</div>
{/* 테두리 표시 */}
<div className="flex items-center space-x-2">
<Switch
id="show-borders"
checked={config.showBorders !== false}
onCheckedChange={(checked) => handleConfigChange("showBorders", checked)}
/>
<Label htmlFor="show-borders"> </Label>
</div>
{/* 배경색 */}
<div className="space-y-2">
<Label htmlFor="background-color"></Label>
<ColorPickerWithTransparent
id="background-color"
value={config.backgroundColor}
onChange={(value) => handleConfigChange("backgroundColor", value)}
defaultColor="#f8f9fa"
placeholder="#f8f9fa"
/>
</div>
{/* 테두리 반경 */}
<div className="space-y-2">
<Label htmlFor="border-radius"> </Label>
<Input
id="border-radius"
placeholder="8px"
value={config.borderRadius || "8px"}
onChange={(e) => handleConfigChange("borderRadius", e.target.value)}
/>
</div>
</CardContent>
</Card>
<ConfigPanelBuilder
config={config}
onChange={handleChange}
sections={sections}
/>
);
};

View File

@ -1,84 +1,93 @@
"use client";
import React from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { ComponentData } from "@/types/screen";
import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent";
import { ConfigPanelBuilder } from "@/lib/registry/components/common/ConfigPanelBuilder";
import { ConfigSectionDefinition } from "@/lib/registry/components/common/ConfigPanelTypes";
interface ProgressBarConfigPanelProps {
component: ComponentData;
onUpdateProperty: (path: string, value: any) => void;
config?: Record<string, any>;
onChange?: (key: string, value: any) => void;
component?: any;
onUpdateProperty?: (path: string, value: any) => void;
}
export const ProgressBarConfigPanel: React.FC<ProgressBarConfigPanelProps> = ({ component, onUpdateProperty }) => {
const config = component.componentConfig || {};
const sections: ConfigSectionDefinition[] = [
{
id: "content",
title: "콘텐츠",
fields: [
{
key: "label",
label: "라벨",
type: "text",
placeholder: "진행률 라벨을 입력하세요",
},
{
key: "value",
label: "현재 값",
type: "number",
min: 0,
placeholder: "현재 값",
},
{
key: "max",
label: "최대 값",
type: "number",
min: 1,
placeholder: "최대 값",
},
],
},
{
id: "style",
title: "스타일",
fields: [
{
key: "color",
label: "진행률 색상",
type: "color",
},
],
},
{
id: "display",
title: "표시 옵션",
fields: [
{
key: "showPercentage",
label: "퍼센트 표시",
type: "switch",
},
{
key: "showValue",
label: "값 표시",
type: "switch",
},
],
},
];
export const ProgressBarConfigPanel: React.FC<ProgressBarConfigPanelProps> = ({
config: directConfig,
onChange: directOnChange,
component,
onUpdateProperty,
}) => {
const config = directConfig || component?.componentConfig || {};
const handleChange = (key: string, value: any) => {
if (directOnChange) {
directOnChange(key, value);
} else if (onUpdateProperty) {
onUpdateProperty(`componentConfig.${key}`, value);
}
};
return (
<div className="space-y-4">
<div>
<Label htmlFor="progress-label"></Label>
<Input
id="progress-label"
value={config.label || "진행률"}
onChange={(e) => onUpdateProperty("componentConfig.label", e.target.value)}
placeholder="진행률 라벨을 입력하세요"
/>
</div>
<div>
<Label htmlFor="progress-value"> </Label>
<Input
id="progress-value"
type="number"
value={config.value || 65}
onChange={(e) => onUpdateProperty("componentConfig.value", parseInt(e.target.value) || 0)}
placeholder="현재 값"
min="0"
/>
</div>
<div>
<Label htmlFor="progress-max"> </Label>
<Input
id="progress-max"
type="number"
value={config.max || 100}
onChange={(e) => onUpdateProperty("componentConfig.max", parseInt(e.target.value) || 100)}
placeholder="최대 값"
min="1"
/>
</div>
<div>
<Label htmlFor="progress-color"> </Label>
<ColorPickerWithTransparent
id="progress-color"
value={config.color}
onChange={(value) => onUpdateProperty("componentConfig.color", value)}
defaultColor="#3b82f6"
placeholder="#3b82f6"
/>
</div>
<div className="flex items-center space-x-2">
<Switch
id="show-percentage"
checked={config.showPercentage ?? true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.showPercentage", checked)}
/>
<Label htmlFor="show-percentage"> </Label>
</div>
<div className="flex items-center space-x-2">
<Switch
id="show-value"
checked={config.showValue ?? true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.showValue", checked)}
/>
<Label htmlFor="show-value"> </Label>
</div>
</div>
<ConfigPanelBuilder
config={config}
onChange={handleChange}
sections={sections}
/>
);
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,132 @@
"use client";
import React, { useMemo } from "react";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ImprovedButtonControlConfigPanel } from "../ImprovedButtonControlConfigPanel";
import { FlowVisibilityConfigPanel } from "../FlowVisibilityConfigPanel";
import type { ButtonTabProps } from "./types";
/**
* AdvancedTab - , ,
*/
export const AdvancedTab: React.FC<ButtonTabProps> = ({
component,
onUpdateProperty,
allComponents,
}) => {
// 플로우 위젯이 화면에 있는지 확인
const hasFlowWidget = useMemo(() => {
return allComponents.some((comp: { componentType?: string; widgetType?: string }) => {
const compType = comp.componentType || comp.widgetType || "";
return compType === "flow-widget" || compType?.toLowerCase().includes("flow");
});
}, [allComponents]);
const actionType = component.componentConfig?.action?.type;
return (
<div className="space-y-4">
{/* 행 선택 시에만 활성화 설정 */}
<div className="space-y-4 rounded-lg border bg-muted/50 p-4">
<h4 className="text-sm font-medium text-foreground"> </h4>
<p className="text-xs text-muted-foreground">
.
</p>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label> </Label>
<p className="text-xs text-muted-foreground">
.
</p>
</div>
<Switch
checked={component.componentConfig?.action?.requireRowSelection || false}
onCheckedChange={(checked) => {
onUpdateProperty("componentConfig.action.requireRowSelection", checked);
}}
/>
</div>
{component.componentConfig?.action?.requireRowSelection && (
<div className="space-y-3 border-l-2 border-primary/20 pl-4">
<div>
<Label htmlFor="row-selection-source"> </Label>
<Select
value={component.componentConfig?.action?.rowSelectionSource || "auto"}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.rowSelectionSource", value);
}}
>
<SelectTrigger id="row-selection-source" className="h-8 text-xs">
<SelectValue placeholder="데이터 소스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto"> ()</SelectItem>
<SelectItem value="tableList"> </SelectItem>
<SelectItem value="splitPanelLeft"> </SelectItem>
<SelectItem value="flowWidget"> </SelectItem>
</SelectContent>
</Select>
<p className="mt-1 text-xs text-muted-foreground">
감지: 테이블, ,
</p>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label> </Label>
<p className="text-xs text-muted-foreground">
(기본: 1개 )
</p>
</div>
<Switch
checked={component.componentConfig?.action?.allowMultiRowSelection ?? true}
onCheckedChange={(checked) => {
onUpdateProperty("componentConfig.action.allowMultiRowSelection", checked);
}}
/>
</div>
{!(component.componentConfig?.action?.allowMultiRowSelection ?? true) && (
<div className="rounded-md bg-muted p-2">
<p className="text-xs text-muted-foreground">
1 .
</p>
</div>
)}
</div>
)}
</div>
{/* 제어 기능 섹션 - 엑셀 업로드 계열이 아닐 때만 표시 */}
{actionType !== "excel_upload" && actionType !== "multi_table_excel_upload" && (
<div className="border-t border-border pt-6">
<ImprovedButtonControlConfigPanel
component={component}
onUpdateProperty={onUpdateProperty}
/>
</div>
)}
{/* 플로우 단계별 표시 제어 (플로우 위젯이 있을 때만) */}
{hasFlowWidget && (
<div className="border-t border-border pt-6">
<FlowVisibilityConfigPanel
component={component}
allComponents={allComponents}
onUpdateProperty={onUpdateProperty}
/>
</div>
)}
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,20 @@
"use client";
import React from "react";
import { ButtonTabProps } from "./types";
import { ButtonDataflowConfigPanel } from "../ButtonDataflowConfigPanel";
/** 데이터플로우 탭: 버튼 제어관리 설정 패널 래퍼 */
export const DataflowTab: React.FC<ButtonTabProps> = ({
component,
onUpdateProperty,
}) => {
return (
<div className="space-y-4">
<ButtonDataflowConfigPanel
component={component}
onUpdateProperty={onUpdateProperty}
/>
</div>
);
};

View File

@ -0,0 +1,31 @@
import { ComponentData } from "@/types/screen";
export interface TitleBlock {
id: string;
type: "text" | "field";
value: string;
tableName?: string;
label?: string;
}
export interface ButtonConfigPanelProps {
component: ComponentData;
onUpdateProperty: (path: string, value: any) => void;
allComponents?: ComponentData[];
currentTableName?: string;
currentScreenCompanyCode?: string;
}
export interface ScreenOption {
id: number;
name: string;
description?: string;
}
export interface ButtonTabProps {
component: ComponentData;
onUpdateProperty: (path: string, value: any) => void;
allComponents: ComponentData[];
currentTableName?: string;
currentScreenCompanyCode?: string;
}

File diff suppressed because it is too large Load Diff

View File

@ -194,7 +194,7 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
operator: "contains", // 기본 연산자
value: "",
filterType: cf.filterType,
width: cf.width || 200, // 너비 포함 (기본 200px)
width: cf.width && cf.width >= 10 && cf.width <= 100 ? cf.width : 25,
}));
// localStorage에 저장 (화면별로 독립적)
@ -334,20 +334,20 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
{/* 너비 입력 */}
<Input
type="number"
value={filter.width || 200}
value={filter.width && filter.width >= 10 && filter.width <= 100 ? filter.width : 25}
onChange={(e) => {
const newWidth = parseInt(e.target.value) || 200;
const newWidth = Math.min(100, Math.max(10, parseInt(e.target.value) || 25));
setColumnFilters((prev) =>
prev.map((f) => (f.columnName === filter.columnName ? { ...f, width: newWidth } : f)),
);
}}
disabled={!filter.enabled}
placeholder="너비"
placeholder="25"
className="h-8 w-[80px] text-xs sm:h-9 sm:text-sm"
min={50}
max={500}
min={10}
max={100}
/>
<span className="text-muted-foreground text-xs">px</span>
<span className="text-muted-foreground text-xs">%</span>
</div>
))}
</div>

View File

@ -136,7 +136,7 @@ export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFilters
inputType,
enabled: false,
filterType,
width: 200,
width: 25,
};
});
@ -271,7 +271,7 @@ export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFilters
operator: "contains",
value: "",
filterType: f.filterType,
width: f.width || 200,
width: f.width && f.width >= 10 && f.width <= 100 ? f.width : 25,
}));
onFiltersApplied?.(activeFilters);
@ -498,15 +498,15 @@ export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFilters
</Select>
<Input
type="number"
min={100}
max={400}
value={filter.width || 200}
min={10}
max={100}
value={filter.width && filter.width >= 10 && filter.width <= 100 ? filter.width : 25}
onChange={(e) =>
handleFilterWidthChange(filter.columnName, parseInt(e.target.value) || 200)
handleFilterWidthChange(filter.columnName, Math.min(100, Math.max(10, parseInt(e.target.value) || 25)))
}
className="h-7 w-16 text-center text-xs"
/>
<span className="text-muted-foreground text-xs">px</span>
<span className="text-muted-foreground text-xs">%</span>
</div>
))}
</div>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,397 @@
"use client";
/**
* V2
* UX: 데이터 (Combobox) -> -> (Collapsible)
*/
import React, { useState, useEffect } from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Check, ChevronsUpDown, Database, ChevronDown, Settings } from "lucide-react";
import { cn } from "@/lib/utils";
import { tableTypeApi } from "@/lib/api/screen";
import { tableManagementApi } from "@/lib/api/tableManagement";
import type { ApprovalStepConfig } from "@/lib/registry/components/v2-approval-step/types";
interface V2ApprovalStepConfigPanelProps {
config: ApprovalStepConfig;
onChange: (config: Partial<ApprovalStepConfig>) => void;
screenTableName?: string;
}
export const V2ApprovalStepConfigPanel: React.FC<V2ApprovalStepConfigPanelProps> = ({
config,
onChange,
screenTableName,
}) => {
const [availableTables, setAvailableTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
const [loadingTables, setLoadingTables] = useState(false);
const [tableOpen, setTableOpen] = useState(false);
const [availableColumns, setAvailableColumns] = useState<Array<{ columnName: string; label: string }>>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
const [columnOpen, setColumnOpen] = useState(false);
const [displayOpen, setDisplayOpen] = useState(false);
const targetTableName = config.targetTable || screenTableName;
const handleChange = (key: keyof ApprovalStepConfig, value: any) => {
onChange({ [key]: value });
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("componentConfigChanged", {
detail: { config: { ...config, [key]: value } },
})
);
}
};
useEffect(() => {
const fetchTables = async () => {
setLoadingTables(true);
try {
const response = await tableTypeApi.getTables();
setAvailableTables(
response.map((table: any) => ({
tableName: table.tableName,
displayName: table.displayName || table.tableName,
}))
);
} catch {
/* ignore */
} finally {
setLoadingTables(false);
}
};
fetchTables();
}, []);
useEffect(() => {
if (!targetTableName) {
setAvailableColumns([]);
return;
}
const fetchColumns = async () => {
setLoadingColumns(true);
try {
const result = await tableManagementApi.getColumnList(targetTableName);
if (result.success && result.data) {
const columns = Array.isArray(result.data) ? result.data : result.data.columns;
if (columns && Array.isArray(columns)) {
setAvailableColumns(
columns.map((col: any) => ({
columnName: col.columnName || col.column_name || col.name,
label:
col.displayName ||
col.columnLabel ||
col.column_label ||
col.columnName ||
col.column_name ||
col.name,
}))
);
}
}
} catch {
setAvailableColumns([]);
} finally {
setLoadingColumns(false);
}
};
fetchColumns();
}, [targetTableName]);
const handleTableChange = (newTableName: string) => {
if (newTableName === targetTableName) return;
const patch = { targetTable: newTableName, targetRecordIdField: "" };
onChange(patch);
setTableOpen(false);
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("componentConfigChanged", {
detail: { config: { ...config, ...patch } },
})
);
}
};
return (
<div className="space-y-4">
{/* ─── 1단계: 데이터 소스 ─── */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium"> </p>
</div>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
{/* 대상 테이블 */}
<div className="space-y-1">
<span className="text-xs text-muted-foreground"> </span>
<Popover open={tableOpen} onOpenChange={setTableOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableOpen}
className="h-7 w-full justify-between text-xs"
disabled={loadingTables}
>
{loadingTables
? "로딩 중..."
: targetTableName
? availableTables.find((t) => t.tableName === targetTableName)?.displayName ||
targetTableName
: "테이블 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-4 text-center text-xs">
.
</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{availableTables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.tableName} ${table.displayName}`}
onSelect={() => handleTableChange(table.tableName)}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
targetTableName === table.tableName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{table.displayName}</span>
{table.displayName !== table.tableName && (
<span className="text-[10px] text-muted-foreground">
{table.tableName}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{screenTableName && targetTableName !== screenTableName && (
<div className="mt-1 flex items-center justify-between rounded-md bg-amber-50 px-2 py-1">
<span className="text-[10px] text-amber-700">
({screenTableName})
</span>
<Button
variant="ghost"
size="sm"
className="h-5 px-1.5 text-[10px] text-amber-700 hover:text-amber-900"
onClick={() => handleTableChange(screenTableName)}
>
</Button>
</div>
)}
</div>
{/* 레코드 ID 필드 */}
<div className="space-y-1">
<span className="text-xs text-muted-foreground"> ID </span>
{targetTableName ? (
<Popover open={columnOpen} onOpenChange={setColumnOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={columnOpen}
className="h-7 w-full justify-between text-xs"
disabled={loadingColumns}
>
{loadingColumns
? "컬럼 로딩 중..."
: config.targetRecordIdField
? availableColumns.find((c) => c.columnName === config.targetRecordIdField)
?.label || config.targetRecordIdField
: "PK 컬럼 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-4 text-center text-xs">
.
</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{availableColumns.map((col) => (
<CommandItem
key={col.columnName}
value={`${col.columnName} ${col.label}`}
onSelect={() => {
handleChange("targetRecordIdField", col.columnName);
setColumnOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.targetRecordIdField === col.columnName
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{col.label}</span>
{col.label !== col.columnName && (
<span className="text-[10px] text-muted-foreground">
{col.columnName}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<p className="text-[10px] text-muted-foreground"> </p>
)}
<p className="text-[10px] text-muted-foreground mt-0.5">
PK
</p>
</div>
</div>
{/* ─── 2단계: 표시 모드 ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<div className="rounded-lg border bg-muted/30 p-4">
<Select
value={config.displayMode || "horizontal"}
onValueChange={(v) => handleChange("displayMode", v)}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="horizontal"> </SelectItem>
<SelectItem value="vertical"> </SelectItem>
</SelectContent>
</Select>
</div>
{/* ─── 3단계: 표시 옵션 (Collapsible) ─── */}
<Collapsible open={displayOpen} onOpenChange={setDisplayOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
displayOpen && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm">/ </p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.showDept !== false}
onCheckedChange={(checked) => handleChange("showDept", checked)}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.showComment !== false}
onCheckedChange={(checked) => handleChange("showComment", checked)}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.showTimestamp !== false}
onCheckedChange={(checked) => handleChange("showTimestamp", checked)}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.compact || false}
onCheckedChange={(checked) => handleChange("compact", checked)}
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
V2ApprovalStepConfigPanel.displayName = "V2ApprovalStepConfigPanel";
export default V2ApprovalStepConfigPanel;

View File

@ -2,15 +2,25 @@
/**
* V2Biz
* .
* UX: 비즈니스 -> -> ()
*/
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox";
import { Switch } from "@/components/ui/switch";
import {
GitBranch,
LayoutGrid,
MapPin,
Hash,
FolderTree,
ArrowRightLeft,
Link2,
Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { tableTypeApi } from "@/lib/api/screen";
interface V2BizConfigPanelProps {
@ -28,27 +38,32 @@ interface ColumnOption {
displayName: string;
}
const BIZ_TYPE_CARDS = [
{ value: "flow", icon: GitBranch, title: "플로우", description: "워크플로우를 구성해요" },
{ value: "rack", icon: LayoutGrid, title: "랙 구조", description: "창고 렉 위치를 관리해요" },
{ value: "map", icon: MapPin, title: "지도", description: "위치 정보를 표시해요" },
{ value: "numbering", icon: Hash, title: "채번 규칙", description: "자동 번호를 생성해요" },
{ value: "category", icon: FolderTree, title: "카테고리", description: "분류 체계를 관리해요" },
{ value: "data-mapping", icon: ArrowRightLeft, title: "데이터 매핑", description: "테이블 간 매핑해요" },
{ value: "related-data", icon: Link2, title: "관련 데이터", description: "연결된 데이터를 조회해요" },
] as const;
export const V2BizConfigPanel: React.FC<V2BizConfigPanelProps> = ({
config,
onChange,
}) => {
// 테이블 목록
const [tables, setTables] = useState<TableOption[]>([]);
const [loadingTables, setLoadingTables] = useState(false);
// 컬럼 목록 (소스/대상/관련 테이블용)
const [sourceColumns, setSourceColumns] = useState<ColumnOption[]>([]);
const [targetColumns, setTargetColumns] = useState<ColumnOption[]>([]);
const [relatedColumns, setRelatedColumns] = useState<ColumnOption[]>([]);
const [categoryColumns, setCategoryColumns] = useState<ColumnOption[]>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
// 설정 업데이트 핸들러
const updateConfig = (field: string, value: any) => {
onChange({ ...config, [field]: value });
};
// 테이블 목록 로드
useEffect(() => {
const loadTables = async () => {
setLoadingTables(true);
@ -67,13 +82,9 @@ export const V2BizConfigPanel: React.FC<V2BizConfigPanelProps> = ({
loadTables();
}, []);
// 소스 테이블 선택 시 컬럼 목록 로드
useEffect(() => {
const loadColumns = async () => {
if (!config.sourceTable) {
setSourceColumns([]);
return;
}
if (!config.sourceTable) { setSourceColumns([]); return; }
try {
const data = await tableTypeApi.getColumns(config.sourceTable);
setSourceColumns(data.map((c: any) => ({
@ -87,13 +98,9 @@ export const V2BizConfigPanel: React.FC<V2BizConfigPanelProps> = ({
loadColumns();
}, [config.sourceTable]);
// 대상 테이블 선택 시 컬럼 목록 로드
useEffect(() => {
const loadColumns = async () => {
if (!config.targetTable) {
setTargetColumns([]);
return;
}
if (!config.targetTable) { setTargetColumns([]); return; }
try {
const data = await tableTypeApi.getColumns(config.targetTable);
setTargetColumns(data.map((c: any) => ({
@ -107,13 +114,9 @@ export const V2BizConfigPanel: React.FC<V2BizConfigPanelProps> = ({
loadColumns();
}, [config.targetTable]);
// 관련 테이블 선택 시 컬럼 목록 로드
useEffect(() => {
const loadColumns = async () => {
if (!config.relatedTable) {
setRelatedColumns([]);
return;
}
if (!config.relatedTable) { setRelatedColumns([]); return; }
try {
const data = await tableTypeApi.getColumns(config.relatedTable);
setRelatedColumns(data.map((c: any) => ({
@ -127,13 +130,9 @@ export const V2BizConfigPanel: React.FC<V2BizConfigPanelProps> = ({
loadColumns();
}, [config.relatedTable]);
// 카테고리 테이블 선택 시 컬럼 목록 로드
useEffect(() => {
const loadColumns = async () => {
if (!config.tableName) {
setCategoryColumns([]);
return;
}
if (!config.tableName) { setCategoryColumns([]); return; }
setLoadingColumns(true);
try {
const data = await tableTypeApi.getColumns(config.tableName);
@ -150,281 +149,348 @@ export const V2BizConfigPanel: React.FC<V2BizConfigPanelProps> = ({
loadColumns();
}, [config.tableName]);
const bizType = config.bizType || config.type || "flow";
return (
<div className="space-y-4">
{/* 비즈니스 타입 */}
{/* ─── 1단계: 비즈니스 타입 선택 (카드) ─── */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.bizType || config.type || "flow"}
onValueChange={(value) => updateConfig("bizType", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="타입 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="flow"></SelectItem>
<SelectItem value="rack"> </SelectItem>
<SelectItem value="map"></SelectItem>
<SelectItem value="numbering"> </SelectItem>
<SelectItem value="category"></SelectItem>
<SelectItem value="data-mapping"> </SelectItem>
<SelectItem value="related-data"> </SelectItem>
</SelectContent>
</Select>
<p className="text-sm font-medium"> ?</p>
<div className="grid grid-cols-2 gap-2">
{BIZ_TYPE_CARDS.map((card) => {
const Icon = card.icon;
const isSelected = bizType === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateConfig("bizType", card.value)}
className={cn(
"flex flex-col items-center justify-center rounded-lg border p-3 text-center transition-all min-h-[80px]",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<Icon className="h-5 w-5 mb-1.5 text-primary" />
<span className="text-xs font-medium leading-tight">{card.title}</span>
<span className="text-[10px] text-muted-foreground leading-tight mt-0.5">{card.description}</span>
</button>
);
})}
</div>
</div>
<Separator />
{/* ─── 2단계: 타입별 설정 ─── */}
{/* 플로우 설정 */}
{config.bizType === "flow" && (
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> ID</Label>
{bizType === "flow" && (
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4 text-primary" />
<span className="text-sm font-medium"> </span>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> ID</span>
<Input
type="number"
value={config.flowId || ""}
onChange={(e) => updateConfig("flowId", e.target.value ? Number(e.target.value) : undefined)}
placeholder="플로우 ID"
className="h-8 text-xs"
className="h-7 w-[160px] text-xs"
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="editable"
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.editable || false}
onCheckedChange={(checked) => updateConfig("editable", checked)}
/>
<label htmlFor="editable" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showMinimap"
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.showMinimap || false}
onCheckedChange={(checked) => updateConfig("showMinimap", checked)}
/>
<label htmlFor="showMinimap" className="text-xs"> </label>
</div>
</div>
)}
{/* 랙 구조 설정 */}
{config.bizType === "rack" && (
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Input
type="number"
value={config.rows || ""}
onChange={(e) => updateConfig("rows", e.target.value ? Number(e.target.value) : undefined)}
placeholder="5"
min="1"
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Input
type="number"
value={config.columns || ""}
onChange={(e) => updateConfig("columns", e.target.value ? Number(e.target.value) : undefined)}
placeholder="10"
min="1"
className="h-8 text-xs"
/>
{bizType === "rack" && (
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<LayoutGrid className="h-4 w-4 text-primary" />
<span className="text-sm font-medium"> </span>
</div>
<div className="space-y-2">
<p className="text-xs text-muted-foreground"> </p>
<div className="flex gap-2">
<div className="flex-1">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Input
type="number"
value={config.rows || ""}
onChange={(e) => updateConfig("rows", e.target.value ? Number(e.target.value) : undefined)}
placeholder="5"
min="1"
className="h-7 text-xs"
/>
</div>
<div className="flex-1">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Input
type="number"
value={config.columns || ""}
onChange={(e) => updateConfig("columns", e.target.value ? Number(e.target.value) : undefined)}
placeholder="10"
min="1"
className="h-7 text-xs"
/>
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showLabels"
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.showLabels !== false}
onCheckedChange={(checked) => updateConfig("showLabels", checked)}
/>
<label htmlFor="showLabels" className="text-xs"> </label>
</div>
</div>
)}
{/* 채번 규칙 설정 */}
{config.bizType === "numbering" && (
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> ID</Label>
{bizType === "numbering" && (
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<Hash className="h-4 w-4 text-primary" />
<span className="text-sm font-medium"> </span>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> ID</span>
<Input
type="number"
value={config.ruleId || ""}
onChange={(e) => updateConfig("ruleId", e.target.value ? Number(e.target.value) : undefined)}
placeholder="규칙 ID"
className="h-8 text-xs"
className="h-7 w-[160px] text-xs"
/>
</div>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"></Label>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"></span>
<Input
value={config.prefix || ""}
onChange={(e) => updateConfig("prefix", e.target.value)}
placeholder="예: INV-"
className="h-8 text-xs"
className="h-7 w-[160px] text-xs"
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="autoGenerate"
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.autoGenerate !== false}
onCheckedChange={(checked) => updateConfig("autoGenerate", checked)}
/>
<label htmlFor="autoGenerate" className="text-xs"> </label>
</div>
</div>
)}
{/* 카테고리 설정 */}
{config.bizType === "category" && (
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={config.tableName || ""}
onValueChange={(value) => {
updateConfig("tableName", value);
updateConfig("columnName", "");
}}
disabled={loadingTables}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={loadingTables ? "로딩 중..." : "테이블 선택"} />
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
{bizType === "category" && (
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<FolderTree className="h-4 w-4 text-primary" />
<span className="text-sm font-medium"> </span>
</div>
{config.tableName && (
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"></Label>
<div>
<p className="mb-1.5 text-xs text-muted-foreground"> </p>
{loadingTables ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
) : (
<Select
value={config.columnName || ""}
onValueChange={(value) => updateConfig("columnName", value)}
disabled={loadingColumns}
value={config.tableName || ""}
onValueChange={(value) => {
updateConfig("tableName", value);
updateConfig("columnName", "");
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={loadingColumns ? "로딩 중..." : "컬럼 선택"} />
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{categoryColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
{tables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{config.tableName && (
<div>
<p className="mb-1.5 text-xs text-muted-foreground"></p>
{loadingColumns ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
) : (
<Select
value={config.columnName || ""}
onValueChange={(value) => updateConfig("columnName", value)}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{categoryColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)}
</div>
)}
{/* 데이터 매핑 설정 */}
{config.bizType === "data-mapping" && (
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={config.sourceTable || ""}
onValueChange={(value) => updateConfig("sourceTable", value)}
disabled={loadingTables}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={loadingTables ? "로딩 중..." : "테이블 선택"} />
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
{bizType === "data-mapping" && (
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<ArrowRightLeft className="h-4 w-4 text-primary" />
<span className="text-sm font-medium"> </span>
</div>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={config.targetTable || ""}
onValueChange={(value) => updateConfig("targetTable", value)}
disabled={loadingTables}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={loadingTables ? "로딩 중..." : "테이블 선택"} />
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
<div>
<p className="mb-1.5 text-xs text-muted-foreground"> </p>
{loadingTables ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
) : (
<Select
value={config.sourceTable || ""}
onValueChange={(value) => updateConfig("sourceTable", value)}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="소스 테이블 선택" />
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div>
<p className="mb-1.5 text-xs text-muted-foreground"> </p>
{loadingTables ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
) : (
<Select
value={config.targetTable || ""}
onValueChange={(value) => updateConfig("targetTable", value)}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="대상 테이블 선택" />
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
)}
{/* 관련 데이터 설정 */}
{config.bizType === "related-data" && (
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={config.relatedTable || ""}
onValueChange={(value) => {
updateConfig("relatedTable", value);
updateConfig("linkColumn", "");
}}
disabled={loadingTables}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={loadingTables ? "로딩 중..." : "테이블 선택"} />
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
{bizType === "related-data" && (
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4 text-primary" />
<span className="text-sm font-medium"> </span>
</div>
<div>
<p className="mb-1.5 text-xs text-muted-foreground"> </p>
{loadingTables ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
) : (
<Select
value={config.relatedTable || ""}
onValueChange={(value) => {
updateConfig("relatedTable", value);
updateConfig("linkColumn", "");
}}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="관련 테이블 선택" />
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{config.relatedTable && (
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<div>
<p className="mb-1.5 text-xs text-muted-foreground"> </p>
<Select
value={config.linkColumn || ""}
onValueChange={(value) => updateConfig("linkColumn", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
@ -438,13 +504,13 @@ export const V2BizConfigPanel: React.FC<V2BizConfigPanelProps> = ({
</div>
)}
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Input
value={config.buttonText || ""}
onChange={(e) => updateConfig("buttonText", e.target.value)}
placeholder="관련 데이터 보기"
className="h-8 text-xs"
className="h-7 w-[160px] text-xs"
/>
</div>
</div>

View File

@ -3,13 +3,12 @@
/**
* BOM
*
* V2RepeaterConfigPanel :
* - : 저장 + + +
* UX:
* - : 저장 ()
* - : 소스 + +
*/
import React, { useState, useEffect, useMemo, useCallback } from "react";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
@ -17,7 +16,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -35,6 +34,7 @@ import {
Check,
ChevronsUpDown,
GitBranch,
Settings,
} from "lucide-react";
import {
Command,
@ -49,6 +49,12 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Badge } from "@/components/ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { tableTypeApi } from "@/lib/api/screen";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { cn } from "@/lib/utils";
@ -102,27 +108,18 @@ interface BomColumnConfig {
}
interface BomItemEditorConfig {
// 저장 테이블 설정 (리피터 패턴)
useCustomTable?: boolean;
mainTableName?: string;
foreignKeyColumn?: string;
foreignKeySourceColumn?: string;
// 트리 구조 설정
parentKeyColumn?: string;
// 엔티티 (품목 참조) 설정
dataSource?: {
sourceTable?: string;
foreignKey?: string;
referenceKey?: string;
displayColumn?: string;
};
// 컬럼 설정
columns: BomColumnConfig[];
// 기능 옵션
features?: {
showAddButton?: boolean;
showDeleteButton?: boolean;
@ -150,19 +147,22 @@ export function V2BomItemEditorConfigPanel({
const currentTableName = screenTableName || propCurrentTableName;
const config: BomItemEditorConfig = useMemo(
() => ({
columns: [],
...propConfig,
dataSource: { ...propConfig?.dataSource },
features: {
showAddButton: true,
showDeleteButton: true,
inlineEdit: false,
showRowNumber: false,
maxDepth: 3,
...propConfig?.features,
},
}),
() => {
const { columns: propColumns, ...rest } = propConfig || {} as BomItemEditorConfig;
return {
...rest,
columns: propColumns || [],
dataSource: { ...propConfig?.dataSource },
features: {
showAddButton: true,
showDeleteButton: true,
inlineEdit: false,
showRowNumber: false,
maxDepth: 3,
...propConfig?.features,
},
};
},
[propConfig],
);
@ -178,6 +178,9 @@ export function V2BomItemEditorConfigPanel({
const [loadingRelations, setLoadingRelations] = useState(false);
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
const [expandedColumn, setExpandedColumn] = useState<string | null>(null);
const [featureOptionsOpen, setFeatureOptionsOpen] = useState(false);
const [columnSelectOpen, setColumnSelectOpen] = useState(false);
const [selectedColumnsOpen, setSelectedColumnsOpen] = useState(false);
// ─── 업데이트 헬퍼 (리피터 패턴) ───
const updateConfig = useCallback(
@ -472,7 +475,6 @@ export function V2BomItemEditorConfigPanel({
});
};
// FK 컬럼 제외한 입력 가능 컬럼
const inputableColumns = useMemo(() => {
const fkColumn = config.dataSource?.foreignKey;
return currentTableColumns.filter(
@ -495,9 +497,12 @@ export function V2BomItemEditorConfigPanel({
{/* ─── 기본 설정 탭 ─── */}
<TabsContent value="basic" className="mt-4 space-y-4">
{/* 저장 대상 테이블 (리피터 동일) */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
{/* 저장 대상 테이블 */}
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">BOM ?</span>
</div>
<div
className={cn(
@ -539,7 +544,7 @@ export function V2BomItemEditorConfigPanel({
</div>
</div>
{/* 테이블 Combobox (리피터 동일) */}
{/* 테이블 Combobox */}
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
<PopoverTrigger asChild>
<Button
@ -651,19 +656,18 @@ export function V2BomItemEditorConfigPanel({
</PopoverContent>
</Popover>
{/* FK 직접 입력 (연관 없는 테이블 선택 시) */}
{/* FK 직접 입력 */}
{config.useCustomTable &&
config.mainTableName &&
currentTableName &&
!relatedTables.some((r) => r.tableName === config.mainTableName) && (
<div className="space-y-2 rounded border border-amber-200 bg-amber-50 p-2">
<p className="text-[10px] text-amber-700">
({currentTableName}) . FK
.
({currentTableName}) . FK .
</p>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-[10px]">FK ( )</Label>
<p className="text-xs text-muted-foreground">FK ( )</p>
<Input
value={config.foreignKeyColumn || ""}
onChange={(e) => updateConfig({ foreignKeyColumn: e.target.value })}
@ -672,7 +676,7 @@ export function V2BomItemEditorConfigPanel({
/>
</div>
<div className="space-y-1">
<Label className="text-[10px]">PK ( )</Label>
<p className="text-xs text-muted-foreground">PK ( )</p>
<Input
value={config.foreignKeySourceColumn || "id"}
onChange={(e) => updateConfig({ foreignKeySourceColumn: e.target.value })}
@ -683,72 +687,85 @@ export function V2BomItemEditorConfigPanel({
</div>
</div>
)}
{/* 화면 메인 테이블 참고 정보 */}
{currentTableName && (
<div className="rounded-md border bg-background p-3">
<p className="text-xs text-muted-foreground"> </p>
<p className="mt-0.5 text-sm font-medium">{currentTableName}</p>
<p className="text-[10px] text-muted-foreground mt-0.5">
{currentTableColumns.length} / {entityColumns.length}
</p>
</div>
)}
</div>
<Separator />
{/* 트리 구조 설정 (BOM 전용) */}
<div className="space-y-2">
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4" />
<Label className="text-xs font-medium"> </Label>
<GitBranch className="h-4 w-4 text-primary" />
<span className="text-sm font-medium"> ?</span>
</div>
<p className="text-muted-foreground text-[10px]">
FK
<p className="text-[11px] text-muted-foreground">
FK -
</p>
{currentTableColumns.length > 0 ? (
<Select
value={config.parentKeyColumn || ""}
onValueChange={(value) => updateConfig({ parentKeyColumn: value })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="부모 키 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{currentTableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
<div className="flex items-center gap-2">
<span>{col.displayName}</span>
{col.displayName !== col.columnName && (
<span className="text-muted-foreground text-[10px]">
({col.columnName})
</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<div>
<p className="mb-1.5 text-xs text-muted-foreground"> </p>
<Select
value={config.parentKeyColumn || ""}
onValueChange={(value) => updateConfig({ parentKeyColumn: value })}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{currentTableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
<div className="flex items-center gap-2">
<span>{col.displayName}</span>
{col.displayName !== col.columnName && (
<span className="text-muted-foreground text-[10px]">
({col.columnName})
</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<div className="rounded border border-border bg-muted p-2">
<p className="text-[10px] text-muted-foreground">
{loadingColumns ? "로딩 중..." : "저장 테이블을 먼저 선택하세요"}
<div className="rounded-md border-2 border-dashed p-4 text-center">
<GitBranch className="mx-auto mb-2 h-8 w-8 opacity-30 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
{loadingColumns ? "컬럼 정보를 불러오고 있어요..." : "저장 테이블을 먼저 선택해주세요"}
</p>
</div>
)}
{/* 최대 깊이 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Input
type="number"
min={1}
max={10}
value={config.features?.maxDepth ?? 3}
onChange={(e) => updateFeatures("maxDepth", parseInt(e.target.value) || 3)}
className="h-7 w-20 text-xs"
className="h-7 w-[80px] text-xs"
/>
</div>
</div>
<Separator />
{/* 엔티티 선택 (리피터 모달 모드와 동일) */}
<div className="space-y-2">
<Label className="text-xs font-medium"> ( )</Label>
<p className="text-muted-foreground text-[10px]">
(FK만 )
{/* 엔티티 선택 (품목 참조) */}
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4 text-primary" />
<span className="text-sm font-medium"> ?</span>
</div>
<p className="text-[11px] text-muted-foreground">
FK만
</p>
{entityColumns.length > 0 ? (
@ -757,7 +774,7 @@ export function V2BomItemEditorConfigPanel({
onValueChange={handleEntityColumnSelect}
disabled={!targetTableForColumns}
>
<SelectTrigger className="h-8 text-xs">
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="엔티티 컬럼 선택" />
</SelectTrigger>
<SelectContent>
@ -774,308 +791,366 @@ export function V2BomItemEditorConfigPanel({
</SelectContent>
</Select>
) : (
<div className="rounded border border-border bg-muted p-2">
<p className="text-[10px] text-muted-foreground">
<div className="rounded-md border-2 border-dashed p-4 text-center">
<p className="text-sm text-muted-foreground">
{loadingColumns
? "로딩 중..."
? "컬럼 정보를 불러오고 있어요..."
: !targetTableForColumns
? "저장 테이블을 먼저 선택세요"
: "엔티티 타입 컬럼이 없습니다"}
? "저장 테이블을 먼저 선택해주세요"
: "엔티티 타입 컬럼이 없어요"}
</p>
</div>
)}
{config.dataSource?.sourceTable && (
<div className="space-y-1 rounded border border-emerald-200 bg-emerald-50 p-2">
<p className="text-xs font-medium text-emerald-700"> </p>
<div className="text-[10px] text-emerald-600">
<p> : {config.dataSource.sourceTable}</p>
<p> : {config.dataSource.foreignKey} (FK)</p>
</div>
<div className="rounded-md border bg-background p-3 space-y-1">
<p className="text-xs text-muted-foreground"> </p>
<p className="text-sm font-medium">{config.dataSource.sourceTable}</p>
<p className="text-[11px] text-muted-foreground">
{config.dataSource.foreignKey} FK로
</p>
</div>
)}
</div>
<Separator />
{/* 기능 옵션 */}
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center space-x-2">
<Checkbox
id="bom-showAddButton"
checked={config.features?.showAddButton ?? true}
onCheckedChange={(checked) => updateFeatures("showAddButton", !!checked)}
{/* 기능 옵션 - Collapsible + Badge */}
<Collapsible open={featureOptionsOpen} onOpenChange={setFeatureOptionsOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
<Badge
variant="secondary"
className="text-[10px] h-5"
>
{[
config.features?.showAddButton ?? true,
config.features?.showDeleteButton ?? true,
config.features?.inlineEdit ?? false,
config.features?.showRowNumber ?? false,
].filter(Boolean).length}
</Badge>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
featureOptionsOpen && "rotate-180",
)}
/>
<label htmlFor="bom-showAddButton" className="text-xs">
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="bom-showDeleteButton"
checked={config.features?.showDeleteButton ?? true}
onCheckedChange={(checked) => updateFeatures("showDeleteButton", !!checked)}
/>
<label htmlFor="bom-showDeleteButton" className="text-xs">
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="bom-inlineEdit"
checked={config.features?.inlineEdit ?? false}
onCheckedChange={(checked) => updateFeatures("inlineEdit", !!checked)}
/>
<label htmlFor="bom-inlineEdit" className="text-xs">
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="bom-showRowNumber"
checked={config.features?.showRowNumber ?? false}
onCheckedChange={(checked) => updateFeatures("showRowNumber", !!checked)}
/>
<label htmlFor="bom-showRowNumber" className="text-xs">
</label>
</div>
</div>
</div>
{/* 메인 화면 테이블 참고 */}
{currentTableName && (
<>
<Separator />
<div className="space-y-2">
<Label className="text-xs font-medium"> ()</Label>
<div className="rounded border border-border bg-muted p-2">
<p className="text-xs font-medium text-foreground">{currentTableName}</p>
<p className="text-[10px] text-muted-foreground">
{currentTableColumns.length} / {entityColumns.length}
</p>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-3 space-y-2">
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.features?.showAddButton ?? true}
onCheckedChange={(checked) => updateFeatures("showAddButton", checked)}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.features?.showDeleteButton ?? true}
onCheckedChange={(checked) => updateFeatures("showDeleteButton", checked)}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.features?.inlineEdit ?? false}
onCheckedChange={(checked) => updateFeatures("inlineEdit", checked)}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.features?.showRowNumber ?? false}
onCheckedChange={(checked) => updateFeatures("showRowNumber", checked)}
/>
</div>
</div>
</>
)}
</CollapsibleContent>
</Collapsible>
</TabsContent>
{/* ─── 컬럼 설정 탭 (리피터 동일 패턴) ─── */}
{/* ─── 컬럼 설정 탭 ─── */}
<TabsContent value="columns" className="mt-4 space-y-4">
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<p className="text-muted-foreground text-[10px]">
</p>
{/* 소스 테이블 컬럼 (표시용) */}
{config.dataSource?.sourceTable && (
<>
<div className="mb-1 mt-2 flex items-center gap-1 text-[10px] font-medium text-primary">
<Link2 className="h-3 w-3" />
({config.dataSource.sourceTable}) -
{/* 컬럼 선택 - Collapsible + Badge */}
<Collapsible open={columnSelectOpen} onOpenChange={setColumnSelectOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
<Badge variant="secondary" className="text-[10px] h-5">
{config.columns.length}
</Badge>
</div>
{loadingSourceColumns ? (
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
columnSelectOpen && "rotate-180",
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-3 space-y-3">
<p className="text-[11px] text-muted-foreground">
,
</p>
{/* 소스 테이블 컬럼 (표시용) */}
{config.dataSource?.sourceTable && (
<>
<div className="mb-1 flex items-center gap-1 text-[10px] font-medium text-primary">
<Link2 className="h-3 w-3" />
({config.dataSource.sourceTable}) -
</div>
{loadingSourceColumns ? (
<p className="text-muted-foreground py-2 text-xs"> ...</p>
) : sourceTableColumns.length === 0 ? (
<p className="text-muted-foreground py-2 text-xs"> </p>
) : (
<div className="max-h-28 space-y-0.5 overflow-y-auto rounded-md border border-primary/20 bg-primary/10/30 p-2">
{sourceTableColumns.map((column) => (
<div
key={`source-${column.columnName}`}
className={cn(
"flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-primary/10/50",
isSourceColumnSelected(column.columnName) && "bg-primary/10",
)}
onClick={() => toggleSourceDisplayColumn(column)}
>
<Checkbox
checked={isSourceColumnSelected(column.columnName)}
onCheckedChange={() => toggleSourceDisplayColumn(column)}
className="pointer-events-none h-3.5 w-3.5"
/>
<Link2 className="h-3 w-3 flex-shrink-0 text-primary" />
<span className="truncate text-xs">{column.displayName}</span>
<span className="ml-auto text-[10px] text-primary/80"></span>
</div>
))}
</div>
)}
</>
)}
{/* 저장 테이블 컬럼 (입력용) */}
<div className="mb-1 flex items-center gap-1 text-[10px] font-medium text-muted-foreground">
<Database className="h-3 w-3" />
({targetTableForColumns || "미선택"}) -
</div>
{loadingColumns ? (
<p className="text-muted-foreground py-2 text-xs"> ...</p>
) : sourceTableColumns.length === 0 ? (
<p className="text-muted-foreground py-2 text-xs"> </p>
) : inputableColumns.length === 0 ? (
<p className="text-muted-foreground py-2 text-xs"> </p>
) : (
<div className="max-h-28 space-y-0.5 overflow-y-auto rounded-md border border-primary/20 bg-primary/10/30 p-2">
{sourceTableColumns.map((column) => (
<div className="max-h-36 space-y-0.5 overflow-y-auto rounded-md border p-2">
{inputableColumns.map((column) => (
<div
key={`source-${column.columnName}`}
key={`input-${column.columnName}`}
className={cn(
"flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-primary/10/50",
isSourceColumnSelected(column.columnName) && "bg-primary/10",
"hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1",
isColumnAdded(column.columnName) && "bg-primary/10",
)}
onClick={() => toggleSourceDisplayColumn(column)}
onClick={() => toggleInputColumn(column)}
>
<Checkbox
checked={isSourceColumnSelected(column.columnName)}
onCheckedChange={() => toggleSourceDisplayColumn(column)}
checked={isColumnAdded(column.columnName)}
onCheckedChange={() => toggleInputColumn(column)}
className="pointer-events-none h-3.5 w-3.5"
/>
<Link2 className="h-3 w-3 flex-shrink-0 text-primary" />
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
<span className="truncate text-xs">{column.displayName}</span>
<span className="ml-auto text-[10px] text-primary/80"></span>
<span className="ml-auto text-[10px] text-muted-foreground/70">{column.inputType}</span>
</div>
))}
</div>
)}
</>
)}
{/* 저장 테이블 컬럼 (입력용) */}
<div className="mb-1 mt-3 flex items-center gap-1 text-[10px] font-medium text-muted-foreground">
<Database className="h-3 w-3" />
({targetTableForColumns || "미선택"}) -
</div>
{loadingColumns ? (
<p className="text-muted-foreground py-2 text-xs"> ...</p>
) : inputableColumns.length === 0 ? (
<p className="text-muted-foreground py-2 text-xs"> </p>
) : (
<div className="max-h-36 space-y-0.5 overflow-y-auto rounded-md border p-2">
{inputableColumns.map((column) => (
<div
key={`input-${column.columnName}`}
className={cn(
"hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1",
isColumnAdded(column.columnName) && "bg-primary/10",
)}
onClick={() => toggleInputColumn(column)}
>
<Checkbox
checked={isColumnAdded(column.columnName)}
onCheckedChange={() => toggleInputColumn(column)}
className="pointer-events-none h-3.5 w-3.5"
/>
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
<span className="truncate text-xs">{column.displayName}</span>
<span className="ml-auto text-[10px] text-muted-foreground/70">{column.inputType}</span>
</div>
))}
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
{/* 선택된 컬럼 상세 (리피터 동일 패턴) */}
{/* 선택된 컬럼 상세 - Collapsible + Badge */}
{config.columns.length > 0 && (
<>
<Separator />
<div className="space-y-2">
<Label className="text-xs font-medium">
({config.columns.length})
<span className="text-muted-foreground ml-2 font-normal"> </span>
</Label>
<div className="max-h-48 space-y-1 overflow-y-auto">
{config.columns.map((col, index) => (
<div key={col.key} className="space-y-1">
<div
className={cn(
"flex items-center gap-2 rounded-md border p-2",
col.isSourceDisplay
? "border-primary/20 bg-primary/10/50"
: "border-border bg-muted/30",
col.hidden && "opacity-50",
)}
draggable
onDragStart={(e) => e.dataTransfer.setData("columnIndex", String(index))}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const fromIndex = parseInt(e.dataTransfer.getData("columnIndex"), 10);
if (fromIndex !== index) {
const newColumns = [...config.columns];
const [movedCol] = newColumns.splice(fromIndex, 1);
newColumns.splice(index, 0, movedCol);
updateConfig({ columns: newColumns });
<Collapsible open={selectedColumnsOpen} onOpenChange={setSelectedColumnsOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<span className="text-sm font-medium"> </span>
<Badge variant="secondary" className="text-[10px] h-5">
{config.columns.length}
</Badge>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
selectedColumnsOpen && "rotate-180",
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-3 space-y-3">
<div className="flex items-center justify-between">
<span className="text-[11px] text-muted-foreground"> </span>
</div>
<div className="max-h-48 space-y-1 overflow-y-auto">
{config.columns.map((col, index) => (
<div key={col.key} className="space-y-1">
<div
className={cn(
"flex items-center gap-2 rounded-md border p-2",
col.isSourceDisplay
? "border-primary/20 bg-primary/10/50"
: "border-border bg-muted/30",
col.hidden && "opacity-50",
)}
draggable
onDragStart={(e) => e.dataTransfer.setData("columnIndex", String(index))}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const fromIndex = parseInt(e.dataTransfer.getData("columnIndex"), 10);
if (fromIndex !== index) {
const newColumns = [...config.columns];
const [movedCol] = newColumns.splice(fromIndex, 1);
newColumns.splice(index, 0, movedCol);
updateConfig({ columns: newColumns });
}
}}
>
<GripVertical className="text-muted-foreground h-3 w-3 cursor-grab flex-shrink-0" />
{!col.isSourceDisplay && (
<button
type="button"
onClick={() =>
setExpandedColumn(expandedColumn === col.key ? null : col.key)
}
className="rounded p-0.5 hover:bg-muted/80"
>
{expandedColumn === col.key ? (
<ChevronDown className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
)}
</button>
)}
{col.isSourceDisplay ? (
<Link2 className="h-3 w-3 flex-shrink-0 text-primary" />
) : (
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
)}
<Input
value={col.title}
onChange={(e) => updateColumnProp(col.key, "title", e.target.value)}
placeholder="제목"
className="h-6 flex-1 text-xs"
/>
{!col.isSourceDisplay && (
<button
type="button"
onClick={() => updateColumnProp(col.key, "hidden", !col.hidden)}
className={cn(
"rounded p-1 hover:bg-muted/80",
col.hidden ? "text-muted-foreground/70" : "text-muted-foreground",
)}
title={col.hidden ? "히든 (저장만 됨)" : "표시됨"}
>
{col.hidden ? (
<EyeOff className="h-3 w-3" />
) : (
<Eye className="h-3 w-3" />
)}
</button>
)}
{!col.isSourceDisplay && (
<button
type="button"
onClick={() => updateColumnProp(col.key, "editable", !(col.editable ?? true))}
className={cn(
"shrink-0 rounded px-1.5 py-0.5 text-[9px] font-medium transition-colors",
(col.editable ?? true)
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400"
: "bg-muted text-muted-foreground dark:bg-foreground/90 dark:text-muted-foreground/70"
)}
title={(col.editable ?? true) ? "편집 가능" : "읽기 전용"}
>
{(col.editable ?? true) ? "편집" : "읽기"}
</button>
)}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
if (col.isSourceDisplay) {
toggleSourceDisplayColumn({
columnName: col.key,
displayName: col.title,
});
} else {
toggleInputColumn({ columnName: col.key, displayName: col.title });
}
}}
className="text-destructive h-6 w-6 p-0"
>
<GripVertical className="text-muted-foreground h-3 w-3 cursor-grab flex-shrink-0" />
{!col.isSourceDisplay && (
<button
type="button"
onClick={() =>
setExpandedColumn(expandedColumn === col.key ? null : col.key)
}
className="rounded p-0.5 hover:bg-muted/80"
>
{expandedColumn === col.key ? (
<ChevronDown className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
)}
</button>
)}
{col.isSourceDisplay ? (
<Link2
className="h-3 w-3 flex-shrink-0 text-primary"
title="소스 표시 (읽기 전용)"
/>
) : (
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
)}
<Input
value={col.title}
onChange={(e) => updateColumnProp(col.key, "title", e.target.value)}
placeholder="제목"
className="h-6 flex-1 text-xs"
/>
{!col.isSourceDisplay && (
<button
type="button"
onClick={() => updateColumnProp(col.key, "hidden", !col.hidden)}
className={cn(
"rounded p-1 hover:bg-muted/80",
col.hidden ? "text-muted-foreground/70" : "text-muted-foreground",
)}
title={col.hidden ? "히든 (저장만 됨)" : "표시됨"}
>
{col.hidden ? (
<EyeOff className="h-3 w-3" />
) : (
<Eye className="h-3 w-3" />
)}
</button>
)}
{!col.isSourceDisplay && (
<Checkbox
checked={col.editable ?? true}
onCheckedChange={(checked) =>
updateColumnProp(col.key, "editable", !!checked)
}
title="편집 가능"
/>
)}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
if (col.isSourceDisplay) {
toggleSourceDisplayColumn({
columnName: col.key,
displayName: col.title,
});
} else {
toggleInputColumn({ columnName: col.key, displayName: col.title });
}
}}
className="text-destructive h-6 w-6 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 확장 상세 */}
{!col.isSourceDisplay && expandedColumn === col.key && (
<div className="ml-6 space-y-2 rounded-md border border-dashed border-input bg-muted p-2">
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Input
value={col.width || "auto"}
onChange={(e) => updateColumnProp(col.key, "width", e.target.value)}
placeholder="auto, 100px, 20%"
className="h-6 text-xs"
/>
</div>
</div>
)}
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
{/* 확장 상세 */}
{!col.isSourceDisplay && expandedColumn === col.key && (
<div className="ml-6 space-y-2 rounded-md border border-dashed border-input bg-muted p-2">
<div className="space-y-1">
<p className="text-[10px] text-muted-foreground"> </p>
<Input
value={col.width || "auto"}
onChange={(e) => updateColumnProp(col.key, "width", e.target.value)}
placeholder="auto, 100px, 20%"
className="h-6 text-xs"
/>
</div>
</div>
)}
</div>
))}
</div>
</div>
</div>
</>
</CollapsibleContent>
</Collapsible>
)}
</TabsContent>
</Tabs>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,789 @@
"use client";
/**
* V2CardDisplay
* UX: 테이블 -> -> -> ()
*/
import React, { useState, useEffect, useMemo } from "react";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Badge } from "@/components/ui/badge";
import {
Settings,
ChevronDown,
Database,
ChevronsUpDown,
Check,
Plus,
Trash2,
Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { tableManagementApi } from "@/lib/api/tableManagement";
// ─── 한 행당 카드 수 카드 정의 ───
const CARDS_PER_ROW_OPTIONS = [
{ value: 1, label: "1개" },
{ value: 2, label: "2개" },
{ value: 3, label: "3개" },
{ value: 4, label: "4개" },
{ value: 5, label: "5개" },
{ value: 6, label: "6개" },
] as const;
interface EntityJoinColumn {
tableName: string;
columnName: string;
columnLabel: string;
dataType: string;
joinAlias: string;
suggestedLabel: string;
}
interface JoinTable {
tableName: string;
currentDisplayColumn: string;
joinConfig?: { sourceColumn: string };
availableColumns: Array<{
columnName: string;
columnLabel: string;
dataType: string;
description?: string;
}>;
}
interface V2CardDisplayConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
screenTableName?: string;
tableColumns?: any[];
}
export const V2CardDisplayConfigPanel: React.FC<V2CardDisplayConfigPanelProps> = ({
config,
onChange,
screenTableName,
tableColumns = [],
}) => {
const [advancedOpen, setAdvancedOpen] = useState(false);
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
const [allTables, setAllTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
const [loadingTables, setLoadingTables] = useState(false);
const [availableColumns, setAvailableColumns] = useState<any[]>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
const [entityJoinColumns, setEntityJoinColumns] = useState<{
availableColumns: EntityJoinColumn[];
joinTables: JoinTable[];
}>({ availableColumns: [], joinTables: [] });
const [loadingEntityJoins, setLoadingEntityJoins] = useState(false);
const targetTableName = useMemo(() => {
if (config.useCustomTable && config.customTableName) {
return config.customTableName;
}
return config.tableName || screenTableName;
}, [config.useCustomTable, config.customTableName, config.tableName, screenTableName]);
const updateConfig = (field: string, value: any) => {
const newConfig = { ...config, [field]: value };
onChange(newConfig);
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("componentConfigChanged", { detail: { config: newConfig } })
);
}
};
const updateNestedConfig = (path: string, value: any) => {
const keys = path.split(".");
const newConfig = { ...config };
let current = newConfig;
for (let i = 0; i < keys.length - 1; i++) {
if (!current[keys[i]]) current[keys[i]] = {};
current[keys[i]] = { ...current[keys[i]] };
current = current[keys[i]];
}
current[keys[keys.length - 1]] = value;
onChange(newConfig);
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("componentConfigChanged", { detail: { config: newConfig } })
);
}
};
// 테이블 목록 로드
useEffect(() => {
const loadAllTables = async () => {
setLoadingTables(true);
try {
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
setAllTables(
response.data.map((t: any) => ({
tableName: t.tableName || t.table_name,
displayName: t.tableLabel || t.displayName || t.tableName || t.table_name,
}))
);
}
} catch {
/* 무시 */
} finally {
setLoadingTables(false);
}
};
loadAllTables();
}, []);
// 컬럼 로드
useEffect(() => {
const loadColumns = async () => {
if (!targetTableName) {
setAvailableColumns([]);
return;
}
if (!config.useCustomTable && tableColumns && tableColumns.length > 0) {
setAvailableColumns(tableColumns);
return;
}
setLoadingColumns(true);
try {
const result = await tableManagementApi.getColumnList(targetTableName);
if (result.success && result.data?.columns) {
setAvailableColumns(
result.data.columns.map((col: any) => ({
columnName: col.columnName,
columnLabel: col.displayName || col.columnLabel || col.columnName,
dataType: col.dataType,
}))
);
}
} catch {
setAvailableColumns([]);
} finally {
setLoadingColumns(false);
}
};
loadColumns();
}, [targetTableName, config.useCustomTable, tableColumns]);
// 엔티티 조인 컬럼 로드
useEffect(() => {
const fetchEntityJoinColumns = async () => {
if (!targetTableName) {
setEntityJoinColumns({ availableColumns: [], joinTables: [] });
return;
}
setLoadingEntityJoins(true);
try {
const result = await entityJoinApi.getEntityJoinColumns(targetTableName);
setEntityJoinColumns({
availableColumns: result.availableColumns || [],
joinTables: result.joinTables || [],
});
} catch {
setEntityJoinColumns({ availableColumns: [], joinTables: [] });
} finally {
setLoadingEntityJoins(false);
}
};
fetchEntityJoinColumns();
}, [targetTableName]);
const handleTableSelect = (selectedTable: string, isScreenTable: boolean) => {
const newConfig = isScreenTable
? { ...config, useCustomTable: false, customTableName: undefined, tableName: selectedTable, columnMapping: { displayColumns: [] } }
: { ...config, useCustomTable: true, customTableName: selectedTable, tableName: selectedTable, columnMapping: { displayColumns: [] } };
onChange(newConfig);
setTableComboboxOpen(false);
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("componentConfigChanged", { detail: { config: newConfig } })
);
}
};
const getSelectedTableDisplay = () => {
if (!targetTableName) return "테이블을 선택하세요";
const found = allTables.find((t) => t.tableName === targetTableName);
return found?.displayName || targetTableName;
};
const handleColumnSelect = (path: string, columnName: string) => {
const joinColumn = entityJoinColumns.availableColumns.find(
(col) => col.joinAlias === columnName
);
if (joinColumn) {
const joinColumnsConfig = config.joinColumns || [];
const exists = joinColumnsConfig.find((jc: any) => jc.columnName === columnName);
if (!exists) {
const joinTableInfo = entityJoinColumns.joinTables?.find(
(jt) => jt.tableName === joinColumn.tableName
);
const newJoinColumnConfig = {
columnName: joinColumn.joinAlias,
label: joinColumn.suggestedLabel || joinColumn.columnLabel,
sourceColumn: joinTableInfo?.joinConfig?.sourceColumn || "",
referenceTable: joinColumn.tableName,
referenceColumn: joinColumn.columnName,
isJoinColumn: true,
};
const newConfig = {
...config,
columnMapping: { ...config.columnMapping, [path.split(".")[1]]: columnName },
joinColumns: [...joinColumnsConfig, newJoinColumnConfig],
};
onChange(newConfig);
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("componentConfigChanged", { detail: { config: newConfig } })
);
}
return;
}
}
updateNestedConfig(path, columnName);
};
// 표시 컬럼 관리
const addDisplayColumn = () => {
const current = config.columnMapping?.displayColumns || [];
updateNestedConfig("columnMapping.displayColumns", [...current, ""]);
};
const removeDisplayColumn = (index: number) => {
const current = [...(config.columnMapping?.displayColumns || [])];
current.splice(index, 1);
updateNestedConfig("columnMapping.displayColumns", current);
};
const updateDisplayColumn = (index: number, value: string) => {
const current = [...(config.columnMapping?.displayColumns || [])];
current[index] = value;
const joinColumn = entityJoinColumns.availableColumns.find(
(col) => col.joinAlias === value
);
if (joinColumn) {
const joinColumnsConfig = config.joinColumns || [];
const exists = joinColumnsConfig.find((jc: any) => jc.columnName === value);
if (!exists) {
const joinTableInfo = entityJoinColumns.joinTables?.find(
(jt) => jt.tableName === joinColumn.tableName
);
const newConfig = {
...config,
columnMapping: { ...config.columnMapping, displayColumns: current },
joinColumns: [
...joinColumnsConfig,
{
columnName: joinColumn.joinAlias,
label: joinColumn.suggestedLabel || joinColumn.columnLabel,
sourceColumn: joinTableInfo?.joinConfig?.sourceColumn || "",
referenceTable: joinColumn.tableName,
referenceColumn: joinColumn.columnName,
isJoinColumn: true,
},
],
};
onChange(newConfig);
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("componentConfigChanged", { detail: { config: newConfig } })
);
}
return;
}
}
updateNestedConfig("columnMapping.displayColumns", current);
};
// 테이블별 조인 컬럼 그룹화
const joinColumnsByTable: Record<string, EntityJoinColumn[]> = {};
entityJoinColumns.availableColumns.forEach((col) => {
if (!joinColumnsByTable[col.tableName]) joinColumnsByTable[col.tableName] = [];
joinColumnsByTable[col.tableName].push(col);
});
const currentTableColumns = config.useCustomTable
? availableColumns
: tableColumns.length > 0
? tableColumns
: availableColumns;
// 컬럼 선택 Select 렌더링
const renderColumnSelect = (
value: string,
onChangeHandler: (value: string) => void,
placeholder: string = "컬럼 선택"
) => (
<Select
value={value || "__none__"}
onValueChange={(val) => onChangeHandler(val === "__none__" ? "" : val)}
>
<SelectTrigger className="h-7 text-[11px]">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-[11px] text-muted-foreground">
</SelectItem>
{currentTableColumns.length > 0 && (
<SelectGroup>
<SelectLabel className="text-[10px] font-semibold text-muted-foreground">
</SelectLabel>
{currentTableColumns.map((column: any) => (
<SelectItem key={column.columnName} value={column.columnName} className="text-[11px]">
{column.columnLabel || column.columnName}
</SelectItem>
))}
</SelectGroup>
)}
{Object.entries(joinColumnsByTable).map(([tableName, columns]) => (
<SelectGroup key={tableName}>
<SelectLabel className="text-[10px] font-semibold text-primary">
{tableName} ()
</SelectLabel>
{columns.map((col) => (
<SelectItem key={col.joinAlias} value={col.joinAlias} className="text-[11px]">
{col.suggestedLabel || col.columnLabel}
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
);
return (
<div className="space-y-4">
{/* ─── 1단계: 테이블 선택 ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"> </p>
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableComboboxOpen}
className="h-8 w-full justify-between text-xs"
disabled={loadingTables}
>
<div className="flex items-center gap-2 truncate">
<Database className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">
{loadingTables ? "로딩 중..." : getSelectedTableDisplay()}
</span>
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-3 text-center text-xs text-muted-foreground">
</CommandEmpty>
{screenTableName && (
<CommandGroup heading="기본 (화면 테이블)">
<CommandItem
value={screenTableName}
onSelect={() => handleTableSelect(screenTableName, true)}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-4 w-4",
targetTableName === screenTableName && !config.useCustomTable
? "opacity-100"
: "opacity-0"
)}
/>
<Database className="mr-2 h-3.5 w-3.5 text-primary" />
{allTables.find((t) => t.tableName === screenTableName)?.displayName ||
screenTableName}
</CommandItem>
</CommandGroup>
)}
<CommandGroup heading="전체 테이블">
{allTables
.filter((t) => t.tableName !== screenTableName)
.map((table) => (
<CommandItem
key={table.tableName}
value={table.tableName}
onSelect={() => handleTableSelect(table.tableName, false)}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-4 w-4",
config.useCustomTable && targetTableName === table.tableName
? "opacity-100"
: "opacity-0"
)}
/>
<Database className="mr-2 h-3.5 w-3.5 text-muted-foreground" />
<span className="truncate">{table.displayName}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{config.useCustomTable && (
<p className="text-[10px] text-muted-foreground">
</p>
)}
</div>
{/* ─── 2단계: 컬럼 매핑 ─── */}
{(currentTableColumns.length > 0 || loadingColumns) && (
<div className="space-y-3">
<p className="text-sm font-medium truncate"> </p>
{(loadingEntityJoins || loadingColumns) && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
{loadingColumns ? "컬럼 로딩 중..." : "조인 컬럼 로딩 중..."}
</div>
)}
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground truncate"></span>
<div className="w-[180px]">
{renderColumnSelect(
config.columnMapping?.titleColumn || "",
(value) => handleColumnSelect("columnMapping.titleColumn", value)
)}
</div>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground truncate"></span>
<div className="w-[180px]">
{renderColumnSelect(
config.columnMapping?.subtitleColumn || "",
(value) => handleColumnSelect("columnMapping.subtitleColumn", value)
)}
</div>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground truncate"></span>
<div className="w-[180px]">
{renderColumnSelect(
config.columnMapping?.descriptionColumn || "",
(value) => handleColumnSelect("columnMapping.descriptionColumn", value)
)}
</div>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground truncate"></span>
<div className="w-[180px]">
{renderColumnSelect(
config.columnMapping?.imageColumn || "",
(value) => handleColumnSelect("columnMapping.imageColumn", value)
)}
</div>
</div>
</div>
{/* 표시 컬럼 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium truncate"> </span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={addDisplayColumn}
className="h-6 px-2 text-xs"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{(config.columnMapping?.displayColumns || []).length > 0 ? (
<div className="space-y-1.5">
{(config.columnMapping?.displayColumns || []).map(
(column: string, index: number) => (
<div key={index} className="flex items-center gap-2">
<div className="flex-1">
{renderColumnSelect(column, (value) =>
updateDisplayColumn(index, value)
)}
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeDisplayColumn(index)}
className="h-7 w-7 shrink-0 p-0 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
)
)}
</div>
) : (
<div className="rounded-md border border-dashed border-muted-foreground/30 py-3 text-center text-xs text-muted-foreground">
</div>
)}
</div>
</div>
)}
{/* ─── 3단계: 카드 레이아웃 ─── */}
<div className="space-y-3">
<p className="text-sm font-medium truncate"> </p>
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground truncate"> </span>
<Select
value={String(config.cardsPerRow || 3)}
onValueChange={(v) => updateConfig("cardsPerRow", parseInt(v))}
>
<SelectTrigger className="h-7 w-[100px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CARDS_PER_ROW_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={String(opt.value)}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground truncate"> (px)</span>
<Input
type="number"
min={0}
max={50}
value={config.cardSpacing ?? 16}
onChange={(e) => updateConfig("cardSpacing", parseInt(e.target.value))}
className="h-7 w-[100px] text-xs"
/>
</div>
</div>
</div>
{/* ─── 4단계: 표시 요소 토글 ─── */}
<div className="space-y-3">
<p className="text-sm font-medium truncate"> </p>
<div className="rounded-lg border bg-muted/30 p-4 space-y-2">
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"></p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.cardStyle?.showTitle ?? true}
onCheckedChange={(checked) => updateNestedConfig("cardStyle.showTitle", checked)}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"></p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.cardStyle?.showSubtitle ?? true}
onCheckedChange={(checked) =>
updateNestedConfig("cardStyle.showSubtitle", checked)
}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"></p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.cardStyle?.showDescription ?? true}
onCheckedChange={(checked) =>
updateNestedConfig("cardStyle.showDescription", checked)
}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"></p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.cardStyle?.showImage ?? false}
onCheckedChange={(checked) =>
updateNestedConfig("cardStyle.showImage", checked)
}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
, ,
</p>
</div>
<Switch
checked={config.cardStyle?.showActions ?? true}
onCheckedChange={(checked) =>
updateNestedConfig("cardStyle.showActions", checked)
}
/>
</div>
{(config.cardStyle?.showActions ?? true) && (
<div className="ml-4 border-l-2 border-primary/20 pl-3 space-y-2">
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"></span>
<Switch
checked={config.cardStyle?.showViewButton ?? true}
onCheckedChange={(checked) =>
updateNestedConfig("cardStyle.showViewButton", checked)
}
/>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"></span>
<Switch
checked={config.cardStyle?.showEditButton ?? true}
onCheckedChange={(checked) =>
updateNestedConfig("cardStyle.showEditButton", checked)
}
/>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"></span>
<Switch
checked={config.cardStyle?.showDeleteButton ?? false}
onCheckedChange={(checked) =>
updateNestedConfig("cardStyle.showDeleteButton", checked)
}
/>
</div>
</div>
)}
</div>
</div>
{/* ─── 5단계: 고급 설정 (기본 접혀있음) ─── */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium truncate"> </span>
<Badge variant="secondary" className="text-[10px] h-5">3</Badge>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
advancedOpen && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground truncate"> </span>
<Input
type="number"
min={10}
max={500}
value={config.cardStyle?.maxDescriptionLength ?? 100}
onChange={(e) =>
updateNestedConfig(
"cardStyle.maxDescriptionLength",
parseInt(e.target.value)
)
}
className="h-7 w-[100px] text-xs"
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"></p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.disabled || false}
onCheckedChange={(checked) => updateConfig("disabled", checked)}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.readonly || false}
onCheckedChange={(checked) => updateConfig("readonly", checked)}
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
V2CardDisplayConfigPanel.displayName = "V2CardDisplayConfigPanel";
export default V2CardDisplayConfigPanel;

View File

@ -0,0 +1,198 @@
"use client";
/**
* V2 ( )
* UX: -> -> ()
*/
import React, { useState } from "react";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Settings, ChevronDown, FolderTree } from "lucide-react";
import { cn } from "@/lib/utils";
import type { V2CategoryManagerConfig, ViewMode } from "@/lib/registry/components/v2-category-manager/types";
import { defaultV2CategoryManagerConfig } from "@/lib/registry/components/v2-category-manager/types";
interface V2CategoryManagerConfigPanelProps {
config: Partial<V2CategoryManagerConfig>;
onChange: (config: Partial<V2CategoryManagerConfig>) => void;
}
export const V2CategoryManagerConfigPanel: React.FC<V2CategoryManagerConfigPanelProps> = ({
config: externalConfig,
onChange,
}) => {
const [layoutOpen, setLayoutOpen] = useState(false);
const config: V2CategoryManagerConfig = {
...defaultV2CategoryManagerConfig,
...externalConfig,
};
const handleChange = <K extends keyof V2CategoryManagerConfig>(key: K, value: V2CategoryManagerConfig[K]) => {
const newConfig = { ...config, [key]: value };
onChange(newConfig);
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("componentConfigChanged", { detail: { config: newConfig } })
);
}
};
return (
<div className="space-y-4">
{/* ─── 1단계: 뷰 모드 설정 ─── */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<FolderTree className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium"> </p>
</div>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Select
value={config.viewMode}
onValueChange={(value: ViewMode) => handleChange("viewMode", value)}
>
<SelectTrigger className="h-7 w-[120px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="tree"> </SelectItem>
<SelectItem value="list"> </SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">/ </p>
</div>
<Switch
checked={config.showViewModeToggle}
onCheckedChange={(checked) => handleChange("showViewModeToggle", checked)}
/>
</div>
</div>
{/* ─── 2단계: 트리 설정 ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center justify-between py-1">
<div>
<span className="text-xs text-muted-foreground"> </span>
<p className="text-[10px] text-muted-foreground mt-0.5"> </p>
</div>
<Select
value={String(config.defaultExpandLevel)}
onValueChange={(value) => handleChange("defaultExpandLevel", Number(value))}
>
<SelectTrigger className="h-7 w-[120px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 ()</SelectItem>
<SelectItem value="2">2 ()</SelectItem>
<SelectItem value="3">3 ( )</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.showInactiveItems}
onCheckedChange={(checked) => handleChange("showInactiveItems", checked)}
/>
</div>
</div>
{/* ─── 3단계: 레이아웃 (Collapsible) ─── */}
<Collapsible open={layoutOpen} onOpenChange={setLayoutOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
layoutOpen && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.showColumnList}
onCheckedChange={(checked) => handleChange("showColumnList", checked)}
/>
</div>
{config.showColumnList && (
<div className="ml-1 border-l-2 border-primary/20 pl-3">
<div className="flex items-center justify-between py-1">
<div>
<span className="text-xs text-muted-foreground"> (%)</span>
<p className="text-[10px] text-muted-foreground mt-0.5">10~40% </p>
</div>
<Input
type="number"
min={10}
max={40}
value={config.leftPanelWidth}
onChange={(e) => handleChange("leftPanelWidth", Number(e.target.value))}
className="h-7 w-[80px] text-xs"
/>
</div>
</div>
)}
<div className="flex items-center justify-between py-1">
<div>
<span className="text-xs text-muted-foreground"></span>
<p className="text-[10px] text-muted-foreground mt-0.5">px % (: 100%, 600)</p>
</div>
<Input
value={String(config.height)}
onChange={(e) => {
const v = e.target.value;
handleChange("height", isNaN(Number(v)) ? v : Number(v));
}}
placeholder="100%"
className="h-7 w-[100px] text-xs"
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
V2CategoryManagerConfigPanel.displayName = "V2CategoryManagerConfigPanel";
export default V2CategoryManagerConfigPanel;

View File

@ -2,15 +2,70 @@
/**
* V2Date
* .
* UX: 날짜 -> -> ()
*/
import React from "react";
import React, { useState, useMemo } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Calendar, Clock, CalendarClock, Settings, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
// ─── 날짜 타입 카드 정의 ───
const DATE_TYPE_CARDS = [
{
value: "date",
icon: Calendar,
title: "날짜",
description: "연/월/일을 선택해요",
},
{
value: "time",
icon: Clock,
title: "시간",
description: "시/분을 선택해요",
},
{
value: "datetime",
icon: CalendarClock,
title: "날짜+시간",
description: "날짜와 시간을 함께 선택해요",
},
] as const;
// ─── 날짜 타입별 표시 형식 옵션 ───
const FORMAT_OPTIONS: Record<string, { value: string; label: string }[]> = {
date: [
{ value: "YYYY-MM-DD", label: "YYYY-MM-DD" },
{ value: "YYYY/MM/DD", label: "YYYY/MM/DD" },
{ value: "DD/MM/YYYY", label: "DD/MM/YYYY" },
{ value: "MM/DD/YYYY", label: "MM/DD/YYYY" },
{ value: "YYYY년 MM월 DD일", label: "YYYY년 MM월 DD일" },
],
time: [
{ value: "HH:mm", label: "HH:mm" },
{ value: "HH:mm:ss", label: "HH:mm:ss" },
],
datetime: [
{ value: "YYYY-MM-DD HH:mm", label: "YYYY-MM-DD HH:mm" },
{ value: "YYYY-MM-DD HH:mm:ss", label: "YYYY-MM-DD HH:mm:ss" },
{ value: "YYYY/MM/DD HH:mm", label: "YYYY/MM/DD HH:mm" },
{ value: "YYYY년 MM월 DD일", label: "YYYY년 MM월 DD일" },
],
};
interface V2DateConfigPanelProps {
config: Record<string, any>;
@ -21,137 +76,183 @@ export const V2DateConfigPanel: React.FC<V2DateConfigPanelProps> = ({
config,
onChange,
}) => {
// 설정 업데이트 핸들러
const [advancedOpen, setAdvancedOpen] = useState(false);
const updateConfig = (field: string, value: any) => {
onChange({ ...config, [field]: value });
};
const currentDateType = config.dateType || config.type || "date";
const showTimeOptions = currentDateType === "datetime" || currentDateType === "time";
const formatOptions = useMemo(() => {
return FORMAT_OPTIONS[currentDateType] || FORMAT_OPTIONS.date;
}, [currentDateType]);
return (
<div className="space-y-4">
{/* 날짜 타입 */}
{/* ─── 1단계: 날짜 타입 선택 (카드) ─── */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.dateType || config.type || "date"}
onValueChange={(value) => updateConfig("dateType", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="타입 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="date"></SelectItem>
<SelectItem value="time"></SelectItem>
<SelectItem value="datetime">+</SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
{/* 플레이스홀더 */}
<div className="space-y-2">
<Label className="text-xs font-medium"></Label>
<Input
value={config.placeholder || ""}
onChange={(e) => updateConfig("placeholder", e.target.value)}
placeholder="날짜 선택"
className="h-8 text-xs"
/>
<p className="text-muted-foreground text-[10px]"> </p>
</div>
<Separator />
{/* 표시 형식 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.format || "YYYY-MM-DD"}
onValueChange={(value) => updateConfig("format", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="형식 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="YYYY-MM-DD">YYYY-MM-DD</SelectItem>
<SelectItem value="YYYY/MM/DD">YYYY/MM/DD</SelectItem>
<SelectItem value="DD/MM/YYYY">DD/MM/YYYY</SelectItem>
<SelectItem value="MM/DD/YYYY">MM/DD/YYYY</SelectItem>
<SelectItem value="YYYY년 MM월 DD일">YYYY년 MM월 DD일</SelectItem>
{(config.dateType === "time" || config.dateType === "datetime") && (
<>
<SelectItem value="HH:mm">HH:mm</SelectItem>
<SelectItem value="HH:mm:ss">HH:mm:ss</SelectItem>
<SelectItem value="YYYY-MM-DD HH:mm">YYYY-MM-DD HH:mm</SelectItem>
<SelectItem value="YYYY-MM-DD HH:mm:ss">YYYY-MM-DD HH:mm:ss</SelectItem>
</>
)}
</SelectContent>
</Select>
</div>
<Separator />
{/* 날짜 범위 제한 */}
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Input
type="date"
value={config.minDate || ""}
onChange={(e) => updateConfig("minDate", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Input
type="date"
value={config.maxDate || ""}
onChange={(e) => updateConfig("maxDate", e.target.value)}
className="h-8 text-xs"
/>
</div>
<p className="text-sm font-medium"> ?</p>
<div className="grid grid-cols-3 gap-2">
{DATE_TYPE_CARDS.map((card) => {
const Icon = card.icon;
const isSelected = currentDateType === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateConfig("dateType", card.value)}
className={cn(
"flex flex-col items-center justify-center rounded-lg border p-3 text-center transition-all min-h-[80px]",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50",
)}
>
<Icon className="h-5 w-5 mb-1.5 text-primary" />
<span className="text-xs font-medium leading-tight">
{card.title}
</span>
<span className="text-[10px] text-muted-foreground leading-tight mt-0.5">
{card.description}
</span>
</button>
);
})}
</div>
</div>
<Separator />
{/* ─── 2단계: 표시 설정 ─── */}
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<span className="text-sm font-medium"> </span>
{/* 추가 옵션 */}
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="flex items-center space-x-2">
<Checkbox
id="range"
<div className="flex items-center justify-between py-1.5">
<span className="text-xs text-muted-foreground"> </span>
<Select
value={config.format || formatOptions[0]?.value || "YYYY-MM-DD"}
onValueChange={(value) => updateConfig("format", value)}
>
<SelectTrigger className="h-8 w-[180px] text-sm">
<SelectValue placeholder="형식 선택" />
</SelectTrigger>
<SelectContent>
{formatOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between py-1.5">
<span className="text-xs text-muted-foreground"> </span>
<Input
value={config.placeholder || ""}
onChange={(e) => updateConfig("placeholder", e.target.value)}
placeholder="날짜 선택"
className="h-8 w-[180px] text-sm"
/>
</div>
</div>
{/* ─── 3단계: 옵션 (Switch + 설명) ─── */}
<div className="space-y-2">
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
~
</p>
</div>
<Switch
checked={config.range || false}
onCheckedChange={(checked) => updateConfig("range", checked)}
/>
<label htmlFor="range" className="text-xs"> (~)</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showToday"
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.showToday !== false}
onCheckedChange={(checked) => updateConfig("showToday", checked)}
/>
<label htmlFor="showToday" className="text-xs"> </label>
</div>
{(config.dateType === "datetime" || config.dateType === "time") && (
<div className="flex items-center space-x-2">
<Checkbox
id="showSeconds"
{showTimeOptions && (
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
:
</p>
</div>
<Switch
checked={config.showSeconds || false}
onCheckedChange={(checked) => updateConfig("showSeconds", checked)}
/>
<label htmlFor="showSeconds" className="text-xs"> </label>
</div>
)}
</div>
{/* ─── 4단계: 고급 설정 (기본 접혀있음) ─── */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
advancedOpen && "rotate-180",
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
<p className="text-xs text-muted-foreground">
</p>
<div className="flex gap-2">
<div className="flex-1">
<Label className="text-xs text-muted-foreground"> </Label>
<Input
type="date"
value={config.minDate || ""}
onChange={(e) => updateConfig("minDate", e.target.value)}
className="h-8 w-full text-sm"
/>
</div>
<div className="flex-1">
<Label className="text-xs text-muted-foreground"> </Label>
<Input
type="date"
value={config.maxDate || ""}
onChange={(e) => updateConfig("maxDate", e.target.value)}
className="h-8 w-full text-sm"
/>
</div>
</div>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
@ -159,5 +260,3 @@ export const V2DateConfigPanel: React.FC<V2DateConfigPanelProps> = ({
V2DateConfigPanel.displayName = "V2DateConfigPanel";
export default V2DateConfigPanel;

View File

@ -0,0 +1,236 @@
"use client";
/**
* V2DividerLine
* UX: 구분선 -> -> ()
*/
import React, { useState } from "react";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Settings, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const THICKNESS_CARDS = [
{ value: "1px", label: "얇게", size: "1px" },
{ value: "2px", label: "보통", size: "2px" },
{ value: "4px", label: "두껍게", size: "4px" },
] as const;
const COLOR_CARDS = [
{ value: "#d1d5db", label: "기본", description: "연한 회색" },
{ value: "#9ca3af", label: "진하게", description: "중간 회색" },
{ value: "#3b82f6", label: "강조", description: "파란색" },
] as const;
interface V2DividerLineConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
}
export const V2DividerLineConfigPanel: React.FC<V2DividerLineConfigPanelProps> = ({
config,
onChange,
}) => {
const [advancedOpen, setAdvancedOpen] = useState(false);
const updateConfig = (field: string, value: any) => {
const newConfig = { ...config, [field]: value };
onChange(newConfig);
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("componentConfigChanged", {
detail: { config: newConfig },
})
);
}
};
return (
<div className="space-y-4">
{/* ─── 1단계: 선 두께 카드 선택 ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"> </p>
<div className="grid grid-cols-3 gap-2">
{THICKNESS_CARDS.map((card) => {
const isSelected = (config.thickness || "1px") === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateConfig("thickness", card.value)}
className={cn(
"flex flex-col items-center justify-center rounded-lg border p-2.5 text-center transition-all min-h-[56px]",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<div
className="mb-1.5 w-full rounded-full"
style={{
height: card.size,
backgroundColor: config.color || "#d1d5db",
}}
/>
<span className="text-xs font-medium">{card.label}</span>
</button>
);
})}
</div>
</div>
{/* ─── 2단계: 선 색상 카드 선택 ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"> </p>
<div className="grid grid-cols-3 gap-2">
{COLOR_CARDS.map((card) => {
const isSelected = (config.color || "#d1d5db") === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateConfig("color", card.value)}
className={cn(
"flex flex-col items-center rounded-md border p-2 text-center transition-all",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<div
className="mb-1 h-3 w-3 rounded-full"
style={{ backgroundColor: card.value }}
/>
<span className="text-xs font-medium">{card.label}</span>
</button>
);
})}
</div>
{/* 커스텀 색상 */}
<div className="flex items-center gap-2 pt-1">
<input
type="color"
value={config.color || "#d1d5db"}
onChange={(e) => updateConfig("color", e.target.value)}
className="h-7 w-7 cursor-pointer rounded border"
/>
<Input
value={config.color || "#d1d5db"}
onChange={(e) => updateConfig("color", e.target.value)}
placeholder="#d1d5db"
className="h-7 flex-1 text-xs"
/>
</div>
</div>
{/* ─── 3단계: 구분선 텍스트 ─── */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium"> </p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={!!config.dividerText}
onCheckedChange={(checked) =>
updateConfig("dividerText", checked ? "구분" : "")
}
/>
</div>
{config.dividerText && (
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"></span>
<Input
value={config.dividerText || ""}
onChange={(e) => updateConfig("dividerText", e.target.value)}
placeholder="구분 텍스트 입력"
className="h-7 w-[160px] text-xs"
/>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<div className="flex items-center gap-1.5">
<input
type="color"
value={config.textColor || "#6b7280"}
onChange={(e) => updateConfig("textColor", e.target.value)}
className="h-6 w-6 cursor-pointer rounded border"
/>
<Input
value={config.textColor || "#6b7280"}
onChange={(e) => updateConfig("textColor", e.target.value)}
className="h-7 w-[100px] text-xs"
/>
</div>
</div>
</div>
)}
</div>
{/* ─── 4단계: 고급 설정 (기본 접혀있음) ─── */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
advancedOpen && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.rounded || false}
onCheckedChange={(checked) => updateConfig("rounded", checked)}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"></p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.disabled || false}
onCheckedChange={(checked) => updateConfig("disabled", checked)}
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
V2DividerLineConfigPanel.displayName = "V2DividerLineConfigPanel";
export default V2DividerLineConfigPanel;

View File

@ -0,0 +1,749 @@
"use client";
/**
* V2
* (text/number/textarea/numbering) (select/category/entity)
* UI
*/
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Separator } from "@/components/ui/separator";
import {
Type, Hash, AlignLeft, ListOrdered, List, Database, FolderTree,
Settings, ChevronDown, Plus, Trash2, Loader2, Filter,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { AutoGenerationType } from "@/types/screen";
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
import { getAvailableNumberingRulesForScreen } from "@/lib/api/numberingRule";
import { NumberingRuleConfig } from "@/types/numbering-rule";
import type { V2SelectFilter } from "@/types/v2-components";
// ─── 필드 유형 카드 정의 ───
const FIELD_TYPE_CARDS = [
{ value: "text", icon: Type, label: "텍스트", desc: "일반 텍스트 입력", group: "input" },
{ value: "number", icon: Hash, label: "숫자", desc: "숫자만 입력", group: "input" },
{ value: "textarea", icon: AlignLeft, label: "여러 줄", desc: "긴 텍스트 입력", group: "input" },
{ value: "select", icon: List, label: "셀렉트", desc: "직접 옵션 선택", group: "select" },
{ value: "category", icon: FolderTree, label: "카테고리", desc: "등록된 선택지", group: "select" },
{ value: "entity", icon: Database, label: "테이블 참조", desc: "다른 테이블 참조", group: "select" },
{ value: "numbering", icon: ListOrdered, label: "채번", desc: "자동 번호 생성", group: "input" },
] as const;
type FieldType = typeof FIELD_TYPE_CARDS[number]["value"];
// 필터 조건 관련 상수
const OPERATOR_OPTIONS = [
{ value: "=", label: "같음 (=)" },
{ value: "!=", label: "다름 (!=)" },
{ value: ">", label: "초과 (>)" },
{ value: "<", label: "미만 (<)" },
{ value: ">=", label: "이상 (>=)" },
{ value: "<=", label: "이하 (<=)" },
{ value: "in", label: "포함 (IN)" },
{ value: "notIn", label: "미포함 (NOT IN)" },
{ value: "like", label: "유사 (LIKE)" },
{ value: "isNull", label: "NULL" },
{ value: "isNotNull", label: "NOT NULL" },
] as const;
const VALUE_TYPE_OPTIONS = [
{ value: "static", label: "고정값" },
{ value: "field", label: "폼 필드 참조" },
{ value: "user", label: "로그인 사용자" },
] as const;
const USER_FIELD_OPTIONS = [
{ value: "companyCode", label: "회사코드" },
{ value: "userId", label: "사용자ID" },
{ value: "deptCode", label: "부서코드" },
{ value: "userName", label: "사용자명" },
] as const;
interface ColumnOption {
columnName: string;
columnLabel: string;
}
interface CategoryValueOption {
valueCode: string;
valueLabel: string;
}
// ─── 하위 호환: 기존 config에서 fieldType 추론 ───
function resolveFieldType(config: Record<string, any>, componentType?: string): FieldType {
if (config.fieldType) return config.fieldType as FieldType;
// v2-select 계열
if (componentType === "v2-select" || config.source) {
const source = config.source === "code" ? "category" : config.source;
if (source === "entity") return "entity";
if (source === "category") return "category";
return "select";
}
// v2-input 계열
const it = config.inputType || config.type;
if (it === "number") return "number";
if (it === "textarea") return "textarea";
if (it === "numbering") return "numbering";
return "text";
}
// ─── 필터 조건 서브 컴포넌트 ───
const FilterConditionsSection: React.FC<{
filters: V2SelectFilter[];
columns: ColumnOption[];
loadingColumns: boolean;
targetTable: string;
onFiltersChange: (filters: V2SelectFilter[]) => void;
}> = ({ filters, columns, loadingColumns, targetTable, onFiltersChange }) => {
const addFilter = () => {
onFiltersChange([...filters, { column: "", operator: "=", valueType: "static", value: "" }]);
};
const updateFilter = (index: number, patch: Partial<V2SelectFilter>) => {
const updated = [...filters];
updated[index] = { ...updated[index], ...patch };
if (patch.valueType) {
if (patch.valueType === "static") { updated[index].fieldRef = undefined; updated[index].userField = undefined; }
else if (patch.valueType === "field") { updated[index].value = undefined; updated[index].userField = undefined; }
else if (patch.valueType === "user") { updated[index].value = undefined; updated[index].fieldRef = undefined; }
}
if (patch.operator === "isNull" || patch.operator === "isNotNull") {
updated[index].value = undefined; updated[index].fieldRef = undefined;
updated[index].userField = undefined; updated[index].valueType = "static";
}
onFiltersChange(updated);
};
const removeFilter = (index: number) => onFiltersChange(filters.filter((_, i) => i !== index));
const needsValue = (op: string) => op !== "isNull" && op !== "isNotNull";
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Filter className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium"> </span>
</div>
<Button type="button" variant="ghost" size="sm" onClick={addFilter} className="h-6 px-2 text-xs">
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<p className="text-muted-foreground text-[10px]">{targetTable} </p>
{loadingColumns && (
<div className="text-muted-foreground flex items-center gap-2 text-xs"><Loader2 className="h-3 w-3 animate-spin" /> ...</div>
)}
{filters.length === 0 && <p className="text-muted-foreground py-2 text-center text-xs"> </p>}
<div className="space-y-2">
{filters.map((filter, index) => (
<div key={index} className="space-y-2 rounded-md border p-3">
<div className="flex items-center gap-1.5">
<Select value={filter.column || ""} onValueChange={(v) => updateFilter(index, { column: v })}>
<SelectTrigger className="h-7 flex-1 text-[11px]"><SelectValue placeholder="컬럼" /></SelectTrigger>
<SelectContent>{columns.map((col) => (<SelectItem key={col.columnName} value={col.columnName}>{col.columnLabel}</SelectItem>))}</SelectContent>
</Select>
<Select value={filter.operator || "="} onValueChange={(v) => updateFilter(index, { operator: v as V2SelectFilter["operator"] })}>
<SelectTrigger className="h-7 flex-1 text-[11px]"><SelectValue /></SelectTrigger>
<SelectContent>{OPERATOR_OPTIONS.map((op) => (<SelectItem key={op.value} value={op.value}>{op.label}</SelectItem>))}</SelectContent>
</Select>
<Button type="button" variant="ghost" size="sm" onClick={() => removeFilter(index)} className="text-destructive h-8 w-8 shrink-0 p-0"><Trash2 className="h-3 w-3" /></Button>
</div>
{needsValue(filter.operator) && (
<div className="flex items-center gap-1.5">
<Select value={filter.valueType || "static"} onValueChange={(v) => updateFilter(index, { valueType: v as V2SelectFilter["valueType"] })}>
<SelectTrigger className="h-7 w-[100px] shrink-0 text-[11px]"><SelectValue /></SelectTrigger>
<SelectContent>{VALUE_TYPE_OPTIONS.map((vt) => (<SelectItem key={vt.value} value={vt.value}>{vt.label}</SelectItem>))}</SelectContent>
</Select>
{(filter.valueType || "static") === "static" && (
<Input value={String(filter.value ?? "")} onChange={(e) => updateFilter(index, { value: e.target.value })}
placeholder={filter.operator === "in" || filter.operator === "notIn" ? "값1, 값2, ..." : "값 입력"} className="h-7 flex-1 text-[11px]" />
)}
{filter.valueType === "field" && (
<Input value={filter.fieldRef || ""} onChange={(e) => updateFilter(index, { fieldRef: e.target.value })} placeholder="참조할 필드명" className="h-7 flex-1 text-[11px]" />
)}
{filter.valueType === "user" && (
<Select value={filter.userField || ""} onValueChange={(v) => updateFilter(index, { userField: v as V2SelectFilter["userField"] })}>
<SelectTrigger className="h-7 flex-1 text-[11px]"><SelectValue placeholder="사용자 필드" /></SelectTrigger>
<SelectContent>{USER_FIELD_OPTIONS.map((uf) => (<SelectItem key={uf.value} value={uf.value}>{uf.label}</SelectItem>))}</SelectContent>
</Select>
)}
</div>
)}
</div>
))}
</div>
</div>
);
};
// ─── 메인 컴포넌트 ───
interface V2FieldConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
tableName?: string;
columnName?: string;
tables?: Array<{ tableName: string; displayName?: string; tableComment?: string }>;
menuObjid?: number;
screenTableName?: string;
inputType?: string;
componentType?: string;
}
export const V2FieldConfigPanel: React.FC<V2FieldConfigPanelProps> = ({
config,
onChange,
tableName,
columnName,
tables = [],
screenTableName,
inputType: metaInputType,
componentType,
}) => {
const fieldType = resolveFieldType(config, componentType);
const isSelectGroup = ["select", "category", "entity"].includes(fieldType);
// ─── 채번 관련 상태 (테이블 기반) ───
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
const [loadingRules, setLoadingRules] = useState(false);
const numberingTableName = screenTableName || tableName;
// ─── 셀렉트 관련 상태 ───
const [entityColumns, setEntityColumns] = useState<ColumnOption[]>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
const [categoryValues, setCategoryValues] = useState<CategoryValueOption[]>([]);
const [loadingCategoryValues, setLoadingCategoryValues] = useState(false);
const [filterColumns, setFilterColumns] = useState<ColumnOption[]>([]);
const [loadingFilterColumns, setLoadingFilterColumns] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const updateConfig = (field: string, value: any) => {
onChange({ ...config, [field]: value });
};
// ─── 필드 타입 전환 핸들러 ───
const handleFieldTypeChange = (newType: FieldType) => {
const newIsSelect = ["select", "category", "entity"].includes(newType);
const base: Record<string, any> = { ...config, fieldType: newType };
if (newIsSelect) {
base.source = newType === "category" ? "category" : newType === "entity" ? "entity" : "static";
delete base.inputType;
} else {
base.inputType = newType;
// 선택형 -> 입력형 전환 시 source 잔류 제거 (안 지우면 '카테고리 값이 없습니다' 같은 오류 표시)
delete base.source;
}
if (newType === "numbering") {
base.autoGeneration = {
...config.autoGeneration,
type: "numbering_rule" as AutoGenerationType,
tableName: numberingTableName,
};
base.readonly = config.readonly ?? true;
}
onChange(base);
// table_type_columns.input_type 동기화 (카테고리/엔티티 등 설정 가능하도록)
const syncTableName = screenTableName || tableName;
const syncColumnName = columnName || config.columnName || config.fieldName;
if (syncTableName && syncColumnName) {
apiClient.put(`/table-management/tables/${syncTableName}/columns/${syncColumnName}/input-type`, {
inputType: newType,
}).then(() => {
// 왼쪽 테이블 패널의 컬럼 타입 뱃지 갱신
window.dispatchEvent(new CustomEvent("table-columns-refresh"));
}).catch(() => { /* 동기화 실패해도 화면 설정은 유지 */ });
}
};
// ─── 채번 규칙 로드 (테이블 기반) ───
useEffect(() => {
if (fieldType !== "numbering") return;
if (!numberingTableName) { setNumberingRules([]); return; }
const load = async () => {
setLoadingRules(true);
try {
const resp = await getAvailableNumberingRulesForScreen(numberingTableName);
if (resp.success && resp.data) setNumberingRules(resp.data);
else setNumberingRules([]);
} catch { setNumberingRules([]); } finally { setLoadingRules(false); }
};
load();
}, [numberingTableName, fieldType]);
// ─── 엔티티 컬럼 로드 ───
const loadEntityColumns = useCallback(async (tblName: string) => {
if (!tblName) { setEntityColumns([]); return; }
setLoadingColumns(true);
try {
const resp = await apiClient.get(`/table-management/tables/${tblName}/columns?size=500`);
const data = resp.data.data || resp.data;
const cols = data.columns || data || [];
setEntityColumns(cols.map((col: any) => ({
columnName: col.columnName || col.column_name || col.name,
columnLabel: col.displayName || col.display_name || col.columnLabel || col.column_label || col.columnName || col.column_name || col.name,
})));
} catch { setEntityColumns([]); } finally { setLoadingColumns(false); }
}, []);
useEffect(() => {
if (fieldType === "entity" && config.entityTable) loadEntityColumns(config.entityTable);
}, [fieldType, config.entityTable, loadEntityColumns]);
// ─── 카테고리 값 로드 ───
const loadCategoryValues = useCallback(async (catTable: string, catColumn: string) => {
if (!catTable || !catColumn) { setCategoryValues([]); return; }
setLoadingCategoryValues(true);
try {
const resp = await apiClient.get(`/table-categories/${catTable}/${catColumn}/values`);
if (resp.data.success && resp.data.data) {
const flattenTree = (items: any[], depth = 0): CategoryValueOption[] => {
const result: CategoryValueOption[] = [];
for (const item of items) {
result.push({ valueCode: item.valueCode, valueLabel: depth > 0 ? `${" ".repeat(depth)}${item.valueLabel}` : item.valueLabel });
if (item.children?.length) result.push(...flattenTree(item.children, depth + 1));
}
return result;
};
setCategoryValues(flattenTree(resp.data.data));
}
} catch { setCategoryValues([]); } finally { setLoadingCategoryValues(false); }
}, []);
useEffect(() => {
if (fieldType === "category") {
const catTable = config.categoryTable || tableName;
const catColumn = config.categoryColumn || columnName;
if (catTable && catColumn) loadCategoryValues(catTable, catColumn);
}
}, [fieldType, config.categoryTable, config.categoryColumn, tableName, columnName, loadCategoryValues]);
// ─── 필터 컬럼 로드 ───
const filterTargetTable = useMemo(() => {
if (fieldType === "entity") return config.entityTable;
if (fieldType === "category") return config.categoryTable || tableName;
return null;
}, [fieldType, config.entityTable, config.categoryTable, tableName]);
useEffect(() => {
if (!filterTargetTable) { setFilterColumns([]); return; }
const load = async () => {
setLoadingFilterColumns(true);
try {
const resp = await apiClient.get(`/table-management/tables/${filterTargetTable}/columns?size=500`);
const data = resp.data.data || resp.data;
const cols = data.columns || data || [];
setFilterColumns(cols.map((col: any) => ({
columnName: col.columnName || col.column_name || col.name,
columnLabel: col.displayName || col.display_name || col.columnLabel || col.column_label || col.columnName || col.column_name || col.name,
})));
} catch { setFilterColumns([]); } finally { setLoadingFilterColumns(false); }
};
load();
}, [filterTargetTable]);
// ─── 옵션 관리 (select static) ───
const options = config.options || [];
const addOption = () => updateConfig("options", [...options, { value: "", label: "" }]);
const updateOptionValue = (index: number, value: string) => {
const newOpts = [...options];
newOpts[index] = { ...newOpts[index], value, label: value };
updateConfig("options", newOpts);
};
const removeOption = (index: number) => updateConfig("options", options.filter((_: any, i: number) => i !== index));
return (
<div className="space-y-4">
{/* ═══ 1단계: 필드 유형 선택 ═══ */}
<div className="space-y-2">
<p className="text-sm font-medium"> ?</p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<div className="grid grid-cols-3 gap-2">
{FIELD_TYPE_CARDS.map((card) => {
const Icon = card.icon;
const isSelected = fieldType === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => handleFieldTypeChange(card.value)}
className={cn(
"flex flex-col items-center justify-center rounded-lg border p-2.5 text-center transition-all min-h-[72px]",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<Icon className={cn("h-4 w-4 mb-1", isSelected ? "text-primary" : "text-muted-foreground")} />
<span className={cn("text-[11px] font-medium leading-tight", isSelected ? "text-primary" : "text-foreground")}>{card.label}</span>
<span className="text-[9px] text-muted-foreground leading-tight mt-0.5">{card.desc}</span>
</button>
);
})}
</div>
{/* ═══ 2단계: 유형별 상세 설정 ═══ */}
{/* ─── 텍스트/숫자/여러줄: 기본 설정 ─── */}
{(fieldType === "text" || fieldType === "number" || fieldType === "textarea") && (
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Input value={config.placeholder || ""} onChange={(e) => updateConfig("placeholder", e.target.value)} placeholder="입력 안내" className="h-7 w-[160px] text-xs" />
</div>
{fieldType === "text" && (
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Select value={config.format || "none"} onValueChange={(v) => updateConfig("format", v)}>
<SelectTrigger className="h-7 w-[160px] text-xs"><SelectValue placeholder="형식 선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
<SelectItem value="email"></SelectItem>
<SelectItem value="tel"></SelectItem>
<SelectItem value="url">URL</SelectItem>
<SelectItem value="currency"></SelectItem>
<SelectItem value="biz_no"></SelectItem>
</SelectContent>
</Select>
</div>
)}
{fieldType === "number" && (
<div className="space-y-2 pt-1">
<p className="text-xs text-muted-foreground"> </p>
<div className="flex gap-2">
<div className="flex-1">
<Label className="text-[10px] text-muted-foreground"></Label>
<Input type="number" value={config.min ?? ""} onChange={(e) => updateConfig("min", e.target.value ? Number(e.target.value) : undefined)} placeholder="0" className="h-7 text-xs" />
</div>
<div className="flex-1">
<Label className="text-[10px] text-muted-foreground"></Label>
<Input type="number" value={config.max ?? ""} onChange={(e) => updateConfig("max", e.target.value ? Number(e.target.value) : undefined)} placeholder="100" className="h-7 text-xs" />
</div>
<div className="flex-1">
<Label className="text-[10px] text-muted-foreground"></Label>
<Input type="number" value={config.step ?? ""} onChange={(e) => updateConfig("step", e.target.value ? Number(e.target.value) : undefined)} placeholder="1" className="h-7 text-xs" />
</div>
</div>
</div>
)}
{fieldType === "textarea" && (
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Input type="number" value={config.rows || 3} onChange={(e) => updateConfig("rows", parseInt(e.target.value) || 3)} min={2} max={20} className="h-7 w-[160px] text-xs" />
</div>
)}
</div>
)}
{/* ─── 셀렉트 (직접 입력): 옵션 관리 ─── */}
{fieldType === "select" && (
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium"> </span>
<Button type="button" variant="outline" size="sm" onClick={addOption} className="h-7 px-2 text-xs">
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{options.length > 0 ? (
<div className="max-h-40 space-y-1.5 overflow-y-auto">
{options.map((option: any, index: number) => (
<div key={index} className="flex items-center gap-2">
<Input value={option.value || ""} onChange={(e) => updateOptionValue(index, e.target.value)} placeholder={`옵션 ${index + 1}`} className="h-8 flex-1 text-sm" />
<Button type="button" variant="ghost" size="icon" onClick={() => removeOption(index)} className="text-destructive h-8 w-8 shrink-0"><Trash2 className="h-4 w-4" /></Button>
</div>
))}
</div>
) : (
<div className="text-center py-6 text-muted-foreground">
<List className="mx-auto mb-2 h-8 w-8 opacity-30" />
<p className="text-sm"> </p>
<p className="text-xs"> </p>
</div>
)}
{options.length > 0 && (
<div className="border-t pt-3 mt-3">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground"> </span>
<Select value={config.defaultValue || "_none_"} onValueChange={(v) => updateConfig("defaultValue", v === "_none_" ? "" : v)}>
<SelectTrigger className="h-8 w-[160px] text-sm"><SelectValue placeholder="선택 안함" /></SelectTrigger>
<SelectContent>
<SelectItem value="_none_"> </SelectItem>
{options.map((opt: any, i: number) => (<SelectItem key={`d-${i}`} value={opt.value || `_idx_${i}`}>{opt.label || opt.value || `옵션 ${i + 1}`}</SelectItem>))}
</SelectContent>
</Select>
</div>
</div>
)}
</div>
)}
{/* ─── 카테고리 ─── */}
{fieldType === "category" && (
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<FolderTree className="h-4 w-4 text-primary" />
<span className="text-sm font-medium"></span>
</div>
{config.source === "code" && config.codeGroup && (
<div className="rounded-md border bg-background p-3">
<p className="text-xs text-muted-foreground"> </p>
<p className="mt-0.5 text-sm font-medium">{config.codeGroup}</p>
</div>
)}
<div className="rounded-md border bg-background p-3">
<div className="flex gap-6">
<div><p className="text-xs text-muted-foreground"></p><p className="text-sm font-medium">{config.categoryTable || tableName || "-"}</p></div>
<div><p className="text-xs text-muted-foreground"></p><p className="text-sm font-medium">{config.categoryColumn || columnName || "-"}</p></div>
</div>
</div>
{loadingCategoryValues && <div className="text-muted-foreground flex items-center gap-2 text-xs"><Loader2 className="h-3 w-3 animate-spin" /> ...</div>}
{categoryValues.length > 0 && (
<div>
<p className="mb-1.5 text-xs text-muted-foreground">{categoryValues.length} </p>
<div className="max-h-28 overflow-y-auto rounded-md border bg-background p-2 space-y-0.5">
{categoryValues.map((cv) => (
<div key={cv.valueCode} className="flex items-center gap-2 px-1.5 py-0.5 text-xs">
<span className="shrink-0 font-mono text-[10px] text-muted-foreground">{cv.valueCode}</span>
<span className="truncate">{cv.valueLabel}</span>
</div>
))}
</div>
<div className="mt-3 flex items-center justify-between">
<span className="text-xs text-muted-foreground"> </span>
<Select value={config.defaultValue || "_none_"} onValueChange={(v) => updateConfig("defaultValue", v === "_none_" ? "" : v)}>
<SelectTrigger className="h-8 w-[160px] text-sm"><SelectValue placeholder="선택 안함" /></SelectTrigger>
<SelectContent>
<SelectItem value="_none_"> </SelectItem>
{categoryValues.map((cv) => (<SelectItem key={cv.valueCode} value={cv.valueCode}>{cv.valueLabel}</SelectItem>))}
</SelectContent>
</Select>
</div>
</div>
)}
{!loadingCategoryValues && categoryValues.length === 0 && (
<p className="text-[10px] text-amber-600"> . .</p>
)}
</div>
)}
{/* ─── 테이블 참조 (entity) ─── */}
{fieldType === "entity" && (
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-primary" />
<span className="text-sm font-medium"> </span>
</div>
<div>
<p className="mb-1.5 text-xs text-muted-foreground"> </p>
<Select value={config.entityTable || ""} onValueChange={(v) => onChange({ ...config, entityTable: v, entityValueColumn: "", entityLabelColumn: "" })}>
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="테이블을 선택해주세요" /></SelectTrigger>
<SelectContent>
{tables.map((t) => (<SelectItem key={t.tableName} value={t.tableName}>{t.displayName || t.tableComment ? `${t.displayName || t.tableComment} (${t.tableName})` : t.tableName}</SelectItem>))}
</SelectContent>
</Select>
</div>
{loadingColumns && <div className="text-muted-foreground flex items-center gap-2 text-xs"><Loader2 className="h-3 w-3 animate-spin" /> ...</div>}
{entityColumns.length > 0 && (
<div className="space-y-3">
<div>
<p className="mb-1.5 text-xs text-muted-foreground"> </p>
<Select value={config.entityValueColumn || ""} onValueChange={(v) => updateConfig("entityValueColumn", v)}>
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="컬럼 선택" /></SelectTrigger>
<SelectContent>{entityColumns.map((col) => (<SelectItem key={col.columnName} value={col.columnName}>{col.columnLabel}</SelectItem>))}</SelectContent>
</Select>
</div>
<div>
<p className="mb-1.5 text-xs text-muted-foreground"> </p>
<Select value={config.entityLabelColumn || ""} onValueChange={(v) => updateConfig("entityLabelColumn", v)}>
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="컬럼 선택" /></SelectTrigger>
<SelectContent>{entityColumns.map((col) => (<SelectItem key={col.columnName} value={col.columnName}>{col.columnLabel}</SelectItem>))}</SelectContent>
</Select>
</div>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
)}
{config.entityTable && !loadingColumns && entityColumns.length === 0 && (
<p className="text-[10px] text-amber-600"> .</p>
)}
</div>
)}
{/* ─── 채번 (테이블 기반) ─── */}
{fieldType === "numbering" && (
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<ListOrdered className="h-4 w-4 text-primary" />
<span className="text-sm font-medium"> </span>
</div>
{numberingTableName ? (
<div className="rounded-md border bg-background p-2">
<p className="text-xs text-muted-foreground"> </p>
<p className="text-sm font-medium mt-0.5">{numberingTableName}</p>
</div>
) : (
<p className="text-xs text-amber-600"> .</p>
)}
{numberingTableName && (
<div>
<p className="mb-1.5 text-xs text-muted-foreground"> </p>
{loadingRules ? (
<div className="text-muted-foreground flex items-center gap-2 text-xs py-1"><Loader2 className="h-3 w-3 animate-spin" /> ...</div>
) : numberingRules.length > 0 ? (
<Select value={config.autoGeneration?.numberingRuleId || ""} onValueChange={(v) => {
onChange({ ...config, autoGeneration: { ...config.autoGeneration, type: "numbering_rule" as AutoGenerationType, numberingRuleId: v, tableName: numberingTableName } });
}}>
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="채번 규칙 선택" /></SelectTrigger>
<SelectContent>
{numberingRules.map((rule) => (<SelectItem key={rule.ruleId} value={String(rule.ruleId)}>{rule.ruleName} ({rule.separator || "-"}{"{번호}"})</SelectItem>))}
</SelectContent>
</Select>
) : (
<p className="text-xs text-muted-foreground"> </p>
)}
</div>
)}
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"></p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch checked={config.readonly !== false} onCheckedChange={(checked) => updateConfig("readonly", checked)} />
</div>
</div>
)}
{/* ─── 데이터 필터 (선택형 + 테이블 있을 때만) ─── */}
{isSelectGroup && fieldType !== "select" && filterTargetTable && (
<div className="rounded-lg border bg-muted/30 p-4">
<FilterConditionsSection
filters={(config.filters as V2SelectFilter[]) || []}
columns={filterColumns}
loadingColumns={loadingFilterColumns}
targetTable={filterTargetTable}
onFiltersChange={(filters) => updateConfig("filters", filters)}
/>
</div>
)}
{/* ═══ 3단계: 고급 설정 ═══ */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<button type="button" className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50">
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
</div>
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", advancedOpen && "rotate-180")} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
{/* 선택형: 선택 방식, 복수 선택, 검색 등 */}
{isSelectGroup && (
<>
<div>
<p className="mb-1.5 text-xs text-muted-foreground"> </p>
<Select value={config.mode || "dropdown"} onValueChange={(v) => updateConfig("mode", v)}>
<SelectTrigger className="h-8 text-sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="dropdown"></SelectItem>
<SelectItem value="combobox"> </SelectItem>
<SelectItem value="radio"> </SelectItem>
<SelectItem value="check"></SelectItem>
<Separator className="my-1" />
<SelectItem value="tag"> </SelectItem>
<SelectItem value="toggle"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between py-1">
<div><p className="text-sm"> </p><p className="text-[11px] text-muted-foreground"> </p></div>
<Switch checked={config.multiple || false} onCheckedChange={(v) => updateConfig("multiple", v)} />
</div>
{config.multiple && (
<div className="ml-4 border-l-2 border-primary/20 pl-3">
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Input type="number" value={config.maxSelect ?? ""} onChange={(e) => updateConfig("maxSelect", e.target.value ? Number(e.target.value) : undefined)} placeholder="제한 없음" min={1} className="h-7 w-[100px] text-xs" />
</div>
</div>
)}
<div className="flex items-center justify-between py-1">
<div><p className="text-sm"> </p><p className="text-[11px] text-muted-foreground"> </p></div>
<Switch checked={config.searchable || false} onCheckedChange={(v) => updateConfig("searchable", v)} />
</div>
<div className="flex items-center justify-between py-1">
<div><p className="text-sm"> </p><p className="text-[11px] text-muted-foreground"> X </p></div>
<Switch checked={config.allowClear !== false} onCheckedChange={(v) => updateConfig("allowClear", v)} />
</div>
</div>
</>
)}
{/* 입력형: 자동 생성 */}
{!isSelectGroup && fieldType !== "numbering" && (
<div className="space-y-3">
<div className="flex items-center justify-between py-1">
<div><p className="text-sm"> </p><p className="text-[11px] text-muted-foreground"> </p></div>
<Switch checked={config.autoGeneration?.enabled || false} onCheckedChange={(checked) => updateConfig("autoGeneration", { ...config.autoGeneration || { type: "none", enabled: false }, enabled: checked })} />
</div>
{config.autoGeneration?.enabled && (
<div className="ml-1 border-l-2 border-primary/20 pl-3">
<div>
<p className="mb-1.5 text-xs text-muted-foreground"> </p>
<Select value={config.autoGeneration?.type || "none"} onValueChange={(v: AutoGenerationType) => updateConfig("autoGeneration", { ...config.autoGeneration, type: v })}>
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="자동생성 타입 선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
<SelectItem value="uuid">UUID </SelectItem>
<SelectItem value="current_user"> ID</SelectItem>
<SelectItem value="current_time"> </SelectItem>
<SelectItem value="sequence"> </SelectItem>
<SelectItem value="company_code"> </SelectItem>
<SelectItem value="department"> </SelectItem>
</SelectContent>
</Select>
</div>
{config.autoGeneration?.type && config.autoGeneration.type !== "none" && (
<p className="text-[11px] text-muted-foreground mt-1">{AutoGenerationUtils.getTypeDescription(config.autoGeneration.type)}</p>
)}
</div>
)}
{/* 입력 마스크 */}
<div className="flex items-center justify-between py-1">
<div>
<span className="text-xs text-muted-foreground"> </span>
<p className="text-[10px] text-muted-foreground mt-0.5"># = , A = , * = </p>
</div>
<Input value={config.mask || ""} onChange={(e) => updateConfig("mask", e.target.value)} placeholder="###-####-####" className="h-7 w-[140px] text-xs" />
</div>
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
V2FieldConfigPanel.displayName = "V2FieldConfigPanel";
export default V2FieldConfigPanel;

View File

@ -0,0 +1,371 @@
"use client";
/**
* V2FileUpload
* UX: 파일 () -> -> /(Switch) -> ()
*/
import React, { useState, useMemo, useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Settings,
ChevronDown,
FileText,
Image,
Archive,
File,
FileImage,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { FileUploadConfig } from "@/lib/registry/components/v2-file-upload/types";
import { V2FileUploadDefaultConfig } from "@/lib/registry/components/v2-file-upload/config";
const FILE_TYPE_CARDS = [
{ value: "*/*", label: "모든 파일", icon: File, desc: "제한 없음" },
{ value: "image/*", label: "이미지", icon: Image, desc: "JPG, PNG 등" },
{ value: ".pdf,.doc,.docx,.xls,.xlsx", label: "문서", icon: FileText, desc: "PDF, Word, Excel" },
{ value: "image/*,.pdf", label: "이미지+PDF", icon: FileImage, desc: "이미지와 PDF" },
{ value: ".zip,.rar,.7z", label: "압축 파일", icon: Archive, desc: "ZIP, RAR 등" },
] as const;
const VARIANT_CARDS = [
{ value: "default", label: "기본", desc: "기본 스타일" },
{ value: "outlined", label: "테두리", desc: "테두리 강조" },
{ value: "filled", label: "채움", desc: "배경 채움" },
] as const;
const SIZE_CARDS = [
{ value: "sm", label: "작게" },
{ value: "md", label: "보통" },
{ value: "lg", label: "크게" },
] as const;
interface V2FileUploadConfigPanelProps {
config: FileUploadConfig;
onChange: (config: Partial<FileUploadConfig>) => void;
screenTableName?: string;
}
export const V2FileUploadConfigPanel: React.FC<V2FileUploadConfigPanelProps> = ({
config: propConfig,
onChange,
screenTableName,
}) => {
const [advancedOpen, setAdvancedOpen] = useState(false);
const config = useMemo(() => ({
...V2FileUploadDefaultConfig,
...propConfig,
}), [propConfig]);
const maxSizeMB = useMemo(() => {
return (config.maxSize || 10 * 1024 * 1024) / (1024 * 1024);
}, [config.maxSize]);
const updateConfig = useCallback(<K extends keyof FileUploadConfig>(
field: K,
value: FileUploadConfig[K]
) => {
const newConfig = { ...config, [field]: value };
onChange({ [field]: value });
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("componentConfigChanged", {
detail: { config: newConfig },
})
);
}
}, [config, onChange]);
const handleMaxSizeChange = useCallback((value: string) => {
const mb = parseFloat(value) || 10;
updateConfig("maxSize", mb * 1024 * 1024);
}, [updateConfig]);
return (
<div className="space-y-4">
{/* ─── 1단계: 허용 파일 형식 카드 선택 ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"> </p>
<div className="grid grid-cols-2 gap-2">
{FILE_TYPE_CARDS.map((card) => {
const Icon = card.icon;
const isSelected = (config.accept || "*/*") === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateConfig("accept", card.value)}
className={cn(
"flex items-center gap-2 rounded-lg border p-2.5 text-left transition-all",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<span className="text-xs font-medium block">{card.label}</span>
<span className="text-[10px] text-muted-foreground block">
{card.desc}
</span>
</div>
</button>
);
})}
</div>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
{/* ─── 2단계: 파일 제한 설정 ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"> </p>
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Input
value={config.placeholder || ""}
onChange={(e) => updateConfig("placeholder", e.target.value)}
placeholder="파일을 선택하세요"
className="h-7 w-[160px] text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<span className="text-xs text-muted-foreground"> (MB)</span>
<Input
type="number"
min={1}
max={100}
value={maxSizeMB}
onChange={(e) => handleMaxSizeChange(e.target.value)}
className="mt-1 h-7 text-xs"
/>
</div>
<div>
<span className="text-xs text-muted-foreground"> </span>
<Input
type="number"
min={1}
max={50}
value={config.maxFiles || 10}
onChange={(e) => updateConfig("maxFiles", parseInt(e.target.value) || 10)}
className="mt-1 h-7 text-xs"
/>
</div>
</div>
</div>
</div>
{/* ─── 3단계: 동작 설정 (Switch) ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"> </p>
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.multiple !== false}
onCheckedChange={(checked) => updateConfig("multiple", checked)}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.allowDelete !== false}
onCheckedChange={(checked) => updateConfig("allowDelete", checked)}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.allowDownload !== false}
onCheckedChange={(checked) => updateConfig("allowDownload", checked)}
/>
</div>
</div>
</div>
{/* ─── 4단계: 표시 설정 (Switch) ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"> </p>
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.showPreview !== false}
onCheckedChange={(checked) => updateConfig("showPreview", checked)}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.showFileList !== false}
onCheckedChange={(checked) => updateConfig("showFileList", checked)}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.showFileSize !== false}
onCheckedChange={(checked) => updateConfig("showFileSize", checked)}
/>
</div>
</div>
</div>
{/* ─── 5단계: 스타일 카드 선택 ─── */}
<div className="space-y-3">
<p className="text-sm font-medium"></p>
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div>
<span className="text-xs text-muted-foreground"> </span>
<div className="mt-1.5 grid grid-cols-3 gap-2">
{VARIANT_CARDS.map((card) => {
const isSelected = (config.variant || "default") === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateConfig("variant", card.value as "default" | "outlined" | "filled")}
className={cn(
"flex flex-col items-center rounded-md border p-2 text-center transition-all",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<span className="text-xs font-medium">{card.label}</span>
<span className="text-[10px] text-muted-foreground">{card.desc}</span>
</button>
);
})}
</div>
</div>
<div>
<span className="text-xs text-muted-foreground"></span>
<div className="mt-1.5 grid grid-cols-3 gap-2">
{SIZE_CARDS.map((card) => {
const isSelected = (config.size || "md") === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateConfig("size", card.value as "sm" | "md" | "lg")}
className={cn(
"flex items-center justify-center rounded-md border p-2 text-center transition-all",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<span className="text-xs font-medium">{card.label}</span>
</button>
);
})}
</div>
</div>
</div>
</div>
{/* ─── 6단계: 고급 설정 (기본 접혀있음) ─── */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
advancedOpen && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
{/* 도움말 */}
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"></span>
<Input
value={config.helperText || ""}
onChange={(e) => updateConfig("helperText", e.target.value)}
placeholder="안내 문구 입력"
className="h-7 w-[160px] text-xs"
/>
</div>
{/* 필수 입력 */}
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.required || false}
onCheckedChange={(checked) => updateConfig("required", checked)}
/>
</div>
{/* 읽기 전용 */}
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.readonly || false}
onCheckedChange={(checked) => updateConfig("readonly", checked)}
/>
</div>
{/* 비활성화 */}
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"></p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.disabled || false}
onCheckedChange={(checked) => updateConfig("disabled", checked)}
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
V2FileUploadConfigPanel.displayName = "V2FileUploadConfigPanel";
export default V2FileUploadConfigPanel;

View File

@ -2,17 +2,78 @@
/**
* V2Group
* .
* UX: 그룹 -> -> ()
*/
import React from "react";
import { Label } from "@/components/ui/label";
import React, { useState } from "react";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Plus, Trash2 } from "lucide-react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
LayoutList,
Rows3,
ChevronsDownUp,
SquareStack,
AppWindow,
FileInput,
Settings,
ChevronDown,
Plus,
Trash2,
} from "lucide-react";
import { cn } from "@/lib/utils";
// ─── 그룹 타입 카드 정의 ───
const GROUP_TYPE_CARDS = [
{
value: "section",
icon: LayoutList,
title: "섹션",
description: "기본 영역 구분이에요",
},
{
value: "tabs",
icon: Rows3,
title: "탭",
description: "탭으로 내용을 나눠요",
},
{
value: "accordion",
icon: ChevronsDownUp,
title: "아코디언",
description: "접었다 펼 수 있어요",
},
{
value: "card",
icon: SquareStack,
title: "카드 섹션",
description: "카드 형태로 묶어요",
},
{
value: "modal",
icon: AppWindow,
title: "모달",
description: "팝업으로 표시해요",
},
{
value: "form-modal",
icon: FileInput,
title: "폼 모달",
description: "입력 폼 팝업이에요",
},
] as const;
interface V2GroupConfigPanelProps {
config: Record<string, any>;
@ -23,12 +84,17 @@ export const V2GroupConfigPanel: React.FC<V2GroupConfigPanelProps> = ({
config,
onChange,
}) => {
// 설정 업데이트 핸들러
const [advancedOpen, setAdvancedOpen] = useState(false);
const updateConfig = (field: string, value: any) => {
onChange({ ...config, [field]: value });
};
// 탭 관리
const currentGroupType = config.groupType || config.type || "section";
const isSectionType = currentGroupType === "section" || currentGroupType === "accordion";
const isModalType = currentGroupType === "modal" || currentGroupType === "form-modal";
const isTabsType = currentGroupType === "tabs";
const tabs = config.tabs || [];
const addTab = () => {
@ -49,168 +115,241 @@ export const V2GroupConfigPanel: React.FC<V2GroupConfigPanelProps> = ({
return (
<div className="space-y-4">
{/* 그룹 타입 */}
{/* ─── 1단계: 그룹 타입 선택 (카드) ─── */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.groupType || config.type || "section"}
onValueChange={(value) => updateConfig("groupType", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="타입 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="section"></SelectItem>
<SelectItem value="tabs"></SelectItem>
<SelectItem value="accordion"></SelectItem>
<SelectItem value="card"> </SelectItem>
<SelectItem value="modal"></SelectItem>
<SelectItem value="form-modal"> </SelectItem>
</SelectContent>
</Select>
<p className="text-sm font-medium"> ?</p>
<div className="grid grid-cols-3 gap-2">
{GROUP_TYPE_CARDS.map((card) => {
const Icon = card.icon;
const isSelected = currentGroupType === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateConfig("groupType", card.value)}
className={cn(
"flex flex-col items-center justify-center rounded-lg border p-3 text-center transition-all min-h-[80px]",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50",
)}
>
<Icon className="h-5 w-5 mb-1.5 text-primary" />
<span className="text-xs font-medium leading-tight">
{card.title}
</span>
<span className="text-[10px] text-muted-foreground leading-tight mt-0.5">
{card.description}
</span>
</button>
);
})}
</div>
</div>
<Separator />
{/* ─── 2단계: 기본 설정 ─── */}
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<span className="text-sm font-medium"> </span>
{/* 제목 */}
<div className="space-y-2">
<Label className="text-xs font-medium"></Label>
<Input
value={config.title || ""}
onChange={(e) => updateConfig("title", e.target.value)}
placeholder="그룹 제목"
className="h-8 text-xs"
/>
<div className="flex items-center justify-between py-1.5">
<span className="text-xs text-muted-foreground"></span>
<Input
value={config.title || ""}
onChange={(e) => updateConfig("title", e.target.value)}
placeholder="그룹 제목"
className="h-8 w-[180px] text-sm"
/>
</div>
</div>
{/* 탭 설정 */}
{config.groupType === "tabs" && (
<div className="space-y-2">
{/* ─── 3단계: 타입별 설정 ─── */}
{/* 탭 타입: 탭 목록 관리 */}
{isTabsType && (
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium"> </Label>
<div className="flex items-center gap-2">
<Rows3 className="h-4 w-4 text-primary" />
<span className="text-sm font-medium"> </span>
</div>
<Button
type="button"
variant="ghost"
variant="outline"
size="sm"
onClick={addTab}
className="h-6 px-2 text-xs"
className="h-7 px-2 text-xs"
>
<Plus className="h-3 w-3 mr-1" />
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<div className="space-y-2 max-h-40 overflow-y-auto">
{tabs.map((tab: any, index: number) => (
<div key={index} className="flex items-center gap-2">
<Input
value={tab.id || ""}
onChange={(e) => updateTab(index, "id", e.target.value)}
placeholder="ID"
className="h-7 text-xs flex-1"
/>
<Input
value={tab.label || ""}
onChange={(e) => updateTab(index, "label", e.target.value)}
placeholder="라벨"
className="h-7 text-xs flex-1"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeTab(index)}
className="h-7 w-7 p-0 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
{tabs.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-2">
</p>
)}
</div>
</div>
)}
{/* 섹션/아코디언 옵션 */}
{(config.groupType === "section" || config.groupType === "accordion" || !config.groupType) && (
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="collapsible"
checked={config.collapsible || false}
onCheckedChange={(checked) => updateConfig("collapsible", checked)}
/>
<label htmlFor="collapsible" className="text-xs">/ </label>
</div>
{config.collapsible && (
<div className="flex items-center space-x-2">
<Checkbox
id="defaultOpen"
checked={config.defaultOpen !== false}
onCheckedChange={(checked) => updateConfig("defaultOpen", checked)}
/>
<label htmlFor="defaultOpen" className="text-xs"> </label>
{tabs.length > 0 ? (
<div className="max-h-40 space-y-1.5 overflow-y-auto">
{tabs.map((tab: any, index: number) => (
<div key={index} className="flex items-center gap-1.5">
<Input
value={tab.id || ""}
onChange={(e) => updateTab(index, "id", e.target.value)}
placeholder="ID"
className="h-8 flex-1 text-sm"
/>
<Input
value={tab.label || ""}
onChange={(e) => updateTab(index, "label", e.target.value)}
placeholder="라벨"
className="h-8 flex-1 text-sm"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeTab(index)}
className="text-destructive h-8 w-8 shrink-0"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
) : (
<div className="text-center py-6 text-muted-foreground">
<Rows3 className="mx-auto mb-2 h-8 w-8 opacity-30" />
<p className="text-sm"> </p>
<p className="text-xs mt-0.5"> </p>
</div>
)}
</div>
)}
{/* 모달 옵션 */}
{(config.groupType === "modal" || config.groupType === "form-modal") && (
<div className="space-y-3">
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.modalSize || "md"}
onValueChange={(value) => updateConfig("modalSize", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm"> (400px)</SelectItem>
<SelectItem value="md"> (600px)</SelectItem>
<SelectItem value="lg"> (800px)</SelectItem>
<SelectItem value="xl"> (1000px)</SelectItem>
<SelectItem value="full"> </SelectItem>
</SelectContent>
</Select>
{/* 섹션/아코디언 타입: 접기/펴기 옵션 */}
{isSectionType && (
<div className="space-y-2">
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm">/</p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.collapsible || false}
onCheckedChange={(checked) => updateConfig("collapsible", checked)}
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="closeable"
{config.collapsible && (
<div className="ml-4 border-l-2 border-primary/20 pl-3">
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.defaultOpen !== false}
onCheckedChange={(checked) => updateConfig("defaultOpen", checked)}
/>
</div>
</div>
)}
</div>
)}
{/* 모달/폼모달 타입: 모달 옵션 */}
{isModalType && (
<div className="space-y-3">
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<AppWindow className="h-4 w-4 text-primary" />
<span className="text-sm font-medium"> </span>
</div>
<div className="flex items-center justify-between py-1.5">
<span className="text-xs text-muted-foreground"> </span>
<Select
value={config.modalSize || "md"}
onValueChange={(value) => updateConfig("modalSize", value)}
>
<SelectTrigger className="h-8 w-[180px] text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm"> (400px)</SelectItem>
<SelectItem value="md"> (600px)</SelectItem>
<SelectItem value="lg"> (800px)</SelectItem>
<SelectItem value="xl"> (1000px)</SelectItem>
<SelectItem value="full"> </SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
X
</p>
</div>
<Switch
checked={config.closeable !== false}
onCheckedChange={(checked) => updateConfig("closeable", checked)}
/>
<label htmlFor="closeable" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="backdrop"
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.backdrop !== false}
onCheckedChange={(checked) => updateConfig("backdrop", checked)}
/>
<label htmlFor="backdrop" className="text-xs"> </label>
</div>
</div>
)}
{/* 헤더 표시 여부 */}
<Separator />
<div className="flex items-center space-x-2">
<Checkbox
id="showHeader"
checked={config.showHeader !== false}
onCheckedChange={(checked) => updateConfig("showHeader", checked)}
/>
<label htmlFor="showHeader" className="text-xs"> </label>
</div>
{/* ─── 4단계: 고급 설정 (기본 접혀있음) ─── */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
advancedOpen && "rotate-180",
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.showHeader !== false}
onCheckedChange={(checked) => updateConfig("showHeader", checked)}
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
@ -218,5 +357,3 @@ export const V2GroupConfigPanel: React.FC<V2GroupConfigPanelProps> = ({
V2GroupConfigPanel.displayName = "V2GroupConfigPanel";
export default V2GroupConfigPanel;

View File

@ -2,15 +2,28 @@
/**
* V2Hierarchy
* .
* UX: 계층 -> -> ()
*/
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox";
import { Switch } from "@/components/ui/switch";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
GitFork,
Building2,
Layers,
ListTree,
Database,
FileJson,
Globe,
Settings,
ChevronDown,
Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { tableTypeApi } from "@/lib/api/screen";
interface V2HierarchyConfigPanelProps {
@ -28,24 +41,33 @@ interface ColumnOption {
displayName: string;
}
const HIERARCHY_TYPE_CARDS = [
{ value: "tree", icon: GitFork, title: "트리", description: "계층 구조를 표시해요" },
{ value: "org-chart", icon: Building2, title: "조직도", description: "조직 구조를 보여줘요" },
{ value: "bom", icon: Layers, title: "BOM", description: "부품 구성을 관리해요" },
{ value: "cascading", icon: ListTree, title: "연쇄 선택", description: "단계별로 선택해요" },
] as const;
const DATA_SOURCE_CARDS = [
{ value: "static", icon: FileJson, title: "정적 데이터", description: "직접 입력해요" },
{ value: "db", icon: Database, title: "데이터베이스", description: "테이블에서 가져와요" },
{ value: "api", icon: Globe, title: "API", description: "외부 API로 조회해요" },
] as const;
export const V2HierarchyConfigPanel: React.FC<V2HierarchyConfigPanelProps> = ({
config,
onChange,
}) => {
// 테이블 목록
const [tables, setTables] = useState<TableOption[]>([]);
const [loadingTables, setLoadingTables] = useState(false);
// 컬럼 목록
const [columns, setColumns] = useState<ColumnOption[]>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
// 설정 업데이트 핸들러
const updateConfig = (field: string, value: any) => {
onChange({ ...config, [field]: value });
};
// 테이블 목록 로드
useEffect(() => {
const loadTables = async () => {
setLoadingTables(true);
@ -64,14 +86,9 @@ export const V2HierarchyConfigPanel: React.FC<V2HierarchyConfigPanelProps> = ({
loadTables();
}, []);
// 테이블 선택 시 컬럼 목록 로드
useEffect(() => {
const loadColumns = async () => {
if (!config.tableName) {
setColumns([]);
return;
}
if (!config.tableName) { setColumns([]); return; }
setLoadingColumns(true);
try {
const data = await tableTypeApi.getColumns(config.tableName);
@ -88,37 +105,47 @@ export const V2HierarchyConfigPanel: React.FC<V2HierarchyConfigPanelProps> = ({
loadColumns();
}, [config.tableName]);
const hierarchyType = config.hierarchyType || config.type || "tree";
const dataSource = config.dataSource || "static";
return (
<div className="space-y-4">
{/* 계층 타입 */}
{/* ─── 1단계: 계층 타입 선택 (카드) ─── */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.hierarchyType || config.type || "tree"}
onValueChange={(value) => updateConfig("hierarchyType", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="타입 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="tree"></SelectItem>
<SelectItem value="org-chart"></SelectItem>
<SelectItem value="bom">BOM (Bill of Materials)</SelectItem>
<SelectItem value="cascading"> </SelectItem>
</SelectContent>
</Select>
<p className="text-sm font-medium"> ?</p>
<div className="grid grid-cols-2 gap-2">
{HIERARCHY_TYPE_CARDS.map((card) => {
const Icon = card.icon;
const isSelected = hierarchyType === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateConfig("hierarchyType", card.value)}
className={cn(
"flex flex-col items-center justify-center rounded-lg border p-3 text-center transition-all min-h-[80px]",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<Icon className="h-5 w-5 mb-1.5 text-primary" />
<span className="text-xs font-medium leading-tight">{card.title}</span>
<span className="text-[10px] text-muted-foreground leading-tight mt-0.5">{card.description}</span>
</button>
);
})}
</div>
</div>
<Separator />
{/* 뷰 모드 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
{/* ─── 2단계: 표시 방식 ─── */}
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<span className="text-sm font-medium"> </span>
<Select
value={config.viewMode || "tree"}
onValueChange={(value) => updateConfig("viewMode", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="방식 선택" />
</SelectTrigger>
<SelectContent>
@ -128,279 +155,345 @@ export const V2HierarchyConfigPanel: React.FC<V2HierarchyConfigPanelProps> = ({
<SelectItem value="cascading"> </SelectItem>
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Separator />
{/* 데이터 소스 */}
{/* ─── 3단계: 데이터 소스 선택 (카드) ─── */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.dataSource || "static"}
onValueChange={(value) => updateConfig("dataSource", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="소스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="static"> </SelectItem>
<SelectItem value="db"></SelectItem>
<SelectItem value="api">API</SelectItem>
</SelectContent>
</Select>
<p className="text-sm font-medium"> ?</p>
<div className="grid grid-cols-3 gap-2">
{DATA_SOURCE_CARDS.map((card) => {
const Icon = card.icon;
const isSelected = dataSource === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateConfig("dataSource", card.value)}
className={cn(
"flex flex-col items-center justify-center rounded-lg border p-3 text-center transition-all min-h-[80px]",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<Icon className="h-5 w-5 mb-1.5 text-primary" />
<span className="text-xs font-medium leading-tight">{card.title}</span>
<span className="text-[10px] text-muted-foreground leading-tight mt-0.5">{card.description}</span>
</button>
);
})}
</div>
</div>
{/* DB 설정 */}
{config.dataSource === "db" && (
<div className="space-y-3">
{/* 테이블 선택 */}
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"></Label>
<Select
value={config.tableName || ""}
onValueChange={(value) => {
updateConfig("tableName", value);
// 테이블 변경 시 컬럼 초기화
updateConfig("idColumn", "");
updateConfig("parentIdColumn", "");
updateConfig("labelColumn", "");
}}
disabled={loadingTables}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={loadingTables ? "로딩 중..." : "테이블 선택"} />
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
{/* ─── DB 소스 설정 ─── */}
{dataSource === "db" && (
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-primary" />
<span className="text-sm font-medium"> </span>
</div>
{/* 컬럼 선택 */}
<div>
<p className="mb-1.5 text-xs text-muted-foreground"> </p>
{loadingTables ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
) : (
<Select
value={config.tableName || ""}
onValueChange={(value) => {
updateConfig("tableName", value);
updateConfig("idColumn", "");
updateConfig("parentIdColumn", "");
updateConfig("labelColumn", "");
}}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{config.tableName && (
<>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground">ID </Label>
<Select
value={config.idColumn || ""}
onValueChange={(value) => updateConfig("idColumn", value)}
disabled={loadingColumns}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={loadingColumns ? "로딩 중..." : "선택"} />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
{loadingColumns ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> ID </Label>
<Select
value={config.parentIdColumn || ""}
onValueChange={(value) => updateConfig("parentIdColumn", value)}
disabled={loadingColumns}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={loadingColumns ? "로딩 중..." : "선택"} />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={config.labelColumn || ""}
onValueChange={(value) => updateConfig("labelColumn", value)}
disabled={loadingColumns}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={loadingColumns ? "로딩 중..." : "선택"} />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<>
<div className="space-y-2">
<p className="text-xs text-muted-foreground"> </p>
<div className="flex gap-2">
<div className="flex-1">
<Label className="text-[10px] text-muted-foreground">ID </Label>
<Select
value={config.idColumn || ""}
onValueChange={(value) => updateConfig("idColumn", value)}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1">
<Label className="text-[10px] text-muted-foreground"> ID </Label>
<Select
value={config.parentIdColumn || ""}
onValueChange={(value) => updateConfig("parentIdColumn", value)}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<div>
<p className="mb-1.5 text-xs text-muted-foreground"> </p>
<Select
value={config.labelColumn || ""}
onValueChange={(value) => updateConfig("labelColumn", value)}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="표시할 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
</>
)}
</div>
)}
{/* API 설정 */}
{config.dataSource === "api" && (
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground">API </Label>
<Input
value={config.apiEndpoint || ""}
onChange={(e) => updateConfig("apiEndpoint", e.target.value)}
placeholder="/api/hierarchy"
className="h-8 text-xs"
/>
{/* ─── API 소스 설정 ─── */}
{dataSource === "api" && (
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">API </span>
</div>
<div>
<p className="mb-1.5 text-xs text-muted-foreground"> URL</p>
<Input
value={config.apiEndpoint || ""}
onChange={(e) => updateConfig("apiEndpoint", e.target.value)}
placeholder="/api/hierarchy"
className="h-8 text-sm"
/>
</div>
</div>
)}
<Separator />
{/* ─── BOM 전용 설정 ─── */}
{hierarchyType === "bom" && (
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">BOM </span>
</div>
{/* 옵션 */}
<div className="space-y-3">
<Label className="text-xs font-medium"></Label>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Input
type="number"
value={config.maxLevel || ""}
onChange={(e) => updateConfig("maxLevel", e.target.value ? Number(e.target.value) : undefined)}
placeholder="제한 없음"
min="1"
className="h-8 text-xs"
/>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.showQuantity !== false}
onCheckedChange={(checked) => updateConfig("showQuantity", checked)}
/>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Select
value={config.quantityColumn || ""}
onValueChange={(value) => updateConfig("quantityColumn", value)}
disabled={loadingColumns || !config.tableName}
>
<SelectTrigger className="h-7 w-[160px] text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
<div className="flex items-center space-x-2">
<Checkbox
id="draggable"
checked={config.draggable || false}
onCheckedChange={(checked) => updateConfig("draggable", checked)}
/>
<label htmlFor="draggable" className="text-xs"> </label>
{/* ─── Cascading 전용 설정 ─── */}
{hierarchyType === "cascading" && (
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<ListTree className="h-4 w-4 text-primary" />
<span className="text-sm font-medium"> </span>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Select
value={config.parentField || ""}
onValueChange={(value) => updateConfig("parentField", value)}
disabled={loadingColumns || !config.tableName}
>
<SelectTrigger className="h-7 w-[160px] text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.clearOnParentChange !== false}
onCheckedChange={(checked) => updateConfig("clearOnParentChange", checked)}
/>
</div>
</div>
)}
<div className="flex items-center space-x-2">
<Checkbox
id="selectable"
checked={config.selectable !== false}
onCheckedChange={(checked) => updateConfig("selectable", checked)}
/>
<label htmlFor="selectable" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="multiSelect"
checked={config.multiSelect || false}
onCheckedChange={(checked) => updateConfig("multiSelect", checked)}
/>
<label htmlFor="multiSelect" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showCheckbox"
checked={config.showCheckbox || false}
onCheckedChange={(checked) => updateConfig("showCheckbox", checked)}
/>
<label htmlFor="showCheckbox" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="expandAll"
checked={config.expandAll || false}
onCheckedChange={(checked) => updateConfig("expandAll", checked)}
/>
<label htmlFor="expandAll" className="text-xs"> </label>
</div>
</div>
{/* BOM 전용 설정 */}
{config.hierarchyType === "bom" && (
<>
<Separator />
<div className="space-y-3">
<Label className="text-xs font-medium">BOM </Label>
<div className="flex items-center space-x-2">
<Checkbox
id="showQuantity"
checked={config.showQuantity !== false}
onCheckedChange={(checked) => updateConfig("showQuantity", checked)}
{/* ─── 고급 설정 (Collapsible) ─── */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
advancedOpen && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
{/* 최대 레벨 */}
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Input
type="number"
value={config.maxLevel || ""}
onChange={(e) => updateConfig("maxLevel", e.target.value ? Number(e.target.value) : undefined)}
placeholder="제한 없음"
min="1"
className="h-7 w-[120px] text-xs"
/>
<label htmlFor="showQuantity" className="text-xs"> </label>
</div>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={config.quantityColumn || ""}
onValueChange={(value) => updateConfig("quantityColumn", value)}
disabled={loadingColumns || !config.tableName}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 토글 옵션들 */}
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.draggable || false}
onCheckedChange={(checked) => updateConfig("draggable", checked)}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.selectable !== false}
onCheckedChange={(checked) => updateConfig("selectable", checked)}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.multiSelect || false}
onCheckedChange={(checked) => updateConfig("multiSelect", checked)}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.showCheckbox || false}
onCheckedChange={(checked) => updateConfig("showCheckbox", checked)}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.expandAll || false}
onCheckedChange={(checked) => updateConfig("expandAll", checked)}
/>
</div>
</div>
</>
)}
{/* 연쇄 선택박스 전용 설정 */}
{config.hierarchyType === "cascading" && (
<>
<Separator />
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={config.parentField || ""}
onValueChange={(value) => updateConfig("parentField", value)}
disabled={loadingColumns || !config.tableName}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="clearOnParentChange"
checked={config.clearOnParentChange !== false}
onCheckedChange={(checked) => updateConfig("clearOnParentChange", checked)}
/>
<label htmlFor="clearOnParentChange" className="text-xs"> </label>
</div>
</div>
</>
)}
</CollapsibleContent>
</Collapsible>
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,609 @@
"use client";
/**
* V2
*/
import React, { useState, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Badge } from "@/components/ui/badge";
import {
Settings, ChevronDown, ChevronRight, Plus, Trash2, Check, ChevronsUpDown,
Database, Monitor, Columns, List, Filter, Eye,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { ItemRoutingConfig, ProcessColumnDef, ColumnDef, ItemFilterCondition } from "@/lib/registry/components/v2-item-routing/types";
import { defaultConfig } from "@/lib/registry/components/v2-item-routing/config";
interface V2ItemRoutingConfigPanelProps {
config: Partial<ItemRoutingConfig>;
onChange: (config: Partial<ItemRoutingConfig>) => void;
}
interface TableInfo { tableName: string; displayName?: string; }
interface ColumnInfo { columnName: string; displayName?: string; dataType?: string; }
interface ScreenInfo { screenId: number; screenName: string; screenCode: string; }
// ─── 공용: 테이블 Combobox ───
function TableCombobox({ value, onChange, tables, loading }: {
value: string; onChange: (v: string) => void; tables: TableInfo[]; loading: boolean;
}) {
const [open, setOpen] = useState(false);
const selected = tables.find((t) => t.tableName === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open} className="h-7 w-full justify-between text-xs" disabled={loading}>
{loading ? "로딩 중..." : selected ? selected.displayName || selected.tableName : "테이블 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-4 text-center text-xs"> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{tables.map((t) => (
<CommandItem key={t.tableName} value={`${t.displayName || ""} ${t.tableName}`}
onSelect={() => { onChange(t.tableName); setOpen(false); }} className="text-xs">
<Check className={cn("mr-2 h-3 w-3", value === t.tableName ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span className="font-medium">{t.displayName || t.tableName}</span>
{t.displayName && <span className="text-[10px] text-muted-foreground">{t.tableName}</span>}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// ─── 공용: 컬럼 Combobox ───
function ColumnCombobox({ value, onChange, tableName, placeholder }: {
value: string; onChange: (v: string, displayName?: string) => void; tableName: string; placeholder?: string;
}) {
const [open, setOpen] = useState(false);
const [columns, setColumns] = useState<ColumnInfo[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!tableName) { setColumns([]); return; }
const load = async () => {
setLoading(true);
try {
const { tableManagementApi } = await import("@/lib/api/tableManagement");
const res = await tableManagementApi.getColumnList(tableName);
if (res.success && res.data?.columns) setColumns(res.data.columns);
} catch { /* ignore */ } finally { setLoading(false); }
};
load();
}, [tableName]);
const selected = columns.find((c) => c.columnName === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open} className="h-7 w-full justify-between text-xs" disabled={loading || !tableName}>
<span className="truncate">
{loading ? "로딩..." : !tableName ? "테이블 먼저 선택" : selected ? selected.displayName || selected.columnName : placeholder || "컬럼 선택"}
</span>
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[240px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-4 text-center text-xs"> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{columns.map((c) => (
<CommandItem key={c.columnName} value={`${c.displayName || ""} ${c.columnName}`}
onSelect={() => { onChange(c.columnName, c.displayName); setOpen(false); }} className="text-xs">
<Check className={cn("mr-2 h-3 w-3", value === c.columnName ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span className="font-medium">{c.displayName || c.columnName}</span>
{c.displayName && <span className="text-[10px] text-muted-foreground">{c.columnName}</span>}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// ─── 공용: 화면 Combobox ───
function ScreenCombobox({ value, onChange }: { value?: number; onChange: (v?: number) => void; }) {
const [open, setOpen] = useState(false);
const [screens, setScreens] = useState<ScreenInfo[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const load = async () => {
setLoading(true);
try {
const { screenApi } = await import("@/lib/api/screen");
const res = await screenApi.getScreens({ page: 1, size: 1000 });
if (res.data) {
setScreens(res.data.map((s: any) => ({
screenId: s.screenId, screenName: s.screenName || `화면 ${s.screenId}`, screenCode: s.screenCode || "",
})));
}
} catch { /* ignore */ } finally { setLoading(false); }
};
load();
}, []);
const selected = screens.find((s) => s.screenId === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open} className="h-7 w-full justify-between text-xs" disabled={loading}>
<span className="truncate">{loading ? "로딩..." : selected ? selected.screenName : "화면 선택"}</span>
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[260px] p-0" align="start">
<Command>
<CommandInput placeholder="화면 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-4 text-center text-xs"> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{screens.map((s) => (
<CommandItem key={s.screenId} value={`${s.screenName} ${s.screenCode} ${s.screenId}`}
onSelect={() => { onChange(s.screenId); setOpen(false); }} className="text-xs">
<Check className={cn("mr-2 h-3 w-3", value === s.screenId ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span className="font-medium">{s.screenName}</span>
<span className="text-[10px] text-muted-foreground">ID: {s.screenId}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// ─── 컬럼 편집 카드 (품목/모달/공정 공용) ───
function ColumnEditor({ columns, onChange, tableName, title, icon }: {
columns: ColumnDef[];
onChange: (cols: ColumnDef[]) => void;
tableName: string;
title: string;
icon: React.ReactNode;
}) {
const [open, setOpen] = useState(false);
const addColumn = () => onChange([...columns, { name: "", label: "새 컬럼", width: 100, align: "left" }]);
const removeColumn = (idx: number) => onChange(columns.filter((_, i) => i !== idx));
const updateColumn = (idx: number, field: keyof ColumnDef, value: string | number) => {
const next = [...columns];
next[idx] = { ...next[idx], [field]: value };
onChange(next);
};
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<button type="button" className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50">
<div className="flex items-center gap-2">
{icon}
<span className="text-sm font-medium">{title}</span>
<Badge variant="secondary" className="text-[10px] h-5">{columns.length}</Badge>
</div>
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", open && "rotate-180")} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-3 space-y-1.5">
{columns.map((col, idx) => (
<Collapsible key={idx}>
<div className="rounded-md border">
<CollapsibleTrigger asChild>
<button type="button" className="flex w-full items-center gap-1.5 px-2.5 py-1.5 text-left hover:bg-muted/30 transition-colors">
<ChevronRight className="h-3 w-3 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90 shrink-0" />
<span className="text-[10px] text-muted-foreground font-medium shrink-0">#{idx + 1}</span>
<span className="text-xs font-medium truncate flex-1 min-w-0">{col.label || col.name || "미설정"}</span>
<Badge variant="outline" className="text-[9px] h-4 shrink-0">{col.name || "?"}</Badge>
<Button type="button" variant="ghost" size="sm"
onClick={(e) => { e.stopPropagation(); removeColumn(idx); }}
className="h-5 w-5 p-0 text-muted-foreground hover:text-destructive shrink-0">
<Trash2 className="h-3 w-3" />
</Button>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="grid grid-cols-2 gap-1.5 border-t px-2.5 py-2">
<div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span>
<ColumnCombobox value={col.name} onChange={(v, displayName) => {
updateColumn(idx, "name", v);
if (!col.label || col.label === "새 컬럼" || col.label === col.name) updateColumn(idx, "label", displayName || v);
}} tableName={tableName} placeholder="컬럼 선택" />
</div>
<div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span>
<Input value={col.label} onChange={(e) => updateColumn(idx, "label", e.target.value)} className="h-7 text-xs" />
</div>
<div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span>
<Input type="number" value={col.width || 100} onChange={(e) => updateColumn(idx, "width", parseInt(e.target.value) || 100)} className="h-7 text-xs" />
</div>
<div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span>
<Select value={col.align || "left"} onValueChange={(v) => updateColumn(idx, "align", v)}>
<SelectTrigger className="h-7 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="left"></SelectItem>
<SelectItem value="center"></SelectItem>
<SelectItem value="right"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CollapsibleContent>
</div>
</Collapsible>
))}
<Button variant="outline" size="sm" className="h-7 w-full gap-1 text-xs border-dashed" onClick={addColumn}>
<Plus className="h-3 w-3" />
</Button>
</div>
</CollapsibleContent>
</Collapsible>
);
}
// ─── 메인 컴포넌트 ───
export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> = ({ config: configProp, onChange }) => {
const [tables, setTables] = useState<TableInfo[]>([]);
const [loadingTables, setLoadingTables] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [dataSourceOpen, setDataSourceOpen] = useState(false);
const [layoutOpen, setLayoutOpen] = useState(false);
const [filterOpen, setFilterOpen] = useState(false);
const config: ItemRoutingConfig = {
...defaultConfig,
...configProp,
dataSource: { ...defaultConfig.dataSource, ...configProp?.dataSource },
modals: { ...defaultConfig.modals, ...configProp?.modals },
processColumns: configProp?.processColumns?.length ? configProp.processColumns : defaultConfig.processColumns,
itemDisplayColumns: configProp?.itemDisplayColumns?.length ? configProp.itemDisplayColumns : defaultConfig.itemDisplayColumns,
modalDisplayColumns: configProp?.modalDisplayColumns?.length ? configProp.modalDisplayColumns : defaultConfig.modalDisplayColumns,
itemFilterConditions: configProp?.itemFilterConditions || [],
};
useEffect(() => {
const loadTables = async () => {
setLoadingTables(true);
try {
const { tableManagementApi } = await import("@/lib/api/tableManagement");
const res = await tableManagementApi.getTableList();
if (res.success && res.data) {
setTables(res.data.map((t: any) => ({ tableName: t.tableName, displayName: t.displayName || t.tableName })));
}
} catch { /* ignore */ } finally { setLoadingTables(false); }
};
loadTables();
}, []);
const dispatchConfigEvent = (newConfig: Partial<ItemRoutingConfig>) => {
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("componentConfigChanged", { detail: { config: { ...config, ...newConfig } } }));
}
};
const update = (partial: Partial<ItemRoutingConfig>) => {
const merged = { ...configProp, ...partial };
onChange(merged);
dispatchConfigEvent(partial);
};
const updateDataSource = (field: string, value: string) => {
const newDS = { ...config.dataSource, [field]: value };
onChange({ ...configProp, dataSource: newDS });
dispatchConfigEvent({ dataSource: newDS });
};
const updateModals = (field: string, value?: number) => {
const newM = { ...config.modals, [field]: value };
onChange({ ...configProp, modals: newM });
dispatchConfigEvent({ modals: newM });
};
// 필터 조건 관리
const filters = config.itemFilterConditions || [];
const addFilter = () => update({ itemFilterConditions: [...filters, { column: "", operator: "equals", value: "" }] });
const removeFilter = (idx: number) => update({ itemFilterConditions: filters.filter((_, i) => i !== idx) });
const updateFilter = (idx: number, field: keyof ItemFilterCondition, val: string) => {
const next = [...filters];
next[idx] = { ...next[idx], [field]: val };
update({ itemFilterConditions: next });
};
return (
<div className="space-y-4">
{/* ─── 품목 목록 모드 ─── */}
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center gap-2">
<List className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
</div>
<p className="text-[10px] text-muted-foreground"> </p>
<div className="grid grid-cols-2 gap-2">
<button type="button"
className={cn("flex flex-col items-center gap-1 rounded-md border px-3 py-2.5 text-xs transition-colors",
(config.itemListMode || "all") === "all" ? "border-primary bg-primary/5 text-primary" : "border-input hover:bg-muted/50")}
onClick={() => update({ itemListMode: "all" })}>
<span className="font-medium"> </span>
<span className="text-[10px] text-muted-foreground"> </span>
</button>
<button type="button"
className={cn("flex flex-col items-center gap-1 rounded-md border px-3 py-2.5 text-xs transition-colors",
config.itemListMode === "registered" ? "border-primary bg-primary/5 text-primary" : "border-input hover:bg-muted/50")}
onClick={() => update({ itemListMode: "registered" })}>
<span className="font-medium"> </span>
<span className="text-[10px] text-muted-foreground"> </span>
</button>
</div>
{config.itemListMode === "registered" && (
<p className="text-[10px] text-muted-foreground pt-1">
ID를 .
</p>
)}
</div>
{/* ─── 품목 표시 컬럼 ─── */}
<ColumnEditor
columns={config.itemDisplayColumns || []}
onChange={(cols) => update({ itemDisplayColumns: cols })}
tableName={config.dataSource.itemTable}
title="품목 목록 컬럼"
icon={<Eye className="h-4 w-4 text-muted-foreground" />}
/>
{/* ─── 모달 표시 컬럼 (등록 모드에서만 의미 있지만 항상 설정 가능) ─── */}
<ColumnEditor
columns={config.modalDisplayColumns || []}
onChange={(cols) => update({ modalDisplayColumns: cols })}
tableName={config.dataSource.itemTable}
title="품목 추가 모달 컬럼"
icon={<Columns className="h-4 w-4 text-muted-foreground" />}
/>
{/* ─── 품목 필터 조건 ─── */}
<Collapsible open={filterOpen} onOpenChange={setFilterOpen}>
<CollapsibleTrigger asChild>
<button type="button" className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50">
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
{filters.length > 0 && <Badge variant="secondary" className="text-[10px] h-5">{filters.length}</Badge>}
</div>
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", filterOpen && "rotate-180")} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-3 space-y-2">
<p className="text-[10px] text-muted-foreground"> </p>
{filters.map((f, idx) => (
<div key={idx} className="flex items-end gap-1.5 rounded-md border p-2">
<div className="flex-1 space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span>
<ColumnCombobox value={f.column} onChange={(v) => updateFilter(idx, "column", v)}
tableName={config.dataSource.itemTable} placeholder="필터 컬럼" />
</div>
<div className="w-[90px] space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span>
<Select value={f.operator} onValueChange={(v) => updateFilter(idx, "operator", v)}>
<SelectTrigger className="h-7 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="equals"></SelectItem>
<SelectItem value="contains"></SelectItem>
<SelectItem value="not_equals"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex-1 space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span>
<Input value={f.value} onChange={(e) => updateFilter(idx, "value", e.target.value)}
placeholder="필터값" className="h-7 text-xs" />
</div>
<Button type="button" variant="ghost" size="sm"
onClick={() => removeFilter(idx)}
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive shrink-0">
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
<Button variant="outline" size="sm" className="h-7 w-full gap-1 text-xs border-dashed" onClick={addFilter}>
<Plus className="h-3 w-3" />
</Button>
</div>
</CollapsibleContent>
</Collapsible>
{/* ─── 모달 연동 ─── */}
<Collapsible open={modalOpen} onOpenChange={setModalOpen}>
<CollapsibleTrigger asChild>
<button type="button" className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50">
<div className="flex items-center gap-2">
<Monitor className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
<Badge variant="secondary" className="text-[10px] h-5">
{[config.modals.versionAddScreenId, config.modals.processAddScreenId, config.modals.processEditScreenId].filter(Boolean).length}
</Badge>
</div>
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", modalOpen && "rotate-180")} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-3 space-y-2">
<p className="text-[10px] text-muted-foreground"> / · </p>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<ScreenCombobox value={config.modals.versionAddScreenId} onChange={(v) => updateModals("versionAddScreenId", v)} />
</div>
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<ScreenCombobox value={config.modals.processAddScreenId} onChange={(v) => updateModals("processAddScreenId", v)} />
</div>
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<ScreenCombobox value={config.modals.processEditScreenId} onChange={(v) => updateModals("processEditScreenId", v)} />
</div>
</div>
</div>
</CollapsibleContent>
</Collapsible>
{/* ─── 공정 테이블 컬럼 ─── */}
<ColumnEditor
columns={config.processColumns}
onChange={(cols) => update({ processColumns: cols })}
tableName={config.dataSource.routingDetailTable}
title="공정 테이블 컬럼"
icon={<Columns className="h-4 w-4 text-muted-foreground" />}
/>
{/* ─── 데이터 소스 ─── */}
<Collapsible open={dataSourceOpen} onOpenChange={setDataSourceOpen}>
<CollapsibleTrigger asChild>
<button type="button" className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50">
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
{config.dataSource.itemTable && (
<Badge variant="secondary" className="text-[10px] h-5 truncate max-w-[100px]">{config.dataSource.itemTable}</Badge>
)}
</div>
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", dataSourceOpen && "rotate-180")} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
<div className="space-y-1">
<span className="text-xs text-muted-foreground"> </span>
<TableCombobox value={config.dataSource.itemTable} onChange={(v) => updateDataSource("itemTable", v)} tables={tables} loading={loadingTables} />
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground"> </span>
<ColumnCombobox value={config.dataSource.itemNameColumn} onChange={(v) => updateDataSource("itemNameColumn", v)} tableName={config.dataSource.itemTable} placeholder="품목명" />
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground"> </span>
<ColumnCombobox value={config.dataSource.itemCodeColumn} onChange={(v) => updateDataSource("itemCodeColumn", v)} tableName={config.dataSource.itemTable} placeholder="품목코드" />
</div>
<div className="space-y-1 pt-2">
<span className="text-xs text-muted-foreground"> </span>
<TableCombobox value={config.dataSource.routingVersionTable} onChange={(v) => updateDataSource("routingVersionTable", v)} tables={tables} loading={loadingTables} />
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground"> FK </span>
<ColumnCombobox value={config.dataSource.routingVersionFkColumn} onChange={(v) => updateDataSource("routingVersionFkColumn", v)} tableName={config.dataSource.routingVersionTable} placeholder="FK 컬럼" />
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground"> </span>
<ColumnCombobox value={config.dataSource.routingVersionNameColumn} onChange={(v) => updateDataSource("routingVersionNameColumn", v)} tableName={config.dataSource.routingVersionTable} placeholder="버전명" />
</div>
<div className="space-y-1 pt-2">
<span className="text-xs text-muted-foreground"> </span>
<TableCombobox value={config.dataSource.routingDetailTable} onChange={(v) => updateDataSource("routingDetailTable", v)} tables={tables} loading={loadingTables} />
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground"> FK </span>
<ColumnCombobox value={config.dataSource.routingDetailFkColumn} onChange={(v) => updateDataSource("routingDetailFkColumn", v)} tableName={config.dataSource.routingDetailTable} placeholder="FK 컬럼" />
</div>
<div className="space-y-1 pt-2">
<span className="text-xs text-muted-foreground"> </span>
<TableCombobox value={config.dataSource.processTable} onChange={(v) => updateDataSource("processTable", v)} tables={tables} loading={loadingTables} />
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground"> </span>
<ColumnCombobox value={config.dataSource.processNameColumn} onChange={(v) => updateDataSource("processNameColumn", v)} tableName={config.dataSource.processTable} placeholder="공정명" />
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground"> </span>
<ColumnCombobox value={config.dataSource.processCodeColumn} onChange={(v) => updateDataSource("processCodeColumn", v)} tableName={config.dataSource.processTable} placeholder="공정코드" />
</div>
</div>
</CollapsibleContent>
</Collapsible>
{/* ─── 레이아웃 & 기타 ─── */}
<Collapsible open={layoutOpen} onOpenChange={setLayoutOpen}>
<CollapsibleTrigger asChild>
<button type="button" className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50">
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> & </span>
</div>
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", layoutOpen && "rotate-180")} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
<div className="flex items-center justify-between py-1">
<div>
<span className="text-xs text-muted-foreground"> (%)</span>
<p className="text-[10px] text-muted-foreground mt-0.5"> </p>
</div>
<Input type="number" min={20} max={60} value={config.splitRatio || 40}
onChange={(e) => update({ splitRatio: parseInt(e.target.value) || 40 })} className="h-7 w-[80px] text-xs" />
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Input value={config.leftPanelTitle || ""} onChange={(e) => update({ leftPanelTitle: e.target.value })} placeholder="품목 목록" className="h-7 w-[140px] text-xs" />
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Input value={config.rightPanelTitle || ""} onChange={(e) => update({ rightPanelTitle: e.target.value })} placeholder="공정 순서" className="h-7 w-[140px] text-xs" />
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Input value={config.versionAddButtonText || ""} onChange={(e) => update({ versionAddButtonText: e.target.value })} placeholder="+ 라우팅 버전 추가" className="h-7 w-[140px] text-xs" />
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Input value={config.processAddButtonText || ""} onChange={(e) => update({ processAddButtonText: e.target.value })} placeholder="+ 공정 추가" className="h-7 w-[140px] text-xs" />
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch checked={config.autoSelectFirstVersion !== false} onCheckedChange={(checked) => update({ autoSelectFirstVersion: checked })} />
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">// </p>
</div>
<Switch checked={config.readonly || false} onCheckedChange={(checked) => update({ readonly: checked })} />
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
V2ItemRoutingConfigPanel.displayName = "V2ItemRoutingConfigPanel";
export default V2ItemRoutingConfigPanel;

View File

@ -2,15 +2,68 @@
/**
* V2Layout
* .
* UX: 레이아웃 -> -> ()
*/
import React from "react";
import { Label } from "@/components/ui/label";
import React, { useState } from "react";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
LayoutGrid,
PanelLeftClose,
MoveHorizontal,
Minus,
MonitorPlay,
Settings,
ChevronDown,
} from "lucide-react";
import { cn } from "@/lib/utils";
// ─── 레이아웃 타입 카드 정의 ───
const LAYOUT_TYPE_CARDS = [
{
value: "grid",
icon: LayoutGrid,
title: "그리드",
description: "행과 열로 배치해요",
},
{
value: "split",
icon: PanelLeftClose,
title: "분할 패널",
description: "영역을 나눠서 배치해요",
},
{
value: "flex",
icon: MoveHorizontal,
title: "플렉스",
description: "유연하게 배치해요",
},
{
value: "divider",
icon: Minus,
title: "구분선",
description: "영역을 구분해요",
},
{
value: "screen-embed",
icon: MonitorPlay,
title: "화면 임베드",
description: "다른 화면을 불러와요",
},
] as const;
interface V2LayoutConfigPanelProps {
config: Record<string, any>;
@ -21,166 +74,208 @@ export const V2LayoutConfigPanel: React.FC<V2LayoutConfigPanelProps> = ({
config,
onChange,
}) => {
// 설정 업데이트 핸들러
const [advancedOpen, setAdvancedOpen] = useState(false);
const updateConfig = (field: string, value: any) => {
onChange({ ...config, [field]: value });
};
const currentLayoutType = config.layoutType || config.type || "grid";
const isGridType = currentLayoutType === "grid";
const isSplitType = currentLayoutType === "split";
const isFlexType = currentLayoutType === "flex";
const isScreenEmbedType = currentLayoutType === "screen-embed";
return (
<div className="space-y-4">
{/* 레이아웃 타입 */}
{/* ─── 1단계: 레이아웃 타입 선택 (카드) ─── */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.layoutType || config.type || "grid"}
onValueChange={(value) => updateConfig("layoutType", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="타입 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="grid"></SelectItem>
<SelectItem value="split"> </SelectItem>
<SelectItem value="flex"></SelectItem>
<SelectItem value="divider"></SelectItem>
<SelectItem value="screen-embed"> </SelectItem>
</SelectContent>
</Select>
<p className="text-sm font-medium"> ?</p>
<div className="grid grid-cols-3 gap-2">
{LAYOUT_TYPE_CARDS.map((card) => {
const Icon = card.icon;
const isSelected = currentLayoutType === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateConfig("layoutType", card.value)}
className={cn(
"flex flex-col items-center justify-center rounded-lg border p-3 text-center transition-all min-h-[80px]",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50",
)}
>
<Icon className="h-5 w-5 mb-1.5 text-primary" />
<span className="text-xs font-medium leading-tight">
{card.title}
</span>
<span className="text-[10px] text-muted-foreground leading-tight mt-0.5">
{card.description}
</span>
</button>
);
})}
</div>
</div>
<Separator />
{/* ─── 2단계: 타입별 설정 ─── */}
{/* 그리드 설정 */}
{(config.layoutType === "grid" || !config.layoutType) && (
{/* 그리드 타입 설정 */}
{isGridType && (
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="flex items-center space-x-2">
<Checkbox
id="use12Column"
checked={config.use12Column !== false}
onCheckedChange={(checked) => updateConfig("use12Column", checked)}
/>
<label htmlFor="use12Column" className="text-xs">12 </label>
</div>
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<LayoutGrid className="h-4 w-4 text-primary" />
<span className="text-sm font-medium"> </span>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<div className="flex items-center justify-between py-1.5">
<span className="text-xs text-muted-foreground"> </span>
<Select
value={String(config.columns || 12)}
onValueChange={(value) => updateConfig("columns", Number(value))}
>
<SelectTrigger className="h-8 text-xs">
<SelectTrigger className="h-8 w-[180px] text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1</SelectItem>
<SelectItem value="2">2</SelectItem>
<SelectItem value="3">3</SelectItem>
<SelectItem value="4">4</SelectItem>
<SelectItem value="6">6</SelectItem>
<SelectItem value="12">12</SelectItem>
<SelectItem value="1">1 </SelectItem>
<SelectItem value="2">2 </SelectItem>
<SelectItem value="3">3 </SelectItem>
<SelectItem value="4">4 </SelectItem>
<SelectItem value="6">6 </SelectItem>
<SelectItem value="12">12 </SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> (px)</Label>
<div className="flex items-center justify-between py-1.5">
<span className="text-xs text-muted-foreground"> (px)</span>
<Input
value={config.gap || "16"}
onChange={(e) => updateConfig("gap", e.target.value)}
placeholder="16"
className="h-8 text-xs"
className="h-8 w-[180px] text-sm"
/>
</div>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm">12 </p>
<p className="text-[11px] text-muted-foreground">
12
</p>
</div>
<Switch
checked={config.use12Column !== false}
onCheckedChange={(checked) => updateConfig("use12Column", checked)}
/>
</div>
</div>
)}
{/* 분할 패널 설정 */}
{config.layoutType === "split" && (
{/* 분할 패널 타입 설정 */}
{isSplitType && (
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={config.direction || "horizontal"}
onValueChange={(value) => updateConfig("direction", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="horizontal"></SelectItem>
<SelectItem value="vertical"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<PanelLeftClose className="h-4 w-4 text-primary" />
<span className="text-sm font-medium"> </span>
</div>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> (%)</Label>
<div className="grid grid-cols-2 gap-2">
<Input
type="number"
value={config.splitRatio?.[0] || 50}
onChange={(e) => updateConfig("splitRatio", [Number(e.target.value), 100 - Number(e.target.value)])}
placeholder="50"
min="10"
max="90"
className="h-8 text-xs"
/>
<Input
type="number"
value={config.splitRatio?.[1] || 50}
disabled
className="h-8 text-xs bg-muted"
/>
<div className="flex items-center justify-between py-1.5">
<span className="text-xs text-muted-foreground"> </span>
<Select
value={config.direction || "horizontal"}
onValueChange={(value) => updateConfig("direction", value)}
>
<SelectTrigger className="h-8 w-[180px] text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="horizontal"></SelectItem>
<SelectItem value="vertical"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex gap-2">
<div className="flex-1">
<span className="text-xs text-muted-foreground"> (%)</span>
<Input
type="number"
value={config.splitRatio?.[0] || 50}
onChange={(e) => updateConfig("splitRatio", [Number(e.target.value), 100 - Number(e.target.value)])}
placeholder="50"
min="10"
max="90"
className="mt-1 h-8 text-sm"
/>
</div>
<div className="flex-1">
<span className="text-xs text-muted-foreground"></span>
<Input
type="number"
value={config.splitRatio?.[1] || 50}
disabled
className="mt-1 h-8 text-sm bg-muted"
/>
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="resizable"
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.resizable !== false}
onCheckedChange={(checked) => updateConfig("resizable", checked)}
/>
<label htmlFor="resizable" className="text-xs"> </label>
</div>
</div>
)}
{/* 플렉스 설정 */}
{config.layoutType === "flex" && (
{/* 플렉스 타입 설정 */}
{isFlexType && (
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"></Label>
<Select
value={config.direction || "row"}
onValueChange={(value) => updateConfig("direction", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="row"></SelectItem>
<SelectItem value="column"></SelectItem>
<SelectItem value="row-reverse"> ()</SelectItem>
<SelectItem value="column-reverse"> ()</SelectItem>
</SelectContent>
</Select>
</div>
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<MoveHorizontal className="h-4 w-4 text-primary" />
<span className="text-sm font-medium"> </span>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"></Label>
<div className="flex items-center justify-between py-1.5">
<span className="text-xs text-muted-foreground"></span>
<Select
value={config.direction || "row"}
onValueChange={(value) => updateConfig("direction", value)}
>
<SelectTrigger className="h-8 w-[180px] text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="row"></SelectItem>
<SelectItem value="column"></SelectItem>
<SelectItem value="row-reverse"> ()</SelectItem>
<SelectItem value="column-reverse"> ()</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between py-1.5">
<span className="text-xs text-muted-foreground"></span>
<Select
value={config.justifyContent || "flex-start"}
onValueChange={(value) => updateConfig("justifyContent", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectTrigger className="h-8 w-[180px] text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -192,13 +287,14 @@ export const V2LayoutConfigPanel: React.FC<V2LayoutConfigPanelProps> = ({
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<div className="flex items-center justify-between py-1.5">
<span className="text-xs text-muted-foreground"> </span>
<Select
value={config.alignItems || "stretch"}
onValueChange={(value) => updateConfig("alignItems", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectTrigger className="h-8 w-[180px] text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -209,42 +305,128 @@ export const V2LayoutConfigPanel: React.FC<V2LayoutConfigPanelProps> = ({
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between py-1.5">
<span className="text-xs text-muted-foreground"> (px)</span>
<Input
value={config.gap || "16"}
onChange={(e) => updateConfig("gap", e.target.value)}
placeholder="16"
className="h-8 w-[180px] text-sm"
/>
</div>
</div>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> (px)</Label>
<Input
value={config.gap || "16"}
onChange={(e) => updateConfig("gap", e.target.value)}
placeholder="16"
className="h-8 text-xs"
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="wrap"
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.wrap || false}
onCheckedChange={(checked) => updateConfig("wrap", checked)}
/>
<label htmlFor="wrap" className="text-xs"> </label>
</div>
</div>
)}
{/* 화면 임베드 설정 */}
{config.layoutType === "screen-embed" && (
<div className="space-y-2">
<Label className="text-xs font-medium"> ID</Label>
<Input
type="number"
value={config.screenId || ""}
onChange={(e) => updateConfig("screenId", e.target.value ? Number(e.target.value) : undefined)}
placeholder="화면 ID"
className="h-8 text-xs"
/>
{/* 화면 임베드 타입 설정 */}
{isScreenEmbedType && (
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<MonitorPlay className="h-4 w-4 text-primary" />
<span className="text-sm font-medium"> </span>
</div>
<div className="flex items-center justify-between py-1.5">
<span className="text-xs text-muted-foreground"> ID</span>
<Input
type="number"
value={config.screenId || ""}
onChange={(e) => updateConfig("screenId", e.target.value ? Number(e.target.value) : undefined)}
placeholder="화면 ID 입력"
className="h-8 w-[180px] text-sm"
/>
</div>
</div>
)}
{/* 구분선 타입: 별도 설정 없음 - 빈 상태 표시 */}
{currentLayoutType === "divider" && (
<div className="text-center py-6 text-muted-foreground">
<Minus className="mx-auto mb-2 h-8 w-8 opacity-30" />
<p className="text-sm"> </p>
<p className="text-xs mt-0.5"> </p>
</div>
)}
{/* ─── 3단계: 고급 설정 (그리드/플렉스 타입에서만) ─── */}
{(isGridType || isFlexType || isSplitType) && (
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
advancedOpen && "rotate-180",
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
{isGridType && (
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.responsive !== false}
onCheckedChange={(checked) => updateConfig("responsive", checked)}
/>
</div>
)}
{isFlexType && (
<div className="flex items-center justify-between py-1.5">
<span className="text-xs text-muted-foreground"> </span>
<Input
value={config.minItemWidth || ""}
onChange={(e) => updateConfig("minItemWidth", e.target.value)}
placeholder="자동"
className="h-8 w-[180px] text-sm"
/>
</div>
)}
{isSplitType && (
<div className="flex items-center justify-between py-1.5">
<span className="text-xs text-muted-foreground"> (px)</span>
<Input
type="number"
value={config.minPanelSize || ""}
onChange={(e) => updateConfig("minPanelSize", e.target.value ? Number(e.target.value) : undefined)}
placeholder="자동"
className="h-8 w-[180px] text-sm"
/>
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
)}
</div>
);
};
@ -252,5 +434,3 @@ export const V2LayoutConfigPanel: React.FC<V2LayoutConfigPanelProps> = ({
V2LayoutConfigPanel.displayName = "V2LayoutConfigPanel";
export default V2LayoutConfigPanel;

View File

@ -2,33 +2,51 @@
/**
* V2List
* TableListConfigPanel을 .
* card-display .
* UX: 테이블 -> (Switch) -> (Collapsible)
* / TableListConfigPanel에
*/
import React, { useMemo } from "react";
import React, { useState, useMemo } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Table2, Settings, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { TableListConfigPanel } from "@/lib/registry/components/table-list/TableListConfigPanel";
import { TableListConfig } from "@/lib/registry/components/table-list/types";
interface V2ListConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
/** 현재 화면의 테이블명 */
currentTableName?: string;
}
/**
* V2List
* TableListConfigPanel과
*/
export const V2ListConfigPanel: React.FC<V2ListConfigPanelProps> = ({
config,
onChange,
currentTableName,
}) => {
// V2List config를 TableListConfig 형식으로 변환
const [detailOpen, setDetailOpen] = useState(false);
const updateConfig = (field: string, value: any) => {
onChange({ ...config, [field]: value });
};
const tableName = config.tableName || config.dataSource?.table || currentTableName || "";
const columnCount = (config.columns || []).length;
// ─── V2List config → TableListConfig 변환 (기존 로직 100% 유지) ───
const tableListConfig: TableListConfig = useMemo(() => {
// 컬럼 형식 변환: V2List columns -> TableList columns
const columns = (config.columns || []).map((col: any, index: number) => ({
columnName: col.key || col.columnName || col.field || "",
displayName: col.title || col.header || col.displayName || col.key || col.columnName || col.field || "",
@ -50,27 +68,44 @@ export const V2ListConfigPanel: React.FC<V2ListConfigPanelProps> = ({
columns,
useCustomTable: config.useCustomTable,
customTableName: config.customTableName,
isReadOnly: config.isReadOnly !== false, // V2List는 기본적으로 읽기 전용
displayMode: "table", // 테이블 모드 고정 (카드는 card-display 컴포넌트 사용)
isReadOnly: config.isReadOnly !== false,
displayMode: "table",
showHeader: true,
showFooter: false,
pagination: config.pagination !== false ? {
enabled: true,
pageSize: config.pageSize || 10,
position: "bottom",
showPageSize: true,
showSizeSelector: true,
showPageInfo: true,
pageSizeOptions: [5, 10, 20, 50, 100],
} : {
enabled: false,
pageSize: 10,
position: "bottom",
showPageSize: false,
showSizeSelector: false,
showPageInfo: false,
pageSizeOptions: [10],
},
filter: config.filter,
filter: config.filter || { enabled: false, filters: [] },
dataFilter: config.dataFilter,
actions: config.actions || {
showActions: false,
actions: [],
bulkActions: false,
bulkActionList: [],
},
tableStyle: config.tableStyle || {
theme: "default",
headerStyle: "default",
rowHeight: "normal",
alternateRows: false,
hoverEffect: true,
borderStyle: "light",
},
checkbox: {
enabled: true,
multiple: true,
position: "left",
showHeader: true,
selectAll: true,
},
height: "auto",
autoWidth: true,
@ -81,31 +116,30 @@ export const V2ListConfigPanel: React.FC<V2ListConfigPanelProps> = ({
minColumnWidth: 100,
maxColumnWidth: 300,
},
toolbar: config.toolbar,
linkedFilters: config.linkedFilters,
excludeFilter: config.excludeFilter,
defaultSort: config.defaultSort,
};
}, [config, currentTableName]);
// TableListConfig 변경을 V2List config 형식으로 변환
// ─── TableListConfig 변경 → V2List config 변환 (기존 로직 100% 유지) ───
const handleConfigChange = (partialConfig: Partial<TableListConfig>) => {
const newConfig: Record<string, any> = { ...config };
// 테이블 설정 변환
if (partialConfig.selectedTable !== undefined) {
newConfig.tableName = partialConfig.selectedTable;
if (!newConfig.dataSource) {
newConfig.dataSource = {};
}
if (!newConfig.dataSource) newConfig.dataSource = {};
newConfig.dataSource.table = partialConfig.selectedTable;
}
if (partialConfig.tableName !== undefined) {
newConfig.tableName = partialConfig.tableName;
if (!newConfig.dataSource) {
newConfig.dataSource = {};
}
if (!newConfig.dataSource) newConfig.dataSource = {};
newConfig.dataSource.table = partialConfig.tableName;
}
if (partialConfig.useCustomTable !== undefined) {
newConfig.useCustomTable = partialConfig.useCustomTable;
}
}
if (partialConfig.customTableName !== undefined) {
newConfig.customTableName = partialConfig.customTableName;
}
@ -113,7 +147,6 @@ export const V2ListConfigPanel: React.FC<V2ListConfigPanelProps> = ({
newConfig.isReadOnly = partialConfig.isReadOnly;
}
// 컬럼 형식 변환: TableList columns -> V2List columns
if (partialConfig.columns !== undefined) {
newConfig.columns = partialConfig.columns.map((col: any) => ({
key: col.columnName,
@ -133,32 +166,165 @@ export const V2ListConfigPanel: React.FC<V2ListConfigPanelProps> = ({
}));
}
// 페이지네이션 변환
if (partialConfig.pagination !== undefined) {
newConfig.pagination = partialConfig.pagination?.enabled;
newConfig.pageSize = partialConfig.pagination?.pageSize || 10;
}
// 필터 변환
if (partialConfig.filter !== undefined) {
newConfig.filter = partialConfig.filter;
}
// 데이터 필터 변환
if (partialConfig.dataFilter !== undefined) {
newConfig.dataFilter = partialConfig.dataFilter;
}
console.log("⚙️ V2ListConfigPanel handleConfigChange:", { partialConfig, newConfig });
if (partialConfig.actions !== undefined) {
newConfig.actions = partialConfig.actions;
}
if (partialConfig.tableStyle !== undefined) {
newConfig.tableStyle = partialConfig.tableStyle;
}
if (partialConfig.toolbar !== undefined) {
newConfig.toolbar = partialConfig.toolbar;
}
if (partialConfig.linkedFilters !== undefined) {
newConfig.linkedFilters = partialConfig.linkedFilters;
}
if (partialConfig.excludeFilter !== undefined) {
newConfig.excludeFilter = partialConfig.excludeFilter;
}
if (partialConfig.defaultSort !== undefined) {
newConfig.defaultSort = partialConfig.defaultSort;
}
onChange(newConfig);
};
return (
<TableListConfigPanel
config={tableListConfig}
onChange={handleConfigChange}
screenTableName={currentTableName}
/>
<div className="space-y-4">
{/* ─── 1단계: 테이블 정보 ─── */}
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<Table2 className="h-4 w-4 text-primary" />
<span className="text-sm font-medium"> </span>
</div>
{tableName ? (
<div className="rounded-md border bg-background p-3">
<p className="text-xs text-muted-foreground"> </p>
<p className="mt-0.5 text-sm font-medium">{tableName}</p>
{columnCount > 0 && (
<p className="mt-1 text-[11px] text-muted-foreground">
{columnCount}
</p>
)}
</div>
) : (
<div className="rounded-md border-2 border-dashed p-4 text-center">
<Table2 className="mx-auto mb-2 h-8 w-8 opacity-30 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
</p>
<p className="mt-1 text-xs text-muted-foreground">
</p>
</div>
)}
</div>
{/* ─── 2단계: 기본 옵션 (Switch + 설명) ─── */}
<div className="space-y-2">
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.isReadOnly !== false}
onCheckedChange={(checked) => updateConfig("isReadOnly", checked)}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"></p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.pagination !== false}
onCheckedChange={(checked) => {
updateConfig("pagination", checked);
}}
/>
</div>
{config.pagination !== false && (
<div className="ml-4 border-l-2 border-primary/20 pl-3">
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Select
value={String(config.pageSize || 10)}
onValueChange={(v) => updateConfig("pageSize", Number(v))}
>
<SelectTrigger className="h-8 w-[180px] text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
{/* ─── 3단계: 상세 설정 (컬럼, 필터, 테이블 선택 등) ─── */}
<Collapsible open={detailOpen} onOpenChange={setDetailOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
detailOpen && "rotate-180",
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-2">
<p className="text-xs text-muted-foreground px-2 pb-2">
, ,
</p>
<TableListConfigPanel
config={tableListConfig}
onChange={handleConfigChange}
screenTableName={currentTableName}
/>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};

View File

@ -0,0 +1,573 @@
"use client";
/**
* V2 /
* UX: 데이터 -> -> UI -> DB ()
*/
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Settings, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
interface V2LocationSwapSelectorConfigPanelProps {
config: any;
onChange: (config: any) => void;
tableColumns?: Array<{ columnName: string; columnLabel?: string; dataType?: string }>;
screenTableName?: string;
}
export const V2LocationSwapSelectorConfigPanel: React.FC<V2LocationSwapSelectorConfigPanelProps> = ({
config,
onChange,
tableColumns = [],
screenTableName,
}) => {
const [tables, setTables] = useState<Array<{ name: string; label: string }>>([]);
const [columns, setColumns] = useState<Array<{ name: string; label: string }>>([]);
const [codeCategories, setCodeCategories] = useState<Array<{ value: string; label: string }>>([]);
const [dbSettingsOpen, setDbSettingsOpen] = useState(false);
useEffect(() => {
const loadTables = async () => {
try {
const response = await apiClient.get("/table-management/tables");
if (response.data.success && response.data.data) {
setTables(
response.data.data.map((t: any) => ({
name: t.tableName || t.table_name,
label: t.displayName || t.tableLabel || t.table_label || t.tableName || t.table_name,
}))
);
}
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
}
};
loadTables();
}, []);
useEffect(() => {
const loadColumns = async () => {
const tableName = config?.dataSource?.tableName;
if (!tableName) {
setColumns([]);
return;
}
try {
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
if (response.data.success) {
let columnData = response.data.data;
if (!Array.isArray(columnData) && columnData?.columns) {
columnData = columnData.columns;
}
if (Array.isArray(columnData)) {
setColumns(
columnData.map((c: any) => ({
name: c.columnName || c.column_name || c.name,
label: c.displayName || c.columnLabel || c.column_label || c.columnName || c.column_name || c.name,
}))
);
}
}
} catch (error) {
console.error("컬럼 목록 로드 실패:", error);
}
};
if (config?.dataSource?.type === "table") {
loadColumns();
}
}, [config?.dataSource?.tableName, config?.dataSource?.type]);
useEffect(() => {
const loadCodeCategories = async () => {
try {
const response = await apiClient.get("/code-management/categories");
if (response.data.success && response.data.data) {
setCodeCategories(
response.data.data.map((c: any) => ({
value: c.category_code || c.categoryCode || c.code,
label: c.category_name || c.categoryName || c.name,
}))
);
}
} catch (error: any) {
if (error?.response?.status !== 404) {
console.error("코드 카테고리 로드 실패:", error);
}
}
};
loadCodeCategories();
}, []);
const handleChange = (path: string, value: any) => {
const keys = path.split(".");
const newConfig = { ...config };
let current: any = newConfig;
for (let i = 0; i < keys.length - 1; i++) {
if (!current[keys[i]]) {
current[keys[i]] = {};
}
current[keys[i]] = { ...current[keys[i]] };
current = current[keys[i]];
}
current[keys[keys.length - 1]] = value;
onChange(newConfig);
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("componentConfigChanged", {
detail: { config: newConfig },
})
);
}
};
const dataSourceType = config?.dataSource?.type || "static";
return (
<div className="space-y-4">
{/* ─── 1단계: 데이터 소스 ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
{/* 소스 타입 카드 선택 */}
<div className="grid grid-cols-3 gap-2">
{[
{ value: "static", label: "고정 옵션" },
{ value: "table", label: "테이블" },
{ value: "code", label: "코드 관리" },
].map(({ value, label }) => (
<button
key={value}
type="button"
onClick={() => handleChange("dataSource.type", value)}
className={cn(
"rounded-md border p-2 text-xs transition-colors text-center",
dataSourceType === value
? "border-primary bg-primary/5 text-primary"
: "border-border bg-background text-muted-foreground hover:bg-muted/50"
)}
>
{label}
</button>
))}
</div>
{/* 고정 옵션 설정 */}
{dataSourceType === "static" && (
<div className="space-y-2 pt-1">
<div className="rounded-md border border-primary/20 bg-primary/5 p-2">
<p className="text-[10px] text-primary"> 2 (: 포항 / )</p>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground"> 1 </Label>
<Input
value={config?.dataSource?.staticOptions?.[0]?.value || ""}
onChange={(e) => {
const options = config?.dataSource?.staticOptions || [];
const newOptions = [...options];
newOptions[0] = { ...newOptions[0], value: e.target.value };
handleChange("dataSource.staticOptions", newOptions);
}}
placeholder="포항"
className="h-7 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground"> 1 </Label>
<Input
value={config?.dataSource?.staticOptions?.[0]?.label || ""}
onChange={(e) => {
const options = config?.dataSource?.staticOptions || [];
const newOptions = [...options];
newOptions[0] = { ...newOptions[0], label: e.target.value };
handleChange("dataSource.staticOptions", newOptions);
}}
placeholder="포항"
className="h-7 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground"> 2 </Label>
<Input
value={config?.dataSource?.staticOptions?.[1]?.value || ""}
onChange={(e) => {
const options = config?.dataSource?.staticOptions || [];
const newOptions = [...options];
newOptions[1] = { ...newOptions[1], value: e.target.value };
handleChange("dataSource.staticOptions", newOptions);
}}
placeholder="광양"
className="h-7 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground"> 2 </Label>
<Input
value={config?.dataSource?.staticOptions?.[1]?.label || ""}
onChange={(e) => {
const options = config?.dataSource?.staticOptions || [];
const newOptions = [...options];
newOptions[1] = { ...newOptions[1], label: e.target.value };
handleChange("dataSource.staticOptions", newOptions);
}}
placeholder="광양"
className="h-7 text-xs"
/>
</div>
</div>
</div>
)}
{/* 테이블 설정 */}
{dataSourceType === "table" && (
<div className="space-y-2 pt-1">
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"></span>
<Select
value={config?.dataSource?.tableName || ""}
onValueChange={(value) => handleChange("dataSource.tableName", value)}
>
<SelectTrigger className="h-7 w-[160px] text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.name} value={table.name}>
{table.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Select
value={config?.dataSource?.valueField || ""}
onValueChange={(value) => handleChange("dataSource.valueField", value)}
>
<SelectTrigger className="h-7 w-[160px] text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.name} value={col.name}>{col.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Select
value={config?.dataSource?.labelField || ""}
onValueChange={(value) => handleChange("dataSource.labelField", value)}
>
<SelectTrigger className="h-7 w-[160px] text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.name} value={col.name}>{col.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
{/* 코드 카테고리 설정 */}
{dataSourceType === "code" && (
<div className="pt-1">
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Select
value={config?.dataSource?.codeCategory || ""}
onValueChange={(value) => handleChange("dataSource.codeCategory", value)}
>
<SelectTrigger className="h-7 w-[160px] text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{codeCategories.map((cat) => (
<SelectItem key={cat.value} value={cat.value}>{cat.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
</div>
{/* ─── 2단계: 필드 매핑 ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"> </p>
<p className="text-[11px] text-muted-foreground">
/
{screenTableName && (
<span className="ml-1">( : <strong>{screenTableName}</strong>)</span>
)}
</p>
</div>
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
{tableColumns.length > 0 ? (
<Select
value={config?.departureField || ""}
onValueChange={(value) => handleChange("departureField", value)}
>
<SelectTrigger className="h-7 w-[140px] text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={config?.departureField || "departure"}
onChange={(e) => handleChange("departureField", e.target.value)}
className="h-7 w-[140px] text-xs"
/>
)}
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
{tableColumns.length > 0 ? (
<Select
value={config?.destinationField || ""}
onValueChange={(value) => handleChange("destinationField", value)}
>
<SelectTrigger className="h-7 w-[140px] text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={config?.destinationField || "destination"}
onChange={(e) => handleChange("destinationField", e.target.value)}
className="h-7 w-[140px] text-xs"
/>
)}
</div>
<div className="flex items-center justify-between py-1">
<div>
<span className="text-xs text-muted-foreground"> </span>
<p className="text-[10px] text-muted-foreground mt-0.5"> ()</p>
</div>
{tableColumns.length > 0 ? (
<Select
value={config?.departureLabelField || "__none__"}
onValueChange={(value) => handleChange("departureLabelField", value === "__none__" ? "" : value)}
>
<SelectTrigger className="h-7 w-[140px] text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"></SelectItem>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={config?.departureLabelField || ""}
onChange={(e) => handleChange("departureLabelField", e.target.value)}
placeholder="departure_name"
className="h-7 w-[140px] text-xs"
/>
)}
</div>
<div className="flex items-center justify-between py-1">
<div>
<span className="text-xs text-muted-foreground"> </span>
<p className="text-[10px] text-muted-foreground mt-0.5"> ()</p>
</div>
{tableColumns.length > 0 ? (
<Select
value={config?.destinationLabelField || "__none__"}
onValueChange={(value) => handleChange("destinationLabelField", value === "__none__" ? "" : value)}
>
<SelectTrigger className="h-7 w-[140px] text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"></SelectItem>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={config?.destinationLabelField || ""}
onChange={(e) => handleChange("destinationLabelField", e.target.value)}
placeholder="destination_name"
className="h-7 w-[140px] text-xs"
/>
)}
</div>
</div>
{/* ─── 3단계: UI 설정 ─── */}
<div className="space-y-2">
<p className="text-sm font-medium">UI </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Input
value={config?.departureLabel || "출발지"}
onChange={(e) => handleChange("departureLabel", e.target.value)}
className="h-7 w-[120px] text-xs"
/>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Input
value={config?.destinationLabel || "도착지"}
onChange={(e) => handleChange("destinationLabel", e.target.value)}
className="h-7 w-[120px] text-xs"
/>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"></span>
<Select
value={config?.variant || "card"}
onValueChange={(value) => handleChange("variant", value)}
>
<SelectTrigger className="h-7 w-[120px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="card"></SelectItem>
<SelectItem value="inline"></SelectItem>
<SelectItem value="minimal"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config?.showSwapButton !== false}
onCheckedChange={(checked) => handleChange("showSwapButton", checked)}
/>
</div>
</div>
{/* ─── 4단계: DB 초기값 로드 (Collapsible) ─── */}
<Collapsible open={dbSettingsOpen} onOpenChange={setDbSettingsOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">DB </span>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
dbSettingsOpen && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm">DB에서 </p>
<p className="text-[11px] text-muted-foreground"> DB에 </p>
</div>
<Switch
checked={config?.loadFromDb !== false}
onCheckedChange={(checked) => handleChange("loadFromDb", checked)}
/>
</div>
{config?.loadFromDb !== false && (
<div className="ml-1 border-l-2 border-primary/20 pl-3 space-y-3">
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Select
value={config?.dbTableName || "vehicles"}
onValueChange={(value) => handleChange("dbTableName", value)}
>
<SelectTrigger className="h-7 w-[140px] text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="vehicles">vehicles ()</SelectItem>
{tables.map((table) => (
<SelectItem key={table.name} value={table.name}>
{table.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between py-1">
<div>
<span className="text-xs text-muted-foreground"> </span>
<p className="text-[10px] text-muted-foreground mt-0.5"> ID로 </p>
</div>
<Input
value={config?.dbKeyField || "user_id"}
onChange={(e) => handleChange("dbKeyField", e.target.value)}
placeholder="user_id"
className="h-7 w-[120px] text-xs"
/>
</div>
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
V2LocationSwapSelectorConfigPanel.displayName = "V2LocationSwapSelectorConfigPanel";
export default V2LocationSwapSelectorConfigPanel;

View File

@ -2,15 +2,54 @@
/**
* V2Media
* .
* UX: 미디어 -> -> -> ()
*/
import React from "react";
import { Label } from "@/components/ui/label";
import React, { useState } from "react";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox";
import { Switch } from "@/components/ui/switch";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
FileText,
Image,
Video,
Music,
Settings,
ChevronDown,
} from "lucide-react";
import { cn } from "@/lib/utils";
// ─── 미디어 타입 카드 정의 ───
const MEDIA_TYPE_CARDS = [
{
value: "file",
icon: FileText,
title: "파일",
description: "일반 파일을 업로드해요",
},
{
value: "image",
icon: Image,
title: "이미지",
description: "사진이나 그림을 올려요",
},
{
value: "video",
icon: Video,
title: "비디오",
description: "동영상을 업로드해요",
},
{
value: "audio",
icon: Music,
title: "오디오",
description: "음악이나 녹음을 올려요",
},
] as const;
interface V2MediaConfigPanelProps {
config: Record<string, any>;
@ -21,186 +60,259 @@ export const V2MediaConfigPanel: React.FC<V2MediaConfigPanelProps> = ({
config,
onChange,
}) => {
// 설정 업데이트 핸들러
const [advancedOpen, setAdvancedOpen] = useState(false);
const updateConfig = (field: string, value: any) => {
onChange({ ...config, [field]: value });
};
const currentMediaType = config.mediaType || config.type || "image";
const isImageType = currentMediaType === "image";
const isPlayerType = currentMediaType === "video" || currentMediaType === "audio";
return (
<div className="space-y-4">
{/* 미디어 타입 */}
{/* ─── 1단계: 미디어 타입 선택 (카드) ─── */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.mediaType || config.type || "image"}
onValueChange={(value) => updateConfig("mediaType", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="타입 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="file"></SelectItem>
<SelectItem value="image"></SelectItem>
<SelectItem value="video"></SelectItem>
<SelectItem value="audio"></SelectItem>
</SelectContent>
</Select>
<p className="text-sm font-medium"> ?</p>
<div className="grid grid-cols-2 gap-2">
{MEDIA_TYPE_CARDS.map((card) => {
const Icon = card.icon;
const isSelected = currentMediaType === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateConfig("mediaType", card.value)}
className={cn(
"flex flex-col items-center justify-center rounded-lg border p-3 text-center transition-all min-h-[80px]",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50",
)}
>
<Icon className="h-5 w-5 mb-1.5 text-primary" />
<span className="text-xs font-medium leading-tight">
{card.title}
</span>
<span className="text-[10px] text-muted-foreground leading-tight mt-0.5">
{card.description}
</span>
</button>
);
})}
</div>
</div>
<Separator />
{/* ─── 2단계: 기본 설정 ─── */}
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<span className="text-sm font-medium"> </span>
{/* 허용 파일 형식 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Input
value={config.accept || ""}
onChange={(e) => updateConfig("accept", e.target.value)}
placeholder="예: .jpg,.png,.pdf"
className="h-8 text-xs"
/>
<p className="text-[10px] text-muted-foreground">
. : .jpg,.png,.gif image/*
</p>
<div className="flex items-center justify-between py-1.5">
<span className="text-xs text-muted-foreground"> </span>
<Input
value={config.accept || ""}
onChange={(e) => updateConfig("accept", e.target.value)}
placeholder=".jpg,.png,.pdf"
className="h-8 w-[180px] text-sm"
/>
</div>
</div>
{/* 최대 파일 크기 */}
{/* ─── 3단계: 업로드 옵션 (Switch + 설명) ─── */}
<div className="space-y-2">
<Label className="text-xs font-medium"> (MB)</Label>
<Input
type="number"
value={config.maxSize || ""}
onChange={(e) => updateConfig("maxSize", e.target.value ? Number(e.target.value) : undefined)}
placeholder="10"
min="1"
className="h-8 text-xs"
/>
</div>
{/* 최대 파일 수 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Input
type="number"
value={config.maxFiles || ""}
onChange={(e) => updateConfig("maxFiles", e.target.value ? Number(e.target.value) : undefined)}
placeholder="제한 없음"
min="1"
className="h-8 text-xs"
/>
</div>
<Separator />
{/* 옵션 */}
<div className="space-y-3">
<Label className="text-xs font-medium"></Label>
<div className="flex items-center space-x-2">
<Checkbox
id="multiple"
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.multiple || false}
onCheckedChange={(checked) => updateConfig("multiple", checked)}
/>
<label htmlFor="multiple" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="preview"
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"></p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.preview !== false}
onCheckedChange={(checked) => updateConfig("preview", checked)}
/>
<label htmlFor="preview" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="dragDrop"
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.dragDrop !== false}
onCheckedChange={(checked) => updateConfig("dragDrop", checked)}
/>
<label htmlFor="dragDrop" className="text-xs"> </label>
</div>
</div>
{/* 이미지 전용 설정 */}
{config.mediaType === "image" && (
<>
<Separator />
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> (px)</Label>
{/* ─── 4단계: 타입별 설정 ─── */}
{/* 이미지 타입: 크기 제한 + 자르기 */}
{isImageType && (
<div className="space-y-3">
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<Image className="h-4 w-4 text-primary" />
<span className="text-sm font-medium"> </span>
</div>
<div className="flex gap-2">
<div className="flex-1">
<span className="text-xs text-muted-foreground"> (px)</span>
<Input
type="number"
value={config.maxWidth || ""}
onChange={(e) => updateConfig("maxWidth", e.target.value ? Number(e.target.value) : undefined)}
placeholder="자동"
className="h-8 text-xs"
className="mt-1 h-8 text-sm"
/>
</div>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> (px)</Label>
<div className="flex-1">
<span className="text-xs text-muted-foreground"> (px)</span>
<Input
type="number"
value={config.maxHeight || ""}
onChange={(e) => updateConfig("maxHeight", e.target.value ? Number(e.target.value) : undefined)}
placeholder="자동"
className="h-8 text-xs"
className="mt-1 h-8 text-sm"
/>
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="crop"
checked={config.crop || false}
onCheckedChange={(checked) => updateConfig("crop", checked)}
/>
<label htmlFor="crop" className="text-xs"> </label>
</div>
</div>
</>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.crop || false}
onCheckedChange={(checked) => updateConfig("crop", checked)}
/>
</div>
</div>
)}
{/* 비디오/오디오 전용 설정 */}
{(config.mediaType === "video" || config.mediaType === "audio") && (
<>
<Separator />
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="flex items-center space-x-2">
<Checkbox
id="autoplay"
checked={config.autoplay || false}
onCheckedChange={(checked) => updateConfig("autoplay", checked)}
/>
<label htmlFor="autoplay" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="controls"
checked={config.controls !== false}
onCheckedChange={(checked) => updateConfig("controls", checked)}
/>
<label htmlFor="controls" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="loop"
checked={config.loop || false}
onCheckedChange={(checked) => updateConfig("loop", checked)}
/>
<label htmlFor="loop" className="text-xs"> </label>
{/* 비디오/오디오 타입: 플레이어 설정 */}
{isPlayerType && (
<div className="space-y-2">
<div className="rounded-lg border bg-muted/30 p-4">
<div className="flex items-center gap-2">
{currentMediaType === "video" ? (
<Video className="h-4 w-4 text-primary" />
) : (
<Music className="h-4 w-4 text-primary" />
)}
<span className="text-sm font-medium"> </span>
</div>
</div>
</>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.autoplay || false}
onCheckedChange={(checked) => updateConfig("autoplay", checked)}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
, ,
</p>
</div>
<Switch
checked={config.controls !== false}
onCheckedChange={(checked) => updateConfig("controls", checked)}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.loop || false}
onCheckedChange={(checked) => updateConfig("loop", checked)}
/>
</div>
</div>
)}
{/* ─── 5단계: 고급 설정 (기본 접혀있음) ─── */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
advancedOpen && "rotate-180",
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
<div className="flex items-center justify-between py-1.5">
<span className="text-xs text-muted-foreground"> (MB)</span>
<Input
type="number"
value={config.maxSize || ""}
onChange={(e) => updateConfig("maxSize", e.target.value ? Number(e.target.value) : undefined)}
placeholder="10"
min="1"
className="h-8 w-[180px] text-sm"
/>
</div>
<div className="flex items-center justify-between py-1.5">
<span className="text-xs text-muted-foreground"> </span>
<Input
type="number"
value={config.maxFiles || ""}
onChange={(e) => updateConfig("maxFiles", e.target.value ? Number(e.target.value) : undefined)}
placeholder="제한 없음"
min="1"
className="h-8 w-[180px] text-sm"
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
@ -208,5 +320,3 @@ export const V2MediaConfigPanel: React.FC<V2MediaConfigPanelProps> = ({
V2MediaConfigPanel.displayName = "V2MediaConfigPanel";
export default V2MediaConfigPanel;

View File

@ -0,0 +1,206 @@
"use client";
/**
* V2NumberingRule
* UX: 최대 () -> () -> /(Switch) -> ()
*/
import React, { useState } from "react";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Settings, ChevronDown, LayoutList, LayoutGrid } from "lucide-react";
import { cn } from "@/lib/utils";
import { NumberingRuleComponentConfig } from "@/lib/registry/components/v2-numbering-rule/types";
const MAX_RULES_CARDS = [
{ value: 3, label: "3개", desc: "간단한 코드" },
{ value: 6, label: "6개", desc: "기본 (권장)" },
{ value: 8, label: "8개", desc: "복잡한 코드" },
{ value: 10, label: "10개", desc: "최대" },
] as const;
const LAYOUT_CARDS = [
{ value: "vertical", label: "세로", desc: "위에서 아래로", icon: LayoutList },
{ value: "horizontal", label: "가로", desc: "왼쪽에서 오른쪽으로", icon: LayoutGrid },
] as const;
interface V2NumberingRuleConfigPanelProps {
config: NumberingRuleComponentConfig;
onChange: (config: NumberingRuleComponentConfig) => void;
}
export const V2NumberingRuleConfigPanel: React.FC<V2NumberingRuleConfigPanelProps> = ({
config,
onChange,
}) => {
const [advancedOpen, setAdvancedOpen] = useState(false);
const updateConfig = (field: keyof NumberingRuleComponentConfig, value: any) => {
const newConfig = { ...config, [field]: value };
onChange(newConfig);
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("componentConfigChanged", {
detail: { config: newConfig },
})
);
}
};
return (
<div className="space-y-4">
{/* ─── 1단계: 최대 규칙 수 카드 선택 ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"> </p>
<div className="grid grid-cols-4 gap-2">
{MAX_RULES_CARDS.map((card) => {
const isSelected = (config.maxRules || 6) === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateConfig("maxRules", card.value)}
className={cn(
"flex flex-col items-center justify-center rounded-lg border p-2.5 text-center transition-all min-h-[60px]",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<span className="text-xs font-medium">{card.label}</span>
<span className="text-[10px] text-muted-foreground mt-0.5">
{card.desc}
</span>
</button>
);
})}
</div>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
{/* ─── 2단계: 카드 레이아웃 카드 선택 ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"> </p>
<div className="grid grid-cols-2 gap-2">
{LAYOUT_CARDS.map((card) => {
const Icon = card.icon;
const isSelected = (config.cardLayout || "vertical") === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateConfig("cardLayout", card.value)}
className={cn(
"flex items-center gap-2 rounded-lg border p-3 text-left transition-all",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<Icon className="h-5 w-5 shrink-0 text-muted-foreground" />
<div>
<span className="text-xs font-medium block">{card.label}</span>
<span className="text-[10px] text-muted-foreground block">
{card.desc}
</span>
</div>
</button>
);
})}
</div>
</div>
{/* ─── 3단계: 표시 설정 (Switch) ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"> </p>
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.showPreview !== false}
onCheckedChange={(checked) => updateConfig("showPreview", checked)}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.showRuleList !== false}
onCheckedChange={(checked) => updateConfig("showRuleList", checked)}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.enableReorder !== false}
onCheckedChange={(checked) => updateConfig("enableReorder", checked)}
/>
</div>
</div>
</div>
{/* ─── 4단계: 고급 설정 (기본 접혀있음) ─── */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
advancedOpen && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.readonly || false}
onCheckedChange={(checked) => updateConfig("readonly", checked)}
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
V2NumberingRuleConfigPanel.displayName = "V2NumberingRuleConfigPanel";
export default V2NumberingRuleConfigPanel;

View File

@ -0,0 +1,804 @@
"use client";
/**
* V2
* UX: 테이블 (Combobox) -> (AreaDropZone) -> (Collapsible)
*/
import React, { useState, useEffect, useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import {
Rows, Columns, Calculator, X, Plus, GripVertical,
Check, ChevronsUpDown, ChevronDown, ChevronUp,
Settings, Database, Info,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { tableTypeApi } from "@/lib/api/screen";
import type {
PivotGridComponentConfig,
PivotFieldConfig,
PivotAreaType,
AggregationType,
FieldDataType,
ConditionalFormatRule,
} from "@/lib/registry/components/v2-pivot-grid/types";
interface TableInfo {
tableName: string;
displayName: string;
}
interface ColumnInfo {
column_name: string;
data_type: string;
column_comment?: string;
}
interface V2PivotGridConfigPanelProps {
config: PivotGridComponentConfig;
onChange: (config: PivotGridComponentConfig) => void;
}
function mapDbTypeToFieldType(dbType: string): FieldDataType {
const type = dbType.toLowerCase();
if (type.includes("int") || type.includes("numeric") || type.includes("decimal") || type.includes("float")) return "number";
if (type.includes("date") || type.includes("time") || type.includes("timestamp")) return "date";
if (type.includes("bool")) return "boolean";
return "string";
}
/* ─── 영역 드롭존 서브 컴포넌트 ─── */
interface AreaDropZoneProps {
area: PivotAreaType;
label: string;
description: string;
icon: React.ReactNode;
fields: PivotFieldConfig[];
columns: ColumnInfo[];
onAddField: (column: ColumnInfo) => void;
onRemoveField: (index: number) => void;
onUpdateField: (index: number, updates: Partial<PivotFieldConfig>) => void;
color: string;
}
const AreaDropZone: React.FC<AreaDropZoneProps> = ({
area,
label,
description,
icon,
fields,
columns,
onAddField,
onRemoveField,
onUpdateField,
color,
}) => {
const [isExpanded, setIsExpanded] = useState(true);
const availableColumns = columns.filter(
(col) => !fields.some((f) => f.field === col.column_name)
);
return (
<div className={cn("rounded-lg border-2 p-3", color)}>
<div
className="flex cursor-pointer items-center justify-between"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2">
{icon}
<span className="text-sm font-medium">{label}</span>
<Badge variant="secondary" className="text-xs">{fields.length}</Badge>
</div>
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</div>
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
{isExpanded && (
<div className="mt-3 space-y-2">
{fields.length > 0 ? (
<div className="space-y-1">
{fields.map((field, idx) => (
<div
key={`${field.field}-${idx}`}
className="flex items-center gap-2 rounded-md border bg-background px-2 py-1.5"
>
<GripVertical className="h-3 w-3 text-muted-foreground" />
<span className="flex-1 truncate text-xs font-medium">
{field.caption || field.field}
</span>
{area === "data" && (
<Select
value={field.summaryType || "sum"}
onValueChange={(v) => onUpdateField(idx, { summaryType: v as AggregationType })}
>
<SelectTrigger className="h-6 w-20 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sum"></SelectItem>
<SelectItem value="count"></SelectItem>
<SelectItem value="avg"></SelectItem>
<SelectItem value="min"></SelectItem>
<SelectItem value="max"></SelectItem>
</SelectContent>
</Select>
)}
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => onRemoveField(idx)}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
) : (
<div className="rounded-md border border-dashed py-2 text-center text-xs text-muted-foreground">
</div>
)}
{availableColumns.length > 0 && (
<Select
onValueChange={(v) => {
const col = columns.find((c) => c.column_name === v);
if (col) onAddField(col);
}}
>
<SelectTrigger className="h-8 text-xs">
<Plus className="mr-1 h-3 w-3" />
<span> </span>
</SelectTrigger>
<SelectContent>
{availableColumns.map((col) => (
<SelectItem key={col.column_name} value={col.column_name}>
<div className="flex items-center gap-2">
<span>{col.column_comment || col.column_name}</span>
<span className="text-xs text-muted-foreground">
({mapDbTypeToFieldType(col.data_type)})
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)}
</div>
);
};
const STYLE_DEFAULTS: { theme: "default"; headerStyle: "default"; cellPadding: "normal"; borderStyle: "light" } = {
theme: "default",
headerStyle: "default",
cellPadding: "normal",
borderStyle: "light",
};
/* ─── 메인 컴포넌트 ─── */
export const V2PivotGridConfigPanel: React.FC<V2PivotGridConfigPanelProps> = ({
config,
onChange,
}) => {
const [tables, setTables] = useState<TableInfo[]>([]);
const [columns, setColumns] = useState<ColumnInfo[]>([]);
const [loadingTables, setLoadingTables] = useState(false);
const [loadingColumns, setLoadingColumns] = useState(false);
const [tableOpen, setTableOpen] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
useEffect(() => {
const loadTables = async () => {
setLoadingTables(true);
try {
const tableList = await tableTypeApi.getTables();
setTables(
tableList.map((t: any) => ({
tableName: t.tableName,
displayName: t.tableLabel || t.displayName || t.tableName,
}))
);
} catch {
/* ignore */
} finally {
setLoadingTables(false);
}
};
loadTables();
}, []);
useEffect(() => {
if (!config.dataSource?.tableName) {
setColumns([]);
return;
}
const loadColumns = async () => {
setLoadingColumns(true);
try {
const columnList = await tableTypeApi.getColumns(config.dataSource!.tableName!);
setColumns(
columnList.map((c: any) => ({
column_name: c.columnName || c.column_name,
data_type: c.dataType || c.data_type || "text",
column_comment: c.columnLabel || c.column_label || c.columnName || c.column_name,
}))
);
} catch {
/* ignore */
} finally {
setLoadingColumns(false);
}
};
loadColumns();
}, [config.dataSource?.tableName]);
const updateConfig = useCallback(
(updates: Partial<PivotGridComponentConfig>) => {
const newConfig = { ...config, ...updates };
onChange(newConfig);
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("componentConfigChanged", {
detail: { config: newConfig },
})
);
}
},
[config, onChange]
);
const handleAddField = (area: PivotAreaType, column: ColumnInfo) => {
const currentFields = config.fields || [];
const areaFields = currentFields.filter((f) => f.area === area);
const newField: PivotFieldConfig = {
field: column.column_name,
caption: column.column_comment || column.column_name,
area,
areaIndex: areaFields.length,
dataType: mapDbTypeToFieldType(column.data_type),
visible: true,
};
if (area === "data") newField.summaryType = "sum";
updateConfig({ fields: [...currentFields, newField] });
};
const handleRemoveField = (area: PivotAreaType, index: number) => {
const currentFields = config.fields || [];
const newFields = currentFields.filter(
(f) => !(f.area === area && f.areaIndex === index)
);
let idx = 0;
newFields.forEach((f) => {
if (f.area === area) f.areaIndex = idx++;
});
updateConfig({ fields: newFields });
};
const handleUpdateField = (area: PivotAreaType, index: number, updates: Partial<PivotFieldConfig>) => {
const currentFields = config.fields || [];
const newFields = currentFields.map((f) =>
f.area === area && f.areaIndex === index ? { ...f, ...updates } : f
);
updateConfig({ fields: newFields });
};
const getFieldsByArea = (area: PivotAreaType) =>
(config.fields || []).filter((f) => f.area === area).sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0));
const handleTableChange = (tableName: string) => {
updateConfig({
dataSource: { ...config.dataSource, type: "table", tableName },
fields: [],
});
setTableOpen(false);
};
return (
<div className="space-y-4">
{/* ─── 안내 ─── */}
<div className="rounded-lg border border-primary/20 bg-primary/5 p-3">
<div className="flex items-start gap-2">
<Info className="mt-0.5 h-4 w-4 text-primary" />
<div className="text-xs text-primary">
<p className="mb-1 font-medium"> </p>
<ol className="list-inside list-decimal space-y-0.5">
<li> <strong></strong> </li>
<li><strong> </strong> (: 지역, )</li>
<li><strong> </strong> (: , )</li>
<li><strong></strong> (: 매출, )</li>
</ol>
</div>
</div>
</div>
{/* ─── 1단계: 테이블 선택 ─── */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium"> </p>
</div>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Popover open={tableOpen} onOpenChange={setTableOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableOpen}
className="h-8 w-full justify-between text-xs"
disabled={loadingTables}
>
{loadingTables
? "로딩 중..."
: config.dataSource?.tableName
? tables.find((t) => t.tableName === config.dataSource?.tableName)?.displayName ||
config.dataSource.tableName
: "테이블 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-4 text-center text-xs">
.
</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{tables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.tableName} ${table.displayName}`}
onSelect={() => handleTableChange(table.tableName)}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.dataSource?.tableName === table.tableName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{table.displayName}</span>
{table.displayName !== table.tableName && (
<span className="text-[10px] text-muted-foreground">{table.tableName}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* ─── 2단계: 필드 배치 ─── */}
{config.dataSource?.tableName && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Rows className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium truncate"> </p>
{loadingColumns && (
<span className="text-[11px] text-muted-foreground">( ...)</span>
)}
</div>
<p className="text-[11px] text-muted-foreground">
</p>
<div className="grid gap-3">
<AreaDropZone
area="row"
label="행 그룹"
description="세로로 그룹화할 항목 (예: 지역, 부서, 제품)"
icon={<Rows className="h-4 w-4 text-emerald-600" />}
fields={getFieldsByArea("row")}
columns={columns}
onAddField={(col) => handleAddField("row", col)}
onRemoveField={(idx) => handleRemoveField("row", idx)}
onUpdateField={(idx, updates) => handleUpdateField("row", idx, updates)}
color="border-emerald-200 bg-emerald-50/50"
/>
<AreaDropZone
area="column"
label="열 그룹"
description="가로로 펼칠 항목 (예: 월, 분기, 연도)"
icon={<Columns className="h-4 w-4 text-primary" />}
fields={getFieldsByArea("column")}
columns={columns}
onAddField={(col) => handleAddField("column", col)}
onRemoveField={(idx) => handleRemoveField("column", idx)}
onUpdateField={(idx, updates) => handleUpdateField("column", idx, updates)}
color="border-primary/20 bg-primary/5"
/>
<AreaDropZone
area="data"
label="값 (집계)"
description="합계, 평균 등을 계산할 숫자 항목 (예: 매출, 수량)"
icon={<Calculator className="h-4 w-4 text-amber-600" />}
fields={getFieldsByArea("data")}
columns={columns}
onAddField={(col) => handleAddField("data", col)}
onRemoveField={(idx) => handleRemoveField("data", idx)}
onUpdateField={(idx, updates) => handleUpdateField("data", idx, updates)}
color="border-amber-200 bg-amber-50/50"
/>
</div>
</div>
)}
{/* ─── 3단계: 고급 설정 (Collapsible) ─── */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium truncate"> </span>
<Badge variant="secondary" className="text-[10px] h-5">12</Badge>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
advancedOpen && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="space-y-4 rounded-b-lg border border-t-0 p-4">
{/* 총계 설정 */}
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground truncate"> </p>
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center justify-between rounded-md bg-muted/30 p-2">
<span className="text-xs truncate"> </span>
<Switch
checked={config.totals?.showRowGrandTotals !== false}
onCheckedChange={(v) =>
updateConfig({ totals: { ...config.totals, showRowGrandTotals: v } })
}
/>
</div>
<div className="flex items-center justify-between rounded-md bg-muted/30 p-2">
<span className="text-xs truncate"> </span>
<Switch
checked={config.totals?.showColumnGrandTotals !== false}
onCheckedChange={(v) =>
updateConfig({ totals: { ...config.totals, showColumnGrandTotals: v } })
}
/>
</div>
<div className="flex items-center justify-between rounded-md bg-muted/30 p-2">
<span className="text-xs truncate"> </span>
<Select
value={config.totals?.rowGrandTotalPosition || "bottom"}
onValueChange={(v) =>
updateConfig({ totals: { ...config.totals, rowGrandTotalPosition: v as "top" | "bottom" } })
}
>
<SelectTrigger className="h-6 w-16 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="top"></SelectItem>
<SelectItem value="bottom"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between rounded-md bg-muted/30 p-2">
<span className="text-xs truncate"> </span>
<Select
value={config.totals?.columnGrandTotalPosition || "right"}
onValueChange={(v) =>
updateConfig({ totals: { ...config.totals, columnGrandTotalPosition: v as "left" | "right" } })
}
>
<SelectTrigger className="h-6 w-16 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="left"></SelectItem>
<SelectItem value="right"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between rounded-md bg-muted/30 p-2">
<span className="text-xs truncate"> </span>
<Switch
checked={config.totals?.showRowTotals !== false}
onCheckedChange={(v) =>
updateConfig({ totals: { ...config.totals, showRowTotals: v } })
}
/>
</div>
<div className="flex items-center justify-between rounded-md bg-muted/30 p-2">
<span className="text-xs truncate"> </span>
<Switch
checked={config.totals?.showColumnTotals !== false}
onCheckedChange={(v) =>
updateConfig({ totals: { ...config.totals, showColumnTotals: v } })
}
/>
</div>
</div>
</div>
{/* 스타일 설정 */}
<div className="space-y-3">
<p className="text-xs font-medium text-muted-foreground truncate"> </p>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.style?.alternateRowColors !== false}
onCheckedChange={(v) =>
updateConfig({ style: { ...STYLE_DEFAULTS, ...config.style, alternateRowColors: v } })
}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.style?.mergeCells === true}
onCheckedChange={(v) =>
updateConfig({ style: { ...STYLE_DEFAULTS, ...config.style, mergeCells: v } })
}
/>
</div>
</div>
{/* 크기 설정 */}
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground truncate"> </p>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<span className="text-[11px] text-muted-foreground"></span>
<Input
value={config.height || ""}
onChange={(e) => updateConfig({ height: e.target.value })}
placeholder="400px"
className="h-7 text-xs"
/>
</div>
<div className="space-y-1">
<span className="text-[11px] text-muted-foreground"> </span>
<Input
value={config.maxHeight || ""}
onChange={(e) => updateConfig({ maxHeight: e.target.value })}
placeholder="600px"
className="h-7 text-xs"
/>
</div>
</div>
</div>
{/* 기능 설정 */}
<div className="space-y-3">
<p className="text-xs font-medium text-muted-foreground truncate"> </p>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm">CSV </p>
<p className="text-[11px] text-muted-foreground"> CSV </p>
</div>
<Switch
checked={config.exportConfig?.excel === true}
onCheckedChange={(v) =>
updateConfig({ exportConfig: { ...config.exportConfig, excel: v } })
}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> /</p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.allowExpandAll !== false}
onCheckedChange={(v) => updateConfig({ allowExpandAll: v })}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"></p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.allowFiltering !== false}
onCheckedChange={(v) => updateConfig({ allowFiltering: v })}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.allowSortingBySummary !== false}
onCheckedChange={(v) => updateConfig({ allowSortingBySummary: v })}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.wordWrapEnabled === true}
onCheckedChange={(v) => updateConfig({ wordWrapEnabled: v })}
/>
</div>
</div>
{/* 조건부 서식 */}
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground truncate"> </p>
<div className="space-y-2">
{(config.style?.conditionalFormats || []).map((rule, index) => (
<div key={rule.id} className="flex items-center gap-2 rounded-md bg-muted/30 p-2">
<Select
value={rule.type}
onValueChange={(v) => {
const newFormats = [...(config.style?.conditionalFormats || [])];
newFormats[index] = { ...rule, type: v as ConditionalFormatRule["type"] };
updateConfig({ style: { ...STYLE_DEFAULTS, ...config.style, conditionalFormats: newFormats } });
}}
>
<SelectTrigger className="h-7 w-24 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="colorScale"> </SelectItem>
<SelectItem value="dataBar"> </SelectItem>
<SelectItem value="iconSet"> </SelectItem>
<SelectItem value="cellValue"> </SelectItem>
</SelectContent>
</Select>
{rule.type === "colorScale" && (
<div className="flex items-center gap-1">
<input
type="color"
value={rule.colorScale?.minColor || "#ff0000"}
onChange={(e) => {
const newFormats = [...(config.style?.conditionalFormats || [])];
newFormats[index] = {
...rule,
colorScale: {
...rule.colorScale,
minColor: e.target.value,
maxColor: rule.colorScale?.maxColor || "#00ff00",
},
};
updateConfig({ style: { ...STYLE_DEFAULTS, ...config.style, conditionalFormats: newFormats } });
}}
className="h-6 w-6 cursor-pointer rounded"
title="최소값 색상"
/>
<span className="text-xs"></span>
<input
type="color"
value={rule.colorScale?.maxColor || "#00ff00"}
onChange={(e) => {
const newFormats = [...(config.style?.conditionalFormats || [])];
newFormats[index] = {
...rule,
colorScale: {
...rule.colorScale,
minColor: rule.colorScale?.minColor || "#ff0000",
maxColor: e.target.value,
},
};
updateConfig({ style: { ...STYLE_DEFAULTS, ...config.style, conditionalFormats: newFormats } });
}}
className="h-6 w-6 cursor-pointer rounded"
title="최대값 색상"
/>
</div>
)}
{rule.type === "dataBar" && (
<input
type="color"
value={rule.dataBar?.color || "#3b82f6"}
onChange={(e) => {
const newFormats = [...(config.style?.conditionalFormats || [])];
newFormats[index] = { ...rule, dataBar: { color: e.target.value } };
updateConfig({ style: { ...STYLE_DEFAULTS, ...config.style, conditionalFormats: newFormats } });
}}
className="h-6 w-6 cursor-pointer rounded"
title="바 색상"
/>
)}
{rule.type === "iconSet" && (
<Select
value={rule.iconSet?.type || "traffic"}
onValueChange={(v) => {
const newFormats = [...(config.style?.conditionalFormats || [])];
newFormats[index] = { ...rule, iconSet: { type: v as "arrows" | "traffic" | "rating" | "flags", thresholds: [33, 67] } };
updateConfig({ style: { ...STYLE_DEFAULTS, ...config.style, conditionalFormats: newFormats } });
}}
>
<SelectTrigger className="h-7 w-20 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="arrows"></SelectItem>
<SelectItem value="traffic"></SelectItem>
<SelectItem value="rating"></SelectItem>
<SelectItem value="flags"></SelectItem>
</SelectContent>
</Select>
)}
<Button
variant="ghost"
size="icon"
className="ml-auto h-6 w-6"
onClick={() => {
const newFormats = (config.style?.conditionalFormats || []).filter((_, i) => i !== index);
updateConfig({ style: { ...STYLE_DEFAULTS, ...config.style, conditionalFormats: newFormats } });
}}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => {
const newFormats = [
...(config.style?.conditionalFormats || []),
{
id: `cf_${Date.now()}`,
type: "colorScale" as const,
colorScale: { minColor: "#ff0000", maxColor: "#00ff00" },
},
];
updateConfig({ style: { ...STYLE_DEFAULTS, ...config.style, conditionalFormats: newFormats } });
}}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
export default V2PivotGridConfigPanel;

View File

@ -0,0 +1,467 @@
"use client";
/**
* V2
* Progressive Disclosure: 작업 -> -> ()
*/
import React, { useState } from "react";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Badge } from "@/components/ui/badge";
import { Settings, ChevronDown, ChevronRight, Plus, Trash2, Database, Layers, List } from "lucide-react";
import { cn } from "@/lib/utils";
import type {
ProcessWorkStandardConfig,
WorkPhaseDefinition,
DetailTypeDefinition,
} from "@/lib/registry/components/v2-process-work-standard/types";
import { defaultConfig } from "@/lib/registry/components/v2-process-work-standard/config";
interface V2ProcessWorkStandardConfigPanelProps {
config: Partial<ProcessWorkStandardConfig>;
onChange: (config: Partial<ProcessWorkStandardConfig>) => void;
}
export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardConfigPanelProps> = ({
config: configProp,
onChange,
}) => {
const [phasesOpen, setPhasesOpen] = useState(false);
const [detailTypesOpen, setDetailTypesOpen] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [dataSourceOpen, setDataSourceOpen] = useState(false);
const config: ProcessWorkStandardConfig = {
...defaultConfig,
...configProp,
dataSource: { ...defaultConfig.dataSource, ...configProp?.dataSource },
phases: configProp?.phases?.length ? configProp.phases : defaultConfig.phases,
detailTypes: configProp?.detailTypes?.length ? configProp.detailTypes : defaultConfig.detailTypes,
};
const update = (partial: Partial<ProcessWorkStandardConfig>) => {
onChange({ ...configProp, ...partial });
};
const updateDataSource = (field: string, value: string) => {
update({ dataSource: { ...config.dataSource, [field]: value } });
};
// ─── 작업 단계 관리 ───
const addPhase = () => {
const nextOrder = config.phases.length + 1;
update({
phases: [
...config.phases,
{ key: `PHASE_${nextOrder}`, label: `단계 ${nextOrder}`, sortOrder: nextOrder },
],
});
};
const removePhase = (idx: number) => {
update({ phases: config.phases.filter((_, i) => i !== idx) });
};
const updatePhase = (idx: number, field: keyof WorkPhaseDefinition, value: string | number) => {
const next = [...config.phases];
next[idx] = { ...next[idx], [field]: value };
update({ phases: next });
};
// ─── 상세 유형 관리 ───
const addDetailType = () => {
update({
detailTypes: [
...config.detailTypes,
{ value: `TYPE_${config.detailTypes.length + 1}`, label: "신규 유형" },
],
});
};
const removeDetailType = (idx: number) => {
update({ detailTypes: config.detailTypes.filter((_, i) => i !== idx) });
};
const updateDetailType = (idx: number, field: keyof DetailTypeDefinition, value: string) => {
const next = [...config.detailTypes];
next[idx] = { ...next[idx], [field]: value };
update({ detailTypes: next });
};
return (
<div className="space-y-4">
{/* ─── 1단계: 작업 단계 설정 (Collapsible + 접이식 카드) ─── */}
<Collapsible open={phasesOpen} onOpenChange={setPhasesOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
<Badge variant="secondary" className="text-[10px] h-5">
{config.phases.length}
</Badge>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
phasesOpen && "rotate-180",
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-3 space-y-1.5">
<p className="text-[10px] text-muted-foreground mb-1"> (Phase) </p>
<div className="space-y-1">
{config.phases.map((phase, idx) => (
<Collapsible key={idx}>
<div className="rounded-md border">
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center gap-1.5 px-2.5 py-1.5 text-left hover:bg-muted/30 transition-colors"
>
<ChevronRight className="h-3 w-3 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90 shrink-0" />
<span className="text-[10px] text-muted-foreground font-medium shrink-0">#{idx + 1}</span>
<span className="text-xs font-medium truncate flex-1 min-w-0">{phase.label}</span>
<Badge variant="outline" className="text-[9px] h-4 shrink-0">{phase.key}</Badge>
<Button
type="button"
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); removePhase(idx); }}
className="h-5 w-5 p-0 text-muted-foreground hover:text-destructive shrink-0"
disabled={config.phases.length <= 1}
>
<Trash2 className="h-3 w-3" />
</Button>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="grid grid-cols-3 gap-1.5 border-t px-2.5 py-2">
<div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span>
<Input
value={phase.key}
onChange={(e) => updatePhase(idx, "key", e.target.value)}
className="h-7 text-xs"
placeholder="키"
/>
</div>
<div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span>
<Input
value={phase.label}
onChange={(e) => updatePhase(idx, "label", e.target.value)}
className="h-7 text-xs"
placeholder="표시명"
/>
</div>
<div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span>
<Input
type="number"
min={1}
value={phase.sortOrder}
onChange={(e) => updatePhase(idx, "sortOrder", parseInt(e.target.value) || 1)}
className="h-7 text-xs text-center"
placeholder="1"
/>
</div>
</div>
</CollapsibleContent>
</div>
</Collapsible>
))}
</div>
<Button
variant="outline"
size="sm"
className="h-7 w-full gap-1 text-xs border-dashed"
onClick={addPhase}
>
<Plus className="h-3 w-3" />
</Button>
</div>
</CollapsibleContent>
</Collapsible>
{/* ─── 2단계: 상세 유형 옵션 (Collapsible + 접이식 카드) ─── */}
<Collapsible open={detailTypesOpen} onOpenChange={setDetailTypesOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<List className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
<Badge variant="secondary" className="text-[10px] h-5">
{config.detailTypes.length}
</Badge>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
detailTypesOpen && "rotate-180",
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-3 space-y-1.5">
<p className="text-[10px] text-muted-foreground mb-1"> </p>
<div className="space-y-1">
{config.detailTypes.map((dt, idx) => (
<Collapsible key={idx}>
<div className="rounded-md border">
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center gap-1.5 px-2.5 py-1.5 text-left hover:bg-muted/30 transition-colors"
>
<ChevronRight className="h-3 w-3 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90 shrink-0" />
<span className="text-[10px] text-muted-foreground font-medium shrink-0">#{idx + 1}</span>
<span className="text-xs font-medium truncate flex-1 min-w-0">{dt.label}</span>
<Badge variant="outline" className="text-[9px] h-4 shrink-0">{dt.value}</Badge>
<Button
type="button"
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); removeDetailType(idx); }}
className="h-5 w-5 p-0 text-muted-foreground hover:text-destructive shrink-0"
disabled={config.detailTypes.length <= 1}
>
<Trash2 className="h-3 w-3" />
</Button>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="grid grid-cols-2 gap-1.5 border-t px-2.5 py-2">
<div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span>
<Input
value={dt.value}
onChange={(e) => updateDetailType(idx, "value", e.target.value)}
className="h-7 text-xs"
placeholder="값"
/>
</div>
<div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span>
<Input
value={dt.label}
onChange={(e) => updateDetailType(idx, "label", e.target.value)}
className="h-7 text-xs"
placeholder="표시명"
/>
</div>
</div>
</CollapsibleContent>
</div>
</Collapsible>
))}
</div>
<Button
variant="outline"
size="sm"
className="h-7 w-full gap-1 text-xs border-dashed"
onClick={addDetailType}
>
<Plus className="h-3 w-3" />
</Button>
</div>
</CollapsibleContent>
</Collapsible>
{/* ─── 3단계: 고급 설정 (데이터 소스 + 레이아웃 통합) ─── */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
advancedOpen && "rotate-180",
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-3 space-y-3">
{/* 레이아웃 기본 설정 */}
<div className="space-y-2">
<div className="flex items-center justify-between py-1">
<div>
<span className="text-xs text-muted-foreground"> (%)</span>
<p className="text-[10px] text-muted-foreground mt-0.5">/ </p>
</div>
<Input
type="number"
min={15}
max={50}
value={config.splitRatio || 30}
onChange={(e) => update({ splitRatio: parseInt(e.target.value) || 30 })}
className="h-7 w-[80px] text-xs"
/>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Input
value={config.leftPanelTitle || ""}
onChange={(e) => update({ leftPanelTitle: e.target.value })}
placeholder="품목 및 공정 선택"
className="h-7 w-[140px] text-xs"
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-xs"> </p>
<p className="text-[10px] text-muted-foreground">/ </p>
</div>
<Switch
checked={config.readonly || false}
onCheckedChange={(checked) => update({ readonly: checked })}
/>
</div>
</div>
{/* 데이터 소스 (서브 Collapsible) */}
<Collapsible open={dataSourceOpen} onOpenChange={setDataSourceOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-md border px-3 py-2 transition-colors hover:bg-muted/30"
>
<div className="flex items-center gap-2">
<Database className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium"> </span>
{config.dataSource.itemTable && (
<Badge variant="secondary" className="text-[10px] h-5 truncate max-w-[100px]">
{config.dataSource.itemTable}
</Badge>
)}
</div>
<ChevronDown
className={cn(
"h-3.5 w-3.5 text-muted-foreground transition-transform",
dataSourceOpen && "rotate-180",
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 pt-2">
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={config.dataSource.itemTable}
onChange={(e) => updateDataSource("itemTable", e.target.value)}
className="h-7 w-full text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={config.dataSource.itemNameColumn}
onChange={(e) => updateDataSource("itemNameColumn", e.target.value)}
className="h-7 text-xs"
/>
</div>
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={config.dataSource.itemCodeColumn}
onChange={(e) => updateDataSource("itemCodeColumn", e.target.value)}
className="h-7 text-xs"
/>
</div>
</div>
<div className="space-y-1 pt-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={config.dataSource.routingVersionTable}
onChange={(e) => updateDataSource("routingVersionTable", e.target.value)}
className="h-7 w-full text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> FK</span>
<Input
value={config.dataSource.routingFkColumn}
onChange={(e) => updateDataSource("routingFkColumn", e.target.value)}
className="h-7 text-xs"
/>
</div>
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={config.dataSource.routingVersionNameColumn}
onChange={(e) => updateDataSource("routingVersionNameColumn", e.target.value)}
className="h-7 text-xs"
/>
</div>
</div>
<div className="space-y-1 pt-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={config.dataSource.routingDetailTable}
onChange={(e) => updateDataSource("routingDetailTable", e.target.value)}
className="h-7 w-full text-xs"
/>
</div>
<div className="space-y-1 pt-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={config.dataSource.processTable}
onChange={(e) => updateDataSource("processTable", e.target.value)}
className="h-7 w-full text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={config.dataSource.processNameColumn}
onChange={(e) => updateDataSource("processNameColumn", e.target.value)}
className="h-7 text-xs"
/>
</div>
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={config.dataSource.processCodeColumn}
onChange={(e) => updateDataSource("processCodeColumn", e.target.value)}
className="h-7 text-xs"
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
V2ProcessWorkStandardConfigPanel.displayName = "V2ProcessWorkStandardConfigPanel";
export default V2ProcessWorkStandardConfigPanel;

View File

@ -0,0 +1,267 @@
"use client";
/**
* V2
* UX: 필드 -> -> UI ()
*/
import React, { useState, useEffect, useMemo } from "react";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Badge } from "@/components/ui/badge";
import { Database, SlidersHorizontal, Settings, ChevronDown, CheckCircle2, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
import type { RackStructureComponentConfig, FieldMapping } from "@/lib/registry/components/v2-rack-structure/types";
interface V2RackStructureConfigPanelProps {
config: RackStructureComponentConfig;
onChange: (config: RackStructureComponentConfig) => void;
tables?: Array<{
tableName: string;
tableLabel?: string;
columns: Array<{
columnName: string;
columnLabel?: string;
dataType?: string;
}>;
}>;
}
export const V2RackStructureConfigPanel: React.FC<V2RackStructureConfigPanelProps> = ({
config,
onChange,
tables = [],
}) => {
const [availableColumns, setAvailableColumns] = useState<
Array<{ value: string; label: string }>
>([]);
const [advancedOpen, setAdvancedOpen] = useState(false);
useEffect(() => {
const columns: Array<{ value: string; label: string }> = [];
tables.forEach((table) => {
table.columns.forEach((col) => {
columns.push({
value: col.columnName,
label: col.columnLabel || col.columnName,
});
});
});
setAvailableColumns(columns);
}, [tables]);
const handleChange = (key: keyof RackStructureComponentConfig, value: any) => {
onChange({ ...config, [key]: value });
};
const handleFieldMappingChange = (field: keyof FieldMapping, value: string) => {
const currentMapping = config.fieldMapping || {};
onChange({
...config,
fieldMapping: {
...currentMapping,
[field]: value === "__none__" ? undefined : value,
},
});
};
const fieldMapping = config.fieldMapping || {};
const fieldMappingItems: Array<{
key: keyof FieldMapping;
label: string;
description: string;
}> = [
{ key: "warehouseCodeField", label: "창고 코드", description: "창고를 식별하는 코드 필드예요" },
{ key: "warehouseNameField", label: "창고명", description: "창고 이름을 표시하는 필드예요" },
{ key: "floorField", label: "층", description: "몇 층인지 나타내는 필드예요" },
{ key: "zoneField", label: "구역", description: "구역 정보를 가져올 필드예요" },
{ key: "locationTypeField", label: "위치 유형", description: "위치의 유형(선반, 바닥 등)을 나타내요" },
{ key: "statusField", label: "사용 여부", description: "사용/미사용 상태를 나타내는 필드예요" },
];
const mappedCount = useMemo(
() => fieldMappingItems.filter((item) => fieldMapping[item.key]).length,
[fieldMapping]
);
return (
<div className="space-y-4">
{/* ─── 1단계: 필드 매핑 ─── */}
<div className="space-y-1">
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-primary" />
<p className="text-sm font-medium"> </p>
<Badge variant="secondary" className="ml-auto text-[10px] px-1.5 py-0">
{mappedCount}/{fieldMappingItems.length}
</Badge>
</div>
<p className="text-[11px] text-muted-foreground pl-6">
</p>
</div>
<div className="space-y-1.5">
{fieldMappingItems.map((item) => {
const isMapped = !!fieldMapping[item.key];
return (
<div
key={item.key}
className={cn(
"flex items-center gap-3 rounded-lg border px-3 py-2 transition-colors",
isMapped ? "border-primary/30 bg-primary/5" : "bg-muted/30"
)}
>
{isMapped ? (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-primary" />
) : (
<Circle className="h-3.5 w-3.5 shrink-0 text-muted-foreground/40" />
)}
<div className="min-w-0 flex-1">
<p className="text-xs font-medium truncate">{item.label}</p>
<p className="text-[10px] text-muted-foreground truncate">{item.description}</p>
</div>
<Select
value={fieldMapping[item.key] || "__none__"}
onValueChange={(v) => handleFieldMappingChange(item.key, v)}
>
<SelectTrigger className="h-7 w-[120px] shrink-0 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{availableColumns.map((col) => (
<SelectItem key={col.value} value={col.value}>
{col.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
})}
</div>
{/* ─── 2단계: 제한 설정 ─── */}
<div className="space-y-1">
<div className="flex items-center gap-2">
<SlidersHorizontal className="h-4 w-4 text-primary" />
<p className="text-sm font-medium"> </p>
</div>
<p className="text-[11px] text-muted-foreground pl-6">
</p>
</div>
<div className="grid grid-cols-3 gap-2">
<div className="rounded-lg border bg-muted/30 p-3 text-center space-y-1.5">
<p className="text-[10px] text-muted-foreground"> </p>
<Input
type="number"
min={1}
max={20}
value={config.maxConditions || 10}
onChange={(e) => handleChange("maxConditions", parseInt(e.target.value) || 10)}
className="h-7 text-xs text-center"
/>
</div>
<div className="rounded-lg border bg-muted/30 p-3 text-center space-y-1.5">
<p className="text-[10px] text-muted-foreground"> </p>
<Input
type="number"
min={1}
max={999}
value={config.maxRows || 99}
onChange={(e) => handleChange("maxRows", parseInt(e.target.value) || 99)}
className="h-7 text-xs text-center"
/>
</div>
<div className="rounded-lg border bg-muted/30 p-3 text-center space-y-1.5">
<p className="text-[10px] text-muted-foreground"> </p>
<Input
type="number"
min={1}
max={99}
value={config.maxLevels || 20}
onChange={(e) => handleChange("maxLevels", parseInt(e.target.value) || 20)}
className="h-7 text-xs text-center"
/>
</div>
</div>
{/* ─── 3단계: 고급 설정 (Collapsible) ─── */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">UI </span>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
advancedOpen && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
<div className="flex items-center justify-between py-1">
<div className="min-w-0 flex-1 mr-3">
<p className="text-xs font-medium">릿 </p>
<p className="text-[10px] text-muted-foreground"> 릿 / </p>
</div>
<Switch
checked={config.showTemplates ?? true}
onCheckedChange={(checked) => handleChange("showTemplates", checked)}
/>
</div>
<div className="flex items-center justify-between py-1">
<div className="min-w-0 flex-1 mr-3">
<p className="text-xs font-medium"></p>
<p className="text-[10px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.showPreview ?? true}
onCheckedChange={(checked) => handleChange("showPreview", checked)}
/>
</div>
<div className="flex items-center justify-between py-1">
<div className="min-w-0 flex-1 mr-3">
<p className="text-xs font-medium"> </p>
<p className="text-[10px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.showStatistics ?? true}
onCheckedChange={(checked) => handleChange("showStatistics", checked)}
/>
</div>
<div className="flex items-center justify-between py-1">
<div className="min-w-0 flex-1 mr-3">
<p className="text-xs font-medium"> </p>
<p className="text-[10px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.readonly ?? false}
onCheckedChange={(checked) => handleChange("readonly", checked)}
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
V2RackStructureConfigPanel.displayName = "V2RackStructureConfigPanel";
export default V2RackStructureConfigPanel;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,277 @@
"use client";
/**
* V2SectionCard
* UX: 패딩 -> / -> ()
*/
import React, { useState } from "react";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Settings,
ChevronDown,
Square,
Minus,
SquareDashed,
} from "lucide-react";
import { cn } from "@/lib/utils";
// ─── 내부 여백 카드 정의 ───
const PADDING_CARDS = [
{ value: "none", label: "없음", size: "0px" },
{ value: "sm", label: "작게", size: "12px" },
{ value: "md", label: "중간", size: "24px" },
{ value: "lg", label: "크게", size: "32px" },
] as const;
// ─── 배경색 카드 정의 ───
const BG_CARDS = [
{ value: "default", label: "카드", description: "기본 카드 배경" },
{ value: "muted", label: "회색", description: "연한 회색 배경" },
{ value: "transparent", label: "투명", description: "배경 없음" },
] as const;
// ─── 테두리 스타일 카드 정의 ───
const BORDER_CARDS = [
{ value: "solid", label: "실선", icon: Minus },
{ value: "dashed", label: "점선", icon: SquareDashed },
{ value: "none", label: "없음", icon: Square },
] as const;
interface V2SectionCardConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
}
export const V2SectionCardConfigPanel: React.FC<
V2SectionCardConfigPanelProps
> = ({ config, onChange }) => {
const [advancedOpen, setAdvancedOpen] = useState(false);
const updateConfig = (field: string, value: any) => {
const newConfig = { ...config, [field]: value };
onChange(newConfig);
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("componentConfigChanged", {
detail: { config: newConfig },
})
);
}
};
return (
<div className="space-y-4">
{/* ─── 1단계: 헤더 설정 ─── */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium"> </p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.showHeader !== false}
onCheckedChange={(checked) => updateConfig("showHeader", checked)}
/>
</div>
{config.showHeader !== false && (
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"></span>
<Input
value={config.title || ""}
onChange={(e) => updateConfig("title", e.target.value)}
placeholder="섹션 제목 입력"
className="h-7 w-[180px] text-xs"
/>
</div>
<div>
<span className="text-xs text-muted-foreground"> ()</span>
<Textarea
value={config.description || ""}
onChange={(e) => updateConfig("description", e.target.value)}
placeholder="섹션에 대한 간단한 설명"
className="mt-1.5 text-xs resize-none"
rows={2}
/>
</div>
</div>
)}
</div>
{/* ─── 2단계: 내부 여백 카드 선택 ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"> </p>
<div className="grid grid-cols-4 gap-2">
{PADDING_CARDS.map((card) => {
const isSelected = (config.padding || "md") === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateConfig("padding", card.value)}
className={cn(
"flex flex-col items-center justify-center rounded-lg border p-2.5 text-center transition-all min-h-[60px]",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<span className="text-xs font-medium">{card.label}</span>
<span className="text-[10px] text-muted-foreground mt-0.5">
{card.size}
</span>
</button>
);
})}
</div>
</div>
{/* ─── 3단계: 외관 설정 ─── */}
<div className="space-y-3">
<p className="text-sm font-medium"></p>
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
{/* 배경색 */}
<div>
<span className="text-xs text-muted-foreground"></span>
<div className="mt-1.5 grid grid-cols-3 gap-2">
{BG_CARDS.map((card) => {
const isSelected =
(config.backgroundColor || "default") === card.value;
return (
<button
key={card.value}
type="button"
onClick={() =>
updateConfig("backgroundColor", card.value)
}
className={cn(
"flex flex-col items-center rounded-md border p-2 text-center transition-all",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<span className="text-xs font-medium">{card.label}</span>
<span className="text-[10px] text-muted-foreground">
{card.description}
</span>
</button>
);
})}
</div>
</div>
{/* 테두리 스타일 */}
<div>
<span className="text-xs text-muted-foreground"></span>
<div className="mt-1.5 grid grid-cols-3 gap-2">
{BORDER_CARDS.map((card) => {
const Icon = card.icon;
const isSelected =
(config.borderStyle || "solid") === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateConfig("borderStyle", card.value)}
className={cn(
"flex flex-col items-center rounded-md border p-2 text-center transition-all gap-1",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<Icon className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-medium">{card.label}</span>
</button>
);
})}
</div>
</div>
</div>
</div>
{/* ─── 4단계: 고급 설정 (기본 접혀있음) ─── */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
advancedOpen && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
{/* 접기/펼치기 */}
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm">/</p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.collapsible || false}
onCheckedChange={(checked) =>
updateConfig("collapsible", checked)
}
/>
</div>
{config.collapsible && (
<div className="ml-4 border-l-2 border-primary/20 pl-3">
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.defaultOpen !== false}
onCheckedChange={(checked) =>
updateConfig("defaultOpen", checked)
}
/>
</div>
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
V2SectionCardConfigPanel.displayName = "V2SectionCardConfigPanel";
export default V2SectionCardConfigPanel;

View File

@ -0,0 +1,275 @@
"use client";
/**
* V2SectionPaper
* UX: 배경색 -> / -> ()
*/
import React, { useState } from "react";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Settings, ChevronDown, Palette } from "lucide-react";
import { cn } from "@/lib/utils";
// ─── 배경색 카드 정의 ───
const BG_CARDS = [
{ value: "default", label: "기본", description: "연한 회색" },
{ value: "muted", label: "회색", description: "조금 더 진한" },
{ value: "accent", label: "강조", description: "연한 파랑" },
{ value: "primary", label: "브랜드", description: "브랜드 컬러" },
{ value: "custom", label: "커스텀", description: "직접 선택" },
] as const;
// ─── 내부 여백 카드 정의 ───
const PADDING_CARDS = [
{ value: "none", label: "없음", size: "0px" },
{ value: "sm", label: "작게", size: "12px" },
{ value: "md", label: "중간", size: "16px" },
{ value: "lg", label: "크게", size: "24px" },
] as const;
// ─── 둥근 모서리 카드 정의 ───
const ROUNDED_CARDS = [
{ value: "none", label: "없음", size: "0px" },
{ value: "sm", label: "작게", size: "2px" },
{ value: "md", label: "중간", size: "6px" },
{ value: "lg", label: "크게", size: "8px" },
] as const;
// ─── 그림자 카드 정의 ───
const SHADOW_CARDS = [
{ value: "none", label: "없음" },
{ value: "sm", label: "작게" },
{ value: "md", label: "중간" },
] as const;
interface V2SectionPaperConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
}
export const V2SectionPaperConfigPanel: React.FC<
V2SectionPaperConfigPanelProps
> = ({ config, onChange }) => {
const [advancedOpen, setAdvancedOpen] = useState(false);
const updateConfig = (field: string, value: any) => {
const newConfig = { ...config, [field]: value };
onChange(newConfig);
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("componentConfigChanged", {
detail: { config: newConfig },
})
);
}
};
const selectedBg = config.backgroundColor || "default";
return (
<div className="space-y-4">
{/* ─── 1단계: 배경색 카드 선택 ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"></p>
<div className="grid grid-cols-3 gap-2">
{BG_CARDS.map((card) => {
const isSelected = selectedBg === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateConfig("backgroundColor", card.value)}
className={cn(
"flex flex-col items-center justify-center rounded-lg border p-2.5 text-center transition-all min-h-[60px]",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
{card.value === "custom" ? (
<Palette className="h-4 w-4 mb-0.5 text-muted-foreground" />
) : null}
<span className="text-xs font-medium">{card.label}</span>
<span className="text-[10px] text-muted-foreground mt-0.5">
{card.description}
</span>
</button>
);
})}
</div>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
{/* 커스텀 색상 선택 */}
{selectedBg === "custom" && (
<div className="rounded-lg border bg-muted/30 p-4">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground"> </span>
<Input
type="color"
value={config.customColor || "#f0f0f0"}
onChange={(e) => updateConfig("customColor", e.target.value)}
className="h-8 w-[80px] cursor-pointer"
/>
</div>
</div>
)}
{/* ─── 2단계: 내부 여백 카드 선택 ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"> </p>
<div className="grid grid-cols-4 gap-2">
{PADDING_CARDS.map((card) => {
const isSelected = (config.padding || "md") === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateConfig("padding", card.value)}
className={cn(
"flex flex-col items-center justify-center rounded-lg border p-2.5 text-center transition-all min-h-[60px]",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<span className="text-xs font-medium">{card.label}</span>
<span className="text-[10px] text-muted-foreground mt-0.5">
{card.size}
</span>
</button>
);
})}
</div>
</div>
{/* ─── 3단계: 모서리 카드 선택 ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"> </p>
<div className="grid grid-cols-4 gap-2">
{ROUNDED_CARDS.map((card) => {
const isSelected =
(config.roundedCorners || "md") === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateConfig("roundedCorners", card.value)}
className={cn(
"flex flex-col items-center justify-center rounded-lg border p-2.5 text-center transition-all min-h-[60px]",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<span className="text-xs font-medium">{card.label}</span>
<span className="text-[10px] text-muted-foreground mt-0.5">
{card.size}
</span>
</button>
);
})}
</div>
</div>
{/* ─── 4단계: 고급 설정 (기본 접혀있음) ─── */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
advancedOpen && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
{/* 그림자 */}
<div>
<span className="text-xs text-muted-foreground"></span>
<div className="mt-1.5 grid grid-cols-3 gap-2">
{SHADOW_CARDS.map((card) => {
const isSelected =
(config.shadow || "none") === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateConfig("shadow", card.value)}
className={cn(
"flex items-center justify-center rounded-md border p-2 text-center transition-all",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<span className="text-xs font-medium">{card.label}</span>
</button>
);
})}
</div>
</div>
{/* 테두리 표시 */}
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.showBorder || false}
onCheckedChange={(checked) =>
updateConfig("showBorder", checked)
}
/>
</div>
{/* 테두리 스타일 */}
{config.showBorder && (
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Select
value={config.borderStyle || "subtle"}
onValueChange={(value) => updateConfig("borderStyle", value)}
>
<SelectTrigger className="h-7 w-[100px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="subtle"></SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
V2SectionPaperConfigPanel.displayName = "V2SectionPaperConfigPanel";
export default V2SectionPaperConfigPanel;

View File

@ -2,17 +2,18 @@
/**
* V2Select
* .
* UX: 소스 -> -> ()
*/
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Plus, Trash2, Loader2, Filter } from "lucide-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Separator } from "@/components/ui/separator";
import { List, Database, FolderTree, Settings, ChevronDown, Plus, Trash2, Loader2, Filter } from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import type { V2SelectFilter } from "@/types/v2-components";
@ -53,6 +54,28 @@ const USER_FIELD_OPTIONS = [
{ value: "userName", label: "사용자명" },
] as const;
// ─── 데이터 소스 카드 정의 ───
const SOURCE_CARDS = [
{
value: "static",
icon: List,
title: "직접 입력",
description: "옵션을 직접 추가해요",
},
{
value: "category",
icon: FolderTree,
title: "카테고리",
description: "등록된 선택지를 사용해요",
},
{
value: "entity",
icon: Database,
title: "테이블 참조",
description: "다른 테이블에서 가져와요",
},
] as const;
/**
*
*/
@ -75,7 +98,6 @@ const FilterConditionsSection: React.FC<{
const updated = [...filters];
updated[index] = { ...updated[index], ...patch };
// valueType 변경 시 관련 필드 초기화
if (patch.valueType) {
if (patch.valueType === "static") {
updated[index].fieldRef = undefined;
@ -89,7 +111,6 @@ const FilterConditionsSection: React.FC<{
}
}
// isNull/isNotNull 연산자는 값 불필요
if (patch.operator === "isNull" || patch.operator === "isNotNull") {
updated[index].value = undefined;
updated[index].fieldRef = undefined;
@ -107,11 +128,11 @@ const FilterConditionsSection: React.FC<{
const needsValue = (op: string) => op !== "isNull" && op !== "isNotNull";
return (
<div className="space-y-3">
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Filter className="h-3.5 w-3.5 text-muted-foreground" />
<Label className="text-xs font-medium"> </Label>
<span className="text-xs font-medium"> </span>
</div>
<Button
type="button"
@ -142,12 +163,10 @@ const FilterConditionsSection: React.FC<{
</p>
)}
<div className="space-y-3">
<div className="space-y-2">
{filters.map((filter, index) => (
<div key={index} className="space-y-1.5 rounded-md border p-2">
{/* 행 1: 컬럼 + 연산자 + 삭제 */}
<div key={index} className="space-y-2 rounded-md border p-3">
<div className="flex items-center gap-1.5">
{/* 컬럼 선택 */}
<Select
value={filter.column || ""}
onValueChange={(v) => updateFilter(index, { column: v })}
@ -164,12 +183,11 @@ const FilterConditionsSection: React.FC<{
</SelectContent>
</Select>
{/* 연산자 선택 */}
<Select
value={filter.operator || "="}
onValueChange={(v) => updateFilter(index, { operator: v as V2SelectFilter["operator"] })}
>
<SelectTrigger className="h-7 w-[90px] shrink-0 text-[11px]">
<SelectTrigger className="h-7 flex-1 text-[11px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -181,22 +199,19 @@ const FilterConditionsSection: React.FC<{
</SelectContent>
</Select>
{/* 삭제 버튼 */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeFilter(index)}
className="text-destructive h-7 w-7 shrink-0 p-0"
className="text-destructive h-8 w-8 shrink-0 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 행 2: 값 유형 + 값 입력 (isNull/isNotNull 제외) */}
{needsValue(filter.operator) && (
<div className="flex items-center gap-1.5">
{/* 값 유형 */}
<Select
value={filter.valueType || "static"}
onValueChange={(v) => updateFilter(index, { valueType: v as V2SelectFilter["valueType"] })}
@ -213,7 +228,6 @@ const FilterConditionsSection: React.FC<{
</SelectContent>
</Select>
{/* 값 입력 영역 */}
{(filter.valueType || "static") === "static" && (
<Input
value={String(filter.value ?? "")}
@ -261,12 +275,11 @@ const FilterConditionsSection: React.FC<{
interface V2SelectConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
/** 컬럼의 inputType (entity/category 타입 확인용) */
inputType?: string;
/** 현재 테이블명 (카테고리 값 조회용) */
tableName?: string;
/** 현재 컬럼명 (카테고리 값 조회용) */
columnName?: string;
tables?: Array<{ tableName: string; displayName?: string; tableComment?: string }>;
screenTableName?: string;
}
export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
@ -275,26 +288,27 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
inputType,
tableName,
columnName,
tables = [],
screenTableName,
}) => {
const isEntityType = inputType === "entity";
const isEntityType = inputType === "entity" || config.source === "entity" || !!config.entityTable;
const isCategoryType = inputType === "category";
const [entityColumns, setEntityColumns] = useState<ColumnOption[]>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
// 카테고리 값 목록
const [categoryValues, setCategoryValues] = useState<CategoryValueOption[]>([]);
const [loadingCategoryValues, setLoadingCategoryValues] = useState(false);
// 필터용 컬럼 목록 (옵션 데이터 소스 테이블의 컬럼)
const [filterColumns, setFilterColumns] = useState<ColumnOption[]>([]);
const [loadingFilterColumns, setLoadingFilterColumns] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const updateConfig = (field: string, value: any) => {
onChange({ ...config, [field]: value });
};
// 필터 대상 테이블 결정
const filterTargetTable = useMemo(() => {
const src = config.source || "static";
if (src === "entity") return config.entityTable;
@ -303,7 +317,6 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
return null;
}, [config.source, config.entityTable, config.table, tableName]);
// 필터 대상 테이블의 컬럼 로드
useEffect(() => {
if (!filterTargetTable) {
setFilterColumns([]);
@ -332,14 +345,13 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
loadFilterColumns();
}, [filterTargetTable]);
// 카테고리 타입이면 source를 자동으로 category로 설정
// 초기 source가 설정 안 된 경우에만 기본값 설정
useEffect(() => {
if (isCategoryType && config.source !== "category") {
if (!config.source && isCategoryType) {
onChange({ ...config, source: "category" });
}
}, [isCategoryType]);
}, []);
// 카테고리 값 로드
const loadCategoryValues = useCallback(async (catTable: string, catColumn: string) => {
if (!catTable || !catColumn) {
setCategoryValues([]);
@ -374,9 +386,8 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
}
}, []);
// 카테고리 소스일 때 값 로드
useEffect(() => {
if (config.source === "category") {
if (config.source === "category" || config.source === "code") {
const catTable = config.categoryTable || tableName;
const catColumn = config.categoryColumn || columnName;
if (catTable && catColumn) {
@ -385,7 +396,6 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
}
}, [config.source, config.categoryTable, config.categoryColumn, tableName, columnName, loadCategoryValues]);
// 엔티티 테이블 변경 시 컬럼 목록 조회
const loadEntityColumns = useCallback(async (tblName: string) => {
if (!tblName) {
setEntityColumns([]);
@ -423,7 +433,6 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
}
}, [config.source, config.entityTable, loadEntityColumns]);
// 정적 옵션 관리
const options = config.options || [];
const addOption = () => {
@ -442,218 +451,140 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
updateConfig("options", newOptions);
};
// 현재 source 결정 (카테고리 타입이면 강제 category)
const effectiveSource = isCategoryType ? "category" : config.source || "static";
const effectiveSource = config.source === "code"
? "category"
: config.source || (isCategoryType ? "category" : "static");
const visibleCards = SOURCE_CARDS;
const gridCols = "grid-cols-3";
return (
<div className="space-y-4">
{/* 선택 모드 */}
{/* ─── 1단계: 데이터 소스 선택 ─── */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select value={config.mode || "dropdown"} onValueChange={(value) => updateConfig("mode", value)}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="모드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="dropdown"></SelectItem>
<SelectItem value="combobox"> ()</SelectItem>
<SelectItem value="radio"> </SelectItem>
<SelectItem value="check"></SelectItem>
<SelectItem value="tag"> </SelectItem>
<SelectItem value="tagbox"> (+)</SelectItem>
<SelectItem value="toggle"> </SelectItem>
<SelectItem value="swap"> </SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
{/* 데이터 소스 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
{isCategoryType ? (
<div className="bg-muted flex h-8 items-center rounded-md px-3">
<span className="text-xs font-medium text-emerald-600"> ( )</span>
</div>
) : (
<Select value={config.source || "static"} onValueChange={(value) => updateConfig("source", value)}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="소스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="static"> </SelectItem>
<SelectItem value="code"> </SelectItem>
<SelectItem value="category"></SelectItem>
{isEntityType && <SelectItem value="entity"></SelectItem>}
</SelectContent>
</Select>
)}
</div>
{/* 카테고리 설정 */}
{effectiveSource === "category" && (
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-xs font-medium"> </Label>
<div className="bg-muted rounded-md p-2">
<div className="grid grid-cols-2 gap-2">
<div>
<p className="text-muted-foreground text-[10px]"></p>
<p className="text-xs font-medium">{config.categoryTable || tableName || "-"}</p>
</div>
<div>
<p className="text-muted-foreground text-[10px]"></p>
<p className="text-xs font-medium">{config.categoryColumn || columnName || "-"}</p>
</div>
</div>
</div>
</div>
{/* 카테고리 값 로딩 중 */}
{loadingCategoryValues && (
<div className="text-muted-foreground flex items-center gap-2 text-xs">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
)}
{/* 카테고리 값 목록 표시 */}
{categoryValues.length > 0 && (
<div className="space-y-2">
<Label className="text-xs font-medium"> ({categoryValues.length})</Label>
<div className="bg-muted max-h-32 space-y-0.5 overflow-y-auto rounded-md p-1.5">
{categoryValues.map((cv) => (
<div key={cv.valueCode} className="flex items-center gap-2 px-1.5 py-0.5">
<span className="text-muted-foreground shrink-0 font-mono text-[10px]">{cv.valueCode}</span>
<span className="truncate text-xs">{cv.valueLabel}</span>
</div>
))}
</div>
</div>
)}
{/* 기본값 설정 */}
{categoryValues.length > 0 && (
<div className="border-t pt-2">
<Label className="text-xs font-medium"></Label>
<Select
value={config.defaultValue || "_none_"}
onValueChange={(value) => updateConfig("defaultValue", value === "_none_" ? "" : value)}
<p className="text-sm font-medium"> ?</p>
<div className={cn("grid gap-2", gridCols)}>
{visibleCards.map((card) => {
const Icon = card.icon;
const isSelected = effectiveSource === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateConfig("source", card.value)}
className={cn(
"flex flex-col items-center justify-center rounded-lg border p-3 text-center transition-all min-h-[80px]",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue placeholder="기본값 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none_"> </SelectItem>
{categoryValues.map((cv) => (
<SelectItem key={cv.valueCode} value={cv.valueCode}>
{cv.valueLabel}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-[10px]"> </p>
</div>
)}
{/* 카테고리 값 없음 안내 */}
{!loadingCategoryValues && categoryValues.length === 0 && (
<p className="text-[10px] text-amber-600">
. .
</p>
)}
<Icon className="h-5 w-5 mb-1.5 text-primary" />
<span className="text-xs font-medium leading-tight">{card.title}</span>
<span className="text-[10px] text-muted-foreground leading-tight mt-0.5">{card.description}</span>
</button>
);
})}
</div>
)}
</div>
{/* 정적 옵션 관리 */}
{/* ─── 2단계: 소스별 설정 ─── */}
{/* 직접 입력 (static) */}
{effectiveSource === "static" && (
<div className="space-y-2">
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium"> </Label>
<Button type="button" variant="ghost" size="sm" onClick={addOption} className="h-6 px-2 text-xs">
<span className="text-sm font-medium"> </span>
<Button type="button" variant="outline" size="sm" onClick={addOption} className="h-7 px-2 text-xs">
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<div className="max-h-40 space-y-1.5 overflow-y-auto">
{options.map((option: any, index: number) => (
<div key={index} className="flex items-center gap-1.5">
<Input
value={option.value || ""}
onChange={(e) => updateOptionValue(index, e.target.value)}
placeholder={`옵션 ${index + 1}`}
className="h-7 flex-1 text-xs"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeOption(index)}
className="text-destructive h-7 w-7 shrink-0 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
{options.length === 0 && (
<p className="text-muted-foreground py-2 text-center text-xs"> </p>
)}
</div>
{/* 기본값 설정 */}
{options.length > 0 ? (
<div className="max-h-40 space-y-1.5 overflow-y-auto">
{options.map((option: any, index: number) => (
<div key={index} className="flex items-center gap-2">
<Input
value={option.value || ""}
onChange={(e) => updateOptionValue(index, e.target.value)}
placeholder={`옵션 ${index + 1}`}
className="h-8 flex-1 text-sm"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeOption(index)}
className="text-destructive h-8 w-8 shrink-0"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
) : (
<div className="text-center py-6 text-muted-foreground">
<List className="mx-auto mb-2 h-8 w-8 opacity-30" />
<p className="text-sm"> </p>
<p className="text-xs"> </p>
</div>
)}
{options.length > 0 && (
<div className="mt-3 border-t pt-2">
<Label className="text-xs font-medium"></Label>
<Select
value={config.defaultValue || "_none_"}
onValueChange={(value) => updateConfig("defaultValue", value === "_none_" ? "" : value)}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue placeholder="기본값 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none_"> </SelectItem>
{options.map((option: any, index: number) => (
<SelectItem key={`default-${index}`} value={option.value || `_idx_${index}`}>
{option.label || option.value || `옵션 ${index + 1}`}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-[10px]"> </p>
<div className="border-t pt-3 mt-3">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground"> </span>
<Select
value={config.defaultValue || "_none_"}
onValueChange={(value) => updateConfig("defaultValue", value === "_none_" ? "" : value)}
>
<SelectTrigger className="h-8 w-[160px] text-sm">
<SelectValue placeholder="선택 안함" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none_"> </SelectItem>
{options.map((option: any, index: number) => (
<SelectItem key={`default-${index}`} value={option.value || `_idx_${index}`}>
{option.label || option.value || `옵션 ${index + 1}`}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
</div>
)}
{/* 공통 코드 설정 */}
{effectiveSource === "code" && (
<div className="space-y-1">
<Label className="text-xs font-medium"> </Label>
{config.codeGroup ? (
<p className="text-foreground text-sm font-medium">{config.codeGroup}</p>
) : (
<p className="text-xs text-amber-600"> </p>
)}
</div>
)}
{/* 엔티티(참조 테이블) 설정 */}
{/* 테이블 참조 (entity) */}
{effectiveSource === "entity" && (
<div className="space-y-3">
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Input
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-primary" />
<span className="text-sm font-medium"> </span>
</div>
<div>
<p className="mb-1.5 text-xs text-muted-foreground"> </p>
<Select
value={config.entityTable || ""}
readOnly
disabled
placeholder="테이블 타입 관리에서 설정"
className="bg-muted h-8 text-xs"
/>
<p className="text-muted-foreground text-[10px]">
( )
</p>
onValueChange={(v) => {
onChange({ ...config, entityTable: v, entityValueColumn: "", entityLabelColumn: "" });
}}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="테이블을 선택해주세요" />
</SelectTrigger>
<SelectContent>
{tables.map((t) => (
<SelectItem key={t.tableName} value={t.tableName}>
{t.displayName || t.tableComment ? `${t.displayName || t.tableComment} (${t.tableName})` : t.tableName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{loadingColumns && (
@ -663,141 +594,143 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
</div>
)}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label className="text-xs font-medium"> ()</Label>
{entityColumns.length > 0 ? (
{entityColumns.length > 0 && (
<div className="space-y-3">
<div>
<p className="mb-1.5 text-xs text-muted-foreground"> </p>
<Select
value={config.entityValueColumn || ""}
onValueChange={(value) => updateConfig("entityValueColumn", value)}
onValueChange={(v) => updateConfig("entityValueColumn", v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{entityColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
{col.columnLabel}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={config.entityValueColumn || ""}
onChange={(e) => updateConfig("entityValueColumn", e.target.value)}
placeholder="id"
className="h-8 text-xs"
/>
)}
<p className="text-muted-foreground text-[10px]"> </p>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
{entityColumns.length > 0 ? (
</div>
<div>
<p className="mb-1.5 text-xs text-muted-foreground"> </p>
<Select
value={config.entityLabelColumn || ""}
onValueChange={(value) => updateConfig("entityLabelColumn", value)}
onValueChange={(v) => updateConfig("entityLabelColumn", v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{entityColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
{col.columnLabel}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={config.entityLabelColumn || ""}
onChange={(e) => updateConfig("entityLabelColumn", e.target.value)}
placeholder="name"
className="h-8 text-xs"
/>
)}
<p className="text-muted-foreground text-[10px]"> </p>
</div>
</div>
{config.entityTable && !loadingColumns && entityColumns.length === 0 && (
<p className="text-[10px] text-amber-600">
. .
</p>
)}
{config.entityTable && entityColumns.length > 0 && (
<div className="border-t pt-3">
<p className="text-muted-foreground text-[10px]">
({config.entityTable}) ,
.
</div>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
)}
{config.entityTable && !loadingColumns && entityColumns.length === 0 && (
<p className="text-[10px] text-amber-600">
. .
</p>
)}
</div>
)}
<Separator />
{/* 카테고리 (category) - source="code" 하위 호환 포함 */}
{effectiveSource === "category" && (
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<FolderTree className="h-4 w-4 text-primary" />
<span className="text-sm font-medium"></span>
</div>
{/* 추가 옵션 */}
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
{config.source === "code" && config.codeGroup && (
<div className="rounded-md border bg-background p-3">
<p className="text-xs text-muted-foreground"> </p>
<p className="mt-0.5 text-sm font-medium">{config.codeGroup}</p>
<p className="mt-1 text-[11px] text-muted-foreground">
</p>
</div>
)}
<div className="flex items-center space-x-2">
<Checkbox
id="multiple"
checked={config.multiple || false}
onCheckedChange={(checked) => updateConfig("multiple", checked)}
/>
<label htmlFor="multiple" className="text-xs">
</label>
</div>
<div className="rounded-md border bg-background p-3">
<div className="flex gap-6">
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="text-sm font-medium">{config.categoryTable || tableName || "-"}</p>
</div>
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="text-sm font-medium">{config.categoryColumn || columnName || "-"}</p>
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="searchable"
checked={config.searchable || false}
onCheckedChange={(checked) => updateConfig("searchable", checked)}
/>
<label htmlFor="searchable" className="text-xs">
</label>
</div>
{loadingCategoryValues && (
<div className="text-muted-foreground flex items-center gap-2 text-xs">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
)}
<div className="flex items-center space-x-2">
<Checkbox
id="allowClear"
checked={config.allowClear !== false}
onCheckedChange={(checked) => updateConfig("allowClear", checked)}
/>
<label htmlFor="allowClear" className="text-xs">
</label>
</div>
</div>
{categoryValues.length > 0 && (
<div>
<p className="mb-1.5 text-xs text-muted-foreground">
{categoryValues.length}
</p>
<div className="max-h-28 overflow-y-auto rounded-md border bg-background p-2 space-y-0.5">
{categoryValues.map((cv) => (
<div key={cv.valueCode} className="flex items-center gap-2 px-1.5 py-0.5 text-xs">
<span className="shrink-0 font-mono text-[10px] text-muted-foreground">{cv.valueCode}</span>
<span className="truncate">{cv.valueLabel}</span>
</div>
))}
</div>
{/* 다중 선택 시 최대 개수 */}
{config.multiple && (
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Input
type="number"
value={config.maxSelect ?? ""}
onChange={(e) => updateConfig("maxSelect", e.target.value ? Number(e.target.value) : undefined)}
placeholder="제한 없음"
min="1"
className="h-8 text-xs"
/>
<div className="mt-3 flex items-center justify-between">
<span className="text-xs text-muted-foreground"> </span>
<Select
value={config.defaultValue || "_none_"}
onValueChange={(value) => updateConfig("defaultValue", value === "_none_" ? "" : value)}
>
<SelectTrigger className="h-8 w-[160px] text-sm">
<SelectValue placeholder="선택 안함" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none_"> </SelectItem>
{categoryValues.map((cv) => (
<SelectItem key={cv.valueCode} value={cv.valueCode}>
{cv.valueLabel}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
{!loadingCategoryValues && categoryValues.length === 0 && (
<p className="text-[10px] text-amber-600">
. .
</p>
)}
</div>
)}
{/* 데이터 필터 조건 - static 소스 외 모든 소스에서 사용 */}
{/* 데이터 필터 (static 제외, filterTargetTable 있을 때만) */}
{effectiveSource !== "static" && filterTargetTable && (
<>
<Separator />
<div className="rounded-lg border bg-muted/30 p-4">
<FilterConditionsSection
filters={(config.filters as V2SelectFilter[]) || []}
columns={filterColumns}
@ -805,8 +738,106 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
targetTable={filterTargetTable}
onFiltersChange={(filters) => updateConfig("filters", filters)}
/>
</>
</div>
)}
{/* ─── 3단계: 고급 설정 (기본 접혀있음) ─── */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
advancedOpen && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
{/* 선택 모드 */}
<div>
<p className="mb-1.5 text-xs text-muted-foreground"> </p>
<Select value={config.mode || "dropdown"} onValueChange={(v) => updateConfig("mode", v)}>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="dropdown"></SelectItem>
<SelectItem value="combobox"> </SelectItem>
<SelectItem value="radio"> </SelectItem>
<SelectItem value="check"></SelectItem>
<Separator className="my-1" />
<SelectItem value="tag"> </SelectItem>
<SelectItem value="tagbox"></SelectItem>
<SelectItem value="toggle"></SelectItem>
<SelectItem value="swap"></SelectItem>
</SelectContent>
</Select>
<p className="mt-1 text-[11px] text-muted-foreground"> </p>
</div>
{/* 토글 옵션들 */}
<div className="space-y-2">
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.multiple || false}
onCheckedChange={(checked) => updateConfig("multiple", checked)}
/>
</div>
{config.multiple && (
<div className="ml-4 border-l-2 border-primary/20 pl-3">
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Input
type="number"
value={config.maxSelect ?? ""}
onChange={(e) => updateConfig("maxSelect", e.target.value ? Number(e.target.value) : undefined)}
placeholder="제한 없음"
min={1}
className="h-7 w-[100px] text-xs"
/>
</div>
</div>
)}
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.searchable || false}
onCheckedChange={(checked) => updateConfig("searchable", checked)}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> X </p>
</div>
<Switch
checked={config.allowClear !== false}
onCheckedChange={(checked) => updateConfig("allowClear", checked)}
/>
</div>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,207 @@
"use client";
/**
* V2SplitLine
* UX: 리사이즈 Switch -> ->
*/
import React, { useState } from "react";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Settings, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const WIDTH_CARDS = [
{ value: 2, label: "얇게" },
{ value: 4, label: "보통" },
{ value: 6, label: "두껍게" },
{ value: 8, label: "넓게" },
] as const;
const COLOR_CARDS = [
{ value: "#e2e8f0", label: "기본", description: "연한 회색" },
{ value: "#94a3b8", label: "진하게", description: "중간 회색" },
{ value: "#3b82f6", label: "강조", description: "파란색" },
] as const;
interface V2SplitLineConfigPanelProps {
config: Record<string, any>;
onConfigChange: (config: Record<string, any>) => void;
}
export const V2SplitLineConfigPanel: React.FC<V2SplitLineConfigPanelProps> = ({
config,
onConfigChange,
}) => {
const [advancedOpen, setAdvancedOpen] = useState(false);
const currentConfig = config || {};
const updateConfig = (field: string, value: any) => {
const newConfig = { ...currentConfig, [field]: value };
onConfigChange(newConfig);
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("componentConfigChanged", {
detail: { config: newConfig },
})
);
}
};
return (
<div className="space-y-4">
{/* ─── 1단계: 드래그 리사이즈 ─── */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium"> </p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={currentConfig.resizable ?? true}
onCheckedChange={(checked) => updateConfig("resizable", checked)}
/>
</div>
{/* ─── 2단계: 분할선 두께 카드 선택 ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"> </p>
<div className="grid grid-cols-4 gap-2">
{WIDTH_CARDS.map((card) => {
const isSelected = (currentConfig.lineWidth || 4) === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateConfig("lineWidth", card.value)}
className={cn(
"flex flex-col items-center justify-center rounded-lg border p-2 text-center transition-all min-h-[52px]",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<div
className="mb-1.5 h-6 rounded-sm"
style={{
width: `${card.value}px`,
backgroundColor: currentConfig.lineColor || "#e2e8f0",
border: "1px solid rgba(0,0,0,0.1)",
}}
/>
<span className="text-[10px] font-medium">{card.label}</span>
</button>
);
})}
</div>
<p className="text-[11px] text-muted-foreground">
: {currentConfig.lineWidth || 4}px
</p>
</div>
{/* ─── 3단계: 분할선 색상 카드 선택 ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"> </p>
<div className="grid grid-cols-3 gap-2">
{COLOR_CARDS.map((card) => {
const isSelected =
(currentConfig.lineColor || "#e2e8f0") === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateConfig("lineColor", card.value)}
className={cn(
"flex flex-col items-center rounded-md border p-2 text-center transition-all",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<div
className="mb-1 h-3 w-3 rounded-full"
style={{ backgroundColor: card.value }}
/>
<span className="text-xs font-medium">{card.label}</span>
</button>
);
})}
</div>
</div>
{/* ─── 고급 설정: 커스텀 색상 입력 ─── */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
advancedOpen && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
{/* 커스텀 색상 입력 */}
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<div className="flex items-center gap-1.5">
<input
type="color"
value={currentConfig.lineColor || "#e2e8f0"}
onChange={(e) => updateConfig("lineColor", e.target.value)}
className="h-7 w-7 cursor-pointer rounded border"
/>
<Input
value={currentConfig.lineColor || "#e2e8f0"}
onChange={(e) => updateConfig("lineColor", e.target.value)}
placeholder="#e2e8f0"
className="h-7 w-[100px] text-xs"
/>
</div>
</div>
{/* 커스텀 두께 입력 */}
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> (px)</span>
<Input
type="number"
value={currentConfig.lineWidth || 4}
onChange={(e) =>
updateConfig("lineWidth", parseInt(e.target.value) || 4)
}
className="h-7 w-[80px] text-xs"
min={1}
max={12}
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
<p className="text-[10px] text-muted-foreground">
릿 X .
.
</p>
</div>
);
};
V2SplitLineConfigPanel.displayName = "V2SplitLineConfigPanel";
export default V2SplitLineConfigPanel;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,679 @@
"use client";
/**
* V2StatusCount
* UX: 데이터 -> -> -> ()
* StatusCountConfigPanel의 UI로
*/
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Separator } from "@/components/ui/separator";
import {
Table2,
Columns3,
Check,
ChevronsUpDown,
Loader2,
Link2,
Plus,
Trash2,
BarChart3,
Type,
Maximize2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { tableTypeApi } from "@/lib/api/screen";
import { entityJoinApi, type EntityJoinConfig } from "@/lib/api/entityJoin";
import { apiClient } from "@/lib/api/client";
import type { StatusCountConfig, StatusCountItem } from "@/lib/registry/components/v2-status-count/types";
import { STATUS_COLOR_MAP } from "@/lib/registry/components/v2-status-count/types";
const COLOR_OPTIONS = Object.keys(STATUS_COLOR_MAP);
// ─── 카드 크기 선택 카드 ───
const SIZE_CARDS = [
{ value: "sm", title: "작게", description: "컴팩트" },
{ value: "md", title: "보통", description: "기본 크기" },
{ value: "lg", title: "크게", description: "넓은 카드" },
] as const;
// ─── 섹션 헤더 컴포넌트 ───
function SectionHeader({ icon: Icon, title, description }: {
icon: React.ComponentType<{ className?: string }>;
title: string;
description?: string;
}) {
return (
<div className="space-y-1">
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold">{title}</h3>
</div>
{description && <p className="text-muted-foreground text-[10px]">{description}</p>}
</div>
);
}
// ─── 수평 라벨 + 컨트롤 Row ───
function LabeledRow({ label, description, children }: {
label: string;
description?: string;
children: React.ReactNode;
}) {
return (
<div className="flex items-center justify-between py-1">
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground">{label}</p>
{description && <p className="text-[10px] text-muted-foreground">{description}</p>}
</div>
{children}
</div>
);
}
interface V2StatusCountConfigPanelProps {
config: StatusCountConfig;
onChange: (config: Partial<StatusCountConfig>) => void;
}
export const V2StatusCountConfigPanel: React.FC<V2StatusCountConfigPanelProps> = ({
config,
onChange,
}) => {
// componentConfigChanged 이벤트 발행 래퍼
const handleChange = useCallback((newConfig: Partial<StatusCountConfig>) => {
onChange(newConfig);
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("componentConfigChanged", {
detail: { config: { ...config, ...newConfig } },
})
);
}
}, [onChange, config]);
const updateField = useCallback((key: keyof StatusCountConfig, value: any) => {
handleChange({ [key]: value });
}, [handleChange]);
// ─── 상태 ───
const [tables, setTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
const [columns, setColumns] = useState<Array<{ columnName: string; columnLabel: string }>>([]);
const [entityJoins, setEntityJoins] = useState<EntityJoinConfig[]>([]);
const [loadingTables, setLoadingTables] = useState(false);
const [loadingColumns, setLoadingColumns] = useState(false);
const [loadingJoins, setLoadingJoins] = useState(false);
const [statusCategoryValues, setStatusCategoryValues] = useState<Array<{ value: string; label: string }>>([]);
const [loadingCategoryValues, setLoadingCategoryValues] = useState(false);
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
const [statusColumnOpen, setStatusColumnOpen] = useState(false);
const [relationOpen, setRelationOpen] = useState(false);
const items = config.items || [];
// ─── 테이블 목록 로드 ───
useEffect(() => {
const loadTables = async () => {
setLoadingTables(true);
try {
const result = await tableTypeApi.getTables();
setTables(
(result || []).map((t: any) => ({
tableName: t.tableName || t.table_name,
displayName: t.displayName || t.tableName || t.table_name,
}))
);
} catch (err) {
console.error("테이블 목록 로드 실패:", err);
} finally {
setLoadingTables(false);
}
};
loadTables();
}, []);
// ─── 선택된 테이블의 컬럼 + 엔티티 조인 로드 ───
useEffect(() => {
if (!config.tableName) {
setColumns([]);
setEntityJoins([]);
return;
}
const loadColumns = async () => {
setLoadingColumns(true);
try {
const result = await tableTypeApi.getColumns(config.tableName);
setColumns(
(result || []).map((c: any) => ({
columnName: c.columnName || c.column_name,
columnLabel: c.columnLabel || c.column_label || c.displayName || c.columnName || c.column_name,
}))
);
} catch (err) {
console.error("컬럼 목록 로드 실패:", err);
} finally {
setLoadingColumns(false);
}
};
const loadEntityJoins = async () => {
setLoadingJoins(true);
try {
const result = await entityJoinApi.getEntityJoinConfigs(config.tableName);
setEntityJoins(result?.joinConfigs || []);
} catch (err) {
console.error("엔티티 조인 설정 로드 실패:", err);
setEntityJoins([]);
} finally {
setLoadingJoins(false);
}
};
loadColumns();
loadEntityJoins();
}, [config.tableName]);
// ─── 상태 컬럼의 카테고리 값 로드 ───
useEffect(() => {
if (!config.tableName || !config.statusColumn) {
setStatusCategoryValues([]);
return;
}
const loadCategoryValues = async () => {
setLoadingCategoryValues(true);
try {
const response = await apiClient.get(
`/table-categories/${config.tableName}/${config.statusColumn}/values`
);
if (response.data?.success && response.data?.data) {
const flatValues: Array<{ value: string; label: string }> = [];
const flatten = (categoryItems: any[]) => {
for (const item of categoryItems) {
flatValues.push({
value: item.valueCode || item.value_code,
label: item.valueLabel || item.value_label,
});
if (item.children?.length > 0) flatten(item.children);
}
};
flatten(response.data.data);
setStatusCategoryValues(flatValues);
}
} catch {
setStatusCategoryValues([]);
} finally {
setLoadingCategoryValues(false);
}
};
loadCategoryValues();
}, [config.tableName, config.statusColumn]);
// ─── 엔티티 관계 Combobox 아이템 ───
const relationComboItems = useMemo(() => {
return entityJoins.map((ej) => {
const refTableLabel = tables.find((t) => t.tableName === ej.referenceTable)?.displayName || ej.referenceTable;
return {
value: `${ej.sourceColumn}::${ej.referenceTable}.${ej.referenceColumn}`,
label: `${ej.sourceColumn} -> ${refTableLabel}`,
sublabel: `${ej.referenceTable}.${ej.referenceColumn}`,
};
});
}, [entityJoins, tables]);
const currentRelationValue = useMemo(() => {
if (!config.relationColumn) return "";
return relationComboItems.find((item) => {
const [srcCol] = item.value.split("::");
return srcCol === config.relationColumn;
})?.value || "";
}, [config.relationColumn, relationComboItems]);
// ─── 상태 항목 관리 ───
const addItem = useCallback(() => {
updateField("items", [...items, { value: "", label: "새 상태", color: "gray" }]);
}, [items, updateField]);
const removeItem = useCallback((index: number) => {
updateField("items", items.filter((_: StatusCountItem, i: number) => i !== index));
}, [items, updateField]);
const updateItem = useCallback((index: number, key: keyof StatusCountItem, value: string) => {
const newItems = [...items];
newItems[index] = { ...newItems[index], [key]: value };
updateField("items", newItems);
}, [items, updateField]);
// ─── 테이블 변경 핸들러 ───
const handleTableChange = useCallback((newTableName: string) => {
handleChange({ tableName: newTableName, statusColumn: "", relationColumn: "", parentColumn: "" });
setTableComboboxOpen(false);
}, [handleChange]);
// ─── 렌더링 ───
return (
<div className="space-y-4">
{/* ═══════════════════════════════════════ */}
{/* 1단계: 데이터 소스 (테이블 선택) */}
{/* ═══════════════════════════════════════ */}
<div className="space-y-3">
<SectionHeader icon={Table2} title="데이터 소스" description="상태를 집계할 테이블을 선택하세요" />
<Separator />
{/* 제목 */}
<div className="space-y-1">
<div className="flex items-center gap-2">
<Type className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium truncate"></span>
</div>
<Input
value={config.title || ""}
onChange={(e) => updateField("title", e.target.value)}
placeholder="예: 일련번호 현황"
className="h-7 text-xs"
/>
</div>
{/* 테이블 선택 */}
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableComboboxOpen}
className="h-8 w-full justify-between text-xs"
disabled={loadingTables}
>
<div className="flex items-center gap-2 truncate">
<Table2 className="h-3 w-3 shrink-0" />
<span className="truncate">
{loadingTables
? "테이블 로딩 중..."
: config.tableName
? tables.find((t) => t.tableName === config.tableName)?.displayName || config.tableName
: "테이블 선택"}
</span>
</div>
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.displayName} ${table.tableName}`}
onSelect={() => handleTableChange(table.tableName)}
className="text-xs"
>
<Check
className={cn("mr-2 h-3 w-3", config.tableName === table.tableName ? "opacity-100" : "opacity-0")}
/>
<div className="flex flex-col">
<span>{table.displayName}</span>
{table.displayName !== table.tableName && (
<span className="text-[10px] text-muted-foreground/70">{table.tableName}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* ═══════════════════════════════════════ */}
{/* 2단계: 컬럼 매핑 */}
{/* ═══════════════════════════════════════ */}
{config.tableName && (
<div className="space-y-3">
<SectionHeader icon={Columns3} title="컬럼 매핑" description="상태 컬럼과 부모 관계를 설정하세요" />
<Separator />
{/* 상태 컬럼 */}
<div className="space-y-1">
<span className="text-xs font-medium truncate"> *</span>
<Popover open={statusColumnOpen} onOpenChange={setStatusColumnOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={statusColumnOpen}
className="h-8 w-full justify-between text-xs"
disabled={loadingColumns}
>
<span className="truncate">
{loadingColumns
? "컬럼 로딩 중..."
: config.statusColumn
? columns.find((c) => c.columnName === config.statusColumn)?.columnLabel || config.statusColumn
: "상태 컬럼 선택"}
</span>
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup>
{columns.map((col) => (
<CommandItem
key={col.columnName}
value={`${col.columnLabel} ${col.columnName}`}
onSelect={() => {
updateField("statusColumn", col.columnName);
setStatusColumnOpen(false);
}}
className="text-xs"
>
<Check
className={cn("mr-2 h-3 w-3", config.statusColumn === col.columnName ? "opacity-100" : "opacity-0")}
/>
<div className="flex flex-col">
<span>{col.columnLabel}</span>
{col.columnLabel !== col.columnName && (
<span className="text-[10px] text-muted-foreground/70">{col.columnName}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 엔티티 관계 */}
<div className="space-y-1">
<div className="flex items-center gap-2">
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium truncate"> </span>
</div>
{loadingJoins ? (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" /> ...
</div>
) : entityJoins.length > 0 ? (
<Popover open={relationOpen} onOpenChange={setRelationOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={relationOpen}
className="h-8 w-full justify-between text-xs"
>
<span className="truncate">
{currentRelationValue
? relationComboItems.find((r) => r.value === currentRelationValue)?.label || "관계 선택"
: "엔티티 관계 선택"}
</span>
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="관계 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup>
{relationComboItems.map((item) => (
<CommandItem
key={item.value}
value={`${item.label} ${item.sublabel}`}
onSelect={() => {
if (item.value === currentRelationValue) {
handleChange({ relationColumn: "", parentColumn: "" });
} else {
const [sourceCol, refPart] = item.value.split("::");
const [, refCol] = refPart.split(".");
handleChange({ relationColumn: sourceCol, parentColumn: refCol });
}
setRelationOpen(false);
}}
className="text-xs"
>
<Check
className={cn("mr-2 h-3 w-3", currentRelationValue === item.value ? "opacity-100" : "opacity-0")}
/>
<div className="flex flex-col">
<span>{item.label}</span>
<span className="text-[10px] text-muted-foreground/70">{item.sublabel}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<div className="rounded-lg border-2 border-dashed py-3 text-center">
<p className="text-[10px] text-muted-foreground"> </p>
</div>
)}
{config.relationColumn && config.parentColumn && (
<div className="rounded bg-muted/50 px-2 py-1.5 text-[10px] text-muted-foreground">
FK: <span className="font-medium text-foreground">{config.relationColumn}</span>
{" -> "}
: <span className="font-medium text-foreground">{config.parentColumn}</span>
</div>
)}
</div>
</div>
)}
{/* 테이블 미선택 안내 */}
{!config.tableName && (
<div className="rounded-lg border-2 border-dashed p-6 text-center">
<Table2 className="mx-auto mb-2 h-8 w-8 text-muted-foreground opacity-30" />
<p className="text-sm text-muted-foreground"> </p>
<p className="text-xs text-muted-foreground"> </p>
</div>
)}
{/* ═══════════════════════════════════════ */}
{/* 3단계: 카드 크기 (카드 선택 UI) */}
{/* ═══════════════════════════════════════ */}
<div className="space-y-3">
<SectionHeader icon={Maximize2} title="카드 크기" description="상태 카드의 크기를 선택하세요" />
<Separator />
<div className="grid grid-cols-3 gap-2">
{SIZE_CARDS.map((card) => {
const isSelected = (config.cardSize || "md") === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateField("cardSize", card.value)}
className={cn(
"flex min-h-[60px] flex-col items-center justify-center rounded-lg border p-2 text-center transition-all",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<span className="text-xs font-medium leading-tight">{card.title}</span>
<span className="mt-0.5 text-[10px] leading-tight text-muted-foreground">{card.description}</span>
</button>
);
})}
</div>
</div>
{/* ═══════════════════════════════════════ */}
{/* 4단계: 상태 항목 관리 */}
{/* ═══════════════════════════════════════ */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<SectionHeader icon={BarChart3} title="상태 항목" description="집계할 상태 값과 표시 스타일을 설정하세요" />
<Badge variant="secondary" className="text-[10px] h-5">{items.length}</Badge>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={addItem}
className="h-6 shrink-0 px-2 text-xs"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<Separator />
{loadingCategoryValues && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" /> ...
</div>
)}
{items.length === 0 ? (
<div className="rounded-lg border-2 border-dashed py-6 text-center">
<BarChart3 className="mx-auto mb-2 h-8 w-8 text-muted-foreground opacity-30" />
<p className="text-sm text-muted-foreground"> </p>
<p className="text-xs text-muted-foreground"> </p>
</div>
) : (
<div className="space-y-2">
{items.map((item: StatusCountItem, i: number) => (
<div key={i} className="space-y-1.5 rounded-md border p-2.5">
{/* 첫 번째 줄: 상태값 + 삭제 */}
<div className="flex items-center gap-1">
{statusCategoryValues.length > 0 ? (
<Select
value={item.value || ""}
onValueChange={(v) => {
updateItem(i, "value", v);
if (v === "__ALL__" && !item.label) {
updateItem(i, "label", "전체");
} else {
const catVal = statusCategoryValues.find((cv) => cv.value === v);
if (catVal && !item.label) {
updateItem(i, "label", catVal.label);
}
}
}}
>
<SelectTrigger className="h-7 flex-1 text-xs">
<SelectValue placeholder="카테고리 값 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__ALL__" className="text-xs font-medium">
</SelectItem>
{statusCategoryValues.map((cv) => (
<SelectItem key={cv.value} value={cv.value} className="text-xs">
{cv.label} ({cv.value})
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={item.value}
onChange={(e) => updateItem(i, "value", e.target.value)}
placeholder="상태값 (예: IN_USE)"
className="h-7 text-xs"
/>
)}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeItem(i)}
className="h-6 w-6 shrink-0 p-0 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 두 번째 줄: 라벨 + 색상 */}
<div className="flex gap-1">
<Input
value={item.label}
onChange={(e) => updateItem(i, "label", e.target.value)}
placeholder="표시 라벨"
className="h-7 text-xs"
/>
<Select
value={item.color}
onValueChange={(v) => updateItem(i, "color", v)}
>
<SelectTrigger className="h-7 w-24 shrink-0 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{COLOR_OPTIONS.map((c) => (
<SelectItem key={c} value={c} className="text-xs">
<div className="flex items-center gap-1.5">
<div
className={cn("h-3 w-3 rounded-full border", STATUS_COLOR_MAP[c].bg, STATUS_COLOR_MAP[c].border)}
/>
{c}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
))}
</div>
)}
{!loadingCategoryValues && statusCategoryValues.length === 0 && config.tableName && config.statusColumn && (
<div className="rounded bg-amber-50 px-2 py-1.5 text-[10px] text-amber-700 dark:bg-amber-950/30 dark:text-amber-400">
. &gt; .
</div>
)}
{/* 미리보기 */}
{items.length > 0 && (
<div className="space-y-1.5">
<span className="text-xs text-muted-foreground truncate"></span>
<div className="flex gap-1.5 rounded-md bg-muted/30 p-2">
{items.map((item, i) => {
const colors = STATUS_COLOR_MAP[item.color] || STATUS_COLOR_MAP.gray;
return (
<div
key={i}
className={cn("flex flex-1 flex-col items-center rounded-md border p-1.5", colors.bg, colors.border)}
>
<span className={cn("text-sm font-bold", colors.text)}>0</span>
<span className={cn("text-[10px]", colors.text)}>{item.label || "라벨"}</span>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
);
};
V2StatusCountConfigPanel.displayName = "V2StatusCountConfigPanel";
export default V2StatusCountConfigPanel;

View File

@ -0,0 +1,771 @@
"use client";
/**
* V2TableGrouped
* UX: 데이터 -> -> -> () -> ()
* TableGroupedConfigPanel의 UI로
*/
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Separator } from "@/components/ui/separator";
import {
Table2,
Database,
Layers,
Columns3,
Check,
ChevronsUpDown,
Settings,
ChevronDown,
Loader2,
Link2,
Plus,
Trash2,
FoldVertical,
ArrowUpDown,
CheckSquare,
LayoutGrid,
Type,
Hash,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { tableTypeApi } from "@/lib/api/screen";
import type { TableGroupedConfig, LinkedFilterConfig } from "@/lib/registry/components/v2-table-grouped/types";
import type { ColumnConfig } from "@/lib/registry/components/v2-table-list/types";
import {
groupHeaderStyleOptions,
checkboxModeOptions,
sortDirectionOptions,
} from "@/lib/registry/components/v2-table-grouped/config";
// ─── 섹션 헤더 컴포넌트 ───
function SectionHeader({ icon: Icon, title, description }: {
icon: React.ComponentType<{ className?: string }>;
title: string;
description?: string;
}) {
return (
<div className="space-y-1">
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold">{title}</h3>
</div>
{description && <p className="text-muted-foreground text-[10px]">{description}</p>}
</div>
);
}
// ─── 수평 Switch Row (토스 패턴) ───
function SwitchRow({ label, description, checked, onCheckedChange }: {
label: string;
description?: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
}) {
return (
<div className="flex items-center justify-between py-1">
<div className="space-y-0.5">
<p className="text-sm">{label}</p>
{description && <p className="text-[11px] text-muted-foreground">{description}</p>}
</div>
<Switch checked={checked} onCheckedChange={onCheckedChange} />
</div>
);
}
// ─── 수평 라벨 + 컨트롤 Row ───
function LabeledRow({ label, description, children }: {
label: string;
description?: string;
children: React.ReactNode;
}) {
return (
<div className="flex items-center justify-between py-1">
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground">{label}</p>
{description && <p className="text-[10px] text-muted-foreground">{description}</p>}
</div>
{children}
</div>
);
}
// ─── 그룹 헤더 스타일 카드 ───
const HEADER_STYLE_CARDS = [
{ value: "default", icon: LayoutGrid, title: "기본", description: "표준 그룹 헤더" },
{ value: "compact", icon: FoldVertical, title: "컴팩트", description: "간결한 헤더" },
{ value: "card", icon: Layers, title: "카드", description: "카드 스타일 헤더" },
] as const;
interface V2TableGroupedConfigPanelProps {
config: TableGroupedConfig;
onChange: (newConfig: Partial<TableGroupedConfig>) => void;
}
export const V2TableGroupedConfigPanel: React.FC<V2TableGroupedConfigPanelProps> = ({
config,
onChange,
}) => {
// componentConfigChanged 이벤트 발행 래퍼
const handleChange = useCallback((newConfig: Partial<TableGroupedConfig>) => {
onChange(newConfig);
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("componentConfigChanged", {
detail: { config: { ...config, ...newConfig } },
})
);
}
}, [onChange, config]);
const updateConfig = useCallback((updates: Partial<TableGroupedConfig>) => {
handleChange({ ...config, ...updates });
}, [handleChange, config]);
const updateGroupConfig = useCallback((updates: Partial<TableGroupedConfig["groupConfig"]>) => {
handleChange({
...config,
groupConfig: { ...config.groupConfig, ...updates },
});
}, [handleChange, config]);
// ─── 상태 ───
const [tables, setTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
const [tableColumns, setTableColumns] = useState<ColumnConfig[]>([]);
const [loadingTables, setLoadingTables] = useState(false);
const [loadingColumns, setLoadingColumns] = useState(false);
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
// Collapsible 상태
const [displayOpen, setDisplayOpen] = useState(false);
const [linkedOpen, setLinkedOpen] = useState(false);
// ─── 실제 사용할 테이블 이름 ───
const targetTableName = useMemo(() => {
if (config.useCustomTable && config.customTableName) {
return config.customTableName;
}
return config.selectedTable;
}, [config.useCustomTable, config.customTableName, config.selectedTable]);
// ─── 테이블 목록 로드 ───
useEffect(() => {
const loadTables = async () => {
setLoadingTables(true);
try {
const tableList = await tableTypeApi.getTables();
if (tableList && Array.isArray(tableList)) {
setTables(
tableList.map((t: any) => ({
tableName: t.tableName || t.table_name,
displayName: t.displayName || t.display_name || t.tableName || t.table_name,
}))
);
}
} catch (err) {
console.error("테이블 목록 로드 실패:", err);
} finally {
setLoadingTables(false);
}
};
loadTables();
}, []);
// ─── 선택된 테이블의 컬럼 로드 ───
useEffect(() => {
if (!targetTableName) {
setTableColumns([]);
return;
}
const loadColumns = async () => {
setLoadingColumns(true);
try {
const columns = await tableTypeApi.getColumns(targetTableName);
if (columns && Array.isArray(columns)) {
const cols: ColumnConfig[] = columns.map((col: any, idx: number) => ({
columnName: col.column_name || col.columnName,
displayName: col.display_name || col.displayName || col.column_name || col.columnName,
visible: true,
sortable: true,
searchable: false,
align: "left" as const,
order: idx,
}));
setTableColumns(cols);
if (!config.columns || config.columns.length === 0) {
updateConfig({ columns: cols });
}
}
} catch (err) {
console.error("컬럼 로드 실패:", err);
} finally {
setLoadingColumns(false);
}
};
loadColumns();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [targetTableName]);
// ─── 테이블 변경 핸들러 ───
const handleTableChange = useCallback((newTableName: string) => {
if (newTableName === config.selectedTable) return;
updateConfig({ selectedTable: newTableName, columns: [] });
setTableComboboxOpen(false);
}, [config.selectedTable, updateConfig]);
// ─── 컬럼 가시성 토글 ───
const toggleColumnVisibility = useCallback((columnName: string) => {
const updatedColumns = (config.columns || []).map((col) =>
col.columnName === columnName ? { ...col, visible: !col.visible } : col
);
updateConfig({ columns: updatedColumns });
}, [config.columns, updateConfig]);
// ─── 합계 컬럼 토글 ───
const toggleSumColumn = useCallback((columnName: string) => {
const currentSumCols = config.groupConfig?.summary?.sumColumns || [];
const newSumCols = currentSumCols.includes(columnName)
? currentSumCols.filter((c) => c !== columnName)
: [...currentSumCols, columnName];
updateGroupConfig({
summary: {
...config.groupConfig?.summary,
sumColumns: newSumCols,
},
});
}, [config.groupConfig?.summary, updateGroupConfig]);
// ─── 연결 필터 관리 ───
const addLinkedFilter = useCallback(() => {
const newFilter: LinkedFilterConfig = {
sourceComponentId: "",
sourceField: "value",
targetColumn: "",
enabled: true,
};
updateConfig({
linkedFilters: [...(config.linkedFilters || []), newFilter],
});
}, [config.linkedFilters, updateConfig]);
const removeLinkedFilter = useCallback((index: number) => {
const filters = [...(config.linkedFilters || [])];
filters.splice(index, 1);
updateConfig({ linkedFilters: filters });
}, [config.linkedFilters, updateConfig]);
const updateLinkedFilter = useCallback((index: number, updates: Partial<LinkedFilterConfig>) => {
const filters = [...(config.linkedFilters || [])];
filters[index] = { ...filters[index], ...updates };
updateConfig({ linkedFilters: filters });
}, [config.linkedFilters, updateConfig]);
// ─── 렌더링 ───
return (
<div className="space-y-4">
{/* ═══════════════════════════════════════ */}
{/* 1단계: 데이터 소스 (테이블 선택) */}
{/* ═══════════════════════════════════════ */}
<div className="space-y-3">
<SectionHeader icon={Table2} title="데이터 소스" description="그룹화할 테이블을 선택하세요" />
<Separator />
<SwitchRow
label="커스텀 테이블 사용"
description="화면 메인 테이블 대신 다른 테이블을 사용합니다"
checked={config.useCustomTable ?? false}
onCheckedChange={(checked) => updateConfig({ useCustomTable: checked })}
/>
{config.useCustomTable ? (
<Input
value={config.customTableName || ""}
onChange={(e) => updateConfig({ customTableName: e.target.value })}
placeholder="테이블명을 직접 입력하세요"
className="h-8 text-xs"
/>
) : (
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableComboboxOpen}
className="h-8 w-full justify-between text-xs"
disabled={loadingTables}
>
<div className="flex items-center gap-2 truncate">
<Table2 className="h-3 w-3 shrink-0" />
<span className="truncate">
{loadingTables
? "테이블 로딩 중..."
: config.selectedTable
? tables.find((t) => t.tableName === config.selectedTable)?.displayName || config.selectedTable
: "테이블 선택"}
</span>
</div>
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command
filter={(value, search) => {
if (value.toLowerCase().includes(search.toLowerCase())) return 1;
return 0;
}}
>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.displayName} ${table.tableName}`}
onSelect={() => handleTableChange(table.tableName)}
className="text-xs"
>
<Check
className={cn("mr-2 h-3 w-3", config.selectedTable === table.tableName ? "opacity-100" : "opacity-0")}
/>
<div className="flex flex-col">
<span>{table.displayName}</span>
{table.displayName !== table.tableName && (
<span className="text-[10px] text-muted-foreground/70">{table.tableName}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
</div>
{/* ═══════════════════════════════════════ */}
{/* 2단계: 그룹화 설정 */}
{/* ═══════════════════════════════════════ */}
{targetTableName && (
<div className="space-y-3">
<SectionHeader icon={Layers} title="그룹화 설정" description="데이터를 어떤 컬럼 기준으로 그룹화할지 설정합니다" />
<Separator />
{/* 그룹화 기준 컬럼 */}
<LabeledRow label="그룹화 기준 컬럼 *">
<Select
value={config.groupConfig?.groupByColumn || ""}
onValueChange={(value) => updateGroupConfig({ groupByColumn: value })}
>
<SelectTrigger className="h-7 w-[160px] text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{(loadingColumns ? [] : tableColumns).map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</LabeledRow>
{/* 그룹 라벨 형식 */}
<div className="space-y-1">
<div className="flex items-center gap-2">
<Type className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium truncate"> </span>
</div>
<Input
value={config.groupConfig?.groupLabelFormat || "{value}"}
onChange={(e) => updateGroupConfig({ groupLabelFormat: e.target.value })}
placeholder="{value} ({컬럼명})"
className="h-7 text-xs"
/>
<p className="text-[10px] text-muted-foreground">
{"{value}"} = , {"{컬럼명}"} =
</p>
</div>
<SwitchRow
label="기본 펼침 상태"
description="그룹이 기본으로 펼쳐진 상태로 표시됩니다"
checked={config.groupConfig?.defaultExpanded ?? true}
onCheckedChange={(checked) => updateGroupConfig({ defaultExpanded: checked })}
/>
{/* 그룹 정렬 */}
<LabeledRow label="그룹 정렬">
<Select
value={config.groupConfig?.sortDirection || "asc"}
onValueChange={(value: string) => updateGroupConfig({ sortDirection: value as "asc" | "desc" })}
>
<SelectTrigger className="h-7 w-[120px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{sortDirectionOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</LabeledRow>
<SwitchRow
label="개수 표시"
description="그룹 헤더에 항목 수를 표시합니다"
checked={config.groupConfig?.summary?.showCount ?? true}
onCheckedChange={(checked) =>
updateGroupConfig({
summary: { ...config.groupConfig?.summary, showCount: checked },
})
}
/>
{/* 합계 컬럼 */}
{tableColumns.length > 0 && (
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<Hash className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium truncate"> </span>
</div>
<p className="text-[10px] text-muted-foreground"> </p>
<div className="max-h-28 space-y-0.5 overflow-y-auto rounded-md border p-2">
{tableColumns.map((col) => {
const isChecked = config.groupConfig?.summary?.sumColumns?.includes(col.columnName) ?? false;
return (
<div
key={col.columnName}
className={cn(
"flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-muted/50",
isChecked && "bg-primary/10",
)}
onClick={() => toggleSumColumn(col.columnName)}
>
<Checkbox
checked={isChecked}
onCheckedChange={() => toggleSumColumn(col.columnName)}
className="pointer-events-none h-3.5 w-3.5"
/>
<span className="truncate text-xs">{col.displayName || col.columnName}</span>
</div>
);
})}
</div>
</div>
)}
</div>
)}
{/* 테이블 미선택 안내 */}
{!targetTableName && (
<div className="rounded-lg border-2 border-dashed p-6 text-center">
<Table2 className="mx-auto mb-2 h-8 w-8 text-muted-foreground opacity-30" />
<p className="text-sm text-muted-foreground"> </p>
<p className="text-xs text-muted-foreground"> </p>
</div>
)}
{/* ═══════════════════════════════════════ */}
{/* 3단계: 컬럼 선택 */}
{/* ═══════════════════════════════════════ */}
{targetTableName && (config.columns || tableColumns).length > 0 && (
<div className="space-y-3">
<SectionHeader
icon={Columns3}
title={`컬럼 선택 (${(config.columns || tableColumns).filter((c) => c.visible !== false).length}개 표시)`}
description="표시할 컬럼을 선택하세요"
/>
<Separator />
<div className="max-h-48 space-y-0.5 overflow-y-auto rounded-md border p-2">
{(config.columns || tableColumns).map((col) => {
const isVisible = col.visible !== false;
return (
<div
key={col.columnName}
className={cn(
"flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-muted/50",
isVisible && "bg-primary/10",
)}
onClick={() => toggleColumnVisibility(col.columnName)}
>
<Checkbox
checked={isVisible}
onCheckedChange={() => toggleColumnVisibility(col.columnName)}
className="pointer-events-none h-3.5 w-3.5"
/>
<Database className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
<span className="truncate text-xs">{col.displayName || col.columnName}</span>
</div>
);
})}
</div>
</div>
)}
{/* ═══════════════════════════════════════ */}
{/* 4단계: 그룹 헤더 스타일 (카드 선택) */}
{/* ═══════════════════════════════════════ */}
{targetTableName && (
<div className="space-y-3">
<SectionHeader icon={LayoutGrid} title="그룹 헤더 스타일" description="그룹 헤더의 디자인을 선택하세요" />
<Separator />
<div className="grid grid-cols-3 gap-2">
{HEADER_STYLE_CARDS.map((card) => {
const Icon = card.icon;
const isSelected = (config.groupHeaderStyle || "default") === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateConfig({ groupHeaderStyle: card.value as "default" | "compact" | "card" })}
className={cn(
"flex min-h-[70px] flex-col items-center justify-center rounded-lg border p-2.5 text-center transition-all",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<Icon className="mb-1 h-4 w-4 text-primary" />
<span className="text-xs font-medium leading-tight">{card.title}</span>
<span className="mt-0.5 text-[10px] leading-tight text-muted-foreground">{card.description}</span>
</button>
);
})}
</div>
</div>
)}
{/* ═══════════════════════════════════════ */}
{/* 5단계: 표시 설정 (기본 접힘) */}
{/* ═══════════════════════════════════════ */}
<Collapsible open={displayOpen} onOpenChange={setDisplayOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium truncate"> </span>
<Badge variant="secondary" className="text-[10px] h-5">6</Badge>
</div>
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", displayOpen && "rotate-180")} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
{/* 체크박스 */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<CheckSquare className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium truncate"></span>
</div>
<SwitchRow
label="체크박스 표시"
description="행 선택용 체크박스를 표시합니다"
checked={config.showCheckbox ?? false}
onCheckedChange={(checked) => updateConfig({ showCheckbox: checked })}
/>
{config.showCheckbox && (
<div className="ml-4 border-l-2 border-primary/20 pl-3">
<LabeledRow label="선택 모드">
<Select
value={config.checkboxMode || "multi"}
onValueChange={(value: string) => updateConfig({ checkboxMode: value as "single" | "multi" })}
>
<SelectTrigger className="h-7 w-[120px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{checkboxModeOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</LabeledRow>
</div>
)}
</div>
<Separator />
{/* UI 옵션 */}
<SwitchRow
label="펼치기/접기 버튼 표시"
description="전체 펼치기/접기 버튼을 상단에 표시합니다"
checked={config.showExpandAllButton ?? true}
onCheckedChange={(checked) => updateConfig({ showExpandAllButton: checked })}
/>
<SwitchRow
label="행 클릭 가능"
description="행 클릭 시 이벤트를 발생시킵니다"
checked={config.rowClickable ?? true}
onCheckedChange={(checked) => updateConfig({ rowClickable: checked })}
/>
<Separator />
{/* 높이 및 메시지 */}
<LabeledRow label="최대 높이 (px)">
<Input
type="number"
value={typeof config.maxHeight === "number" ? config.maxHeight : 600}
onChange={(e) => updateConfig({ maxHeight: parseInt(e.target.value) || 600 })}
min={200}
max={2000}
className="h-7 w-[100px] text-xs"
/>
</LabeledRow>
<div className="space-y-1">
<span className="text-xs text-muted-foreground truncate"> </span>
<Input
value={config.emptyMessage || ""}
onChange={(e) => updateConfig({ emptyMessage: e.target.value })}
placeholder="데이터가 없습니다."
className="h-7 text-xs"
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
{/* ═══════════════════════════════════════ */}
{/* 6단계: 연동 설정 (기본 접힘) */}
{/* ═══════════════════════════════════════ */}
<Collapsible open={linkedOpen} onOpenChange={setLinkedOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium truncate"> </span>
<Badge variant="secondary" className="text-[10px] h-5">{config.linkedFilters?.length || 0}</Badge>
</div>
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", linkedOpen && "rotate-180")} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
<div className="flex items-center justify-between">
<p className="text-[10px] text-muted-foreground">
( )
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={addLinkedFilter}
className="h-6 shrink-0 px-2 text-xs"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{(config.linkedFilters || []).length === 0 ? (
<div className="rounded-lg border-2 border-dashed py-4 text-center">
<Link2 className="mx-auto mb-1 h-6 w-6 text-muted-foreground opacity-30" />
<p className="text-xs text-muted-foreground"> </p>
</div>
) : (
<div className="space-y-2">
{(config.linkedFilters || []).map((filter, idx) => (
<div key={idx} className="space-y-2 rounded-md border p-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium"> #{idx + 1}</span>
<div className="flex items-center gap-2">
<Switch
checked={filter.enabled !== false}
onCheckedChange={(checked) => updateLinkedFilter(idx, { enabled: checked })}
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeLinkedFilter(idx)}
className="h-5 w-5 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> ID</span>
<Input
value={filter.sourceComponentId}
onChange={(e) => updateLinkedFilter(idx, { sourceComponentId: e.target.value })}
placeholder="예: search-filter-1"
className="h-6 text-xs"
/>
</div>
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={filter.sourceField || "value"}
onChange={(e) => updateLinkedFilter(idx, { sourceField: e.target.value })}
placeholder="value"
className="h-6 text-xs"
/>
</div>
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Select
value={filter.targetColumn}
onValueChange={(value) => updateLinkedFilter(idx, { targetColumn: value })}
>
<SelectTrigger className="h-6 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
))}
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
V2TableGroupedConfigPanel.displayName = "V2TableGroupedConfigPanel";
export default V2TableGroupedConfigPanel;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,563 @@
"use client";
/**
* V2TableSearchWidget
* UX: 대상 -> -> -> ()
*/
import React, { useState, useEffect, useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
PanelLeft,
PanelRight,
Layers,
Zap,
Lock,
Plus,
Trash2,
Settings,
ChevronDown,
Search,
Filter,
} from "lucide-react";
import { cn } from "@/lib/utils";
// ─── 대상 패널 위치 카드 정의 ───
const PANEL_POSITION_CARDS = [
{
value: "left",
icon: PanelLeft,
title: "좌측 패널",
description: "카드 디스플레이 등",
},
{
value: "right",
icon: PanelRight,
title: "우측 패널",
description: "테이블 리스트 등",
},
{
value: "auto",
icon: Layers,
title: "자동",
description: "모든 테이블 대상",
},
] as const;
// ─── 필터 모드 카드 정의 ───
const FILTER_MODE_CARDS = [
{
value: "dynamic",
icon: Zap,
title: "동적 모드",
description: "사용자가 직접 필터를 선택해요",
},
{
value: "preset",
icon: Lock,
title: "고정 모드",
description: "디자이너가 미리 필터를 지정해요",
},
] as const;
// ─── 필터 타입 옵션 ───
const FILTER_TYPE_OPTIONS = [
{ value: "text", label: "텍스트" },
{ value: "number", label: "숫자" },
{ value: "date", label: "날짜" },
{ value: "select", label: "선택" },
] as const;
interface PresetFilter {
id: string;
columnName: string;
columnLabel: string;
filterType: "text" | "number" | "date" | "select";
width?: number;
multiSelect?: boolean;
}
// ─── 수평 Switch Row (토스 패턴) ───
function SwitchRow({
label,
description,
checked,
onCheckedChange,
}: {
label: string;
description?: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
}) {
return (
<div className="flex items-center justify-between py-1">
<div className="space-y-0.5">
<p className="text-sm">{label}</p>
{description && (
<p className="text-[11px] text-muted-foreground">{description}</p>
)}
</div>
<Switch checked={checked} onCheckedChange={onCheckedChange} />
</div>
);
}
// ─── 섹션 헤더 컴포넌트 ───
function SectionHeader({
icon: Icon,
title,
description,
}: {
icon: React.ComponentType<{ className?: string }>;
title: string;
description?: string;
}) {
return (
<div className="space-y-1">
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold">{title}</h3>
</div>
{description && (
<p className="text-muted-foreground text-[10px]">{description}</p>
)}
</div>
);
}
// ─── inputType에서 filterType 추출 헬퍼 ───
function getFilterTypeFromInputType(
inputType: string
): "text" | "number" | "date" | "select" {
if (
inputType.includes("number") ||
inputType.includes("decimal") ||
inputType.includes("integer")
) {
return "number";
}
if (inputType.includes("date") || inputType.includes("time")) {
return "date";
}
if (
inputType.includes("select") ||
inputType.includes("dropdown") ||
inputType.includes("code") ||
inputType.includes("category")
) {
return "select";
}
return "text";
}
interface V2TableSearchWidgetConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
tables?: any[];
}
export const V2TableSearchWidgetConfigPanel: React.FC<
V2TableSearchWidgetConfigPanelProps
> = ({ config: configProp, onChange, tables = [] }) => {
const config = configProp || {};
// componentConfigChanged 이벤트 발행 래퍼
const handleChange = useCallback(
(newConfig: Record<string, any>) => {
onChange(newConfig);
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("componentConfigChanged", {
detail: { config: newConfig },
})
);
}
},
[onChange]
);
// key-value 형태 업데이트 헬퍼
const updateField = useCallback(
(key: string, value: any) => {
handleChange({ ...config, [key]: value });
},
[handleChange, config]
);
// 첫 번째 테이블의 컬럼 목록
const availableColumns =
tables.length > 0 && tables[0].columns ? tables[0].columns : [];
// ─── 로컬 상태 ───
const [advancedOpen, setAdvancedOpen] = useState(false);
const [localPresetFilters, setLocalPresetFilters] = useState<PresetFilter[]>(
config.presetFilters ?? []
);
// config 외부 변경 시 로컬 상태 동기화
useEffect(() => {
setLocalPresetFilters(config.presetFilters ?? []);
}, [config.presetFilters]);
// 현재 config 값들
const targetPanelPosition = config.targetPanelPosition ?? "left";
const filterMode = config.filterMode ?? "dynamic";
const autoSelectFirstTable = config.autoSelectFirstTable ?? true;
const showTableSelector = config.showTableSelector ?? true;
// ─── 고정 필터 CRUD ───
const addFilter = useCallback(() => {
const newFilter: PresetFilter = {
id: `filter_${Date.now()}`,
columnName: "",
columnLabel: "",
filterType: "text",
width: 200,
};
const updated = [...localPresetFilters, newFilter];
setLocalPresetFilters(updated);
handleChange({ ...config, presetFilters: updated });
}, [localPresetFilters, handleChange, config]);
const removeFilter = useCallback(
(id: string) => {
const updated = localPresetFilters.filter((f) => f.id !== id);
setLocalPresetFilters(updated);
handleChange({ ...config, presetFilters: updated });
},
[localPresetFilters, handleChange, config]
);
const updateFilter = useCallback(
(id: string, field: keyof PresetFilter, value: any) => {
const updated = localPresetFilters.map((f) =>
f.id === id ? { ...f, [field]: value } : f
);
setLocalPresetFilters(updated);
handleChange({ ...config, presetFilters: updated });
},
[localPresetFilters, handleChange, config]
);
// 컬럼 선택 시 라벨+타입 자동 설정
const handleColumnSelect = useCallback(
(filterId: string, columnName: string) => {
const selectedColumn = availableColumns.find(
(col: any) => col.columnName === columnName
);
const updated = localPresetFilters.map((f) =>
f.id === filterId
? {
...f,
columnName,
columnLabel: selectedColumn?.columnLabel || columnName,
filterType: getFilterTypeFromInputType(
selectedColumn?.inputType || "text"
),
}
: f
);
setLocalPresetFilters(updated);
handleChange({ ...config, presetFilters: updated });
},
[availableColumns, localPresetFilters, handleChange, config]
);
return (
<div className="space-y-4">
{/* ─── 1단계: 대상 패널 위치 선택 ─── */}
<div className="space-y-2">
<SectionHeader
icon={Search}
title="검색 필터 위젯"
description="화면 내 테이블을 자동 감지하여 검색, 필터, 그룹 기능을 제공합니다"
/>
<p className="text-sm font-medium mt-3">
?
</p>
<div className="grid grid-cols-3 gap-2">
{PANEL_POSITION_CARDS.map((card) => {
const Icon = card.icon;
const isSelected = targetPanelPosition === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateField("targetPanelPosition", card.value)}
className={cn(
"flex flex-col items-center justify-center rounded-lg border p-3 text-center transition-all min-h-[80px]",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<Icon className="h-5 w-5 mb-1.5 text-primary" />
<span className="text-xs font-medium leading-tight">
{card.title}
</span>
<span className="text-[10px] text-muted-foreground leading-tight mt-0.5">
{card.description}
</span>
</button>
);
})}
</div>
</div>
{/* ─── 2단계: 필터 모드 선택 ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"> ?</p>
<div className="grid grid-cols-2 gap-2">
{FILTER_MODE_CARDS.map((card) => {
const Icon = card.icon;
const isSelected = filterMode === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateField("filterMode", card.value)}
className={cn(
"flex flex-col items-center justify-center rounded-lg border p-3 text-center transition-all min-h-[80px]",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<Icon className="h-5 w-5 mb-1.5 text-primary" />
<span className="text-xs font-medium leading-tight">
{card.title}
</span>
<span className="text-[10px] text-muted-foreground leading-tight mt-0.5">
{card.description}
</span>
</button>
);
})}
</div>
</div>
{/* ─── 3단계: 고정 모드 필터 목록 ─── */}
{filterMode === "preset" && (
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-primary" />
<span className="text-sm font-medium"> </span>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={addFilter}
className="h-7 px-2 text-xs"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{localPresetFilters.length === 0 ? (
<div className="text-center py-6 text-muted-foreground">
<Filter className="mx-auto mb-2 h-8 w-8 opacity-30" />
<p className="text-sm"> </p>
<p className="text-xs">
</p>
</div>
) : (
<div className="space-y-2">
{localPresetFilters.map((filter) => (
<div
key={filter.id}
className="bg-card flex flex-col gap-2 rounded-md border px-3 py-2.5"
>
{/* 상단: 컬럼 선택 + 삭제 */}
<div className="flex items-center gap-1.5">
<div className="flex-1">
{availableColumns.length > 0 ? (
<Select
value={filter.columnName || ""}
onValueChange={(value) =>
handleColumnSelect(filter.id, value)
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{availableColumns.map((col: any) => (
<SelectItem
key={col.columnName}
value={col.columnName}
>
<div className="flex items-center gap-2">
<span className="font-medium">
{col.columnLabel}
</span>
<span className="text-muted-foreground text-[10px]">
({col.columnName})
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={filter.columnName}
onChange={(e) =>
updateFilter(
filter.id,
"columnName",
e.target.value
)
}
placeholder="예: customer_name"
className="h-7 text-xs"
/>
)}
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeFilter(filter.id)}
className="text-muted-foreground hover:text-destructive h-7 w-7 shrink-0 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 하단: 필터 타입 + 너비 */}
<div className="flex items-center gap-1.5">
<Select
value={filter.filterType}
onValueChange={(
value: "text" | "number" | "date" | "select"
) => updateFilter(filter.id, "filterType", value)}
>
<SelectTrigger className="h-7 flex-1 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FILTER_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-1">
<span className="text-muted-foreground shrink-0 text-[10px]">
</span>
<Input
type="number"
value={filter.width || 200}
onChange={(e) =>
updateFilter(
filter.id,
"width",
parseInt(e.target.value) || 200
)
}
className="h-7 w-16 text-xs"
min={100}
max={500}
/>
</div>
</div>
{/* 표시명 (컬럼 선택 시 자동 설정, 수동 변경 가능) */}
{filter.columnLabel && (
<p className="text-muted-foreground text-[10px]">
: {filter.columnLabel}
</p>
)}
</div>
))}
</div>
)}
<p className="text-[11px] text-muted-foreground">
</p>
</div>
)}
{/* 동적 모드 안내 */}
{filterMode === "dynamic" && (
<div className="rounded-lg border bg-muted/30 p-4">
<div className="flex items-center gap-2 mb-2">
<Zap className="h-4 w-4 text-primary" />
<span className="text-sm font-medium"> </span>
</div>
<p className="text-[11px] text-muted-foreground">
. .
</p>
</div>
)}
{/* ─── 4단계: 고급 설정 (기본 접혀있음) ─── */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
advancedOpen && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-2">
<SwitchRow
label="첫 번째 테이블 자동 선택"
description="화면 로딩 시 대상 패널의 첫 번째 테이블을 자동으로 선택해요"
checked={autoSelectFirstTable}
onCheckedChange={(checked) =>
updateField("autoSelectFirstTable", checked)
}
/>
<SwitchRow
label="테이블 선택 드롭다운 표시"
description="여러 테이블이 있을 때 사용자가 직접 대상을 선택할 수 있어요"
checked={showTableSelector}
onCheckedChange={(checked) =>
updateField("showTableSelector", checked)
}
/>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
V2TableSearchWidgetConfigPanel.displayName = "V2TableSearchWidgetConfigPanel";
export default V2TableSearchWidgetConfigPanel;

View File

@ -0,0 +1,304 @@
"use client";
/**
* V2TextDisplay
* UX: 텍스트 -> () -> () -> ()
*/
import React, { useState } from "react";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Settings,
ChevronDown,
AlignLeft,
AlignCenter,
AlignRight,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { TextDisplayConfig } from "@/lib/registry/components/v2-text-display/types";
const FONT_SIZE_CARDS = [
{ value: "12px", label: "작게", preview: "Aa" },
{ value: "14px", label: "보통", preview: "Aa" },
{ value: "18px", label: "크게", preview: "Aa" },
{ value: "24px", label: "제목", preview: "Aa" },
] as const;
const FONT_WEIGHT_CARDS = [
{ value: "lighter", label: "얇게" },
{ value: "normal", label: "보통" },
{ value: "bold", label: "굵게" },
] as const;
const ALIGN_CARDS = [
{ value: "left", label: "왼쪽", icon: AlignLeft },
{ value: "center", label: "가운데", icon: AlignCenter },
{ value: "right", label: "오른쪽", icon: AlignRight },
] as const;
interface V2TextDisplayConfigPanelProps {
config: TextDisplayConfig;
onChange: (config: Partial<TextDisplayConfig>) => void;
}
export const V2TextDisplayConfigPanel: React.FC<V2TextDisplayConfigPanelProps> = ({
config,
onChange,
}) => {
const [advancedOpen, setAdvancedOpen] = useState(false);
const updateConfig = (field: keyof TextDisplayConfig, value: any) => {
const newConfig = { ...config, [field]: value };
onChange({ [field]: value });
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("componentConfigChanged", {
detail: { config: newConfig },
})
);
}
};
return (
<div className="space-y-4">
{/* ─── 1단계: 표시 텍스트 ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"> </p>
<Input
value={config.text || ""}
onChange={(e) => updateConfig("text", e.target.value)}
placeholder="표시할 텍스트를 입력하세요"
className="h-8 text-sm"
/>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
{/* ─── 2단계: 폰트 크기 카드 선택 ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"> </p>
<div className="grid grid-cols-4 gap-2">
{FONT_SIZE_CARDS.map((card) => {
const isSelected = (config.fontSize || "14px") === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateConfig("fontSize", card.value)}
className={cn(
"flex flex-col items-center justify-center rounded-lg border p-2.5 text-center transition-all min-h-[60px]",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<span
className="font-medium"
style={{ fontSize: card.value }}
>
{card.preview}
</span>
<span className="text-[10px] text-muted-foreground mt-0.5">
{card.label}
</span>
</button>
);
})}
</div>
<div className="flex items-center justify-between pt-1">
<span className="text-[11px] text-muted-foreground"> </span>
<Input
value={config.fontSize || "14px"}
onChange={(e) => updateConfig("fontSize", e.target.value)}
placeholder="14px"
className="h-7 w-[100px] text-xs"
/>
</div>
</div>
{/* ─── 3단계: 폰트 굵기 카드 선택 ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"> </p>
<div className="grid grid-cols-3 gap-2">
{FONT_WEIGHT_CARDS.map((card) => {
const isSelected = (config.fontWeight || "normal") === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateConfig("fontWeight", card.value)}
className={cn(
"flex flex-col items-center justify-center rounded-lg border p-2.5 text-center transition-all min-h-[50px]",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<span
className="text-sm"
style={{ fontWeight: card.value }}
>
</span>
<span className="text-[10px] text-muted-foreground mt-0.5">
{card.label}
</span>
</button>
);
})}
</div>
</div>
{/* ─── 4단계: 텍스트 정렬 카드 선택 ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"> </p>
<div className="grid grid-cols-3 gap-2">
{ALIGN_CARDS.map((card) => {
const Icon = card.icon;
const isSelected = (config.textAlign || "left") === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateConfig("textAlign", card.value)}
className={cn(
"flex flex-col items-center justify-center rounded-lg border p-2.5 text-center transition-all gap-1 min-h-[50px]",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<Icon className="h-4 w-4 text-muted-foreground" />
<span className="text-[10px] text-muted-foreground">
{card.label}
</span>
</button>
);
})}
</div>
</div>
{/* ─── 5단계: 텍스트 색상 ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"> </p>
<div className="rounded-lg border bg-muted/30 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div
className="h-6 w-6 rounded-md border"
style={{ backgroundColor: config.color || "#212121" }}
/>
<span className="text-xs text-muted-foreground">
{config.color || "#212121"}
</span>
</div>
<Input
type="color"
value={config.color || "#212121"}
onChange={(e) => updateConfig("color", e.target.value)}
className="h-7 w-[60px] cursor-pointer p-0.5"
/>
</div>
</div>
</div>
{/* ─── 6단계: 고급 설정 (기본 접혀있음) ─── */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
advancedOpen && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
{/* 배경색 */}
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"></span>
<div className="flex items-center gap-2">
<div
className="h-5 w-5 rounded border"
style={{ backgroundColor: config.backgroundColor || "#ffffff" }}
/>
<Input
type="color"
value={config.backgroundColor || "#ffffff"}
onChange={(e) => updateConfig("backgroundColor", e.target.value)}
className="h-7 w-[60px] cursor-pointer p-0.5"
/>
</div>
</div>
{/* 패딩 */}
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"></span>
<Input
value={config.padding || ""}
onChange={(e) => updateConfig("padding", e.target.value)}
placeholder="8px"
className="h-7 w-[100px] text-xs"
/>
</div>
{/* 모서리 둥글기 */}
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Input
value={config.borderRadius || ""}
onChange={(e) => updateConfig("borderRadius", e.target.value)}
placeholder="4px"
className="h-7 w-[100px] text-xs"
/>
</div>
{/* 테두리 */}
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"></span>
<Input
value={config.border || ""}
onChange={(e) => updateConfig("border", e.target.value)}
placeholder="1px solid #d1d5db"
className="h-7 w-[140px] text-xs"
/>
</div>
{/* 비활성화 */}
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"></p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.disabled || false}
onCheckedChange={(checked) => updateConfig("disabled", checked)}
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
V2TextDisplayConfigPanel.displayName = "V2TextDisplayConfigPanel";
export default V2TextDisplayConfigPanel;

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,8 @@
import React from "react";
import DOMPurify from "isomorphic-dompurify";
let DOMPurify: any = null;
if (typeof window !== "undefined") {
DOMPurify = require("isomorphic-dompurify");
}
import {
Check, Save, CheckCircle, CircleCheck, FileCheck, ShieldCheck,
Trash2, Trash, XCircle, X, Eraser, CircleX,
@ -119,6 +122,7 @@ export function addToIconMap(name: string, component: LucideIcon): void {
// SVG 정화
// ---------------------------------------------------------------------------
export function sanitizeSvg(svgString: string): string {
if (!DOMPurify) return svgString;
return DOMPurify.sanitize(svgString, { USE_PROFILES: { svg: true } });
}

View File

@ -304,9 +304,16 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
return type;
};
const componentType = mapToV2ComponentType(rawComponentType);
const mappedComponentType = mapToV2ComponentType(rawComponentType);
// 컴포넌트 타입 변환 완료
// fieldType 기반 동적 컴포넌트 전환 (통합 필드 설정 패널에서 설정된 값)
const componentType = (() => {
const ft = (component as any).componentConfig?.fieldType;
if (!ft) return mappedComponentType;
if (["text", "number", "password", "textarea", "slider", "color", "numbering"].includes(ft)) return "v2-input";
if (["select", "category", "entity"].includes(ft)) return "v2-select";
return mappedComponentType;
})();
// 🆕 조건부 렌더링 체크 (conditionalConfig)
// componentConfig 또는 overrides에서 conditionalConfig를 가져와서 formData와 비교
@ -740,7 +747,21 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 컬럼 메타데이터 기반 componentConfig 병합 (DB 최신 설정 우선)
const isEntityJoinColumn = fieldName?.includes(".");
const baseColumnName = isEntityJoinColumn ? undefined : fieldName;
const mergedComponentConfig = mergeColumnMeta(screenTableName, baseColumnName, component.componentConfig || {});
const rawMergedConfig = mergeColumnMeta(screenTableName, baseColumnName, component.componentConfig || {});
// fieldType이 설정된 경우, source/inputType 보조 속성 자동 보완
const mergedComponentConfig = (() => {
const ft = rawMergedConfig?.fieldType;
if (!ft) return rawMergedConfig;
const patch: Record<string, any> = {};
if (["select", "category", "entity"].includes(ft) && !rawMergedConfig.source) {
patch.source = ft === "category" ? "category" : ft === "entity" ? "entity" : "static";
}
if (["text", "number", "password", "textarea", "slider", "color", "numbering"].includes(ft) && !rawMergedConfig.inputType) {
patch.inputType = ft;
}
return Object.keys(patch).length > 0 ? { ...rawMergedConfig, ...patch } : rawMergedConfig;
})();
// NOT NULL 기반 필수 여부를 component.required에 반영
const notNullRequired = isColumnRequiredByMeta(screenTableName, baseColumnName);
@ -757,17 +778,16 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
onClick,
onDragStart,
onDragEnd,
size: needsExternalHorizLabel
? { ...(component.size || newComponent.defaultSize), width: undefined, height: undefined }
: component.size || newComponent.defaultSize,
position: component.position,
config: mergedComponentConfig,
componentConfig: mergedComponentConfig,
// componentConfig의 모든 속성을 props로 spread (tableName, displayField 등)
// componentConfig spread를 먼저 → 이후 명시적 속성이 override
...(mergedComponentConfig || {}),
// 🔧 style은 맨 마지막에! (componentConfig.style이 있어도 mergedStyle이 우선)
// size/position/style/label은 componentConfig spread 이후에 설정 (덮어쓰기 방지)
size: needsExternalHorizLabel
? { ...(component.size || newComponent.defaultSize), width: undefined }
: component.size || newComponent.defaultSize,
position: component.position,
style: mergedStyle,
// 수평 라벨 → 외부에서 처리하므로 label 전달 안 함
label: needsExternalHorizLabel ? undefined : effectiveLabel,
// NOT NULL 메타데이터 포함된 필수 여부 (V2Hierarchy 등 직접 props.required 참조하는 컴포넌트용)
required: effectiveRequired,

View File

@ -1,6 +1,6 @@
"use client";
import React from "react";
import React, { useState } from "react";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
@ -13,7 +13,18 @@ import {
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Plus, X } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Plus, X, Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { ConfigFieldDefinition, ConfigOption } from "./ConfigPanelTypes";
interface ConfigFieldProps<T = any> {
@ -29,6 +40,8 @@ export function ConfigField<T>({
onChange,
tableColumns,
}: ConfigFieldProps<T>) {
const [comboboxOpen, setComboboxOpen] = useState(false);
const handleChange = (newValue: any) => {
onChange(field.key, newValue);
};
@ -41,7 +54,7 @@ export function ConfigField<T>({
value={value ?? ""}
onChange={(e) => handleChange(e.target.value)}
placeholder={field.placeholder}
className="h-8 text-xs"
className="h-7 text-xs"
/>
);
@ -59,7 +72,7 @@ export function ConfigField<T>({
min={field.min}
max={field.max}
step={field.step}
className="h-8 text-xs"
className="h-7 text-xs"
/>
);
@ -77,7 +90,7 @@ export function ConfigField<T>({
value={value ?? ""}
onValueChange={handleChange}
>
<SelectTrigger className="h-8 text-xs">
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder={field.placeholder || "선택"} />
</SelectTrigger>
<SelectContent>
@ -103,25 +116,25 @@ export function ConfigField<T>({
case "color":
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<input
type="color"
value={value ?? "#000000"}
onChange={(e) => handleChange(e.target.value)}
className="h-8 w-8 cursor-pointer rounded border"
className="h-7 w-7 cursor-pointer rounded border"
/>
<Input
value={value ?? ""}
onChange={(e) => handleChange(e.target.value)}
placeholder="#000000"
className="h-8 flex-1 text-xs"
className="h-7 flex-1 text-xs"
/>
</div>
);
case "slider":
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<Input
type="number"
value={value ?? field.min ?? 0}
@ -129,17 +142,17 @@ export function ConfigField<T>({
min={field.min}
max={field.max}
step={field.step}
className="h-8 w-20 text-xs"
className="h-7 w-16 text-xs"
/>
<span className="text-muted-foreground text-[10px]">
{field.min ?? 0} ~ {field.max ?? 100}
<span className="text-muted-foreground text-[9px]">
{field.min ?? 0}~{field.max ?? 100}
</span>
</div>
);
case "multi-select":
return (
<div className="space-y-1">
<div className="space-y-0.5">
{(field.options || []).map((opt) => {
const selected = Array.isArray(value) && value.includes(opt.value);
return (
@ -230,7 +243,7 @@ export function ConfigField<T>({
value={value ?? ""}
onValueChange={handleChange}
>
<SelectTrigger className="h-8 text-xs">
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder={field.placeholder || "컬럼 선택"} />
</SelectTrigger>
<SelectContent>
@ -244,21 +257,123 @@ export function ConfigField<T>({
);
}
case "checkbox":
return (
<div className="flex items-center gap-2">
<Checkbox
id={`field-${field.key}`}
checked={!!value}
onCheckedChange={handleChange}
/>
{field.description && (
<label
htmlFor={`field-${field.key}`}
className="cursor-pointer text-xs text-muted-foreground"
>
{field.description}
</label>
)}
</div>
);
case "combobox": {
const options = field.options || [];
const selectedLabel = options.find((opt) => opt.value === value)?.label;
return (
<Popover open={comboboxOpen} onOpenChange={setComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={comboboxOpen}
className="h-7 w-full justify-between text-xs font-normal"
>
<span className="truncate">
{selectedLabel || field.placeholder || "선택"}
</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs">
</CommandEmpty>
<CommandGroup>
{options.map((opt) => (
<CommandItem
key={opt.value}
value={opt.value}
onSelect={(currentValue) => {
handleChange(currentValue === value ? "" : currentValue);
setComboboxOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-1.5 h-3 w-3",
value === opt.value ? "opacity-100" : "opacity-0",
)}
/>
{opt.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
default:
return null;
}
};
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium">{field.label}</Label>
{field.type === "switch" && renderField()}
// textarea, multi-select, key-value는 전체 폭 수직 레이아웃
const isFullWidth = ["textarea", "multi-select", "key-value"].includes(field.type);
// checkbox는 description을 인라인으로 표시하므로 별도 처리
const isCheckbox = field.type === "checkbox";
if (isFullWidth) {
return (
<div className="py-1.5">
<Label className="mb-1 block text-xs text-muted-foreground">{field.label}</Label>
{field.description && !isCheckbox && (
<p className="text-muted-foreground/60 mb-1 text-[9px]">{field.description}</p>
)}
{renderField()}
</div>
);
}
// switch, checkbox: 라벨 왼쪽, 컨트롤 오른쪽 (고정폭 없이)
if (field.type === "switch" || isCheckbox) {
return (
<div className="flex items-center justify-between py-1.5">
<Label className="mr-3 truncate text-xs text-muted-foreground">{field.label}</Label>
{renderField()}
</div>
);
}
// 기본: 수평 property row (라벨 왼쪽, 컨트롤 오른쪽 고정폭)
return (
<div className="flex items-center justify-between py-1.5">
<Label className="mr-3 min-w-0 shrink truncate text-xs text-muted-foreground">
{field.label}
</Label>
<div className="w-[140px] flex-shrink-0">
{renderField()}
</div>
{field.description && (
<p className="text-muted-foreground text-[10px]">{field.description}</p>
)}
{field.type !== "switch" && renderField()}
</div>
);
}

View File

@ -1,75 +1,144 @@
"use client";
import React from "react";
import { ConfigPanelBuilderProps } from "./ConfigPanelTypes";
import { ConfigPanelBuilderProps, ConfigSectionDefinition } from "./ConfigPanelTypes";
import { ConfigSection } from "./ConfigSection";
import { ConfigField } from "./ConfigField";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
function renderSections<T extends Record<string, any>>(
sections: ConfigSectionDefinition<T>[],
config: T,
onChange: (key: string, value: any) => void,
tableColumns?: any[],
) {
return sections.map((section) => {
if (section.condition && !section.condition(config)) {
return null;
}
const visibleFields = section.fields.filter(
(field) => !field.condition || field.condition(config),
);
if (visibleFields.length === 0) {
return null;
}
return (
<ConfigSection key={section.id} section={section}>
{visibleFields.map((field) => (
<ConfigField
key={field.key}
field={field}
value={(config as any)[field.key]}
onChange={onChange}
tableColumns={tableColumns}
/>
))}
</ConfigSection>
);
});
}
export function ConfigPanelBuilder<T extends Record<string, any>>({
config,
onChange,
onConfigChange,
sections,
presets,
tableColumns,
children,
mode = "flat",
context,
}: ConfigPanelBuilderProps<T>) {
return (
<div className="space-y-3">
{/* 프리셋 버튼 */}
{presets && presets.length > 0 && (
<div className="border-b pb-3">
<h4 className="mb-2 text-xs font-medium text-muted-foreground">
</h4>
<div className="flex flex-wrap gap-1">
{presets.map((preset, idx) => (
<button
key={idx}
onClick={() => {
Object.entries(preset.values).forEach(([key, value]) => {
onChange(key, value);
});
}}
className="rounded-full bg-muted px-2.5 py-1 text-[10px] font-medium text-muted-foreground transition-colors hover:bg-primary hover:text-primary-foreground"
title={preset.description}
>
{preset.label}
</button>
))}
</div>
const effectiveTableColumns = tableColumns || context?.tableColumns;
const presetSection = presets && presets.length > 0 && (
<div className="border-b border-border/40 pb-2.5">
<h4 className="mb-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
</h4>
<div className="flex flex-wrap gap-1">
{presets.map((preset, idx) => (
<button
key={idx}
onClick={() => {
Object.entries(preset.values).forEach(([key, value]) => {
onChange(key, value);
});
if (onConfigChange) {
onConfigChange({ ...config, ...preset.values } as Record<string, any>);
}
}}
className="rounded border border-border bg-background px-2 py-0.5 text-[10px] font-medium text-muted-foreground transition-colors hover:border-primary hover:text-primary"
title={preset.description}
>
{preset.label}
</button>
))}
</div>
</div>
);
if (mode === "tabs") {
const groupMap = new Map<string, ConfigSectionDefinition<T>[]>();
const ungrouped: ConfigSectionDefinition<T>[] = [];
for (const section of sections) {
if (section.group) {
const existing = groupMap.get(section.group) || [];
existing.push(section);
groupMap.set(section.group, existing);
} else {
ungrouped.push(section);
}
}
const tabGroups = Array.from(groupMap.entries());
if (tabGroups.length === 0) {
return (
<div className="space-y-1">
{presetSection}
{renderSections(sections, config, onChange, effectiveTableColumns)}
{children}
</div>
)}
);
}
{/* 섹션 렌더링 */}
{sections.map((section) => {
if (section.condition && !section.condition(config)) {
return null;
}
const defaultTab = tabGroups[0]?.[0] || "general";
const visibleFields = section.fields.filter(
(field) => !field.condition || field.condition(config),
);
return (
<div className="space-y-1">
{presetSection}
if (visibleFields.length === 0) {
return null;
}
{ungrouped.length > 0 && renderSections(ungrouped, config, onChange, effectiveTableColumns)}
return (
<ConfigSection key={section.id} section={section}>
{visibleFields.map((field) => (
<ConfigField
key={field.key}
field={field}
value={(config as any)[field.key]}
onChange={onChange}
tableColumns={tableColumns}
/>
<Tabs defaultValue={defaultTab} className="w-full">
<TabsList className="h-7 w-full">
{tabGroups.map(([groupName]) => (
<TabsTrigger key={groupName} value={groupName} className="h-6 text-xs">
{groupName}
</TabsTrigger>
))}
</ConfigSection>
);
})}
</TabsList>
{tabGroups.map(([groupName, groupSections]) => (
<TabsContent key={groupName} value={groupName} className="mt-1">
{renderSections(groupSections, config, onChange, effectiveTableColumns)}
</TabsContent>
))}
</Tabs>
{/* 커스텀 children */}
{children}
</div>
);
}
return (
<div className="space-y-1">
{presetSection}
{renderSections(sections, config, onChange, effectiveTableColumns)}
{children}
</div>
);

View File

@ -10,7 +10,9 @@ export type ConfigFieldType =
| "slider"
| "multi-select"
| "key-value"
| "column-picker";
| "column-picker"
| "checkbox"
| "combobox";
export interface ConfigOption {
label: string;
@ -40,11 +42,13 @@ export interface ConfigSectionDefinition<T = any> {
defaultOpen?: boolean;
fields: ConfigFieldDefinition<T>[];
condition?: (config: T) => boolean;
group?: string;
}
export interface ConfigPanelBuilderProps<T = any> {
config: T;
onChange: (key: string, value: any) => void;
onConfigChange?: (config: Record<string, any>) => void;
sections: ConfigSectionDefinition<T>[];
presets?: Array<{
label: string;
@ -53,4 +57,30 @@ export interface ConfigPanelBuilderProps<T = any> {
}>;
tableColumns?: ConfigOption[];
children?: React.ReactNode;
mode?: "flat" | "tabs";
context?: ConfigPanelContext;
}
/**
* /
*/
export interface ConfigPanelContext {
tables?: any[];
tableColumns?: any[];
screenTableName?: string;
menuObjid?: number;
allComponents?: any[];
currentComponent?: any;
allTables?: any[];
screenComponents?: any[];
currentScreenCompanyCode?: string;
}
/**
* ConfigPanel이 Props
*/
export interface StandardConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
context?: ConfigPanelContext;
}

View File

@ -14,39 +14,45 @@ export function ConfigSection({ section, children }: ConfigSectionProps) {
if (section.collapsible) {
return (
<div className="border-b pb-3">
<div className="border-b border-border/40 py-2.5">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex w-full items-center gap-1.5 py-1 text-left"
className="flex w-full items-center justify-between py-0.5 text-left"
>
{isOpen ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
)}
<span className="text-sm font-medium">{section.title}</span>
{section.description && (
<span className="text-muted-foreground ml-auto text-[10px]">
{section.description}
</span>
)}
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{section.title}
</span>
<div className="flex items-center gap-1.5">
{section.description && (
<span className="text-muted-foreground/60 text-[9px]">
{section.description}
</span>
)}
{isOpen ? (
<ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground/50" />
) : (
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/50" />
)}
</div>
</button>
{isOpen && <div className="mt-2 space-y-3">{children}</div>}
{isOpen && <div className="mt-1.5 space-y-1">{children}</div>}
</div>
);
}
return (
<div className="border-b pb-3">
<div className="mb-2">
<h4 className="text-sm font-medium">{section.title}</h4>
<div className="border-b border-border/40 py-2.5">
<div className="mb-1.5 flex items-center justify-between">
<h4 className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{section.title}
</h4>
{section.description && (
<p className="text-muted-foreground text-[10px]">
<span className="text-muted-foreground/60 text-[9px]">
{section.description}
</p>
</span>
)}
</div>
<div className="space-y-3">{children}</div>
<div className="space-y-1">{children}</div>
</div>
);
}

View File

@ -1,11 +1,9 @@
"use client";
import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import type { WebType } from "@/types/screen";
import { SelectedItemsDetailInputWrapper } from "./SelectedItemsDetailInputComponent";
import { SelectedItemsDetailInputConfigPanel } from "./SelectedItemsDetailInputConfigPanel";
import { V2SelectedItemsDetailInputConfigPanel } from "@/components/v2/config-panels/V2SelectedItemsDetailInputConfigPanel";
import { SelectedItemsDetailInputConfig } from "./types";
/**
@ -33,7 +31,7 @@ export const SelectedItemsDetailInputDefinition = createComponentDefinition({
readonly: false,
} as SelectedItemsDetailInputConfig,
defaultSize: { width: 800, height: 400 },
configPanel: SelectedItemsDetailInputConfigPanel,
configPanel: V2SelectedItemsDetailInputConfigPanel,
icon: "Table",
tags: ["선택", "상세입력", "반복", "테이블", "데이터전달"],
version: "1.0.0",

View File

@ -607,18 +607,20 @@ export const ColumnConfigModal: React.FC<ColumnConfigModalProps> = ({
</div>
<div>
<Label className="text-xs"> (px)</Label>
<Label className="text-xs"> (%)</Label>
<Input
type="number"
value={editingColumn.width || ""}
value={editingColumn.width && editingColumn.width <= 100 ? editingColumn.width : ""}
onChange={(e) =>
setEditingColumn({
...editingColumn,
width: e.target.value ? parseInt(e.target.value) : undefined,
width: e.target.value ? Math.min(100, Math.max(5, parseInt(e.target.value) || 20)) : undefined,
})
}
placeholder="자동"
className="mt-1 h-9"
min={5}
max={100}
/>
</div>
</div>

View File

@ -1751,7 +1751,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
<TableHeader>
<TableRow>
{displayColumns.map((col, idx) => (
<TableHead key={idx} style={{ width: col.width ? `${col.width}px` : "auto" }}>
<TableHead key={idx} style={{ width: col.width && col.width <= 100 ? `${col.width}%` : "auto" }}>
{col.label || col.name}
</TableHead>
))}
@ -1952,7 +1952,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
</TableHead>
)}
{displayColumns.map((col, idx) => (
<TableHead key={idx} style={{ width: col.width ? `${col.width}px` : "auto" }}>
<TableHead key={idx} style={{ width: col.width && col.width <= 100 ? `${col.width}%` : "auto" }}>
{col.label || col.name}
</TableHead>
))}

View File

@ -3,7 +3,7 @@
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { AggregationWidgetWrapper } from "./AggregationWidgetComponent";
import { AggregationWidgetConfigPanel } from "./AggregationWidgetConfigPanel";
import { V2AggregationWidgetConfigPanel } from "@/components/v2/config-panels/V2AggregationWidgetConfigPanel";
import type { AggregationWidgetConfig } from "./types";
/**
@ -34,7 +34,7 @@ export const V2AggregationWidgetDefinition = createComponentDefinition({
refreshOnFormChange: true, // 폼 변경 시 자동 새로고침
} as Partial<AggregationWidgetConfig>,
defaultSize: { width: 400, height: 60 },
configPanel: AggregationWidgetConfigPanel,
configPanel: V2AggregationWidgetConfigPanel,
icon: "Calculator",
tags: ["집계", "합계", "평균", "개수", "통계", "데이터", "필터"],
version: "1.1.0",

View File

@ -5,7 +5,7 @@ import { createComponentDefinition } from "../../utils/createComponentDefinition
import { ComponentCategory } from "@/types/component";
import type { WebType } from "@/types/screen";
import { ApprovalStepWrapper } from "./ApprovalStepComponent";
import { ApprovalStepConfigPanel } from "./ApprovalStepConfigPanel";
import { V2ApprovalStepConfigPanel as ApprovalStepConfigPanel } from "@/components/v2/config-panels/V2ApprovalStepConfigPanel";
import { ApprovalStepConfig } from "./types";
/**

View File

@ -1,11 +1,9 @@
"use client";
import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import type { WebType } from "@/types/screen";
import { ButtonPrimaryWrapper } from "./ButtonPrimaryComponent";
import { ButtonPrimaryConfig } from "./types";
import { V2ButtonConfigPanel } from "@/components/v2/config-panels/V2ButtonConfigPanel";
/**
* ButtonPrimary
@ -30,7 +28,7 @@ export const V2ButtonPrimaryDefinition = createComponentDefinition({
},
},
defaultSize: { width: 120, height: 40 },
configPanel: undefined, // 상세 설정 패널(ButtonConfigPanel)이 대신 사용됨
configPanel: V2ButtonConfigPanel,
icon: "MousePointer",
tags: ["버튼", "액션", "클릭"],
version: "1.0.0",

View File

@ -5,7 +5,7 @@ import { createComponentDefinition } from "../../utils/createComponentDefinition
import { ComponentCategory } from "@/types/component";
import type { WebType } from "@/types/screen";
import { CardDisplayComponent } from "./CardDisplayComponent";
import { CardDisplayConfigPanel } from "./CardDisplayConfigPanel";
import { V2CardDisplayConfigPanel } from "@/components/v2/config-panels/V2CardDisplayConfigPanel";
import { CardDisplayConfig } from "./types";
/**
@ -38,7 +38,7 @@ export const V2CardDisplayDefinition = createComponentDefinition({
staticData: [],
},
defaultSize: { width: 800, height: 400 },
configPanel: CardDisplayConfigPanel,
configPanel: V2CardDisplayConfigPanel,
icon: "Grid3x3",
tags: ["card", "display", "table", "grid"],
version: "1.0.0",

View File

@ -3,7 +3,7 @@
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { V2CategoryManagerComponent } from "./V2CategoryManagerComponent";
import { V2CategoryManagerConfigPanel } from "./V2CategoryManagerConfigPanel";
import { V2CategoryManagerConfigPanel } from "@/components/v2/config-panels/V2CategoryManagerConfigPanel";
import { defaultV2CategoryManagerConfig } from "./types";
/**
@ -32,5 +32,5 @@ export const V2CategoryManagerDefinition = createComponentDefinition({
// 타입 내보내기
export type { V2CategoryManagerConfig, CategoryValue, ViewMode } from "./types";
export { V2CategoryManagerComponent } from "./V2CategoryManagerComponent";
export { V2CategoryManagerConfigPanel } from "./V2CategoryManagerConfigPanel";
export { V2CategoryManagerConfigPanel } from "@/components/v2/config-panels/V2CategoryManagerConfigPanel";

View File

@ -5,7 +5,7 @@ import { createComponentDefinition } from "../../utils/createComponentDefinition
import { ComponentCategory } from "@/types/component";
import type { WebType } from "@/types/screen";
import { DividerLineWrapper } from "./DividerLineComponent";
import { DividerLineConfigPanel } from "./DividerLineConfigPanel";
import { V2DividerLineConfigPanel } from "@/components/v2/config-panels/V2DividerLineConfigPanel";
import { DividerLineConfig } from "./types";
/**
@ -25,7 +25,7 @@ export const V2DividerLineDefinition = createComponentDefinition({
maxLength: 255,
},
defaultSize: { width: 400, height: 2 },
configPanel: DividerLineConfigPanel,
configPanel: V2DividerLineConfigPanel,
icon: "Layout",
tags: [],
version: "1.0.0",

View File

@ -5,7 +5,7 @@ import { createComponentDefinition } from "../../utils/createComponentDefinition
import { ComponentCategory } from "@/types/component";
import type { WebType } from "@/types/screen";
import { FileUploadComponent } from "./FileUploadComponent";
import { FileUploadConfigPanel } from "./FileUploadConfigPanel";
import { V2FileUploadConfigPanel } from "@/components/v2/config-panels/V2FileUploadConfigPanel";
import { FileUploadConfig } from "./types";
/**
@ -27,7 +27,7 @@ export const V2FileUploadDefinition = createComponentDefinition({
maxSize: 10 * 1024 * 1024, // 10MB
},
defaultSize: { width: 350, height: 240 },
configPanel: FileUploadConfigPanel,
configPanel: V2FileUploadConfigPanel,
icon: "Upload",
tags: ["file", "upload", "attachment", "v2"],
version: "2.0.0",

View File

@ -6,7 +6,7 @@
import { ComponentCategory } from "@/types/component";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { V2InputConfigPanel } from "@/components/v2/config-panels/V2InputConfigPanel";
import { V2FieldConfigPanel } from "@/components/v2/config-panels/V2FieldConfigPanel";
import { V2Input } from "@/components/v2/V2Input";
export const V2InputDefinition = createComponentDefinition({
@ -72,7 +72,7 @@ export const V2InputDefinition = createComponentDefinition({
tags: ["input", "text", "number", "v2"],
// 설정 패널
configPanel: V2InputConfigPanel,
configPanel: V2FieldConfigPanel,
});
export default V2InputDefinition;

View File

@ -1,217 +1,203 @@
"use client";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Search, Plus, Trash2, Edit, ListOrdered, Package, Star } from "lucide-react";
import React, { useState, useEffect, useCallback } from "react";
import { Search, Plus, Trash2, Edit, ListOrdered, Package, Star, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import { useToast } from "@/hooks/use-toast";
import { ItemRoutingConfig, ItemRoutingComponentProps } from "./types";
import { defaultConfig } from "./config";
import { ItemRoutingComponentProps, ColumnDef } from "./types";
import { useItemRouting } from "./hooks/useItemRouting";
const DEFAULT_ITEM_COLS: ColumnDef[] = [
{ name: "item_name", label: "품명" },
{ name: "item_code", label: "품번", width: 100 },
];
export function ItemRoutingComponent({
config: configProp,
isPreview,
screenId,
}: ItemRoutingComponentProps) {
const { toast } = useToast();
const resolvedConfig = React.useMemo(() => {
if (configProp?.itemListMode === "registered" && !configProp?.screenCode && screenId) {
return { ...configProp, screenCode: `screen_${screenId}` };
}
return configProp;
}, [configProp, screenId]);
const {
config,
items,
versions,
details,
loading,
selectedItemCode,
selectedItemName,
selectedVersionId,
fetchItems,
selectItem,
selectVersion,
refreshVersions,
refreshDetails,
deleteDetail,
deleteVersion,
setDefaultVersion,
unsetDefaultVersion,
} = useItemRouting(configProp || {});
config, items, allItems, versions, details, loading,
selectedItemCode, selectedItemName, selectedVersionId, isRegisteredMode,
fetchItems, fetchRegisteredItems, fetchAllItems,
registerItemsBatch, unregisterItem,
selectItem, selectVersion, refreshVersions, refreshDetails,
deleteDetail, deleteVersion, setDefaultVersion, unsetDefaultVersion,
} = useItemRouting(resolvedConfig || {});
const [searchText, setSearchText] = useState("");
const [deleteTarget, setDeleteTarget] = useState<{
type: "version" | "detail";
id: string;
name: string;
type: "version" | "detail"; id: string; name: string;
} | null>(null);
// 초기 로딩 (마운트 시 1회만)
// 품목 추가 다이얼로그
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [addSearchText, setAddSearchText] = useState("");
const [selectedAddItems, setSelectedAddItems] = useState<Set<string>>(new Set());
const [addLoading, setAddLoading] = useState(false);
const itemDisplayCols = config.itemDisplayColumns?.length
? config.itemDisplayColumns : DEFAULT_ITEM_COLS;
const modalDisplayCols = config.modalDisplayColumns?.length
? config.modalDisplayColumns : DEFAULT_ITEM_COLS;
// 초기 로딩
const mountedRef = React.useRef(false);
useEffect(() => {
if (!mountedRef.current) {
mountedRef.current = true;
fetchItems();
if (isRegisteredMode) fetchRegisteredItems();
else fetchItems();
}
}, [fetchItems]);
}, [fetchItems, fetchRegisteredItems, isRegisteredMode]);
// 모달 저장 성공 감지 -> 데이터 새로고침
// 모달 저장 성공 감지
const refreshVersionsRef = React.useRef(refreshVersions);
const refreshDetailsRef = React.useRef(refreshDetails);
refreshVersionsRef.current = refreshVersions;
refreshDetailsRef.current = refreshDetails;
useEffect(() => {
const handleSaveSuccess = () => {
refreshVersionsRef.current();
refreshDetailsRef.current();
};
window.addEventListener("saveSuccessInModal", handleSaveSuccess);
return () => {
window.removeEventListener("saveSuccessInModal", handleSaveSuccess);
};
const h = () => { refreshVersionsRef.current(); refreshDetailsRef.current(); };
window.addEventListener("saveSuccessInModal", h);
return () => window.removeEventListener("saveSuccessInModal", h);
}, []);
// 품목 검색
// 검색
const handleSearch = useCallback(() => {
fetchItems(searchText || undefined);
}, [fetchItems, searchText]);
if (isRegisteredMode) fetchRegisteredItems(searchText || undefined);
else fetchItems(searchText || undefined);
}, [fetchItems, fetchRegisteredItems, isRegisteredMode, searchText]);
const handleSearchKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") handleSearch();
// ──── 품목 추가 모달 ────
const handleOpenAddDialog = useCallback(() => {
setAddSearchText(""); setSelectedAddItems(new Set()); setAddDialogOpen(true);
fetchAllItems();
}, [fetchAllItems]);
const handleToggleAddItem = useCallback((itemId: string) => {
setSelectedAddItems((prev) => {
const next = new Set(prev);
next.has(itemId) ? next.delete(itemId) : next.add(itemId);
return next;
});
}, []);
const handleConfirmAdd = useCallback(async () => {
if (selectedAddItems.size === 0) return;
setAddLoading(true);
const itemList = allItems
.filter((item) => selectedAddItems.has(item.id))
.map((item) => ({
itemId: item.id,
itemCode: item.item_code || item[config.dataSource.itemCodeColumn] || "",
}));
const success = await registerItemsBatch(itemList);
setAddLoading(false);
if (success) {
toast({ title: `${itemList.length}개 품목이 등록되었습니다` });
setAddDialogOpen(false);
} else {
toast({ title: "품목 등록 실패", variant: "destructive" });
}
}, [selectedAddItems, allItems, config.dataSource.itemCodeColumn, registerItemsBatch, toast]);
const handleUnregisterItem = useCallback(
async (registeredId: string, itemName: string) => {
const success = await unregisterItem(registeredId);
if (success) toast({ title: `${itemName} 등록 해제됨` });
else toast({ title: "등록 해제 실패", variant: "destructive" });
},
[handleSearch]
[unregisterItem, toast]
);
// 버전 추가 모달
// ──── 기존 핸들러 ────
const handleAddVersion = useCallback(() => {
if (!selectedItemCode) {
toast({ title: "품목을 먼저 선택해주세요", variant: "destructive" });
return;
}
const screenId = config.modals.versionAddScreenId;
if (!screenId) return;
window.dispatchEvent(
new CustomEvent("openScreenModal", {
detail: {
screenId,
urlParams: { mode: "add", tableName: config.dataSource.routingVersionTable },
splitPanelParentData: {
[config.dataSource.routingVersionFkColumn]: selectedItemCode,
},
},
})
);
if (!selectedItemCode) { toast({ title: "품목을 먼저 선택해주세요", variant: "destructive" }); return; }
const sid = config.modals.versionAddScreenId;
if (!sid) return;
window.dispatchEvent(new CustomEvent("openScreenModal", {
detail: { screenId: sid, urlParams: { mode: "add", tableName: config.dataSource.routingVersionTable },
splitPanelParentData: { [config.dataSource.routingVersionFkColumn]: selectedItemCode } },
}));
}, [selectedItemCode, config, toast]);
// 공정 추가 모달
const handleAddProcess = useCallback(() => {
if (!selectedVersionId) {
toast({ title: "라우팅 버전을 먼저 선택해주세요", variant: "destructive" });
return;
}
const screenId = config.modals.processAddScreenId;
if (!screenId) return;
window.dispatchEvent(
new CustomEvent("openScreenModal", {
detail: {
screenId,
urlParams: { mode: "add", tableName: config.dataSource.routingDetailTable },
splitPanelParentData: {
[config.dataSource.routingDetailFkColumn]: selectedVersionId,
},
},
})
);
if (!selectedVersionId) { toast({ title: "라우팅 버전을 먼저 선택해주세요", variant: "destructive" }); return; }
const sid = config.modals.processAddScreenId;
if (!sid) return;
window.dispatchEvent(new CustomEvent("openScreenModal", {
detail: { screenId: sid, urlParams: { mode: "add", tableName: config.dataSource.routingDetailTable },
splitPanelParentData: { [config.dataSource.routingDetailFkColumn]: selectedVersionId } },
}));
}, [selectedVersionId, config, toast]);
// 공정 수정 모달
const handleEditProcess = useCallback(
(detail: Record<string, any>) => {
const screenId = config.modals.processEditScreenId;
if (!screenId) return;
window.dispatchEvent(
new CustomEvent("openScreenModal", {
detail: {
screenId,
urlParams: { mode: "edit", tableName: config.dataSource.routingDetailTable },
editData: detail,
},
})
);
},
[config]
const sid = config.modals.processEditScreenId;
if (!sid) return;
window.dispatchEvent(new CustomEvent("openScreenModal", {
detail: { screenId: sid, urlParams: { mode: "edit", tableName: config.dataSource.routingDetailTable }, editData: detail },
}));
}, [config]
);
// 기본 버전 토글
const handleToggleDefault = useCallback(
async (versionId: string, currentIsDefault: boolean) => {
let success: boolean;
if (currentIsDefault) {
success = await unsetDefaultVersion(versionId);
if (success) toast({ title: "기본 버전이 해제되었습니다" });
} else {
success = await setDefaultVersion(versionId);
if (success) toast({ title: "기본 버전으로 설정되었습니다" });
}
if (!success) {
toast({ title: "기본 버전 변경 실패", variant: "destructive" });
}
const success = currentIsDefault ? await unsetDefaultVersion(versionId) : await setDefaultVersion(versionId);
if (success) toast({ title: currentIsDefault ? "기본 버전이 해제되었습니다" : "기본 버전으로 설정되었습니다" });
else toast({ title: "기본 버전 변경 실패", variant: "destructive" });
},
[setDefaultVersion, unsetDefaultVersion, toast]
);
// 삭제 확인
const handleConfirmDelete = useCallback(async () => {
if (!deleteTarget) return;
let success = false;
if (deleteTarget.type === "version") {
success = await deleteVersion(deleteTarget.id);
} else {
success = await deleteDetail(deleteTarget.id);
}
if (success) {
toast({ title: `${deleteTarget.name} 삭제 완료` });
} else {
toast({ title: "삭제 실패", variant: "destructive" });
}
const success = deleteTarget.type === "version"
? await deleteVersion(deleteTarget.id) : await deleteDetail(deleteTarget.id);
toast({ title: success ? `${deleteTarget.name} 삭제 완료` : "삭제 실패", variant: success ? undefined : "destructive" });
setDeleteTarget(null);
}, [deleteTarget, deleteVersion, deleteDetail, toast]);
const splitRatio = config.splitRatio || 40;
const registeredItemIds = React.useMemo(() => new Set(items.map((i) => i.id)), [items]);
// ──── 셀 값 추출 헬퍼 ────
const getCellValue = (item: Record<string, any>, colName: string) => {
return item[colName] ?? item[`item_${colName}`] ?? "-";
};
if (isPreview) {
return (
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/20 bg-muted/10 p-4">
<div className="text-center">
<ListOrdered className="mx-auto mb-2 h-8 w-8 text-muted-foreground/50" />
<p className="text-sm font-medium text-muted-foreground">
</p>
<p className="text-sm font-medium text-muted-foreground"> </p>
<p className="mt-1 text-xs text-muted-foreground/70">
- -
{isRegisteredMode ? "등록 품목 모드" : "전체 품목 모드"}
</p>
</div>
</div>
@ -221,94 +207,111 @@ export function ItemRoutingComponent({
return (
<div className="flex h-full flex-col overflow-hidden rounded-lg border bg-background">
<div className="flex flex-1 overflow-hidden">
{/* 좌측 패널: 품목 목록 */}
<div
style={{ width: `${splitRatio}%` }}
className="flex shrink-0 flex-col overflow-hidden border-r"
>
<div className="border-b px-3 py-2">
{/* ════ 좌측 패널: 품목 목록 (테이블) ════ */}
<div style={{ width: `${splitRatio}%` }} className="flex shrink-0 flex-col overflow-hidden border-r">
<div className="flex items-center justify-between border-b px-3 py-2">
<h3 className="text-sm font-semibold">
{config.leftPanelTitle || "품목 목록"}
{isRegisteredMode && (
<span className="ml-1.5 text-[10px] font-normal text-muted-foreground">( )</span>
)}
</h3>
{isRegisteredMode && !config.readonly && (
<Button variant="outline" size="sm" className="h-6 gap-1 text-[10px]" onClick={handleOpenAddDialog}>
<Plus className="h-3 w-3" />
</Button>
)}
</div>
{/* 검색 */}
<div className="flex gap-1.5 border-b px-3 py-2">
<Input
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={handleSearchKeyDown}
placeholder="품목명/품번 검색"
className="h-8 text-xs"
/>
<Input value={searchText} onChange={(e) => setSearchText(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleSearch(); }}
placeholder="품목명/품번 검색" className="h-8 text-xs" />
<Button variant="outline" size="icon" className="h-8 w-8 shrink-0" onClick={handleSearch}>
<Search className="h-3.5 w-3.5" />
</Button>
</div>
{/* 품목 리스트 */}
<div className="flex-1 overflow-y-auto">
{/* 품목 테이블 */}
<div className="flex-1 overflow-auto">
{items.length === 0 ? (
<div className="flex h-full items-center justify-center p-4">
<div className="flex h-full flex-col items-center justify-center gap-2 p-4">
<p className="text-xs text-muted-foreground">
{loading ? "로딩 중..." : "품목이 없습니다"}
{loading ? "로딩 중..." : isRegisteredMode ? "등록된 품목이 없습니다" : "품목이 없습니다"}
</p>
{isRegisteredMode && !loading && !config.readonly && (
<Button variant="outline" size="sm" className="h-7 gap-1 text-xs" onClick={handleOpenAddDialog}>
<Plus className="h-3 w-3" />
</Button>
)}
</div>
) : (
<div className="divide-y">
{items.map((item) => {
const itemCode =
item[config.dataSource.itemCodeColumn] || item.item_code || item.item_number;
const itemName =
item[config.dataSource.itemNameColumn] || item.item_name;
const isSelected = selectedItemCode === itemCode;
<Table>
<TableHeader>
<TableRow>
{itemDisplayCols.map((col) => (
<TableHead key={col.name}
style={{ width: col.width ? `${col.width}px` : undefined }}
className={cn("text-[11px] py-1.5", col.align === "center" && "text-center", col.align === "right" && "text-right")}>
{col.label}
</TableHead>
))}
{isRegisteredMode && !config.readonly && (
<TableHead className="w-[36px] py-1.5" />
)}
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => {
const itemCode = item[config.dataSource.itemCodeColumn] || item.item_code || item.item_number;
const itemName = item[config.dataSource.itemNameColumn] || item.item_name;
const isSelected = selectedItemCode === itemCode;
return (
<button
key={item.id}
className={cn(
"flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-muted/50",
isSelected && "bg-primary/10 font-medium"
)}
onClick={() => selectItem(itemCode, itemName)}
>
<Package className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{itemName}</p>
<p className="truncate text-muted-foreground">{itemCode}</p>
</div>
</button>
);
})}
</div>
return (
<TableRow key={item.registered_id || item.id}
className={cn("cursor-pointer group", isSelected && "bg-primary/10")}
onClick={() => selectItem(itemCode, itemName)}>
{itemDisplayCols.map((col) => (
<TableCell key={col.name}
className={cn("text-xs py-1.5", col.align === "center" && "text-center", col.align === "right" && "text-right")}>
{getCellValue(item, col.name)}
</TableCell>
))}
{isRegisteredMode && !config.readonly && item.registered_id && (
<TableCell className="py-1.5 text-center">
<Button variant="ghost" size="icon"
className="h-5 w-5 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => { e.stopPropagation(); handleUnregisterItem(item.registered_id, itemName); }}
title="등록 해제">
<X className="h-3 w-3 text-muted-foreground hover:text-destructive" />
</Button>
</TableCell>
)}
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
</div>
{/* 우측 패널: 버전 + 공정 */}
{/* ════ 우측 패널: 버전 + 공정 ════ */}
<div className="flex flex-1 flex-col overflow-hidden">
{selectedItemCode ? (
<>
{/* 헤더: 선택된 품목 + 버전 추가 */}
<div className="flex items-center justify-between border-b px-4 py-2">
<div>
<h3 className="text-sm font-semibold">{selectedItemName}</h3>
<p className="text-xs text-muted-foreground">{selectedItemCode}</p>
</div>
{!config.readonly && (
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={handleAddVersion}
>
<Plus className="h-3 w-3" />
{config.versionAddButtonText || "+ 라우팅 버전 추가"}
<Button variant="outline" size="sm" className="h-7 gap-1 text-xs" onClick={handleAddVersion}>
<Plus className="h-3 w-3" /> {config.versionAddButtonText || "+ 라우팅 버전 추가"}
</Button>
)}
</div>
{/* 버전 선택 버튼들 */}
{versions.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5 border-b px-4 py-2">
<span className="mr-1 text-xs text-muted-foreground">:</span>
@ -317,50 +320,24 @@ export function ItemRoutingComponent({
const isDefault = ver.is_default === true;
return (
<div key={ver.id} className="flex items-center gap-0.5">
<Badge
variant={isActive ? "default" : "outline"}
className={cn(
"cursor-pointer px-2.5 py-0.5 text-xs transition-colors",
<Badge variant={isActive ? "default" : "outline"}
className={cn("cursor-pointer px-2.5 py-0.5 text-xs transition-colors",
isActive && "bg-primary text-primary-foreground",
isDefault && !isActive && "border-amber-400 bg-amber-50 text-amber-700"
)}
onClick={() => selectVersion(ver.id)}
>
isDefault && !isActive && "border-amber-400 bg-amber-50 text-amber-700")}
onClick={() => selectVersion(ver.id)}>
{isDefault && <Star className="mr-1 h-3 w-3 fill-current" />}
{ver[config.dataSource.routingVersionNameColumn] || ver.version_name || ver.id}
</Badge>
{!config.readonly && (
<>
<Button
variant="ghost"
size="icon"
className={cn(
"h-5 w-5",
isDefault
? "text-amber-500 hover:text-amber-600"
: "text-muted-foreground hover:text-amber-500"
)}
onClick={(e) => {
e.stopPropagation();
handleToggleDefault(ver.id, isDefault);
}}
title={isDefault ? "기본 버전 해제" : "기본 버전으로 설정"}
>
<Button variant="ghost" size="icon"
className={cn("h-5 w-5", isDefault ? "text-amber-500 hover:text-amber-600" : "text-muted-foreground hover:text-amber-500")}
onClick={(e) => { e.stopPropagation(); handleToggleDefault(ver.id, isDefault); }}
title={isDefault ? "기본 버전 해제" : "기본 버전으로 설정"}>
<Star className={cn("h-3 w-3", isDefault && "fill-current")} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
setDeleteTarget({
type: "version",
id: ver.id,
name: ver.version_name || ver.id,
});
}}
>
<Button variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:text-destructive"
onClick={(e) => { e.stopPropagation(); setDeleteTarget({ type: "version", id: ver.id, name: ver.version_name || ver.id }); }}>
<Trash2 className="h-3 w-3" />
</Button>
</>
@ -371,112 +348,65 @@ export function ItemRoutingComponent({
</div>
) : (
<div className="border-b px-4 py-3 text-center">
<p className="text-xs text-muted-foreground">
. .
</p>
<p className="text-xs text-muted-foreground"> . .</p>
</div>
)}
{/* 공정 테이블 */}
{selectedVersionId ? (
<div className="flex flex-1 flex-col overflow-hidden">
{/* 공정 테이블 헤더 */}
<div className="flex items-center justify-between px-4 py-2">
<h4 className="text-xs font-medium text-muted-foreground">
{config.rightPanelTitle || "공정 순서"} ({details.length})
</h4>
{!config.readonly && (
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={handleAddProcess}
>
<Plus className="h-3 w-3" />
{config.processAddButtonText || "+ 공정 추가"}
<Button variant="outline" size="sm" className="h-7 gap-1 text-xs" onClick={handleAddProcess}>
<Plus className="h-3 w-3" /> {config.processAddButtonText || "+ 공정 추가"}
</Button>
)}
</div>
{/* 테이블 */}
<div className="flex-1 overflow-auto px-4 pb-4">
{details.length === 0 ? (
<div className="flex h-32 items-center justify-center">
<p className="text-xs text-muted-foreground">
{loading ? "로딩 중..." : "등록된 공정이 없습니다"}
</p>
<p className="text-xs text-muted-foreground">{loading ? "로딩 중..." : "등록된 공정이 없습니다"}</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
{config.processColumns.map((col) => (
<TableHead
key={col.name}
<TableHead key={col.name}
style={{ width: col.width ? `${col.width}px` : undefined }}
className={cn(
"text-xs",
col.align === "center" && "text-center",
col.align === "right" && "text-right"
)}
>
className={cn("text-xs", col.align === "center" && "text-center", col.align === "right" && "text-right")}>
{col.label}
</TableHead>
))}
{!config.readonly && (
<TableHead className="w-[80px] text-center text-xs">
</TableHead>
)}
{!config.readonly && <TableHead className="w-[80px] text-center text-xs"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{details.map((detail) => (
<TableRow key={detail.id}>
{config.processColumns.map((col) => {
let cellValue = detail[col.name];
if (cellValue == null) {
const aliasKey = Object.keys(detail).find(
(k) => k.endsWith(`_${col.name}`)
);
if (aliasKey) cellValue = detail[aliasKey];
let v = detail[col.name];
if (v == null) {
const ak = Object.keys(detail).find((k) => k.endsWith(`_${col.name}`));
if (ak) v = detail[ak];
}
return (
<TableCell
key={col.name}
className={cn(
"text-xs",
col.align === "center" && "text-center",
col.align === "right" && "text-right"
)}
>
{cellValue ?? "-"}
<TableCell key={col.name}
className={cn("text-xs", col.align === "center" && "text-center", col.align === "right" && "text-right")}>
{v ?? "-"}
</TableCell>
);
})}
{!config.readonly && (
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleEditProcess(detail)}
>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleEditProcess(detail)}>
<Edit className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive hover:text-destructive"
onClick={() =>
setDeleteTarget({
type: "detail",
id: detail.id,
name: `공정 ${detail.seq_no || detail.id}`,
})
}
>
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive hover:text-destructive"
onClick={() => setDeleteTarget({ type: "detail", id: detail.id, name: `공정 ${detail.seq_no || detail.id}` })}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
@ -492,9 +422,7 @@ export function ItemRoutingComponent({
) : (
versions.length > 0 && (
<div className="flex flex-1 items-center justify-center">
<p className="text-xs text-muted-foreground">
</p>
<p className="text-xs text-muted-foreground"> </p>
</div>
)
)}
@ -502,43 +430,121 @@ export function ItemRoutingComponent({
) : (
<div className="flex flex-1 flex-col items-center justify-center text-center">
<ListOrdered className="mb-3 h-12 w-12 text-muted-foreground/30" />
<p className="text-sm font-medium text-muted-foreground">
</p>
<p className="mt-1 text-xs text-muted-foreground/70">
</p>
<p className="text-sm font-medium text-muted-foreground"> </p>
<p className="mt-1 text-xs text-muted-foreground/70"> </p>
</div>
)}
</div>
</div>
{/* 삭제 확인 다이얼로그 */}
{/* ════ 삭제 확인 ════ */}
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="text-base"> </AlertDialogTitle>
<AlertDialogDescription className="text-sm">
{deleteTarget?.name}() ?
{deleteTarget?.type === "version" && (
<>
<br />
.
</>
)}
{deleteTarget?.type === "version" && (<><br /> .</>)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
<AlertDialogAction onClick={handleConfirmDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* ════ 품목 추가 다이얼로그 (테이블 형태 + 검색) ════ */}
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{(config.itemFilterConditions?.length ?? 0) > 0 && (
<span className="ml-1 text-[10px] text-muted-foreground">
( {config.itemFilterConditions!.length} )
</span>
)}
</DialogDescription>
</DialogHeader>
<div className="flex gap-1.5">
<Input value={addSearchText} onChange={(e) => setAddSearchText(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") fetchAllItems(addSearchText || undefined); }}
placeholder="품목명/품번 검색" className="h-8 text-xs sm:h-10 sm:text-sm" />
<Button variant="outline" size="icon" className="h-8 w-8 shrink-0 sm:h-10 sm:w-10"
onClick={() => fetchAllItems(addSearchText || undefined)}>
<Search className="h-3.5 w-3.5" />
</Button>
</div>
<div className="max-h-[340px] overflow-auto rounded-md border">
{allItems.length === 0 ? (
<div className="flex items-center justify-center py-8">
<p className="text-xs text-muted-foreground"> </p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40px] text-center text-[11px] py-1.5" />
{modalDisplayCols.map((col) => (
<TableHead key={col.name}
style={{ width: col.width ? `${col.width}px` : undefined }}
className={cn("text-[11px] py-1.5", col.align === "center" && "text-center", col.align === "right" && "text-right")}>
{col.label}
</TableHead>
))}
<TableHead className="w-[60px] text-center text-[11px] py-1.5"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{allItems.map((item) => {
const isAlreadyRegistered = registeredItemIds.has(item.id);
const isChecked = selectedAddItems.has(item.id);
return (
<TableRow key={item.id}
className={cn("cursor-pointer", isAlreadyRegistered && "opacity-50", isChecked && "bg-primary/5")}
onClick={() => { if (!isAlreadyRegistered) handleToggleAddItem(item.id); }}>
<TableCell className="text-center py-1.5">
<Checkbox checked={isChecked || isAlreadyRegistered} disabled={isAlreadyRegistered} className="h-4 w-4" />
</TableCell>
{modalDisplayCols.map((col) => (
<TableCell key={col.name}
className={cn("text-xs py-1.5", col.align === "center" && "text-center", col.align === "right" && "text-right")}>
{getCellValue(item, col.name)}
</TableCell>
))}
<TableCell className="text-center py-1.5">
{isAlreadyRegistered && (
<Badge variant="secondary" className="h-5 text-[10px]"></Badge>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
{selectedAddItems.size > 0 && (
<p className="text-xs text-muted-foreground">{selectedAddItems.size} </p>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setAddDialogOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"></Button>
<Button onClick={handleConfirmAdd} disabled={selectedAddItems.size === 0 || addLoading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
{addLoading ? "등록 중..." : `${selectedAddItems.size}개 추가`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -9,7 +9,7 @@ export class ItemRoutingRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = V2ItemRoutingDefinition;
render(): React.ReactElement {
const { formData, isPreview, config, tableName } = this.props as Record<
const { formData, isPreview, config, tableName, screenId } = this.props as Record<
string,
unknown
>;
@ -20,6 +20,7 @@ export class ItemRoutingRenderer extends AutoRegisteringComponentRenderer {
formData={formData as Record<string, unknown>}
tableName={tableName as string}
isPreview={isPreview as boolean}
screenId={screenId as number | string}
/>
);
}

View File

@ -35,4 +35,15 @@ export const defaultConfig: ItemRoutingConfig = {
autoSelectFirstVersion: true,
versionAddButtonText: "+ 라우팅 버전 추가",
processAddButtonText: "+ 공정 추가",
itemListMode: "all",
screenCode: "",
itemDisplayColumns: [
{ name: "item_name", label: "품명" },
{ name: "item_code", label: "품번", width: 100 },
],
modalDisplayColumns: [
{ name: "item_name", label: "품명" },
{ name: "item_code", label: "품번", width: 100 },
],
itemFilterConditions: [],
};

View File

@ -1,12 +1,21 @@
"use client";
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { useState, useCallback, useMemo, useRef } from "react";
import { apiClient } from "@/lib/api/client";
import { ItemRoutingConfig, ItemData, RoutingVersionData, RoutingDetailData } from "../types";
import { ItemRoutingConfig, ItemData, RoutingVersionData, RoutingDetailData, ColumnDef } from "../types";
import { defaultConfig } from "../config";
const API_BASE = "/process-work-standard";
/** 표시 컬럼 목록에서 기본(item_name, item_code) 외 추가 컬럼만 추출 */
function getExtraColumnNames(columns?: ColumnDef[]): string {
if (!columns || columns.length === 0) return "";
return columns
.map((c) => c.name)
.filter((n) => n && n !== "item_name" && n !== "item_code")
.join(",");
}
export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
const configKey = useMemo(
() => JSON.stringify(configPartial),
@ -27,21 +36,81 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
configRef.current = config;
const [items, setItems] = useState<ItemData[]>([]);
const [allItems, setAllItems] = useState<ItemData[]>([]);
const [versions, setVersions] = useState<RoutingVersionData[]>([]);
const [details, setDetails] = useState<RoutingDetailData[]>([]);
const [loading, setLoading] = useState(false);
// 선택 상태
const [selectedItemCode, setSelectedItemCode] = useState<string | null>(null);
const [selectedItemName, setSelectedItemName] = useState<string | null>(null);
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(null);
// 품목 목록 조회
const isRegisteredMode = config.itemListMode === "registered";
/** API 기본 파라미터 생성 */
const buildBaseParams = useCallback((search?: string, columns?: ColumnDef[]) => {
const ds = configRef.current.dataSource;
const extra = getExtraColumnNames(columns);
const filters = configRef.current.itemFilterConditions;
const params: Record<string, string> = {
tableName: ds.itemTable,
nameColumn: ds.itemNameColumn,
codeColumn: ds.itemCodeColumn,
routingTable: ds.routingVersionTable,
routingFkColumn: ds.routingVersionFkColumn,
};
if (search) params.search = search;
if (extra) params.extraColumns = extra;
if (filters && filters.length > 0) {
params.filterConditions = JSON.stringify(filters);
}
return new URLSearchParams(params);
}, []);
// ────────────────────────────────────────
// 품목 목록 조회 (all 모드)
// ────────────────────────────────────────
const fetchItems = useCallback(
async (search?: string) => {
try {
setLoading(true);
const cols = configRef.current.itemDisplayColumns;
const params = buildBaseParams(search, cols);
const res = await apiClient.get(`${API_BASE}/items?${params}`);
if (res.data?.success) {
const data = res.data.data || [];
if (configRef.current.itemListMode !== "registered") {
setItems(data);
}
return data;
}
} catch (err) {
console.error("품목 조회 실패", err);
} finally {
setLoading(false);
}
return [];
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[configKey, buildBaseParams]
);
// ────────────────────────────────────────
// 등록 품목 조회 (registered 모드)
// ────────────────────────────────────────
const fetchRegisteredItems = useCallback(
async (search?: string) => {
const screenCode = configRef.current.screenCode;
if (!screenCode) {
console.warn("screenCode가 설정되지 않았습니다");
setItems([]);
return;
}
try {
setLoading(true);
const ds = configRef.current.dataSource;
const cols = configRef.current.itemDisplayColumns;
const extra = getExtraColumnNames(cols);
const params = new URLSearchParams({
tableName: ds.itemTable,
nameColumn: ds.itemNameColumn,
@ -49,13 +118,16 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
routingTable: ds.routingVersionTable,
routingFkColumn: ds.routingVersionFkColumn,
...(search ? { search } : {}),
...(extra ? { extraColumns: extra } : {}),
});
const res = await apiClient.get(`${API_BASE}/items?${params}`);
const res = await apiClient.get(
`${API_BASE}/registered-items/${encodeURIComponent(screenCode)}?${params}`
);
if (res.data?.success) {
setItems(res.data.data || []);
}
} catch (err) {
console.error("품목 조회 실패", err);
console.error("등록 품목 조회 실패", err);
} finally {
setLoading(false);
}
@ -64,7 +136,104 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
[configKey]
);
// 라우팅 버전 목록 조회
// ────────────────────────────────────────
// 전체 품목 조회 (등록 팝업용 - 필터+추가컬럼 적용)
// ────────────────────────────────────────
const fetchAllItems = useCallback(
async (search?: string) => {
try {
const cols = configRef.current.modalDisplayColumns;
const params = buildBaseParams(search, cols);
const res = await apiClient.get(`${API_BASE}/items?${params}`);
if (res.data?.success) {
setAllItems(res.data.data || []);
}
} catch (err) {
console.error("전체 품목 조회 실패", err);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[configKey, buildBaseParams]
);
// ────────────────────────────────────────
// 품목 등록/제거 (registered 모드)
// ────────────────────────────────────────
const registerItem = useCallback(
async (itemId: string, itemCode: string) => {
const screenCode = configRef.current.screenCode;
if (!screenCode) return false;
try {
const res = await apiClient.post(`${API_BASE}/registered-items`, {
screenCode,
itemId,
itemCode,
});
if (res.data?.success) {
await fetchRegisteredItems();
return true;
}
} catch (err) {
console.error("품목 등록 실패", err);
}
return false;
},
[fetchRegisteredItems]
);
const registerItemsBatch = useCallback(
async (itemList: { itemId: string; itemCode: string }[]) => {
const screenCode = configRef.current.screenCode;
if (!screenCode) return false;
try {
const res = await apiClient.post(`${API_BASE}/registered-items/batch`, {
screenCode,
items: itemList,
});
if (res.data?.success) {
await fetchRegisteredItems();
return true;
}
} catch (err) {
console.error("품목 일괄 등록 실패", err);
}
return false;
},
[fetchRegisteredItems]
);
const unregisterItem = useCallback(
async (registeredId: string) => {
try {
const res = await apiClient.delete(`${API_BASE}/registered-items/${registeredId}`);
if (res.data?.success) {
if (selectedItemCode) {
const removedItem = items.find((i) => i.registered_id === registeredId);
if (removedItem) {
const removedCode = removedItem.item_code || removedItem[configRef.current.dataSource.itemCodeColumn];
if (selectedItemCode === removedCode) {
setSelectedItemCode(null);
setSelectedItemName(null);
setSelectedVersionId(null);
setVersions([]);
setDetails([]);
}
}
}
await fetchRegisteredItems();
return true;
}
} catch (err) {
console.error("등록 품목 제거 실패", err);
}
return false;
},
[selectedItemCode, items, fetchRegisteredItems]
);
// ────────────────────────────────────────
// 라우팅 버전/공정 관련 (기존 동일)
// ────────────────────────────────────────
const fetchVersions = useCallback(
async (itemCode: string) => {
try {
@ -94,7 +263,6 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
[configKey]
);
// 공정 상세 목록 조회 (특정 버전의 공정들) - entity join 포함
const fetchDetails = useCallback(
async (versionId: string) => {
try {
@ -128,18 +296,14 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
[configKey]
);
// 품목 선택
const selectItem = useCallback(
async (itemCode: string, itemName: string) => {
setSelectedItemCode(itemCode);
setSelectedItemName(itemName);
setSelectedVersionId(null);
setDetails([]);
const versionList = await fetchVersions(itemCode);
if (versionList.length > 0) {
// 기본 버전 우선, 없으면 첫번째 버전 선택
const defaultVersion = versionList.find((v: RoutingVersionData) => v.is_default);
const targetVersion = defaultVersion || (configRef.current.autoSelectFirstVersion ? versionList[0] : null);
if (targetVersion) {
@ -151,7 +315,6 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
[fetchVersions, fetchDetails]
);
// 버전 선택
const selectVersion = useCallback(
async (versionId: string) => {
setSelectedVersionId(versionId);
@ -160,7 +323,6 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
[fetchDetails]
);
// 모달에서 데이터 변경 후 새로고침
const refreshVersions = useCallback(async () => {
if (selectedItemCode) {
const versionList = await fetchVersions(selectedItemCode);
@ -180,7 +342,6 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
}
}, [selectedVersionId, fetchDetails]);
// 공정 삭제
const deleteDetail = useCallback(
async (detailId: string) => {
try {
@ -189,19 +350,13 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
`/table-management/tables/${ds.routingDetailTable}/delete`,
{ data: [{ id: detailId }] }
);
if (res.data?.success) {
await refreshDetails();
return true;
}
} catch (err) {
console.error("공정 삭제 실패", err);
}
if (res.data?.success) { await refreshDetails(); return true; }
} catch (err) { console.error("공정 삭제 실패", err); }
return false;
},
[refreshDetails]
);
// 버전 삭제
const deleteVersion = useCallback(
async (versionId: string) => {
try {
@ -211,22 +366,16 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
{ data: [{ id: versionId }] }
);
if (res.data?.success) {
if (selectedVersionId === versionId) {
setSelectedVersionId(null);
setDetails([]);
}
if (selectedVersionId === versionId) { setSelectedVersionId(null); setDetails([]); }
await refreshVersions();
return true;
}
} catch (err) {
console.error("버전 삭제 실패", err);
}
} catch (err) { console.error("버전 삭제 실패", err); }
return false;
},
[selectedVersionId, refreshVersions]
);
// 기본 버전 설정
const setDefaultVersion = useCallback(
async (versionId: string) => {
try {
@ -236,20 +385,15 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
routingFkColumn: ds.routingVersionFkColumn,
});
if (res.data?.success) {
if (selectedItemCode) {
await fetchVersions(selectedItemCode);
}
if (selectedItemCode) await fetchVersions(selectedItemCode);
return true;
}
} catch (err) {
console.error("기본 버전 설정 실패", err);
}
} catch (err) { console.error("기본 버전 설정 실패", err); }
return false;
},
[selectedItemCode, fetchVersions]
);
// 기본 버전 해제
const unsetDefaultVersion = useCallback(
async (versionId: string) => {
try {
@ -258,14 +402,10 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
routingVersionTable: ds.routingVersionTable,
});
if (res.data?.success) {
if (selectedItemCode) {
await fetchVersions(selectedItemCode);
}
if (selectedItemCode) await fetchVersions(selectedItemCode);
return true;
}
} catch (err) {
console.error("기본 버전 해제 실패", err);
}
} catch (err) { console.error("기본 버전 해제 실패", err); }
return false;
},
[selectedItemCode, fetchVersions]
@ -274,13 +414,20 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
return {
config,
items,
allItems,
versions,
details,
loading,
selectedItemCode,
selectedItemName,
selectedVersionId,
isRegisteredMode,
fetchItems,
fetchRegisteredItems,
fetchAllItems,
registerItem,
registerItemsBatch,
unregisterItem,
selectItem,
selectVersion,
refreshVersions,

View File

@ -4,7 +4,7 @@ import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { ItemRoutingComponent } from "./ItemRoutingComponent";
import { ItemRoutingConfigPanel } from "./ItemRoutingConfigPanel";
import { V2ItemRoutingConfigPanel as ItemRoutingConfigPanel } from "@/components/v2/config-panels/V2ItemRoutingConfigPanel";
import { defaultConfig } from "./config";
export const V2ItemRoutingDefinition = createComponentDefinition({

View File

@ -10,10 +10,10 @@ export interface ItemRoutingDataSource {
itemNameColumn: string;
itemCodeColumn: string;
routingVersionTable: string;
routingVersionFkColumn: string; // item_routing_version에서 item_code를 가리키는 FK
routingVersionFkColumn: string;
routingVersionNameColumn: string;
routingDetailTable: string;
routingDetailFkColumn: string; // item_routing_detail에서 routing_version_id를 가리키는 FK
routingDetailFkColumn: string;
processTable: string;
processNameColumn: string;
processCodeColumn: string;
@ -26,14 +26,24 @@ export interface ItemRoutingModals {
processEditScreenId?: number;
}
// 공정 테이블 컬럼 정의
export interface ProcessColumnDef {
// 컬럼 정의 (공정/품목 공용)
export interface ColumnDef {
name: string;
label: string;
width?: number;
align?: "left" | "center" | "right";
}
// 공정 테이블 컬럼 정의 (기존 호환)
export type ProcessColumnDef = ColumnDef;
// 품목 필터 조건
export interface ItemFilterCondition {
column: string;
operator: "equals" | "contains" | "not_equals";
value: string;
}
// 전체 Config
export interface ItemRoutingConfig {
dataSource: ItemRoutingDataSource;
@ -46,6 +56,14 @@ export interface ItemRoutingConfig {
autoSelectFirstVersion?: boolean;
versionAddButtonText?: string;
processAddButtonText?: string;
itemListMode?: "all" | "registered";
screenCode?: string;
/** 좌측 품목 목록에 표시할 컬럼 */
itemDisplayColumns?: ColumnDef[];
/** 품목 추가 모달에 표시할 컬럼 */
modalDisplayColumns?: ColumnDef[];
/** 품목 조회 시 사전 필터 조건 */
itemFilterConditions?: ItemFilterCondition[];
}
// 컴포넌트 Props
@ -54,6 +72,7 @@ export interface ItemRoutingComponentProps {
formData?: Record<string, any>;
isPreview?: boolean;
tableName?: string;
screenId?: number | string;
}
// 데이터 모델

View File

@ -3,7 +3,7 @@
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { LocationSwapSelectorComponent } from "./LocationSwapSelectorComponent";
import { LocationSwapSelectorConfigPanel } from "./LocationSwapSelectorConfigPanel";
import { V2LocationSwapSelectorConfigPanel as LocationSwapSelectorConfigPanel } from "@/components/v2/config-panels/V2LocationSwapSelectorConfigPanel";
/**
* LocationSwapSelector

View File

@ -4,7 +4,7 @@ import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { NumberingRuleWrapper } from "./NumberingRuleComponent";
import { NumberingRuleConfigPanel } from "./NumberingRuleConfigPanel";
import { V2NumberingRuleConfigPanel } from "@/components/v2/config-panels/V2NumberingRuleConfigPanel";
import { defaultConfig } from "./config";
/**
@ -25,7 +25,7 @@ export const V2NumberingRuleDefinition = createComponentDefinition({
height: 800,
gridColumnSpan: "12",
},
configPanel: NumberingRuleConfigPanel,
configPanel: V2NumberingRuleConfigPanel,
icon: "Hash",
tags: ["코드", "채번", "규칙", "표시", "자동생성"],
version: "1.0.0",

View File

@ -43,7 +43,7 @@ export type {
// 컴포넌트 내보내기
export { PivotGridComponent } from "./PivotGridComponent";
export { PivotGridConfigPanel } from "./PivotGridConfigPanel";
export { V2PivotGridConfigPanel as PivotGridConfigPanel } from "@/components/v2/config-panels/V2PivotGridConfigPanel";
// 유틸리티
export {

View File

@ -4,7 +4,7 @@ import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { ProcessWorkStandardComponent } from "./ProcessWorkStandardComponent";
import { ProcessWorkStandardConfigPanel } from "./ProcessWorkStandardConfigPanel";
import { V2ProcessWorkStandardConfigPanel as ProcessWorkStandardConfigPanel } from "@/components/v2/config-panels/V2ProcessWorkStandardConfigPanel";
import { defaultConfig } from "./config";
export const V2ProcessWorkStandardDefinition = createComponentDefinition({

View File

@ -4,7 +4,7 @@ import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { RackStructureWrapper } from "./RackStructureComponent";
import { RackStructureConfigPanel } from "./RackStructureConfigPanel";
import { V2RackStructureConfigPanel as RackStructureConfigPanel } from "@/components/v2/config-panels/V2RackStructureConfigPanel";
import { defaultConfig } from "./config";
/**

View File

@ -3,7 +3,7 @@
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { RepeatContainerWrapper } from "./RepeatContainerComponent";
import { RepeatContainerConfigPanel } from "./RepeatContainerConfigPanel";
import { V2RepeatContainerConfigPanel as RepeatContainerConfigPanel } from "@/components/v2/config-panels/V2RepeatContainerConfigPanel";
import type { RepeatContainerConfig } from "./types";
/**

View File

@ -3,7 +3,7 @@
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { SectionCardComponent } from "./SectionCardComponent";
import { SectionCardConfigPanel } from "./SectionCardConfigPanel";
import { V2SectionCardConfigPanel } from "@/components/v2/config-panels/V2SectionCardConfigPanel";
/**
* Section Card
@ -28,7 +28,7 @@ export const V2SectionCardDefinition = createComponentDefinition({
defaultOpen: true,
},
defaultSize: { width: 800, height: 250 },
configPanel: SectionCardConfigPanel,
configPanel: V2SectionCardConfigPanel,
icon: "LayoutPanelTop",
tags: ["섹션", "그룹", "카드", "컨테이너", "제목", "card"],
version: "1.0.0",

View File

@ -3,7 +3,7 @@
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { SectionPaperComponent } from "./SectionPaperComponent";
import { SectionPaperConfigPanel } from "./SectionPaperConfigPanel";
import { V2SectionPaperConfigPanel } from "@/components/v2/config-panels/V2SectionPaperConfigPanel";
/**
* Section Paper
@ -25,7 +25,7 @@ export const V2SectionPaperDefinition = createComponentDefinition({
showBorder: false,
},
defaultSize: { width: 800, height: 200 },
configPanel: SectionPaperConfigPanel,
configPanel: V2SectionPaperConfigPanel,
icon: "Square",
tags: ["섹션", "그룹", "배경", "컨테이너", "색종이", "paper"],
version: "1.0.0",

View File

@ -6,7 +6,7 @@
import { ComponentCategory } from "@/types/component";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { V2SelectConfigPanel } from "@/components/v2/config-panels/V2SelectConfigPanel";
import { V2FieldConfigPanel } from "@/components/v2/config-panels/V2FieldConfigPanel";
import { V2Select } from "@/components/v2/V2Select";
export const V2SelectDefinition = createComponentDefinition({
@ -82,7 +82,7 @@ export const V2SelectDefinition = createComponentDefinition({
tags: ["select", "dropdown", "combobox", "v2"],
// 설정 패널
configPanel: V2SelectConfigPanel,
configPanel: V2FieldConfigPanel,
});
export default V2SelectDefinition;

View File

@ -4,7 +4,7 @@ import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { SplitLineWrapper } from "./SplitLineComponent";
import { SplitLineConfigPanel } from "./SplitLineConfigPanel";
import { V2SplitLineConfigPanel } from "@/components/v2/config-panels/V2SplitLineConfigPanel";
import { SplitLineConfig } from "./types";
/**
@ -25,7 +25,7 @@ export const V2SplitLineDefinition = createComponentDefinition({
lineWidth: 4,
} as SplitLineConfig,
defaultSize: { width: 8, height: 600 },
configPanel: SplitLineConfigPanel,
configPanel: V2SplitLineConfigPanel,
icon: "SeparatorVertical",
tags: ["스플릿", "분할", "분할선", "레이아웃"],
version: "1.0.0",

View File

@ -3543,6 +3543,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
format: undefined, // 🆕 기본값
}));
// 컬럼 너비 합계 계산 (작업 컬럼 제외, 100% 초과 시 스크롤)
const leftTotalColWidth = columnsToShow.reduce((sum, col) => {
const w = col.width && col.width <= 100 ? col.width : 0;
return sum + w;
}, 0);
// 🔧 그룹화된 데이터 렌더링
const hasGroupedLeftActions = !isDesignMode && (
(componentConfig.leftPanel?.showEdit !== false) ||
@ -3556,7 +3562,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<div className="bg-muted px-3 py-2 text-sm font-semibold">
{group.groupKey} ({group.count})
</div>
<table className="min-w-full divide-y divide-border">
<table className="divide-y divide-border table-fixed" style={{ width: leftTotalColWidth > 100 ? `${leftTotalColWidth}%` : '100%' }}>
<thead className="bg-muted">
<tr>
{columnsToShow.map((col, idx) => (
@ -3564,8 +3570,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
key={idx}
className="px-3 py-2 text-left text-xs font-medium tracking-wider text-muted-foreground uppercase whitespace-nowrap"
style={{
width: col.width ? `${col.width}px` : "auto",
minWidth: "80px",
width: col.width && col.width <= 100 ? `${col.width}%` : "auto",
textAlign: col.align || "left",
}}
>
@ -3654,7 +3659,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
);
return (
<div className="overflow-auto">
<table className="min-w-full divide-y divide-border">
<table className="divide-y divide-border table-fixed" style={{ width: leftTotalColWidth > 100 ? `${leftTotalColWidth}%` : '100%' }}>
<thead className="sticky top-0 z-10 bg-muted">
<tr>
{columnsToShow.map((col, idx) => (
@ -3662,8 +3667,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
key={idx}
className="px-3 py-2 text-left text-xs font-medium tracking-wider text-muted-foreground uppercase whitespace-nowrap"
style={{
width: col.width ? `${col.width}px` : "auto",
minWidth: "80px",
width: col.width && col.width <= 100 ? `${col.width}%` : "auto",
textAlign: col.align || "left",
}}
>
@ -4659,11 +4663,16 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}));
}
const tableMinWidth = columnsToShow.reduce((sum, col) => sum + (col.width || 100), 0) + 80;
// 컬럼 너비 합계 계산 (작업 컬럼 제외, 100% 초과 시 스크롤)
const rightTotalColWidth = columnsToShow.reduce((sum, col) => {
const w = col.width && col.width <= 100 ? col.width : 0;
return sum + w;
}, 0);
return (
<div className="flex h-full w-full flex-col">
<div className="min-h-0 flex-1 overflow-auto">
<table style={{ minWidth: `${tableMinWidth}px` }}>
<table className="table-fixed" style={{ width: rightTotalColWidth > 100 ? `${rightTotalColWidth}%` : '100%' }}>
<thead className="sticky top-0 z-10">
<tr className="border-b-2 border-border/60">
{columnsToShow.map((col, idx) => (
@ -4671,8 +4680,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
key={idx}
className="text-muted-foreground px-3 py-2 text-left text-xs font-semibold whitespace-nowrap"
style={{
width: col.width ? `${col.width}px` : "auto",
minWidth: "80px",
width: col.width && col.width <= 100 ? `${col.width}%` : "auto",
textAlign: col.align || "left",
}}
>
@ -4683,7 +4691,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
{!isDesignMode &&
((componentConfig.rightPanel?.editButton?.enabled ?? true) ||
(componentConfig.rightPanel?.deleteButton?.enabled ?? true)) && (
<th className="text-muted-foreground px-3 py-2 text-right text-xs font-semibold">
<th className="text-muted-foreground px-3 py-2 text-right text-xs font-semibold" style={{ width: '80px' }}>
</th>
)}
@ -4762,7 +4770,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
{
// 표시 컬럼 결정
const rightColumns = componentConfig.rightPanel?.columns;
let columnsToDisplay: { name: string; label: string; format?: string; bold?: boolean }[] = [];
let columnsToDisplay: { name: string; label: string; format?: string; bold?: boolean; width?: number }[] = [];
if (rightColumns && rightColumns.length > 0) {
// showInSummary가 false가 아닌 것만 메인 테이블에 표시
@ -4773,6 +4781,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
label: rightColumnLabels[col.name] || col.label || col.name,
format: col.format,
bold: col.bold,
width: col.width,
}));
} else if (filteredData.length > 0) {
columnsToDisplay = Object.keys(filteredData[0])
@ -4784,24 +4793,33 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}));
}
// 컬럼 너비 합계 계산 (작업 컬럼 제외, 100% 초과 시 스크롤)
const displayTotalColWidth = columnsToDisplay.reduce((sum, col) => {
const w = col.width && col.width <= 100 ? col.width : 0;
return sum + w;
}, 0);
const hasEditButton = !isDesignMode && (componentConfig.rightPanel?.editButton?.enabled ?? true);
const hasDeleteButton = !isDesignMode && (componentConfig.rightPanel?.deleteButton?.enabled ?? true);
const hasActions = hasEditButton || hasDeleteButton;
const tableMinW2 = columnsToDisplay.reduce((sum, col) => sum + (col.width || 100), 0) + 80;
return filteredData.length > 0 ? (
<div className="flex h-full w-full flex-col">
<div className="min-h-0 flex-1 overflow-auto">
<table className="text-sm" style={{ minWidth: `${tableMinW2}px` }}>
<table className="table-fixed text-sm" style={{ width: displayTotalColWidth > 100 ? `${displayTotalColWidth}%` : '100%' }}>
<thead className="sticky top-0 z-10 bg-background">
<tr className="border-b-2 border-border/60">
{columnsToDisplay.map((col) => (
<th key={col.name} className="text-muted-foreground px-3 py-2 text-left text-xs font-semibold">
<th
key={col.name}
className="text-muted-foreground px-3 py-2 text-left text-xs font-semibold whitespace-nowrap"
style={{ width: col.width && col.width <= 100 ? `${col.width}%` : "auto" }}
>
{col.label}
</th>
))}
{hasActions && (
<th className="text-muted-foreground px-3 py-2 text-right text-xs font-semibold"></th>
<th className="text-muted-foreground px-3 py-2 text-right text-xs font-semibold" style={{ width: '80px' }}></th>
)}
</tr>
</thead>

View File

@ -174,10 +174,13 @@ function SortableColumnRow({
/>
<Input
value={col.width || ""}
onChange={(e) => onWidthChange(parseInt(e.target.value) || 100)}
placeholder="너비"
onChange={(e) => onWidthChange(parseInt(e.target.value) || 20)}
placeholder="20"
className="h-6 w-14 shrink-0 text-xs"
min={5}
max={100}
/>
<span className="text-muted-foreground shrink-0 text-[10px]">%</span>
{isNumeric && (
<>
<label className="flex shrink-0 cursor-pointer items-center gap-1 text-[10px]" title="천 단위 구분자 (,)">
@ -888,7 +891,7 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
updateTab({
columns: [
...selectedColumns,
{ name: column.columnName, label: column.columnLabel || column.columnName, width: 100 },
{ name: column.columnName, label: column.columnLabel || column.columnName, width: 20 },
],
});
}}
@ -1058,7 +1061,7 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
{
name: matchingJoinColumn.joinAlias,
label: matchingJoinColumn.suggestedLabel || matchingJoinColumn.columnLabel,
width: 100,
width: 20,
isEntityJoin: true,
joinInfo: {
sourceTable: tab.tableName!,
@ -2396,7 +2399,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
{
name: column.columnName,
label: column.columnLabel || column.columnName,
width: 100,
width: 20,
},
],
});
@ -2466,7 +2469,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
label:
matchingJoinColumn.suggestedLabel ||
matchingJoinColumn.columnLabel,
width: 100,
width: 20,
isEntityJoin: true,
joinInfo: {
sourceTable: leftTable!,
@ -3074,7 +3077,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
{
name: column.columnName,
label: column.columnLabel || column.columnName,
width: 100,
width: 20,
},
],
});
@ -3141,7 +3144,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
name: matchingJoinColumn.joinAlias,
label:
matchingJoinColumn.suggestedLabel || matchingJoinColumn.columnLabel,
width: 100,
width: 20,
isEntityJoin: true,
joinInfo: {
sourceTable: rightTable!,

Some files were not shown because too many files have changed in this diff Show More