Merge branch 'gbpark-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node

This commit is contained in:
kjs 2026-03-12 15:09:55 +09:00
commit ca390bb191
125 changed files with 33087 additions and 11045 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

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

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

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,756 @@
"use client";
/**
* V2
* UX: 데이터 -> -> -> ()
*/
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,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { ItemRoutingConfig, ProcessColumnDef } 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) => 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); 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>
);
}
// ─── 메인 컴포넌트 ───
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 [columnsOpen, setColumnsOpen] = useState(false);
const [dataSourceOpen, setDataSourceOpen] = useState(false);
const [layoutOpen, setLayoutOpen] = 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,
};
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 newDataSource = { ...config.dataSource, [field]: value };
const partial = { dataSource: newDataSource };
onChange({ ...configProp, ...partial });
dispatchConfigEvent(partial);
};
const updateModals = (field: string, value?: number) => {
const newModals = { ...config.modals, [field]: value };
const partial = { modals: newModals };
onChange({ ...configProp, ...partial });
dispatchConfigEvent(partial);
};
// 공정 컬럼 관리
const addColumn = () => {
update({
processColumns: [
...config.processColumns,
{ name: "", label: "새 컬럼", width: 100, align: "left" as const },
],
});
};
const removeColumn = (idx: number) => {
update({ processColumns: config.processColumns.filter((_, i) => i !== idx) });
};
const updateColumn = (idx: number, field: keyof ProcessColumnDef, value: string | number) => {
const next = [...config.processColumns];
next[idx] = { ...next[idx], [field]: value };
update({ processColumns: next });
};
return (
<div className="space-y-4">
{/* ─── 1단계: 모달 연동 (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>
{/* ─── 2단계: 공정 테이블 컬럼 (Collapsible + 접이식 카드) ─── */}
<Collapsible open={columnsOpen} onOpenChange={setColumnsOpen}>
<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">
<Columns className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
<Badge variant="secondary" className="text-[10px] h-5">
{config.processColumns.length}
</Badge>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
columnsOpen && "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.processColumns.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.name || "미설정"}</span>
<span className="text-[10px] text-muted-foreground truncate max-w-[60px] shrink-0">{col.label}</span>
<Badge variant="outline" className="text-[9px] h-4 shrink-0">{col.align || "left"}</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>
<Input
value={col.name}
onChange={(e) => updateColumn(idx, "name", 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={col.label}
onChange={(e) => updateColumn(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"
value={col.width || 100}
onChange={(e) => updateColumn(idx, "width", parseInt(e.target.value) || 100)}
className="h-7 text-xs"
placeholder="100"
/>
</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>
))}
</div>
<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>
{/* ─── 3단계: 데이터 소스 (Collapsible) ─── */}
<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>
{/* ─── 4단계: 레이아웃 & 기타 (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

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

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

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

@ -4,7 +4,7 @@ import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { SplitPanelLayoutWrapper } from "./SplitPanelLayoutComponent";
import { SplitPanelLayoutConfigPanel } from "./SplitPanelLayoutConfigPanel";
import { V2SplitPanelLayoutConfigPanel } from "@/components/v2/config-panels/V2SplitPanelLayoutConfigPanel";
import { SplitPanelLayoutConfig } from "./types";
/**
@ -42,7 +42,7 @@ export const V2SplitPanelLayoutDefinition = createComponentDefinition({
syncSelection: true,
} as SplitPanelLayoutConfig,
defaultSize: { width: 800, height: 600 },
configPanel: SplitPanelLayoutConfigPanel,
configPanel: V2SplitPanelLayoutConfigPanel,
icon: "PanelLeftRight",
tags: ["분할", "마스터", "디테일", "레이아웃"],
version: "1.0.0",

View File

@ -3,7 +3,7 @@
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { StatusCountWrapper } from "./StatusCountComponent";
import { StatusCountConfigPanel } from "./StatusCountConfigPanel";
import { V2StatusCountConfigPanel } from "@/components/v2/config-panels/V2StatusCountConfigPanel";
export const V2StatusCountDefinition = createComponentDefinition({
id: "v2-status-count",
@ -13,7 +13,7 @@ export const V2StatusCountDefinition = createComponentDefinition({
category: ComponentCategory.DISPLAY,
webType: "text",
component: StatusCountWrapper,
configPanel: StatusCountConfigPanel,
configPanel: V2StatusCountConfigPanel,
defaultConfig: {
title: "상태 현황",
tableName: "",

View File

@ -5,7 +5,7 @@ import { createComponentDefinition } from "../../utils/createComponentDefinition
import { ComponentCategory } from "@/types/component";
import type { WebType } from "@/types/screen";
import { TableGroupedComponent } from "./TableGroupedComponent";
import { TableGroupedConfigPanel } from "./TableGroupedConfigPanel";
import { V2TableGroupedConfigPanel } from "@/components/v2/config-panels/V2TableGroupedConfigPanel";
import { TableGroupedConfig } from "./types";
/**
@ -63,7 +63,7 @@ export const V2TableGroupedDefinition = createComponentDefinition({
maxHeight: 600,
},
defaultSize: { width: 800, height: 500 },
configPanel: TableGroupedConfigPanel,
configPanel: V2TableGroupedConfigPanel,
icon: "Layers",
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 { TableListWrapper } from "./TableListComponent";
import { TableListConfigPanel } from "./TableListConfigPanel";
import { V2TableListConfigPanel } from "@/components/v2/config-panels/V2TableListConfigPanel";
import { TableListConfig } from "./types";
/**
@ -109,7 +109,7 @@ export const V2TableListDefinition = createComponentDefinition({
autoLoad: true,
},
defaultSize: { width: 1000, height: 600 }, // 테이블 리스트 기본 크기 (너비 1000px, 높이 600px)
configPanel: TableListConfigPanel,
configPanel: V2TableListConfigPanel,
icon: "Table",
tags: ["테이블", "데이터", "목록", "그리드"],
version: "1.0.0",

View File

@ -3,7 +3,7 @@
import { ComponentRegistry } from "../../ComponentRegistry";
import { TableSearchWidget } from "./TableSearchWidget";
import { TableSearchWidgetRenderer } from "./TableSearchWidgetRenderer";
import { TableSearchWidgetConfigPanel } from "./TableSearchWidgetConfigPanel";
import { V2TableSearchWidgetConfigPanel } from "@/components/v2/config-panels/V2TableSearchWidgetConfigPanel";
// 검색 필터 위젯 등록 (v2)
ComponentRegistry.registerComponent({
@ -30,7 +30,7 @@ ComponentRegistry.registerComponent({
},
},
renderer: TableSearchWidgetRenderer.render,
configPanel: TableSearchWidgetConfigPanel,
configPanel: V2TableSearchWidgetConfigPanel,
version: "1.0.0",
author: "WACE",
});

View File

@ -5,7 +5,7 @@ import { createComponentDefinition } from "../../utils/createComponentDefinition
import { ComponentCategory } from "@/types/component";
import type { WebType } from "@/types/screen";
import { TextDisplayWrapper } from "./TextDisplayComponent";
import { TextDisplayConfigPanel } from "./TextDisplayConfigPanel";
import { V2TextDisplayConfigPanel } from "@/components/v2/config-panels/V2TextDisplayConfigPanel";
import { TextDisplayConfig } from "./types";
/**
@ -28,7 +28,7 @@ export const V2TextDisplayDefinition = createComponentDefinition({
textAlign: "left",
},
defaultSize: { width: 150, height: 24 },
configPanel: TextDisplayConfigPanel,
configPanel: V2TextDisplayConfigPanel,
icon: "Type",
tags: ["텍스트", "표시", "라벨"],
version: "1.0.0",

View File

@ -3,7 +3,7 @@
import { ComponentCategory } from "@/types/component";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { TimelineSchedulerComponent } from "./TimelineSchedulerComponent";
import { TimelineSchedulerConfigPanel } from "./TimelineSchedulerConfigPanel";
import { V2TimelineSchedulerConfigPanel as TimelineSchedulerConfigPanel } from "@/components/v2/config-panels/V2TimelineSchedulerConfigPanel";
import { defaultTimelineSchedulerConfig } from "./config";
import { TimelineSchedulerConfig } from "./types";

View File

@ -3,13 +3,13 @@
*/
import React from "react";
import type { ConfigPanelContext } from "@/lib/registry/components/common/ConfigPanelTypes";
// 컴포넌트별 ConfigPanel 동적 import 맵
// 모든 ConfigPanel이 있는 컴포넌트를 여기에 등록해야 슬롯/중첩 컴포넌트에서 전용 설정 패널이 표시됨
const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
// ========== V2 컴포넌트 ==========
"v2-input": () => import("@/components/v2/config-panels/V2InputConfigPanel"),
"v2-select": () => import("@/components/v2/config-panels/V2SelectConfigPanel"),
"v2-input": () => import("@/components/v2/config-panels/V2FieldConfigPanel"),
"v2-select": () => import("@/components/v2/config-panels/V2FieldConfigPanel"),
"v2-date": () => import("@/components/v2/config-panels/V2DateConfigPanel"),
"v2-list": () => import("@/components/v2/config-panels/V2ListConfigPanel"),
"v2-media": () => import("@/components/v2/config-panels/V2MediaConfigPanel"),
@ -72,8 +72,6 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
"repeat-container": () => import("@/lib/registry/components/repeat-container/RepeatContainerConfigPanel"),
"v2-repeat-container": () => import("@/lib/registry/components/v2-repeat-container/RepeatContainerConfigPanel"),
"repeater-field-group": () => import("@/components/webtypes/config/RepeaterConfigPanel"),
"v2-repeater": () => import("@/components/v2/config-panels/V2RepeaterConfigPanel"),
"v2-repeater": () => import("@/components/v2/config-panels/V2RepeaterConfigPanel"),
"simple-repeater-table": () => import("@/lib/registry/components/simple-repeater-table/SimpleRepeaterTableConfigPanel"),
"modal-repeater-table": () => import("@/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel"),
"repeat-screen-modal": () => import("@/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel"),
@ -104,84 +102,66 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
"category-manager": () => import("@/lib/registry/components/category-manager/CategoryManagerConfigPanel"),
"universal-form-modal": () => import("@/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel"),
"v2-process-work-standard": () => import("@/lib/registry/components/v2-process-work-standard/ProcessWorkStandardConfigPanel"),
// ========== V2 BOM 컴포넌트 ==========
"v2-bom-item-editor": () => import("@/components/v2/config-panels/V2BomItemEditorConfigPanel"),
"v2-bom-tree": () => import("@/components/v2/config-panels/V2BomTreeConfigPanel"),
// ========== 레거시 위젯 (component/onUpdateProperty props 사용) ==========
"card": () => import("@/components/screen/config-panels/CardConfigPanel"),
"dashboard": () => import("@/components/screen/config-panels/DashboardConfigPanel"),
"stats": () => import("@/components/screen/config-panels/StatsCardConfigPanel"),
"stats-card": () => import("@/components/screen/config-panels/StatsCardConfigPanel"),
"progress": () => import("@/components/screen/config-panels/ProgressBarConfigPanel"),
"progress-bar": () => import("@/components/screen/config-panels/ProgressBarConfigPanel"),
"chart": () => import("@/components/screen/config-panels/ChartConfigPanel"),
"chart-basic": () => import("@/components/screen/config-panels/ChartConfigPanel"),
"alert": () => import("@/components/screen/config-panels/AlertConfigPanel"),
"alert-info": () => import("@/components/screen/config-panels/AlertConfigPanel"),
"badge": () => import("@/components/screen/config-panels/BadgeConfigPanel"),
"badge-status": () => import("@/components/screen/config-panels/BadgeConfigPanel"),
};
// ConfigPanel 컴포넌트 캐시
const configPanelCache = new Map<string, React.ComponentType<any>>();
/**
* ID로 ConfigPanel
*/
export async function getComponentConfigPanel(componentId: string): Promise<React.ComponentType<any> | null> {
// 캐시에서 먼저 확인
if (configPanelCache.has(componentId)) {
return configPanelCache.get(componentId)!;
}
// 매핑에서 import 함수 찾기
const importFn = CONFIG_PANEL_MAP[componentId];
if (!importFn) {
console.warn(`컴포넌트 "${componentId}"에 대한 ConfigPanel을 찾을 수 없습니다.`);
return null;
}
try {
const module = await importFn();
// 모듈에서 ConfigPanel 컴포넌트 추출
// 1차: PascalCase 변환된 이름으로 찾기 (예: text-input -> TextInputConfigPanel)
// 2차: v2- 접두사 제거 후 PascalCase 이름으로 찾기 (예: v2-table-list -> TableListConfigPanel)
// 3차: 특수 export명들 fallback
// 모듈에서 ConfigPanel 컴포넌트 추출 (우선순위):
// 1차: PascalCase 변환된 이름 (예: text-input -> TextInputConfigPanel)
// 2차: v2- 접두사 제거 후 PascalCase (예: v2-table-list -> TableListConfigPanel)
// 3차: *ConfigPanel로 끝나는 첫 번째 named export
// 4차: default export
const pascalCaseName = `${toPascalCase(componentId)}ConfigPanel`;
// v2- 접두사가 있는 경우 접두사를 제거한 이름도 시도
const baseComponentId = componentId.startsWith("v2-") ? componentId.slice(3) : componentId;
const basePascalCaseName = `${toPascalCase(baseComponentId)}ConfigPanel`;
const findConfigPanelExport = () => {
for (const key of Object.keys(module)) {
if (key.endsWith("ConfigPanel") && typeof module[key] === "function") {
return module[key];
}
}
return null;
};
const ConfigPanelComponent =
module[pascalCaseName] ||
module[basePascalCaseName] ||
// 특수 export명들
module.RepeaterConfigPanel ||
module.FlowWidgetConfigPanel ||
module.CustomerItemMappingConfigPanel ||
module.SelectedItemsDetailInputConfigPanel ||
module.ButtonConfigPanel ||
module.TableListConfigPanel ||
module.SectionCardConfigPanel ||
module.SectionPaperConfigPanel ||
module.TabsConfigPanel ||
module.V2RepeaterConfigPanel ||
module.V2InputConfigPanel ||
module.V2SelectConfigPanel ||
module.V2DateConfigPanel ||
module.V2ListConfigPanel ||
module.V2MediaConfigPanel ||
module.V2BizConfigPanel ||
module.V2GroupConfigPanel ||
module.V2HierarchyConfigPanel ||
module.V2LayoutConfigPanel ||
module.RepeatContainerConfigPanel ||
module.ScreenSplitPanelConfigPanel ||
module.SimpleRepeaterTableConfigPanel ||
module.ModalRepeaterTableConfigPanel ||
module.RepeatScreenModalConfigPanel ||
module.RelatedDataButtonsConfigPanel ||
module.AutocompleteSearchInputConfigPanel ||
module.EntitySearchInputConfigPanel ||
module.MailRecipientSelectorConfigPanel ||
module.LocationSwapSelectorConfigPanel ||
module.MapConfigPanel ||
module.RackStructureConfigPanel ||
module.AggregationWidgetConfigPanel ||
module.NumberingRuleConfigPanel ||
module.CategoryManagerConfigPanel ||
module.UniversalFormModalConfigPanel ||
module.PivotGridConfigPanel ||
module.TableSearchWidgetConfigPanel ||
module.TaxInvoiceListConfigPanel ||
module.ImageWidgetConfigPanel ||
module.TestInputConfigPanel ||
findConfigPanelExport() ||
module.default;
if (!ConfigPanelComponent) {
@ -189,9 +169,7 @@ export async function getComponentConfigPanel(componentId: string): Promise<Reac
return null;
}
// 캐시에 저장
configPanelCache.set(componentId, ConfigPanelComponent);
return ConfigPanelComponent;
} catch (error) {
console.error(`컴포넌트 "${componentId}"의 ConfigPanel 로드 실패:`, error);
@ -199,24 +177,14 @@ export async function getComponentConfigPanel(componentId: string): Promise<Reac
}
}
/**
* ID가 ConfigPanel을
*/
export function hasComponentConfigPanel(componentId: string): boolean {
return componentId in CONFIG_PANEL_MAP;
}
/**
* ID
*/
export function getSupportedConfigPanelComponents(): string[] {
return Object.keys(CONFIG_PANEL_MAP);
}
/**
* kebab-case를 PascalCase로
* text-input TextInput
*/
function toPascalCase(str: string): string {
return str
.split("-")
@ -231,12 +199,13 @@ export interface ComponentConfigPanelProps {
componentId: string;
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
screenTableName?: string; // 화면에서 지정한 테이블명
tableColumns?: any[]; // 테이블 컬럼 정보
tables?: any[]; // 전체 테이블 목록
menuObjid?: number; // 🆕 메뉴 OBJID (코드/카테고리/채번규칙 스코프용)
allComponents?: any[]; // 🆕 현재 화면의 모든 컴포넌트 (연쇄 드롭다운 부모 감지용)
currentComponent?: any; // 🆕 현재 컴포넌트 정보
screenTableName?: string;
tableColumns?: any[];
tables?: any[];
menuObjid?: number;
allComponents?: any[];
currentComponent?: any;
componentType?: string;
}
export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> = ({
@ -249,54 +218,43 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
menuObjid,
allComponents,
currentComponent,
componentType,
}) => {
// 모든 useState를 최상단에 선언 (Hooks 규칙)
const [ConfigPanelComponent, setConfigPanelComponent] = React.useState<React.ComponentType<any> | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [selectedTableColumns, setSelectedTableColumns] = React.useState(tableColumns);
const [allTablesList, setAllTablesList] = React.useState<any[]>([]);
// 🆕 selected-items-detail-input 전용 상태
const [sourceTableColumns, setSourceTableColumns] = React.useState<any[]>([]);
const [targetTableColumns, setTargetTableColumns] = React.useState<any[]>([]);
React.useEffect(() => {
let mounted = true;
async function loadConfigPanel() {
try {
setLoading(true);
setError(null);
const component = await getComponentConfigPanel(componentId);
if (mounted) {
setConfigPanelComponent(() => component);
setLoading(false);
}
} catch (err) {
console.error(`❌ DynamicComponentConfigPanel: ${componentId} 로드 실패:`, err);
if (mounted) {
setError(err instanceof Error ? err.message : String(err));
setLoading(false);
}
}
}
loadConfigPanel();
return () => {
mounted = false;
};
return () => { mounted = false; };
}, [componentId]);
// tableColumns가 변경되면 selectedTableColumns도 업데이트
React.useEffect(() => {
setSelectedTableColumns(tableColumns);
}, [tableColumns]);
// RepeaterConfigPanel과 selected-items-detail-input에서 전체 테이블 목록 로드
// repeater-field-group / selected-items-detail-input에서 전체 테이블 목록 로드
React.useEffect(() => {
if (componentId === "repeater-field-group" || componentId === "selected-items-detail-input") {
const loadAllTables = async () => {
@ -304,100 +262,57 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
const { tableManagementApi } = await import("@/lib/api/tableManagement");
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
console.log(`✅ 전체 테이블 목록 로드 완료 (${componentId}):`, response.data.length);
setAllTablesList(response.data);
}
} catch (error) {
console.error("전체 테이블 목록 로드 실패:", error);
} catch (_) {
// 전체 테이블 목록 로드 실패 시 무시
}
};
loadAllTables();
}
}, [componentId]);
// 🆕 selected-items-detail-input: 초기 sourceTable/targetTable 컬럼 로드
// selected-items-detail-input: 초기 sourceTable/targetTable 컬럼 로드
React.useEffect(() => {
if (componentId === "selected-items-detail-input") {
console.log("🔍 selected-items-detail-input 초기 설정:", config);
// 원본 테이블 컬럼 로드
if (config.sourceTable) {
const loadSourceColumns = async () => {
try {
const { tableTypeApi } = await import("@/lib/api/screen");
const columnsResponse = await tableTypeApi.getColumns(config.sourceTable);
if (componentId !== "selected-items-detail-input") return;
const columns = (columnsResponse || []).map((col: any) => ({
columnName: col.columnName || col.column_name,
columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
dataType: col.dataType || col.data_type || col.dbType,
inputType: col.inputType || col.input_type, // 🆕 inputType 추가
}));
console.log("✅ 원본 테이블 컬럼 초기 로드 완료:", columns.length);
setSourceTableColumns(columns);
} catch (error) {
console.error("❌ 원본 테이블 컬럼 초기 로드 실패:", error);
}
};
loadSourceColumns();
const loadColumns = async (tableName: string, setter: React.Dispatch<React.SetStateAction<any[]>>, includeCodeCategory?: boolean) => {
try {
const { tableTypeApi } = await import("@/lib/api/screen");
const columnsResponse = await tableTypeApi.getColumns(tableName);
const columns = (columnsResponse || []).map((col: any) => ({
columnName: col.columnName || col.column_name,
columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
dataType: col.dataType || col.data_type || col.dbType,
inputType: col.inputType || col.input_type,
...(includeCodeCategory ? { codeCategory: col.codeCategory || col.code_category } : {}),
}));
setter(columns);
} catch (_) {
setter([]);
}
// 대상 테이블 컬럼 로드
if (config.targetTable) {
const loadTargetColumns = async () => {
try {
const { tableTypeApi } = await import("@/lib/api/screen");
const columnsResponse = await tableTypeApi.getColumns(config.targetTable);
};
const columns = (columnsResponse || []).map((col: any) => ({
columnName: col.columnName || col.column_name,
columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
dataType: col.dataType || col.data_type || col.dbType,
inputType: col.inputType || col.input_type, // 🆕 inputType 추가
codeCategory: col.codeCategory || col.code_category, // 🆕 codeCategory 추가
}));
console.log("✅ 대상 테이블 컬럼 초기 로드 완료:", columns.length);
setTargetTableColumns(columns);
} catch (error) {
console.error("❌ 대상 테이블 컬럼 초기 로드 실패:", error);
}
};
loadTargetColumns();
}
}
if (config.sourceTable) loadColumns(config.sourceTable, setSourceTableColumns);
if (config.targetTable) loadColumns(config.targetTable, setTargetTableColumns, true);
}, [componentId, config.sourceTable, config.targetTable]);
// 🆕 allComponents를 screenComponents 형태로 변환 (집계 위젯 등에서 사용)
// Hooks 규칙: 조건부 return 전에 선언해야 함
const screenComponents = React.useMemo(() => {
if (!allComponents) {
console.log("[getComponentConfigPanel] allComponents is undefined or null");
return [];
}
console.log("[getComponentConfigPanel] allComponents 변환 시작:", allComponents.length, "개");
const result = allComponents.map((comp: any) => {
const columnName = comp.columnName || comp.componentConfig?.columnName || comp.componentConfig?.fieldName;
console.log(`[getComponentConfigPanel] comp: ${comp.id}, type: ${comp.componentType || comp.type}, columnName: ${columnName}`);
return {
id: comp.id,
componentType: comp.componentType || comp.type,
label: comp.label || comp.name || comp.id,
tableName: comp.componentConfig?.tableName || comp.tableName,
// 🆕 폼 필드 인식용 columnName 추가
columnName,
};
});
console.log("[getComponentConfigPanel] screenComponents 변환 완료:", result);
return result;
if (!allComponents) return [];
return allComponents.map((comp: any) => ({
id: comp.id,
componentType: comp.componentType || comp.type,
label: comp.label || comp.name || comp.id,
tableName: comp.componentConfig?.tableName || comp.tableName,
columnName: comp.columnName || comp.componentConfig?.columnName || comp.componentConfig?.fieldName,
}));
}, [allComponents]);
if (loading) {
return (
<div className="rounded-md border border-dashed border-input bg-muted p-4 w-full">
<div className="flex items-center gap-2 text-muted-foreground">
<span className="text-sm font-medium"> ...</span>
<span className="text-sm font-medium"> ...</span>
</div>
<p className="mt-1 text-xs text-muted-foreground"> .</p>
</div>
@ -408,7 +323,7 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
return (
<div className="rounded-md border border-dashed border-destructive/30 bg-destructive/10 p-4 w-full">
<div className="flex items-center gap-2 text-destructive">
<span className="text-sm font-medium"> </span>
<span className="text-sm font-medium"> </span>
</div>
<p className="mt-1 text-xs text-destructive"> : {error}</p>
</div>
@ -416,31 +331,26 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
}
if (!ConfigPanelComponent) {
console.warn(`⚠️ DynamicComponentConfigPanel: ${componentId} ConfigPanelComponent가 null`);
return (
<div className="rounded-md border border-dashed border-amber-300 bg-amber-50 p-4 w-full">
<div className="flex items-center gap-2 text-amber-600">
<span className="text-sm font-medium"> </span>
<span className="text-sm font-medium"> </span>
</div>
<p className="mt-1 text-xs text-amber-500"> "{componentId}" .</p>
<p className="mt-1 text-xs text-amber-500"> &quot;{componentId}&quot; .</p>
</div>
);
}
// 테이블 변경 핸들러 - 선택된 테이블의 컬럼을 동적으로 로드
// 테이블 변경 핸들러
const handleTableChange = async (tableName: string) => {
try {
// 먼저 tables에서 찾아보기 (이미 컬럼이 있는 경우)
const existingTable = tables?.find((t) => t.tableName === tableName);
if (existingTable && existingTable.columns && existingTable.columns.length > 0) {
if (existingTable?.columns?.length > 0) {
setSelectedTableColumns(existingTable.columns);
return;
}
// 컬럼이 없으면 tableTypeApi로 조회 (ScreenDesigner와 동일한 방식)
const { tableTypeApi } = await import("@/lib/api/screen");
const columnsResponse = await tableTypeApi.getColumns(tableName);
const columns = (columnsResponse || []).map((col: any) => ({
tableName: col.tableName || tableName,
columnName: col.columnName || col.column_name,
@ -456,160 +366,97 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
codeCategory: col.codeCategory || col.code_category,
codeValue: col.codeValue || col.code_value,
}));
setSelectedTableColumns(columns);
} catch (error) {
console.error("❌ 테이블 변경 오류:", error);
// 오류 발생 시 빈 배열
} catch (_) {
setSelectedTableColumns([]);
}
};
// 🆕 원본 테이블 컬럼 로드 핸들러 (selected-items-detail-input용)
const handleSourceTableChange = async (tableName: string) => {
console.log("🔄 원본 테이블 변경:", tableName);
try {
const { tableTypeApi } = await import("@/lib/api/screen");
const columnsResponse = await tableTypeApi.getColumns(tableName);
const columns = (columnsResponse || []).map((col: any) => ({
columnName: col.columnName || col.column_name,
columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
dataType: col.dataType || col.data_type || col.dbType,
inputType: col.inputType || col.input_type, // 🆕 inputType 추가
inputType: col.inputType || col.input_type,
}));
console.log("✅ 원본 테이블 컬럼 로드 완료:", columns.length);
setSourceTableColumns(columns);
} catch (error) {
console.error("❌ 원본 테이블 컬럼 로드 실패:", error);
} catch (_) {
setSourceTableColumns([]);
}
};
// 🆕 대상 테이블 컬럼 로드 핸들러 (selected-items-detail-input용)
const handleTargetTableChange = async (tableName: string) => {
console.log("🔄 대상 테이블 변경:", tableName);
try {
const { tableTypeApi } = await import("@/lib/api/screen");
const columnsResponse = await tableTypeApi.getColumns(tableName);
console.log("📡 [handleTargetTableChange] API 응답 (원본):", {
totalColumns: columnsResponse.length,
sampleColumns: columnsResponse.slice(0, 3),
currency_code_raw: columnsResponse.find((c: any) => (c.columnName || c.column_name) === 'currency_code')
});
const columns = (columnsResponse || []).map((col: any) => ({
columnName: col.columnName || col.column_name,
columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
dataType: col.dataType || col.data_type || col.dbType,
inputType: col.inputType || col.input_type, // 🆕 inputType 추가
codeCategory: col.codeCategory || col.code_category, // 🆕 codeCategory 추가
inputType: col.inputType || col.input_type,
codeCategory: col.codeCategory || col.code_category,
}));
console.log("✅ 대상 테이블 컬럼 변환 완료:", {
tableName,
totalColumns: columns.length,
currency_code: columns.find((c: any) => c.columnName === "currency_code"),
discount_rate: columns.find((c: any) => c.columnName === "discount_rate")
});
setTargetTableColumns(columns);
} catch (error) {
console.error("❌ 대상 테이블 컬럼 로드 실패:", error);
} catch (_) {
setTargetTableColumns([]);
}
};
// 🆕 수주 등록 관련 컴포넌트들은 간단한 인터페이스 사용
const isSimpleConfigPanel = [
"autocomplete-search-input",
"modal-repeater-table",
"conditional-container",
].includes(componentId);
// --- 특수 래퍼: 레거시 위젯 (component/onUpdateProperty props) ---
const LEGACY_PANELS = new Set([
"card", "dashboard", "stats", "stats-card",
"progress", "progress-bar", "chart", "chart-basic",
"alert", "alert-info", "badge", "badge-status",
]);
if (isSimpleConfigPanel) {
return <ConfigPanelComponent config={config} onConfigChange={onChange} />;
}
// 🆕 V2 컴포넌트들은 전용 props 사용
if (componentId.startsWith("v2-")) {
return (
<ConfigPanelComponent
config={config}
onChange={onChange}
menuObjid={menuObjid}
inputType={currentComponent?.inputType || config?.inputType}
screenTableName={screenTableName}
tableColumns={selectedTableColumns}
/>
);
}
// entity-search-input은 currentComponent 정보 필요 (참조 테이블 자동 로드용)
// 그리고 allComponents 필요 (연쇄관계 부모 필드 선택용)
if (componentId === "entity-search-input") {
return (
<ConfigPanelComponent
config={config}
onConfigChange={onChange}
currentComponent={currentComponent}
allComponents={allComponents}
/>
);
}
// 🆕 selected-items-detail-input은 특별한 props 사용
if (componentId === "selected-items-detail-input") {
if (LEGACY_PANELS.has(componentId)) {
const pseudoComponent = {
id: currentComponent?.id || "temp",
type: "component",
componentConfig: config,
...currentComponent,
};
return (
<ConfigPanelComponent
config={config}
onChange={onChange}
sourceTableColumns={sourceTableColumns} // 🆕 원본 테이블 컬럼
targetTableColumns={targetTableColumns} // 🆕 대상 테이블 컬럼
allTables={allTablesList.length > 0 ? allTablesList : tables} // 전체 테이블 목록 (동적 로드 or 전달된 목록)
screenTableName={screenTableName} // 🆕 현재 화면의 테이블명 (자동 설정용)
onSourceTableChange={handleSourceTableChange} // 🆕 원본 테이블 변경 핸들러
onTargetTableChange={handleTargetTableChange} // 🆕 대상 테이블 변경 핸들러
component={pseudoComponent}
onUpdateProperty={(path: string, value: any) => {
if (path.startsWith("componentConfig.")) {
const key = path.replace("componentConfig.", "");
onChange({ ...config, [key]: value });
}
}}
/>
);
}
// 🆕 ButtonConfigPanel은 component와 onUpdateProperty를 사용
// --- 특수 래퍼: ButtonConfigPanel (component/onUpdateProperty props) ---
if (componentId === "button-primary" || componentId === "v2-button-primary") {
// currentComponent가 있으면 그것을 사용, 없으면 config에서 component 구조 생성
const componentForButton = currentComponent || {
id: "temp",
type: "component",
componentType: componentId,
componentConfig: config,
};
return (
<ConfigPanelComponent
component={componentForButton}
onUpdateProperty={(path: string, value: any) => {
// path가 componentConfig로 시작하면 내부 경로 추출
if (path.startsWith("componentConfig.")) {
const configPath = path.replace("componentConfig.", "");
const pathParts = configPath.split(".");
// 중첩된 경로 처리 - 현재 config를 기반으로 새 config 생성
const currentConfig = componentForButton.componentConfig || {};
const newConfig = JSON.parse(JSON.stringify(currentConfig)); // deep clone
const newConfig = JSON.parse(JSON.stringify(currentConfig));
let current: any = newConfig;
for (let i = 0; i < pathParts.length - 1; i++) {
if (!current[pathParts[i]]) {
current[pathParts[i]] = {};
}
if (!current[pathParts[i]]) current[pathParts[i]] = {};
current = current[pathParts[i]];
}
current[pathParts[pathParts.length - 1]] = value;
onChange(newConfig);
} else {
// 직접 config 속성 변경
const currentConfig = componentForButton.componentConfig || {};
onChange({ ...currentConfig, [path]: value });
}
@ -620,20 +467,41 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
);
}
// --- 통일된 props: 모든 일반 패널에 동일한 props 전달 ---
const context: ConfigPanelContext = {
tables,
tableColumns: selectedTableColumns,
screenTableName,
menuObjid,
allComponents,
currentComponent,
allTables: allTablesList.length > 0 ? allTablesList : tables,
screenComponents,
};
return (
<ConfigPanelComponent
config={config}
onChange={onChange}
onConfigChange={onChange} // TableListConfigPanel을 위한 추가 prop
onConfigChange={onChange}
context={context}
screenTableName={screenTableName}
tableColumns={selectedTableColumns} // 동적으로 변경되는 컬럼 전달
tables={tables} // 기본 테이블 목록 (현재 화면의 테이블만)
allTables={componentId === "repeater-field-group" ? allTablesList : tables} // RepeaterConfigPanel만 전체 테이블
onTableChange={handleTableChange} // 테이블 변경 핸들러 전달
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
allComponents={allComponents} // 🆕 현재 화면의 모든 컴포넌트 (연쇄 드롭다운 부모 감지용)
currentComponent={currentComponent} // 🆕 현재 컴포넌트 정보
screenComponents={screenComponents} // 🆕 집계 위젯 등에서 사용하는 컴포넌트 목록
tableName={screenTableName}
columnName={currentComponent?.columnName || config?.columnName || config?.fieldName}
tableColumns={selectedTableColumns}
tables={tables}
allTables={allTablesList.length > 0 ? allTablesList : tables}
onTableChange={handleTableChange}
menuObjid={menuObjid}
allComponents={allComponents}
currentComponent={currentComponent}
screenComponents={screenComponents}
inputType={currentComponent?.inputType || config?.inputType}
componentType={componentType || componentId}
sourceTableColumns={sourceTableColumns}
targetTableColumns={targetTableColumns}
onSourceTableChange={handleSourceTableChange}
onTargetTableChange={handleTargetTableChange}
/>
);
};

View File

@ -0,0 +1,389 @@
import { chromium, Page, Browser } from "playwright";
import * as fs from "fs";
import * as path from "path";
const BASE_URL = "http://localhost:9771";
const API_URL = "http://localhost:8080/api";
const OUTPUT_DIR = path.join(__dirname);
interface ComponentTestResult {
componentType: string;
screenId: number;
status: "pass" | "fail" | "no_panel" | "not_found" | "error";
errorMessage?: string;
consoleErrors: string[];
hasConfigPanel: boolean;
screenshot?: string;
}
const COMPONENT_SCREEN_MAP: Record<string, number> = {
"v2-input": 60,
"v2-select": 71,
"v2-date": 77,
"v2-button-primary": 47,
"v2-text-display": 114,
"v2-table-list": 47,
"v2-table-search-widget": 79,
"v2-media": 74,
"v2-split-panel-layout": 74,
"v2-tabs-widget": 1011,
"v2-section-card": 1188,
"v2-section-paper": 202,
"v2-card-display": 83,
"v2-numbering-rule": 130,
"v2-repeater": 1188,
"v2-divider-line": 1195,
"v2-location-swap-selector": 1195,
"v2-category-manager": 135,
"v2-file-upload": 138,
"v2-pivot-grid": 2327,
"v2-rack-structure": 1575,
"v2-repeat-container": 2403,
"v2-split-line": 4151,
"v2-bom-item-editor": 4154,
"v2-process-work-standard": 4158,
"v2-aggregation-widget": 4119,
"flow-widget": 77,
"entity-search-input": 3986,
"select-basic": 4470,
"textarea-basic": 3986,
"selected-items-detail-input": 227,
"screen-split-panel": 1674,
"split-panel-layout2": 2089,
"universal-form-modal": 2180,
"v2-table-grouped": 79,
"v2-status-count": 4498,
"v2-timeline-scheduler": 79,
};
async function login(page: Page): Promise<string> {
await page.goto(`${BASE_URL}/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(5000);
const token = await page.evaluate(() => localStorage.getItem("authToken") || "");
console.log("Login token obtained:", token ? "YES" : "NO");
return token;
}
async function openDesigner(page: Page, screenId: number): Promise<boolean> {
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,
})
);
});
await page.goto(`${BASE_URL}/admin/screenMng/screenMngList?openDesigner=${screenId}`);
await page.waitForTimeout(8000);
const designerOpen = await page.locator('[class*="designer"], [class*="canvas"], [data-testid*="designer"]').count();
const hasComponents = await page.locator('[data-component-id], [class*="component-wrapper"]').count();
console.log(` Designer elements: ${designerOpen}, Components: ${hasComponents}`);
return designerOpen > 0 || hasComponents > 0;
}
async function testComponentConfigPanel(
page: Page,
componentType: string,
screenId: number
): Promise<ComponentTestResult> {
const result: ComponentTestResult = {
componentType,
screenId,
status: "error",
consoleErrors: [],
hasConfigPanel: false,
};
const consoleErrors: string[] = [];
const pageErrors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") {
consoleErrors.push(msg.text());
}
});
page.on("pageerror", (err) => {
pageErrors.push(err.message);
});
try {
const opened = await openDesigner(page, screenId);
if (!opened) {
result.status = "error";
result.errorMessage = "Designer failed to open";
return result;
}
// find component by its url or type attribute in the DOM
const componentUrl = componentType.startsWith("v2-")
? `@/lib/registry/components/${componentType}`
: `@/lib/registry/components/${componentType}`;
// Try clicking on a component of this type
// The screen designer renders components with data attributes
const componentSelector = `[data-component-type="${componentType}"], [data-component-url*="${componentType}"]`;
const componentCount = await page.locator(componentSelector).count();
if (componentCount === 0) {
// Try alternative: look for components in the canvas by clicking around
// First try to find any clickable component wrapper
const wrappers = page.locator('[data-component-id]');
const wrapperCount = await wrappers.count();
if (wrapperCount === 0) {
result.status = "not_found";
result.errorMessage = `No components found in screen ${screenId}`;
return result;
}
// Click the first component to see if panel opens
let foundTarget = false;
for (let i = 0; i < Math.min(wrapperCount, 20); i++) {
try {
await wrappers.nth(i).click({ force: true, timeout: 2000 });
await page.waitForTimeout(1000);
// Check if the properties panel shows the right component type
const panelText = await page.locator('[class*="properties"], [class*="config-panel"], [class*="setting"]').textContent().catch(() => "");
if (panelText && panelText.includes(componentType)) {
foundTarget = true;
break;
}
} catch {
continue;
}
}
if (!foundTarget) {
result.status = "not_found";
result.errorMessage = `Component type "${componentType}" not clickable in screen ${screenId}`;
}
} else {
await page.locator(componentSelector).first().click({ force: true });
await page.waitForTimeout(2000);
}
// Check for the config panel in the right sidebar
await page.waitForTimeout(2000);
// Look for config panel indicators
const configPanelVisible = await page.evaluate(() => {
// Check for error boundaries or error messages
const errorElements = document.querySelectorAll('[class*="error"], [class*="Error"]');
const errorTexts: string[] = [];
errorElements.forEach((el) => {
const text = el.textContent || "";
if (text.includes("로드 실패") || text.includes("에러") || text.includes("Error") || text.includes("Cannot read")) {
errorTexts.push(text.substring(0, 200));
}
});
// Check for config panel elements
const hasTabs = document.querySelectorAll('button[role="tab"]').length > 0;
const hasLabels = document.querySelectorAll("label").length > 0;
const hasInputs = document.querySelectorAll('input, select, [role="combobox"]').length > 0;
const hasConfigContent = document.querySelectorAll('[class*="config"], [class*="panel"], [class*="properties"]').length > 0;
const hasEditTab = Array.from(document.querySelectorAll("button")).some((b) => b.textContent?.includes("편집"));
return {
errorTexts,
hasTabs,
hasLabels,
hasInputs,
hasConfigContent,
hasEditTab,
};
});
result.hasConfigPanel = configPanelVisible.hasConfigContent || configPanelVisible.hasEditTab;
// Take screenshot
const screenshotName = `${componentType.replace(/[^a-zA-Z0-9-]/g, "_")}.png`;
const screenshotPath = path.join(OUTPUT_DIR, screenshotName);
await page.screenshot({ path: screenshotPath, fullPage: false });
result.screenshot = screenshotName;
// Collect errors
result.consoleErrors = [...consoleErrors, ...pageErrors];
if (configPanelVisible.errorTexts.length > 0) {
result.status = "fail";
result.errorMessage = configPanelVisible.errorTexts.join("; ");
} else if (pageErrors.length > 0) {
result.status = "fail";
result.errorMessage = pageErrors.join("; ");
} else if (consoleErrors.some((e) => e.includes("Cannot read") || e.includes("is not a function") || e.includes("undefined"))) {
result.status = "fail";
result.errorMessage = consoleErrors.filter((e) => e.includes("Cannot read") || e.includes("is not a function")).join("; ");
} else {
result.status = "pass";
}
} catch (err: any) {
result.status = "error";
result.errorMessage = err.message;
}
return result;
}
async function main() {
console.log("=== Config Panel Full Audit ===");
console.log(`Testing ${Object.keys(COMPONENT_SCREEN_MAP).length} component types\n`);
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
const page = await context.newPage();
// Login
const token = await login(page);
if (!token) {
console.error("Login failed!");
await browser.close();
return;
}
const results: ComponentTestResult[] = [];
const componentTypes = Object.keys(COMPONENT_SCREEN_MAP);
for (let i = 0; i < componentTypes.length; i++) {
const componentType = componentTypes[i];
const screenId = COMPONENT_SCREEN_MAP[componentType];
console.log(`\n[${i + 1}/${componentTypes.length}] Testing: ${componentType} (screen: ${screenId})`);
const result = await testComponentConfigPanel(page, componentType, screenId);
results.push(result);
const statusEmoji = {
pass: "OK",
fail: "FAIL",
no_panel: "NO_PANEL",
not_found: "NOT_FOUND",
error: "ERROR",
}[result.status];
console.log(` Result: ${statusEmoji} ${result.errorMessage || ""}`);
// Clear console listeners for next iteration
page.removeAllListeners("console");
page.removeAllListeners("pageerror");
}
await browser.close();
// Write results
const reportPath = path.join(OUTPUT_DIR, "audit-results.json");
fs.writeFileSync(reportPath, JSON.stringify(results, null, 2));
// Summary
console.log("\n\n=== AUDIT SUMMARY ===");
const passed = results.filter((r) => r.status === "pass");
const failed = results.filter((r) => r.status === "fail");
const errors = results.filter((r) => r.status === "error");
const notFound = results.filter((r) => r.status === "not_found");
console.log(`PASS: ${passed.length}`);
console.log(`FAIL: ${failed.length}`);
console.log(`ERROR: ${errors.length}`);
console.log(`NOT_FOUND: ${notFound.length}`);
if (failed.length > 0) {
console.log("\n--- FAILED Components ---");
failed.forEach((r) => {
console.log(` ${r.componentType} (screen: ${r.screenId}): ${r.errorMessage}`);
});
}
if (errors.length > 0) {
console.log("\n--- ERROR Components ---");
errors.forEach((r) => {
console.log(` ${r.componentType} (screen: ${r.screenId}): ${r.errorMessage}`);
});
}
// Write markdown report
const mdReport = generateMarkdownReport(results);
fs.writeFileSync(path.join(OUTPUT_DIR, "audit-report.md"), mdReport);
console.log(`\nReport saved to ${OUTPUT_DIR}/audit-report.md`);
}
function generateMarkdownReport(results: ComponentTestResult[]): string {
const lines: string[] = [];
lines.push("# Config Panel Audit Report");
lines.push(`\nDate: ${new Date().toISOString()}`);
lines.push(`\nTotal: ${results.length} components tested\n`);
const passed = results.filter((r) => r.status === "pass");
const failed = results.filter((r) => r.status === "fail");
const errors = results.filter((r) => r.status === "error");
const notFound = results.filter((r) => r.status === "not_found");
lines.push(`| Status | Count |`);
lines.push(`|--------|-------|`);
lines.push(`| PASS | ${passed.length} |`);
lines.push(`| FAIL | ${failed.length} |`);
lines.push(`| ERROR | ${errors.length} |`);
lines.push(`| NOT_FOUND | ${notFound.length} |`);
lines.push(`\n## Failed Components\n`);
if (failed.length === 0) {
lines.push("None\n");
} else {
lines.push(`| Component | Screen ID | Error |`);
lines.push(`|-----------|-----------|-------|`);
failed.forEach((r) => {
lines.push(`| ${r.componentType} | ${r.screenId} | ${(r.errorMessage || "").substring(0, 100)} |`);
});
}
lines.push(`\n## Error Components\n`);
if (errors.length === 0) {
lines.push("None\n");
} else {
lines.push(`| Component | Screen ID | Error |`);
lines.push(`|-----------|-----------|-------|`);
errors.forEach((r) => {
lines.push(`| ${r.componentType} | ${r.screenId} | ${(r.errorMessage || "").substring(0, 100)} |`);
});
}
lines.push(`\n## Not Found Components\n`);
if (notFound.length === 0) {
lines.push("None\n");
} else {
notFound.forEach((r) => {
lines.push(`- ${r.componentType} (screen: ${r.screenId}): ${r.errorMessage}`);
});
}
lines.push(`\n## All Results\n`);
lines.push(`| # | Component | Screen | Status | Config Panel | Error |`);
lines.push(`|---|-----------|--------|--------|--------------|-------|`);
results.forEach((r, i) => {
const status = r.status.toUpperCase();
lines.push(
`| ${i + 1} | ${r.componentType} | ${r.screenId} | ${status} | ${r.hasConfigPanel ? "Yes" : "No"} | ${(r.errorMessage || "-").substring(0, 80)} |`
);
});
return lines.join("\n");
}
main().catch(console.error);

View File

@ -0,0 +1,139 @@
import { chromium } from "playwright";
import * as fs from "fs";
import * as path from "path";
const BASE = "http://localhost:9771";
const OUT = path.join(__dirname);
async function main() {
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
const page = await ctx.newPage();
const jsErrors: string[] = [];
page.on("pageerror", (e) => jsErrors.push(e.message));
page.on("console", (msg) => {
if (msg.type() === "error") jsErrors.push("[console.error] " + msg.text().substring(0, 200));
});
// 1) Login
console.log("1) Logging in...");
await page.goto(`${BASE}/login`);
await page.waitForLoadState("networkidle");
await page.waitForTimeout(3000);
await page.fill('[placeholder="사용자 ID를 입력하세요"]', "wace");
await page.fill('[placeholder="비밀번호를 입력하세요"]', "qlalfqjsgh11");
await page.click('button:has-text("로그인")');
await page.waitForTimeout(6000);
await page.screenshot({ path: path.join(OUT, "01-after-login.png") });
console.log(" URL after login:", page.url());
// 2) Open designer for screen 60 (has v2-input)
console.log("\n2) Opening designer for screen 60...");
await page.evaluate(() => {
sessionStorage.setItem("erp-tab-store", JSON.stringify({
state: { tabs: [{ id: "t", title: "화면관리", path: "/admin/screenMng/screenMngList", isActive: true, isPinned: false }], activeTabId: "t" },
version: 0,
}));
});
await page.goto(`${BASE}/admin/screenMng/screenMngList?openDesigner=60`, { waitUntil: "domcontentloaded", timeout: 60000 });
await page.waitForTimeout(12000);
await page.screenshot({ path: path.join(OUT, "02-designer-loaded.png") });
// 3) Check DOM for components
const domInfo = await page.evaluate(() => {
const compWrappers = document.querySelectorAll("[data-component-id]");
const ids = Array.from(compWrappers).map(el => el.getAttribute("data-component-id"));
const bodyText = document.body.innerText.substring(0, 500);
const hasCanvas = !!document.querySelector('[class*="canvas"], [class*="designer-content"]');
const allClasses = Array.from(document.querySelectorAll("[class]"))
.map(el => el.className)
.filter(c => typeof c === "string" && (c.includes("canvas") || c.includes("designer") || c.includes("panel")))
.slice(0, 20);
return { componentIds: ids, hasCanvas, bodyTextPreview: bodyText, relevantClasses: allClasses };
});
console.log(" Components in DOM:", domInfo.componentIds.length);
console.log(" Component IDs:", domInfo.componentIds.slice(0, 10));
console.log(" Has canvas:", domInfo.hasCanvas);
console.log(" Relevant classes:", domInfo.relevantClasses.slice(0, 5));
// 4) If components found, click the first one
if (domInfo.componentIds.length > 0) {
const firstId = domInfo.componentIds[0];
console.log(`\n3) Clicking component: ${firstId}`);
try {
await page.click(`[data-component-id="${firstId}"]`, { force: true, timeout: 5000 });
await page.waitForTimeout(3000);
await page.screenshot({ path: path.join(OUT, "03-component-clicked.png") });
// Check right panel
const panelInfo = await page.evaluate(() => {
const labels = document.querySelectorAll("label");
const inputs = document.querySelectorAll("input");
const selects = document.querySelectorAll('[role="combobox"]');
const tabs = document.querySelectorAll('[role="tab"]');
const switches = document.querySelectorAll('[role="switch"]');
const rightPanel = document.querySelector('[class*="right"], [class*="sidebar"], [class*="properties"]');
return {
labels: labels.length,
inputs: inputs.length,
selects: selects.length,
tabs: tabs.length,
switches: switches.length,
hasRightPanel: !!rightPanel,
rightPanelText: rightPanel?.textContent?.substring(0, 300) || "(no panel found)",
errorTexts: Array.from(document.querySelectorAll('[class*="error"], [class*="destructive"]'))
.map(el => (el as HTMLElement).innerText?.substring(0, 100))
.filter(t => t && t.length > 0),
};
});
console.log(" Panel info:", JSON.stringify(panelInfo, null, 2));
} catch (e: any) {
console.log(" Click failed:", e.message.substring(0, 100));
}
} else {
console.log("\n No components found in DOM - designer may not have loaded");
console.log(" Body preview:", domInfo.bodyTextPreview.substring(0, 200));
}
// 5) Try clicking on different areas of the page to find components
if (domInfo.componentIds.length === 0) {
console.log("\n4) Trying to find canvas area by clicking...");
// Take full page screenshot to see what's there
await page.screenshot({ path: path.join(OUT, "04-full-page.png"), fullPage: true });
// Get viewport-based content
const pageStructure = await page.evaluate(() => {
const allElements = document.querySelectorAll("div, section, main, aside");
const structure: string[] = [];
allElements.forEach(el => {
const rect = el.getBoundingClientRect();
if (rect.width > 100 && rect.height > 100) {
const cls = el.className?.toString().substring(0, 50) || "";
const id = el.id || "";
structure.push(`${el.tagName}#${id}.${cls} [${Math.round(rect.x)},${Math.round(rect.y)} ${Math.round(rect.width)}x${Math.round(rect.height)}]`);
}
});
return structure.slice(0, 30);
});
console.log(" Large elements:");
pageStructure.forEach(s => console.log(" " + s));
}
// Summary
console.log("\n\n=== SUMMARY ===");
console.log(`JS Errors: ${jsErrors.length}`);
if (jsErrors.length > 0) {
const unique = [...new Set(jsErrors)];
console.log("Unique errors:");
unique.slice(0, 20).forEach(e => console.log(" " + e.substring(0, 200)));
}
await browser.close();
console.log("\nScreenshots saved to:", OUT);
}
main().catch(console.error);

View File

@ -0,0 +1,308 @@
[
{
"type": "v2-input",
"screenId": 60,
"compId": "",
"status": "NO_COMPONENT",
"jsErrors": [],
"panelDetails": "",
"errorMsg": "Component not found in layout API data"
},
{
"type": "v2-select",
"screenId": 71,
"compId": "",
"status": "NO_COMPONENT",
"jsErrors": [],
"panelDetails": "",
"errorMsg": "Component not found in layout API data"
},
{
"type": "v2-date",
"screenId": 77,
"compId": "",
"status": "NO_COMPONENT",
"jsErrors": [],
"panelDetails": "",
"errorMsg": "Component not found in layout API data"
},
{
"type": "flow-widget",
"screenId": 77,
"compId": "",
"status": "NO_COMPONENT",
"jsErrors": [],
"panelDetails": "",
"errorMsg": "Component not found in layout API data"
},
{
"type": "v2-button-primary",
"screenId": 50,
"compId": "",
"status": "NO_COMPONENT",
"jsErrors": [],
"panelDetails": "",
"errorMsg": "Component not found in layout API data"
},
{
"type": "v2-text-display",
"screenId": 114,
"compId": "",
"status": "NO_COMPONENT",
"jsErrors": [],
"panelDetails": "",
"errorMsg": "Component not found in layout API data"
},
{
"type": "v2-table-list",
"screenId": 68,
"compId": "",
"status": "NO_COMPONENT",
"jsErrors": [],
"panelDetails": "",
"errorMsg": "Component not found in layout API data"
},
{
"type": "v2-table-search-widget",
"screenId": 79,
"compId": "",
"status": "NO_COMPONENT",
"jsErrors": [],
"panelDetails": "",
"errorMsg": "Component not found in layout API data"
},
{
"type": "v2-media",
"screenId": 74,
"compId": "",
"status": "NO_COMPONENT",
"jsErrors": [],
"panelDetails": "",
"errorMsg": "Component not found in layout API data"
},
{
"type": "v2-split-panel-layout",
"screenId": 74,
"compId": "",
"status": "NO_COMPONENT",
"jsErrors": [],
"panelDetails": "",
"errorMsg": "Component not found in layout API data"
},
{
"type": "v2-tabs-widget",
"screenId": 1011,
"compId": "",
"status": "NO_COMPONENT",
"jsErrors": [],
"panelDetails": "",
"errorMsg": "Component not found in layout API data"
},
{
"type": "v2-section-card",
"screenId": 1188,
"compId": "",
"status": "NO_COMPONENT",
"jsErrors": [],
"panelDetails": "",
"errorMsg": "Component not found in layout API data"
},
{
"type": "v2-repeater",
"screenId": 1188,
"compId": "",
"status": "NO_COMPONENT",
"jsErrors": [],
"panelDetails": "",
"errorMsg": "Component not found in layout API data"
},
{
"type": "v2-section-paper",
"screenId": 202,
"compId": "",
"status": "NO_COMPONENT",
"jsErrors": [],
"panelDetails": "",
"errorMsg": "Component not found in layout API data"
},
{
"type": "v2-card-display",
"screenId": 83,
"compId": "",
"status": "NO_COMPONENT",
"jsErrors": [],
"panelDetails": "",
"errorMsg": "Component not found in layout API data"
},
{
"type": "v2-numbering-rule",
"screenId": 130,
"compId": "",
"status": "NO_COMPONENT",
"jsErrors": [],
"panelDetails": "",
"errorMsg": "Component not found in layout API data"
},
{
"type": "v2-divider-line",
"screenId": 1195,
"compId": "",
"status": "NO_COMPONENT",
"jsErrors": [],
"panelDetails": "",
"errorMsg": "Component not found in layout API data"
},
{
"type": "v2-location-swap-selector",
"screenId": 1195,
"compId": "",
"status": "NO_COMPONENT",
"jsErrors": [],
"panelDetails": "",
"errorMsg": "Component not found in layout API data"
},
{
"type": "v2-category-manager",
"screenId": 135,
"compId": "",
"status": "NO_COMPONENT",
"jsErrors": [],
"panelDetails": "",
"errorMsg": "Component not found in layout API data"
},
{
"type": "v2-file-upload",
"screenId": 138,
"compId": "",
"status": "NO_COMPONENT",
"jsErrors": [],
"panelDetails": "",
"errorMsg": "Component not found in layout API data"
},
{
"type": "v2-pivot-grid",
"screenId": 2327,
"compId": "",
"status": "NO_COMPONENT",
"jsErrors": [],
"panelDetails": "",
"errorMsg": "Component not found in layout API data"
},
{
"type": "v2-rack-structure",
"screenId": 1575,
"compId": "",
"status": "NO_COMPONENT",
"jsErrors": [],
"panelDetails": "",
"errorMsg": "Component not found in layout API data"
},
{
"type": "v2-repeat-container",
"screenId": 2403,
"compId": "",
"status": "NO_COMPONENT",
"jsErrors": [],
"panelDetails": "",
"errorMsg": "Component not found in layout API data"
},
{
"type": "v2-split-line",
"screenId": 4151,
"compId": "",
"status": "NO_COMPONENT",
"jsErrors": [],
"panelDetails": "",
"errorMsg": "Component not found in layout API data"
},
{
"type": "v2-bom-item-editor",
"screenId": 4154,
"compId": "",
"status": "NO_COMPONENT",
"jsErrors": [],
"panelDetails": "",
"errorMsg": "Component not found in layout API data"
},
{
"type": "v2-process-work-standard",
"screenId": 4158,
"compId": "",
"status": "NO_COMPONENT",
"jsErrors": [],
"panelDetails": "",
"errorMsg": "Component not found in layout API data"
},
{
"type": "v2-aggregation-widget",
"screenId": 4119,
"compId": "",
"status": "NO_COMPONENT",
"jsErrors": [],
"panelDetails": "",
"errorMsg": "Component not found in layout API data"
},
{
"type": "entity-search-input",
"screenId": 3986,
"compId": "",
"status": "NO_COMPONENT",
"jsErrors": [],
"panelDetails": "",
"errorMsg": "Component not found in layout API data"
},
{
"type": "textarea-basic",
"screenId": 3986,
"compId": "",
"status": "NO_COMPONENT",
"jsErrors": [],
"panelDetails": "",
"errorMsg": "Component not found in layout API data"
},
{
"type": "select-basic",
"screenId": 4470,
"compId": "",
"status": "NO_COMPONENT",
"jsErrors": [],
"panelDetails": "",
"errorMsg": "Component not found in layout API data"
},
{
"type": "selected-items-detail-input",
"screenId": 227,
"compId": "",
"status": "NO_COMPONENT",
"jsErrors": [],
"panelDetails": "",
"errorMsg": "Component not found in layout API data"
},
{
"type": "screen-split-panel",
"screenId": 1674,
"compId": "",
"status": "NO_COMPONENT",
"jsErrors": [],
"panelDetails": "",
"errorMsg": "Component not found in layout API data"
},
{
"type": "split-panel-layout2",
"screenId": 2089,
"compId": "",
"status": "NO_COMPONENT",
"jsErrors": [],
"panelDetails": "",
"errorMsg": "Component not found in layout API data"
},
{
"type": "universal-form-modal",
"screenId": 2180,
"compId": "",
"status": "NO_COMPONENT",
"jsErrors": [],
"panelDetails": "",
"errorMsg": "Component not found in layout API data"
}
]

View File

@ -0,0 +1,287 @@
import { chromium, Page } from "playwright";
import * as fs from "fs";
import * as path from "path";
const BASE = "http://localhost:9771";
const OUT = path.join(__dirname);
const TARGETS: [string, number][] = [
["v2-input", 60],
["v2-select", 71],
["v2-date", 77],
["v2-button-primary", 50],
["v2-text-display", 114],
["v2-table-list", 68],
["v2-table-search-widget", 79],
["v2-media", 74],
["v2-split-panel-layout", 74],
["v2-tabs-widget", 1011],
["v2-section-card", 1188],
["v2-section-paper", 202],
["v2-card-display", 83],
["v2-numbering-rule", 130],
["v2-repeater", 1188],
["v2-divider-line", 1195],
["v2-location-swap-selector", 1195],
["v2-category-manager", 135],
["v2-file-upload", 138],
["v2-pivot-grid", 2327],
["v2-rack-structure", 1575],
["v2-repeat-container", 2403],
["v2-split-line", 4151],
["v2-bom-item-editor", 4154],
["v2-process-work-standard", 4158],
["v2-aggregation-widget", 4119],
["flow-widget", 77],
["entity-search-input", 3986],
["select-basic", 4470],
["textarea-basic", 3986],
["selected-items-detail-input", 227],
["screen-split-panel", 1674],
["split-panel-layout2", 2089],
["universal-form-modal", 2180],
];
interface Result {
type: string;
screenId: number;
compId: string;
status: "PASS" | "FAIL" | "ERROR" | "NO_COMPONENT";
jsErrors: string[];
panelDetails: string;
errorMsg?: string;
}
async function login(page: Page) {
await page.goto(`${BASE}/login`);
await page.waitForLoadState("networkidle");
await page.waitForTimeout(2000);
await page.fill('[placeholder="사용자 ID를 입력하세요"]', "wace");
await page.fill('[placeholder="비밀번호를 입력하세요"]', "qlalfqjsgh11");
await Promise.all([
page.waitForResponse((r) => r.url().includes("/auth/login"), { timeout: 15000 }).catch(() => null),
page.click('button:has-text("로그인")'),
]);
await page.waitForTimeout(5000);
const hasToken = await page.evaluate(() => !!localStorage.getItem("authToken"));
console.log("Login:", hasToken ? "OK" : "FAILED");
return hasToken;
}
async function getLayoutComponents(page: Page, screenId: number): Promise<any[]> {
return page.evaluate(async (sid) => {
const token = localStorage.getItem("authToken") || "";
const host = window.location.hostname;
const apiBase = host === "localhost" || host === "127.0.0.1"
? "http://localhost:8080/api"
: "/api";
try {
const resp = await fetch(`${apiBase}/screen-management/screens/${sid}/layout-v2`, {
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
});
const data = await resp.json();
if (data.success && data.data?.components) return data.data.components;
if (data.success && Array.isArray(data.data)) {
const all: any[] = [];
for (const layer of data.data) {
if (layer.layout_data?.components) all.push(...layer.layout_data.components);
}
return all;
}
return [];
} catch { return []; }
}, screenId);
}
function findCompId(components: any[], targetType: string): string {
for (const c of components) {
const url: string = c.url || "";
const ctype: string = c.componentType || "";
if (url.endsWith("/" + targetType) || ctype === targetType) return c.id;
}
return "";
}
async function openDesigner(page: Page, screenId: number) {
await page.evaluate(() => {
sessionStorage.setItem("erp-tab-store", JSON.stringify({
state: {
tabs: [{ id: "tab-sm", title: "화면 관리", path: "/admin/screenMng/screenMngList", isActive: true, isPinned: false }],
activeTabId: "tab-sm",
},
version: 0,
}));
});
await page.goto(`${BASE}/admin/screenMng/screenMngList?openDesigner=${screenId}`, {
timeout: 60000, waitUntil: "domcontentloaded",
});
await page.waitForTimeout(10000);
}
async function checkPanel(page: Page): Promise<{ visible: boolean; hasError: boolean; detail: string }> {
return page.evaluate(() => {
const body = document.body.innerText || "";
const hasError = body.includes("로드 실패") || body.includes("Cannot read properties");
const labels = document.querySelectorAll("label").length;
const inputs = document.querySelectorAll('input:not([type="hidden"])').length;
const selects = document.querySelectorAll('select, [role="combobox"], [role="listbox"]').length;
const tabs = document.querySelectorAll('[role="tab"]').length;
const switches = document.querySelectorAll('[role="switch"]').length;
const total = labels + inputs + selects + tabs + switches;
const detail = `L=${labels} I=${inputs} S=${selects} T=${tabs} SW=${switches}`;
return { visible: total > 3, hasError, detail };
});
}
async function main() {
console.log("=== Config Panel Audit v3 ===\n");
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
const page = await ctx.newPage();
const jsErrors: string[] = [];
page.on("pageerror", (err) => jsErrors.push(err.message.substring(0, 300)));
const ok = await login(page);
if (!ok) { await browser.close(); return; }
const groups = new Map<number, string[]>();
for (const [type, sid] of TARGETS) {
if (!groups.has(sid)) groups.set(sid, []);
groups.get(sid)!.push(type);
}
const allResults: Result[] = [];
const entries = Array.from(groups.entries());
for (let i = 0; i < entries.length; i++) {
const [screenId, types] = entries[i];
console.log(`\n[${i + 1}/${entries.length}] Screen ${screenId}: ${types.join(", ")}`);
const components = await getLayoutComponents(page, screenId);
console.log(` API: ${components.length} comps`);
await openDesigner(page, screenId);
const domCompCount = await page.locator('[data-component-id]').count();
console.log(` DOM: ${domCompCount} comp wrappers`);
for (const targetType of types) {
const errIdx = jsErrors.length;
const compId = findCompId(components, targetType);
if (!compId) {
console.log(` ${targetType}: NO_COMPONENT`);
allResults.push({ type: targetType, screenId, compId: "", status: "NO_COMPONENT", jsErrors: [], panelDetails: "", errorMsg: "Not in layout" });
continue;
}
// 컴포넌트 클릭
let clicked = false;
const sel = `[data-component-id="${compId}"], #component-${compId}`;
const elCount = await page.locator(sel).count();
if (elCount > 0) {
try {
await page.locator(sel).first().click({ force: true, timeout: 5000 });
await page.waitForTimeout(2000);
clicked = true;
} catch {
try {
await page.locator(sel).first().dispatchEvent("click");
await page.waitForTimeout(2000);
clicked = true;
} catch {}
}
}
if (!clicked) {
// fallback: 캔버스에서 위치 기반 클릭 시도
const comp = components.find((c: any) => c.id === compId);
if (comp?.position) {
const canvasEl = await page.locator('[class*="canvas"], [class*="designer"]').first().boundingBox();
if (canvasEl) {
const x = canvasEl.x + (comp.position.x || 100);
const y = canvasEl.y + (comp.position.y || 100);
await page.mouse.click(x, y);
await page.waitForTimeout(2000);
clicked = true;
}
}
}
if (!clicked) {
console.log(` ${targetType}: ERROR (click fail, id=${compId})`);
allResults.push({ type: targetType, screenId, compId, status: "ERROR", jsErrors: jsErrors.slice(errIdx), panelDetails: "", errorMsg: "Cannot click component" });
continue;
}
const panel = await checkPanel(page);
const newErrors = jsErrors.slice(errIdx);
const critical = newErrors.find((e) =>
e.includes("Cannot read") || e.includes("is not a function") || e.includes("is not defined") || e.includes("Minified React")
);
let status: Result["status"];
let errorMsg: string | undefined;
if (panel.hasError || critical) {
status = "FAIL";
errorMsg = critical || "Panel error";
} else if (!panel.visible) {
status = "FAIL";
errorMsg = `Panel not visible (${panel.detail})`;
} else {
status = "PASS";
}
const icon = { PASS: "OK", FAIL: "FAIL", ERROR: "ERR", NO_COMPONENT: "??" }[status];
console.log(` ${targetType}: ${icon} [${panel.detail}]${errorMsg ? " " + errorMsg.substring(0, 80) : ""}`);
if (status === "FAIL") {
await page.screenshot({ path: path.join(OUT, `fail-${targetType.replace(/\//g, "_")}.png`) }).catch(() => {});
}
allResults.push({ type: targetType, screenId, compId, status, jsErrors: newErrors, panelDetails: panel.detail, errorMsg });
}
}
await browser.close();
fs.writeFileSync(path.join(OUT, "results.json"), JSON.stringify(allResults, null, 2));
console.log("\n\n=== AUDIT SUMMARY ===");
const p = allResults.filter((r) => r.status === "PASS");
const f = allResults.filter((r) => r.status === "FAIL");
const e = allResults.filter((r) => r.status === "ERROR");
const n = allResults.filter((r) => r.status === "NO_COMPONENT");
console.log(`Total: ${allResults.length}`);
console.log(`PASS: ${p.length}`);
console.log(`FAIL: ${f.length}`);
console.log(`ERROR: ${e.length}`);
console.log(`NO_COMPONENT: ${n.length}`);
if (f.length > 0) {
console.log("\n--- FAILED ---");
f.forEach((r) => console.log(` ${r.type} (screen ${r.screenId}): ${r.errorMsg}`));
}
if (e.length > 0) {
console.log("\n--- ERRORS ---");
e.forEach((r) => console.log(` ${r.type} (screen ${r.screenId}): ${r.errorMsg}`));
}
const unique = [...new Set(jsErrors)];
if (unique.length > 0) {
console.log(`\n--- JS Errors (${unique.length} unique) ---`);
unique.slice(0, 15).forEach((err) => console.log(` ${err.substring(0, 150)}`));
}
console.log("\nDone.");
}
main().catch(console.error);

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

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