jskim-node #418
|
|
@ -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
|
||||
|
|
@ -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으로만 구성
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
description: 컴포넌트 추가/수정 또는 DB 구조 변경 시 관련 문서를 항상 최신화하도록 강제하는 규칙
|
||||
globs:
|
||||
- "frontend/lib/registry/components/**/*.tsx"
|
||||
- "frontend/components/v2/**/*.tsx"
|
||||
- "db/migrations/**/*.sql"
|
||||
- "backend-node/src/types/ddl.ts"
|
||||
---
|
||||
|
||||
# 컴포넌트 및 DB 구조 변경 시 문서 동기화 규칙
|
||||
|
||||
## 🚨 핵심 원칙 (절대 준수)
|
||||
|
||||
새로운 V2 컴포넌트를 생성하거나 기존 컴포넌트의 설정(overrides)을 변경할 때, 또는 DB 테이블 구조나 화면 생성 파이프라인이 변경될 때는 **반드시** 아래 두 문서를 함께 업데이트해야 합니다.
|
||||
|
||||
1. `docs/screen-implementation-guide/01_reference_guides/full-screen-analysis.md` (전체 레퍼런스)
|
||||
2. `docs/screen-implementation-guide/01_reference_guides/v2-component-usage-guide.md` (실행 가이드)
|
||||
|
||||
## 📌 업데이트 대상 및 방법
|
||||
|
||||
### 1. V2 컴포넌트 신규 추가 또는 속성(Props/Overrides) 변경 시
|
||||
- **`full-screen-analysis.md`**: `3. 컴포넌트 전체 설정 레퍼런스` 섹션에 해당 컴포넌트의 모든 설정값(타입, 기본값, 설명)을 표 형태로 추가/수정하세요.
|
||||
- **`v2-component-usage-guide.md`**:
|
||||
- `7. Step 6: screen_layouts_v2 INSERT`의 컴포넌트 url 매핑표에 추가하세요.
|
||||
- `16. 컴포넌트 빠른 참조표`에 추가하세요.
|
||||
- 필요한 경우 `8. 패턴별 layout_data 완전 예시`에 새로운 패턴을 추가하세요.
|
||||
|
||||
### 2. DB 테이블 구조 또는 화면 생성 로직 변경 시
|
||||
- **`full-screen-analysis.md`**: `2. DB 테이블 스키마` 섹션의 테이블 구조(컬럼, 타입, 설명)를 최신화하세요.
|
||||
- **`v2-component-usage-guide.md`**:
|
||||
- `Step 1` ~ `Step 7`의 SQL 템플릿이 변경된 구조와 일치하는지 확인하고 수정하세요.
|
||||
- 특히 `INSERT` 문의 컬럼 목록과 `VALUES` 형식이 정확한지 검증하세요.
|
||||
|
||||
## ⚠️ AI 에이전트 행동 지침
|
||||
|
||||
1. 사용자가 컴포넌트 코드를 수정해달라고 요청하면, 수정 완료 후 **"관련 가이드 문서도 업데이트할까요?"** 라고 반드시 물어보세요.
|
||||
2. 사용자가 DB 마이그레이션 스크립트를 작성해달라고 하거나 핵심 시스템 테이블을 건드리면, 가이드 문서의 SQL 템플릿도 수정해야 하는지 확인하세요.
|
||||
3. 가이드 문서 업데이트 시 JSON 예제 안에 `//` 같은 주석을 넣지 않도록 주의하세요 (DB 파싱 에러 방지).
|
||||
|
|
@ -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 (최고 관리자)
|
||||
|
|
@ -153,6 +153,7 @@ backend-node/uploads/
|
|||
uploads/
|
||||
*.jpg
|
||||
*.jpeg
|
||||
*.png
|
||||
*.gif
|
||||
*.pdf
|
||||
*.doc
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -113,6 +113,7 @@ import scheduleRoutes from "./routes/scheduleRoutes"; // 스케줄 자동 생성
|
|||
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
|
||||
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
|
||||
import bomRoutes from "./routes/bomRoutes"; // BOM 이력/버전 관리
|
||||
import productionRoutes from "./routes/productionRoutes"; // 생산계획 관리
|
||||
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
|
||||
import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
|
||||
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
|
||||
|
|
@ -124,6 +125,7 @@ import entitySearchRoutes, {
|
|||
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
||||
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
|
||||
import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행
|
||||
import popProductionRoutes from "./routes/popProductionRoutes"; // POP 생산 관리 (공정 생성/타이머)
|
||||
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
||||
import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템
|
||||
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
||||
|
|
@ -259,6 +261,7 @@ app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능
|
|||
app.use("/api/screen-management", screenManagementRoutes);
|
||||
app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리
|
||||
app.use("/api/pop", popActionRoutes); // POP 액션 실행
|
||||
app.use("/api/pop/production", popProductionRoutes); // POP 생산 관리
|
||||
app.use("/api/common-codes", commonCodeRoutes);
|
||||
app.use("/api/dynamic-form", dynamicFormRoutes);
|
||||
app.use("/api/files", fileRoutes);
|
||||
|
|
@ -310,6 +313,7 @@ app.use("/api/schedule", scheduleRoutes); // 스케줄 자동 생성
|
|||
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
|
||||
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
|
||||
app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리
|
||||
app.use("/api/production", productionRoutes); // 생산계획 관리
|
||||
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
|
||||
app.use("/api/departments", departmentRoutes); // 부서 관리
|
||||
app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리
|
||||
|
|
|
|||
|
|
@ -1854,7 +1854,7 @@ export async function toggleMenuStatus(
|
|||
|
||||
// 현재 상태 및 회사 코드 조회
|
||||
const currentMenu = await queryOne<any>(
|
||||
`SELECT objid, status, company_code FROM menu_info WHERE objid = $1`,
|
||||
`SELECT objid, status, company_code, menu_name_kor FROM menu_info WHERE objid = $1`,
|
||||
[Number(menuId)]
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||
import { auditLogService } from "../services/auditLogService";
|
||||
import { auditLogService, getClientIp, AuditAction, AuditResourceType } from "../services/auditLogService";
|
||||
import { query } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
|
|
@ -137,3 +137,40 @@ export const getAuditLogUsers = async (
|
|||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 프론트엔드에서 직접 감사 로그 기록 (그룹 복제 등 프론트 오케스트레이션 작업용)
|
||||
*/
|
||||
export const createAuditLog = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { action, resourceType, resourceId, resourceName, tableName, summary, changes } = req.body;
|
||||
|
||||
if (!action || !resourceType) {
|
||||
res.status(400).json({ success: false, message: "action, resourceType은 필수입니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
await auditLogService.log({
|
||||
companyCode: req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: action as AuditAction,
|
||||
resourceType: resourceType as AuditResourceType,
|
||||
resourceId: resourceId || undefined,
|
||||
resourceName: resourceName || undefined,
|
||||
tableName: tableName || undefined,
|
||||
summary: summary || undefined,
|
||||
changes: changes || undefined,
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
logger.error("감사 로그 기록 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: "감사 로그 기록 실패" });
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Router, Request, Response } from "express";
|
|||
import { categoryTreeService, CreateCategoryValueInput, UpdateCategoryValueInput } from "../services/categoryTreeService";
|
||||
import { logger } from "../utils/logger";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { auditLogService, getClientIp } from "../services/auditLogService";
|
||||
|
||||
const router = Router();
|
||||
|
||||
|
|
@ -16,6 +17,7 @@ router.use(authenticateToken);
|
|||
interface AuthenticatedRequest extends Request {
|
||||
user?: {
|
||||
userId: string;
|
||||
userName: string;
|
||||
companyCode: string;
|
||||
};
|
||||
}
|
||||
|
|
@ -157,6 +159,21 @@ router.post("/test/value", async (req: AuthenticatedRequest, res: Response) => {
|
|||
|
||||
const value = await categoryTreeService.createCategoryValue(companyCode, input, createdBy);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName,
|
||||
action: "CREATE",
|
||||
resourceType: "CODE_CATEGORY",
|
||||
resourceId: String(value.valueId),
|
||||
resourceName: input.valueLabel,
|
||||
tableName: "category_values",
|
||||
summary: `카테고리 값 "${input.valueLabel}" 생성 (${input.tableName}.${input.columnName})`,
|
||||
changes: { after: { tableName: input.tableName, columnName: input.columnName, valueCode: input.valueCode, valueLabel: input.valueLabel } },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: value,
|
||||
|
|
@ -182,6 +199,7 @@ router.put("/test/value/:valueId", async (req: AuthenticatedRequest, res: Respon
|
|||
const companyCode = req.user?.companyCode || "*";
|
||||
const updatedBy = req.user?.userId;
|
||||
|
||||
const beforeValue = await categoryTreeService.getCategoryValue(companyCode, Number(valueId));
|
||||
const value = await categoryTreeService.updateCategoryValue(companyCode, Number(valueId), input, updatedBy);
|
||||
|
||||
if (!value) {
|
||||
|
|
@ -191,6 +209,24 @@ router.put("/test/value/:valueId", async (req: AuthenticatedRequest, res: Respon
|
|||
});
|
||||
}
|
||||
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName,
|
||||
action: "UPDATE",
|
||||
resourceType: "CODE_CATEGORY",
|
||||
resourceId: valueId,
|
||||
resourceName: value.valueLabel,
|
||||
tableName: "category_values",
|
||||
summary: `카테고리 값 "${value.valueLabel}" 수정 (${value.tableName}.${value.columnName})`,
|
||||
changes: {
|
||||
before: beforeValue ? { valueLabel: beforeValue.valueLabel, valueCode: beforeValue.valueCode } : undefined,
|
||||
after: input,
|
||||
},
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: value,
|
||||
|
|
@ -239,6 +275,7 @@ router.delete("/test/value/:valueId", async (req: AuthenticatedRequest, res: Res
|
|||
const { valueId } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
const beforeValue = await categoryTreeService.getCategoryValue(companyCode, Number(valueId));
|
||||
const success = await categoryTreeService.deleteCategoryValue(companyCode, Number(valueId));
|
||||
|
||||
if (!success) {
|
||||
|
|
@ -248,6 +285,21 @@ router.delete("/test/value/:valueId", async (req: AuthenticatedRequest, res: Res
|
|||
});
|
||||
}
|
||||
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName,
|
||||
action: "DELETE",
|
||||
resourceType: "CODE_CATEGORY",
|
||||
resourceId: valueId,
|
||||
resourceName: beforeValue?.valueLabel || valueId,
|
||||
tableName: "category_values",
|
||||
summary: `카테고리 값 "${beforeValue?.valueLabel || valueId}" 삭제 (${beforeValue?.tableName || ""}.${beforeValue?.columnName || ""})`,
|
||||
changes: beforeValue ? { before: { valueLabel: beforeValue.valueLabel, valueCode: beforeValue.valueCode, tableName: beforeValue.tableName, columnName: beforeValue.columnName } } : undefined,
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "삭제되었습니다",
|
||||
|
|
|
|||
|
|
@ -396,6 +396,20 @@ export class CommonCodeController {
|
|||
companyCode
|
||||
);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: companyCode || "",
|
||||
userId: userId || "",
|
||||
action: "UPDATE",
|
||||
resourceType: "CODE",
|
||||
resourceId: codeValue,
|
||||
resourceName: codeData.codeName || codeValue,
|
||||
tableName: "code_info",
|
||||
summary: `코드 "${categoryCode}.${codeValue}" 수정`,
|
||||
changes: { after: codeData },
|
||||
ipAddress: getClientIp(req as any),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: code,
|
||||
|
|
@ -440,6 +454,19 @@ export class CommonCodeController {
|
|||
companyCode
|
||||
);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
action: "DELETE",
|
||||
resourceType: "CODE",
|
||||
resourceId: codeValue,
|
||||
tableName: "code_info",
|
||||
summary: `코드 "${categoryCode}.${codeValue}" 삭제`,
|
||||
changes: { before: { categoryCode, codeValue } },
|
||||
ipAddress: getClientIp(req as any),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "코드 삭제 성공",
|
||||
|
|
|
|||
|
|
@ -438,6 +438,19 @@ export class DDLController {
|
|||
);
|
||||
|
||||
if (result.success) {
|
||||
auditLogService.log({
|
||||
companyCode: userCompanyCode || "",
|
||||
userId,
|
||||
action: "DELETE",
|
||||
resourceType: "TABLE",
|
||||
resourceId: tableName,
|
||||
resourceName: tableName,
|
||||
tableName,
|
||||
summary: `테이블 "${tableName}" 삭제`,
|
||||
ipAddress: getClientIp(req as any),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: result.message,
|
||||
|
|
|
|||
|
|
@ -193,6 +193,7 @@ router.post(
|
|||
auditLogService.log({
|
||||
companyCode,
|
||||
userId,
|
||||
userName: req.user?.userName,
|
||||
action: "CREATE",
|
||||
resourceType: "NUMBERING_RULE",
|
||||
resourceId: String(newRule.ruleId),
|
||||
|
|
@ -243,6 +244,7 @@ router.put(
|
|||
auditLogService.log({
|
||||
companyCode,
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName,
|
||||
action: "UPDATE",
|
||||
resourceType: "NUMBERING_RULE",
|
||||
resourceId: ruleId,
|
||||
|
|
@ -285,6 +287,7 @@ router.delete(
|
|||
auditLogService.log({
|
||||
companyCode,
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName,
|
||||
action: "DELETE",
|
||||
resourceType: "NUMBERING_RULE",
|
||||
resourceId: ruleId,
|
||||
|
|
@ -522,6 +525,56 @@ router.post(
|
|||
companyCode,
|
||||
userId
|
||||
);
|
||||
|
||||
const isUpdate = !!ruleConfig.ruleId;
|
||||
|
||||
const resetPeriodLabel: Record<string, string> = {
|
||||
none: "초기화 안함", daily: "일별", monthly: "월별", yearly: "연별",
|
||||
};
|
||||
const partTypeLabel: Record<string, string> = {
|
||||
sequence: "순번", number: "숫자", date: "날짜", text: "문자", category: "카테고리", reference: "참조",
|
||||
};
|
||||
const partsDescription = (ruleConfig.parts || [])
|
||||
.sort((a: any, b: any) => (a.order || 0) - (b.order || 0))
|
||||
.map((p: any) => {
|
||||
const type = partTypeLabel[p.partType] || p.partType;
|
||||
if (p.partType === "text" && p.autoConfig?.textValue) return `${type}("${p.autoConfig.textValue}")`;
|
||||
if (p.partType === "sequence" && p.autoConfig?.sequenceLength) return `${type}(${p.autoConfig.sequenceLength}자리)`;
|
||||
if (p.partType === "date" && p.autoConfig?.dateFormat) return `${type}(${p.autoConfig.dateFormat})`;
|
||||
if (p.partType === "category") return `${type}(${p.autoConfig?.categoryKey || ""})`;
|
||||
if (p.partType === "reference") return `${type}(${p.autoConfig?.referenceColumnName || ""})`;
|
||||
return type;
|
||||
})
|
||||
.join(` ${ruleConfig.separator || "-"} `);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId,
|
||||
userName: req.user?.userName,
|
||||
action: isUpdate ? "UPDATE" : "CREATE",
|
||||
resourceType: "NUMBERING_RULE",
|
||||
resourceId: String(savedRule.ruleId),
|
||||
resourceName: ruleConfig.ruleName,
|
||||
tableName: "numbering_rules",
|
||||
summary: isUpdate
|
||||
? `채번 규칙 "${ruleConfig.ruleName}" 수정`
|
||||
: `채번 규칙 "${ruleConfig.ruleName}" 생성`,
|
||||
changes: {
|
||||
after: {
|
||||
규칙명: ruleConfig.ruleName,
|
||||
적용테이블: ruleConfig.tableName || "(미지정)",
|
||||
적용컬럼: ruleConfig.columnName || "(미지정)",
|
||||
구분자: ruleConfig.separator || "-",
|
||||
리셋주기: resetPeriodLabel[ruleConfig.resetPeriod] || ruleConfig.resetPeriod || "초기화 안함",
|
||||
적용범위: ruleConfig.scopeType === "menu" ? "메뉴별" : "전역",
|
||||
코드구성: partsDescription || "(파트 없음)",
|
||||
파트수: (ruleConfig.parts || []).length,
|
||||
},
|
||||
},
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: savedRule });
|
||||
} catch (error: any) {
|
||||
logger.error("[테스트] 채번 규칙 저장 실패", { error: error.message });
|
||||
|
|
@ -536,10 +589,25 @@ router.delete(
|
|||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { ruleId } = req.params;
|
||||
|
||||
try {
|
||||
await numberingRuleService.deleteRuleFromTest(ruleId, companyCode);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId,
|
||||
userName: req.user?.userName,
|
||||
action: "DELETE",
|
||||
resourceType: "NUMBERING_RULE",
|
||||
resourceId: ruleId,
|
||||
tableName: "numbering_rules",
|
||||
summary: `채번 규칙(ID:${ruleId}) 삭제`,
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "테스트 채번 규칙이 삭제되었습니다",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,291 @@
|
|||
import { Response } from "express";
|
||||
import { getPool } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||
|
||||
/**
|
||||
* D-BE1: 작업지시 공정 일괄 생성
|
||||
* PC에서 작업지시 생성 후 호출. 1 트랜잭션으로 work_order_process + process_work_result 일괄 생성.
|
||||
*/
|
||||
export const createWorkProcesses = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
|
||||
const { work_instruction_id, item_code, routing_version_id, plan_qty } =
|
||||
req.body;
|
||||
|
||||
if (!work_instruction_id || !routing_version_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message:
|
||||
"work_instruction_id와 routing_version_id는 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("[pop/production] create-work-processes 요청", {
|
||||
companyCode,
|
||||
userId,
|
||||
work_instruction_id,
|
||||
item_code,
|
||||
routing_version_id,
|
||||
plan_qty,
|
||||
});
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 중복 호출 방지: 이미 생성된 공정이 있는지 확인
|
||||
const existCheck = await client.query(
|
||||
`SELECT COUNT(*) as cnt FROM work_order_process
|
||||
WHERE wo_id = $1 AND company_code = $2`,
|
||||
[work_instruction_id, companyCode]
|
||||
);
|
||||
if (parseInt(existCheck.rows[0].cnt, 10) > 0) {
|
||||
await client.query("ROLLBACK");
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: "이미 공정이 생성된 작업지시입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 1. item_routing_detail + process_mng JOIN (공정 목록 + 공정명)
|
||||
const routingDetails = await client.query(
|
||||
`SELECT rd.id, rd.seq_no, rd.process_code,
|
||||
COALESCE(pm.process_name, rd.process_code) as process_name,
|
||||
rd.is_required, rd.is_fixed_order, rd.standard_time
|
||||
FROM item_routing_detail rd
|
||||
LEFT JOIN process_mng pm ON pm.process_code = rd.process_code
|
||||
AND pm.company_code = rd.company_code
|
||||
WHERE rd.routing_version_id = $1 AND rd.company_code = $2
|
||||
ORDER BY CAST(rd.seq_no AS int) NULLS LAST`,
|
||||
[routing_version_id, companyCode]
|
||||
);
|
||||
|
||||
if (routingDetails.rows.length === 0) {
|
||||
await client.query("ROLLBACK");
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "라우팅 버전에 등록된 공정이 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const processes: Array<{
|
||||
id: string;
|
||||
seq_no: string;
|
||||
process_name: string;
|
||||
checklist_count: number;
|
||||
}> = [];
|
||||
let totalChecklists = 0;
|
||||
|
||||
for (const rd of routingDetails.rows) {
|
||||
// 2. work_order_process INSERT
|
||||
const wopResult = await client.query(
|
||||
`INSERT INTO work_order_process (
|
||||
company_code, wo_id, seq_no, process_code, process_name,
|
||||
is_required, is_fixed_order, standard_time, plan_qty,
|
||||
status, routing_detail_id, writer
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING id`,
|
||||
[
|
||||
companyCode,
|
||||
work_instruction_id,
|
||||
rd.seq_no,
|
||||
rd.process_code,
|
||||
rd.process_name,
|
||||
rd.is_required,
|
||||
rd.is_fixed_order,
|
||||
rd.standard_time,
|
||||
plan_qty || null,
|
||||
"waiting",
|
||||
rd.id,
|
||||
userId,
|
||||
]
|
||||
);
|
||||
const wopId = wopResult.rows[0].id;
|
||||
|
||||
// 3. process_work_result INSERT (스냅샷 복사)
|
||||
// process_work_item + process_work_item_detail에서 해당 routing_detail의 항목 조회 후 복사
|
||||
const snapshotResult = await client.query(
|
||||
`INSERT INTO process_work_result (
|
||||
company_code, work_order_process_id,
|
||||
source_work_item_id, source_detail_id,
|
||||
work_phase, item_title, item_sort_order,
|
||||
detail_content, detail_type, detail_sort_order, is_required,
|
||||
inspection_code, inspection_method, unit, lower_limit, upper_limit,
|
||||
input_type, lookup_target, display_fields, duration_minutes,
|
||||
status, writer
|
||||
)
|
||||
SELECT
|
||||
pwi.company_code, $1,
|
||||
pwi.id, pwd.id,
|
||||
pwi.work_phase, pwi.title, pwi.sort_order::text,
|
||||
pwd.content, pwd.detail_type, pwd.sort_order::text, pwd.is_required,
|
||||
pwd.inspection_code, pwd.inspection_method, pwd.unit, pwd.lower_limit, pwd.upper_limit,
|
||||
pwd.input_type, pwd.lookup_target, pwd.display_fields, pwd.duration_minutes::text,
|
||||
'pending', $2
|
||||
FROM process_work_item pwi
|
||||
JOIN process_work_item_detail pwd ON pwd.work_item_id = pwi.id
|
||||
AND pwd.company_code = pwi.company_code
|
||||
WHERE pwi.routing_detail_id = $3
|
||||
AND pwi.company_code = $4
|
||||
ORDER BY pwi.sort_order, pwd.sort_order`,
|
||||
[wopId, userId, rd.id, companyCode]
|
||||
);
|
||||
|
||||
const checklistCount = snapshotResult.rowCount ?? 0;
|
||||
totalChecklists += checklistCount;
|
||||
|
||||
processes.push({
|
||||
id: wopId,
|
||||
seq_no: rd.seq_no,
|
||||
process_name: rd.process_name,
|
||||
checklist_count: checklistCount,
|
||||
});
|
||||
|
||||
logger.info("[pop/production] 공정 생성 완료", {
|
||||
wopId,
|
||||
processName: rd.process_name,
|
||||
checklistCount,
|
||||
});
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("[pop/production] create-work-processes 완료", {
|
||||
companyCode,
|
||||
work_instruction_id,
|
||||
total_processes: processes.length,
|
||||
total_checklists: totalChecklists,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
processes,
|
||||
total_processes: processes.length,
|
||||
total_checklists: totalChecklists,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("[pop/production] create-work-processes 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "공정 생성 중 오류가 발생했습니다.",
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* D-BE2: 타이머 API (시작/일시정지/재시작)
|
||||
*/
|
||||
export const controlTimer = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
|
||||
const { work_order_process_id, action } = req.body;
|
||||
|
||||
if (!work_order_process_id || !action) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "work_order_process_id와 action은 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!["start", "pause", "resume"].includes(action)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "action은 start, pause, resume 중 하나여야 합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("[pop/production] timer 요청", {
|
||||
companyCode,
|
||||
userId,
|
||||
work_order_process_id,
|
||||
action,
|
||||
});
|
||||
|
||||
let result;
|
||||
|
||||
switch (action) {
|
||||
case "start":
|
||||
// 최초 1회만 설정, 이미 있으면 무시
|
||||
result = await pool.query(
|
||||
`UPDATE work_order_process
|
||||
SET started_at = CASE WHEN started_at IS NULL THEN NOW()::text ELSE started_at END,
|
||||
status = CASE WHEN status = 'waiting' THEN 'in_progress' ELSE status END,
|
||||
updated_date = NOW()
|
||||
WHERE id = $1 AND company_code = $2
|
||||
RETURNING id, started_at, status`,
|
||||
[work_order_process_id, companyCode]
|
||||
);
|
||||
break;
|
||||
|
||||
case "pause":
|
||||
result = await pool.query(
|
||||
`UPDATE work_order_process
|
||||
SET paused_at = NOW()::text,
|
||||
updated_date = NOW()
|
||||
WHERE id = $1 AND company_code = $2 AND paused_at IS NULL
|
||||
RETURNING id, paused_at`,
|
||||
[work_order_process_id, companyCode]
|
||||
);
|
||||
break;
|
||||
|
||||
case "resume":
|
||||
// 일시정지 시간 누적 후 paused_at 초기화
|
||||
result = await pool.query(
|
||||
`UPDATE work_order_process
|
||||
SET total_paused_time = (
|
||||
COALESCE(total_paused_time::int, 0)
|
||||
+ EXTRACT(EPOCH FROM NOW() - paused_at::timestamp)::int
|
||||
)::text,
|
||||
paused_at = NULL,
|
||||
updated_date = NOW()
|
||||
WHERE id = $1 AND company_code = $2 AND paused_at IS NOT NULL
|
||||
RETURNING id, total_paused_time`,
|
||||
[work_order_process_id, companyCode]
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!result || result.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "대상 공정을 찾을 수 없거나 현재 상태에서 수행할 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("[pop/production] timer 완료", {
|
||||
action,
|
||||
work_order_process_id,
|
||||
result: result.rows[0],
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows[0],
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("[pop/production] timer 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "타이머 처리 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -30,26 +30,68 @@ export async function getItemsWithRouting(req: AuthenticatedRequest, res: Respon
|
|||
routingTable = "item_routing_version",
|
||||
routingFkColumn = "item_code",
|
||||
search = "",
|
||||
extraColumns = "",
|
||||
filterConditions = "",
|
||||
} = req.query as Record<string, string>;
|
||||
|
||||
const searchCondition = search
|
||||
? `AND (i.${nameColumn} ILIKE $2 OR i.${codeColumn} ILIKE $2)`
|
||||
: "";
|
||||
const params: any[] = [companyCode];
|
||||
if (search) params.push(`%${search}%`);
|
||||
let paramIndex = 2;
|
||||
|
||||
// 검색 조건
|
||||
let searchCondition = "";
|
||||
if (search) {
|
||||
searchCondition = `AND (i.${nameColumn} ILIKE $${paramIndex} OR i.${codeColumn} ILIKE $${paramIndex})`;
|
||||
params.push(`%${search}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 추가 컬럼 SELECT
|
||||
const extraColumnNames: string[] = extraColumns
|
||||
? extraColumns.split(",").map((c: string) => c.trim()).filter(Boolean)
|
||||
: [];
|
||||
const extraSelect = extraColumnNames.map((col) => `i.${col}`).join(", ");
|
||||
const extraGroupBy = extraColumnNames.map((col) => `i.${col}`).join(", ");
|
||||
|
||||
// 사전 필터 조건
|
||||
let filterWhere = "";
|
||||
if (filterConditions) {
|
||||
try {
|
||||
const filters = JSON.parse(filterConditions) as Array<{
|
||||
column: string;
|
||||
operator: string;
|
||||
value: string;
|
||||
}>;
|
||||
for (const f of filters) {
|
||||
if (!f.column || !f.value) continue;
|
||||
if (f.operator === "equals") {
|
||||
filterWhere += ` AND i.${f.column} = $${paramIndex}`;
|
||||
params.push(f.value);
|
||||
} else if (f.operator === "contains") {
|
||||
filterWhere += ` AND i.${f.column} ILIKE $${paramIndex}`;
|
||||
params.push(`%${f.value}%`);
|
||||
} else if (f.operator === "not_equals") {
|
||||
filterWhere += ` AND i.${f.column} != $${paramIndex}`;
|
||||
params.push(f.value);
|
||||
}
|
||||
paramIndex++;
|
||||
}
|
||||
} catch { /* 파싱 실패 시 무시 */ }
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
i.id,
|
||||
i.${nameColumn} AS item_name,
|
||||
i.${codeColumn} AS item_code,
|
||||
i.${codeColumn} AS item_code
|
||||
${extraSelect ? ", " + extraSelect : ""},
|
||||
COUNT(rv.id) AS routing_count
|
||||
FROM ${tableName} i
|
||||
LEFT JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn}
|
||||
AND rv.company_code = i.company_code
|
||||
WHERE i.company_code = $1
|
||||
${searchCondition}
|
||||
GROUP BY i.id, i.${nameColumn}, i.${codeColumn}, i.created_date
|
||||
${filterWhere}
|
||||
GROUP BY i.id, i.${nameColumn}, i.${codeColumn}${extraGroupBy ? ", " + extraGroupBy : ""}, i.created_date
|
||||
ORDER BY i.created_date DESC NULLS LAST
|
||||
`;
|
||||
|
||||
|
|
@ -711,3 +753,184 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) {
|
|||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 등록 품목 관리 (item_routing_registered)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 화면별 등록된 품목 목록 조회
|
||||
*/
|
||||
export async function getRegisteredItems(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||
}
|
||||
|
||||
const { screenCode } = req.params;
|
||||
const {
|
||||
tableName = "item_info",
|
||||
nameColumn = "item_name",
|
||||
codeColumn = "item_number",
|
||||
routingTable = "item_routing_version",
|
||||
routingFkColumn = "item_code",
|
||||
search = "",
|
||||
extraColumns = "",
|
||||
} = req.query as Record<string, string>;
|
||||
|
||||
const params: any[] = [companyCode, screenCode];
|
||||
let paramIndex = 3;
|
||||
|
||||
let searchCondition = "";
|
||||
if (search) {
|
||||
searchCondition = `AND (i.${nameColumn} ILIKE $${paramIndex} OR i.${codeColumn} ILIKE $${paramIndex})`;
|
||||
params.push(`%${search}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const extraColumnNames: string[] = extraColumns
|
||||
? extraColumns.split(",").map((c: string) => c.trim()).filter(Boolean)
|
||||
: [];
|
||||
const extraSelect = extraColumnNames.map((col) => `i.${col}`).join(", ");
|
||||
const extraGroupBy = extraColumnNames.map((col) => `i.${col}`).join(", ");
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
irr.id AS registered_id,
|
||||
irr.sort_order,
|
||||
i.id,
|
||||
i.${nameColumn} AS item_name,
|
||||
i.${codeColumn} AS item_code
|
||||
${extraSelect ? ", " + extraSelect : ""},
|
||||
COUNT(rv.id) AS routing_count
|
||||
FROM item_routing_registered irr
|
||||
JOIN ${tableName} i ON irr.item_id = i.id
|
||||
AND i.company_code = irr.company_code
|
||||
LEFT JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn}
|
||||
AND rv.company_code = i.company_code
|
||||
WHERE irr.company_code = $1
|
||||
AND irr.screen_code = $2
|
||||
${searchCondition}
|
||||
GROUP BY irr.id, irr.sort_order, i.id, i.${nameColumn}, i.${codeColumn}${extraGroupBy ? ", " + extraGroupBy : ""}
|
||||
ORDER BY CAST(irr.sort_order AS int) ASC, irr.created_date ASC
|
||||
`;
|
||||
|
||||
const result = await getPool().query(query, params);
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("등록 품목 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목 등록 (화면에 품목 추가)
|
||||
*/
|
||||
export async function registerItem(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||
}
|
||||
|
||||
const { screenCode, itemId, itemCode } = req.body;
|
||||
if (!screenCode || !itemId) {
|
||||
return res.status(400).json({ success: false, message: "screenCode, itemId 필수" });
|
||||
}
|
||||
|
||||
const query = `
|
||||
INSERT INTO item_routing_registered (screen_code, item_id, item_code, company_code, writer)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (screen_code, item_id, company_code) DO NOTHING
|
||||
RETURNING *
|
||||
`;
|
||||
const result = await getPool().query(query, [
|
||||
screenCode, itemId, itemCode || null, companyCode, req.user?.userId || null,
|
||||
]);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.json({ success: true, message: "이미 등록된 품목입니다", data: null });
|
||||
}
|
||||
|
||||
logger.info("품목 등록", { companyCode, screenCode, itemId });
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("품목 등록 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 품목 일괄 등록
|
||||
*/
|
||||
export async function registerItemsBatch(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||
}
|
||||
|
||||
const { screenCode, items } = req.body;
|
||||
if (!screenCode || !Array.isArray(items) || items.length === 0) {
|
||||
return res.status(400).json({ success: false, message: "screenCode, items[] 필수" });
|
||||
}
|
||||
|
||||
const client = await getPool().connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const inserted: any[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const result = await client.query(
|
||||
`INSERT INTO item_routing_registered (screen_code, item_id, item_code, company_code, writer)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (screen_code, item_id, company_code) DO NOTHING
|
||||
RETURNING *`,
|
||||
[screenCode, item.itemId, item.itemCode || null, companyCode, req.user?.userId || null]
|
||||
);
|
||||
if (result.rows[0]) inserted.push(result.rows[0]);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
logger.info("품목 일괄 등록", { companyCode, screenCode, count: inserted.length });
|
||||
return res.json({ success: true, data: inserted });
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK");
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("품목 일괄 등록 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 등록 품목 제거
|
||||
*/
|
||||
export async function unregisterItem(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const result = await getPool().query(
|
||||
`DELETE FROM item_routing_registered WHERE id = $1 AND company_code = $2 RETURNING id`,
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다" });
|
||||
}
|
||||
|
||||
logger.info("등록 품목 제거", { companyCode, id });
|
||||
return res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
logger.error("등록 품목 제거 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,191 @@
|
|||
/**
|
||||
* 생산계획 컨트롤러
|
||||
*/
|
||||
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import * as productionService from "../services/productionPlanService";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// ─── 수주 데이터 조회 (품목별 그룹핑) ───
|
||||
|
||||
export async function getOrderSummary(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { excludePlanned, itemCode, itemName } = req.query;
|
||||
|
||||
const data = await productionService.getOrderSummary(companyCode, {
|
||||
excludePlanned: excludePlanned === "true",
|
||||
itemCode: itemCode as string,
|
||||
itemName: itemName as string,
|
||||
});
|
||||
|
||||
return res.json({ success: true, data });
|
||||
} catch (error: any) {
|
||||
logger.error("수주 데이터 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 안전재고 부족분 조회 ───
|
||||
|
||||
export async function getStockShortage(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const data = await productionService.getStockShortage(companyCode);
|
||||
return res.json({ success: true, data });
|
||||
} catch (error: any) {
|
||||
logger.error("안전재고 부족분 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 생산계획 상세 조회 ───
|
||||
|
||||
export async function getPlanById(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const planId = parseInt(req.params.id, 10);
|
||||
const data = await productionService.getPlanById(companyCode, planId);
|
||||
|
||||
if (!data) {
|
||||
return res.status(404).json({ success: false, message: "생산계획을 찾을 수 없습니다" });
|
||||
}
|
||||
return res.json({ success: true, data });
|
||||
} catch (error: any) {
|
||||
logger.error("생산계획 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 생산계획 수정 ───
|
||||
|
||||
export async function updatePlan(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const planId = parseInt(req.params.id, 10);
|
||||
const updatedBy = req.user!.userId;
|
||||
|
||||
const data = await productionService.updatePlan(companyCode, planId, req.body, updatedBy);
|
||||
return res.json({ success: true, data });
|
||||
} catch (error: any) {
|
||||
logger.error("생산계획 수정 실패", { error: error.message });
|
||||
return res.status(error.message.includes("찾을 수 없") ? 404 : 500).json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 생산계획 삭제 ───
|
||||
|
||||
export async function deletePlan(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const planId = parseInt(req.params.id, 10);
|
||||
|
||||
await productionService.deletePlan(companyCode, planId);
|
||||
return res.json({ success: true, message: "삭제되었습니다" });
|
||||
} catch (error: any) {
|
||||
logger.error("생산계획 삭제 실패", { error: error.message });
|
||||
return res.status(error.message.includes("찾을 수 없") ? 404 : 500).json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 자동 스케줄 생성 ───
|
||||
|
||||
export async function generateSchedule(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const createdBy = req.user!.userId;
|
||||
const { items, options } = req.body;
|
||||
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
return res.status(400).json({ success: false, message: "품목 정보가 필요합니다" });
|
||||
}
|
||||
|
||||
const data = await productionService.generateSchedule(companyCode, items, options || {}, createdBy);
|
||||
return res.json({ success: true, data });
|
||||
} catch (error: any) {
|
||||
logger.error("자동 스케줄 생성 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 스케줄 병합 ───
|
||||
|
||||
export async function mergeSchedules(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const mergedBy = req.user!.userId;
|
||||
const { schedule_ids, product_type } = req.body;
|
||||
|
||||
if (!schedule_ids || !Array.isArray(schedule_ids) || schedule_ids.length < 2) {
|
||||
return res.status(400).json({ success: false, message: "2개 이상의 스케줄을 선택해주세요" });
|
||||
}
|
||||
|
||||
const data = await productionService.mergeSchedules(
|
||||
companyCode,
|
||||
schedule_ids,
|
||||
product_type || "완제품",
|
||||
mergedBy
|
||||
);
|
||||
return res.json({ success: true, data });
|
||||
} catch (error: any) {
|
||||
logger.error("스케줄 병합 실패", { error: error.message });
|
||||
const status = error.message.includes("동일 품목") || error.message.includes("찾을 수 없") ? 400 : 500;
|
||||
return res.status(status).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 반제품 계획 자동 생성 ───
|
||||
|
||||
export async function generateSemiSchedule(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const createdBy = req.user!.userId;
|
||||
const { plan_ids, options } = req.body;
|
||||
|
||||
if (!plan_ids || !Array.isArray(plan_ids) || plan_ids.length === 0) {
|
||||
return res.status(400).json({ success: false, message: "완제품 계획을 선택해주세요" });
|
||||
}
|
||||
|
||||
const data = await productionService.generateSemiSchedule(
|
||||
companyCode,
|
||||
plan_ids,
|
||||
options || {},
|
||||
createdBy
|
||||
);
|
||||
return res.json({ success: true, data });
|
||||
} catch (error: any) {
|
||||
logger.error("반제품 계획 생성 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 스케줄 분할 ───
|
||||
|
||||
export async function splitSchedule(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const splitBy = req.user!.userId;
|
||||
const planId = parseInt(req.params.id, 10);
|
||||
const { split_qty } = req.body;
|
||||
|
||||
if (!split_qty || split_qty <= 0) {
|
||||
return res.status(400).json({ success: false, message: "분할 수량을 입력해주세요" });
|
||||
}
|
||||
|
||||
const data = await productionService.splitSchedule(companyCode, planId, split_qty, splitBy);
|
||||
return res.json({ success: true, data });
|
||||
} catch (error: any) {
|
||||
logger.error("스케줄 분할 실패", { error: error.message });
|
||||
return res.status(error.message.includes("찾을 수 없") ? 404 : 400).json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -614,20 +614,6 @@ export const copyScreenWithModals = async (
|
|||
modalScreens: modalScreens || [],
|
||||
});
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: targetCompanyCode || companyCode,
|
||||
userId: userId || "",
|
||||
userName: (req.user as any)?.userName || "",
|
||||
action: "COPY",
|
||||
resourceType: "SCREEN",
|
||||
resourceId: id,
|
||||
resourceName: mainScreen?.screenName,
|
||||
summary: `화면 일괄 복사 (메인 1개 + 모달 ${result.modalScreens.length}개, 원본 ID:${id})`,
|
||||
changes: { after: { sourceScreenId: id, targetCompanyCode, mainScreenName: mainScreen?.screenName } },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
|
|
@ -663,20 +649,6 @@ export const copyScreen = async (
|
|||
}
|
||||
);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId: userId || "",
|
||||
userName: (req.user as any)?.userName || "",
|
||||
action: "COPY",
|
||||
resourceType: "SCREEN",
|
||||
resourceId: String(copiedScreen?.screenId || ""),
|
||||
resourceName: screenName,
|
||||
summary: `화면 "${screenName}" 복사 (원본 ID:${id})`,
|
||||
changes: { after: { sourceScreenId: id, screenName, screenCode } },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: copiedScreen,
|
||||
|
|
|
|||
|
|
@ -963,6 +963,15 @@ export async function addTableData(
|
|||
|
||||
logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${result.insertedId}`);
|
||||
|
||||
const systemFields = new Set([
|
||||
"id", "created_date", "updated_date", "writer", "company_code",
|
||||
"createdDate", "updatedDate", "companyCode",
|
||||
]);
|
||||
const auditData: Record<string, any> = {};
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (!systemFields.has(k)) auditData[k] = v;
|
||||
}
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
|
|
@ -973,7 +982,7 @@ export async function addTableData(
|
|||
resourceName: tableName,
|
||||
tableName,
|
||||
summary: `${tableName} 데이터 추가`,
|
||||
changes: { after: data },
|
||||
changes: { after: auditData },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
|
@ -1096,10 +1105,14 @@ export async function editTableData(
|
|||
return;
|
||||
}
|
||||
|
||||
// 변경된 필드만 추출
|
||||
const systemFieldsForEdit = new Set([
|
||||
"id", "created_date", "updated_date", "writer", "company_code",
|
||||
"createdDate", "updatedDate", "companyCode",
|
||||
]);
|
||||
const changedBefore: Record<string, any> = {};
|
||||
const changedAfter: Record<string, any> = {};
|
||||
for (const key of Object.keys(updatedData)) {
|
||||
if (systemFieldsForEdit.has(key)) continue;
|
||||
if (String(originalData[key] ?? "") !== String(updatedData[key] ?? "")) {
|
||||
changedBefore[key] = originalData[key];
|
||||
changedAfter[key] = updatedData[key];
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { getAuditLogs, getAuditLogStats, getAuditLogUsers } from "../controllers/auditLogController";
|
||||
import { getAuditLogs, getAuditLogStats, getAuditLogUsers, createAuditLog } from "../controllers/auditLogController";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/", authenticateToken, getAuditLogs);
|
||||
router.get("/stats", authenticateToken, getAuditLogStats);
|
||||
router.get("/users", authenticateToken, getAuditLogUsers);
|
||||
router.post("/", authenticateToken, createAuditLog);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -104,6 +104,11 @@ interface TaskBody {
|
|||
manualItemField?: string;
|
||||
manualPkColumn?: string;
|
||||
cartScreenId?: string;
|
||||
preCondition?: {
|
||||
column: string;
|
||||
expectedValue: string;
|
||||
failMessage?: string;
|
||||
};
|
||||
}
|
||||
|
||||
function resolveStatusValue(
|
||||
|
|
@ -334,14 +339,30 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
|||
const item = items[i] ?? {};
|
||||
const resolved = resolveStatusValue("conditional", task.fixedValue ?? "", task.conditionalValue, item);
|
||||
const autoUpdated = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
|
||||
await client.query(
|
||||
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1${autoUpdated} WHERE company_code = $2 AND "${pkColumn}" = $3`,
|
||||
[resolved, companyCode, lookupValues[i]],
|
||||
let condWhere = `WHERE company_code = $2 AND "${pkColumn}" = $3`;
|
||||
const condParams: unknown[] = [resolved, companyCode, lookupValues[i]];
|
||||
if (task.preCondition?.column && task.preCondition?.expectedValue) {
|
||||
if (!isSafeIdentifier(task.preCondition.column)) throw new Error(`유효하지 않은 preCondition 컬럼명: ${task.preCondition.column}`);
|
||||
condWhere += ` AND "${task.preCondition.column}" = $4`;
|
||||
condParams.push(task.preCondition.expectedValue);
|
||||
}
|
||||
const condResult = await client.query(
|
||||
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1${autoUpdated} ${condWhere}`,
|
||||
condParams,
|
||||
);
|
||||
if (task.preCondition && condResult.rowCount === 0) {
|
||||
const err = new Error(task.preCondition.failMessage || "조건이 일치하지 않아 처리할 수 없습니다.");
|
||||
(err as any).isPreConditionFail = true;
|
||||
throw err;
|
||||
}
|
||||
processedCount++;
|
||||
}
|
||||
} else if (opType === "db-conditional") {
|
||||
// DB 컬럼 간 비교 후 값 판정 (CASE WHEN col_a >= col_b THEN '완료' ELSE '진행중')
|
||||
if (task.preCondition) {
|
||||
logger.warn("[pop/execute-action] db-conditional에는 preCondition 미지원, 무시됨", {
|
||||
taskId: task.id, preCondition: task.preCondition,
|
||||
});
|
||||
}
|
||||
if (!task.compareColumn || !task.compareOperator || !task.compareWith) break;
|
||||
if (!isSafeIdentifier(task.compareColumn) || !isSafeIdentifier(task.compareWith)) break;
|
||||
|
||||
|
|
@ -392,10 +413,24 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
|||
}
|
||||
|
||||
const autoUpdatedDate = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
|
||||
await client.query(
|
||||
`UPDATE "${task.targetTable}" SET ${setSql}${autoUpdatedDate} WHERE company_code = $2 AND "${pkColumn}" = $3`,
|
||||
[value, companyCode, lookupValues[i]],
|
||||
let whereSql = `WHERE company_code = $2 AND "${pkColumn}" = $3`;
|
||||
const queryParams: unknown[] = [value, companyCode, lookupValues[i]];
|
||||
if (task.preCondition?.column && task.preCondition?.expectedValue) {
|
||||
if (!isSafeIdentifier(task.preCondition.column)) {
|
||||
throw new Error(`유효하지 않은 preCondition 컬럼명: ${task.preCondition.column}`);
|
||||
}
|
||||
whereSql += ` AND "${task.preCondition.column}" = $4`;
|
||||
queryParams.push(task.preCondition.expectedValue);
|
||||
}
|
||||
const updateResult = await client.query(
|
||||
`UPDATE "${task.targetTable}" SET ${setSql}${autoUpdatedDate} ${whereSql}`,
|
||||
queryParams,
|
||||
);
|
||||
if (task.preCondition && updateResult.rowCount === 0) {
|
||||
const err = new Error(task.preCondition.failMessage || "조건이 일치하지 않아 처리할 수 없습니다.");
|
||||
(err as any).isPreConditionFail = true;
|
||||
throw err;
|
||||
}
|
||||
processedCount++;
|
||||
}
|
||||
}
|
||||
|
|
@ -746,6 +781,16 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
|||
});
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
|
||||
if (error.isPreConditionFail) {
|
||||
logger.warn("[pop/execute-action] preCondition 실패", { message: error.message });
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
errorCode: "PRE_CONDITION_FAIL",
|
||||
});
|
||||
}
|
||||
|
||||
logger.error("[pop/execute-action] 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import {
|
||||
createWorkProcesses,
|
||||
controlTimer,
|
||||
} from "../controllers/popProductionController";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticateToken);
|
||||
|
||||
router.post("/create-work-processes", createWorkProcesses);
|
||||
router.post("/timer", controlTimer);
|
||||
|
||||
export default router;
|
||||
|
|
@ -33,4 +33,10 @@ router.delete("/work-item-details/:id", ctrl.deleteWorkItemDetail);
|
|||
// 전체 저장 (일괄)
|
||||
router.put("/save-all", ctrl.saveAll);
|
||||
|
||||
// 등록 품목 관리 (화면별 품목 목록)
|
||||
router.get("/registered-items/:screenCode", ctrl.getRegisteredItems);
|
||||
router.post("/registered-items", ctrl.registerItem);
|
||||
router.post("/registered-items/batch", ctrl.registerItemsBatch);
|
||||
router.delete("/registered-items/:id", ctrl.unregisterItem);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* 생산계획 라우트
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import * as productionController from "../controllers/productionController";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 수주 데이터 조회 (품목별 그룹핑)
|
||||
router.get("/order-summary", productionController.getOrderSummary);
|
||||
|
||||
// 안전재고 부족분 조회
|
||||
router.get("/stock-shortage", productionController.getStockShortage);
|
||||
|
||||
// 생산계획 CRUD
|
||||
router.get("/plan/:id", productionController.getPlanById);
|
||||
router.put("/plan/:id", productionController.updatePlan);
|
||||
router.delete("/plan/:id", productionController.deletePlan);
|
||||
|
||||
// 자동 스케줄 생성
|
||||
router.post("/generate-schedule", productionController.generateSchedule);
|
||||
|
||||
// 스케줄 병합
|
||||
router.post("/merge-schedules", productionController.mergeSchedules);
|
||||
|
||||
// 반제품 계획 자동 생성
|
||||
router.post("/generate-semi-schedule", productionController.generateSemiSchedule);
|
||||
|
||||
// 스케줄 분할
|
||||
router.post("/plan/:id/split", productionController.splitSchedule);
|
||||
|
||||
export default router;
|
||||
|
|
@ -66,6 +66,7 @@ export interface AuditLogParams {
|
|||
export interface AuditLogEntry {
|
||||
id: number;
|
||||
company_code: string;
|
||||
company_name: string | null;
|
||||
user_id: string;
|
||||
user_name: string | null;
|
||||
action: string;
|
||||
|
|
@ -107,6 +108,7 @@ class AuditLogService {
|
|||
*/
|
||||
async log(params: AuditLogParams): Promise<void> {
|
||||
try {
|
||||
logger.info(`[AuditLog] 기록 시도: ${params.resourceType} / ${params.action} / ${params.resourceName || params.resourceId || "N/A"}`);
|
||||
await query(
|
||||
`INSERT INTO system_audit_log
|
||||
(company_code, user_id, user_name, action, resource_type,
|
||||
|
|
@ -128,8 +130,9 @@ class AuditLogService {
|
|||
params.requestPath || null,
|
||||
]
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("감사 로그 기록 실패 (무시됨)", { error, params });
|
||||
logger.info(`[AuditLog] 기록 성공: ${params.resourceType} / ${params.action}`);
|
||||
} catch (error: any) {
|
||||
logger.error(`[AuditLog] 기록 실패: ${params.resourceType} / ${params.action} - ${error?.message}`, { error, params });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -186,40 +189,40 @@ class AuditLogService {
|
|||
let paramIndex = 1;
|
||||
|
||||
if (!isSuperAdmin && filters.companyCode) {
|
||||
conditions.push(`company_code = $${paramIndex++}`);
|
||||
conditions.push(`sal.company_code = $${paramIndex++}`);
|
||||
params.push(filters.companyCode);
|
||||
} else if (isSuperAdmin && filters.companyCode) {
|
||||
conditions.push(`company_code = $${paramIndex++}`);
|
||||
conditions.push(`sal.company_code = $${paramIndex++}`);
|
||||
params.push(filters.companyCode);
|
||||
}
|
||||
|
||||
if (filters.userId) {
|
||||
conditions.push(`user_id = $${paramIndex++}`);
|
||||
conditions.push(`sal.user_id = $${paramIndex++}`);
|
||||
params.push(filters.userId);
|
||||
}
|
||||
if (filters.resourceType) {
|
||||
conditions.push(`resource_type = $${paramIndex++}`);
|
||||
conditions.push(`sal.resource_type = $${paramIndex++}`);
|
||||
params.push(filters.resourceType);
|
||||
}
|
||||
if (filters.action) {
|
||||
conditions.push(`action = $${paramIndex++}`);
|
||||
conditions.push(`sal.action = $${paramIndex++}`);
|
||||
params.push(filters.action);
|
||||
}
|
||||
if (filters.tableName) {
|
||||
conditions.push(`table_name = $${paramIndex++}`);
|
||||
conditions.push(`sal.table_name = $${paramIndex++}`);
|
||||
params.push(filters.tableName);
|
||||
}
|
||||
if (filters.dateFrom) {
|
||||
conditions.push(`created_at >= $${paramIndex++}::timestamptz`);
|
||||
conditions.push(`sal.created_at >= $${paramIndex++}::timestamptz`);
|
||||
params.push(filters.dateFrom);
|
||||
}
|
||||
if (filters.dateTo) {
|
||||
conditions.push(`created_at <= $${paramIndex++}::timestamptz`);
|
||||
conditions.push(`sal.created_at <= $${paramIndex++}::timestamptz`);
|
||||
params.push(filters.dateTo);
|
||||
}
|
||||
if (filters.search) {
|
||||
conditions.push(
|
||||
`(summary ILIKE $${paramIndex} OR resource_name ILIKE $${paramIndex} OR user_name ILIKE $${paramIndex})`
|
||||
`(sal.summary ILIKE $${paramIndex} OR sal.resource_name ILIKE $${paramIndex} OR sal.user_name ILIKE $${paramIndex})`
|
||||
);
|
||||
params.push(`%${filters.search}%`);
|
||||
paramIndex++;
|
||||
|
|
@ -233,14 +236,17 @@ class AuditLogService {
|
|||
const offset = (page - 1) * limit;
|
||||
|
||||
const countResult = await query<{ count: string }>(
|
||||
`SELECT COUNT(*) as count FROM system_audit_log ${whereClause}`,
|
||||
`SELECT COUNT(*) as count FROM system_audit_log sal ${whereClause}`,
|
||||
params
|
||||
);
|
||||
const total = parseInt(countResult[0].count, 10);
|
||||
|
||||
const data = await query<AuditLogEntry>(
|
||||
`SELECT * FROM system_audit_log ${whereClause}
|
||||
ORDER BY created_at DESC
|
||||
`SELECT sal.*, ci.company_name
|
||||
FROM system_audit_log sal
|
||||
LEFT JOIN company_mng ci ON sal.company_code = ci.company_code
|
||||
${whereClause}
|
||||
ORDER BY sal.created_at DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
||||
[...params, limit, offset]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,668 @@
|
|||
/**
|
||||
* 생산계획 서비스
|
||||
* - 수주 데이터 조회 (품목별 그룹핑)
|
||||
* - 안전재고 부족분 조회
|
||||
* - 자동 스케줄 생성
|
||||
* - 스케줄 병합
|
||||
* - 반제품 계획 자동 생성
|
||||
* - 스케줄 분할
|
||||
*/
|
||||
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// ─── 수주 데이터 조회 (품목별 그룹핑) ───
|
||||
|
||||
export async function getOrderSummary(
|
||||
companyCode: string,
|
||||
options?: { excludePlanned?: boolean; itemCode?: string; itemName?: string }
|
||||
) {
|
||||
const pool = getPool();
|
||||
const conditions: string[] = ["so.company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIdx = 2;
|
||||
|
||||
if (options?.itemCode) {
|
||||
conditions.push(`so.part_code ILIKE $${paramIdx}`);
|
||||
params.push(`%${options.itemCode}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
if (options?.itemName) {
|
||||
conditions.push(`so.part_name ILIKE $${paramIdx}`);
|
||||
params.push(`%${options.itemName}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(" AND ");
|
||||
|
||||
const query = `
|
||||
WITH order_summary AS (
|
||||
SELECT
|
||||
so.part_code AS item_code,
|
||||
COALESCE(so.part_name, so.part_code) AS item_name,
|
||||
SUM(COALESCE(so.order_qty::numeric, 0)) AS total_order_qty,
|
||||
SUM(COALESCE(so.ship_qty::numeric, 0)) AS total_ship_qty,
|
||||
SUM(COALESCE(so.balance_qty::numeric, 0)) AS total_balance_qty,
|
||||
COUNT(*) AS order_count,
|
||||
MIN(so.due_date) AS earliest_due_date
|
||||
FROM sales_order_mng so
|
||||
WHERE ${whereClause}
|
||||
GROUP BY so.part_code, so.part_name
|
||||
),
|
||||
stock_info AS (
|
||||
SELECT
|
||||
item_code,
|
||||
SUM(COALESCE(current_qty::numeric, 0)) AS current_stock,
|
||||
MAX(COALESCE(safety_qty::numeric, 0)) AS safety_stock
|
||||
FROM inventory_stock
|
||||
WHERE company_code = $1
|
||||
GROUP BY item_code
|
||||
),
|
||||
plan_info AS (
|
||||
SELECT
|
||||
item_code,
|
||||
SUM(CASE WHEN status = 'planned' THEN COALESCE(plan_qty, 0) ELSE 0 END) AS existing_plan_qty,
|
||||
SUM(CASE WHEN status = 'in_progress' THEN COALESCE(plan_qty, 0) ELSE 0 END) AS in_progress_qty
|
||||
FROM production_plan_mng
|
||||
WHERE company_code = $1
|
||||
AND COALESCE(product_type, '완제품') = '완제품'
|
||||
AND status NOT IN ('completed', 'cancelled')
|
||||
GROUP BY item_code
|
||||
)
|
||||
SELECT
|
||||
os.item_code,
|
||||
os.item_name,
|
||||
os.total_order_qty,
|
||||
os.total_ship_qty,
|
||||
os.total_balance_qty,
|
||||
os.order_count,
|
||||
os.earliest_due_date,
|
||||
COALESCE(si.current_stock, 0) AS current_stock,
|
||||
COALESCE(si.safety_stock, 0) AS safety_stock,
|
||||
COALESCE(pi.existing_plan_qty, 0) AS existing_plan_qty,
|
||||
COALESCE(pi.in_progress_qty, 0) AS in_progress_qty,
|
||||
GREATEST(
|
||||
os.total_balance_qty + COALESCE(si.safety_stock, 0) - COALESCE(si.current_stock, 0)
|
||||
- COALESCE(pi.existing_plan_qty, 0) - COALESCE(pi.in_progress_qty, 0),
|
||||
0
|
||||
) AS required_plan_qty
|
||||
FROM order_summary os
|
||||
LEFT JOIN stock_info si ON os.item_code = si.item_code
|
||||
LEFT JOIN plan_info pi ON os.item_code = pi.item_code
|
||||
${options?.excludePlanned ? "WHERE COALESCE(pi.existing_plan_qty, 0) = 0" : ""}
|
||||
ORDER BY os.item_code;
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
// 그룹별 상세 수주 데이터도 함께 조회
|
||||
const detailWhere = conditions.map(c => c.replace(/so\./g, "")).join(" AND ");
|
||||
const detailQuery = `
|
||||
SELECT
|
||||
id, order_no, part_code, part_name,
|
||||
COALESCE(order_qty::numeric, 0) AS order_qty,
|
||||
COALESCE(ship_qty::numeric, 0) AS ship_qty,
|
||||
COALESCE(balance_qty::numeric, 0) AS balance_qty,
|
||||
due_date, status, partner_id, manager_name
|
||||
FROM sales_order_mng
|
||||
WHERE ${detailWhere}
|
||||
ORDER BY part_code, due_date;
|
||||
`;
|
||||
const detailResult = await pool.query(detailQuery, params);
|
||||
|
||||
// 그룹별로 상세 데이터 매핑
|
||||
const ordersByItem: Record<string, any[]> = {};
|
||||
for (const row of detailResult.rows) {
|
||||
const key = row.part_code || "__null__";
|
||||
if (!ordersByItem[key]) ordersByItem[key] = [];
|
||||
ordersByItem[key].push(row);
|
||||
}
|
||||
|
||||
const data = result.rows.map((group: any) => ({
|
||||
...group,
|
||||
orders: ordersByItem[group.item_code || "__null__"] || [],
|
||||
}));
|
||||
|
||||
logger.info("수주 데이터 조회", { companyCode, groupCount: data.length });
|
||||
return data;
|
||||
}
|
||||
|
||||
// ─── 안전재고 부족분 조회 ───
|
||||
|
||||
export async function getStockShortage(companyCode: string) {
|
||||
const pool = getPool();
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
ist.item_code,
|
||||
ii.item_name,
|
||||
COALESCE(ist.current_qty::numeric, 0) AS current_qty,
|
||||
COALESCE(ist.safety_qty::numeric, 0) AS safety_qty,
|
||||
(COALESCE(ist.current_qty::numeric, 0) - COALESCE(ist.safety_qty::numeric, 0)) AS shortage_qty,
|
||||
GREATEST(
|
||||
COALESCE(ist.safety_qty::numeric, 0) * 2 - COALESCE(ist.current_qty::numeric, 0), 0
|
||||
) AS recommended_qty,
|
||||
ist.last_in_date
|
||||
FROM inventory_stock ist
|
||||
LEFT JOIN item_info ii ON ist.item_code = ii.id AND ist.company_code = ii.company_code
|
||||
WHERE ist.company_code = $1
|
||||
AND COALESCE(ist.current_qty::numeric, 0) < COALESCE(ist.safety_qty::numeric, 0)
|
||||
ORDER BY shortage_qty ASC;
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [companyCode]);
|
||||
logger.info("안전재고 부족분 조회", { companyCode, count: result.rowCount });
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
// ─── 생산계획 CRUD ───
|
||||
|
||||
export async function getPlanById(companyCode: string, planId: number) {
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM production_plan_mng WHERE id = $1 AND company_code = $2`,
|
||||
[planId, companyCode]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
export async function updatePlan(
|
||||
companyCode: string,
|
||||
planId: number,
|
||||
data: Record<string, any>,
|
||||
updatedBy: string
|
||||
) {
|
||||
const pool = getPool();
|
||||
|
||||
const allowedFields = [
|
||||
"plan_qty", "start_date", "end_date", "due_date",
|
||||
"equipment_id", "equipment_code", "equipment_name",
|
||||
"manager_name", "work_shift", "priority", "remarks", "status",
|
||||
"item_code", "item_name", "product_type", "order_no",
|
||||
];
|
||||
|
||||
const setClauses: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
for (const field of allowedFields) {
|
||||
if (data[field] !== undefined) {
|
||||
setClauses.push(`${field} = $${paramIdx}`);
|
||||
params.push(data[field]);
|
||||
paramIdx++;
|
||||
}
|
||||
}
|
||||
|
||||
if (setClauses.length === 0) {
|
||||
throw new Error("수정할 필드가 없습니다");
|
||||
}
|
||||
|
||||
setClauses.push(`updated_date = NOW()`);
|
||||
setClauses.push(`updated_by = $${paramIdx}`);
|
||||
params.push(updatedBy);
|
||||
paramIdx++;
|
||||
|
||||
params.push(planId);
|
||||
params.push(companyCode);
|
||||
|
||||
const query = `
|
||||
UPDATE production_plan_mng
|
||||
SET ${setClauses.join(", ")}
|
||||
WHERE id = $${paramIdx - 1} AND company_code = $${paramIdx}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
if (result.rowCount === 0) {
|
||||
throw new Error("생산계획을 찾을 수 없거나 권한이 없습니다");
|
||||
}
|
||||
logger.info("생산계획 수정", { companyCode, planId });
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
export async function deletePlan(companyCode: string, planId: number) {
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`DELETE FROM production_plan_mng WHERE id = $1 AND company_code = $2 RETURNING id`,
|
||||
[planId, companyCode]
|
||||
);
|
||||
if (result.rowCount === 0) {
|
||||
throw new Error("생산계획을 찾을 수 없거나 권한이 없습니다");
|
||||
}
|
||||
logger.info("생산계획 삭제", { companyCode, planId });
|
||||
return { id: planId };
|
||||
}
|
||||
|
||||
// ─── 자동 스케줄 생성 ───
|
||||
|
||||
interface GenerateScheduleItem {
|
||||
item_code: string;
|
||||
item_name: string;
|
||||
required_qty: number;
|
||||
earliest_due_date: string;
|
||||
hourly_capacity?: number;
|
||||
daily_capacity?: number;
|
||||
lead_time?: number;
|
||||
}
|
||||
|
||||
interface GenerateScheduleOptions {
|
||||
safety_lead_time?: number;
|
||||
recalculate_unstarted?: boolean;
|
||||
product_type?: string;
|
||||
}
|
||||
|
||||
export async function generateSchedule(
|
||||
companyCode: string,
|
||||
items: GenerateScheduleItem[],
|
||||
options: GenerateScheduleOptions,
|
||||
createdBy: string
|
||||
) {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
const productType = options.product_type || "완제품";
|
||||
const safetyLeadTime = options.safety_lead_time || 1;
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
let deletedCount = 0;
|
||||
let keptCount = 0;
|
||||
const newSchedules: any[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
// 기존 미진행(planned) 스케줄 처리
|
||||
if (options.recalculate_unstarted) {
|
||||
const deleteResult = await client.query(
|
||||
`DELETE FROM production_plan_mng
|
||||
WHERE company_code = $1
|
||||
AND item_code = $2
|
||||
AND COALESCE(product_type, '완제품') = $3
|
||||
AND status = 'planned'
|
||||
RETURNING id`,
|
||||
[companyCode, item.item_code, productType]
|
||||
);
|
||||
deletedCount += deleteResult.rowCount || 0;
|
||||
|
||||
const keptResult = await client.query(
|
||||
`SELECT COUNT(*) AS cnt FROM production_plan_mng
|
||||
WHERE company_code = $1
|
||||
AND item_code = $2
|
||||
AND COALESCE(product_type, '완제품') = $3
|
||||
AND status NOT IN ('planned', 'completed', 'cancelled')`,
|
||||
[companyCode, item.item_code, productType]
|
||||
);
|
||||
keptCount += parseInt(keptResult.rows[0].cnt, 10);
|
||||
}
|
||||
|
||||
// 생산일수 계산
|
||||
const dailyCapacity = item.daily_capacity || 800;
|
||||
const requiredQty = item.required_qty;
|
||||
if (requiredQty <= 0) continue;
|
||||
|
||||
const productionDays = Math.ceil(requiredQty / dailyCapacity);
|
||||
|
||||
// 시작일 = 납기일 - 생산일수 - 안전리드타임
|
||||
const dueDate = new Date(item.earliest_due_date);
|
||||
const endDate = new Date(dueDate);
|
||||
endDate.setDate(endDate.getDate() - safetyLeadTime);
|
||||
const startDate = new Date(endDate);
|
||||
startDate.setDate(startDate.getDate() - productionDays);
|
||||
|
||||
// 시작일이 오늘보다 이전이면 오늘로 조정
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
if (startDate < today) {
|
||||
startDate.setTime(today.getTime());
|
||||
endDate.setTime(startDate.getTime());
|
||||
endDate.setDate(endDate.getDate() + productionDays);
|
||||
}
|
||||
|
||||
// 계획번호 생성
|
||||
const planNoResult = await client.query(
|
||||
`SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no
|
||||
FROM production_plan_mng WHERE company_code = $1`,
|
||||
[companyCode]
|
||||
);
|
||||
const nextNo = planNoResult.rows[0].next_no || 1;
|
||||
const planNo = `PP-${String(nextNo).padStart(6, "0")}`;
|
||||
|
||||
const insertResult = await client.query(
|
||||
`INSERT INTO production_plan_mng (
|
||||
company_code, plan_no, plan_date, item_code, item_name,
|
||||
product_type, plan_qty, start_date, end_date, due_date,
|
||||
status, priority, hourly_capacity, daily_capacity, lead_time,
|
||||
created_by, created_date, updated_date
|
||||
) VALUES (
|
||||
$1, $2, CURRENT_DATE, $3, $4,
|
||||
$5, $6, $7, $8, $9,
|
||||
'planned', 'normal', $10, $11, $12,
|
||||
$13, NOW(), NOW()
|
||||
) RETURNING *`,
|
||||
[
|
||||
companyCode, planNo, item.item_code, item.item_name,
|
||||
productType, requiredQty,
|
||||
startDate.toISOString().split("T")[0],
|
||||
endDate.toISOString().split("T")[0],
|
||||
item.earliest_due_date,
|
||||
item.hourly_capacity || 100,
|
||||
dailyCapacity,
|
||||
item.lead_time || 1,
|
||||
createdBy,
|
||||
]
|
||||
);
|
||||
newSchedules.push(insertResult.rows[0]);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
const summary = {
|
||||
total: newSchedules.length + keptCount,
|
||||
new_count: newSchedules.length,
|
||||
kept_count: keptCount,
|
||||
deleted_count: deletedCount,
|
||||
};
|
||||
|
||||
logger.info("자동 스케줄 생성 완료", { companyCode, summary });
|
||||
return { summary, schedules: newSchedules };
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("자동 스케줄 생성 실패", { companyCode, error });
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 스케줄 병합 ───
|
||||
|
||||
export async function mergeSchedules(
|
||||
companyCode: string,
|
||||
scheduleIds: number[],
|
||||
productType: string,
|
||||
mergedBy: string
|
||||
) {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 대상 스케줄 조회
|
||||
const placeholders = scheduleIds.map((_, i) => `$${i + 2}`).join(", ");
|
||||
const targetResult = await client.query(
|
||||
`SELECT * FROM production_plan_mng
|
||||
WHERE company_code = $1 AND id IN (${placeholders})
|
||||
ORDER BY start_date`,
|
||||
[companyCode, ...scheduleIds]
|
||||
);
|
||||
|
||||
if (targetResult.rowCount !== scheduleIds.length) {
|
||||
throw new Error("일부 스케줄을 찾을 수 없습니다");
|
||||
}
|
||||
|
||||
const rows = targetResult.rows;
|
||||
|
||||
// 동일 품목 검증
|
||||
const itemCodes = [...new Set(rows.map((r: any) => r.item_code))];
|
||||
if (itemCodes.length > 1) {
|
||||
throw new Error("동일 품목의 스케줄만 병합할 수 있습니다");
|
||||
}
|
||||
|
||||
// 병합 값 계산
|
||||
const totalQty = rows.reduce((sum: number, r: any) => sum + (parseFloat(r.plan_qty) || 0), 0);
|
||||
const earliestStart = rows.reduce(
|
||||
(min: string, r: any) => (!min || r.start_date < min ? r.start_date : min),
|
||||
""
|
||||
);
|
||||
const latestEnd = rows.reduce(
|
||||
(max: string, r: any) => (!max || r.end_date > max ? r.end_date : max),
|
||||
""
|
||||
);
|
||||
const earliestDue = rows.reduce(
|
||||
(min: string, r: any) => (!min || (r.due_date && r.due_date < min) ? r.due_date : min),
|
||||
""
|
||||
);
|
||||
const orderNos = [...new Set(rows.map((r: any) => r.order_no).filter(Boolean))].join(", ");
|
||||
|
||||
// 기존 삭제
|
||||
await client.query(
|
||||
`DELETE FROM production_plan_mng WHERE company_code = $1 AND id IN (${placeholders})`,
|
||||
[companyCode, ...scheduleIds]
|
||||
);
|
||||
|
||||
// 병합된 스케줄 생성
|
||||
const planNoResult = await client.query(
|
||||
`SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no
|
||||
FROM production_plan_mng WHERE company_code = $1`,
|
||||
[companyCode]
|
||||
);
|
||||
const planNo = `PP-${String(planNoResult.rows[0].next_no || 1).padStart(6, "0")}`;
|
||||
|
||||
const insertResult = await client.query(
|
||||
`INSERT INTO production_plan_mng (
|
||||
company_code, plan_no, plan_date, item_code, item_name,
|
||||
product_type, plan_qty, start_date, end_date, due_date,
|
||||
status, order_no, created_by, created_date, updated_date
|
||||
) VALUES (
|
||||
$1, $2, CURRENT_DATE, $3, $4,
|
||||
$5, $6, $7, $8, $9,
|
||||
'planned', $10, $11, NOW(), NOW()
|
||||
) RETURNING *`,
|
||||
[
|
||||
companyCode, planNo, rows[0].item_code, rows[0].item_name,
|
||||
productType, totalQty,
|
||||
earliestStart, latestEnd, earliestDue || null,
|
||||
orderNos || null, mergedBy,
|
||||
]
|
||||
);
|
||||
|
||||
await client.query("COMMIT");
|
||||
logger.info("스케줄 병합 완료", {
|
||||
companyCode,
|
||||
mergedFrom: scheduleIds,
|
||||
mergedTo: insertResult.rows[0].id,
|
||||
});
|
||||
return insertResult.rows[0];
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("스케줄 병합 실패", { companyCode, error });
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 반제품 계획 자동 생성 ───
|
||||
|
||||
export async function generateSemiSchedule(
|
||||
companyCode: string,
|
||||
planIds: number[],
|
||||
options: { considerStock?: boolean; excludeUsed?: boolean },
|
||||
createdBy: string
|
||||
) {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 선택된 완제품 계획 조회
|
||||
const placeholders = planIds.map((_, i) => `$${i + 2}`).join(", ");
|
||||
const plansResult = await client.query(
|
||||
`SELECT * FROM production_plan_mng
|
||||
WHERE company_code = $1 AND id IN (${placeholders})`,
|
||||
[companyCode, ...planIds]
|
||||
);
|
||||
|
||||
const newSemiPlans: any[] = [];
|
||||
|
||||
for (const plan of plansResult.rows) {
|
||||
// BOM에서 해당 품목의 반제품 소요량 조회
|
||||
const bomQuery = `
|
||||
SELECT
|
||||
bd.child_item_id,
|
||||
ii.item_name AS child_item_name,
|
||||
ii.item_code AS child_item_code,
|
||||
bd.quantity AS bom_qty,
|
||||
bd.unit
|
||||
FROM bom b
|
||||
JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code
|
||||
LEFT JOIN item_info ii ON bd.child_item_id = ii.id AND bd.company_code = ii.company_code
|
||||
WHERE b.company_code = $1
|
||||
AND b.item_code = $2
|
||||
AND COALESCE(b.status, 'active') = 'active'
|
||||
`;
|
||||
const bomResult = await client.query(bomQuery, [companyCode, plan.item_code]);
|
||||
|
||||
for (const bomItem of bomResult.rows) {
|
||||
let requiredQty = (parseFloat(plan.plan_qty) || 0) * (parseFloat(bomItem.bom_qty) || 1);
|
||||
|
||||
// 재고 고려
|
||||
if (options.considerStock) {
|
||||
const stockResult = await client.query(
|
||||
`SELECT COALESCE(SUM(current_qty::numeric), 0) AS stock
|
||||
FROM inventory_stock
|
||||
WHERE company_code = $1 AND item_code = $2`,
|
||||
[companyCode, bomItem.child_item_code || bomItem.child_item_id]
|
||||
);
|
||||
const stock = parseFloat(stockResult.rows[0].stock) || 0;
|
||||
requiredQty = Math.max(requiredQty - stock, 0);
|
||||
}
|
||||
|
||||
if (requiredQty <= 0) continue;
|
||||
|
||||
// 반제품 납기일 = 완제품 시작일
|
||||
const semiDueDate = plan.start_date;
|
||||
const semiEndDate = plan.start_date;
|
||||
const semiStartDate = new Date(plan.start_date);
|
||||
semiStartDate.setDate(semiStartDate.getDate() - (plan.lead_time || 1));
|
||||
|
||||
const planNoResult = await client.query(
|
||||
`SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no
|
||||
FROM production_plan_mng WHERE company_code = $1`,
|
||||
[companyCode]
|
||||
);
|
||||
const planNo = `PP-${String(planNoResult.rows[0].next_no || 1).padStart(6, "0")}`;
|
||||
|
||||
const insertResult = await client.query(
|
||||
`INSERT INTO production_plan_mng (
|
||||
company_code, plan_no, plan_date, item_code, item_name,
|
||||
product_type, plan_qty, start_date, end_date, due_date,
|
||||
status, parent_plan_id, created_by, created_date, updated_date
|
||||
) VALUES (
|
||||
$1, $2, CURRENT_DATE, $3, $4,
|
||||
'반제품', $5, $6, $7, $8,
|
||||
'planned', $9, $10, NOW(), NOW()
|
||||
) RETURNING *`,
|
||||
[
|
||||
companyCode, planNo,
|
||||
bomItem.child_item_code || bomItem.child_item_id,
|
||||
bomItem.child_item_name || bomItem.child_item_id,
|
||||
requiredQty,
|
||||
semiStartDate.toISOString().split("T")[0],
|
||||
typeof semiEndDate === "string" ? semiEndDate : semiEndDate.toISOString().split("T")[0],
|
||||
typeof semiDueDate === "string" ? semiDueDate : semiDueDate.toISOString().split("T")[0],
|
||||
plan.id,
|
||||
createdBy,
|
||||
]
|
||||
);
|
||||
newSemiPlans.push(insertResult.rows[0]);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
logger.info("반제품 계획 생성 완료", {
|
||||
companyCode,
|
||||
parentPlanIds: planIds,
|
||||
semiPlanCount: newSemiPlans.length,
|
||||
});
|
||||
return { count: newSemiPlans.length, schedules: newSemiPlans };
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("반제품 계획 생성 실패", { companyCode, error });
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 스케줄 분할 ───
|
||||
|
||||
export async function splitSchedule(
|
||||
companyCode: string,
|
||||
planId: number,
|
||||
splitQty: number,
|
||||
splitBy: string
|
||||
) {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
const planResult = await client.query(
|
||||
`SELECT * FROM production_plan_mng WHERE id = $1 AND company_code = $2`,
|
||||
[planId, companyCode]
|
||||
);
|
||||
if (planResult.rowCount === 0) {
|
||||
throw new Error("생산계획을 찾을 수 없습니다");
|
||||
}
|
||||
|
||||
const plan = planResult.rows[0];
|
||||
const originalQty = parseFloat(plan.plan_qty) || 0;
|
||||
|
||||
if (splitQty >= originalQty || splitQty <= 0) {
|
||||
throw new Error("분할 수량은 0보다 크고 원래 수량보다 작아야 합니다");
|
||||
}
|
||||
|
||||
// 원본 수량 감소
|
||||
await client.query(
|
||||
`UPDATE production_plan_mng SET plan_qty = $1, updated_date = NOW(), updated_by = $2
|
||||
WHERE id = $3 AND company_code = $4`,
|
||||
[originalQty - splitQty, splitBy, planId, companyCode]
|
||||
);
|
||||
|
||||
// 분할된 새 계획 생성
|
||||
const planNoResult = await client.query(
|
||||
`SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no
|
||||
FROM production_plan_mng WHERE company_code = $1`,
|
||||
[companyCode]
|
||||
);
|
||||
const planNo = `PP-${String(planNoResult.rows[0].next_no || 1).padStart(6, "0")}`;
|
||||
|
||||
const insertResult = await client.query(
|
||||
`INSERT INTO production_plan_mng (
|
||||
company_code, plan_no, plan_date, item_code, item_name,
|
||||
product_type, plan_qty, start_date, end_date, due_date,
|
||||
status, priority, equipment_id, equipment_code, equipment_name,
|
||||
order_no, parent_plan_id, created_by, created_date, updated_date
|
||||
) VALUES (
|
||||
$1, $2, CURRENT_DATE, $3, $4,
|
||||
$5, $6, $7, $8, $9,
|
||||
$10, $11, $12, $13, $14,
|
||||
$15, $16, $17, NOW(), NOW()
|
||||
) RETURNING *`,
|
||||
[
|
||||
companyCode, planNo, plan.item_code, plan.item_name,
|
||||
plan.product_type, splitQty,
|
||||
plan.start_date, plan.end_date, plan.due_date,
|
||||
plan.status, plan.priority, plan.equipment_id, plan.equipment_code, plan.equipment_name,
|
||||
plan.order_no, plan.parent_plan_id,
|
||||
splitBy,
|
||||
]
|
||||
);
|
||||
|
||||
await client.query("COMMIT");
|
||||
logger.info("스케줄 분할 완료", { companyCode, planId, splitQty });
|
||||
return {
|
||||
original: { id: planId, plan_qty: originalQty - splitQty },
|
||||
split: insertResult.rows[0],
|
||||
};
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("스케줄 분할 실패", { companyCode, error });
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,620 @@
|
|||
# POP 작업진행 관리 설계서
|
||||
|
||||
> 작성일: 2026-03-13
|
||||
> 목적: POP 시스템에서 작업지시 기반으로 라우팅/작업기준정보를 조회하고, 공정별 작업 진행 상태를 관리하는 구조 설계
|
||||
|
||||
---
|
||||
|
||||
## 1. 핵심 설계 원칙
|
||||
|
||||
**작업지시에 라우팅ID, 작업기준정보ID 등을 별도 컬럼으로 넣지 않는다.**
|
||||
|
||||
- 작업지시(`work_instruction`)에는 `item_id`(품목 ID)만 있으면 충분
|
||||
- 품목 → 라우팅 → 작업기준정보는 마스터 데이터 체인으로 조회
|
||||
- 작업 진행 상태만 별도 테이블에서 관리
|
||||
|
||||
---
|
||||
|
||||
## 2. 기존 테이블 구조 (마스터 데이터)
|
||||
|
||||
### 2-1. ER 다이어그램
|
||||
|
||||
> GitHub / VSCode Mermaid 플러그인에서 렌더링됩니다.
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
%% ========== 마스터 데이터 (변경 없음) ==========
|
||||
|
||||
item_info {
|
||||
varchar id PK "UUID"
|
||||
varchar item_number "품번"
|
||||
varchar item_name "품명"
|
||||
varchar company_code "회사코드"
|
||||
}
|
||||
|
||||
item_routing_version {
|
||||
varchar id PK "UUID"
|
||||
varchar item_code "품번 (= item_info.item_number)"
|
||||
varchar version_name "버전명"
|
||||
boolean is_default "기본버전 여부"
|
||||
varchar company_code "회사코드"
|
||||
}
|
||||
|
||||
item_routing_detail {
|
||||
varchar id PK "UUID"
|
||||
varchar routing_version_id FK "→ item_routing_version.id"
|
||||
varchar seq_no "공정순서 10,20,30..."
|
||||
varchar process_code FK "→ process_mng.process_code"
|
||||
varchar is_required "필수/선택"
|
||||
varchar is_fixed_order "고정/선택"
|
||||
varchar standard_time "표준시간(분)"
|
||||
varchar company_code "회사코드"
|
||||
}
|
||||
|
||||
process_mng {
|
||||
varchar id PK "UUID"
|
||||
varchar process_code "공정코드"
|
||||
varchar process_name "공정명"
|
||||
varchar process_type "공정유형"
|
||||
varchar company_code "회사코드"
|
||||
}
|
||||
|
||||
process_work_item {
|
||||
varchar id PK "UUID"
|
||||
varchar routing_detail_id FK "→ item_routing_detail.id"
|
||||
varchar work_phase "PRE / IN / POST"
|
||||
varchar title "작업항목명"
|
||||
varchar is_required "Y/N"
|
||||
int sort_order "정렬순서"
|
||||
varchar company_code "회사코드"
|
||||
}
|
||||
|
||||
process_work_item_detail {
|
||||
varchar id PK "UUID"
|
||||
varchar work_item_id FK "→ process_work_item.id"
|
||||
varchar detail_type "check/inspect/input/procedure/info"
|
||||
varchar content "내용"
|
||||
varchar input_type "입력타입"
|
||||
varchar inspection_code "검사코드"
|
||||
varchar unit "단위"
|
||||
varchar lower_limit "하한값"
|
||||
varchar upper_limit "상한값"
|
||||
varchar company_code "회사코드"
|
||||
}
|
||||
|
||||
%% ========== 트랜잭션 데이터 ==========
|
||||
|
||||
work_instruction {
|
||||
varchar id PK "UUID"
|
||||
varchar work_instruction_no "작업지시번호"
|
||||
varchar item_id FK "→ item_info.id ★핵심★"
|
||||
varchar status "waiting/in_progress/completed/cancelled"
|
||||
varchar qty "지시수량"
|
||||
varchar completed_qty "완성수량"
|
||||
varchar worker "작업자"
|
||||
varchar company_code "회사코드"
|
||||
}
|
||||
|
||||
work_order_process {
|
||||
varchar id PK "UUID"
|
||||
varchar wo_id FK "→ work_instruction.id"
|
||||
varchar routing_detail_id FK "→ item_routing_detail.id ★추가★"
|
||||
varchar seq_no "공정순서"
|
||||
varchar process_code "공정코드"
|
||||
varchar process_name "공정명"
|
||||
varchar status "waiting/in_progress/completed/skipped"
|
||||
varchar plan_qty "계획수량"
|
||||
varchar good_qty "양품수량"
|
||||
varchar defect_qty "불량수량"
|
||||
timestamp started_at "시작시간"
|
||||
timestamp completed_at "완료시간"
|
||||
varchar company_code "회사코드"
|
||||
}
|
||||
|
||||
work_order_work_item {
|
||||
varchar id PK "UUID ★신규★"
|
||||
varchar company_code "회사코드"
|
||||
varchar work_order_process_id FK "→ work_order_process.id"
|
||||
varchar work_item_id FK "→ process_work_item.id"
|
||||
varchar work_phase "PRE/IN/POST"
|
||||
varchar status "pending/completed/skipped/failed"
|
||||
varchar completed_by "완료자"
|
||||
timestamp completed_at "완료시간"
|
||||
}
|
||||
|
||||
work_order_work_item_result {
|
||||
varchar id PK "UUID ★신규★"
|
||||
varchar company_code "회사코드"
|
||||
varchar work_order_work_item_id FK "→ work_order_work_item.id"
|
||||
varchar work_item_detail_id FK "→ process_work_item_detail.id"
|
||||
varchar detail_type "check/inspect/input/procedure"
|
||||
varchar result_value "결과값"
|
||||
varchar is_passed "Y/N/null"
|
||||
varchar recorded_by "기록자"
|
||||
timestamp recorded_at "기록시간"
|
||||
}
|
||||
|
||||
%% ========== 관계 ==========
|
||||
|
||||
%% 마스터 체인: 품목 → 라우팅 → 작업기준정보
|
||||
item_info ||--o{ item_routing_version : "item_number = item_code"
|
||||
item_routing_version ||--o{ item_routing_detail : "id = routing_version_id"
|
||||
item_routing_detail }o--|| process_mng : "process_code"
|
||||
item_routing_detail ||--o{ process_work_item : "id = routing_detail_id"
|
||||
process_work_item ||--o{ process_work_item_detail : "id = work_item_id"
|
||||
|
||||
%% 트랜잭션: 작업지시 → 공정진행 → 작업기준정보 진행
|
||||
work_instruction }o--|| item_info : "item_id = id"
|
||||
work_instruction ||--o{ work_order_process : "id = wo_id"
|
||||
work_order_process }o--|| item_routing_detail : "routing_detail_id = id"
|
||||
work_order_process ||--o{ work_order_work_item : "id = work_order_process_id"
|
||||
work_order_work_item }o--|| process_work_item : "work_item_id = id"
|
||||
work_order_work_item ||--o{ work_order_work_item_result : "id = work_order_work_item_id"
|
||||
work_order_work_item_result }o--|| process_work_item_detail : "work_item_detail_id = id"
|
||||
```
|
||||
|
||||
### 2-1-1. 관계 요약 (텍스트)
|
||||
|
||||
```
|
||||
[마스터 데이터 체인 - 조회용, 변경 없음]
|
||||
|
||||
item_info ─── 1:N ───→ item_routing_version ─── 1:N ───→ item_routing_detail
|
||||
(품목) item_number (라우팅 버전) routing_ (공정별 상세)
|
||||
= item_code version_id
|
||||
│
|
||||
process_mng ◄───┘ process_code (공정 마스터)
|
||||
│
|
||||
├── 1:N ───→ process_work_item ─── 1:N ───→ process_work_item_detail
|
||||
│ (작업기준정보) (작업기준정보 상세)
|
||||
│ routing_detail_id work_item_id
|
||||
│
|
||||
[트랜잭션 데이터 - 상태 관리] │
|
||||
│
|
||||
work_instruction ─── 1:N ───→ work_order_process ─┘ routing_detail_id (★추가★)
|
||||
(작업지시) wo_id (공정별 진행)
|
||||
item_id → item_info │
|
||||
├── 1:N ───→ work_order_work_item ─── 1:N ───→ work_order_work_item_result
|
||||
│ (작업기준정보 진행) (상세 결과값)
|
||||
│ work_order_process_id work_order_work_item_id
|
||||
│ work_item_id → process_work_item work_item_detail_id → process_work_item_detail
|
||||
│ ★신규 테이블★ ★신규 테이블★
|
||||
```
|
||||
|
||||
### 2-2. 마스터 테이블 상세
|
||||
|
||||
#### item_info (품목 마스터)
|
||||
| 컬럼 | 설명 | 비고 |
|
||||
|------|------|------|
|
||||
| id | PK (UUID) | |
|
||||
| item_number | 품번 | item_routing_version.item_code와 매칭 |
|
||||
| item_name | 품명 | |
|
||||
| company_code | 회사코드 | 멀티테넌시 |
|
||||
|
||||
#### item_routing_version (라우팅 버전)
|
||||
| 컬럼 | 설명 | 비고 |
|
||||
|------|------|------|
|
||||
| id | PK (UUID) | |
|
||||
| item_code | 품번 | item_info.item_number와 매칭 |
|
||||
| version_name | 버전명 | 예: "기본 라우팅", "버전2" |
|
||||
| is_default | 기본 버전 여부 | true/false, 기본 버전을 사용 |
|
||||
| company_code | 회사코드 | |
|
||||
|
||||
#### item_routing_detail (라우팅 상세 - 공정별)
|
||||
| 컬럼 | 설명 | 비고 |
|
||||
|------|------|------|
|
||||
| id | PK (UUID) | |
|
||||
| routing_version_id | FK → item_routing_version.id | |
|
||||
| seq_no | 공정 순서 | 10, 20, 30... |
|
||||
| process_code | 공정코드 | FK → process_mng.process_code |
|
||||
| is_required | 필수/선택 | "필수" / "선택" |
|
||||
| is_fixed_order | 순서고정 여부 | "고정" / "선택" |
|
||||
| work_type | 작업유형 | |
|
||||
| standard_time | 표준시간(분) | |
|
||||
| outsource_supplier | 외주업체 | |
|
||||
| company_code | 회사코드 | |
|
||||
|
||||
#### process_work_item (작업기준정보)
|
||||
| 컬럼 | 설명 | 비고 |
|
||||
|------|------|------|
|
||||
| id | PK (UUID) | |
|
||||
| routing_detail_id | FK → item_routing_detail.id | |
|
||||
| work_phase | 작업단계 | PRE(작업전) / IN(작업중) / POST(작업후) |
|
||||
| title | 작업항목명 | 예: "장비 체크", "소재 준비" |
|
||||
| is_required | 필수여부 | Y/N |
|
||||
| sort_order | 정렬순서 | |
|
||||
| description | 설명 | |
|
||||
| company_code | 회사코드 | |
|
||||
|
||||
#### process_work_item_detail (작업기준정보 상세)
|
||||
| 컬럼 | 설명 | 비고 |
|
||||
|------|------|------|
|
||||
| id | PK (UUID) | |
|
||||
| work_item_id | FK → process_work_item.id | |
|
||||
| detail_type | 상세유형 | check(체크) / inspect(검사) / input(입력) / procedure(절차) / info(정보) |
|
||||
| content | 내용 | 예: "소음검사", "치수검사" |
|
||||
| input_type | 입력타입 | select, text 등 |
|
||||
| inspection_code | 검사코드 | |
|
||||
| inspection_method | 검사방법 | |
|
||||
| unit | 단위 | |
|
||||
| lower_limit | 하한값 | |
|
||||
| upper_limit | 상한값 | |
|
||||
| is_required | 필수여부 | Y/N |
|
||||
| sort_order | 정렬순서 | |
|
||||
| company_code | 회사코드 | |
|
||||
|
||||
---
|
||||
|
||||
## 3. 작업 진행 테이블 (트랜잭션 데이터)
|
||||
|
||||
### 3-1. work_instruction (작업지시) - 기존 테이블
|
||||
|
||||
| 컬럼 | 설명 | 비고 |
|
||||
|------|------|------|
|
||||
| id | PK (UUID) | |
|
||||
| work_instruction_no | 작업지시번호 | 예: WO-2026-001 |
|
||||
| **item_id** | **FK → item_info.id** | **이것만으로 라우팅/작업기준정보 전부 조회 가능** |
|
||||
| status | 작업지시 상태 | waiting / in_progress / completed / cancelled |
|
||||
| qty | 지시수량 | |
|
||||
| completed_qty | 완성수량 | |
|
||||
| work_team | 작업팀 | |
|
||||
| worker | 작업자 | |
|
||||
| equipment_id | 설비 | |
|
||||
| start_date | 시작일 | |
|
||||
| end_date | 종료일 | |
|
||||
| remark | 비고 | |
|
||||
| company_code | 회사코드 | |
|
||||
|
||||
> **routing 컬럼**: 현재 존재하지만 사용하지 않음 (null). 라우팅 버전을 지정하고 싶으면 이 컬럼에 `item_routing_version.id`를 넣어 특정 버전을 지정할 수 있음. 없으면 `is_default=true` 버전 자동 사용.
|
||||
|
||||
### 3-2. work_order_process (공정별 진행) - 기존 테이블, 변경 필요
|
||||
|
||||
작업지시가 생성될 때, 해당 품목의 라우팅 공정을 복사해서 이 테이블에 INSERT.
|
||||
|
||||
| 컬럼 | 설명 | 비고 |
|
||||
|------|------|------|
|
||||
| id | PK (UUID) | |
|
||||
| wo_id | FK → work_instruction.id | 작업지시 참조 |
|
||||
| **routing_detail_id** | **FK → item_routing_detail.id** | **추가 필요 - 라우팅 상세 참조** |
|
||||
| seq_no | 공정 순서 | 라우팅에서 복사 |
|
||||
| process_code | 공정코드 | 라우팅에서 복사 |
|
||||
| process_name | 공정명 | 라우팅에서 복사 (비정규화, 조회 편의) |
|
||||
| is_required | 필수여부 | 라우팅에서 복사 |
|
||||
| is_fixed_order | 순서고정 | 라우팅에서 복사 |
|
||||
| standard_time | 표준시간 | 라우팅에서 복사 |
|
||||
| **status** | **공정 상태** | **waiting / in_progress / completed / skipped** |
|
||||
| plan_qty | 계획수량 | |
|
||||
| input_qty | 투입수량 | |
|
||||
| good_qty | 양품수량 | |
|
||||
| defect_qty | 불량수량 | |
|
||||
| equipment_code | 사용설비 | |
|
||||
| accepted_by | 접수자 | |
|
||||
| accepted_at | 접수시간 | |
|
||||
| started_at | 시작시간 | |
|
||||
| completed_at | 완료시간 | |
|
||||
| remark | 비고 | |
|
||||
| company_code | 회사코드 | |
|
||||
|
||||
### 3-3. work_order_work_item (작업기준정보별 진행) - 신규 테이블
|
||||
|
||||
POP에서 작업자가 각 작업기준정보 항목을 체크/입력할 때 사용.
|
||||
|
||||
| 컬럼 | 설명 | 비고 |
|
||||
|------|------|------|
|
||||
| id | PK (UUID) | gen_random_uuid() |
|
||||
| company_code | 회사코드 | 멀티테넌시 |
|
||||
| work_order_process_id | FK → work_order_process.id | 어떤 작업지시의 어떤 공정인지 |
|
||||
| work_item_id | FK → process_work_item.id | 어떤 작업기준정보인지 |
|
||||
| work_phase | 작업단계 | PRE / IN / POST (마스터에서 복사) |
|
||||
| status | 완료상태 | pending / completed / skipped / failed |
|
||||
| completed_by | 완료자 | 작업자 ID |
|
||||
| completed_at | 완료시간 | |
|
||||
| created_date | 생성일 | |
|
||||
| updated_date | 수정일 | |
|
||||
| writer | 작성자 | |
|
||||
|
||||
### 3-4. work_order_work_item_result (작업기준정보 상세 결과) - 신규 테이블
|
||||
|
||||
작업기준정보의 상세 항목(체크, 검사, 입력 등)에 대한 실제 결과값 저장.
|
||||
|
||||
| 컬럼 | 설명 | 비고 |
|
||||
|------|------|------|
|
||||
| id | PK (UUID) | gen_random_uuid() |
|
||||
| company_code | 회사코드 | 멀티테넌시 |
|
||||
| work_order_work_item_id | FK → work_order_work_item.id | |
|
||||
| work_item_detail_id | FK → process_work_item_detail.id | 어떤 상세항목인지 |
|
||||
| detail_type | 상세유형 | check / inspect / input / procedure (마스터에서 복사) |
|
||||
| result_value | 결과값 | 체크: "Y"/"N", 검사: 측정값, 입력: 입력값 |
|
||||
| is_passed | 합격여부 | Y / N / null(해당없음) |
|
||||
| remark | 비고 | 불합격 사유 등 |
|
||||
| recorded_by | 기록자 | |
|
||||
| recorded_at | 기록시간 | |
|
||||
| created_date | 생성일 | |
|
||||
| updated_date | 수정일 | |
|
||||
| writer | 작성자 | |
|
||||
|
||||
---
|
||||
|
||||
## 4. POP 데이터 플로우
|
||||
|
||||
### 4-1. 작업지시 등록 시 (ERP 측)
|
||||
|
||||
```
|
||||
[작업지시 생성]
|
||||
│
|
||||
├── 1. work_instruction INSERT (item_id, qty, status='waiting' 등)
|
||||
│
|
||||
├── 2. item_id → item_info.item_number 조회
|
||||
│
|
||||
├── 3. item_number → item_routing_version 조회 (is_default=true 또는 지정 버전)
|
||||
│
|
||||
├── 4. routing_version_id → item_routing_detail 조회 (공정 목록)
|
||||
│
|
||||
└── 5. 각 공정별로 work_order_process INSERT
|
||||
├── wo_id = work_instruction.id
|
||||
├── routing_detail_id = item_routing_detail.id ← 핵심!
|
||||
├── seq_no, process_code, process_name 복사
|
||||
├── status = 'waiting'
|
||||
└── plan_qty = work_instruction.qty
|
||||
```
|
||||
|
||||
### 4-2. POP 작업 조회 시
|
||||
|
||||
```
|
||||
[POP 화면: 작업지시 선택]
|
||||
│
|
||||
├── 1. work_instruction 목록 조회 (status = 'waiting' or 'in_progress')
|
||||
│
|
||||
├── 2. 선택한 작업지시의 공정 목록 조회
|
||||
│ SELECT wop.*, pm.process_name
|
||||
│ FROM work_order_process wop
|
||||
│ LEFT JOIN process_mng pm ON wop.process_code = pm.process_code
|
||||
│ WHERE wop.wo_id = {작업지시ID}
|
||||
│ ORDER BY CAST(wop.seq_no AS int)
|
||||
│
|
||||
└── 3. 선택한 공정의 작업기준정보 조회 (마스터 데이터 참조)
|
||||
SELECT pwi.*, pwid.*
|
||||
FROM process_work_item pwi
|
||||
LEFT JOIN process_work_item_detail pwid ON pwi.id = pwid.work_item_id
|
||||
WHERE pwi.routing_detail_id = {work_order_process.routing_detail_id}
|
||||
ORDER BY pwi.work_phase, pwi.sort_order, pwid.sort_order
|
||||
```
|
||||
|
||||
### 4-3. POP 작업 실행 시
|
||||
|
||||
```
|
||||
[작업자가 공정 시작]
|
||||
│
|
||||
├── 1. work_order_process UPDATE
|
||||
│ SET status = 'in_progress', started_at = NOW(), accepted_by = {작업자}
|
||||
│
|
||||
├── 2. work_instruction UPDATE (첫 공정 시작 시)
|
||||
│ SET status = 'in_progress'
|
||||
│
|
||||
├── 3. 작업기준정보 항목별 체크/입력 시
|
||||
│ ├── work_order_work_item UPSERT (항목별 상태)
|
||||
│ └── work_order_work_item_result UPSERT (상세 결과값)
|
||||
│
|
||||
└── 4. 공정 완료 시
|
||||
├── work_order_process UPDATE
|
||||
│ SET status = 'completed', completed_at = NOW(),
|
||||
│ good_qty = {양품}, defect_qty = {불량}
|
||||
│
|
||||
└── (모든 공정 완료 시)
|
||||
work_instruction UPDATE
|
||||
SET status = 'completed', completed_qty = {최종양품}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 핵심 조회 쿼리
|
||||
|
||||
### 5-1. 작업지시 → 전체 공정 + 작업기준정보 한방 조회
|
||||
|
||||
```sql
|
||||
-- 작업지시의 공정별 진행 현황 + 작업기준정보
|
||||
SELECT
|
||||
wi.work_instruction_no,
|
||||
wi.qty,
|
||||
wi.status as wi_status,
|
||||
ii.item_number,
|
||||
ii.item_name,
|
||||
wop.id as process_id,
|
||||
wop.seq_no,
|
||||
wop.process_code,
|
||||
wop.process_name,
|
||||
wop.status as process_status,
|
||||
wop.plan_qty,
|
||||
wop.good_qty,
|
||||
wop.defect_qty,
|
||||
wop.started_at,
|
||||
wop.completed_at,
|
||||
wop.routing_detail_id,
|
||||
-- 작업기준정보는 routing_detail_id로 마스터 조회
|
||||
pwi.id as work_item_id,
|
||||
pwi.work_phase,
|
||||
pwi.title as work_item_title,
|
||||
pwi.is_required as work_item_required
|
||||
FROM work_instruction wi
|
||||
JOIN item_info ii ON wi.item_id = ii.id
|
||||
JOIN work_order_process wop ON wi.id = wop.wo_id
|
||||
LEFT JOIN process_work_item pwi ON wop.routing_detail_id = pwi.routing_detail_id
|
||||
WHERE wi.id = $1
|
||||
AND wi.company_code = $2
|
||||
ORDER BY CAST(wop.seq_no AS int), pwi.work_phase, pwi.sort_order;
|
||||
```
|
||||
|
||||
### 5-2. 특정 공정의 작업기준정보 + 진행 상태 조회
|
||||
|
||||
```sql
|
||||
-- POP에서 특정 공정 선택 시: 마스터 + 진행 상태 조인
|
||||
SELECT
|
||||
pwi.id as work_item_id,
|
||||
pwi.work_phase,
|
||||
pwi.title,
|
||||
pwi.is_required,
|
||||
pwid.id as detail_id,
|
||||
pwid.detail_type,
|
||||
pwid.content,
|
||||
pwid.input_type,
|
||||
pwid.inspection_code,
|
||||
pwid.inspection_method,
|
||||
pwid.unit,
|
||||
pwid.lower_limit,
|
||||
pwid.upper_limit,
|
||||
-- 진행 상태
|
||||
wowi.status as item_status,
|
||||
wowi.completed_by,
|
||||
wowi.completed_at,
|
||||
-- 결과값
|
||||
wowir.result_value,
|
||||
wowir.is_passed,
|
||||
wowir.remark as result_remark
|
||||
FROM process_work_item pwi
|
||||
LEFT JOIN process_work_item_detail pwid
|
||||
ON pwi.id = pwid.work_item_id
|
||||
LEFT JOIN work_order_work_item wowi
|
||||
ON wowi.work_item_id = pwi.id
|
||||
AND wowi.work_order_process_id = $1 -- work_order_process.id
|
||||
LEFT JOIN work_order_work_item_result wowir
|
||||
ON wowir.work_order_work_item_id = wowi.id
|
||||
AND wowir.work_item_detail_id = pwid.id
|
||||
WHERE pwi.routing_detail_id = $2 -- work_order_process.routing_detail_id
|
||||
ORDER BY
|
||||
CASE pwi.work_phase WHEN 'PRE' THEN 1 WHEN 'IN' THEN 2 WHEN 'POST' THEN 3 END,
|
||||
pwi.sort_order,
|
||||
pwid.sort_order;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 변경사항 요약
|
||||
|
||||
### 6-1. 기존 테이블 변경
|
||||
|
||||
| 테이블 | 변경내용 |
|
||||
|--------|---------|
|
||||
| work_order_process | `routing_detail_id VARCHAR(500)` 컬럼 추가 |
|
||||
|
||||
### 6-2. 신규 테이블
|
||||
|
||||
| 테이블 | 용도 |
|
||||
|--------|------|
|
||||
| work_order_work_item | 작업지시 공정별 작업기준정보 진행 상태 |
|
||||
| work_order_work_item_result | 작업기준정보 상세 항목의 실제 결과값 |
|
||||
|
||||
### 6-3. 건드리지 않는 것
|
||||
|
||||
| 테이블 | 이유 |
|
||||
|--------|------|
|
||||
| work_instruction | item_id만 있으면 충분. 라우팅/작업기준정보 ID 추가 불필요 |
|
||||
| item_routing_version | 마스터 데이터, 변경 없음 |
|
||||
| item_routing_detail | 마스터 데이터, 변경 없음 |
|
||||
| process_work_item | 마스터 데이터, 변경 없음 |
|
||||
| process_work_item_detail | 마스터 데이터, 변경 없음 |
|
||||
|
||||
---
|
||||
|
||||
## 7. DDL (마이그레이션 SQL)
|
||||
|
||||
```sql
|
||||
-- 1. work_order_process에 routing_detail_id 추가
|
||||
ALTER TABLE work_order_process
|
||||
ADD COLUMN IF NOT EXISTS routing_detail_id VARCHAR(500);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_wop_routing_detail_id
|
||||
ON work_order_process(routing_detail_id);
|
||||
|
||||
-- 2. 작업기준정보별 진행 상태 테이블
|
||||
CREATE TABLE IF NOT EXISTS work_order_work_item (
|
||||
id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
company_code VARCHAR(500) NOT NULL,
|
||||
work_order_process_id VARCHAR(500) NOT NULL,
|
||||
work_item_id VARCHAR(500) NOT NULL,
|
||||
work_phase VARCHAR(500),
|
||||
status VARCHAR(500) DEFAULT 'pending',
|
||||
completed_by VARCHAR(500),
|
||||
completed_at TIMESTAMP,
|
||||
created_date TIMESTAMP DEFAULT NOW(),
|
||||
updated_date TIMESTAMP DEFAULT NOW(),
|
||||
writer VARCHAR(500)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_wowi_process_id ON work_order_work_item(work_order_process_id);
|
||||
CREATE INDEX idx_wowi_work_item_id ON work_order_work_item(work_item_id);
|
||||
CREATE INDEX idx_wowi_company_code ON work_order_work_item(company_code);
|
||||
|
||||
-- 3. 작업기준정보 상세 결과 테이블
|
||||
CREATE TABLE IF NOT EXISTS work_order_work_item_result (
|
||||
id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
company_code VARCHAR(500) NOT NULL,
|
||||
work_order_work_item_id VARCHAR(500) NOT NULL,
|
||||
work_item_detail_id VARCHAR(500) NOT NULL,
|
||||
detail_type VARCHAR(500),
|
||||
result_value VARCHAR(500),
|
||||
is_passed VARCHAR(500),
|
||||
remark TEXT,
|
||||
recorded_by VARCHAR(500),
|
||||
recorded_at TIMESTAMP DEFAULT NOW(),
|
||||
created_date TIMESTAMP DEFAULT NOW(),
|
||||
updated_date TIMESTAMP DEFAULT NOW(),
|
||||
writer VARCHAR(500)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_wowir_work_order_work_item_id ON work_order_work_item_result(work_order_work_item_id);
|
||||
CREATE INDEX idx_wowir_detail_id ON work_order_work_item_result(work_item_detail_id);
|
||||
CREATE INDEX idx_wowir_company_code ON work_order_work_item_result(company_code);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 상태값 정의
|
||||
|
||||
### work_instruction.status (작업지시 상태)
|
||||
| 값 | 의미 |
|
||||
|----|------|
|
||||
| waiting | 대기 |
|
||||
| in_progress | 진행중 |
|
||||
| completed | 완료 |
|
||||
| cancelled | 취소 |
|
||||
|
||||
### work_order_process.status (공정 상태)
|
||||
| 값 | 의미 |
|
||||
|----|------|
|
||||
| waiting | 대기 (아직 시작 안 함) |
|
||||
| in_progress | 진행중 (작업자가 시작) |
|
||||
| completed | 완료 |
|
||||
| skipped | 건너뜀 (선택 공정인 경우) |
|
||||
|
||||
### work_order_work_item.status (작업기준정보 항목 상태)
|
||||
| 값 | 의미 |
|
||||
|----|------|
|
||||
| pending | 미완료 |
|
||||
| completed | 완료 |
|
||||
| skipped | 건너뜀 |
|
||||
| failed | 실패 (검사 불합격 등) |
|
||||
|
||||
### work_order_work_item_result.is_passed (검사 합격여부)
|
||||
| 값 | 의미 |
|
||||
|----|------|
|
||||
| Y | 합격 |
|
||||
| N | 불합격 |
|
||||
| null | 해당없음 (체크/입력 항목) |
|
||||
|
||||
---
|
||||
|
||||
## 9. 설계 의도 요약
|
||||
|
||||
1. **마스터와 트랜잭션 분리**: 라우팅/작업기준정보는 마스터(템플릿), 실제 진행은 트랜잭션 테이블에서 관리
|
||||
2. **조회 경로**: `work_instruction.item_id` → `item_info.item_number` → `item_routing_version` → `item_routing_detail` → `process_work_item` → `process_work_item_detail`
|
||||
3. **진행 경로**: `work_order_process.routing_detail_id`로 마스터 작업기준정보를 참조하되, 실제 진행/결과는 `work_order_work_item` + `work_order_work_item_result`에 저장
|
||||
4. **중복 저장 최소화**: 작업지시에 공정/작업기준정보 ID를 넣지 않음. 품목만 있으면 전부 파생 조회 가능
|
||||
5. **work_order_process**: 작업지시 생성 시 라우팅 공정을 복사하는 이유는 진행 중 수량/상태/시간 등 트랜잭션 데이터를 기록해야 하기 때문 (마스터가 변경되어도 이미 발행된 작업지시의 공정은 유지)
|
||||
|
||||
---
|
||||
|
||||
## 10. 주의사항
|
||||
|
||||
- `work_order_process`에 공정 정보를 복사(스냅샷)하는 이유: 마스터 라우팅이 나중에 변경되어도 이미 진행 중인 작업지시의 공정 구성은 영향받지 않아야 함
|
||||
- `routing_detail_id`는 "이 공정이 어떤 마스터 라우팅에서 왔는지" 추적용. 작업기준정보 조회 키로 사용
|
||||
- POP에서 작업기준정보를 표시할 때는 항상 마스터(`process_work_item`)를 조회하고, 결과만 트랜잭션 테이블에 저장
|
||||
- 모든 테이블에 `company_code` 필수 (멀티테넌시)
|
||||
|
|
@ -1,331 +0,0 @@
|
|||
# 화면 전체 분석 보고서
|
||||
|
||||
> **분석 대상**: `/Users/kimjuseok/Downloads/화면개발 8` 폴더 내 핵심 업무 화면
|
||||
> **분석 기준**: 메뉴별 분류, 3개 이상 재활용 가능한 컴포넌트 식별
|
||||
> **분석 일자**: 2026-01-30
|
||||
|
||||
---
|
||||
|
||||
## 1. 현재 사용 중인 V2 컴포넌트 목록
|
||||
|
||||
> **중요**: v2- 접두사가 붙은 컴포넌트만 사용합니다.
|
||||
|
||||
### 입력 컴포넌트
|
||||
| ID | 이름 | 용도 |
|
||||
|----|------|------|
|
||||
| `v2-input` | V2 입력 | 텍스트, 숫자, 비밀번호, 이메일 등 입력 |
|
||||
| `v2-select` | V2 선택 | 드롭다운, 콤보박스, 라디오, 체크박스 |
|
||||
| `v2-date` | V2 날짜 | 날짜, 시간, 날짜범위 입력 |
|
||||
|
||||
### 표시 컴포넌트
|
||||
| ID | 이름 | 용도 |
|
||||
|----|------|------|
|
||||
| `v2-text-display` | 텍스트 표시 | 라벨, 텍스트 표시 |
|
||||
| `v2-card-display` | 카드 디스플레이 | 테이블 데이터를 카드 형태로 표시 |
|
||||
| `v2-aggregation-widget` | 집계 위젯 | 합계, 평균, 개수 등 집계 표시 |
|
||||
|
||||
### 테이블/데이터 컴포넌트
|
||||
| ID | 이름 | 용도 |
|
||||
|----|------|------|
|
||||
| `v2-table-list` | 테이블 리스트 | 데이터 테이블 표시, 페이지네이션, 정렬, 필터 |
|
||||
| `v2-table-search-widget` | 검색 필터 | 화면 내 테이블 검색/필터/그룹 기능 |
|
||||
| `v2-pivot-grid` | 피벗 그리드 | 다차원 데이터 분석 (피벗 테이블) |
|
||||
|
||||
### 레이아웃 컴포넌트
|
||||
| ID | 이름 | 용도 |
|
||||
|----|------|------|
|
||||
| `v2-split-panel-layout` | 분할 패널 | 마스터-디테일 좌우 분할 레이아웃 |
|
||||
| `v2-tabs-widget` | 탭 위젯 | 탭 전환, 탭 내 컴포넌트 배치 |
|
||||
| `v2-section-card` | Section Card | 제목/테두리가 있는 그룹화 컨테이너 |
|
||||
| `v2-section-paper` | Section Paper | 배경색 기반 미니멀 그룹화 컨테이너 |
|
||||
| `v2-divider-line` | 구분선 | 영역 구분 |
|
||||
| `v2-repeat-container` | 리피터 컨테이너 | 데이터 수만큼 내부 컴포넌트 반복 렌더링 |
|
||||
| `v2-repeater` | 리피터 | 반복 컨트롤 |
|
||||
|
||||
### 액션/기타 컴포넌트
|
||||
| ID | 이름 | 용도 |
|
||||
|----|------|------|
|
||||
| `v2-button-primary` | 기본 버튼 | 저장, 삭제 등 액션 버튼 |
|
||||
| `v2-numbering-rule` | 채번규칙 | 자동 코드/번호 생성 |
|
||||
| `v2-category-manager` | 카테고리 관리자 | 카테고리 관리 |
|
||||
| `v2-location-swap-selector` | 위치 교환 선택기 | 위치 교환 기능 |
|
||||
| `v2-rack-structure` | 랙 구조 | 창고 랙 시각화 |
|
||||
| `v2-media` | 미디어 | 미디어 표시 |
|
||||
|
||||
**총 23개 V2 컴포넌트**
|
||||
|
||||
---
|
||||
|
||||
## 2. 화면 분류 (메뉴별)
|
||||
|
||||
### 01. 기준정보 (master-data)
|
||||
| 화면명 | 파일명 | 패턴 | 구현 가능 |
|
||||
|--------|--------|------|----------|
|
||||
| 회사정보 | 회사정보.html | 검색+테이블 | ✅ 완전 |
|
||||
| 부서정보 | 부서정보.html | 검색+테이블 | ✅ 완전 |
|
||||
| 품목정보 | 품목정보.html | 검색+테이블+그룹화 | ⚠️ 그룹화 미지원 |
|
||||
| BOM관리 | BOM관리.html | 분할패널+트리 | ⚠️ 트리뷰 미지원 |
|
||||
| 공정정보관리 | 공정정보관리.html | 분할패널+테이블 | ✅ 완전 |
|
||||
| 공정작업기준 | 공정작업기준관리.html | 검색+테이블 | ✅ 완전 |
|
||||
| 품목라우팅 | 품목라우팅관리.html | 분할패널+테이블 | ✅ 완전 |
|
||||
|
||||
### 02. 영업관리 (sales)
|
||||
| 화면명 | 파일명 | 패턴 | 구현 가능 |
|
||||
|--------|--------|------|----------|
|
||||
| 수주관리 | 수주관리.html | 분할패널+테이블 | ✅ 완전 |
|
||||
| 견적관리 | 견적관리.html | 분할패널+테이블 | ✅ 완전 |
|
||||
| 거래처관리 | 거래처관리.html | 분할패널+탭+그룹화 | ⚠️ 그룹화 미지원 |
|
||||
| 판매품목정보 | 판매품목정보.html | 검색+테이블 | ✅ 완전 |
|
||||
| 출하계획관리 | 출하계획관리.html | 검색+테이블 | ✅ 완전 |
|
||||
|
||||
### 03. 생산관리 (production)
|
||||
| 화면명 | 파일명 | 패턴 | 구현 가능 |
|
||||
|--------|--------|------|----------|
|
||||
| 생산계획관리 | 생산계획관리.html | 분할패널+탭+타임라인 | ❌ 타임라인 미지원 |
|
||||
| 생산관리 | 생산관리.html | 검색+테이블 | ✅ 완전 |
|
||||
| 생산실적관리 | 생산실적관리.html | 검색+테이블 | ✅ 완전 |
|
||||
| 작업지시 | 작업지시.html | 탭+그룹화테이블+분할패널 | ⚠️ 그룹화 미지원 |
|
||||
| 공정관리 | 공정관리.html | 분할패널+테이블 | ✅ 완전 |
|
||||
|
||||
### 04. 구매관리 (purchase)
|
||||
| 화면명 | 파일명 | 패턴 | 구현 가능 |
|
||||
|--------|--------|------|----------|
|
||||
| 발주관리 | 발주관리.html | 검색+테이블 | ✅ 완전 |
|
||||
| 공급업체관리 | 공급업체관리.html | 검색+테이블 | ✅ 완전 |
|
||||
| 구매입고 | pages/구매입고.html | 검색+테이블 | ✅ 완전 |
|
||||
|
||||
### 05. 설비관리 (equipment)
|
||||
| 화면명 | 파일명 | 패턴 | 구현 가능 |
|
||||
|--------|--------|------|----------|
|
||||
| 설비정보 | 설비정보.html | 분할패널+카드+탭 | ✅ v2-card-display 활용 |
|
||||
|
||||
### 06. 물류관리 (logistics)
|
||||
| 화면명 | 파일명 | 패턴 | 구현 가능 |
|
||||
|--------|--------|------|----------|
|
||||
| 창고관리 | 창고관리.html | 모바일앱스타일+iframe | ❌ 별도개발 필요 |
|
||||
| 창고정보관리 | 창고정보관리.html | 검색+테이블 | ✅ 완전 |
|
||||
| 입출고관리 | 입출고관리.html | 검색+테이블+그룹화 | ⚠️ 그룹화 미지원 |
|
||||
| 재고현황 | 재고현황.html | 검색+테이블 | ✅ 완전 |
|
||||
|
||||
### 07. 품질관리 (quality)
|
||||
| 화면명 | 파일명 | 패턴 | 구현 가능 |
|
||||
|--------|--------|------|----------|
|
||||
| 검사기준 | 검사기준.html | 검색+테이블 | ✅ 완전 |
|
||||
| 검사정보관리 | 검사정보관리.html | 탭+테이블 | ✅ 완전 |
|
||||
| 검사장비관리 | 검사장비관리.html | 검색+테이블 | ✅ 완전 |
|
||||
| 불량관리 | 불량관리.html | 검색+테이블 | ✅ 완전 |
|
||||
| 클레임관리 | 클레임관리.html | 검색+테이블 | ✅ 완전 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 화면 UI 패턴 분석
|
||||
|
||||
### 패턴 A: 검색 + 테이블 (가장 기본)
|
||||
**해당 화면**: 약 60% (15개 이상)
|
||||
|
||||
**사용 컴포넌트**:
|
||||
- `v2-table-search-widget`: 검색 필터
|
||||
- `v2-table-list`: 데이터 테이블
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ [검색필드들...] [조회] [엑셀] │ ← v2-table-search-widget
|
||||
├─────────────────────────────────────────┤
|
||||
│ 테이블 제목 [신규등록] [삭제] │
|
||||
│ ────────────────────────────────────── │
|
||||
│ □ | 코드 | 이름 | 상태 | 등록일 | │ ← v2-table-list
|
||||
│ □ | A001 | 테스트| 사용 | 2026-01-30 | │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 패턴 B: 분할 패널 (마스터-디테일)
|
||||
**해당 화면**: 약 25% (8개)
|
||||
|
||||
**사용 컴포넌트**:
|
||||
- `v2-split-panel-layout`: 좌우 분할
|
||||
- `v2-table-list`: 마스터/디테일 테이블
|
||||
- `v2-tabs-widget`: 상세 탭 (선택)
|
||||
|
||||
```
|
||||
┌──────────────────┬──────────────────────┐
|
||||
│ 마스터 리스트 │ 상세 정보 / 탭 │
|
||||
│ ─────────────── │ ┌────┬────┬────┐ │
|
||||
│ □ A001 제품A │ │기본│이력│첨부│ │
|
||||
│ □ A002 제품B ← │ └────┴────┴────┘ │
|
||||
│ □ A003 제품C │ [테이블 or 폼] │
|
||||
└──────────────────┴──────────────────────┘
|
||||
```
|
||||
|
||||
### 패턴 C: 탭 + 테이블
|
||||
**해당 화면**: 약 10% (3개)
|
||||
|
||||
**사용 컴포넌트**:
|
||||
- `v2-tabs-widget`: 탭 전환
|
||||
- `v2-table-list`: 탭별 테이블
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ [탭1] [탭2] [탭3] │
|
||||
├─────────────────────────────────────────┤
|
||||
│ [테이블 영역] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 패턴 D: 특수 UI
|
||||
**해당 화면**: 약 5% (2개)
|
||||
|
||||
- 생산계획관리: 타임라인/간트 차트 → **v2-timeline 미존재**
|
||||
- 창고관리: 모바일 앱 스타일 → **별도 개발 필요**
|
||||
|
||||
---
|
||||
|
||||
## 4. 신규 컴포넌트 분석 (3개 이상 재활용 기준)
|
||||
|
||||
### 4.1 v2-grouped-table (그룹화 테이블)
|
||||
**재활용 화면 수**: 5개 이상 ✅
|
||||
|
||||
| 화면 | 그룹화 기준 |
|
||||
|------|------------|
|
||||
| 품목정보 | 품목구분, 카테고리 |
|
||||
| 거래처관리 | 거래처유형, 지역 |
|
||||
| 작업지시 | 작업일자, 공정 |
|
||||
| 입출고관리 | 입출고구분, 창고 |
|
||||
| 견적관리 | 상태, 거래처 |
|
||||
|
||||
**기능 요구사항**:
|
||||
- 특정 컬럼 기준 그룹핑
|
||||
- 그룹 접기/펼치기
|
||||
- 그룹 헤더에 집계 표시
|
||||
- 다중 그룹핑 지원
|
||||
|
||||
**구현 복잡도**: 중
|
||||
|
||||
### 4.2 v2-tree-view (트리 뷰)
|
||||
**재활용 화면 수**: 3개 ✅
|
||||
|
||||
| 화면 | 트리 용도 |
|
||||
|------|----------|
|
||||
| BOM관리 | BOM 구조 (정전개/역전개) |
|
||||
| 부서정보 | 조직도 |
|
||||
| 메뉴관리 | 메뉴 계층 |
|
||||
|
||||
**기능 요구사항**:
|
||||
- 노드 접기/펼치기
|
||||
- 드래그앤드롭 (선택)
|
||||
- 정전개/역전개 전환
|
||||
- 노드 선택 이벤트
|
||||
|
||||
**구현 복잡도**: 중상
|
||||
|
||||
### 4.3 v2-timeline-scheduler (타임라인)
|
||||
**재활용 화면 수**: 1~2개 (기준 미달)
|
||||
|
||||
| 화면 | 용도 |
|
||||
|------|------|
|
||||
| 생산계획관리 | 간트 차트 |
|
||||
| 설비 가동 현황 | 타임라인 |
|
||||
|
||||
**기능 요구사항**:
|
||||
- 시간축 기반 배치
|
||||
- 드래그로 일정 변경
|
||||
- 공정별 색상 구분
|
||||
- 줌 인/아웃
|
||||
|
||||
**구현 복잡도**: 상
|
||||
|
||||
> **참고**: 3개 미만이므로 우선순위 하향
|
||||
|
||||
---
|
||||
|
||||
## 5. 컴포넌트 커버리지
|
||||
|
||||
### 현재 V2 컴포넌트로 구현 가능
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 17개 화면 (65%) │
|
||||
│ - 기본 검색 + 테이블 패턴 │
|
||||
│ - 분할 패널 │
|
||||
│ - 탭 전환 │
|
||||
│ - 카드 디스플레이 │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### v2-grouped-table 개발 후
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ +5개 화면 (22개, 85%) │
|
||||
│ - 품목정보, 거래처관리, 작업지시 │
|
||||
│ - 입출고관리, 견적관리 │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### v2-tree-view 개발 후
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ +2개 화면 (24개, 92%) │
|
||||
│ - BOM관리, 부서정보(계층) │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 별도 개발 필요
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 2개 화면 (8%) │
|
||||
│ - 생산계획관리 (타임라인) │
|
||||
│ - 창고관리 (모바일 앱 스타일) │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 신규 컴포넌트 개발 우선순위
|
||||
|
||||
| 순위 | 컴포넌트 | 재활용 화면 수 | 복잡도 | ROI |
|
||||
|------|----------|--------------|--------|-----|
|
||||
| 1 | v2-grouped-table | 5+ | 중 | ⭐⭐⭐⭐⭐ |
|
||||
| 2 | v2-tree-view | 3 | 중상 | ⭐⭐⭐⭐ |
|
||||
| 3 | v2-timeline-scheduler | 1~2 | 상 | ⭐⭐ |
|
||||
|
||||
---
|
||||
|
||||
## 7. 권장 구현 전략
|
||||
|
||||
### Phase 1: 즉시 구현 (현재 V2 컴포넌트)
|
||||
- 회사정보, 부서정보
|
||||
- 발주관리, 공급업체관리
|
||||
- 검사기준, 검사장비관리, 불량관리
|
||||
- 창고정보관리, 재고현황
|
||||
- 공정작업기준관리
|
||||
- 수주관리, 견적관리, 공정관리
|
||||
- 설비정보 (v2-card-display 활용)
|
||||
- 검사정보관리
|
||||
|
||||
### Phase 2: v2-grouped-table 개발 후
|
||||
- 품목정보, 거래처관리, 입출고관리
|
||||
- 작업지시
|
||||
|
||||
### Phase 3: v2-tree-view 개발 후
|
||||
- BOM관리
|
||||
- 부서정보 (계층 뷰)
|
||||
|
||||
### Phase 4: 개별 개발
|
||||
- 생산계획관리 (타임라인)
|
||||
- 창고관리 (모바일 스타일)
|
||||
|
||||
---
|
||||
|
||||
## 8. 요약
|
||||
|
||||
| 항목 | 수치 |
|
||||
|------|------|
|
||||
| 전체 분석 화면 수 | 26개 |
|
||||
| 현재 즉시 구현 가능 | 17개 (65%) |
|
||||
| v2-grouped-table 추가 시 | 22개 (85%) |
|
||||
| v2-tree-view 추가 시 | 24개 (92%) |
|
||||
| 별도 개발 필요 | 2개 (8%) |
|
||||
|
||||
**핵심 결론**:
|
||||
1. **현재 V2 컴포넌트**로 65% 화면 구현 가능
|
||||
2. **v2-grouped-table** 1개 컴포넌트 개발로 85%까지 확대
|
||||
3. **v2-tree-view** 추가로 92% 도달
|
||||
4. 나머지 8%는 화면별 특수 UI (타임라인, 모바일 스타일)로 개별 개발 필요
|
||||
|
|
@ -1,631 +0,0 @@
|
|||
# V2 공통 컴포넌트 사용 가이드
|
||||
|
||||
> **목적**: 다양한 회사에서 V2 컴포넌트를 활용하여 화면을 개발할 때 참고하는 범용 가이드
|
||||
> **대상**: 화면 설계자, 개발자
|
||||
> **버전**: 1.1.0
|
||||
> **작성일**: 2026-02-23 (최종 업데이트)
|
||||
|
||||
---
|
||||
|
||||
## 1. V2 컴포넌트로 가능한 것 / 불가능한 것
|
||||
|
||||
### 1.1 가능한 화면 유형
|
||||
|
||||
| 화면 유형 | 설명 | 대표 예시 |
|
||||
|-----------|------|----------|
|
||||
| 마스터 관리 | 단일 테이블 CRUD | 회사정보, 부서정보, 코드관리 |
|
||||
| 마스터-디테일 | 좌측 선택 → 우측 상세 | 공정관리, 품목라우팅, 견적관리 |
|
||||
| 탭 기반 화면 | 탭별 다른 테이블/뷰 | 검사정보관리, 거래처관리 |
|
||||
| 카드 뷰 | 이미지+정보 카드 형태 | 설비정보, 대시보드 |
|
||||
| 피벗 분석 | 다차원 집계 | 매출분석, 재고현황 |
|
||||
| 반복 컨테이너 | 데이터 수만큼 UI 반복 | 주문 상세, 항목 리스트 |
|
||||
| 그룹화 테이블 | 그룹핑 기능 포함 테이블 | 카테고리별 집계, 부서별 현황 |
|
||||
| 타임라인/스케줄 | 시간축 기반 일정 관리 | 생산일정, 작업스케줄 |
|
||||
|
||||
### 1.2 불가능한 화면 유형 (별도 개발 필요)
|
||||
|
||||
| 화면 유형 | 이유 | 해결 방안 |
|
||||
|-----------|------|----------|
|
||||
| 트리 뷰 (계층 구조) | 트리 컴포넌트 미존재 | `v2-tree-view` 개발 필요 |
|
||||
| 드래그앤드롭 보드 | 칸반 스타일 UI 없음 | 별도 개발 |
|
||||
| 모바일 앱 스타일 | 네이티브 앱 UI | 별도 개발 |
|
||||
| 복잡한 차트 | 기본 집계 외 시각화 | 차트 라이브러리 연동 |
|
||||
|
||||
> **참고**: 그룹화 테이블(`v2-table-grouped`)과 타임라인 스케줄러(`v2-timeline-scheduler`)는 v1.1에서 추가되어 이제 지원됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. V2 컴포넌트 전체 목록 (25개)
|
||||
|
||||
### 2.1 입력 컴포넌트 (4개)
|
||||
|
||||
| ID | 이름 | 용도 | 주요 옵션 |
|
||||
|----|------|------|----------|
|
||||
| `v2-input` | 입력 | 텍스트, 숫자, 비밀번호, 슬라이더, 컬러 | inputType(text/number/password/slider/color/button), format(email/tel/url/currency/biz_no), required, readonly, maxLength, min, max, step |
|
||||
| `v2-select` | 선택 | 드롭다운, 콤보박스, 라디오, 체크, 태그, 토글, 스왑 | mode(dropdown/combobox/radio/check/tag/tagbox/toggle/swap), source(static/code/db/api/entity/category/distinct/select), searchable, multiple, cascading |
|
||||
| `v2-date` | 날짜 | 날짜, 시간, 날짜시간 | dateType(date/time/datetime), format, range, minDate, maxDate, showToday |
|
||||
| `v2-file-upload` | 파일 업로드 | 파일/이미지 업로드 | - |
|
||||
|
||||
### 2.2 표시 컴포넌트 (3개)
|
||||
|
||||
| ID | 이름 | 용도 | 주요 옵션 |
|
||||
|----|------|------|----------|
|
||||
| `v2-text-display` | 텍스트 표시 | 라벨, 제목, 설명 텍스트 | fontSize, fontWeight, color, textAlign |
|
||||
| `v2-card-display` | 카드 디스플레이 | 테이블 데이터를 카드 형태로 표시 | cardsPerRow, cardSpacing, columnMapping(titleColumn/subtitleColumn/descriptionColumn/imageColumn), cardStyle(imagePosition/imageSize), dataSource(table/static/api) |
|
||||
| `v2-aggregation-widget` | 집계 위젯 | 합계, 평균, 개수, 최대, 최소 | items, filters, layout |
|
||||
|
||||
### 2.3 테이블/데이터 컴포넌트 (4개)
|
||||
|
||||
| ID | 이름 | 용도 | 주요 옵션 |
|
||||
|----|------|------|----------|
|
||||
| `v2-table-list` | 테이블 리스트 | 데이터 조회/편집 테이블 | selectedTable, columns, pagination, filter, displayMode(table/card), checkbox, horizontalScroll, linkedFilters, excludeFilter, toolbar, tableStyle, autoLoad |
|
||||
| `v2-table-search-widget` | 검색 필터 | 테이블 검색/필터/그룹 | autoSelectFirstTable, showTableSelector, title |
|
||||
| `v2-pivot-grid` | 피벗 그리드 | 다차원 분석 (행/열/데이터 영역) | fields(area: row/column/data/filter, summaryType: sum/avg/count/min/max/countDistinct, groupInterval: year/quarter/month/week/day), dataSource(type: table/api/static, joinConfigs, filterConditions) |
|
||||
| `v2-table-grouped` | 그룹화 테이블 | 그룹핑 기능이 포함된 테이블 | - |
|
||||
|
||||
### 2.4 레이아웃 컴포넌트 (7개)
|
||||
|
||||
| ID | 이름 | 용도 | 주요 옵션 |
|
||||
|----|------|------|----------|
|
||||
| `v2-split-panel-layout` | 분할 패널 | 마스터-디테일 좌우 분할 | splitRatio, resizable, minLeftWidth, minRightWidth, syncSelection, panel별: displayMode(list/table/custom), relation(type/foreignKey), editButton, addButton, deleteButton, additionalTabs |
|
||||
| `v2-tabs-widget` | 탭 위젯 | 탭 전환, 탭 내 컴포넌트 | tabs(id/label/order/disabled/components), defaultTab, orientation(horizontal/vertical), allowCloseable, persistSelection |
|
||||
| `v2-section-card` | 섹션 카드 | 제목+테두리 그룹화 | title, collapsible, padding |
|
||||
| `v2-section-paper` | 섹션 페이퍼 | 배경색 그룹화 | backgroundColor, padding, shadow |
|
||||
| `v2-divider-line` | 구분선 | 영역 구분 | orientation, thickness |
|
||||
| `v2-repeat-container` | 리피터 컨테이너 | 데이터 수만큼 반복 렌더링 | dataSourceType, layout, gridColumns |
|
||||
| `v2-repeater` | 리피터 | 반복 컨트롤 (inline/modal) | - |
|
||||
|
||||
### 2.5 액션/특수 컴포넌트 (7개)
|
||||
|
||||
| ID | 이름 | 용도 | 주요 옵션 |
|
||||
|----|------|------|----------|
|
||||
| `v2-button-primary` | 기본 버튼 | 저장, 삭제 등 액션 | text, actionType, variant |
|
||||
| `v2-numbering-rule` | 채번규칙 | 자동 코드/번호 생성 | rule, prefix, format |
|
||||
| `v2-category-manager` | 카테고리 관리자 | 카테고리 관리 UI | - |
|
||||
| `v2-location-swap-selector` | 위치 교환 | 위치 선택/교환 | - |
|
||||
| `v2-rack-structure` | 랙 구조 | 창고 랙 시각화 | - |
|
||||
| `v2-media` | 미디어 | 이미지/동영상 표시 | - |
|
||||
| `v2-timeline-scheduler` | 타임라인 스케줄러 | 시간축 기반 일정/작업 관리 | - |
|
||||
|
||||
---
|
||||
|
||||
## 3. 화면 패턴별 컴포넌트 조합
|
||||
|
||||
### 3.1 패턴 A: 기본 마스터 화면 (가장 흔함)
|
||||
|
||||
**적용 화면**: 코드관리, 사용자관리, 부서정보, 창고정보 등
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ v2-table-search-widget │
|
||||
│ [검색필드1] [검색필드2] [조회] [엑셀] │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ v2-table-list │
|
||||
│ 제목 [신규] [삭제] │
|
||||
│ ─────────────────────────────────────────────── │
|
||||
│ □ | 코드 | 이름 | 상태 | 등록일 | │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**필수 컴포넌트**:
|
||||
- `v2-table-search-widget` (1개)
|
||||
- `v2-table-list` (1개)
|
||||
|
||||
**설정 포인트**:
|
||||
- 테이블명 지정
|
||||
- 검색 대상 컬럼 설정
|
||||
- 컬럼 표시/숨김 설정
|
||||
|
||||
---
|
||||
|
||||
### 3.2 패턴 B: 마스터-디테일 화면
|
||||
|
||||
**적용 화면**: 공정관리, 견적관리, 수주관리, 품목라우팅 등
|
||||
|
||||
```
|
||||
┌──────────────────┬──────────────────────────────┐
|
||||
│ v2-table-list │ v2-table-list 또는 폼 │
|
||||
│ (마스터) │ (디테일) │
|
||||
│ ─────────────── │ │
|
||||
│ □ A001 항목1 │ [상세 정보] │
|
||||
│ □ A002 항목2 ← │ │
|
||||
│ □ A003 항목3 │ │
|
||||
└──────────────────┴──────────────────────────────┘
|
||||
v2-split-panel-layout
|
||||
```
|
||||
|
||||
**필수 컴포넌트**:
|
||||
- `v2-split-panel-layout` (1개)
|
||||
- `v2-table-list` (2개: 마스터, 디테일)
|
||||
|
||||
**설정 포인트**:
|
||||
- `splitRatio`: 좌우 비율 (기본 30:70)
|
||||
- `relation.type`: join / detail / custom
|
||||
- `relation.foreignKey`: 연결 키 컬럼
|
||||
|
||||
---
|
||||
|
||||
### 3.3 패턴 C: 마스터-디테일 + 탭
|
||||
|
||||
**적용 화면**: 거래처관리, 품목정보, 설비정보 등
|
||||
|
||||
```
|
||||
┌──────────────────┬──────────────────────────────┐
|
||||
│ v2-table-list │ v2-tabs-widget │
|
||||
│ (마스터) │ ┌────┬────┬────┐ │
|
||||
│ │ │기본│이력│첨부│ │
|
||||
│ □ A001 거래처1 │ └────┴────┴────┘ │
|
||||
│ □ A002 거래처2 ← │ [탭별 컨텐츠] │
|
||||
└──────────────────┴──────────────────────────────┘
|
||||
```
|
||||
|
||||
**필수 컴포넌트**:
|
||||
- `v2-split-panel-layout` (1개)
|
||||
- `v2-table-list` (1개: 마스터)
|
||||
- `v2-tabs-widget` (1개)
|
||||
|
||||
**설정 포인트**:
|
||||
- 탭별 표시할 테이블/폼 설정
|
||||
- 마스터 선택 시 탭 컨텐츠 연동
|
||||
|
||||
---
|
||||
|
||||
### 3.4 패턴 D: 카드 뷰
|
||||
|
||||
**적용 화면**: 설비정보, 대시보드, 상품 카탈로그 등
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ v2-table-search-widget │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ v2-card-display │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ [이미지]│ │ [이미지]│ │ [이미지]│ │
|
||||
│ │ 제목 │ │ 제목 │ │ 제목 │ │
|
||||
│ │ 설명 │ │ 설명 │ │ 설명 │ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**필수 컴포넌트**:
|
||||
- `v2-table-search-widget` (1개)
|
||||
- `v2-card-display` (1개)
|
||||
|
||||
**설정 포인트**:
|
||||
- `cardsPerRow`: 한 행당 카드 수
|
||||
- `columnMapping`: 제목, 부제목, 이미지, 상태 필드 매핑
|
||||
- `cardStyle`: 이미지 위치, 크기
|
||||
|
||||
---
|
||||
|
||||
### 3.5 패턴 E: 피벗 분석
|
||||
|
||||
**적용 화면**: 매출분석, 재고현황, 생산실적 분석 등
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ v2-pivot-grid │
|
||||
│ │ 2024년 │ 2025년 │ 2026년 │ 합계 │
|
||||
│ ─────────────────────────────────────────────── │
|
||||
│ 지역A │ 1,000 │ 1,200 │ 1,500 │ 3,700 │
|
||||
│ 지역B │ 2,000 │ 2,500 │ 3,000 │ 7,500 │
|
||||
│ 합계 │ 3,000 │ 3,700 │ 4,500 │ 11,200 │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**필수 컴포넌트**:
|
||||
- `v2-pivot-grid` (1개)
|
||||
|
||||
**설정 포인트**:
|
||||
- `fields[].area`: row / column / data / filter
|
||||
- `fields[].summaryType`: sum / avg / count / min / max
|
||||
- `fields[].groupInterval`: 날짜 그룹화 (year/quarter/month)
|
||||
|
||||
---
|
||||
|
||||
## 4. 회사별 개발 시 핵심 체크포인트
|
||||
|
||||
### 4.1 테이블 설계 확인
|
||||
|
||||
**가장 먼저 확인**:
|
||||
1. 회사에서 사용할 테이블 목록
|
||||
2. 테이블 간 관계 (FK)
|
||||
3. 조회 조건으로 쓸 컬럼
|
||||
|
||||
```
|
||||
✅ 체크리스트:
|
||||
□ 테이블명이 DB에 존재하는가?
|
||||
□ company_code 컬럼이 있는가? (멀티테넌시)
|
||||
□ 마스터-디테일 관계의 FK가 정의되어 있는가?
|
||||
□ 검색 대상 컬럼에 인덱스가 있는가?
|
||||
```
|
||||
|
||||
### 4.2 화면 패턴 판단
|
||||
|
||||
**질문을 통한 판단**:
|
||||
|
||||
| 질문 | 예 → 패턴 |
|
||||
|------|----------|
|
||||
| 단일 테이블만 조회/편집? | 패턴 A (기본 마스터) |
|
||||
| 마스터 선택 시 디테일 표시? | 패턴 B (마스터-디테일) |
|
||||
| 상세에 탭이 필요? | 패턴 C (마스터-디테일+탭) |
|
||||
| 이미지+정보 카드 형태? | 패턴 D (카드 뷰) |
|
||||
| 다차원 집계/분석? | 패턴 E (피벗) |
|
||||
|
||||
### 4.3 컴포넌트 설정 필수 항목
|
||||
|
||||
#### v2-table-list 필수 설정
|
||||
|
||||
```typescript
|
||||
{
|
||||
selectedTable: "테이블명", // 필수
|
||||
columns: [ // 표시할 컬럼
|
||||
{ columnName: "id", displayName: "ID", visible: true, sortable: true },
|
||||
// ...
|
||||
],
|
||||
pagination: {
|
||||
enabled: true,
|
||||
pageSize: 20,
|
||||
showSizeSelector: true,
|
||||
showPageInfo: true
|
||||
},
|
||||
displayMode: "table", // "table" | "card"
|
||||
checkbox: {
|
||||
enabled: true,
|
||||
multiple: true,
|
||||
position: "left",
|
||||
selectAll: true
|
||||
},
|
||||
horizontalScroll: { // 가로 스크롤 설정
|
||||
enabled: true,
|
||||
maxVisibleColumns: 8
|
||||
},
|
||||
linkedFilters: [], // 연결 필터 (다른 컴포넌트와 연동)
|
||||
excludeFilter: {}, // 제외 필터
|
||||
autoLoad: true, // 자동 데이터 로드
|
||||
stickyHeader: false, // 헤더 고정
|
||||
autoWidth: true // 자동 너비 조정
|
||||
}
|
||||
```
|
||||
|
||||
#### v2-split-panel-layout 필수 설정
|
||||
|
||||
```typescript
|
||||
{
|
||||
leftPanel: {
|
||||
displayMode: "table", // "list" | "table" | "custom"
|
||||
tableName: "마스터_테이블명",
|
||||
columns: [], // 컬럼 설정
|
||||
editButton: { // 수정 버튼 설정
|
||||
enabled: true,
|
||||
mode: "auto", // "auto" | "modal"
|
||||
modalScreenId: "" // 모달 모드 시 화면 ID
|
||||
},
|
||||
addButton: { // 추가 버튼 설정
|
||||
enabled: true,
|
||||
mode: "auto",
|
||||
modalScreenId: ""
|
||||
},
|
||||
deleteButton: { // 삭제 버튼 설정
|
||||
enabled: true,
|
||||
buttonLabel: "삭제",
|
||||
confirmMessage: "삭제하시겠습니까?"
|
||||
},
|
||||
addModalColumns: [], // 추가 모달 전용 컬럼
|
||||
additionalTabs: [] // 추가 탭 설정
|
||||
},
|
||||
rightPanel: {
|
||||
displayMode: "table",
|
||||
tableName: "디테일_테이블명",
|
||||
relation: {
|
||||
type: "detail", // "join" | "detail" | "custom"
|
||||
foreignKey: "master_id", // 연결 키
|
||||
leftColumn: "", // 좌측 연결 컬럼
|
||||
rightColumn: "", // 우측 연결 컬럼
|
||||
keys: [] // 복합 키
|
||||
}
|
||||
},
|
||||
splitRatio: 30, // 좌측 비율 (0-100)
|
||||
resizable: true, // 리사이즈 가능
|
||||
minLeftWidth: 200, // 좌측 최소 너비
|
||||
minRightWidth: 300, // 우측 최소 너비
|
||||
syncSelection: true, // 선택 동기화
|
||||
autoLoad: true // 자동 로드
|
||||
}
|
||||
```
|
||||
|
||||
#### v2-split-panel-layout 커스텀 모드 (NEW)
|
||||
|
||||
패널 내부에 자유롭게 컴포넌트를 배치할 수 있습니다. (v2-tabs-widget과 동일 구조)
|
||||
|
||||
```typescript
|
||||
{
|
||||
leftPanel: {
|
||||
displayMode: "custom", // 커스텀 모드 활성화
|
||||
components: [ // 내부 컴포넌트 배열
|
||||
{
|
||||
id: "btn-save",
|
||||
componentType: "v2-button-primary",
|
||||
label: "저장",
|
||||
position: { x: 10, y: 10 },
|
||||
size: { width: 100, height: 40 },
|
||||
componentConfig: { buttonAction: "save" }
|
||||
},
|
||||
{
|
||||
id: "tbl-list",
|
||||
componentType: "v2-table-list",
|
||||
label: "목록",
|
||||
position: { x: 10, y: 60 },
|
||||
size: { width: 400, height: 300 },
|
||||
componentConfig: { selectedTable: "테이블명" }
|
||||
}
|
||||
]
|
||||
},
|
||||
rightPanel: {
|
||||
displayMode: "table" // 기존 모드 유지
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**디자인 모드 기능**:
|
||||
- 컴포넌트 클릭 → 좌측 설정 패널에서 속성 편집
|
||||
- 드래그 핸들(상단)로 이동
|
||||
- 리사이즈 핸들(모서리)로 크기 조절
|
||||
- 실제 컴포넌트 미리보기 렌더링
|
||||
|
||||
#### v2-card-display 필수 설정
|
||||
|
||||
```typescript
|
||||
{
|
||||
dataSource: "table",
|
||||
columnMapping: {
|
||||
title: "name", // 제목 필드
|
||||
subtitle: "code", // 부제목 필드
|
||||
image: "image_url", // 이미지 필드 (선택)
|
||||
status: "status" // 상태 필드 (선택)
|
||||
},
|
||||
cardsPerRow: 3
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 공통 컴포넌트 한계점
|
||||
|
||||
### 5.1 현재 불가능한 기능
|
||||
|
||||
| 기능 | 상태 | 대안 |
|
||||
|------|------|------|
|
||||
| 트리 뷰 (BOM, 조직도) | ❌ 미지원 | 테이블로 대체 or 별도 개발 |
|
||||
| 드래그앤드롭 정렬 | ❌ 미지원 | 순서 컬럼으로 대체 |
|
||||
| 인라인 편집 | ⚠️ 제한적 | 모달 편집으로 대체 |
|
||||
| 복잡한 차트 | ❌ 미지원 | 외부 라이브러리 연동 |
|
||||
|
||||
> **v1.1 업데이트**: 그룹화 테이블(`v2-table-grouped`)과 타임라인 스케줄러(`v2-timeline-scheduler`)가 추가되어 해당 기능은 이제 지원됩니다.
|
||||
|
||||
### 5.2 권장하지 않는 조합
|
||||
|
||||
| 조합 | 이유 |
|
||||
|------|------|
|
||||
| 3단계 이상 중첩 분할 | 화면 복잡도 증가, 성능 저하 |
|
||||
| 탭 안에 탭 | 사용성 저하 |
|
||||
| 한 화면에 3개 이상 테이블 | 데이터 로딩 성능 |
|
||||
| 피벗 + 상세 테이블 동시 | 데이터 과부하 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 제어관리 (비즈니스 로직) - 별도 설정 필수
|
||||
|
||||
> **핵심**: V2 컴포넌트는 **UI만 담당**합니다. 비즈니스 로직은 **제어관리**에서 별도 설정해야 합니다.
|
||||
|
||||
### 6.1 UI vs 제어 분리 구조
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 화면 구성 │
|
||||
├─────────────────────────────┬───────────────────────────────────┤
|
||||
│ UI 레이아웃 │ 제어관리 │
|
||||
│ (screen_layouts_v2) │ (dataflow_diagrams) │
|
||||
├─────────────────────────────┼───────────────────────────────────┤
|
||||
│ • 컴포넌트 배치 │ • 버튼 클릭 시 액션 │
|
||||
│ • 검색 필드 구성 │ • INSERT/UPDATE/DELETE 설정 │
|
||||
│ • 테이블 컬럼 표시 │ • 조건부 실행 │
|
||||
│ • 카드/탭 레이아웃 │ • 다중 행 처리 │
|
||||
│ │ • 테이블 간 데이터 이동 │
|
||||
└─────────────────────────────┴───────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.2 HTML에서 파악 가능/불가능
|
||||
|
||||
| 구분 | HTML에서 파악 | 이유 |
|
||||
|------|--------------|------|
|
||||
| 컴포넌트 배치 | ✅ 가능 | HTML 구조에서 보임 |
|
||||
| 검색 필드 | ✅ 가능 | input 태그로 확인 |
|
||||
| 테이블 컬럼 | ✅ 가능 | thead에서 확인 |
|
||||
| **저장 테이블** | ❌ 불가능 | JS/백엔드에서 처리 |
|
||||
| **버튼 액션** | ❌ 불가능 | 제어관리에서 설정 |
|
||||
| **전/후 처리** | ❌ 불가능 | 제어관리에서 설정 |
|
||||
| **다중 행 처리** | ❌ 불가능 | 제어관리에서 설정 |
|
||||
| **테이블 간 관계** | ❌ 불가능 | DB/제어관리에서 설정 |
|
||||
|
||||
### 6.3 제어관리 설정 항목
|
||||
|
||||
#### 트리거 타입
|
||||
- **버튼 클릭 전 (before)**: 클릭 직전 실행
|
||||
- **버튼 클릭 후 (after)**: 클릭 완료 후 실행
|
||||
|
||||
#### 액션 타입
|
||||
- **INSERT**: 새로운 데이터 삽입
|
||||
- **UPDATE**: 기존 데이터 수정
|
||||
- **DELETE**: 데이터 삭제
|
||||
|
||||
#### 조건 설정
|
||||
```typescript
|
||||
// 예: 선택된 행의 상태가 '대기'인 경우에만 실행
|
||||
{
|
||||
field: "status",
|
||||
operator: "=",
|
||||
value: "대기",
|
||||
dataType: "string"
|
||||
}
|
||||
```
|
||||
|
||||
#### 필드 매핑
|
||||
```typescript
|
||||
// 예: 소스 테이블의 값을 타겟 테이블로 이동
|
||||
{
|
||||
sourceTable: "order_master",
|
||||
sourceField: "order_no",
|
||||
targetTable: "order_history",
|
||||
targetField: "order_no"
|
||||
}
|
||||
```
|
||||
|
||||
### 6.4 제어관리 예시: 수주 확정 버튼
|
||||
|
||||
**시나리오**: 수주 목록에서 3건 선택 후 [확정] 버튼 클릭
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ [확정] 버튼 클릭 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 1. 조건 체크: status = '대기' 인 행만 │
|
||||
│ 2. UPDATE order_master SET status = '확정' WHERE id IN (선택) │
|
||||
│ 3. INSERT order_history (수주이력 테이블에 기록) │
|
||||
│ 4. 외부 시스템 호출 (ERP 연동) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**제어관리 설정**:
|
||||
```json
|
||||
{
|
||||
"triggerType": "after",
|
||||
"actions": [
|
||||
{
|
||||
"actionType": "update",
|
||||
"targetTable": "order_master",
|
||||
"conditions": [{ "field": "status", "operator": "=", "value": "대기" }],
|
||||
"fieldMappings": [{ "targetField": "status", "defaultValue": "확정" }]
|
||||
},
|
||||
{
|
||||
"actionType": "insert",
|
||||
"targetTable": "order_history",
|
||||
"fieldMappings": [
|
||||
{ "sourceField": "order_no", "targetField": "order_no" },
|
||||
{ "sourceField": "customer_name", "targetField": "customer_name" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 6.5 회사별 개발 시 제어관리 체크리스트
|
||||
|
||||
```
|
||||
□ 버튼별 액션 정의
|
||||
- 어떤 버튼이 있는가?
|
||||
- 각 버튼 클릭 시 무슨 동작?
|
||||
|
||||
□ 저장/수정/삭제 대상 테이블
|
||||
- 메인 테이블은?
|
||||
- 이력 테이블은?
|
||||
- 연관 테이블은?
|
||||
|
||||
□ 조건부 실행
|
||||
- 특정 상태일 때만 실행?
|
||||
- 특정 값 체크 필요?
|
||||
|
||||
□ 다중 행 처리
|
||||
- 여러 행 선택 후 일괄 처리?
|
||||
- 각 행별 개별 처리?
|
||||
|
||||
□ 외부 연동
|
||||
- ERP/MES 등 외부 시스템 호출?
|
||||
- API 연동 필요?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 회사별 커스터마이징 영역
|
||||
|
||||
### 7.1 컴포넌트로 처리되는 영역 (표준화)
|
||||
|
||||
| 영역 | 설명 |
|
||||
|------|------|
|
||||
| UI 레이아웃 | 컴포넌트 배치, 크기, 위치 |
|
||||
| 검색 조건 | 화면 디자이너에서 설정 |
|
||||
| 테이블 컬럼 | 표시/숨김, 순서, 너비 |
|
||||
| 기본 CRUD | 조회, 저장, 삭제 자동 처리 |
|
||||
| 페이지네이션 | 자동 처리 |
|
||||
| 정렬/필터 | 자동 처리 |
|
||||
|
||||
### 7.2 회사별 개발 필요 영역
|
||||
|
||||
| 영역 | 설명 | 개발 방법 |
|
||||
|------|------|----------|
|
||||
| 비즈니스 로직 | 저장 전/후 검증, 계산 | 데이터플로우 또는 백엔드 API |
|
||||
| 특수 UI | 간트, 트리, 차트 등 | 별도 컴포넌트 개발 |
|
||||
| 외부 연동 | ERP, MES 등 연계 | 외부 호출 설정 |
|
||||
| 리포트/인쇄 | 전표, 라벨 출력 | 리포트 컴포넌트 |
|
||||
| 결재 프로세스 | 승인/반려 흐름 | 워크플로우 설정 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 빠른 개발 가이드
|
||||
|
||||
### Step 1: 화면 분석
|
||||
1. 어떤 테이블을 사용하는가?
|
||||
2. 테이블 간 관계는?
|
||||
3. 어떤 패턴인가? (A/B/C/D/E)
|
||||
|
||||
### Step 2: 컴포넌트 배치
|
||||
1. 화면 디자이너에서 패턴에 맞는 컴포넌트 배치
|
||||
2. 각 컴포넌트에 테이블/컬럼 설정
|
||||
|
||||
### Step 3: 연동 설정
|
||||
1. 마스터-디테일 관계 설정 (FK)
|
||||
2. 검색 조건 설정
|
||||
3. 버튼 액션 설정
|
||||
|
||||
### Step 4: 테스트
|
||||
1. 데이터 조회 확인
|
||||
2. 마스터 선택 시 디테일 연동 확인
|
||||
3. 저장/삭제 동작 확인
|
||||
|
||||
---
|
||||
|
||||
## 9. 요약
|
||||
|
||||
### V2 컴포넌트 커버리지
|
||||
|
||||
| 화면 유형 | 지원 여부 | 주요 컴포넌트 |
|
||||
|-----------|----------|--------------|
|
||||
| 기본 CRUD | ✅ 완전 | v2-table-list, v2-table-search-widget |
|
||||
| 마스터-디테일 | ✅ 완전 | v2-split-panel-layout |
|
||||
| 탭 화면 | ✅ 완전 | v2-tabs-widget |
|
||||
| 카드 뷰 | ✅ 완전 | v2-card-display |
|
||||
| 피벗 분석 | ✅ 완전 | v2-pivot-grid |
|
||||
| 그룹화 테이블 | ✅ 지원 | v2-table-grouped |
|
||||
| 타임라인/스케줄 | ✅ 지원 | v2-timeline-scheduler |
|
||||
| 파일 업로드 | ✅ 지원 | v2-file-upload |
|
||||
| 트리 뷰 | ❌ 미지원 | 개발 필요 |
|
||||
|
||||
### 개발 시 핵심 원칙
|
||||
|
||||
1. **테이블 먼저**: DB 테이블 구조 확인이 최우선
|
||||
2. **패턴 판단**: 5가지 패턴 중 어디에 해당하는지 판단
|
||||
3. **표준 조합**: 검증된 컴포넌트 조합 사용
|
||||
4. **한계 인식**: 불가능한 UI는 조기에 식별하여 별도 개발 계획
|
||||
5. **멀티테넌시**: 모든 테이블에 company_code 필터링 필수
|
||||
6. **제어관리 필수**: UI 완성 후 버튼별 비즈니스 로직 설정 필수
|
||||
|
||||
### UI vs 제어 구분
|
||||
|
||||
| 영역 | 담당 | 설정 위치 |
|
||||
|------|------|----------|
|
||||
| 화면 레이아웃 | V2 컴포넌트 | 화면 디자이너 |
|
||||
| 비즈니스 로직 | 제어관리 | dataflow_diagrams |
|
||||
| 외부 연동 | 외부호출 설정 | external_call_configs |
|
||||
|
||||
**HTML에서 배낄 수 있는 것**: UI 구조만
|
||||
**별도 설정 필요한 것**: 저장 테이블, 버튼 액션, 조건 처리, 다중 행 처리
|
||||
|
|
@ -0,0 +1,952 @@
|
|||
# WACE 화면 시스템 - DB 스키마 & 컴포넌트 설정 전체 레퍼런스
|
||||
|
||||
> **최종 업데이트**: 2026-03-13
|
||||
> **용도**: AI 챗봇이 화면 생성 시 참조하는 DB 스키마, 컴포넌트 전체 설정 사전
|
||||
> **관련 문서**: `v2-component-usage-guide.md` (SQL 템플릿, 실행 예시)
|
||||
|
||||
---
|
||||
|
||||
## 1. 시스템 아키텍처
|
||||
|
||||
### 렌더링 파이프라인
|
||||
|
||||
```
|
||||
[DB] screen_definitions + screen_layouts_v2
|
||||
→ [Backend API] GET /api/screens/:screenId
|
||||
→ [layoutV2Converter] V2 JSON → Legacy 변환 (기본값 + overrides 병합)
|
||||
→ [ResponsiveGridRenderer] → DynamicComponentRenderer
|
||||
→ [ComponentRegistry] → 실제 React 컴포넌트
|
||||
```
|
||||
|
||||
### 테이블 관계도
|
||||
|
||||
```
|
||||
비즈니스 테이블 ←── table_labels (라벨)
|
||||
←── table_type_columns (컬럼 타입, company_code='*')
|
||||
←── column_labels (한글 라벨)
|
||||
|
||||
screen_definitions ←── screen_layouts_v2 (layout_data JSON)
|
||||
menu_info (메뉴 트리, menu_url → /screen/{screen_code})
|
||||
|
||||
[선택] dataflow_diagrams (비즈니스 로직)
|
||||
[선택] numbering_rules + numbering_rule_parts (채번)
|
||||
[선택] table_column_category_values (카테고리)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. DB 테이블 스키마
|
||||
|
||||
### 2.1 비즈니스 테이블 필수 구조
|
||||
|
||||
> **[최우선 규칙] 비즈니스 테이블에 NOT NULL / UNIQUE 제약조건 절대 금지!**
|
||||
>
|
||||
> 멀티테넌시 환경에서 회사별로 필수값/유니크 규칙이 다를 수 있으므로,
|
||||
> 제약조건은 DB 레벨이 아닌 **`table_type_columns`의 메타데이터(`is_nullable`, `is_unique`)로 논리적 제어**한다.
|
||||
> DB에 직접 NOT NULL/UNIQUE/CHECK/FOREIGN KEY를 걸면 멀티테넌시가 깨진다.
|
||||
>
|
||||
> **허용**: `id` PRIMARY KEY, `DEFAULT` 값만 DB 레벨 설정
|
||||
> **금지**: 비즈니스 컬럼에 `NOT NULL`, `UNIQUE`, `CHECK`, `FOREIGN KEY`
|
||||
|
||||
```sql
|
||||
CREATE TABLE "{테이블명}" (
|
||||
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
"created_date" timestamp DEFAULT now(),
|
||||
"updated_date" timestamp DEFAULT now(),
|
||||
"writer" varchar(500) DEFAULT NULL,
|
||||
"company_code" varchar(500),
|
||||
-- 모든 비즈니스 컬럼은 varchar(500), NOT NULL/UNIQUE 제약조건 금지
|
||||
);
|
||||
```
|
||||
|
||||
### 2.2 table_labels
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| table_name | varchar PK | 테이블명 |
|
||||
| table_label | varchar | 한글 라벨 |
|
||||
| description | text | 설명 |
|
||||
| use_log_table | varchar(1) | 'Y'/'N' |
|
||||
|
||||
### 2.3 table_type_columns
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | serial PK | 자동 증가 |
|
||||
| table_name | varchar | UNIQUE(+column_name+company_code) |
|
||||
| column_name | varchar | 컬럼명 |
|
||||
| company_code | varchar | `'*'` = 전체 공통 |
|
||||
| input_type | varchar | text/number/date/code/entity/select/checkbox/radio/textarea/category/numbering |
|
||||
| detail_settings | text | JSON (code/entity/select 상세) |
|
||||
| is_nullable | varchar | `'Y'`/`'N'` (논리적 필수값 제어) |
|
||||
| display_order | integer | -5~-1: 기본, 0~: 비즈니스 |
|
||||
| column_label | varchar | 컬럼 한글 라벨 |
|
||||
| description | text | 컬럼 설명 |
|
||||
| is_visible | boolean | 화면 표시 여부 (기본 true) |
|
||||
| code_category | varchar | input_type=code일 때 코드 카테고리 |
|
||||
| code_value | varchar | 코드 값 |
|
||||
| reference_table | varchar | input_type=entity일 때 참조 테이블 |
|
||||
| reference_column | varchar | 참조 컬럼 |
|
||||
| display_column | varchar | 참조 표시 컬럼 |
|
||||
| is_unique | varchar | `'Y'`/`'N'` (논리적 유니크 제어) |
|
||||
| category_ref | varchar | 카테고리 참조 |
|
||||
|
||||
### 2.4 screen_definitions
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| screen_id | serial PK | 자동 증가 |
|
||||
| screen_name | varchar NOT NULL | 화면명 |
|
||||
| screen_code | varchar | **조건부 UNIQUE** (`WHERE is_active <> 'D'`) |
|
||||
| table_name | varchar | 메인 테이블명 |
|
||||
| company_code | varchar NOT NULL | 회사 코드 |
|
||||
| description | text | 화면 설명 |
|
||||
| is_active | char(1) | `'Y'`/`'N'`/`'D'` (D=삭제) |
|
||||
| layout_metadata | jsonb | 레이아웃 메타데이터 |
|
||||
| created_date | timestamp | 생성일시 |
|
||||
| created_by | varchar | 생성자 |
|
||||
| updated_date | timestamp | 수정일시 |
|
||||
| updated_by | varchar | 수정자 |
|
||||
| deleted_date | timestamp | 삭제일시 |
|
||||
| deleted_by | varchar | 삭제자 |
|
||||
| delete_reason | text | 삭제 사유 |
|
||||
| db_source_type | varchar | `'internal'` (기본) / `'external'` |
|
||||
| db_connection_id | integer | 외부 DB 연결 ID |
|
||||
| data_source_type | varchar | `'database'` (기본) / `'rest_api'` |
|
||||
| rest_api_connection_id | integer | REST API 연결 ID |
|
||||
| rest_api_endpoint | varchar | REST API 엔드포인트 |
|
||||
| rest_api_json_path | varchar | JSON 응답 경로 (기본 `'data'`) |
|
||||
| source_screen_id | integer | 원본 화면 ID (복사본일 때) |
|
||||
|
||||
> **screen_code UNIQUE 주의**: `is_active = 'D'`(삭제)인 화면은 UNIQUE 대상에서 제외된다. 삭제된 화면과 같은 코드로 새 화면을 만들 수 있지만, 활성 상태(`'Y'`/`'N'`)에서는 중복 불가.
|
||||
|
||||
### 2.5 screen_layouts_v2
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| layout_id | serial PK | 자동 증가 |
|
||||
| screen_id | integer FK | UNIQUE(+company_code+layer_id) |
|
||||
| company_code | varchar NOT NULL | 회사 코드 |
|
||||
| layout_data | jsonb NOT NULL | 전체 레이아웃 JSON (기본 `'{}'`) |
|
||||
| created_at | timestamptz | 생성일시 |
|
||||
| updated_at | timestamptz | 수정일시 |
|
||||
| layer_id | integer | 1=기본 레이어 (기본값 1) |
|
||||
| layer_name | varchar | 레이어명 (기본 `'기본 레이어'`) |
|
||||
| condition_config | jsonb | 레이어 조건부 표시 설정 |
|
||||
|
||||
### 2.6 menu_info
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| objid | numeric PK | BIGINT 고유값 |
|
||||
| menu_type | numeric | 0=화면, 1=폴더 |
|
||||
| parent_obj_id | numeric | 부모 메뉴 objid |
|
||||
| menu_name_kor | varchar | 메뉴명 (한글) |
|
||||
| menu_name_eng | varchar | 메뉴명 (영문) |
|
||||
| seq | numeric | 정렬 순서 |
|
||||
| menu_url | varchar | `/screen/{screen_code}` |
|
||||
| menu_desc | varchar | 메뉴 설명 |
|
||||
| writer | varchar | 작성자 |
|
||||
| regdate | timestamp | 등록일시 |
|
||||
| status | varchar | 상태 (`'active'` 등) |
|
||||
| company_code | varchar | 회사 코드 (기본 `'*'`) |
|
||||
| screen_code | varchar | 연결 화면 코드 |
|
||||
| system_name | varchar | 시스템명 |
|
||||
| lang_key | varchar | 다국어 키 |
|
||||
| lang_key_desc | varchar | 다국어 설명 키 |
|
||||
| menu_code | varchar | 메뉴 코드 |
|
||||
| source_menu_objid | bigint | 원본 메뉴 objid (복사본일 때) |
|
||||
| screen_group_id | integer | 화면 그룹 ID |
|
||||
| menu_icon | varchar | 메뉴 아이콘 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 컴포넌트 전체 설정 레퍼런스 (32개)
|
||||
|
||||
> 아래 설정은 layout_data JSON의 각 컴포넌트 `overrides` 안에 들어가는 값이다.
|
||||
> 기본값과 다른 부분만 overrides에 지정하면 된다.
|
||||
|
||||
---
|
||||
|
||||
### 3.1 v2-table-list (데이터 테이블)
|
||||
|
||||
**용도**: DB 테이블 데이터를 테이블/카드 형태로 조회/편집. 가장 핵심적인 컴포넌트.
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| tableName | string | - | 조회할 DB 테이블명 |
|
||||
| selectedTable | string | - | tableName 별칭 |
|
||||
| displayMode | `"table"\|"card"` | `"table"` | 테이블 모드 또는 카드 모드 |
|
||||
| autoLoad | boolean | `true` | 화면 로드 시 자동으로 데이터 조회 |
|
||||
| isReadOnly | boolean | false | 읽기 전용 (편집 불가) |
|
||||
| columns | ColumnConfig[] | `[]` | 표시할 컬럼 설정 배열 |
|
||||
| title | string | - | 테이블 상단 제목 |
|
||||
| showHeader | boolean | `true` | 테이블 헤더 행 표시 |
|
||||
| showFooter | boolean | `true` | 테이블 푸터 표시 |
|
||||
| height | string | `"auto"` | 높이 모드 (`"auto"`, `"fixed"`, `"viewport"`) |
|
||||
| fixedHeight | number | - | height="fixed"일 때 고정 높이(px) |
|
||||
| autoWidth | boolean | `true` | 컬럼 너비 자동 계산 |
|
||||
| stickyHeader | boolean | `false` | 스크롤 시 헤더 고정 |
|
||||
|
||||
**checkbox (체크박스 설정)**:
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| enabled | boolean | `true` | 체크박스 사용 여부 |
|
||||
| multiple | boolean | `true` | 다중 선택 허용 |
|
||||
| position | `"left"\|"right"` | `"left"` | 체크박스 위치 |
|
||||
| selectAll | boolean | `true` | 전체 선택 버튼 표시 |
|
||||
|
||||
**pagination (페이지네이션)**:
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| enabled | boolean | `true` | 페이지네이션 사용 |
|
||||
| pageSize | number | `20` | 한 페이지당 행 수 |
|
||||
| showSizeSelector | boolean | `true` | 페이지 크기 변경 드롭다운 |
|
||||
| showPageInfo | boolean | `true` | "1-20 / 100건" 같은 정보 표시 |
|
||||
| pageSizeOptions | number[] | `[10,20,50,100]` | 선택 가능한 페이지 크기 |
|
||||
|
||||
**horizontalScroll (가로 스크롤)**:
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| enabled | boolean | `true` | 가로 스크롤 사용 |
|
||||
| maxVisibleColumns | number | `8` | 스크롤 없이 보이는 최대 컬럼 수 |
|
||||
| minColumnWidth | number | `100` | 컬럼 최소 너비(px) |
|
||||
| maxColumnWidth | number | `300` | 컬럼 최대 너비(px) |
|
||||
|
||||
**tableStyle (스타일)**:
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| theme | string | `"default"` | 테마 (`default`/`striped`/`bordered`/`minimal`) |
|
||||
| headerStyle | string | `"default"` | 헤더 스타일 (`default`/`dark`/`light`) |
|
||||
| rowHeight | string | `"normal"` | 행 높이 (`compact`/`normal`/`comfortable`) |
|
||||
| alternateRows | boolean | `true` | 짝수/홀수 행 색상 교차 |
|
||||
| hoverEffect | boolean | `true` | 마우스 호버 시 행 강조 |
|
||||
| borderStyle | string | `"light"` | 테두리 (`none`/`light`/`heavy`) |
|
||||
|
||||
**toolbar (툴바 버튼)**:
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| showEditMode | boolean | `false` | 즉시저장/배치저장 모드 전환 버튼 |
|
||||
| showExcel | boolean | `false` | Excel 내보내기 버튼 |
|
||||
| showPdf | boolean | `false` | PDF 내보내기 버튼 |
|
||||
| showSearch | boolean | `false` | 테이블 내 검색 |
|
||||
| showRefresh | boolean | `false` | 상단 새로고침 버튼 |
|
||||
| showPaginationRefresh | boolean | `true` | 하단 새로고침 버튼 |
|
||||
|
||||
**filter (필터)**:
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| enabled | boolean | `true` | 필터 기능 사용 |
|
||||
| filters | array | `[]` | 사전 정의 필터 목록 |
|
||||
|
||||
**ColumnConfig (columns 배열 요소)**:
|
||||
|
||||
| 설정 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| columnName | string | DB 컬럼명 |
|
||||
| displayName | string | 화면 표시명 |
|
||||
| visible | boolean | 표시 여부 |
|
||||
| sortable | boolean | 정렬 가능 여부 |
|
||||
| searchable | boolean | 검색 가능 여부 |
|
||||
| editable | boolean | 인라인 편집 가능 여부 |
|
||||
| width | number | 컬럼 너비(px) |
|
||||
| align | `"left"\|"center"\|"right"` | 텍스트 정렬 |
|
||||
| format | string | 포맷 (`text`/`number`/`date`/`currency`/`boolean`) |
|
||||
| hidden | boolean | 숨김 (데이터는 로드하되 표시 안 함) |
|
||||
| fixed | `"left"\|"right"\|false` | 컬럼 고정 위치 |
|
||||
| thousandSeparator | boolean | 숫자 천 단위 콤마 |
|
||||
| isEntityJoin | boolean | 엔티티 조인 사용 여부 |
|
||||
| entityJoinInfo | object | 조인 정보 (`sourceTable`, `sourceColumn`, `referenceTable`, `joinAlias`) |
|
||||
|
||||
**cardConfig (displayMode="card"일 때)**:
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| idColumn | string | `"id"` | ID 컬럼 |
|
||||
| titleColumn | string | `"name"` | 카드 제목 컬럼 |
|
||||
| subtitleColumn | string | - | 부제목 컬럼 |
|
||||
| descriptionColumn | string | - | 설명 컬럼 |
|
||||
| imageColumn | string | - | 이미지 URL 컬럼 |
|
||||
| cardsPerRow | number | `3` | 행당 카드 수 |
|
||||
| cardSpacing | number | `16` | 카드 간격(px) |
|
||||
| showActions | boolean | `true` | 카드 액션 버튼 표시 |
|
||||
|
||||
---
|
||||
|
||||
### 3.2 v2-split-panel-layout (마스터-디테일 분할)
|
||||
|
||||
**용도**: 좌측 마스터 테이블 선택 → 우측 디테일 테이블 연동. 가장 복잡한 컴포넌트.
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| splitRatio | number | `30` | 좌측 패널 비율(0~100) |
|
||||
| resizable | boolean | `true` | 사용자가 분할선 드래그로 비율 변경 가능 |
|
||||
| minLeftWidth | number | `200` | 좌측 최소 너비(px) |
|
||||
| minRightWidth | number | `300` | 우측 최소 너비(px) |
|
||||
| autoLoad | boolean | `true` | 화면 로드 시 자동 데이터 조회 |
|
||||
| syncSelection | boolean | `true` | 좌측 선택 시 우측 자동 갱신 |
|
||||
|
||||
**leftPanel / rightPanel 공통 설정**:
|
||||
|
||||
| 설정 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| title | string | 패널 제목 |
|
||||
| tableName | string | DB 테이블명 |
|
||||
| displayMode | `"list"\|"table"\|"custom"` | `list`: 리스트, `table`: 테이블, `custom`: 자유 배치 |
|
||||
| columns | array | 컬럼 설정 (`name`, `label`, `width`, `sortable`, `align`, `isEntityJoin`, `joinInfo`) |
|
||||
| showSearch | boolean | 패널 내 검색 바 표시 |
|
||||
| showAdd | boolean | 추가 버튼 표시 |
|
||||
| showEdit | boolean | 수정 버튼 표시 |
|
||||
| showDelete | boolean | 삭제 버튼 표시 |
|
||||
| addButton | object | `{ enabled, mode("auto"/"modal"), modalScreenId }` |
|
||||
| editButton | object | `{ enabled, mode("auto"/"modal"), modalScreenId, buttonLabel }` |
|
||||
| deleteButton | object | `{ enabled, buttonLabel, confirmMessage }` |
|
||||
| addModalColumns | array | 추가 모달 전용 컬럼 (`name`, `label`, `required`) |
|
||||
| dataFilter | object | `{ enabled, filters, matchType("all"/"any") }` |
|
||||
| tableConfig | object | `{ showCheckbox, showRowNumber, rowHeight, headerHeight, striped, bordered, hoverable, stickyHeader }` |
|
||||
| components | array | displayMode="custom"일 때 내부 컴포넌트 배열 |
|
||||
|
||||
**rightPanel 전용 설정**:
|
||||
|
||||
| 설정 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| relation | object | 마스터-디테일 연결 관계 |
|
||||
| relation.type | `"detail"\|"join"` | detail: FK 관계, join: 테이블 JOIN |
|
||||
| relation.leftColumn | string | 좌측(마스터) 연결 컬럼 (보통 `"id"`) |
|
||||
| relation.rightColumn | string | 우측(디테일) 연결 컬럼 (FK) |
|
||||
| relation.foreignKey | string | FK 컬럼명 (rightColumn과 동일) |
|
||||
| relation.keys | array | 복합키 `[{ leftColumn, rightColumn }]` |
|
||||
| additionalTabs | array | 우측 패널에 탭 추가 (각 탭은 rightPanel과 동일 구조 + `tabId`, `label`) |
|
||||
| addConfig | object | `{ targetTable, autoFillColumns, leftPanelColumn, targetColumn }` |
|
||||
| deduplication | object | `{ enabled, groupByColumn, keepStrategy, sortColumn }` |
|
||||
| summaryColumnCount | number | 요약 표시 컬럼 수 |
|
||||
|
||||
---
|
||||
|
||||
### 3.3 v2-table-search-widget (검색 바)
|
||||
|
||||
**용도**: 테이블 상단에 배치하여 검색/필터 기능 제공. 대상 테이블 컬럼을 자동 감지.
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| autoSelectFirstTable | boolean | `true` | 화면 내 첫 번째 테이블 자동 연결 |
|
||||
| showTableSelector | boolean | `true` | 테이블 선택 드롭다운 표시 |
|
||||
| title | string | `"테이블 검색"` | 검색 바 제목 |
|
||||
| filterMode | `"dynamic"\|"preset"` | `"dynamic"` | dynamic: 자동 필터, preset: 고정 필터 |
|
||||
| presetFilters | array | `[]` | 고정 필터 목록 (`{ columnName, columnLabel, filterType, width }`) |
|
||||
| targetPanelPosition | `"left"\|"right"\|"auto"` | `"left"` | split-panel에서 대상 패널 위치 |
|
||||
|
||||
---
|
||||
|
||||
### 3.4 v2-input (텍스트/숫자 입력)
|
||||
|
||||
**용도**: 텍스트, 숫자, 비밀번호, textarea, 슬라이더, 컬러, 버튼 등 단일 값 입력.
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| inputType | string | `"text"` | 입력 유형: `text`/`number`/`password`/`slider`/`color`/`button`/`textarea` |
|
||||
| format | string | `"none"` | 포맷 검증: `none`/`email`/`tel`/`url`/`currency`/`biz_no` |
|
||||
| placeholder | string | `""` | 입력 힌트 텍스트 |
|
||||
| required | boolean | `false` | 필수 입력 표시 |
|
||||
| readonly | boolean | `false` | 읽기 전용 |
|
||||
| disabled | boolean | `false` | 비활성화 |
|
||||
| maxLength | number | - | 최대 입력 글자 수 |
|
||||
| minLength | number | - | 최소 입력 글자 수 |
|
||||
| pattern | string | - | 정규식 패턴 검증 |
|
||||
| showCounter | boolean | `false` | 글자 수 카운터 표시 |
|
||||
| min | number | - | 최소값 (number/slider) |
|
||||
| max | number | - | 최대값 (number/slider) |
|
||||
| step | number | - | 증감 단위 (number/slider) |
|
||||
| buttonText | string | - | 버튼 텍스트 (inputType=button) |
|
||||
| tableName | string | - | 바인딩 테이블명 |
|
||||
| columnName | string | - | 바인딩 컬럼명 |
|
||||
|
||||
---
|
||||
|
||||
### 3.5 v2-select (선택)
|
||||
|
||||
**용도**: 드롭다운, 콤보박스, 라디오, 체크박스, 태그, 토글 등 선택형 입력.
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| mode | string | `"dropdown"` | 선택 모드: `dropdown`/`combobox`/`radio`/`check`/`tag`/`tagbox`/`toggle`/`swap` |
|
||||
| source | string | `"distinct"` | 데이터 소스: `static`/`code`/`db`/`api`/`entity`/`category`/`distinct`/`select` |
|
||||
| options | array | `[]` | source=static일 때 옵션 목록 `[{ label, value }]` |
|
||||
| codeGroup | string | - | source=code일 때 코드 그룹 |
|
||||
| codeCategory | string | - | source=code일 때 코드 카테고리 |
|
||||
| table | string | - | source=db일 때 테이블명 |
|
||||
| valueColumn | string | - | source=db일 때 값 컬럼 |
|
||||
| labelColumn | string | - | source=db일 때 표시 컬럼 |
|
||||
| entityTable | string | - | source=entity일 때 엔티티 테이블 |
|
||||
| entityValueField | string | - | source=entity일 때 값 필드 |
|
||||
| entityLabelField | string | - | source=entity일 때 표시 필드 |
|
||||
| searchable | boolean | `true` | 검색 가능 (combobox에서 기본 활성) |
|
||||
| multiple | boolean | `false` | 다중 선택 허용 |
|
||||
| maxSelect | number | - | 최대 선택 수 |
|
||||
| allowClear | boolean | - | 선택 해제 허용 |
|
||||
| placeholder | string | `"선택하세요"` | 힌트 텍스트 |
|
||||
| required | boolean | `false` | 필수 선택 |
|
||||
| readonly | boolean | `false` | 읽기 전용 |
|
||||
| disabled | boolean | `false` | 비활성화 |
|
||||
| cascading | object | - | 연쇄 선택 (상위 select 값에 따라 하위 옵션 변경) |
|
||||
| hierarchical | boolean | - | 계층 구조 (부모-자식 관계) |
|
||||
| parentField | string | - | 부모 필드명 |
|
||||
|
||||
---
|
||||
|
||||
### 3.6 v2-date (날짜)
|
||||
|
||||
**용도**: 날짜, 시간, 날짜시간, 날짜범위, 월, 연도 입력.
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| dateType | string | `"date"` | 날짜 유형: `date`/`datetime`/`time`/`daterange`/`month`/`year` |
|
||||
| format | string | `"YYYY-MM-DD"` | 표시/저장 형식 |
|
||||
| placeholder | string | `"날짜 선택"` | 힌트 텍스트 |
|
||||
| required | boolean | `false` | 필수 입력 |
|
||||
| readonly | boolean | `false` | 읽기 전용 |
|
||||
| disabled | boolean | `false` | 비활성화 |
|
||||
| showTime | boolean | `false` | 시간 선택 표시 (datetime) |
|
||||
| use24Hours | boolean | `true` | 24시간 형식 |
|
||||
| range | boolean | - | 범위 선택 (시작~종료) |
|
||||
| minDate | string | - | 선택 가능 최소 날짜 (ISO 8601) |
|
||||
| maxDate | string | - | 선택 가능 최대 날짜 |
|
||||
| showToday | boolean | - | 오늘 버튼 표시 |
|
||||
|
||||
---
|
||||
|
||||
### 3.7 v2-button-primary (액션 버튼)
|
||||
|
||||
**용도**: 저장, 삭제, 조회, 커스텀 등 액션 버튼. 제어관리(dataflow)와 연결 가능.
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| text | string | `"저장"` | 버튼 텍스트 |
|
||||
| actionType | string | `"button"` | 버튼 타입: `button`/`submit`/`reset` |
|
||||
| variant | string | `"primary"` | 스타일: `primary`/`secondary`/`danger` |
|
||||
| size | string | `"md"` | 크기: `sm`/`md`/`lg` |
|
||||
| disabled | boolean | `false` | 비활성화 |
|
||||
| action | object | - | 액션 설정 |
|
||||
| action.type | string | `"save"` | 액션 유형: `save`/`delete`/`edit`/`copy`/`navigate`/`modal`/`control`/`custom` |
|
||||
| action.successMessage | string | `"저장되었습니다."` | 성공 시 토스트 메시지 |
|
||||
| action.errorMessage | string | `"오류가 발생했습니다."` | 실패 시 토스트 메시지 |
|
||||
| webTypeConfig | object | - | 제어관리 연결 설정 |
|
||||
| webTypeConfig.enableDataflowControl | boolean | - | 제어관리 활성화 |
|
||||
| webTypeConfig.dataflowConfig | object | - | 제어관리 설정 |
|
||||
| webTypeConfig.dataflowConfig.controlMode | string | - | `"relationship"`/`"flow"`/`"none"` |
|
||||
| webTypeConfig.dataflowConfig.relationshipConfig | object | - | `{ relationshipId, executionTiming("before"/"after"/"replace") }` |
|
||||
| webTypeConfig.dataflowConfig.flowConfig | object | - | `{ flowId, executionTiming }` |
|
||||
|
||||
---
|
||||
|
||||
### 3.8 v2-table-grouped (그룹화 테이블)
|
||||
|
||||
**용도**: 특정 컬럼 기준으로 데이터를 그룹화. 그룹별 접기/펼치기, 집계 표시.
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| selectedTable | string | `""` | DB 테이블명 |
|
||||
| columns | array | `[]` | 컬럼 설정 (v2-table-list와 동일) |
|
||||
| showCheckbox | boolean | `false` | 체크박스 표시 |
|
||||
| checkboxMode | `"single"\|"multi"` | `"multi"` | 체크박스 모드 |
|
||||
| isReadOnly | boolean | `false` | 읽기 전용 |
|
||||
| rowClickable | boolean | `true` | 행 클릭 가능 |
|
||||
| showExpandAllButton | boolean | `true` | 전체 펼치기/접기 버튼 |
|
||||
| groupHeaderStyle | string | `"default"` | 그룹 헤더 스타일 (`default`/`compact`/`card`) |
|
||||
| emptyMessage | string | `"데이터가 없습니다."` | 빈 데이터 메시지 |
|
||||
| height | string\|number | `"auto"` | 높이 |
|
||||
| maxHeight | number | `600` | 최대 높이(px) |
|
||||
| pagination.enabled | boolean | `false` | 페이지네이션 사용 |
|
||||
| pagination.pageSize | number | `10` | 페이지 크기 |
|
||||
|
||||
**groupConfig (그룹화 설정)**:
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| groupByColumn | string | `""` | **필수**. 그룹화 기준 컬럼 |
|
||||
| groupLabelFormat | string | `"{value}"` | 그룹 라벨 포맷 |
|
||||
| defaultExpanded | boolean | `true` | 초기 펼침 여부 |
|
||||
| sortDirection | `"asc"\|"desc"` | `"asc"` | 그룹 정렬 방향 |
|
||||
| summary.showCount | boolean | `true` | 그룹별 건수 표시 |
|
||||
| summary.sumColumns | string[] | `[]` | 합계 표시할 컬럼 목록 |
|
||||
| summary.avgColumns | string[] | - | 평균 표시 컬럼 |
|
||||
| summary.maxColumns | string[] | - | 최대값 표시 컬럼 |
|
||||
| summary.minColumns | string[] | - | 최소값 표시 컬럼 |
|
||||
|
||||
---
|
||||
|
||||
### 3.9 v2-pivot-grid (피벗 분석)
|
||||
|
||||
**용도**: 다차원 데이터 분석. 행/열/데이터/필터 영역에 필드를 배치하여 집계.
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| fields | array | `[]` | **필수**. 피벗 필드 배열 |
|
||||
| dataSource | object | - | 데이터 소스 (`type`, `tableName`, `joinConfigs`, `filterConditions`) |
|
||||
| allowSortingBySummary | boolean | - | 집계값 기준 정렬 허용 |
|
||||
| allowFiltering | boolean | - | 필터링 허용 |
|
||||
| allowExpandAll | boolean | - | 전체 확장/축소 허용 |
|
||||
| wordWrapEnabled | boolean | - | 텍스트 줄바꿈 |
|
||||
| height | string\|number | - | 높이 |
|
||||
| totals.showRowGrandTotals | boolean | - | 행 총합계 표시 |
|
||||
| totals.showColumnGrandTotals | boolean | - | 열 총합계 표시 |
|
||||
| chart.enabled | boolean | - | 차트 연동 표시 |
|
||||
| chart.type | string | - | 차트 타입 (`bar`/`line`/`area`/`pie`/`stackedBar`) |
|
||||
|
||||
**fields 배열 요소**:
|
||||
|
||||
| 설정 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| field | string | DB 컬럼명 |
|
||||
| caption | string | 표시 라벨 |
|
||||
| area | `"row"\|"column"\|"data"\|"filter"` | **필수**. 배치 영역 |
|
||||
| summaryType | string | area=data일 때: `sum`/`count`/`avg`/`min`/`max`/`countDistinct` |
|
||||
| groupInterval | string | 날짜 그룹화: `year`/`quarter`/`month`/`week`/`day` |
|
||||
| sortBy | string | 정렬 기준: `value`/`caption` |
|
||||
| sortOrder | string | 정렬 방향: `asc`/`desc`/`none` |
|
||||
|
||||
---
|
||||
|
||||
### 3.10 v2-card-display (카드 뷰)
|
||||
|
||||
**용도**: 테이블 데이터를 카드 형태로 표시. 이미지+제목+설명 구조.
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| dataSource | string | `"table"` | 데이터 소스: `table`/`static` |
|
||||
| tableName | string | - | DB 테이블명 |
|
||||
| cardsPerRow | number | `3` | 행당 카드 수 (1~6) |
|
||||
| cardSpacing | number | `16` | 카드 간격(px) |
|
||||
| columnMapping | object | `{}` | 필드 매핑 (`title`, `subtitle`, `description`, `image`, `status`) |
|
||||
| cardStyle.showTitle | boolean | `true` | 제목 표시 |
|
||||
| cardStyle.showSubtitle | boolean | `true` | 부제목 표시 |
|
||||
| cardStyle.showDescription | boolean | `true` | 설명 표시 |
|
||||
| cardStyle.showImage | boolean | `false` | 이미지 표시 |
|
||||
| cardStyle.showActions | boolean | `true` | 액션 버튼 표시 |
|
||||
|
||||
---
|
||||
|
||||
### 3.11 v2-timeline-scheduler (간트차트)
|
||||
|
||||
**용도**: 시간축 기반 일정/계획 시각화. 드래그/리사이즈로 일정 편집.
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| selectedTable | string | - | 스케줄 데이터 테이블 |
|
||||
| resourceTable | string | `"equipment_mng"` | 리소스(설비/작업자) 테이블 |
|
||||
| scheduleType | string | `"PRODUCTION"` | 스케줄 유형: `PRODUCTION`/`MAINTENANCE`/`SHIPPING`/`WORK_ASSIGN` |
|
||||
| defaultZoomLevel | string | `"day"` | 초기 줌: `day`/`week`/`month` |
|
||||
| editable | boolean | `true` | 편집 가능 |
|
||||
| draggable | boolean | `true` | 드래그 이동 허용 |
|
||||
| resizable | boolean | `true` | 기간 리사이즈 허용 |
|
||||
| rowHeight | number | `50` | 행 높이(px) |
|
||||
| headerHeight | number | `60` | 헤더 높이(px) |
|
||||
| resourceColumnWidth | number | `150` | 리소스 컬럼 너비(px) |
|
||||
| cellWidth.day | number | `60` | 일 단위 셀 너비 |
|
||||
| cellWidth.week | number | `120` | 주 단위 셀 너비 |
|
||||
| cellWidth.month | number | `40` | 월 단위 셀 너비 |
|
||||
| showConflicts | boolean | `true` | 시간 겹침 충돌 표시 |
|
||||
| showProgress | boolean | `true` | 진행률 바 표시 |
|
||||
| showTodayLine | boolean | `true` | 오늘 날짜 표시선 |
|
||||
| showToolbar | boolean | `true` | 상단 툴바 표시 |
|
||||
| showAddButton | boolean | `true` | 추가 버튼 |
|
||||
| height | number | `500` | 높이(px) |
|
||||
|
||||
**fieldMapping (필수)**:
|
||||
|
||||
| 설정 | 기본값 | 설명 |
|
||||
|------|--------|------|
|
||||
| id | `"schedule_id"` | 스케줄 PK 필드 |
|
||||
| resourceId | `"resource_id"` | 리소스 FK 필드 |
|
||||
| title | `"schedule_name"` | 제목 필드 |
|
||||
| startDate | `"start_date"` | 시작일 필드 |
|
||||
| endDate | `"end_date"` | 종료일 필드 |
|
||||
| status | - | 상태 필드 |
|
||||
| progress | - | 진행률 필드 (0~100) |
|
||||
|
||||
**resourceFieldMapping**:
|
||||
|
||||
| 설정 | 기본값 | 설명 |
|
||||
|------|--------|------|
|
||||
| id | `"equipment_code"` | 리소스 PK |
|
||||
| name | `"equipment_name"` | 리소스 표시명 |
|
||||
| group | - | 리소스 그룹 |
|
||||
|
||||
**statusColors (상태별 색상)**:
|
||||
|
||||
| 상태 | 기본 색상 |
|
||||
|------|----------|
|
||||
| planned | `"#3b82f6"` (파랑) |
|
||||
| in_progress | `"#f59e0b"` (주황) |
|
||||
| completed | `"#10b981"` (초록) |
|
||||
| delayed | `"#ef4444"` (빨강) |
|
||||
| cancelled | `"#6b7280"` (회색) |
|
||||
|
||||
---
|
||||
|
||||
### 3.12 v2-tabs-widget (탭)
|
||||
|
||||
**용도**: 탭 전환. 각 탭 내부에 컴포넌트 배치 가능.
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| tabs | array | `[{id:"tab-1",label:"탭1",...}]` | 탭 배열 |
|
||||
| defaultTab | string | `"tab-1"` | 기본 활성 탭 ID |
|
||||
| orientation | string | `"horizontal"` | 탭 방향: `horizontal`/`vertical` |
|
||||
| variant | string | `"default"` | 스타일: `default`/`pills`/`underline` |
|
||||
| allowCloseable | boolean | `false` | 탭 닫기 버튼 표시 |
|
||||
| persistSelection | boolean | `false` | 탭 선택 상태 localStorage 저장 |
|
||||
|
||||
**tabs 배열 요소**: `{ id, label, order, disabled, icon, components[] }`
|
||||
**components 요소**: `{ id, componentType, label, position, size, componentConfig }`
|
||||
|
||||
---
|
||||
|
||||
### 3.13 v2-aggregation-widget (집계 카드)
|
||||
|
||||
**용도**: 합계, 평균, 개수 등 집계값을 카드 형태로 표시.
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| dataSourceType | string | `"table"` | 데이터 소스: `table`/`component`/`selection` |
|
||||
| tableName | string | - | 테이블명 |
|
||||
| items | array | `[]` | 집계 항목 배열 |
|
||||
| layout | string | `"horizontal"` | 배치: `horizontal`/`vertical` |
|
||||
| showLabels | boolean | `true` | 라벨 표시 |
|
||||
| showIcons | boolean | `true` | 아이콘 표시 |
|
||||
| gap | string | `"16px"` | 항목 간격 |
|
||||
| autoRefresh | boolean | `false` | 자동 새로고침 |
|
||||
| refreshOnFormChange | boolean | `true` | 폼 변경 시 새로고침 |
|
||||
|
||||
**items 요소**: `{ id, columnName, columnLabel, type("sum"/"avg"/"count"/"max"/"min"), format, decimalPlaces, prefix, suffix }`
|
||||
|
||||
---
|
||||
|
||||
### 3.14 v2-status-count (상태별 건수)
|
||||
|
||||
**용도**: 상태별 건수를 카드 형태로 표시. 대시보드/현황 화면용.
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| title | string | `"상태 현황"` | 제목 |
|
||||
| tableName | string | `""` | 대상 테이블 |
|
||||
| statusColumn | string | `"status"` | 상태 컬럼명 |
|
||||
| relationColumn | string | `""` | 관계 컬럼 (필터용) |
|
||||
| items | array | - | 상태 항목 `[{ value, label, color }]` |
|
||||
| showTotal | boolean | - | 합계 표시 |
|
||||
| cardSize | string | `"md"` | 카드 크기: `sm`/`md`/`lg` |
|
||||
|
||||
---
|
||||
|
||||
### 3.15 v2-text-display (텍스트 표시)
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| text | string | `"텍스트를 입력하세요"` | 표시 텍스트 |
|
||||
| fontSize | string | `"14px"` | 폰트 크기 |
|
||||
| fontWeight | string | `"normal"` | 폰트 굵기 |
|
||||
| color | string | `"#212121"` | 텍스트 색상 |
|
||||
| textAlign | string | `"left"` | 정렬: `left`/`center`/`right` |
|
||||
| backgroundColor | string | - | 배경색 |
|
||||
| padding | string | - | 패딩 |
|
||||
|
||||
---
|
||||
|
||||
### 3.16 v2-numbering-rule (자동 채번)
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| ruleConfig | object | - | 채번 규칙 설정 |
|
||||
| maxRules | number | `6` | 최대 파트 수 |
|
||||
| readonly | boolean | `false` | 읽기 전용 |
|
||||
| showPreview | boolean | `true` | 미리보기 표시 |
|
||||
| showRuleList | boolean | `true` | 규칙 목록 표시 |
|
||||
| cardLayout | string | `"vertical"` | 레이아웃: `vertical`/`horizontal` |
|
||||
|
||||
---
|
||||
|
||||
### 3.17 v2-file-upload (파일 업로드)
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| placeholder | string | `"파일을 선택하세요"` | 힌트 텍스트 |
|
||||
| multiple | boolean | `true` | 다중 업로드 |
|
||||
| accept | string | `"*/*"` | 허용 파일 형식 (예: `"image/*"`, `".pdf,.xlsx"`) |
|
||||
| maxSize | number | `10485760` | 최대 파일 크기(bytes, 기본 10MB) |
|
||||
| maxFiles | number | - | 최대 파일 수 |
|
||||
| showPreview | boolean | - | 미리보기 표시 |
|
||||
| showFileList | boolean | - | 파일 목록 표시 |
|
||||
| allowDelete | boolean | - | 삭제 허용 |
|
||||
| allowDownload | boolean | - | 다운로드 허용 |
|
||||
|
||||
---
|
||||
|
||||
### 3.18 v2-section-card (그룹 컨테이너 - 테두리)
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| title | string | `"섹션 제목"` | 제목 |
|
||||
| description | string | `""` | 설명 |
|
||||
| showHeader | boolean | `true` | 헤더 표시 |
|
||||
| padding | string | `"md"` | 패딩: `none`/`sm`/`md`/`lg` |
|
||||
| backgroundColor | string | `"default"` | 배경: `default`/`muted`/`transparent` |
|
||||
| borderStyle | string | `"solid"` | 테두리: `solid`/`dashed`/`none` |
|
||||
| collapsible | boolean | `false` | 접기/펼치기 가능 |
|
||||
| defaultOpen | boolean | `true` | 기본 펼침 |
|
||||
|
||||
---
|
||||
|
||||
### 3.19 v2-section-paper (그룹 컨테이너 - 배경색)
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| backgroundColor | string | `"default"` | 배경: `default`/`muted`/`accent`/`primary`/`custom` |
|
||||
| customColor | string | - | custom일 때 색상 |
|
||||
| showBorder | boolean | `false` | 테두리 표시 |
|
||||
| padding | string | `"md"` | 패딩: `none`/`sm`/`md`/`lg` |
|
||||
| roundedCorners | string | `"md"` | 모서리: `none`/`sm`/`md`/`lg` |
|
||||
| shadow | string | `"none"` | 그림자: `none`/`sm`/`md` |
|
||||
|
||||
---
|
||||
|
||||
### 3.20 v2-divider-line (구분선)
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| orientation | string | - | 방향 (가로/세로) |
|
||||
| thickness | number | - | 두께 |
|
||||
|
||||
---
|
||||
|
||||
### 3.21 v2-split-line (캔버스 분할선)
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| resizable | boolean | `true` | 드래그 리사이즈 허용 |
|
||||
| lineColor | string | `"#e2e8f0"` | 분할선 색상 |
|
||||
| lineWidth | number | `4` | 분할선 두께(px) |
|
||||
|
||||
---
|
||||
|
||||
### 3.22 v2-repeat-container (반복 렌더링)
|
||||
|
||||
**용도**: 데이터 수만큼 내부 컴포넌트를 반복 렌더링. 카드 리스트 등에 사용.
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| dataSourceType | string | `"manual"` | 소스: `table-list`/`v2-repeater`/`externalData`/`manual` |
|
||||
| dataSourceComponentId | string | - | 연결할 컴포넌트 ID |
|
||||
| tableName | string | - | 테이블명 |
|
||||
| layout | string | `"vertical"` | 배치: `vertical`/`horizontal`/`grid` |
|
||||
| gridColumns | number | `2` | grid일 때 컬럼 수 |
|
||||
| gap | string | `"16px"` | 아이템 간격 |
|
||||
| showBorder | boolean | `true` | 카드 테두리 |
|
||||
| showShadow | boolean | `false` | 카드 그림자 |
|
||||
| borderRadius | string | `"8px"` | 모서리 둥글기 |
|
||||
| backgroundColor | string | `"#ffffff"` | 배경색 |
|
||||
| padding | string | `"16px"` | 패딩 |
|
||||
| showItemTitle | boolean | `false` | 아이템 제목 표시 |
|
||||
| itemTitleTemplate | string | `""` | 제목 템플릿 (예: `"{order_no} - {item}"`) |
|
||||
| emptyMessage | string | `"데이터가 없습니다"` | 빈 상태 메시지 |
|
||||
| clickable | boolean | `false` | 클릭 가능 |
|
||||
| selectionMode | string | `"single"` | 선택 모드: `single`/`multiple` |
|
||||
| usePaging | boolean | `false` | 페이징 사용 |
|
||||
| pageSize | number | `10` | 페이지 크기 |
|
||||
|
||||
---
|
||||
|
||||
### 3.23 v2-repeater (반복 데이터 관리)
|
||||
|
||||
**용도**: 인라인/모달 모드로 반복 데이터(주문 상세 등) 관리. 행 추가/삭제/편집.
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| renderMode | string | `"inline"` | 모드: `inline` (인라인 편집) / `modal` (모달로 선택 추가) |
|
||||
| mainTableName | string | - | 저장 대상 테이블 |
|
||||
| foreignKeyColumn | string | - | 마스터 연결 FK 컬럼 |
|
||||
| foreignKeySourceColumn | string | - | 마스터 PK 컬럼 |
|
||||
| columns | array | `[]` | 컬럼 설정 |
|
||||
| dataSource.tableName | string | - | 데이터 테이블 |
|
||||
| dataSource.foreignKey | string | - | FK 컬럼 |
|
||||
| dataSource.sourceTable | string | - | 모달용 소스 테이블 |
|
||||
| modal.size | string | `"md"` | 모달 크기: `sm`/`md`/`lg`/`xl`/`full` |
|
||||
| modal.title | string | - | 모달 제목 |
|
||||
| modal.searchFields | string[] | - | 검색 필드 |
|
||||
| features.showAddButton | boolean | `true` | 추가 버튼 |
|
||||
| features.showDeleteButton | boolean | `true` | 삭제 버튼 |
|
||||
| features.inlineEdit | boolean | `false` | 인라인 편집 |
|
||||
| features.showRowNumber | boolean | `false` | 행 번호 표시 |
|
||||
| calculationRules | array | - | 자동 계산 규칙 (예: 수량*단가=금액) |
|
||||
|
||||
---
|
||||
|
||||
### 3.24 v2-approval-step (결재 스테퍼)
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| targetTable | string | `""` | 결재 대상 테이블 |
|
||||
| targetRecordIdField | string | `""` | 레코드 ID 필드 |
|
||||
| displayMode | string | `"horizontal"` | 표시 방향: `horizontal`/`vertical` |
|
||||
| showComment | boolean | `true` | 결재 코멘트 표시 |
|
||||
| showTimestamp | boolean | `true` | 결재 시간 표시 |
|
||||
| showDept | boolean | `true` | 부서 표시 |
|
||||
| compact | boolean | `false` | 컴팩트 모드 |
|
||||
|
||||
---
|
||||
|
||||
### 3.25 v2-bom-tree (BOM 트리)
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| detailTable | string | `"bom_detail"` | BOM 디테일 테이블 |
|
||||
| foreignKey | string | `"bom_id"` | BOM 마스터 FK |
|
||||
| parentKey | string | `"parent_detail_id"` | 트리 부모 키 (자기참조) |
|
||||
|
||||
---
|
||||
|
||||
### 3.26 v2-bom-item-editor (BOM 편집)
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| detailTable | string | `"bom_detail"` | BOM 디테일 테이블 |
|
||||
| sourceTable | string | `"item_info"` | 품목 소스 테이블 |
|
||||
| foreignKey | string | `"bom_id"` | BOM 마스터 FK |
|
||||
| parentKey | string | `"parent_detail_id"` | 트리 부모 키 |
|
||||
| itemCodeField | string | `"item_number"` | 품목 코드 필드 |
|
||||
| itemNameField | string | `"item_name"` | 품목명 필드 |
|
||||
| itemTypeField | string | `"type"` | 품목 유형 필드 |
|
||||
| itemUnitField | string | `"unit"` | 품목 단위 필드 |
|
||||
|
||||
---
|
||||
|
||||
### 3.27 v2-category-manager (카테고리 관리)
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| tableName | string | - | 대상 테이블 |
|
||||
| columnName | string | - | 카테고리 컬럼 |
|
||||
| menuObjid | number | - | 연결 메뉴 OBJID |
|
||||
| viewMode | string | `"tree"` | 뷰 모드: `tree`/`list` |
|
||||
| showViewModeToggle | boolean | `true` | 뷰 모드 토글 표시 |
|
||||
| defaultExpandLevel | number | `1` | 기본 트리 펼침 레벨 |
|
||||
| showInactiveItems | boolean | `false` | 비활성 항목 표시 |
|
||||
| leftPanelWidth | number | `15` | 좌측 패널 너비 |
|
||||
|
||||
---
|
||||
|
||||
### 3.28 v2-media (미디어)
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| mediaType | string | `"file"` | 미디어 타입: `file`/`image`/`video`/`audio` |
|
||||
| multiple | boolean | `false` | 다중 업로드 |
|
||||
| preview | boolean | `true` | 미리보기 |
|
||||
| maxSize | number | `10` | 최대 크기(MB) |
|
||||
| accept | string | `"*/*"` | 허용 형식 |
|
||||
| showFileList | boolean | `true` | 파일 목록 |
|
||||
| dragDrop | boolean | `true` | 드래그앤드롭 |
|
||||
|
||||
---
|
||||
|
||||
### 3.29 v2-location-swap-selector (위치 교환)
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| dataSource.type | string | `"static"` | 소스: `static`/`table`/`code` |
|
||||
| dataSource.tableName | string | - | 장소 테이블 |
|
||||
| dataSource.valueField | string | `"location_code"` | 값 필드 |
|
||||
| dataSource.labelField | string | `"location_name"` | 표시 필드 |
|
||||
| dataSource.staticOptions | array | - | 정적 옵션 `[{value, label}]` |
|
||||
| departureField | string | `"departure"` | 출발지 저장 필드 |
|
||||
| destinationField | string | `"destination"` | 도착지 저장 필드 |
|
||||
| departureLabel | string | `"출발지"` | 출발지 라벨 |
|
||||
| destinationLabel | string | `"도착지"` | 도착지 라벨 |
|
||||
| showSwapButton | boolean | `true` | 교환 버튼 표시 |
|
||||
| variant | string | `"card"` | UI: `card`/`inline`/`minimal` |
|
||||
|
||||
---
|
||||
|
||||
### 3.30 v2-rack-structure (창고 랙)
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| maxConditions | number | `10` | 최대 조건 수 |
|
||||
| maxRows | number | `99` | 최대 열 수 |
|
||||
| maxLevels | number | `20` | 최대 단 수 |
|
||||
| codePattern | string | `"{warehouseCode}-{floor}{zone}-{row:02d}-{level}"` | 위치 코드 패턴 |
|
||||
| namePattern | string | `"{zone}구역-{row:02d}열-{level}단"` | 위치 이름 패턴 |
|
||||
| showTemplates | boolean | `true` | 템플릿 표시 |
|
||||
| showPreview | boolean | `true` | 미리보기 |
|
||||
| showStatistics | boolean | `true` | 통계 카드 |
|
||||
| readonly | boolean | `false` | 읽기 전용 |
|
||||
|
||||
---
|
||||
|
||||
### 3.31 v2-process-work-standard (공정 작업기준)
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| dataSource.itemTable | string | `"item_info"` | 품목 테이블 |
|
||||
| dataSource.routingVersionTable | string | `"item_routing_version"` | 라우팅 버전 테이블 |
|
||||
| dataSource.routingDetailTable | string | `"item_routing_detail"` | 라우팅 디테일 테이블 |
|
||||
| dataSource.processTable | string | `"process_mng"` | 공정 테이블 |
|
||||
| splitRatio | number | `30` | 좌우 분할 비율 |
|
||||
| leftPanelTitle | string | `"품목 및 공정 선택"` | 좌측 패널 제목 |
|
||||
| readonly | boolean | `false` | 읽기 전용 |
|
||||
| itemListMode | string | `"all"` | 품목 모드: `all`/`registered` |
|
||||
|
||||
---
|
||||
|
||||
### 3.32 v2-item-routing (품목 라우팅)
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| dataSource.itemTable | string | `"item_info"` | 품목 테이블 |
|
||||
| dataSource.routingVersionTable | string | `"item_routing_version"` | 라우팅 버전 테이블 |
|
||||
| dataSource.routingDetailTable | string | `"item_routing_detail"` | 라우팅 디테일 테이블 |
|
||||
| dataSource.processTable | string | `"process_mng"` | 공정 테이블 |
|
||||
| splitRatio | number | `40` | 좌우 분할 비율 |
|
||||
| leftPanelTitle | string | `"품목 목록"` | 좌측 제목 |
|
||||
| rightPanelTitle | string | `"공정 순서"` | 우측 제목 |
|
||||
| readonly | boolean | `false` | 읽기 전용 |
|
||||
| autoSelectFirstVersion | boolean | `true` | 첫 버전 자동 선택 |
|
||||
| itemListMode | string | `"all"` | 품목 모드: `all`/`registered` |
|
||||
|
||||
---
|
||||
|
||||
## 4. 패턴 의사결정 트리
|
||||
|
||||
```
|
||||
Q1. 시간축 기반 일정/간트차트? → v2-timeline-scheduler
|
||||
Q2. 다차원 피벗 분석? → v2-pivot-grid
|
||||
Q3. 그룹별 접기/펼치기? → v2-table-grouped
|
||||
Q4. 카드 형태 표시? → v2-card-display
|
||||
Q5. 마스터-디테일?
|
||||
├ 우측 멀티 탭? → v2-split-panel-layout + additionalTabs
|
||||
└ 단일 디테일? → v2-split-panel-layout
|
||||
Q6. 단일 테이블? → v2-table-search-widget + v2-table-list
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 관계(relation) 레퍼런스
|
||||
|
||||
| 관계 유형 | 설정 |
|
||||
|----------|------|
|
||||
| 단순 FK | `{ type:"detail", leftColumn:"id", rightColumn:"{FK}", foreignKey:"{FK}" }` |
|
||||
| 복합 키 | `{ type:"detail", keys:[{ leftColumn:"a", rightColumn:"b" }] }` |
|
||||
| JOIN | `{ type:"join", leftColumn:"{col}", rightColumn:"{col}" }` |
|
||||
|
||||
## 6. 엔티티 조인
|
||||
|
||||
FK 컬럼에 참조 테이블의 이름을 표시:
|
||||
|
||||
**table_type_columns**: `input_type='entity'`, `detail_settings='{"referenceTable":"X","referenceColumn":"id","displayColumn":"name"}'`
|
||||
|
||||
**layout_data columns**: `{ name:"fk_col", isEntityJoin:true, joinInfo:{ sourceTable:"A", sourceColumn:"fk_col", referenceTable:"X", joinAlias:"name" } }`
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,856 @@
|
|||
# 생산계획관리 화면 구현 설계서
|
||||
|
||||
> **Screen Code**: `TOPSEAL_PP_MAIN` (screen_id: 3985)
|
||||
> **메뉴 경로**: 생산관리 > 생산계획관리
|
||||
> **HTML 예시**: `00_화면개발_html/Cursor 폴더/화면개발/PC브라우저/생산/생산계획관리.html`
|
||||
> **작성일**: 2026-03-13
|
||||
|
||||
---
|
||||
|
||||
## 1. 화면 전체 구조
|
||||
|
||||
```
|
||||
+---------------------------------------------------------------------+
|
||||
| 검색 섹션 (상단) |
|
||||
| [품목코드] [품명] [계획기간(daterange)] [상태] |
|
||||
| [사용자옵션] [엑셀업로드] [엑셀다운로드] |
|
||||
+----------------------------------+--+-------------------------------+
|
||||
| 좌측 패널 (50%, 리사이즈) | | 우측 패널 (50%) |
|
||||
| +------------------------------+ |리| +---------------------------+ |
|
||||
| | [수주데이터] [안전재고 부족분] | |사| | [완제품] [반제품] | |
|
||||
| +------------------------------+ |이| +---------------------------+ |
|
||||
| | 수주 목록 헤더 | |즈| | 완제품 생산 타임라인 헤더 | |
|
||||
| | [계획에없는품목만] [불러오기] | |핸| | [새로고침] [자동스케줄] | |
|
||||
| | +---------------------------+| |들| | [병합] [반제품계획] [저장] | |
|
||||
| | | 품목 그룹 테이블 || | | | +------------------------+| |
|
||||
| | | - 품목별 그룹 행 (13컬럼) || | | | | 옵션 패널 || |
|
||||
| | | -> 수주 상세 행 (7컬럼) || | | | | [리드타임] [기간] [재계산]|| |
|
||||
| | | - 접기/펼치기 토글 || | | | +------------------------+| |
|
||||
| | | - 체크박스 (그룹/개별) || | | | | 범례 || |
|
||||
| | +---------------------------+| | | | +------------------------+| |
|
||||
| +------------------------------+ | | | | 타임라인 스케줄러 || |
|
||||
| | | | | (간트차트 형태) || |
|
||||
| -- 안전재고 부족분 탭 -- | | | +------------------------+| |
|
||||
| | 부족 품목 테이블 (8컬럼) | | | +---------------------------+ |
|
||||
| | - 체크박스, 품목코드, 품명 | | | |
|
||||
| | - 현재고, 안전재고, 부족수량 | | | -- 반제품 탭 -- |
|
||||
| | - 권장생산량, 최종입고일 | | | | 옵션 + 안내 패널 | |
|
||||
| +------------------------------+ | | | 반제품 타임라인 스케줄러 | |
|
||||
+----------------------------------+--+-------------------------------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 사용 테이블 및 컬럼 매핑
|
||||
|
||||
### 2.1 메인 테이블
|
||||
|
||||
| 테이블명 | 용도 | PK |
|
||||
|----------|------|-----|
|
||||
| `production_plan_mng` | 생산계획 마스터 | `id` (serial) |
|
||||
| `sales_order_mng` | 수주 데이터 (좌측 패널 조회용) | `id` (serial) |
|
||||
| `item_info` | 품목 마스터 (참조) | `id` (uuid text) |
|
||||
| `inventory_stock` | 재고 현황 (안전재고 부족분 탭) | `id` (uuid text) |
|
||||
| `equipment_info` | 설비 정보 (타임라인 리소스) | `id` (serial) |
|
||||
| `bom` / `bom_detail` | BOM 정보 (반제품 계획 생성) | `id` (uuid text) |
|
||||
| `work_instruction` | 작업지시 (타임라인 연동) | 별도 확인 필요 |
|
||||
|
||||
### 2.2 핵심 컬럼 매핑 - production_plan_mng
|
||||
|
||||
| 컬럼명 | 타입 | 용도 | HTML 매핑 |
|
||||
|--------|------|------|-----------|
|
||||
| `id` | serial PK | 고유 ID | `schedule.id` |
|
||||
| `company_code` | varchar | 멀티테넌시 | - |
|
||||
| `plan_no` | varchar NOT NULL | 계획번호 | `SCH-{timestamp}` |
|
||||
| `plan_date` | date | 계획 등록일 | 자동 |
|
||||
| `item_code` | varchar NOT NULL | 품목코드 | `schedule.itemCode` |
|
||||
| `item_name` | varchar | 품목명 | `schedule.itemName` |
|
||||
| `product_type` | varchar | 완제품/반제품 | `'완제품'` or `'반제품'` |
|
||||
| `plan_qty` | numeric NOT NULL | 계획 수량 | `schedule.quantity` |
|
||||
| `completed_qty` | numeric | 완료 수량 | `schedule.completedQty` |
|
||||
| `progress_rate` | numeric | 진행률(%) | `schedule.progressRate` |
|
||||
| `start_date` | date NOT NULL | 시작일 | `schedule.startDate` |
|
||||
| `end_date` | date NOT NULL | 종료일 | `schedule.endDate` |
|
||||
| `due_date` | date | 납기일 | `schedule.dueDate` |
|
||||
| `equipment_id` | integer | 설비 ID | `schedule.equipmentId` |
|
||||
| `equipment_code` | varchar | 설비 코드 | - |
|
||||
| `equipment_name` | varchar | 설비명 | `schedule.productionLine` |
|
||||
| `status` | varchar | 상태 | `planned/in_progress/completed/work-order` |
|
||||
| `priority` | varchar | 우선순위 | `normal/high/urgent` |
|
||||
| `hourly_capacity` | numeric | 시간당 생산능력 | `schedule.hourlyCapacity` |
|
||||
| `daily_capacity` | numeric | 일일 생산능력 | `schedule.dailyCapacity` |
|
||||
| `lead_time` | integer | 리드타임(일) | `schedule.leadTime` |
|
||||
| `work_shift` | varchar | 작업조 | `DAY/NIGHT/BOTH` |
|
||||
| `work_order_no` | varchar | 작업지시번호 | `schedule.workOrderNo` |
|
||||
| `manager_name` | varchar | 담당자 | `schedule.manager` |
|
||||
| `order_no` | varchar | 연관 수주번호 | `schedule.orderInfo[].orderNo` |
|
||||
| `parent_plan_id` | integer | 모 계획 ID (반제품용) | `schedule.parentPlanId` |
|
||||
| `remarks` | text | 비고 | `schedule.remarks` |
|
||||
|
||||
### 2.3 수주 데이터 조회용 - sales_order_mng
|
||||
|
||||
| 컬럼명 | 용도 | 좌측 테이블 컬럼 매핑 |
|
||||
|--------|------|----------------------|
|
||||
| `order_no` | 수주번호 | 수주 상세 행 - 수주번호 |
|
||||
| `part_code` | 품목코드 | 그룹 행 - 품목코드 (그룹 기준) |
|
||||
| `part_name` | 품명 | 그룹 행 - 품목명 |
|
||||
| `order_qty` | 수주량 | 총수주량 (SUM) |
|
||||
| `ship_qty` | 출고량 | 출고량 (SUM) |
|
||||
| `balance_qty` | 잔량 | 잔량 (SUM) |
|
||||
| `due_date` | 납기일 | 수주 상세 행 - 납기일 |
|
||||
| `partner_id` | 거래처 | 수주 상세 행 - 거래처 |
|
||||
| `status` | 상태 | 상태 배지 (일반/긴급) |
|
||||
|
||||
### 2.4 안전재고 부족분 조회용 - inventory_stock + item_info
|
||||
|
||||
| 컬럼명 | 출처 | 좌측 테이블 컬럼 매핑 |
|
||||
|--------|------|----------------------|
|
||||
| `item_code` | inventory_stock | 품목코드 |
|
||||
| `item_name` | item_info (JOIN) | 품목명 |
|
||||
| `current_qty` | inventory_stock | 현재고 |
|
||||
| `safety_qty` | inventory_stock | 안전재고 |
|
||||
| `부족수량` | 계산값 (`safety_qty - current_qty`) | 부족수량 (음수면 부족) |
|
||||
| `권장생산량` | 계산값 (`safety_qty * 2 - current_qty`) | 권장생산량 |
|
||||
| `last_in_date` | inventory_stock | 최종입고일 |
|
||||
|
||||
---
|
||||
|
||||
## 3. V2 컴포넌트 구현 가능/불가능 분석
|
||||
|
||||
### 3.1 구현 가능 (기존 V2 컴포넌트)
|
||||
|
||||
| 기능 | V2 컴포넌트 | 현재 상태 |
|
||||
|------|-------------|-----------|
|
||||
| 좌우 분할 레이아웃 | `v2-split-panel-layout` (`displayMode: "custom"`) | layout_data에 이미 존재 |
|
||||
| 검색 필터 | `v2-table-search-widget` | layout_data에 이미 존재 |
|
||||
| 좌측/우측 탭 전환 | `v2-tabs-widget` | layout_data에 이미 존재 |
|
||||
| 체크박스 선택 | `v2-table-grouped` (`showCheckbox: true`) | layout_data에 이미 존재 |
|
||||
| 단순 그룹핑 테이블 | `v2-table-grouped` (`groupByColumn`) | layout_data에 이미 존재 |
|
||||
| 타임라인 스케줄러 | `v2-timeline-scheduler` | layout_data에 이미 존재 |
|
||||
| 버튼 액션 | `v2-button-primary` | layout_data에 이미 존재 |
|
||||
| 안전재고 부족분 테이블 | `v2-table-list` 또는 `v2-table-grouped` | 미구성 (탭2에 컴포넌트 없음) |
|
||||
|
||||
### 3.2 부분 구현 가능 (개선/확장 필요)
|
||||
|
||||
| 기능 | 문제점 | 필요 작업 |
|
||||
|------|--------|-----------|
|
||||
| 수주 그룹 테이블 (2레벨) | `v2-table-grouped`는 **동일 컬럼 기준 그룹핑**만 지원. HTML은 그룹 행(13컬럼)과 상세 행(7컬럼)이 완전히 다른 구조 | 컴포넌트 확장 or 백엔드에서 집계 데이터를 별도 API로 제공 |
|
||||
| 스케줄러 옵션 패널 | HTML의 안전리드타임/표시기간/재계산 옵션을 위한 전용 UI 없음 | `v2-input` + `v2-select` 조합으로 구성 가능 |
|
||||
| 범례 UI | `v2-timeline-scheduler`에 statusColors 설정은 있지만 범례 UI 자체는 없음 | `v2-text-display` 또는 커스텀 구성 |
|
||||
| 부족수량 빨간색 강조 | 조건부 서식(conditional formatting) 미지원 | 컴포넌트 확장 필요 |
|
||||
| "계획에 없는 품목만" 필터 | 단순 테이블 필터가 아닌 교차 테이블 비교 필터 | 백엔드 API 필요 |
|
||||
|
||||
### 3.3 신규 개발 필요 (현재 V2 컴포넌트로 불가능)
|
||||
|
||||
| 기능 | 설명 | 구현 방안 |
|
||||
|------|------|-----------|
|
||||
| **자동 스케줄 생성 API** | 선택 품목의 필요생산계획량, 납기일, 설비 생산능력 기반으로 타임라인 자동 배치 | 백엔드 전용 API |
|
||||
| **선택 계획 병합 API** | 동일 품목 복수 스케줄을 하나로 합산 | 백엔드 전용 API |
|
||||
| **반제품 계획 자동 생성 API** | BOM 기반으로 완제품 계획에서 필요 반제품 소요량 계산 | 백엔드 전용 API (BOM + 재고 연계) |
|
||||
| **수주 잔량/현재고 연산 조회 API** | 여러 테이블 JOIN + 집계 연산으로 좌측 패널 데이터 제공 | 백엔드 전용 API |
|
||||
| **스케줄 상세 모달** | 기본정보, 근거정보, 생산정보, 계획기간, 계획분할, 설비할당 | 모달 화면 (`TOPSEAL_PP_MODAL` screen_id: 3986) 보강 |
|
||||
| **설비 선택 모달** | 설비별 수량 할당 및 일정 등록 | 신규 모달 화면 필요 |
|
||||
| **변경사항 확인 모달** | 자동 스케줄 생성 전후 비교 (신규/유지/삭제 건수 요약) | 신규 모달 또는 확인 다이얼로그 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 백엔드 API 설계
|
||||
|
||||
### 4.1 수주 데이터 조회 API (좌측 패널 - 수주데이터 탭)
|
||||
|
||||
```
|
||||
GET /api/production/order-summary
|
||||
```
|
||||
|
||||
**목적**: 수주 데이터를 **품목별로 그룹핑**하여 반환. 그룹 헤더에 집계값(총수주량, 출고량, 잔량, 현재고, 안전재고, 기생산계획량 등) 포함.
|
||||
|
||||
**응답 구조**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"item_code": "ITEM-001",
|
||||
"item_name": "탑씰 Type A",
|
||||
"hourly_capacity": 100,
|
||||
"daily_capacity": 800,
|
||||
"lead_time": 1,
|
||||
"total_order_qty": 1000,
|
||||
"total_ship_qty": 300,
|
||||
"total_balance_qty": 700,
|
||||
"current_stock": 100,
|
||||
"safety_stock": 150,
|
||||
"plan_ship_qty": 0,
|
||||
"existing_plan_qty": 0,
|
||||
"in_progress_qty": 0,
|
||||
"required_plan_qty": 750,
|
||||
"orders": [
|
||||
{
|
||||
"order_no": "SO-2025-101",
|
||||
"partner_name": "ABC 상사",
|
||||
"order_qty": 500,
|
||||
"ship_qty": 200,
|
||||
"balance_qty": 300,
|
||||
"due_date": "2025-11-05",
|
||||
"is_urgent": false
|
||||
},
|
||||
{
|
||||
"order_no": "SO-2025-102",
|
||||
"partner_name": "XYZ 무역",
|
||||
"order_qty": 500,
|
||||
"ship_qty": 100,
|
||||
"balance_qty": 400,
|
||||
"due_date": "2025-11-10",
|
||||
"is_urgent": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**SQL 로직 (핵심)**:
|
||||
```sql
|
||||
WITH order_summary AS (
|
||||
SELECT
|
||||
so.part_code AS item_code,
|
||||
so.part_name AS item_name,
|
||||
SUM(COALESCE(so.order_qty, 0)) AS total_order_qty,
|
||||
SUM(COALESCE(so.ship_qty, 0)) AS total_ship_qty,
|
||||
SUM(COALESCE(so.balance_qty, 0)) AS total_balance_qty
|
||||
FROM sales_order_mng so
|
||||
WHERE so.company_code = $1
|
||||
AND so.status NOT IN ('cancelled', 'completed')
|
||||
AND so.balance_qty > 0
|
||||
GROUP BY so.part_code, so.part_name
|
||||
),
|
||||
stock_info AS (
|
||||
SELECT
|
||||
item_code,
|
||||
SUM(COALESCE(current_qty::numeric, 0)) AS current_stock,
|
||||
MAX(COALESCE(safety_qty::numeric, 0)) AS safety_stock
|
||||
FROM inventory_stock
|
||||
WHERE company_code = $1
|
||||
GROUP BY item_code
|
||||
),
|
||||
plan_info AS (
|
||||
SELECT
|
||||
item_code,
|
||||
SUM(CASE WHEN status = 'planned' THEN plan_qty ELSE 0 END) AS existing_plan_qty,
|
||||
SUM(CASE WHEN status = 'in_progress' THEN plan_qty ELSE 0 END) AS in_progress_qty
|
||||
FROM production_plan_mng
|
||||
WHERE company_code = $1
|
||||
AND product_type = '완제품'
|
||||
AND status NOT IN ('completed', 'cancelled')
|
||||
GROUP BY item_code
|
||||
)
|
||||
SELECT
|
||||
os.*,
|
||||
COALESCE(si.current_stock, 0) AS current_stock,
|
||||
COALESCE(si.safety_stock, 0) AS safety_stock,
|
||||
COALESCE(pi.existing_plan_qty, 0) AS existing_plan_qty,
|
||||
COALESCE(pi.in_progress_qty, 0) AS in_progress_qty,
|
||||
GREATEST(
|
||||
os.total_balance_qty + COALESCE(si.safety_stock, 0) - COALESCE(si.current_stock, 0)
|
||||
- COALESCE(pi.existing_plan_qty, 0) - COALESCE(pi.in_progress_qty, 0),
|
||||
0
|
||||
) AS required_plan_qty
|
||||
FROM order_summary os
|
||||
LEFT JOIN stock_info si ON os.item_code = si.item_code
|
||||
LEFT JOIN plan_info pi ON os.item_code = pi.item_code
|
||||
ORDER BY os.item_code;
|
||||
```
|
||||
|
||||
**파라미터**:
|
||||
- `company_code`: req.user.companyCode (자동)
|
||||
- `exclude_planned` (optional): `true`이면 기존 계획이 있는 품목 제외
|
||||
|
||||
---
|
||||
|
||||
### 4.2 안전재고 부족분 조회 API (좌측 패널 - 안전재고 탭)
|
||||
|
||||
```
|
||||
GET /api/production/stock-shortage
|
||||
```
|
||||
|
||||
**응답 구조**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"item_code": "ITEM-001",
|
||||
"item_name": "탑씰 Type A",
|
||||
"current_qty": 50,
|
||||
"safety_qty": 200,
|
||||
"shortage_qty": -150,
|
||||
"recommended_qty": 300,
|
||||
"last_in_date": "2025-10-15"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**SQL 로직**:
|
||||
```sql
|
||||
SELECT
|
||||
ist.item_code,
|
||||
ii.item_name,
|
||||
COALESCE(ist.current_qty::numeric, 0) AS current_qty,
|
||||
COALESCE(ist.safety_qty::numeric, 0) AS safety_qty,
|
||||
(COALESCE(ist.current_qty::numeric, 0) - COALESCE(ist.safety_qty::numeric, 0)) AS shortage_qty,
|
||||
GREATEST(COALESCE(ist.safety_qty::numeric, 0) * 2 - COALESCE(ist.current_qty::numeric, 0), 0) AS recommended_qty,
|
||||
ist.last_in_date
|
||||
FROM inventory_stock ist
|
||||
JOIN item_info ii ON ist.item_code = ii.id AND ist.company_code = ii.company_code
|
||||
WHERE ist.company_code = $1
|
||||
AND COALESCE(ist.current_qty::numeric, 0) < COALESCE(ist.safety_qty::numeric, 0)
|
||||
ORDER BY shortage_qty ASC;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.3 자동 스케줄 생성 API
|
||||
|
||||
```
|
||||
POST /api/production/generate-schedule
|
||||
```
|
||||
|
||||
**요청 body**:
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"item_code": "ITEM-001",
|
||||
"item_name": "탑씰 Type A",
|
||||
"required_qty": 750,
|
||||
"earliest_due_date": "2025-11-05",
|
||||
"hourly_capacity": 100,
|
||||
"daily_capacity": 800,
|
||||
"lead_time": 1,
|
||||
"orders": [
|
||||
{ "order_no": "SO-2025-101", "balance_qty": 300, "due_date": "2025-11-05" },
|
||||
{ "order_no": "SO-2025-102", "balance_qty": 400, "due_date": "2025-11-10" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"options": {
|
||||
"safety_lead_time": 1,
|
||||
"recalculate_unstarted": true,
|
||||
"product_type": "완제품"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**비즈니스 로직**:
|
||||
1. 각 품목의 필요생산계획량, 납기일, 일일생산능력을 기반으로 생산일수 계산
|
||||
2. `생산일수 = ceil(필요생산계획량 / 일일생산능력)`
|
||||
3. `시작일 = 납기일 - 생산일수 - 안전리드타임`
|
||||
4. 시작일이 오늘 이전이면 오늘로 조정
|
||||
5. `recalculate_unstarted = true`면 기존 진행중/작업지시/완료 스케줄은 유지, 미진행(planned)만 제거 후 재계산
|
||||
6. 결과를 `production_plan_mng`에 INSERT
|
||||
7. 변경사항 요약(신규/유지/삭제 건수) 반환
|
||||
|
||||
**응답 구조**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"summary": {
|
||||
"total": 3,
|
||||
"new_count": 2,
|
||||
"kept_count": 1,
|
||||
"deleted_count": 1
|
||||
},
|
||||
"schedules": [
|
||||
{
|
||||
"id": 101,
|
||||
"plan_no": "PP-2025-0001",
|
||||
"item_code": "ITEM-001",
|
||||
"item_name": "탑씰 Type A",
|
||||
"plan_qty": 750,
|
||||
"start_date": "2025-10-30",
|
||||
"end_date": "2025-11-03",
|
||||
"due_date": "2025-11-05",
|
||||
"status": "planned"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.4 스케줄 병합 API
|
||||
|
||||
```
|
||||
POST /api/production/merge-schedules
|
||||
```
|
||||
|
||||
**요청 body**:
|
||||
```json
|
||||
{
|
||||
"schedule_ids": [101, 102, 103],
|
||||
"product_type": "완제품"
|
||||
}
|
||||
```
|
||||
|
||||
**비즈니스 로직**:
|
||||
1. 선택된 스케줄이 모두 동일 품목인지 검증
|
||||
2. 완제품/반제품이 섞여있지 않은지 검증
|
||||
3. 수량 합산, 가장 빠른 시작일/납기일, 가장 늦은 종료일 적용
|
||||
4. 원본 스케줄 DELETE, 병합된 스케줄 INSERT
|
||||
5. 수주 정보(order_no)는 병합 (중복 제거)
|
||||
|
||||
---
|
||||
|
||||
### 4.5 반제품 계획 자동 생성 API
|
||||
|
||||
```
|
||||
POST /api/production/generate-semi-schedule
|
||||
```
|
||||
|
||||
**요청 body**:
|
||||
```json
|
||||
{
|
||||
"plan_ids": [101, 102],
|
||||
"options": {
|
||||
"consider_stock": true,
|
||||
"keep_in_progress": false,
|
||||
"exclude_used": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**비즈니스 로직**:
|
||||
1. 선택된 완제품 계획의 품목코드로 BOM 조회
|
||||
2. `bom` 테이블에서 해당 품목의 `item_id` → `bom_detail`에서 하위 반제품(`child_item_id`) 조회
|
||||
3. 각 반제품의 필요 수량 = `완제품 계획수량 x BOM 소요량(quantity)`
|
||||
4. `consider_stock = true`면 현재고/안전재고 감안하여 순 필요량 계산
|
||||
5. `exclude_used = true`면 이미 투입된 반제품 수량 차감
|
||||
6. 모품목 생산 시작일 고려하여 반제품 납기일 설정 (시작일 - 반제품 리드타임)
|
||||
7. `production_plan_mng`에 `product_type = '반제품'`, `parent_plan_id` 설정하여 INSERT
|
||||
|
||||
---
|
||||
|
||||
### 4.6 스케줄 상세 저장/수정 API
|
||||
|
||||
```
|
||||
PUT /api/production/plan/:id
|
||||
```
|
||||
|
||||
**요청 body**:
|
||||
```json
|
||||
{
|
||||
"plan_qty": 750,
|
||||
"start_date": "2025-10-30",
|
||||
"end_date": "2025-11-03",
|
||||
"equipment_id": 1,
|
||||
"equipment_code": "LINE-01",
|
||||
"equipment_name": "1호기",
|
||||
"manager_name": "홍길동",
|
||||
"work_shift": "DAY",
|
||||
"priority": "high",
|
||||
"remarks": "긴급 생산"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.7 스케줄 분할 API
|
||||
|
||||
```
|
||||
POST /api/production/split-schedule
|
||||
```
|
||||
|
||||
**요청 body**:
|
||||
```json
|
||||
{
|
||||
"plan_id": 101,
|
||||
"splits": [
|
||||
{ "qty": 500, "start_date": "2025-10-30", "end_date": "2025-11-01" },
|
||||
{ "qty": 250, "start_date": "2025-11-02", "end_date": "2025-11-03" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**비즈니스 로직**:
|
||||
1. 분할 수량 합산이 원본 수량과 일치하는지 검증
|
||||
2. 원본 스케줄 DELETE
|
||||
3. 분할된 각 조각을 신규 INSERT (동일 `order_no`, `item_code` 유지)
|
||||
|
||||
---
|
||||
|
||||
## 5. 모달 화면 설계
|
||||
|
||||
### 5.1 스케줄 상세 모달 (screen_id: 3986 보강)
|
||||
|
||||
**섹션 구성**:
|
||||
|
||||
| 섹션 | 필드 | 타입 | 비고 |
|
||||
|------|------|------|------|
|
||||
| **기본 정보** | 품목코드, 품목명 | text (readonly) | 자동 채움 |
|
||||
| **근거 정보** | 수주번호/거래처/납기일 목록 | text (readonly) | 연관 수주 정보 표시 |
|
||||
| **생산 정보** | 총 생산수량 | number | 수정 가능 |
|
||||
| | 납기일 (수주 기준) | date (readonly) | 가장 빠른 납기일 |
|
||||
| **계획 기간** | 계획 시작일, 종료일 | date | 수정 가능 |
|
||||
| | 생산 기간 | text (readonly) | 자동 계산 표시 |
|
||||
| **계획 분할** | 분할 개수, 분할 수량 입력 | select, number | 분할하기 기능 |
|
||||
| **설비 할당** | 설비 선택 버튼 | button → 모달 | 설비 선택 모달 오픈 |
|
||||
| **생산 상태** | 상태 | select (disabled) | `planned/work-order/in_progress/completed` |
|
||||
| **추가 정보** | 담당자, 작업지시번호, 비고 | text | 수정 가능 |
|
||||
| **하단 버튼** | 삭제, 취소, 저장 | buttons | - |
|
||||
|
||||
### 5.2 수주 불러오기 모달
|
||||
|
||||
**구성**:
|
||||
- 선택된 품목 목록 표시
|
||||
- 주의사항 안내
|
||||
- 라디오 버튼: "기존 계획에 추가" / "별도 계획으로 생성"
|
||||
- 취소/불러오기 버튼
|
||||
|
||||
### 5.3 안전재고 불러오기 모달
|
||||
|
||||
**구성**: 수주 불러오기 모달과 동일한 패턴
|
||||
|
||||
### 5.4 설비 선택 모달
|
||||
|
||||
**구성**:
|
||||
- 총 수량 / 할당 수량 / 미할당 수량 요약
|
||||
- 설비 카드 그리드 (설비명, 생산능력, 할당 수량 입력, 시작일/종료일)
|
||||
- 취소/저장 버튼
|
||||
|
||||
### 5.5 변경사항 확인 모달
|
||||
|
||||
**구성**:
|
||||
- 경고 메시지
|
||||
- 변경사항 요약 카드 (총 계획, 신규 생성, 유지됨, 삭제됨)
|
||||
- 변경사항 상세 목록 (품목별 변경 전/후 비교)
|
||||
- 취소/확인 및 적용 버튼
|
||||
|
||||
---
|
||||
|
||||
## 6. 현재 layout_data 수정 필요 사항
|
||||
|
||||
### 6.1 현재 layout_data 구조 (screen_id: 3985, layout_id: 9192)
|
||||
|
||||
```
|
||||
comp_search (v2-table-search-widget) - 검색 필터
|
||||
comp_split_panel (v2-split-panel-layout)
|
||||
├── leftPanel (custom mode)
|
||||
│ ├── left_tabs (v2-tabs-widget) - [수주데이터, 안전재고 부족분]
|
||||
│ ├── order_table (v2-table-grouped) - 수주 테이블
|
||||
│ └── btn_import (v2-button-primary) - 선택 품목 불러오기
|
||||
├── rightPanel (custom mode)
|
||||
│ ├── right_tabs (v2-tabs-widget) - [완제품, 반제품]
|
||||
│ │ └── finished_tab.components
|
||||
│ │ ├── v2-timeline-scheduler - 타임라인
|
||||
│ │ └── v2-button-primary - 스케줄 생성
|
||||
│ ├── btn_save (v2-button-primary) - 자동 스케줄 생성
|
||||
│ └── btn_clear (v2-button-primary) - 초기화
|
||||
comp_q0iqzkpx (v2-button-primary) - 하단 저장 버튼 (무의미)
|
||||
```
|
||||
|
||||
### 6.2 수정 필요 사항
|
||||
|
||||
| 항목 | 현재 상태 | 필요 상태 |
|
||||
|------|-----------|-----------|
|
||||
| **좌측 - 안전재고 탭** | 컴포넌트 없음 (`"컴포넌트가 없습니다"` 표시) | `v2-table-list` 또는 별도 조회 API 연결된 테이블 추가 |
|
||||
| **좌측 - order_table** | `selectedTable: "sales_order_mng"` (범용 API) | 전용 API (`/api/production/order-summary`)로 변경 필요 |
|
||||
| **좌측 - 체크박스 필터** | 없음 | "계획에 없는 품목만" 체크박스 UI 추가 |
|
||||
| **우측 - 반제품 탭** | 컴포넌트 없음 | 반제품 타임라인 + 옵션 패널 추가 |
|
||||
| **우측 - 타임라인** | `selectedTable: "work_instruction"` | `selectedTable: "production_plan_mng"` + 필터 `product_type='완제품'` |
|
||||
| **우측 - 옵션 패널** | 없음 | 안전리드타임, 표시기간, 재계산 체크박스 → `v2-input` 조합 |
|
||||
| **우측 - 범례** | 없음 | `v2-text-display` 또는 커스텀 범례 컴포넌트 |
|
||||
| **우측 - 버튼들** | 일부만 존재 | 병합, 반제품계획, 저장, 초기화 추가 |
|
||||
| **하단 저장 버튼** | 존재 (무의미) | 제거 |
|
||||
| **우측 패널 렌더링 버그** | 타임라인 미렌더링 | SplitPanelLayout custom 모드 디버깅 필요 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 구현 단계별 계획
|
||||
|
||||
### Phase 1: 기존 버그 수정 + 기본 구조 안정화
|
||||
|
||||
**목표**: 현재 layout_data로 화면이 최소한 정상 렌더링되게 만들기
|
||||
|
||||
| 작업 | 상세 | 예상 난이도 |
|
||||
|------|------|-------------|
|
||||
| 1-1. 좌측 z-index 겹침 수정 | SplitPanelLayout의 custom 모드에서 내부 컴포넌트가 비대화형 div에 가려지는 이슈 | 중 |
|
||||
| 1-2. 우측 타임라인 렌더링 수정 | tabs-widget 내부 timeline-scheduler가 렌더링되지 않는 이슈 | 중 |
|
||||
| 1-3. 하단 저장 버튼 제거 | layout_data에서 `comp_q0iqzkpx` 제거 | 하 |
|
||||
| 1-4. 타임라인 데이터 소스 수정 | `work_instruction` → `production_plan_mng`으로 변경 | 하 |
|
||||
|
||||
### Phase 2: 백엔드 API 개발
|
||||
|
||||
**목표**: 화면에 필요한 데이터를 제공하는 전용 API 구축
|
||||
|
||||
| 작업 | 상세 | 예상 난이도 |
|
||||
|------|------|-------------|
|
||||
| 2-1. 수주 데이터 조회 API | `GET /api/production/order-summary` (4.1 참조) | 중 |
|
||||
| 2-2. 안전재고 부족분 API | `GET /api/production/stock-shortage` (4.2 참조) | 하 |
|
||||
| 2-3. 자동 스케줄 생성 API | `POST /api/production/generate-schedule` (4.3 참조) | 상 |
|
||||
| 2-4. 스케줄 CRUD API | `PUT/DELETE /api/production/plan/:id` (4.6 참조) | 중 |
|
||||
| 2-5. 스케줄 병합 API | `POST /api/production/merge-schedules` (4.4 참조) | 중 |
|
||||
| 2-6. 반제품 계획 자동 생성 API | `POST /api/production/generate-semi-schedule` (4.5 참조) | 상 |
|
||||
| 2-7. 스케줄 분할 API | `POST /api/production/split-schedule` (4.7 참조) | 중 |
|
||||
|
||||
### Phase 3: layout_data 보강 + 모달 화면
|
||||
|
||||
**목표**: 안전재고 탭, 반제품 탭, 모달들 구성
|
||||
|
||||
| 작업 | 상세 | 예상 난이도 |
|
||||
|------|------|-------------|
|
||||
| 3-1. 안전재고 부족분 탭 구성 | `stock_tab`에 테이블 컴포넌트 + "선택 품목 불러오기" 버튼 추가 | 중 |
|
||||
| 3-2. 반제품 탭 구성 | `semi_tab`에 타임라인 + 옵션 + 버튼 추가 | 중 |
|
||||
| 3-3. 옵션 패널 구성 | v2-input 조합으로 안전리드타임, 표시기간, 체크박스 | 중 |
|
||||
| 3-4. 버튼 액션 연결 | 자동 스케줄, 병합, 반제품계획, 저장, 초기화 → API 연결 | 중 |
|
||||
| 3-5. 스케줄 상세 모달 보강 | screen_id: 3986 layout_data 수정 | 중 |
|
||||
| 3-6. 수주/안전재고 불러오기 모달 | 신규 모달 screen 생성 | 중 |
|
||||
| 3-7. 설비 선택 모달 | 신규 모달 screen 생성 | 중 |
|
||||
|
||||
### Phase 4: v2-table-grouped 확장 (2레벨 트리 지원)
|
||||
|
||||
**목표**: HTML 예시의 "품목 그룹 → 수주 상세" 2레벨 트리 테이블 구현
|
||||
|
||||
| 작업 | 상세 | 예상 난이도 |
|
||||
|------|------|-------------|
|
||||
| 4-1. 컴포넌트 확장 설계 | 그룹 행과 상세 행이 다른 컬럼 구조를 가질 수 있도록 설계 | 상 |
|
||||
| 4-2. expandedRowRenderer 구현 | 그룹 행 펼침 시 별도 컬럼/데이터로 하위 행 렌더링 | 상 |
|
||||
| 4-3. 그룹 행 집계 컬럼 설정 | 그룹 헤더에 SUM, 계산 필드 표시 (현재고, 안전재고, 필요생산계획 등) | 중 |
|
||||
| 4-4. 조건부 서식 지원 | 부족수량 빨간색, 양수 초록색 등 | 중 |
|
||||
|
||||
**대안**: Phase 4가 너무 복잡하면, 좌측 수주데이터를 2개 연동 테이블로 분리 (상단: 품목별 집계 테이블, 하단: 선택 품목의 수주 상세 테이블) 하는 방식도 검토 가능
|
||||
|
||||
---
|
||||
|
||||
## 8. 파일 생성/수정 목록
|
||||
|
||||
### 8.1 백엔드
|
||||
|
||||
| 파일 | 작업 | 비고 |
|
||||
|------|------|------|
|
||||
| `backend-node/src/routes/productionRoutes.ts` | 라우터 등록 | 신규 or 기존 확장 |
|
||||
| `backend-node/src/controllers/productionController.ts` | API 핸들러 | 신규 or 기존 확장 |
|
||||
| `backend-node/src/services/productionPlanService.ts` | 비즈니스 로직 서비스 | 신규 |
|
||||
|
||||
### 8.2 DB (layout_data 수정)
|
||||
|
||||
| 대상 | 작업 |
|
||||
|------|------|
|
||||
| `screen_layouts_v2` (screen_id: 3985) | layout_data JSON 수정 |
|
||||
| `screen_layouts_v2` (screen_id: 3986) | 모달 layout_data 보강 |
|
||||
| `screen_definitions` + `screen_layouts_v2` | 설비 선택 모달 신규 등록 |
|
||||
| `screen_definitions` + `screen_layouts_v2` | 불러오기 모달 신규 등록 |
|
||||
|
||||
### 8.3 프론트엔드 (API 클라이언트)
|
||||
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `frontend/lib/api/production.ts` | 생산계획 전용 API 클라이언트 함수 추가 |
|
||||
|
||||
### 8.4 프론트엔드 (V2 컴포넌트 확장, Phase 4)
|
||||
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `frontend/lib/registry/components/v2-table-grouped/` | 2레벨 트리 지원 확장 |
|
||||
| `frontend/lib/registry/components/v2-timeline-scheduler/` | 옵션 패널/범례 확장 (필요시) |
|
||||
|
||||
---
|
||||
|
||||
## 9. 이벤트 흐름 (주요 시나리오)
|
||||
|
||||
### 9.1 자동 스케줄 생성 흐름
|
||||
|
||||
```
|
||||
1. 사용자가 좌측 수주데이터에서 품목 체크박스 선택
|
||||
2. 우측 "자동 스케줄 생성" 버튼 클릭
|
||||
3. (옵션 확인) 안전리드타임, 재계산 모드 체크
|
||||
4. POST /api/production/generate-schedule 호출
|
||||
5. (응답) 변경사항 확인 모달 표시 (신규/유지/삭제 건수)
|
||||
6. 사용자 "확인 및 적용" 클릭
|
||||
7. 타임라인 스케줄러 새로고침
|
||||
8. 좌측 수주 목록의 "기생산계획량" 컬럼 갱신
|
||||
```
|
||||
|
||||
### 9.2 수주 불러오기 흐름
|
||||
|
||||
```
|
||||
1. 사용자가 좌측 수주데이터에서 품목 체크박스 선택
|
||||
2. "선택 품목 불러오기" 버튼 클릭
|
||||
3. 불러오기 모달 표시 (선택 품목 목록 + 추가방식 선택)
|
||||
4. "기존 계획에 추가" or "별도 계획으로 생성" 선택
|
||||
5. "불러오기" 버튼 클릭
|
||||
6. POST /api/production/generate-schedule 호출 (단건)
|
||||
7. 타임라인 새로고침
|
||||
```
|
||||
|
||||
### 9.3 타임라인 스케줄 클릭 → 상세 모달
|
||||
|
||||
```
|
||||
1. 사용자가 타임라인의 스케줄 바 클릭
|
||||
2. 스케줄 상세 모달 오픈 (TOPSEAL_PP_MODAL)
|
||||
3. 기본정보(readonly), 근거정보(readonly), 생산정보(수정가능) 표시
|
||||
4. 계획기간 수정, 설비할당, 분할 등 작업
|
||||
5. "저장" → PUT /api/production/plan/:id
|
||||
6. "삭제" → DELETE /api/production/plan/:id
|
||||
7. 모달 닫기 → 타임라인 새로고침
|
||||
```
|
||||
|
||||
### 9.4 반제품 계획 생성 흐름
|
||||
|
||||
```
|
||||
1. 우측 완제품 탭에서 스케줄 체크박스 선택
|
||||
2. "선택 품목 → 반제품 계획" 버튼 클릭
|
||||
3. POST /api/production/generate-semi-schedule 호출
|
||||
- BOM 조회 → 필요 반제품 목록 + 소요량 계산
|
||||
- 재고 감안 → 순 필요량 계산
|
||||
- 반제품 계획 INSERT (product_type='반제품', parent_plan_id 설정)
|
||||
4. 반제품 탭으로 자동 전환
|
||||
5. 반제품 타임라인 새로고침
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 검색 필드 설정
|
||||
|
||||
| 필드명 | 타입 | 라벨 | 대상 컬럼 |
|
||||
|--------|------|------|-----------|
|
||||
| `item_code` | text | 품목코드 | `part_code` (수주) / `item_code` (계획) |
|
||||
| `item_name` | text | 품명 | `part_name` / `item_name` |
|
||||
| `plan_date` | daterange | 계획기간 | `start_date` ~ `end_date` |
|
||||
| `status` | select | 상태 | 전체 / 계획 / 진행 / 완료 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 권한 및 멀티테넌시
|
||||
|
||||
### 11.1 모든 API에 적용
|
||||
|
||||
```typescript
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
if (companyCode === '*') {
|
||||
// 최고관리자: 모든 회사 데이터 조회 가능
|
||||
} else {
|
||||
// 일반 회사: WHERE company_code = $1 필수
|
||||
}
|
||||
```
|
||||
|
||||
### 11.2 데이터 격리
|
||||
|
||||
- `production_plan_mng.company_code` 필터 필수
|
||||
- `sales_order_mng.company_code` 필터 필수
|
||||
- `inventory_stock.company_code` 필터 필수
|
||||
- JOIN 시 양쪽 테이블 모두 `company_code` 조건 포함
|
||||
|
||||
---
|
||||
|
||||
## 12. 우선순위 정리
|
||||
|
||||
| 우선순위 | 작업 | 이유 |
|
||||
|----------|------|------|
|
||||
| **1 (긴급)** | Phase 1: 기존 렌더링 버그 수정 | 현재 화면 자체가 정상 동작하지 않음 |
|
||||
| **2 (높음)** | Phase 2-1, 2-2: 수주/재고 조회 API | 좌측 패널의 핵심 데이터 |
|
||||
| **3 (높음)** | Phase 2-3: 자동 스케줄 생성 API | 우측 패널의 핵심 기능 |
|
||||
| **4 (중간)** | Phase 3: layout_data 보강 | 안전재고 탭, 반제품 탭, 모달 |
|
||||
| **5 (중간)** | Phase 2-4~2-7: 나머지 API | 병합, 분할, 반제품 계획 |
|
||||
| **6 (낮음)** | Phase 4: 2레벨 트리 테이블 확장 | 현재 단순 그룹핑으로도 기본 동작 |
|
||||
|
||||
---
|
||||
|
||||
## 부록 A: HTML 예시의 모달 목록
|
||||
|
||||
| 모달명 | HTML ID | 용도 |
|
||||
|--------|---------|------|
|
||||
| 스케줄 상세 모달 | `scheduleModal` | 스케줄 기본정보/근거정보/생산정보/계획기간/분할/설비할당/상태/추가정보 |
|
||||
| 수주 불러오기 모달 | `orderImportModal` | 선택 품목 목록 + 추가방식 선택 (기존추가/별도생성) |
|
||||
| 안전재고 불러오기 모달 | `stockImportModal` | 부족 품목 목록 + 추가방식 선택 |
|
||||
| 설비 선택 모달 | `equipmentSelectModal` | 설비 카드 + 수량할당 + 일정등록 |
|
||||
| 변경사항 확인 모달 | `changeConfirmModal` | 자동스케줄 생성 결과 요약 + 상세 비교 |
|
||||
|
||||
## 부록 B: HTML 예시의 JS 핵심 함수 목록
|
||||
|
||||
| 함수명 | 기능 | 매핑 API |
|
||||
|--------|------|----------|
|
||||
| `generateSchedule()` | 자동 스케줄 생성 (품목별 합산) | POST /api/production/generate-schedule |
|
||||
| `saveSchedule()` | 스케줄 저장 (localStorage → DB) | POST /api/production/plan (bulk) |
|
||||
| `mergeSelectedSchedules()` | 선택 계획 병합 | POST /api/production/merge-schedules |
|
||||
| `generateSemiFromSelected()` | 반제품 계획 자동 생성 | POST /api/production/generate-semi-schedule |
|
||||
| `saveScheduleFromModal()` | 모달에서 스케줄 저장 | PUT /api/production/plan/:id |
|
||||
| `deleteScheduleFromModal()` | 모달에서 스케줄 삭제 | DELETE /api/production/plan/:id |
|
||||
| `openOrderImportModal()` | 수주 불러오기 모달 열기 | - (프론트엔드 UI) |
|
||||
| `importOrderItems()` | 수주 품목 불러오기 실행 | POST /api/production/generate-schedule |
|
||||
| `openStockImportModal()` | 안전재고 불러오기 모달 열기 | - (프론트엔드 UI) |
|
||||
| `importStockItems()` | 안전재고 품목 불러오기 실행 | POST /api/production/generate-schedule |
|
||||
| `refreshOrderList()` | 수주 목록 새로고침 | GET /api/production/order-summary |
|
||||
| `refreshStockList()` | 재고 부족 목록 새로고침 | GET /api/production/stock-shortage |
|
||||
| `switchTab(tabName)` | 좌측 탭 전환 | - (프론트엔드 UI) |
|
||||
| `switchTimelineTab(tabName)` | 우측 탭 전환 | - (프론트엔드 UI) |
|
||||
| `toggleOrderDetails(itemGroup)` | 품목 그룹 펼치기/접기 | - (프론트엔드 UI) |
|
||||
| `renderTimeline()` | 완제품 타임라인 렌더링 | - (프론트엔드 UI) |
|
||||
| `renderSemiTimeline()` | 반제품 타임라인 렌더링 | - (프론트엔드 UI) |
|
||||
| `executeSplit()` | 계획 분할 실행 | POST /api/production/split-schedule |
|
||||
| `openEquipmentSelectModal()` | 설비 선택 모달 열기 | GET /api/equipment (기존) |
|
||||
| `saveEquipmentSelection()` | 설비 할당 저장 | PUT /api/production/plan/:id |
|
||||
| `applyScheduleChanges()` | 변경사항 확인 후 적용 | - (프론트엔드 상태 관리) |
|
||||
|
||||
## 부록 C: 수주 데이터 테이블 컬럼 상세
|
||||
|
||||
### 그룹 행 (품목별 집계)
|
||||
|
||||
| # | 컬럼 | 데이터 소스 | 정렬 |
|
||||
|---|------|-------------|------|
|
||||
| 1 | 체크박스 | - | center |
|
||||
| 2 | 토글 (펼치기/접기) | - | center |
|
||||
| 3 | 품목코드 | `sales_order_mng.part_code` (GROUP BY) | left |
|
||||
| 4 | 품목명 | `sales_order_mng.part_name` | left |
|
||||
| 5 | 총수주량 | `SUM(order_qty)` | right |
|
||||
| 6 | 출고량 | `SUM(ship_qty)` | right |
|
||||
| 7 | 잔량 | `SUM(balance_qty)` | right |
|
||||
| 8 | 현재고 | `inventory_stock.current_qty` (JOIN) | right |
|
||||
| 9 | 안전재고 | `inventory_stock.safety_qty` (JOIN) | right |
|
||||
| 10 | 출하계획량 | `SUM(plan_ship_qty)` | right |
|
||||
| 11 | 기생산계획량 | `production_plan_mng` 조회 (JOIN) | right |
|
||||
| 12 | 생산진행 | `production_plan_mng` (status='in_progress') 조회 | right |
|
||||
| 13 | 필요생산계획 | 계산값 (잔량+안전재고-현재고-기생산계획량-생산진행) | right, 빨간색 강조 |
|
||||
|
||||
### 상세 행 (개별 수주)
|
||||
|
||||
| # | 컬럼 | 데이터 소스 |
|
||||
|---|------|-------------|
|
||||
| 1 | (빈 칸) | - |
|
||||
| 2 | (빈 칸) | - |
|
||||
| 3-4 | 수주번호, 거래처, 상태배지 | `order_no`, `partner_id` → partner_name, `status` |
|
||||
| 5 | 수주량 | `order_qty` |
|
||||
| 6 | 출고량 | `ship_qty` |
|
||||
| 7 | 잔량 | `balance_qty` |
|
||||
| 8-13 | 납기일 (colspan) | `due_date` |
|
||||
|
||||
## 부록 D: 타임라인 스케줄러 필드 매핑
|
||||
|
||||
### 완제품 타임라인
|
||||
|
||||
| 타임라인 필드 | production_plan_mng 컬럼 | 비고 |
|
||||
|--------------|--------------------------|------|
|
||||
| `id` | `id` | PK |
|
||||
| `resourceId` | `item_code` | 품목 기준 리소스 (설비 기준이 아님) |
|
||||
| `title` | `item_name` + `plan_qty` | 표시 텍스트 |
|
||||
| `startDate` | `start_date` | 시작일 |
|
||||
| `endDate` | `end_date` | 종료일 |
|
||||
| `status` | `status` | planned/in_progress/completed/work-order |
|
||||
| `progress` | `progress_rate` | 진행률(%) |
|
||||
|
||||
### 반제품 타임라인
|
||||
|
||||
동일 구조, 단 `product_type = '반제품'` 필터 적용
|
||||
|
||||
### statusColors 매핑
|
||||
|
||||
| 상태 | 색상 | 의미 |
|
||||
|------|------|------|
|
||||
| `planned` | `#3b82f6` (파란색) | 계획됨 |
|
||||
| `work-order` | `#f59e0b` (노란색) | 작업지시 |
|
||||
| `in_progress` | `#10b981` (초록색) | 진행중 |
|
||||
| `completed` | `#6b7280` (회색, 반투명) | 완료 |
|
||||
| `delayed` | `#ef4444` (빨간색) | 지연 |
|
||||
|
|
@ -77,14 +77,12 @@ const RESOURCE_TYPE_CONFIG: Record<
|
|||
NODE_FLOW: { label: "플로우 제어", icon: GitBranch, color: "bg-teal-100 text-teal-700" },
|
||||
USER: { label: "사용자", icon: User, color: "bg-amber-100 text-orange-700" },
|
||||
ROLE: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" },
|
||||
PERMISSION: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" },
|
||||
COMPANY: { label: "회사", icon: Building2, color: "bg-indigo-100 text-indigo-700" },
|
||||
CODE_CATEGORY: { label: "코드 카테고리", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
|
||||
CODE: { label: "코드", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
|
||||
DATA: { label: "데이터", icon: Database, color: "bg-muted text-foreground" },
|
||||
TABLE: { label: "테이블", icon: Database, color: "bg-muted text-foreground" },
|
||||
NUMBERING_RULE: { label: "채번 규칙", icon: FileText, color: "bg-amber-100 text-amber-700" },
|
||||
BATCH: { label: "배치", icon: RefreshCw, color: "bg-teal-100 text-teal-700" },
|
||||
};
|
||||
|
||||
const ACTION_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
|
|
@ -817,7 +815,7 @@ export default function AuditLogPage() {
|
|||
</Badge>
|
||||
{entry.company_code && entry.company_code !== "*" && (
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
[{entry.company_code}]
|
||||
[{entry.company_name || entry.company_code}]
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -862,9 +860,11 @@ export default function AuditLogPage() {
|
|||
</div>
|
||||
<div>
|
||||
<label className="text-muted-foreground text-xs">
|
||||
회사코드
|
||||
회사
|
||||
</label>
|
||||
<p className="font-medium">{selectedEntry.company_code}</p>
|
||||
<p className="font-medium">
|
||||
{selectedEntry.company_name || selectedEntry.company_code}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-muted-foreground text-xs">
|
||||
|
|
|
|||
|
|
@ -17,14 +17,17 @@ import { ScreenContextProvider } from "@/contexts/ScreenContext";
|
|||
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
|
||||
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
|
||||
import {
|
||||
PopLayoutDataV5,
|
||||
PopLayoutData,
|
||||
GridMode,
|
||||
isV5Layout,
|
||||
createEmptyPopLayoutV5,
|
||||
isPopLayout,
|
||||
createEmptyLayout,
|
||||
GAP_PRESETS,
|
||||
GRID_BREAKPOINTS,
|
||||
BLOCK_GAP,
|
||||
BLOCK_PADDING,
|
||||
detectGridMode,
|
||||
} from "@/components/pop/designer/types/pop-layout";
|
||||
import { loadLegacyLayout } from "@/components/pop/designer/utils/legacyLoader";
|
||||
// POP 컴포넌트 자동 등록 (레지스트리 초기화 - PopRenderer보다 먼저 import)
|
||||
import "@/lib/registry/pop-components";
|
||||
import PopViewerWithModals from "@/components/pop/viewer/PopViewerWithModals";
|
||||
|
|
@ -79,7 +82,7 @@ function PopScreenViewPage() {
|
|||
const { user } = useAuth();
|
||||
|
||||
const [screen, setScreen] = useState<ScreenDefinition | null>(null);
|
||||
const [layout, setLayout] = useState<PopLayoutDataV5>(createEmptyPopLayoutV5());
|
||||
const [layout, setLayout] = useState<PopLayoutData>(createEmptyLayout());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -116,22 +119,22 @@ function PopScreenViewPage() {
|
|||
try {
|
||||
const popLayout = await screenApi.getLayoutPop(screenId);
|
||||
|
||||
if (popLayout && isV5Layout(popLayout)) {
|
||||
// v5 레이아웃 로드
|
||||
setLayout(popLayout);
|
||||
if (popLayout && isPopLayout(popLayout)) {
|
||||
const v6Layout = loadLegacyLayout(popLayout);
|
||||
setLayout(v6Layout);
|
||||
const componentCount = Object.keys(popLayout.components).length;
|
||||
console.log(`[POP] v5 레이아웃 로드됨: ${componentCount}개 컴포넌트`);
|
||||
} else if (popLayout) {
|
||||
// 다른 버전 레이아웃은 빈 v5로 처리
|
||||
console.log("[POP] 레거시 레이아웃 감지, 빈 레이아웃으로 시작합니다:", popLayout.version);
|
||||
setLayout(createEmptyPopLayoutV5());
|
||||
setLayout(createEmptyLayout());
|
||||
} else {
|
||||
console.log("[POP] 레이아웃 없음");
|
||||
setLayout(createEmptyPopLayoutV5());
|
||||
setLayout(createEmptyLayout());
|
||||
}
|
||||
} catch (layoutError) {
|
||||
console.warn("[POP] 레이아웃 로드 실패:", layoutError);
|
||||
setLayout(createEmptyPopLayoutV5());
|
||||
setLayout(createEmptyLayout());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[POP] 화면 로드 실패:", error);
|
||||
|
|
@ -318,12 +321,8 @@ function PopScreenViewPage() {
|
|||
style={{ maxWidth: 1366 }}
|
||||
>
|
||||
{(() => {
|
||||
// Gap 프리셋 계산
|
||||
const currentGapPreset = layout.settings.gapPreset || "medium";
|
||||
const gapMultiplier = GAP_PRESETS[currentGapPreset]?.multiplier || 1.0;
|
||||
const breakpoint = GRID_BREAKPOINTS[currentModeKey];
|
||||
const adjustedGap = Math.round(breakpoint.gap * gapMultiplier);
|
||||
const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier));
|
||||
const adjustedGap = BLOCK_GAP;
|
||||
const adjustedPadding = BLOCK_PADDING;
|
||||
|
||||
return (
|
||||
<PopViewerWithModals
|
||||
|
|
|
|||
|
|
@ -402,18 +402,9 @@ select {
|
|||
/* 필요시 특정 컴포넌트에 대한 스타일 오버라이드를 여기에 추가 */
|
||||
/* 예: Calendar, Table 등의 미세 조정 */
|
||||
|
||||
/* 모바일에서 테이블 레이아웃 고정 (화면 밖으로 넘어가지 않도록) */
|
||||
@media (max-width: 639px) {
|
||||
.table-mobile-fixed {
|
||||
table-layout: fixed;
|
||||
}
|
||||
}
|
||||
|
||||
/* 데스크톱에서 테이블 레이아웃 자동 (기본값이지만 명시적으로 설정) */
|
||||
@media (min-width: 640px) {
|
||||
.table-mobile-fixed {
|
||||
table-layout: auto;
|
||||
}
|
||||
/* 테이블 레이아웃 고정 (셀 내용이 영역을 벗어나지 않도록) */
|
||||
.table-mobile-fixed {
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
/* 그리드선 숨기기 */
|
||||
|
|
|
|||
|
|
@ -98,10 +98,43 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
const savedMode = localStorage.getItem("screenModal_continuousMode");
|
||||
if (savedMode === "true") {
|
||||
setContinuousMode(true);
|
||||
// console.log("🔄 연속 모드 복원: true");
|
||||
}
|
||||
}, []);
|
||||
|
||||
// dataBinding: 테이블 선택 시 바인딩된 input의 formData를 자동 업데이트
|
||||
useEffect(() => {
|
||||
if (!modalState.isOpen || !screenData?.components?.length) return;
|
||||
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (!detail?.source || !detail?.data) return;
|
||||
|
||||
const bindingUpdates: Record<string, any> = {};
|
||||
for (const comp of screenData.components) {
|
||||
const db =
|
||||
comp.componentConfig?.dataBinding ||
|
||||
(comp as any).dataBinding;
|
||||
if (!db?.sourceComponentId || !db?.sourceColumn) continue;
|
||||
if (db.sourceComponentId !== detail.source) continue;
|
||||
|
||||
const colName = (comp as any).columnName || comp.componentConfig?.columnName;
|
||||
if (!colName) continue;
|
||||
|
||||
const selectedRow = detail.data[0];
|
||||
const value = selectedRow?.[db.sourceColumn] ?? "";
|
||||
bindingUpdates[colName] = value;
|
||||
}
|
||||
|
||||
if (Object.keys(bindingUpdates).length > 0) {
|
||||
setFormData((prev) => ({ ...prev, ...bindingUpdates }));
|
||||
formDataChangedRef.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("v2-table-selection", handler);
|
||||
return () => window.removeEventListener("v2-table-selection", handler);
|
||||
}, [modalState.isOpen, screenData?.components]);
|
||||
|
||||
// 화면의 실제 크기 계산 함수
|
||||
const calculateScreenDimensions = (components: ComponentData[]) => {
|
||||
if (components.length === 0) {
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import { useCallback, useRef, useState, useEffect, useMemo } from "react";
|
|||
import { useDrop } from "react-dnd";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
PopLayoutDataV5,
|
||||
PopComponentDefinitionV5,
|
||||
PopLayoutData,
|
||||
PopComponentDefinition,
|
||||
PopComponentType,
|
||||
PopGridPosition,
|
||||
GridMode,
|
||||
|
|
@ -17,8 +17,12 @@ import {
|
|||
ModalSizePreset,
|
||||
MODAL_SIZE_PRESETS,
|
||||
resolveModalWidth,
|
||||
BLOCK_SIZE,
|
||||
BLOCK_GAP,
|
||||
BLOCK_PADDING,
|
||||
getBlockColumns,
|
||||
} from "./types/pop-layout";
|
||||
import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, Lock, RotateCcw, AlertTriangle, EyeOff, Monitor, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, Lock, RotateCcw, EyeOff, Monitor, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { useDrag } from "react-dnd";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -30,13 +34,12 @@ import {
|
|||
} from "@/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
import PopRenderer from "./renderers/PopRenderer";
|
||||
import { findNextEmptyPosition, isOverlapping, getAllEffectivePositions, needsReview } from "./utils/gridUtils";
|
||||
import { findNextEmptyPosition, isOverlapping, getAllEffectivePositions } from "./utils/gridUtils";
|
||||
import { DND_ITEM_TYPES } from "./constants";
|
||||
|
||||
/**
|
||||
* 캔버스 내 상대 좌표 → 그리드 좌표 변환
|
||||
* @param relX 캔버스 내 X 좌표 (패딩 포함)
|
||||
* @param relY 캔버스 내 Y 좌표 (패딩 포함)
|
||||
* V6: 캔버스 내 상대 좌표 → 블록 그리드 좌표 변환
|
||||
* 블록 크기가 고정(BLOCK_SIZE)이므로 1fr 계산 불필요
|
||||
*/
|
||||
function calcGridPosition(
|
||||
relX: number,
|
||||
|
|
@ -47,21 +50,13 @@ function calcGridPosition(
|
|||
gap: number,
|
||||
padding: number
|
||||
): { col: number; row: number } {
|
||||
// 패딩 제외한 좌표
|
||||
const x = relX - padding;
|
||||
const y = relY - padding;
|
||||
|
||||
// 사용 가능한 너비 (패딩과 gap 제외)
|
||||
const availableWidth = canvasWidth - padding * 2 - gap * (columns - 1);
|
||||
const colWidth = availableWidth / columns;
|
||||
const cellStride = BLOCK_SIZE + gap;
|
||||
|
||||
// 셀+gap 단위로 계산
|
||||
const cellStride = colWidth + gap;
|
||||
const rowStride = rowHeight + gap;
|
||||
|
||||
// 그리드 좌표 (1부터 시작)
|
||||
const col = Math.max(1, Math.min(columns, Math.floor(x / cellStride) + 1));
|
||||
const row = Math.max(1, Math.floor(y / rowStride) + 1);
|
||||
const row = Math.max(1, Math.floor(y / cellStride) + 1);
|
||||
|
||||
return { col, row };
|
||||
}
|
||||
|
|
@ -78,13 +73,13 @@ interface DragItemMoveComponent {
|
|||
}
|
||||
|
||||
// ========================================
|
||||
// 프리셋 해상도 (4개 모드) - 너비만 정의
|
||||
// V6: 프리셋 해상도 (블록 칸 수 동적 계산)
|
||||
// ========================================
|
||||
const VIEWPORT_PRESETS = [
|
||||
{ id: "mobile_portrait", label: "모바일 세로", shortLabel: "모바일↕ (4칸)", width: 375, icon: Smartphone },
|
||||
{ id: "mobile_landscape", label: "모바일 가로", shortLabel: "모바일↔ (6칸)", width: 600, icon: Smartphone },
|
||||
{ id: "tablet_portrait", label: "태블릿 세로", shortLabel: "태블릿↕ (8칸)", width: 820, icon: Tablet },
|
||||
{ id: "tablet_landscape", label: "태블릿 가로", shortLabel: "태블릿↔ (12칸)", width: 1024, icon: Tablet },
|
||||
{ id: "mobile_portrait", label: "모바일 세로", shortLabel: `모바일↕ (${getBlockColumns(375)}칸)`, width: 375, icon: Smartphone },
|
||||
{ id: "mobile_landscape", label: "모바일 가로", shortLabel: `모바일↔ (${getBlockColumns(600)}칸)`, width: 600, icon: Smartphone },
|
||||
{ id: "tablet_portrait", label: "태블릿 세로", shortLabel: `태블릿↕ (${getBlockColumns(820)}칸)`, width: 820, icon: Tablet },
|
||||
{ id: "tablet_landscape", label: "태블릿 가로", shortLabel: `태블릿↔ (${getBlockColumns(1024)}칸)`, width: 1024, icon: Tablet },
|
||||
] as const;
|
||||
|
||||
type ViewportPreset = GridMode;
|
||||
|
|
@ -100,13 +95,13 @@ const CANVAS_EXTRA_ROWS = 3; // 여유 행 수
|
|||
// Props
|
||||
// ========================================
|
||||
interface PopCanvasProps {
|
||||
layout: PopLayoutDataV5;
|
||||
layout: PopLayoutData;
|
||||
selectedComponentId: string | null;
|
||||
currentMode: GridMode;
|
||||
onModeChange: (mode: GridMode) => void;
|
||||
onSelectComponent: (id: string | null) => void;
|
||||
onDropComponent: (type: PopComponentType, position: PopGridPosition) => void;
|
||||
onUpdateComponent: (componentId: string, updates: Partial<PopComponentDefinitionV5>) => void;
|
||||
onUpdateComponent: (componentId: string, updates: Partial<PopComponentDefinition>) => void;
|
||||
onDeleteComponent: (componentId: string) => void;
|
||||
onMoveComponent?: (componentId: string, newPosition: PopGridPosition) => void;
|
||||
onResizeComponent?: (componentId: string, newPosition: PopGridPosition) => void;
|
||||
|
|
@ -168,7 +163,7 @@ export default function PopCanvas({
|
|||
}, [layout.modals]);
|
||||
|
||||
// activeCanvasId에 따라 렌더링할 layout 분기
|
||||
const activeLayout = useMemo((): PopLayoutDataV5 => {
|
||||
const activeLayout = useMemo((): PopLayoutData => {
|
||||
if (activeCanvasId === "main") return layout;
|
||||
const modal = layout.modals?.find(m => m.id === activeCanvasId);
|
||||
if (!modal) return layout; // fallback
|
||||
|
|
@ -202,15 +197,22 @@ export default function PopCanvas({
|
|||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 현재 뷰포트 해상도
|
||||
// V6: 뷰포트에서 동적 블록 칸 수 계산
|
||||
const currentPreset = VIEWPORT_PRESETS.find((p) => p.id === currentMode)!;
|
||||
const breakpoint = GRID_BREAKPOINTS[currentMode];
|
||||
const dynamicColumns = getBlockColumns(customWidth);
|
||||
const breakpoint = {
|
||||
...GRID_BREAKPOINTS[currentMode],
|
||||
columns: dynamicColumns,
|
||||
rowHeight: BLOCK_SIZE,
|
||||
gap: BLOCK_GAP,
|
||||
padding: BLOCK_PADDING,
|
||||
label: `${dynamicColumns}칸 블록`,
|
||||
};
|
||||
|
||||
// Gap 프리셋 적용
|
||||
// V6: 블록 간격 고정 (프리셋 무관)
|
||||
const currentGapPreset = layout.settings.gapPreset || "medium";
|
||||
const gapMultiplier = GAP_PRESETS[currentGapPreset]?.multiplier || 1.0;
|
||||
const adjustedGap = Math.round(breakpoint.gap * gapMultiplier);
|
||||
const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier));
|
||||
const adjustedGap = BLOCK_GAP;
|
||||
const adjustedPadding = BLOCK_PADDING;
|
||||
|
||||
// 숨김 컴포넌트 ID 목록 (activeLayout 기반)
|
||||
const hiddenComponentIds = activeLayout.overrides?.[currentMode]?.hidden || [];
|
||||
|
|
@ -399,9 +401,9 @@ export default function PopCanvas({
|
|||
const effectivePositions = getAllEffectivePositions(activeLayout, currentMode);
|
||||
|
||||
// 이동 중인 컴포넌트의 현재 유효 위치에서 colSpan/rowSpan 가져오기
|
||||
// 검토 필요(ReviewPanel에서 클릭)나 숨김 컴포넌트는 effectivePositions에 없으므로 원본 사용
|
||||
// 숨김 컴포넌트는 effectivePositions에 없으므로 원본 사용
|
||||
const currentEffectivePos = effectivePositions.get(dragItem.componentId);
|
||||
const componentData = layout.components[dragItem.componentId];
|
||||
const componentData = activeLayout.components[dragItem.componentId];
|
||||
|
||||
if (!currentEffectivePos && !componentData) return;
|
||||
|
||||
|
|
@ -470,22 +472,8 @@ export default function PopCanvas({
|
|||
);
|
||||
}, [activeLayout.components, hiddenComponentIds]);
|
||||
|
||||
// 검토 필요 컴포넌트 목록
|
||||
const reviewComponents = useMemo(() => {
|
||||
return visibleComponents.filter(comp => {
|
||||
const hasOverride = !!activeLayout.overrides?.[currentMode]?.positions?.[comp.id];
|
||||
return needsReview(currentMode, hasOverride);
|
||||
});
|
||||
}, [visibleComponents, activeLayout.overrides, currentMode]);
|
||||
|
||||
// 검토 패널 표시 여부 (12칸 모드가 아니고, 검토 필요 컴포넌트가 있을 때)
|
||||
const showReviewPanel = currentMode !== "tablet_landscape" && reviewComponents.length > 0;
|
||||
|
||||
// 12칸 모드가 아닐 때만 패널 표시
|
||||
// 숨김 패널: 숨김 컴포넌트가 있거나, 그리드에 컴포넌트가 있을 때 드롭 영역으로 표시
|
||||
const hasGridComponents = Object.keys(activeLayout.components).length > 0;
|
||||
const showHiddenPanel = currentMode !== "tablet_landscape" && (hiddenComponents.length > 0 || hasGridComponents);
|
||||
const showRightPanel = showReviewPanel || showHiddenPanel;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-muted">
|
||||
|
|
@ -666,7 +654,7 @@ export default function PopCanvas({
|
|||
<div
|
||||
className="relative mx-auto my-8 origin-top overflow-visible flex gap-4"
|
||||
style={{
|
||||
width: showRightPanel
|
||||
width: showHiddenPanel
|
||||
? `${customWidth + 32 + 220}px` // 오른쪽 패널 공간 추가
|
||||
: `${customWidth + 32}px`,
|
||||
minHeight: `${dynamicCanvasHeight + 32}px`,
|
||||
|
|
@ -774,20 +762,11 @@ export default function PopCanvas({
|
|||
</div>
|
||||
|
||||
{/* 오른쪽 패널 영역 (초과 컴포넌트 + 숨김 컴포넌트) */}
|
||||
{showRightPanel && (
|
||||
{showHiddenPanel && (
|
||||
<div
|
||||
className="flex flex-col gap-3"
|
||||
style={{ marginTop: "32px" }}
|
||||
>
|
||||
{/* 검토 필요 패널 */}
|
||||
{showReviewPanel && (
|
||||
<ReviewPanel
|
||||
components={reviewComponents}
|
||||
selectedComponentId={selectedComponentId}
|
||||
onSelectComponent={onSelectComponent}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 숨김 컴포넌트 패널 */}
|
||||
{showHiddenPanel && (
|
||||
<HiddenPanel
|
||||
|
|
@ -805,7 +784,7 @@ export default function PopCanvas({
|
|||
{/* 하단 정보 */}
|
||||
<div className="flex items-center justify-between border-t bg-background px-4 py-2">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{breakpoint.label} - {breakpoint.columns}칸 그리드 (행 높이: {breakpoint.rowHeight}px)
|
||||
V6 블록 그리드 - {dynamicColumns}칸 (블록: {BLOCK_SIZE}px, 간격: {BLOCK_GAP}px)
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Space + 드래그: 패닝 | Ctrl + 휠: 줌
|
||||
|
|
@ -819,99 +798,12 @@ export default function PopCanvas({
|
|||
// 검토 필요 영역 (오른쪽 패널)
|
||||
// ========================================
|
||||
|
||||
interface ReviewPanelProps {
|
||||
components: PopComponentDefinitionV5[];
|
||||
selectedComponentId: string | null;
|
||||
onSelectComponent: (id: string | null) => void;
|
||||
}
|
||||
|
||||
function ReviewPanel({
|
||||
components,
|
||||
selectedComponentId,
|
||||
onSelectComponent,
|
||||
}: ReviewPanelProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col rounded-lg border-2 border-dashed border-primary/40 bg-primary/5"
|
||||
style={{
|
||||
width: "200px",
|
||||
maxHeight: "300px",
|
||||
}}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 border-b border-primary/20 bg-primary/5 px-3 py-2 rounded-t-lg">
|
||||
<AlertTriangle className="h-4 w-4 text-primary" />
|
||||
<span className="text-xs font-semibold text-primary">
|
||||
검토 필요 ({components.length}개)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 컴포넌트 목록 */}
|
||||
<div className="flex-1 overflow-auto p-2 space-y-2">
|
||||
{components.map((comp) => (
|
||||
<ReviewItem
|
||||
key={comp.id}
|
||||
component={comp}
|
||||
isSelected={selectedComponentId === comp.id}
|
||||
onSelect={() => onSelectComponent(comp.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 안내 문구 */}
|
||||
<div className="border-t border-primary/20 px-3 py-2 bg-primary/10 rounded-b-lg">
|
||||
<p className="text-[10px] text-primary leading-tight">
|
||||
자동 배치됨. 클릭하여 확인 후 편집 가능
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 검토 필요 아이템 (ReviewPanel 내부)
|
||||
// ========================================
|
||||
|
||||
interface ReviewItemProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
function ReviewItem({
|
||||
component,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: ReviewItemProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-1 rounded-md border-2 p-2 cursor-pointer transition-all",
|
||||
isSelected
|
||||
? "border-primary bg-primary/10 shadow-sm"
|
||||
: "border-primary/20 bg-background hover:border-primary/60 hover:bg-primary/10"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect();
|
||||
}}
|
||||
>
|
||||
<span className="text-xs font-medium text-primary line-clamp-1">
|
||||
{component.label || component.id}
|
||||
</span>
|
||||
<span className="text-[10px] text-primary bg-primary/10 rounded px-1.5 py-0.5 self-start">
|
||||
자동 배치됨
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 숨김 컴포넌트 영역 (오른쪽 패널)
|
||||
// ========================================
|
||||
|
||||
interface HiddenPanelProps {
|
||||
components: PopComponentDefinitionV5[];
|
||||
components: PopComponentDefinition[];
|
||||
selectedComponentId: string | null;
|
||||
onSelectComponent: (id: string | null) => void;
|
||||
onHideComponent?: (componentId: string) => void;
|
||||
|
|
@ -997,7 +889,7 @@ function HiddenPanel({
|
|||
// ========================================
|
||||
|
||||
interface HiddenItemProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
component: PopComponentDefinition;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,21 +19,22 @@ import PopCanvas from "./PopCanvas";
|
|||
import ComponentEditorPanel from "./panels/ComponentEditorPanel";
|
||||
import ComponentPalette from "./panels/ComponentPalette";
|
||||
import {
|
||||
PopLayoutDataV5,
|
||||
PopLayoutData,
|
||||
PopComponentType,
|
||||
PopComponentDefinitionV5,
|
||||
PopComponentDefinition,
|
||||
PopGridPosition,
|
||||
GridMode,
|
||||
GapPreset,
|
||||
createEmptyPopLayoutV5,
|
||||
isV5Layout,
|
||||
addComponentToV5Layout,
|
||||
createComponentDefinitionV5,
|
||||
createEmptyLayout,
|
||||
isPopLayout,
|
||||
addComponentToLayout,
|
||||
createComponentDefinition,
|
||||
GRID_BREAKPOINTS,
|
||||
PopModalDefinition,
|
||||
PopDataConnection,
|
||||
} from "./types/pop-layout";
|
||||
import { getAllEffectivePositions } from "./utils/gridUtils";
|
||||
import { loadLegacyLayout } from "./utils/legacyLoader";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { ScreenDefinition } from "@/types/screen";
|
||||
import { PopDesignerContext } from "./PopDesignerContext";
|
||||
|
|
@ -59,10 +60,10 @@ export default function PopDesigner({
|
|||
// ========================================
|
||||
// 레이아웃 상태
|
||||
// ========================================
|
||||
const [layout, setLayout] = useState<PopLayoutDataV5>(createEmptyPopLayoutV5());
|
||||
const [layout, setLayout] = useState<PopLayoutData>(createEmptyLayout());
|
||||
|
||||
// 히스토리
|
||||
const [history, setHistory] = useState<PopLayoutDataV5[]>([]);
|
||||
const [history, setHistory] = useState<PopLayoutData[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
|
||||
// UI 상태
|
||||
|
|
@ -84,7 +85,7 @@ export default function PopDesigner({
|
|||
const [activeCanvasId, setActiveCanvasId] = useState<string>("main");
|
||||
|
||||
// 선택된 컴포넌트 (activeCanvasId에 따라 메인 또는 모달에서 조회)
|
||||
const selectedComponent: PopComponentDefinitionV5 | null = (() => {
|
||||
const selectedComponent: PopComponentDefinition | null = (() => {
|
||||
if (!selectedComponentId) return null;
|
||||
if (activeCanvasId === "main") {
|
||||
return layout.components[selectedComponentId] || null;
|
||||
|
|
@ -96,7 +97,7 @@ export default function PopDesigner({
|
|||
// ========================================
|
||||
// 히스토리 관리
|
||||
// ========================================
|
||||
const saveToHistory = useCallback((newLayout: PopLayoutDataV5) => {
|
||||
const saveToHistory = useCallback((newLayout: PopLayoutData) => {
|
||||
setHistory((prev) => {
|
||||
const newHistory = prev.slice(0, historyIndex + 1);
|
||||
newHistory.push(JSON.parse(JSON.stringify(newLayout)));
|
||||
|
|
@ -150,14 +151,13 @@ export default function PopDesigner({
|
|||
try {
|
||||
const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId);
|
||||
|
||||
if (loadedLayout && isV5Layout(loadedLayout) && loadedLayout.components && Object.keys(loadedLayout.components).length > 0) {
|
||||
// v5 레이아웃 로드
|
||||
// 기존 레이아웃 호환성: gapPreset이 없으면 기본값 추가
|
||||
if (loadedLayout && isPopLayout(loadedLayout) && loadedLayout.components && Object.keys(loadedLayout.components).length > 0) {
|
||||
if (!loadedLayout.settings.gapPreset) {
|
||||
loadedLayout.settings.gapPreset = "medium";
|
||||
}
|
||||
setLayout(loadedLayout);
|
||||
setHistory([loadedLayout]);
|
||||
const v6Layout = loadLegacyLayout(loadedLayout);
|
||||
setLayout(v6Layout);
|
||||
setHistory([v6Layout]);
|
||||
setHistoryIndex(0);
|
||||
|
||||
// 기존 컴포넌트 ID에서 최대 숫자 추출하여 idCounter 설정 (중복 방지)
|
||||
|
|
@ -175,7 +175,7 @@ export default function PopDesigner({
|
|||
console.log(`POP 레이아웃 로드: ${existingIds.length}개 컴포넌트, idCounter: ${maxId + 1}`);
|
||||
} else {
|
||||
// 새 화면 또는 빈 레이아웃
|
||||
const emptyLayout = createEmptyPopLayoutV5();
|
||||
const emptyLayout = createEmptyLayout();
|
||||
setLayout(emptyLayout);
|
||||
setHistory([emptyLayout]);
|
||||
setHistoryIndex(0);
|
||||
|
|
@ -184,7 +184,7 @@ export default function PopDesigner({
|
|||
} catch (error) {
|
||||
console.error("레이아웃 로드 실패:", error);
|
||||
toast.error("레이아웃을 불러오는데 실패했습니다");
|
||||
const emptyLayout = createEmptyPopLayoutV5();
|
||||
const emptyLayout = createEmptyLayout();
|
||||
setLayout(emptyLayout);
|
||||
setHistory([emptyLayout]);
|
||||
setHistoryIndex(0);
|
||||
|
|
@ -225,13 +225,13 @@ export default function PopDesigner({
|
|||
|
||||
if (activeCanvasId === "main") {
|
||||
// 메인 캔버스
|
||||
const newLayout = addComponentToV5Layout(layout, componentId, type, position, `${type} ${idCounter}`);
|
||||
const newLayout = addComponentToLayout(layout, componentId, type, position, `${type} ${idCounter}`);
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
} else {
|
||||
// 모달 캔버스
|
||||
setLayout(prev => {
|
||||
const comp = createComponentDefinitionV5(componentId, type, position, `${type} ${idCounter}`);
|
||||
const comp = createComponentDefinition(componentId, type, position, `${type} ${idCounter}`);
|
||||
const newLayout = {
|
||||
...prev,
|
||||
modals: (prev.modals || []).map(m => {
|
||||
|
|
@ -250,7 +250,7 @@ export default function PopDesigner({
|
|||
);
|
||||
|
||||
const handleUpdateComponent = useCallback(
|
||||
(componentId: string, updates: Partial<PopComponentDefinitionV5>) => {
|
||||
(componentId: string, updates: Partial<PopComponentDefinition>) => {
|
||||
// 함수적 업데이트로 stale closure 방지
|
||||
setLayout((prev) => {
|
||||
if (activeCanvasId === "main") {
|
||||
|
|
@ -303,7 +303,7 @@ export default function PopDesigner({
|
|||
const newId = `conn_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
const newConnection: PopDataConnection = { ...conn, id: newId };
|
||||
const prevConnections = prev.dataFlow?.connections || [];
|
||||
const newLayout: PopLayoutDataV5 = {
|
||||
const newLayout: PopLayoutData = {
|
||||
...prev,
|
||||
dataFlow: {
|
||||
...prev.dataFlow,
|
||||
|
|
@ -322,7 +322,7 @@ export default function PopDesigner({
|
|||
(connectionId: string, conn: Omit<PopDataConnection, "id">) => {
|
||||
setLayout((prev) => {
|
||||
const prevConnections = prev.dataFlow?.connections || [];
|
||||
const newLayout: PopLayoutDataV5 = {
|
||||
const newLayout: PopLayoutData = {
|
||||
...prev,
|
||||
dataFlow: {
|
||||
...prev.dataFlow,
|
||||
|
|
@ -343,7 +343,7 @@ export default function PopDesigner({
|
|||
(connectionId: string) => {
|
||||
setLayout((prev) => {
|
||||
const prevConnections = prev.dataFlow?.connections || [];
|
||||
const newLayout: PopLayoutDataV5 = {
|
||||
const newLayout: PopLayoutData = {
|
||||
...prev,
|
||||
dataFlow: {
|
||||
...prev.dataFlow,
|
||||
|
|
@ -389,97 +389,156 @@ export default function PopDesigner({
|
|||
|
||||
const handleMoveComponent = useCallback(
|
||||
(componentId: string, newPosition: PopGridPosition) => {
|
||||
const component = layout.components[componentId];
|
||||
if (!component) return;
|
||||
|
||||
// 기본 모드(tablet_landscape, 12칸)인 경우: 원본 position 직접 수정
|
||||
if (currentMode === "tablet_landscape") {
|
||||
const newLayout = {
|
||||
...layout,
|
||||
components: {
|
||||
...layout.components,
|
||||
[componentId]: {
|
||||
...component,
|
||||
position: newPosition,
|
||||
},
|
||||
},
|
||||
};
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
setHasChanges(true);
|
||||
} else {
|
||||
// 다른 모드인 경우: 오버라이드에 저장
|
||||
// 숨김 상태였던 컴포넌트를 이동하면 숨김 해제도 함께 처리
|
||||
const currentHidden = layout.overrides?.[currentMode]?.hidden || [];
|
||||
const isHidden = currentHidden.includes(componentId);
|
||||
const newHidden = isHidden
|
||||
? currentHidden.filter(id => id !== componentId)
|
||||
: currentHidden;
|
||||
|
||||
const newLayout = {
|
||||
...layout,
|
||||
overrides: {
|
||||
...layout.overrides,
|
||||
[currentMode]: {
|
||||
...layout.overrides?.[currentMode],
|
||||
positions: {
|
||||
...layout.overrides?.[currentMode]?.positions,
|
||||
[componentId]: newPosition,
|
||||
setLayout((prev) => {
|
||||
if (activeCanvasId === "main") {
|
||||
const component = prev.components[componentId];
|
||||
if (!component) return prev;
|
||||
|
||||
if (currentMode === "tablet_landscape") {
|
||||
const newLayout = {
|
||||
...prev,
|
||||
components: {
|
||||
...prev.components,
|
||||
[componentId]: { ...component, position: newPosition },
|
||||
},
|
||||
// 숨김 배열 업데이트 (빈 배열이면 undefined로)
|
||||
hidden: newHidden.length > 0 ? newHidden : undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
setHasChanges(true);
|
||||
}
|
||||
};
|
||||
saveToHistory(newLayout);
|
||||
return newLayout;
|
||||
} else {
|
||||
const currentHidden = prev.overrides?.[currentMode]?.hidden || [];
|
||||
const newHidden = currentHidden.filter(id => id !== componentId);
|
||||
const newLayout = {
|
||||
...prev,
|
||||
overrides: {
|
||||
...prev.overrides,
|
||||
[currentMode]: {
|
||||
...prev.overrides?.[currentMode],
|
||||
positions: {
|
||||
...prev.overrides?.[currentMode]?.positions,
|
||||
[componentId]: newPosition,
|
||||
},
|
||||
hidden: newHidden.length > 0 ? newHidden : undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
saveToHistory(newLayout);
|
||||
return newLayout;
|
||||
}
|
||||
} else {
|
||||
// 모달 캔버스
|
||||
const newLayout = {
|
||||
...prev,
|
||||
modals: (prev.modals || []).map(m => {
|
||||
if (m.id !== activeCanvasId) return m;
|
||||
const component = m.components[componentId];
|
||||
if (!component) return m;
|
||||
|
||||
if (currentMode === "tablet_landscape") {
|
||||
return {
|
||||
...m,
|
||||
components: {
|
||||
...m.components,
|
||||
[componentId]: { ...component, position: newPosition },
|
||||
},
|
||||
};
|
||||
} else {
|
||||
const currentHidden = m.overrides?.[currentMode]?.hidden || [];
|
||||
const newHidden = currentHidden.filter(id => id !== componentId);
|
||||
return {
|
||||
...m,
|
||||
overrides: {
|
||||
...m.overrides,
|
||||
[currentMode]: {
|
||||
...m.overrides?.[currentMode],
|
||||
positions: {
|
||||
...m.overrides?.[currentMode]?.positions,
|
||||
[componentId]: newPosition,
|
||||
},
|
||||
hidden: newHidden.length > 0 ? newHidden : undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}),
|
||||
};
|
||||
saveToHistory(newLayout);
|
||||
return newLayout;
|
||||
}
|
||||
});
|
||||
setHasChanges(true);
|
||||
},
|
||||
[layout, saveToHistory, currentMode]
|
||||
[saveToHistory, currentMode, activeCanvasId]
|
||||
);
|
||||
|
||||
const handleResizeComponent = useCallback(
|
||||
(componentId: string, newPosition: PopGridPosition) => {
|
||||
const component = layout.components[componentId];
|
||||
if (!component) return;
|
||||
|
||||
// 기본 모드(tablet_landscape, 12칸)인 경우: 원본 position 직접 수정
|
||||
if (currentMode === "tablet_landscape") {
|
||||
const newLayout = {
|
||||
...layout,
|
||||
components: {
|
||||
...layout.components,
|
||||
[componentId]: {
|
||||
...component,
|
||||
position: newPosition,
|
||||
},
|
||||
},
|
||||
};
|
||||
setLayout(newLayout);
|
||||
// 리사이즈는 드래그 중 계속 호출되므로 히스토리는 마우스업 시에만 저장
|
||||
// 현재는 간단히 매번 저장 (최적화 가능)
|
||||
setHasChanges(true);
|
||||
} else {
|
||||
// 다른 모드인 경우: 오버라이드에 저장
|
||||
const newLayout = {
|
||||
...layout,
|
||||
overrides: {
|
||||
...layout.overrides,
|
||||
[currentMode]: {
|
||||
...layout.overrides?.[currentMode],
|
||||
positions: {
|
||||
...layout.overrides?.[currentMode]?.positions,
|
||||
[componentId]: newPosition,
|
||||
setLayout((prev) => {
|
||||
if (activeCanvasId === "main") {
|
||||
const component = prev.components[componentId];
|
||||
if (!component) return prev;
|
||||
|
||||
if (currentMode === "tablet_landscape") {
|
||||
return {
|
||||
...prev,
|
||||
components: {
|
||||
...prev.components,
|
||||
[componentId]: { ...component, position: newPosition },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
setLayout(newLayout);
|
||||
setHasChanges(true);
|
||||
}
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...prev,
|
||||
overrides: {
|
||||
...prev.overrides,
|
||||
[currentMode]: {
|
||||
...prev.overrides?.[currentMode],
|
||||
positions: {
|
||||
...prev.overrides?.[currentMode]?.positions,
|
||||
[componentId]: newPosition,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// 모달 캔버스
|
||||
return {
|
||||
...prev,
|
||||
modals: (prev.modals || []).map(m => {
|
||||
if (m.id !== activeCanvasId) return m;
|
||||
const component = m.components[componentId];
|
||||
if (!component) return m;
|
||||
|
||||
if (currentMode === "tablet_landscape") {
|
||||
return {
|
||||
...m,
|
||||
components: {
|
||||
...m.components,
|
||||
[componentId]: { ...component, position: newPosition },
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...m,
|
||||
overrides: {
|
||||
...m.overrides,
|
||||
[currentMode]: {
|
||||
...m.overrides?.[currentMode],
|
||||
positions: {
|
||||
...m.overrides?.[currentMode]?.positions,
|
||||
[componentId]: newPosition,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
});
|
||||
setHasChanges(true);
|
||||
},
|
||||
[layout, currentMode]
|
||||
[currentMode, activeCanvasId]
|
||||
);
|
||||
|
||||
const handleResizeEnd = useCallback(
|
||||
|
|
@ -493,51 +552,87 @@ export default function PopDesigner({
|
|||
// 컴포넌트가 자신의 rowSpan/colSpan을 동적으로 변경 요청 (CardList 확장 등)
|
||||
const handleRequestResize = useCallback(
|
||||
(componentId: string, newRowSpan: number, newColSpan?: number) => {
|
||||
const component = layout.components[componentId];
|
||||
if (!component) return;
|
||||
setLayout((prev) => {
|
||||
const buildPosition = (comp: PopComponentDefinition) => ({
|
||||
...comp.position,
|
||||
rowSpan: newRowSpan,
|
||||
...(newColSpan !== undefined ? { colSpan: newColSpan } : {}),
|
||||
});
|
||||
|
||||
const newPosition = {
|
||||
...component.position,
|
||||
rowSpan: newRowSpan,
|
||||
...(newColSpan !== undefined ? { colSpan: newColSpan } : {}),
|
||||
};
|
||||
|
||||
// 기본 모드(tablet_landscape)인 경우: 원본 position 직접 수정
|
||||
if (currentMode === "tablet_landscape") {
|
||||
const newLayout = {
|
||||
...layout,
|
||||
components: {
|
||||
...layout.components,
|
||||
[componentId]: {
|
||||
...component,
|
||||
position: newPosition,
|
||||
},
|
||||
},
|
||||
};
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
setHasChanges(true);
|
||||
} else {
|
||||
// 다른 모드인 경우: 오버라이드에 저장
|
||||
const newLayout = {
|
||||
...layout,
|
||||
overrides: {
|
||||
...layout.overrides,
|
||||
[currentMode]: {
|
||||
...layout.overrides?.[currentMode],
|
||||
positions: {
|
||||
...layout.overrides?.[currentMode]?.positions,
|
||||
[componentId]: newPosition,
|
||||
if (activeCanvasId === "main") {
|
||||
const component = prev.components[componentId];
|
||||
if (!component) return prev;
|
||||
const newPosition = buildPosition(component);
|
||||
|
||||
if (currentMode === "tablet_landscape") {
|
||||
const newLayout = {
|
||||
...prev,
|
||||
components: {
|
||||
...prev.components,
|
||||
[componentId]: { ...component, position: newPosition },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
setHasChanges(true);
|
||||
}
|
||||
};
|
||||
saveToHistory(newLayout);
|
||||
return newLayout;
|
||||
} else {
|
||||
const newLayout = {
|
||||
...prev,
|
||||
overrides: {
|
||||
...prev.overrides,
|
||||
[currentMode]: {
|
||||
...prev.overrides?.[currentMode],
|
||||
positions: {
|
||||
...prev.overrides?.[currentMode]?.positions,
|
||||
[componentId]: newPosition,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
saveToHistory(newLayout);
|
||||
return newLayout;
|
||||
}
|
||||
} else {
|
||||
// 모달 캔버스
|
||||
const newLayout = {
|
||||
...prev,
|
||||
modals: (prev.modals || []).map(m => {
|
||||
if (m.id !== activeCanvasId) return m;
|
||||
const component = m.components[componentId];
|
||||
if (!component) return m;
|
||||
const newPosition = buildPosition(component);
|
||||
|
||||
if (currentMode === "tablet_landscape") {
|
||||
return {
|
||||
...m,
|
||||
components: {
|
||||
...m.components,
|
||||
[componentId]: { ...component, position: newPosition },
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...m,
|
||||
overrides: {
|
||||
...m.overrides,
|
||||
[currentMode]: {
|
||||
...m.overrides?.[currentMode],
|
||||
positions: {
|
||||
...m.overrides?.[currentMode]?.positions,
|
||||
[componentId]: newPosition,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}),
|
||||
};
|
||||
saveToHistory(newLayout);
|
||||
return newLayout;
|
||||
}
|
||||
});
|
||||
setHasChanges(true);
|
||||
},
|
||||
[layout, currentMode, saveToHistory]
|
||||
[currentMode, saveToHistory, activeCanvasId]
|
||||
);
|
||||
|
||||
// ========================================
|
||||
|
|
@ -605,9 +700,6 @@ export default function PopDesigner({
|
|||
// ========================================
|
||||
|
||||
const handleHideComponent = useCallback((componentId: string) => {
|
||||
// 12칸 모드에서는 숨기기 불가
|
||||
if (currentMode === "tablet_landscape") return;
|
||||
|
||||
const currentHidden = layout.overrides?.[currentMode]?.hidden || [];
|
||||
|
||||
// 이미 숨겨져 있으면 무시
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// POP 디자이너 컴포넌트 export (v5 그리드 시스템)
|
||||
// POP 디자이너 컴포넌트 export (블록 그리드 시스템)
|
||||
|
||||
// 타입
|
||||
export * from "./types";
|
||||
|
|
@ -17,11 +17,12 @@ export { default as PopRenderer } from "./renderers/PopRenderer";
|
|||
|
||||
// 유틸리티
|
||||
export * from "./utils/gridUtils";
|
||||
export * from "./utils/legacyLoader";
|
||||
|
||||
// 핵심 타입 재export (편의)
|
||||
export type {
|
||||
PopLayoutDataV5,
|
||||
PopComponentDefinitionV5,
|
||||
PopLayoutData,
|
||||
PopComponentDefinition,
|
||||
PopComponentType,
|
||||
PopGridPosition,
|
||||
GridMode,
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@
|
|||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
PopComponentDefinitionV5,
|
||||
PopComponentDefinition,
|
||||
PopGridPosition,
|
||||
GridMode,
|
||||
GRID_BREAKPOINTS,
|
||||
BLOCK_SIZE,
|
||||
getBlockColumns,
|
||||
} from "../types/pop-layout";
|
||||
import {
|
||||
Settings,
|
||||
|
|
@ -31,15 +33,15 @@ import ConnectionEditor from "./ConnectionEditor";
|
|||
|
||||
interface ComponentEditorPanelProps {
|
||||
/** 선택된 컴포넌트 */
|
||||
component: PopComponentDefinitionV5 | null;
|
||||
component: PopComponentDefinition | null;
|
||||
/** 현재 모드 */
|
||||
currentMode: GridMode;
|
||||
/** 컴포넌트 업데이트 */
|
||||
onUpdateComponent?: (updates: Partial<PopComponentDefinitionV5>) => void;
|
||||
onUpdateComponent?: (updates: Partial<PopComponentDefinition>) => void;
|
||||
/** 추가 className */
|
||||
className?: string;
|
||||
/** 그리드에 배치된 모든 컴포넌트 */
|
||||
allComponents?: PopComponentDefinitionV5[];
|
||||
allComponents?: PopComponentDefinition[];
|
||||
/** 컴포넌트 선택 콜백 */
|
||||
onSelectComponent?: (componentId: string) => void;
|
||||
/** 현재 선택된 컴포넌트 ID */
|
||||
|
|
@ -247,11 +249,11 @@ export default function ComponentEditorPanel({
|
|||
// ========================================
|
||||
|
||||
interface PositionFormProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
component: PopComponentDefinition;
|
||||
currentMode: GridMode;
|
||||
isDefaultMode: boolean;
|
||||
columns: number;
|
||||
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
|
||||
onUpdate?: (updates: Partial<PopComponentDefinition>) => void;
|
||||
}
|
||||
|
||||
function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate }: PositionFormProps) {
|
||||
|
|
@ -378,7 +380,7 @@ function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate
|
|||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
높이: {position.rowSpan * GRID_BREAKPOINTS[currentMode].rowHeight}px
|
||||
높이: {position.rowSpan * BLOCK_SIZE + (position.rowSpan - 1) * 2}px
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -400,13 +402,13 @@ function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate
|
|||
// ========================================
|
||||
|
||||
interface ComponentSettingsFormProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
|
||||
component: PopComponentDefinition;
|
||||
onUpdate?: (updates: Partial<PopComponentDefinition>) => void;
|
||||
currentMode?: GridMode;
|
||||
previewPageIndex?: number;
|
||||
onPreviewPage?: (pageIndex: number) => void;
|
||||
modals?: PopModalDefinition[];
|
||||
allComponents?: PopComponentDefinitionV5[];
|
||||
allComponents?: PopComponentDefinition[];
|
||||
connections?: PopDataConnection[];
|
||||
}
|
||||
|
||||
|
|
@ -464,16 +466,16 @@ function ComponentSettingsForm({ component, onUpdate, currentMode, previewPageIn
|
|||
// ========================================
|
||||
|
||||
interface VisibilityFormProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
|
||||
component: PopComponentDefinition;
|
||||
onUpdate?: (updates: Partial<PopComponentDefinition>) => void;
|
||||
}
|
||||
|
||||
function VisibilityForm({ component, onUpdate }: VisibilityFormProps) {
|
||||
const modes: Array<{ key: GridMode; label: string }> = [
|
||||
{ key: "tablet_landscape", label: "태블릿 가로 (12칸)" },
|
||||
{ key: "tablet_portrait", label: "태블릿 세로 (8칸)" },
|
||||
{ key: "mobile_landscape", label: "모바일 가로 (6칸)" },
|
||||
{ key: "mobile_portrait", label: "모바일 세로 (4칸)" },
|
||||
{ key: "tablet_landscape", label: `태블릿 가로 (${getBlockColumns(1024)}칸)` },
|
||||
{ key: "tablet_portrait", label: `태블릿 세로 (${getBlockColumns(820)}칸)` },
|
||||
{ key: "mobile_landscape", label: `모바일 가로 (${getBlockColumns(600)}칸)` },
|
||||
{ key: "mobile_portrait", label: `모바일 세로 (${getBlockColumns(375)}칸)` },
|
||||
];
|
||||
|
||||
const handleVisibilityChange = (mode: GridMode, visible: boolean) => {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useDrag } from "react-dnd";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PopComponentType } from "../types/pop-layout";
|
||||
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput, ScanLine, UserCircle, BarChart2 } from "lucide-react";
|
||||
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput, ScanLine, UserCircle, BarChart2, ClipboardCheck } from "lucide-react";
|
||||
import { DND_ITEM_TYPES } from "../constants";
|
||||
|
||||
// 컴포넌트 정의
|
||||
|
|
@ -93,6 +93,12 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
|||
icon: UserCircle,
|
||||
description: "사용자 프로필 / PC 전환 / 로그아웃",
|
||||
},
|
||||
{
|
||||
type: "pop-work-detail",
|
||||
label: "작업 상세",
|
||||
icon: ClipboardCheck,
|
||||
description: "공정별 체크리스트/검사/실적 상세 작업 화면",
|
||||
},
|
||||
];
|
||||
|
||||
// 드래그 가능한 컴포넌트 아이템
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
PopComponentDefinitionV5,
|
||||
PopComponentDefinition,
|
||||
PopDataConnection,
|
||||
} from "../types/pop-layout";
|
||||
import {
|
||||
|
|
@ -26,8 +26,8 @@ import { getTableColumns } from "@/lib/api/tableManagement";
|
|||
// ========================================
|
||||
|
||||
interface ConnectionEditorProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
allComponents: PopComponentDefinitionV5[];
|
||||
component: PopComponentDefinition;
|
||||
allComponents: PopComponentDefinition[];
|
||||
connections: PopDataConnection[];
|
||||
onAddConnection?: (conn: Omit<PopDataConnection, "id">) => void;
|
||||
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
|
||||
|
|
@ -102,8 +102,8 @@ export default function ConnectionEditor({
|
|||
// ========================================
|
||||
|
||||
interface SendSectionProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
allComponents: PopComponentDefinitionV5[];
|
||||
component: PopComponentDefinition;
|
||||
allComponents: PopComponentDefinition[];
|
||||
outgoing: PopDataConnection[];
|
||||
onAddConnection?: (conn: Omit<PopDataConnection, "id">) => void;
|
||||
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
|
||||
|
|
@ -197,15 +197,15 @@ function SendSection({
|
|||
// ========================================
|
||||
|
||||
interface SimpleConnectionFormProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
allComponents: PopComponentDefinitionV5[];
|
||||
component: PopComponentDefinition;
|
||||
allComponents: PopComponentDefinition[];
|
||||
initial?: PopDataConnection;
|
||||
onSubmit: (data: Omit<PopDataConnection, "id">) => void;
|
||||
onCancel?: () => void;
|
||||
submitLabel: string;
|
||||
}
|
||||
|
||||
function extractSubTableName(comp: PopComponentDefinitionV5): string | null {
|
||||
function extractSubTableName(comp: PopComponentDefinition): string | null {
|
||||
const cfg = comp.config as Record<string, unknown> | undefined;
|
||||
if (!cfg) return null;
|
||||
|
||||
|
|
@ -423,8 +423,8 @@ function SimpleConnectionForm({
|
|||
// ========================================
|
||||
|
||||
interface ReceiveSectionProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
allComponents: PopComponentDefinitionV5[];
|
||||
component: PopComponentDefinition;
|
||||
allComponents: PopComponentDefinition[];
|
||||
incoming: PopDataConnection[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,14 +5,18 @@ import { useDrag } from "react-dnd";
|
|||
import { cn } from "@/lib/utils";
|
||||
import { DND_ITEM_TYPES } from "../constants";
|
||||
import {
|
||||
PopLayoutDataV5,
|
||||
PopComponentDefinitionV5,
|
||||
PopLayoutData,
|
||||
PopComponentDefinition,
|
||||
PopGridPosition,
|
||||
GridMode,
|
||||
GRID_BREAKPOINTS,
|
||||
GridBreakpoint,
|
||||
detectGridMode,
|
||||
PopComponentType,
|
||||
BLOCK_SIZE,
|
||||
BLOCK_GAP,
|
||||
BLOCK_PADDING,
|
||||
getBlockColumns,
|
||||
} from "../types/pop-layout";
|
||||
import {
|
||||
convertAndResolvePositions,
|
||||
|
|
@ -27,7 +31,7 @@ import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
|
|||
|
||||
interface PopRendererProps {
|
||||
/** v5 레이아웃 데이터 */
|
||||
layout: PopLayoutDataV5;
|
||||
layout: PopLayoutData;
|
||||
/** 현재 뷰포트 너비 */
|
||||
viewportWidth: number;
|
||||
/** 현재 모드 (자동 감지 또는 수동 지정) */
|
||||
|
|
@ -80,6 +84,7 @@ const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
|||
"pop-field": "입력",
|
||||
"pop-scanner": "스캐너",
|
||||
"pop-profile": "프로필",
|
||||
"pop-work-detail": "작업 상세",
|
||||
};
|
||||
|
||||
// ========================================
|
||||
|
|
@ -107,18 +112,27 @@ export default function PopRenderer({
|
|||
}: PopRendererProps) {
|
||||
const { gridConfig, components, overrides } = layout;
|
||||
|
||||
// 현재 모드 (자동 감지 또는 지정)
|
||||
// V6: 뷰포트 너비에서 블록 칸 수 동적 계산
|
||||
const mode = currentMode || detectGridMode(viewportWidth);
|
||||
const breakpoint = GRID_BREAKPOINTS[mode];
|
||||
const columns = getBlockColumns(viewportWidth);
|
||||
|
||||
// Gap/Padding: 오버라이드 우선, 없으면 기본값 사용
|
||||
const finalGap = overrideGap !== undefined ? overrideGap : breakpoint.gap;
|
||||
const finalPadding = overridePadding !== undefined ? overridePadding : breakpoint.padding;
|
||||
// V6: 블록 간격 고정
|
||||
const finalGap = overrideGap !== undefined ? overrideGap : BLOCK_GAP;
|
||||
const finalPadding = overridePadding !== undefined ? overridePadding : BLOCK_PADDING;
|
||||
|
||||
// 하위 호환: breakpoint 객체 (ResizeHandles 등에서 사용)
|
||||
const breakpoint: GridBreakpoint = {
|
||||
columns,
|
||||
rowHeight: BLOCK_SIZE,
|
||||
gap: finalGap,
|
||||
padding: finalPadding,
|
||||
label: `${columns}칸 블록`,
|
||||
};
|
||||
|
||||
// 숨김 컴포넌트 ID 목록
|
||||
const hiddenIds = overrides?.[mode]?.hidden || [];
|
||||
|
||||
// 동적 행 수 계산 (가이드 셀 + Grid 스타일 공유, 숨김 컴포넌트 제외)
|
||||
// 동적 행 수 계산
|
||||
const dynamicRowCount = useMemo(() => {
|
||||
const visibleComps = Object.values(components).filter(
|
||||
comp => !hiddenIds.includes(comp.id)
|
||||
|
|
@ -131,19 +145,17 @@ export default function PopRenderer({
|
|||
return Math.max(10, maxRowEnd + 3);
|
||||
}, [components, overrides, mode, hiddenIds]);
|
||||
|
||||
// CSS Grid 스타일
|
||||
// 디자인 모드: 행 높이 고정 (정밀한 레이아웃 편집)
|
||||
// 뷰어 모드: minmax(rowHeight, auto) (컴포넌트가 컨텐츠에 맞게 확장 가능)
|
||||
// V6: CSS Grid - 열은 1fr(뷰포트 꽉 채움), 행은 고정 BLOCK_SIZE
|
||||
const rowTemplate = isDesignMode
|
||||
? `repeat(${dynamicRowCount}, ${breakpoint.rowHeight}px)`
|
||||
: `repeat(${dynamicRowCount}, minmax(${breakpoint.rowHeight}px, auto))`;
|
||||
? `repeat(${dynamicRowCount}, ${BLOCK_SIZE}px)`
|
||||
: `repeat(${dynamicRowCount}, minmax(${BLOCK_SIZE}px, auto))`;
|
||||
const autoRowHeight = isDesignMode
|
||||
? `${breakpoint.rowHeight}px`
|
||||
: `minmax(${breakpoint.rowHeight}px, auto)`;
|
||||
? `${BLOCK_SIZE}px`
|
||||
: `minmax(${BLOCK_SIZE}px, auto)`;
|
||||
|
||||
const gridStyle = useMemo((): React.CSSProperties => ({
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`,
|
||||
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
||||
gridTemplateRows: rowTemplate,
|
||||
gridAutoRows: autoRowHeight,
|
||||
gap: `${finalGap}px`,
|
||||
|
|
@ -151,15 +163,15 @@ export default function PopRenderer({
|
|||
minHeight: "100%",
|
||||
backgroundColor: "#ffffff",
|
||||
position: "relative",
|
||||
}), [breakpoint, finalGap, finalPadding, dynamicRowCount, rowTemplate, autoRowHeight]);
|
||||
}), [columns, finalGap, finalPadding, dynamicRowCount, rowTemplate, autoRowHeight]);
|
||||
|
||||
// 그리드 가이드 셀 생성 (동적 행 수)
|
||||
// 그리드 가이드 셀 생성
|
||||
const gridCells = useMemo(() => {
|
||||
if (!isDesignMode || !showGridGuide) return [];
|
||||
|
||||
const cells = [];
|
||||
for (let row = 1; row <= dynamicRowCount; row++) {
|
||||
for (let col = 1; col <= breakpoint.columns; col++) {
|
||||
for (let col = 1; col <= columns; col++) {
|
||||
cells.push({
|
||||
id: `cell-${col}-${row}`,
|
||||
col,
|
||||
|
|
@ -168,10 +180,10 @@ export default function PopRenderer({
|
|||
}
|
||||
}
|
||||
return cells;
|
||||
}, [isDesignMode, showGridGuide, breakpoint.columns, dynamicRowCount]);
|
||||
}, [isDesignMode, showGridGuide, columns, dynamicRowCount]);
|
||||
|
||||
// visibility 체크
|
||||
const isVisible = (comp: PopComponentDefinitionV5): boolean => {
|
||||
const isVisible = (comp: PopComponentDefinition): boolean => {
|
||||
if (!comp.visibility) return true;
|
||||
const modeVisibility = comp.visibility[mode];
|
||||
return modeVisibility !== false;
|
||||
|
|
@ -196,7 +208,7 @@ export default function PopRenderer({
|
|||
};
|
||||
|
||||
// 오버라이드 적용 또는 자동 재배치
|
||||
const getEffectivePosition = (comp: PopComponentDefinitionV5): PopGridPosition => {
|
||||
const getEffectivePosition = (comp: PopComponentDefinition): PopGridPosition => {
|
||||
// 1순위: 오버라이드가 있으면 사용
|
||||
const override = overrides?.[mode]?.positions?.[comp.id];
|
||||
if (override) {
|
||||
|
|
@ -214,7 +226,7 @@ export default function PopRenderer({
|
|||
};
|
||||
|
||||
// 오버라이드 숨김 체크
|
||||
const isHiddenByOverride = (comp: PopComponentDefinitionV5): boolean => {
|
||||
const isHiddenByOverride = (comp: PopComponentDefinition): boolean => {
|
||||
return overrides?.[mode]?.hidden?.includes(comp.id) ?? false;
|
||||
};
|
||||
|
||||
|
|
@ -311,7 +323,7 @@ export default function PopRenderer({
|
|||
// ========================================
|
||||
|
||||
interface DraggableComponentProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
component: PopComponentDefinition;
|
||||
position: PopGridPosition;
|
||||
positionStyle: React.CSSProperties;
|
||||
isSelected: boolean;
|
||||
|
|
@ -412,7 +424,7 @@ function DraggableComponent({
|
|||
// ========================================
|
||||
|
||||
interface ResizeHandlesProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
component: PopComponentDefinition;
|
||||
position: PopGridPosition;
|
||||
breakpoint: GridBreakpoint;
|
||||
viewportWidth: number;
|
||||
|
|
@ -533,7 +545,7 @@ function ResizeHandles({
|
|||
// ========================================
|
||||
|
||||
interface ComponentContentProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
component: PopComponentDefinition;
|
||||
effectivePosition: PopGridPosition;
|
||||
isDesignMode: boolean;
|
||||
isSelected: boolean;
|
||||
|
|
@ -603,7 +615,7 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect
|
|||
// ========================================
|
||||
|
||||
function renderActualComponent(
|
||||
component: PopComponentDefinitionV5,
|
||||
component: PopComponentDefinition,
|
||||
effectivePosition?: PopGridPosition,
|
||||
onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void,
|
||||
screenId?: string,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
// POP 디자이너 레이아웃 타입 정의
|
||||
// v5.0: CSS Grid 기반 그리드 시스템
|
||||
// 2024-02 버전 통합: v1~v4 제거, v5 단일 버전
|
||||
// POP 블록 그리드 레이아웃 타입 정의
|
||||
|
||||
// ========================================
|
||||
// 공통 타입
|
||||
|
|
@ -9,7 +7,7 @@
|
|||
/**
|
||||
* POP 컴포넌트 타입
|
||||
*/
|
||||
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-card-list-v2" | "pop-button" | "pop-string-list" | "pop-search" | "pop-status-bar" | "pop-field" | "pop-scanner" | "pop-profile";
|
||||
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-card-list-v2" | "pop-button" | "pop-string-list" | "pop-search" | "pop-status-bar" | "pop-field" | "pop-scanner" | "pop-profile" | "pop-work-detail";
|
||||
|
||||
/**
|
||||
* 데이터 흐름 정의
|
||||
|
|
@ -99,24 +97,39 @@ export interface PopLayoutMetadata {
|
|||
}
|
||||
|
||||
// ========================================
|
||||
// v5 그리드 기반 레이아웃
|
||||
// v6 정사각형 블록 그리드 시스템
|
||||
// ========================================
|
||||
// 핵심: CSS Grid로 정확한 위치 지정
|
||||
// - 열/행 좌표로 배치 (col, row)
|
||||
// - 칸 단위 크기 (colSpan, rowSpan)
|
||||
// - Material Design 브레이크포인트 기반
|
||||
// 핵심: 균일한 정사각형 블록 (24px x 24px)
|
||||
// - 열/행 좌표로 배치 (col, row) - 블록 단위
|
||||
// - 뷰포트 너비에 따라 칸 수 동적 계산
|
||||
// - 단일 좌표계 (모드별 변환 불필요)
|
||||
|
||||
/**
|
||||
* 그리드 모드 (4가지)
|
||||
* V6 블록 상수
|
||||
*/
|
||||
export const BLOCK_SIZE = 24; // 블록 크기 (px, 정사각형)
|
||||
export const BLOCK_GAP = 2; // 블록 간격 (px)
|
||||
export const BLOCK_PADDING = 8; // 캔버스 패딩 (px)
|
||||
|
||||
/**
|
||||
* 뷰포트 너비에서 블록 칸 수 계산
|
||||
*/
|
||||
export function getBlockColumns(viewportWidth: number): number {
|
||||
const available = viewportWidth - BLOCK_PADDING * 2;
|
||||
return Math.max(1, Math.floor((available + BLOCK_GAP) / (BLOCK_SIZE + BLOCK_GAP)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 뷰포트 프리셋 (디자이너 해상도 전환용)
|
||||
*/
|
||||
export type GridMode =
|
||||
| "mobile_portrait" // 4칸
|
||||
| "mobile_landscape" // 6칸
|
||||
| "tablet_portrait" // 8칸
|
||||
| "tablet_landscape"; // 12칸 (기본)
|
||||
| "mobile_portrait"
|
||||
| "mobile_landscape"
|
||||
| "tablet_portrait"
|
||||
| "tablet_landscape";
|
||||
|
||||
/**
|
||||
* 그리드 브레이크포인트 설정
|
||||
* 뷰포트 프리셋 설정
|
||||
*/
|
||||
export interface GridBreakpoint {
|
||||
minWidth?: number;
|
||||
|
|
@ -129,50 +142,43 @@ export interface GridBreakpoint {
|
|||
}
|
||||
|
||||
/**
|
||||
* 브레이크포인트 상수
|
||||
* 업계 표준 (768px, 1024px) + 실제 기기 커버리지 기반
|
||||
* V6 브레이크포인트 (블록 기반 동적 칸 수)
|
||||
* columns는 각 뷰포트 너비에서의 블록 수
|
||||
*/
|
||||
export const GRID_BREAKPOINTS: Record<GridMode, GridBreakpoint> = {
|
||||
// 스마트폰 세로 (iPhone SE ~ Galaxy S25 Ultra)
|
||||
mobile_portrait: {
|
||||
maxWidth: 479,
|
||||
columns: 4,
|
||||
rowHeight: 40,
|
||||
gap: 8,
|
||||
padding: 12,
|
||||
label: "모바일 세로 (4칸)",
|
||||
columns: getBlockColumns(375),
|
||||
rowHeight: BLOCK_SIZE,
|
||||
gap: BLOCK_GAP,
|
||||
padding: BLOCK_PADDING,
|
||||
label: `모바일 세로 (${getBlockColumns(375)}칸)`,
|
||||
},
|
||||
|
||||
// 스마트폰 가로 + 소형 태블릿
|
||||
mobile_landscape: {
|
||||
minWidth: 480,
|
||||
maxWidth: 767,
|
||||
columns: 6,
|
||||
rowHeight: 44,
|
||||
gap: 8,
|
||||
padding: 16,
|
||||
label: "모바일 가로 (6칸)",
|
||||
columns: getBlockColumns(600),
|
||||
rowHeight: BLOCK_SIZE,
|
||||
gap: BLOCK_GAP,
|
||||
padding: BLOCK_PADDING,
|
||||
label: `모바일 가로 (${getBlockColumns(600)}칸)`,
|
||||
},
|
||||
|
||||
// 태블릿 세로 (iPad Mini ~ iPad Pro)
|
||||
tablet_portrait: {
|
||||
minWidth: 768,
|
||||
maxWidth: 1023,
|
||||
columns: 8,
|
||||
rowHeight: 48,
|
||||
gap: 12,
|
||||
padding: 16,
|
||||
label: "태블릿 세로 (8칸)",
|
||||
columns: getBlockColumns(820),
|
||||
rowHeight: BLOCK_SIZE,
|
||||
gap: BLOCK_GAP,
|
||||
padding: BLOCK_PADDING,
|
||||
label: `태블릿 세로 (${getBlockColumns(820)}칸)`,
|
||||
},
|
||||
|
||||
// 태블릿 가로 + 데스크톱 (기본)
|
||||
tablet_landscape: {
|
||||
minWidth: 1024,
|
||||
columns: 12,
|
||||
rowHeight: 48,
|
||||
gap: 16,
|
||||
padding: 24,
|
||||
label: "태블릿 가로 (12칸)",
|
||||
columns: getBlockColumns(1024),
|
||||
rowHeight: BLOCK_SIZE,
|
||||
gap: BLOCK_GAP,
|
||||
padding: BLOCK_PADDING,
|
||||
label: `태블릿 가로 (${getBlockColumns(1024)}칸)`,
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
@ -183,7 +189,6 @@ export const DEFAULT_GRID_MODE: GridMode = "tablet_landscape";
|
|||
|
||||
/**
|
||||
* 뷰포트 너비로 모드 감지
|
||||
* GRID_BREAKPOINTS와 일치하는 브레이크포인트 사용
|
||||
*/
|
||||
export function detectGridMode(viewportWidth: number): GridMode {
|
||||
if (viewportWidth < 480) return "mobile_portrait";
|
||||
|
|
@ -193,31 +198,31 @@ export function detectGridMode(viewportWidth: number): GridMode {
|
|||
}
|
||||
|
||||
/**
|
||||
* v5 레이아웃 (그리드 기반)
|
||||
* POP 레이아웃 데이터
|
||||
*/
|
||||
export interface PopLayoutDataV5 {
|
||||
export interface PopLayoutData {
|
||||
version: "pop-5.0";
|
||||
|
||||
// 그리드 설정
|
||||
gridConfig: PopGridConfig;
|
||||
|
||||
// 컴포넌트 정의 (ID → 정의)
|
||||
components: Record<string, PopComponentDefinitionV5>;
|
||||
components: Record<string, PopComponentDefinition>;
|
||||
|
||||
// 데이터 흐름
|
||||
dataFlow: PopDataFlow;
|
||||
|
||||
// 전역 설정
|
||||
settings: PopGlobalSettingsV5;
|
||||
settings: PopGlobalSettings;
|
||||
|
||||
// 메타데이터
|
||||
metadata?: PopLayoutMetadata;
|
||||
|
||||
// 모드별 오버라이드 (위치 변경용)
|
||||
overrides?: {
|
||||
mobile_portrait?: PopModeOverrideV5;
|
||||
mobile_landscape?: PopModeOverrideV5;
|
||||
tablet_portrait?: PopModeOverrideV5;
|
||||
mobile_portrait?: PopModeOverride;
|
||||
mobile_landscape?: PopModeOverride;
|
||||
tablet_portrait?: PopModeOverride;
|
||||
};
|
||||
|
||||
// 모달 캔버스 목록 (버튼의 "모달 열기" 액션으로 생성)
|
||||
|
|
@ -225,17 +230,17 @@ export interface PopLayoutDataV5 {
|
|||
}
|
||||
|
||||
/**
|
||||
* 그리드 설정
|
||||
* 그리드 설정 (V6: 블록 단위)
|
||||
*/
|
||||
export interface PopGridConfig {
|
||||
// 행 높이 (px) - 1행의 기본 높이
|
||||
rowHeight: number; // 기본 48px
|
||||
// 행 높이 = 블록 크기 (px)
|
||||
rowHeight: number; // V6 기본 24px (= BLOCK_SIZE)
|
||||
|
||||
// 간격 (px)
|
||||
gap: number; // 기본 8px
|
||||
gap: number; // V6 기본 2px (= BLOCK_GAP)
|
||||
|
||||
// 패딩 (px)
|
||||
padding: number; // 기본 16px
|
||||
padding: number; // V6 기본 8px (= BLOCK_PADDING)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -249,9 +254,9 @@ export interface PopGridPosition {
|
|||
}
|
||||
|
||||
/**
|
||||
* v5 컴포넌트 정의
|
||||
* POP 컴포넌트 정의
|
||||
*/
|
||||
export interface PopComponentDefinitionV5 {
|
||||
export interface PopComponentDefinition {
|
||||
id: string;
|
||||
type: PopComponentType;
|
||||
label?: string;
|
||||
|
|
@ -274,7 +279,7 @@ export interface PopComponentDefinitionV5 {
|
|||
}
|
||||
|
||||
/**
|
||||
* Gap 프리셋 타입
|
||||
* Gap 프리셋 타입 (V6: 단일 간격이므로 medium만 유효, 하위 호환용 유지)
|
||||
*/
|
||||
export type GapPreset = "narrow" | "medium" | "wide";
|
||||
|
||||
|
|
@ -287,18 +292,18 @@ export interface GapPresetConfig {
|
|||
}
|
||||
|
||||
/**
|
||||
* Gap 프리셋 상수
|
||||
* Gap 프리셋 상수 (V6: 모두 동일 - 블록 간격 고정)
|
||||
*/
|
||||
export const GAP_PRESETS: Record<GapPreset, GapPresetConfig> = {
|
||||
narrow: { multiplier: 0.5, label: "좁게" },
|
||||
medium: { multiplier: 1.0, label: "보통" },
|
||||
wide: { multiplier: 1.5, label: "넓게" },
|
||||
narrow: { multiplier: 1.0, label: "기본" },
|
||||
medium: { multiplier: 1.0, label: "기본" },
|
||||
wide: { multiplier: 1.0, label: "기본" },
|
||||
};
|
||||
|
||||
/**
|
||||
* v5 전역 설정
|
||||
* POP 전역 설정
|
||||
*/
|
||||
export interface PopGlobalSettingsV5 {
|
||||
export interface PopGlobalSettings {
|
||||
// 터치 최소 크기 (px)
|
||||
touchTargetMin: number; // 기본 48
|
||||
|
||||
|
|
@ -310,9 +315,9 @@ export interface PopGlobalSettingsV5 {
|
|||
}
|
||||
|
||||
/**
|
||||
* v5 모드별 오버라이드
|
||||
* 모드별 오버라이드 (위치/숨김)
|
||||
*/
|
||||
export interface PopModeOverrideV5 {
|
||||
export interface PopModeOverride {
|
||||
// 컴포넌트별 위치 오버라이드
|
||||
positions?: Record<string, Partial<PopGridPosition>>;
|
||||
|
||||
|
|
@ -321,18 +326,18 @@ export interface PopModeOverrideV5 {
|
|||
}
|
||||
|
||||
// ========================================
|
||||
// v5 유틸리티 함수
|
||||
// 레이아웃 유틸리티 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 빈 v5 레이아웃 생성
|
||||
* 빈 POP 레이아웃 생성
|
||||
*/
|
||||
export const createEmptyPopLayoutV5 = (): PopLayoutDataV5 => ({
|
||||
export const createEmptyLayout = (): PopLayoutData => ({
|
||||
version: "pop-5.0",
|
||||
gridConfig: {
|
||||
rowHeight: 48,
|
||||
gap: 8,
|
||||
padding: 16,
|
||||
rowHeight: BLOCK_SIZE,
|
||||
gap: BLOCK_GAP,
|
||||
padding: BLOCK_PADDING,
|
||||
},
|
||||
components: {},
|
||||
dataFlow: { connections: [] },
|
||||
|
|
@ -344,40 +349,46 @@ export const createEmptyPopLayoutV5 = (): PopLayoutDataV5 => ({
|
|||
});
|
||||
|
||||
/**
|
||||
* v5 레이아웃 여부 확인
|
||||
* POP 레이아웃 데이터인지 확인
|
||||
*/
|
||||
export const isV5Layout = (layout: any): layout is PopLayoutDataV5 => {
|
||||
export const isPopLayout = (layout: any): layout is PopLayoutData => {
|
||||
return layout?.version === "pop-5.0";
|
||||
};
|
||||
|
||||
/**
|
||||
* 컴포넌트 타입별 기본 크기 (칸 단위)
|
||||
* 컴포넌트 타입별 기본 크기 (블록 단위, V6)
|
||||
*
|
||||
* 소형 (2x2) : 최소 단위. 아이콘, 프로필, 스캐너 등 단일 요소
|
||||
* 중형 (8x4) : 검색, 버튼, 텍스트 등 한 줄 입력/표시
|
||||
* 대형 (8x6) : 샘플, 상태바, 필드 등 여러 줄 컨텐츠
|
||||
* 초대형 (19x8~) : 카드, 리스트, 대시보드 등 메인 영역
|
||||
*/
|
||||
export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: number; rowSpan: number }> = {
|
||||
"pop-sample": { colSpan: 2, rowSpan: 1 },
|
||||
"pop-text": { colSpan: 3, rowSpan: 1 },
|
||||
"pop-icon": { colSpan: 1, rowSpan: 2 },
|
||||
"pop-dashboard": { colSpan: 6, rowSpan: 3 },
|
||||
"pop-card-list": { colSpan: 4, rowSpan: 3 },
|
||||
"pop-card-list-v2": { colSpan: 4, rowSpan: 3 },
|
||||
"pop-button": { colSpan: 2, rowSpan: 1 },
|
||||
"pop-string-list": { colSpan: 4, rowSpan: 3 },
|
||||
"pop-search": { colSpan: 2, rowSpan: 1 },
|
||||
"pop-status-bar": { colSpan: 6, rowSpan: 1 },
|
||||
"pop-field": { colSpan: 6, rowSpan: 2 },
|
||||
"pop-scanner": { colSpan: 1, rowSpan: 1 },
|
||||
"pop-profile": { colSpan: 1, rowSpan: 1 },
|
||||
"pop-sample": { colSpan: 8, rowSpan: 6 },
|
||||
"pop-text": { colSpan: 8, rowSpan: 4 },
|
||||
"pop-icon": { colSpan: 2, rowSpan: 2 },
|
||||
"pop-dashboard": { colSpan: 19, rowSpan: 10 },
|
||||
"pop-card-list": { colSpan: 19, rowSpan: 10 },
|
||||
"pop-card-list-v2": { colSpan: 19, rowSpan: 10 },
|
||||
"pop-button": { colSpan: 8, rowSpan: 4 },
|
||||
"pop-string-list": { colSpan: 19, rowSpan: 10 },
|
||||
"pop-search": { colSpan: 8, rowSpan: 4 },
|
||||
"pop-status-bar": { colSpan: 19, rowSpan: 4 },
|
||||
"pop-field": { colSpan: 19, rowSpan: 6 },
|
||||
"pop-scanner": { colSpan: 2, rowSpan: 2 },
|
||||
"pop-profile": { colSpan: 2, rowSpan: 2 },
|
||||
"pop-work-detail": { colSpan: 38, rowSpan: 26 },
|
||||
};
|
||||
|
||||
/**
|
||||
* v5 컴포넌트 정의 생성
|
||||
* POP 컴포넌트 정의 생성
|
||||
*/
|
||||
export const createComponentDefinitionV5 = (
|
||||
export const createComponentDefinition = (
|
||||
id: string,
|
||||
type: PopComponentType,
|
||||
position: PopGridPosition,
|
||||
label?: string
|
||||
): PopComponentDefinitionV5 => ({
|
||||
): PopComponentDefinition => ({
|
||||
id,
|
||||
type,
|
||||
label,
|
||||
|
|
@ -385,21 +396,21 @@ export const createComponentDefinitionV5 = (
|
|||
});
|
||||
|
||||
/**
|
||||
* v5 레이아웃에 컴포넌트 추가
|
||||
* POP 레이아웃에 컴포넌트 추가
|
||||
*/
|
||||
export const addComponentToV5Layout = (
|
||||
layout: PopLayoutDataV5,
|
||||
export const addComponentToLayout = (
|
||||
layout: PopLayoutData,
|
||||
componentId: string,
|
||||
type: PopComponentType,
|
||||
position: PopGridPosition,
|
||||
label?: string
|
||||
): PopLayoutDataV5 => {
|
||||
): PopLayoutData => {
|
||||
const newLayout = { ...layout };
|
||||
|
||||
// 컴포넌트 정의 추가
|
||||
newLayout.components = {
|
||||
...newLayout.components,
|
||||
[componentId]: createComponentDefinitionV5(componentId, type, position, label),
|
||||
[componentId]: createComponentDefinition(componentId, type, position, label),
|
||||
};
|
||||
|
||||
return newLayout;
|
||||
|
|
@ -474,12 +485,12 @@ export interface PopModalDefinition {
|
|||
/** 모달 내부 그리드 설정 */
|
||||
gridConfig: PopGridConfig;
|
||||
/** 모달 내부 컴포넌트 */
|
||||
components: Record<string, PopComponentDefinitionV5>;
|
||||
components: Record<string, PopComponentDefinition>;
|
||||
/** 모드별 오버라이드 */
|
||||
overrides?: {
|
||||
mobile_portrait?: PopModeOverrideV5;
|
||||
mobile_landscape?: PopModeOverrideV5;
|
||||
tablet_portrait?: PopModeOverrideV5;
|
||||
mobile_portrait?: PopModeOverride;
|
||||
mobile_landscape?: PopModeOverride;
|
||||
tablet_portrait?: PopModeOverride;
|
||||
};
|
||||
/** 모달 프레임 설정 (닫기 방식) */
|
||||
frameConfig?: {
|
||||
|
|
@ -495,15 +506,29 @@ export interface PopModalDefinition {
|
|||
}
|
||||
|
||||
// ========================================
|
||||
// 레거시 타입 별칭 (하위 호환 - 추후 제거)
|
||||
// 레거시 타입 별칭 (이전 코드 호환용)
|
||||
// ========================================
|
||||
// 기존 코드에서 import 오류 방지용
|
||||
|
||||
/** @deprecated v5에서는 PopLayoutDataV5 사용 */
|
||||
export type PopLayoutData = PopLayoutDataV5;
|
||||
/** @deprecated PopLayoutData 사용 */
|
||||
export type PopLayoutDataV5 = PopLayoutData;
|
||||
|
||||
/** @deprecated v5에서는 PopComponentDefinitionV5 사용 */
|
||||
export type PopComponentDefinition = PopComponentDefinitionV5;
|
||||
/** @deprecated PopComponentDefinition 사용 */
|
||||
export type PopComponentDefinitionV5 = PopComponentDefinition;
|
||||
|
||||
/** @deprecated v5에서는 PopGridPosition 사용 */
|
||||
export type GridPosition = PopGridPosition;
|
||||
/** @deprecated PopGlobalSettings 사용 */
|
||||
export type PopGlobalSettingsV5 = PopGlobalSettings;
|
||||
|
||||
/** @deprecated PopModeOverride 사용 */
|
||||
export type PopModeOverrideV5 = PopModeOverride;
|
||||
|
||||
/** @deprecated createEmptyLayout 사용 */
|
||||
export const createEmptyPopLayoutV5 = createEmptyLayout;
|
||||
|
||||
/** @deprecated isPopLayout 사용 */
|
||||
export const isV5Layout = isPopLayout;
|
||||
|
||||
/** @deprecated addComponentToLayout 사용 */
|
||||
export const addComponentToV5Layout = addComponentToLayout;
|
||||
|
||||
/** @deprecated createComponentDefinition 사용 */
|
||||
export const createComponentDefinitionV5 = createComponentDefinition;
|
||||
|
|
|
|||
|
|
@ -1,217 +1,106 @@
|
|||
// POP 그리드 유틸리티 (리플로우, 겹침 해결, 위치 계산)
|
||||
|
||||
import {
|
||||
PopGridPosition,
|
||||
GridMode,
|
||||
GRID_BREAKPOINTS,
|
||||
GridBreakpoint,
|
||||
GapPreset,
|
||||
GAP_PRESETS,
|
||||
PopLayoutDataV5,
|
||||
PopComponentDefinitionV5,
|
||||
PopLayoutData,
|
||||
} from "../types/pop-layout";
|
||||
|
||||
// ========================================
|
||||
// Gap/Padding 조정
|
||||
// 리플로우 (행 그룹 기반 자동 재배치)
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Gap 프리셋에 따라 breakpoint의 gap/padding 조정
|
||||
*
|
||||
* @param base 기본 breakpoint 설정
|
||||
* @param preset Gap 프리셋 ("narrow" | "medium" | "wide")
|
||||
* @returns 조정된 breakpoint (gap, padding 계산됨)
|
||||
*/
|
||||
export function getAdjustedBreakpoint(
|
||||
base: GridBreakpoint,
|
||||
preset: GapPreset
|
||||
): GridBreakpoint {
|
||||
const multiplier = GAP_PRESETS[preset]?.multiplier || 1.0;
|
||||
|
||||
return {
|
||||
...base,
|
||||
gap: Math.round(base.gap * multiplier),
|
||||
padding: Math.max(8, Math.round(base.padding * multiplier)), // 최소 8px
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 그리드 위치 변환
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 12칸 기준 위치를 다른 모드로 변환
|
||||
*/
|
||||
export function convertPositionToMode(
|
||||
position: PopGridPosition,
|
||||
targetMode: GridMode
|
||||
): PopGridPosition {
|
||||
const sourceColumns = 12;
|
||||
const targetColumns = GRID_BREAKPOINTS[targetMode].columns;
|
||||
|
||||
// 같은 칸 수면 그대로 반환
|
||||
if (sourceColumns === targetColumns) {
|
||||
return position;
|
||||
}
|
||||
|
||||
const ratio = targetColumns / sourceColumns;
|
||||
|
||||
// 열 위치 변환
|
||||
let newCol = Math.max(1, Math.ceil((position.col - 1) * ratio) + 1);
|
||||
let newColSpan = Math.max(1, Math.round(position.colSpan * ratio));
|
||||
|
||||
// 범위 초과 방지
|
||||
if (newCol > targetColumns) {
|
||||
newCol = 1;
|
||||
}
|
||||
if (newCol + newColSpan - 1 > targetColumns) {
|
||||
newColSpan = targetColumns - newCol + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
col: newCol,
|
||||
row: position.row,
|
||||
colSpan: Math.max(1, newColSpan),
|
||||
rowSpan: position.rowSpan,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 컴포넌트를 모드별로 변환하고 겹침 해결
|
||||
*
|
||||
* v5.1 자동 줄바꿈:
|
||||
* - 원본 col > targetColumns인 컴포넌트는 자동으로 맨 아래에 배치
|
||||
* - 정보 손실 방지: 모든 컴포넌트가 그리드 안에 배치됨
|
||||
* 행 그룹 리플로우
|
||||
*
|
||||
* CSS Flexbox wrap 원리로 자동 재배치한다.
|
||||
* 1. 같은 행의 컴포넌트를 한 묶음으로 처리
|
||||
* 2. 최소 2x2칸 보장 (터치 가능한 최소 크기)
|
||||
* 3. 한 줄에 안 들어가면 다음 줄로 줄바꿈 (숨김 없음)
|
||||
* 4. 설계 너비의 50% 이상인 컴포넌트는 전체 너비 확장
|
||||
* 5. 리플로우 후 겹침 해결
|
||||
*/
|
||||
export function convertAndResolvePositions(
|
||||
components: Array<{ id: string; position: PopGridPosition }>,
|
||||
targetMode: GridMode
|
||||
): Array<{ id: string; position: PopGridPosition }> {
|
||||
// 엣지 케이스: 빈 배열
|
||||
if (components.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (components.length === 0) return [];
|
||||
|
||||
const targetColumns = GRID_BREAKPOINTS[targetMode].columns;
|
||||
const designColumns = GRID_BREAKPOINTS["tablet_landscape"].columns;
|
||||
|
||||
// 1단계: 각 컴포넌트를 비율로 변환 (원본 col 보존)
|
||||
const converted = components.map(comp => ({
|
||||
id: comp.id,
|
||||
position: convertPositionToMode(comp.position, targetMode),
|
||||
originalCol: comp.position.col, // 원본 col 보존
|
||||
}));
|
||||
if (targetColumns >= designColumns) {
|
||||
return components.map(c => ({ id: c.id, position: { ...c.position } }));
|
||||
}
|
||||
|
||||
// 2단계: 정상 컴포넌트 vs 초과 컴포넌트 분리
|
||||
const normalComponents = converted.filter(c => c.originalCol <= targetColumns);
|
||||
const overflowComponents = converted.filter(c => c.originalCol > targetColumns);
|
||||
const ratio = targetColumns / designColumns;
|
||||
const MIN_COL_SPAN = 2;
|
||||
const MIN_ROW_SPAN = 2;
|
||||
|
||||
// 3단계: 정상 컴포넌트의 최대 row 계산
|
||||
const maxRow = normalComponents.length > 0
|
||||
? Math.max(...normalComponents.map(c => c.position.row + c.position.rowSpan - 1))
|
||||
: 0;
|
||||
|
||||
// 4단계: 초과 컴포넌트들을 맨 아래에 순차 배치
|
||||
let currentRow = maxRow + 1;
|
||||
const wrappedComponents = overflowComponents.map(comp => {
|
||||
const wrappedPosition: PopGridPosition = {
|
||||
col: 1, // 왼쪽 끝부터 시작
|
||||
row: currentRow,
|
||||
colSpan: Math.min(comp.position.colSpan, targetColumns), // 최대 칸 수 제한
|
||||
rowSpan: comp.position.rowSpan,
|
||||
};
|
||||
currentRow += comp.position.rowSpan; // 다음 행으로 이동
|
||||
|
||||
return {
|
||||
id: comp.id,
|
||||
position: wrappedPosition,
|
||||
};
|
||||
const rowGroups: Record<number, Array<{ id: string; position: PopGridPosition }>> = {};
|
||||
components.forEach(comp => {
|
||||
const r = comp.position.row;
|
||||
if (!rowGroups[r]) rowGroups[r] = [];
|
||||
rowGroups[r].push(comp);
|
||||
});
|
||||
|
||||
// 5단계: 정상 + 줄바꿈 컴포넌트 병합
|
||||
const adjusted = [
|
||||
...normalComponents.map(c => ({ id: c.id, position: c.position })),
|
||||
...wrappedComponents,
|
||||
];
|
||||
const placed: Array<{ id: string; position: PopGridPosition }> = [];
|
||||
let outputRow = 1;
|
||||
|
||||
// 6단계: 겹침 해결 (아래로 밀기)
|
||||
return resolveOverlaps(adjusted, targetColumns);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 검토 필요 판별
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 컴포넌트가 현재 모드에서 "검토 필요" 상태인지 확인
|
||||
*
|
||||
* v5.1 검토 필요 기준:
|
||||
* - 12칸 모드(기본 모드)가 아님
|
||||
* - 해당 모드에서 오버라이드가 없음 (아직 편집 안 함)
|
||||
*
|
||||
* @param currentMode 현재 그리드 모드
|
||||
* @param hasOverride 해당 모드에서 오버라이드 존재 여부
|
||||
* @returns true = 검토 필요, false = 검토 완료 또는 불필요
|
||||
*/
|
||||
export function needsReview(
|
||||
currentMode: GridMode,
|
||||
hasOverride: boolean
|
||||
): boolean {
|
||||
const targetColumns = GRID_BREAKPOINTS[currentMode].columns;
|
||||
const sortedRows = Object.keys(rowGroups).map(Number).sort((a, b) => a - b);
|
||||
|
||||
// 12칸 모드는 기본 모드이므로 검토 불필요
|
||||
if (targetColumns === 12) {
|
||||
return false;
|
||||
for (const rowKey of sortedRows) {
|
||||
const group = rowGroups[rowKey].sort((a, b) => a.position.col - b.position.col);
|
||||
let currentCol = 1;
|
||||
let maxRowSpanInLine = 0;
|
||||
|
||||
for (const comp of group) {
|
||||
const pos = comp.position;
|
||||
const isMainContent = pos.colSpan >= designColumns * 0.5;
|
||||
|
||||
let scaledSpan = isMainContent
|
||||
? targetColumns
|
||||
: Math.max(MIN_COL_SPAN, Math.round(pos.colSpan * ratio));
|
||||
scaledSpan = Math.min(scaledSpan, targetColumns);
|
||||
|
||||
const scaledRowSpan = Math.max(MIN_ROW_SPAN, pos.rowSpan);
|
||||
|
||||
if (currentCol + scaledSpan - 1 > targetColumns) {
|
||||
outputRow += Math.max(1, maxRowSpanInLine);
|
||||
currentCol = 1;
|
||||
maxRowSpanInLine = 0;
|
||||
}
|
||||
|
||||
placed.push({
|
||||
id: comp.id,
|
||||
position: {
|
||||
col: currentCol,
|
||||
row: outputRow,
|
||||
colSpan: scaledSpan,
|
||||
rowSpan: scaledRowSpan,
|
||||
},
|
||||
});
|
||||
|
||||
maxRowSpanInLine = Math.max(maxRowSpanInLine, scaledRowSpan);
|
||||
currentCol += scaledSpan;
|
||||
}
|
||||
|
||||
outputRow += Math.max(1, maxRowSpanInLine);
|
||||
}
|
||||
|
||||
// 오버라이드가 있으면 이미 편집함 → 검토 완료
|
||||
if (hasOverride) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 오버라이드 없으면 → 검토 필요
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated v5.1부터 needsReview() 사용 권장
|
||||
*
|
||||
* 기존 isOutOfBounds는 "화면 밖" 개념이었으나,
|
||||
* v5.1 자동 줄바꿈으로 인해 모든 컴포넌트가 그리드 안에 배치됩니다.
|
||||
* 대신 needsReview()로 "검토 필요" 여부를 판별하세요.
|
||||
*/
|
||||
export function isOutOfBounds(
|
||||
originalPosition: PopGridPosition,
|
||||
currentMode: GridMode,
|
||||
overridePosition?: PopGridPosition | null
|
||||
): boolean {
|
||||
const targetColumns = GRID_BREAKPOINTS[currentMode].columns;
|
||||
|
||||
// 12칸 모드면 초과 불가
|
||||
if (targetColumns === 12) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 오버라이드가 있으면 오버라이드 위치로 판단
|
||||
if (overridePosition) {
|
||||
return overridePosition.col > targetColumns;
|
||||
}
|
||||
|
||||
// 오버라이드 없으면 원본 col로 판단
|
||||
return originalPosition.col > targetColumns;
|
||||
return resolveOverlaps(placed, targetColumns);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 겹침 감지 및 해결
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 두 위치가 겹치는지 확인
|
||||
*/
|
||||
export function isOverlapping(a: PopGridPosition, b: PopGridPosition): boolean {
|
||||
// 열 겹침 체크
|
||||
const aColEnd = a.col + a.colSpan - 1;
|
||||
const bColEnd = b.col + b.colSpan - 1;
|
||||
const colOverlap = !(aColEnd < b.col || bColEnd < a.col);
|
||||
|
||||
// 행 겹침 체크
|
||||
const aRowEnd = a.row + a.rowSpan - 1;
|
||||
const bRowEnd = b.row + b.rowSpan - 1;
|
||||
const rowOverlap = !(aRowEnd < b.row || bRowEnd < a.row);
|
||||
|
|
@ -219,14 +108,10 @@ export function isOverlapping(a: PopGridPosition, b: PopGridPosition): boolean {
|
|||
return colOverlap && rowOverlap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 겹침 해결 (아래로 밀기)
|
||||
*/
|
||||
export function resolveOverlaps(
|
||||
positions: Array<{ id: string; position: PopGridPosition }>,
|
||||
columns: number
|
||||
): Array<{ id: string; position: PopGridPosition }> {
|
||||
// row, col 순으로 정렬
|
||||
const sorted = [...positions].sort((a, b) =>
|
||||
a.position.row - b.position.row || a.position.col - b.position.col
|
||||
);
|
||||
|
|
@ -236,21 +121,15 @@ export function resolveOverlaps(
|
|||
sorted.forEach((item) => {
|
||||
let { row, col, colSpan, rowSpan } = item.position;
|
||||
|
||||
// 열이 범위를 초과하면 조정
|
||||
if (col + colSpan - 1 > columns) {
|
||||
colSpan = columns - col + 1;
|
||||
}
|
||||
|
||||
// 기존 배치와 겹치면 아래로 이동
|
||||
let attempts = 0;
|
||||
const maxAttempts = 100;
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
while (attempts < 100) {
|
||||
const currentPos: PopGridPosition = { col, row, colSpan, rowSpan };
|
||||
const hasOverlap = resolved.some(r => isOverlapping(currentPos, r.position));
|
||||
|
||||
if (!hasOverlap) break;
|
||||
|
||||
row++;
|
||||
attempts++;
|
||||
}
|
||||
|
|
@ -265,124 +144,9 @@ export function resolveOverlaps(
|
|||
}
|
||||
|
||||
// ========================================
|
||||
// 좌표 변환
|
||||
// 자동 배치 (새 컴포넌트 드롭 시)
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 마우스 좌표 → 그리드 좌표 변환
|
||||
*
|
||||
* CSS Grid 계산 방식:
|
||||
* - 사용 가능 너비 = 캔버스 너비 - 패딩*2 - gap*(columns-1)
|
||||
* - 각 칸 너비 = 사용 가능 너비 / columns
|
||||
* - 셀 N의 시작 X = padding + (N-1) * (칸너비 + gap)
|
||||
*/
|
||||
export function mouseToGridPosition(
|
||||
mouseX: number,
|
||||
mouseY: number,
|
||||
canvasRect: DOMRect,
|
||||
columns: number,
|
||||
rowHeight: number,
|
||||
gap: number,
|
||||
padding: number
|
||||
): { col: number; row: number } {
|
||||
// 캔버스 내 상대 위치 (패딩 영역 포함)
|
||||
const relX = mouseX - canvasRect.left - padding;
|
||||
const relY = mouseY - canvasRect.top - padding;
|
||||
|
||||
// CSS Grid 1fr 계산과 동일하게
|
||||
// 사용 가능 너비 = 전체 너비 - 양쪽 패딩 - (칸 사이 gap)
|
||||
const availableWidth = canvasRect.width - padding * 2 - gap * (columns - 1);
|
||||
const colWidth = availableWidth / columns;
|
||||
|
||||
// 각 셀의 실제 간격 (셀 너비 + gap)
|
||||
const cellStride = colWidth + gap;
|
||||
|
||||
// 그리드 좌표 계산 (1부터 시작)
|
||||
// relX를 cellStride로 나누면 몇 번째 칸인지 알 수 있음
|
||||
const col = Math.max(1, Math.min(columns, Math.floor(relX / cellStride) + 1));
|
||||
const row = Math.max(1, Math.floor(relY / (rowHeight + gap)) + 1);
|
||||
|
||||
return { col, row };
|
||||
}
|
||||
|
||||
/**
|
||||
* 그리드 좌표 → 픽셀 좌표 변환
|
||||
*/
|
||||
export function gridToPixelPosition(
|
||||
col: number,
|
||||
row: number,
|
||||
colSpan: number,
|
||||
rowSpan: number,
|
||||
canvasWidth: number,
|
||||
columns: number,
|
||||
rowHeight: number,
|
||||
gap: number,
|
||||
padding: number
|
||||
): { x: number; y: number; width: number; height: number } {
|
||||
const totalGap = gap * (columns - 1);
|
||||
const colWidth = (canvasWidth - padding * 2 - totalGap) / columns;
|
||||
|
||||
return {
|
||||
x: padding + (col - 1) * (colWidth + gap),
|
||||
y: padding + (row - 1) * (rowHeight + gap),
|
||||
width: colWidth * colSpan + gap * (colSpan - 1),
|
||||
height: rowHeight * rowSpan + gap * (rowSpan - 1),
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 위치 검증
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 위치가 그리드 범위 내에 있는지 확인
|
||||
*/
|
||||
export function isValidPosition(
|
||||
position: PopGridPosition,
|
||||
columns: number
|
||||
): boolean {
|
||||
return (
|
||||
position.col >= 1 &&
|
||||
position.row >= 1 &&
|
||||
position.colSpan >= 1 &&
|
||||
position.rowSpan >= 1 &&
|
||||
position.col + position.colSpan - 1 <= columns
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 위치를 그리드 범위 내로 조정
|
||||
*/
|
||||
export function clampPosition(
|
||||
position: PopGridPosition,
|
||||
columns: number
|
||||
): PopGridPosition {
|
||||
let { col, row, colSpan, rowSpan } = position;
|
||||
|
||||
// 최소값 보장
|
||||
col = Math.max(1, col);
|
||||
row = Math.max(1, row);
|
||||
colSpan = Math.max(1, colSpan);
|
||||
rowSpan = Math.max(1, rowSpan);
|
||||
|
||||
// 열 범위 초과 방지
|
||||
if (col + colSpan - 1 > columns) {
|
||||
if (col > columns) {
|
||||
col = 1;
|
||||
}
|
||||
colSpan = columns - col + 1;
|
||||
}
|
||||
|
||||
return { col, row, colSpan, rowSpan };
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 자동 배치
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 다음 빈 위치 찾기
|
||||
*/
|
||||
export function findNextEmptyPosition(
|
||||
existingPositions: PopGridPosition[],
|
||||
colSpan: number,
|
||||
|
|
@ -391,168 +155,94 @@ export function findNextEmptyPosition(
|
|||
): PopGridPosition {
|
||||
let row = 1;
|
||||
let col = 1;
|
||||
|
||||
const maxAttempts = 1000;
|
||||
let attempts = 0;
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
while (attempts < 1000) {
|
||||
const candidatePos: PopGridPosition = { col, row, colSpan, rowSpan };
|
||||
|
||||
// 범위 체크
|
||||
if (col + colSpan - 1 > columns) {
|
||||
col = 1;
|
||||
row++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 겹침 체크
|
||||
const hasOverlap = existingPositions.some(pos =>
|
||||
isOverlapping(candidatePos, pos)
|
||||
);
|
||||
const hasOverlap = existingPositions.some(pos => isOverlapping(candidatePos, pos));
|
||||
if (!hasOverlap) return candidatePos;
|
||||
|
||||
if (!hasOverlap) {
|
||||
return candidatePos;
|
||||
}
|
||||
|
||||
// 다음 위치로 이동
|
||||
col++;
|
||||
if (col + colSpan - 1 > columns) {
|
||||
col = 1;
|
||||
row++;
|
||||
}
|
||||
|
||||
attempts++;
|
||||
}
|
||||
|
||||
// 실패 시 마지막 행에 배치
|
||||
return { col: 1, row: row + 1, colSpan, rowSpan };
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트들을 자동으로 배치
|
||||
*/
|
||||
export function autoLayoutComponents(
|
||||
components: Array<{ id: string; colSpan: number; rowSpan: number }>,
|
||||
columns: number
|
||||
): Array<{ id: string; position: PopGridPosition }> {
|
||||
const result: Array<{ id: string; position: PopGridPosition }> = [];
|
||||
|
||||
let currentRow = 1;
|
||||
let currentCol = 1;
|
||||
|
||||
components.forEach(comp => {
|
||||
// 현재 행에 공간이 부족하면 다음 행으로
|
||||
if (currentCol + comp.colSpan - 1 > columns) {
|
||||
currentRow++;
|
||||
currentCol = 1;
|
||||
}
|
||||
|
||||
result.push({
|
||||
id: comp.id,
|
||||
position: {
|
||||
col: currentCol,
|
||||
row: currentRow,
|
||||
colSpan: comp.colSpan,
|
||||
rowSpan: comp.rowSpan,
|
||||
},
|
||||
});
|
||||
|
||||
currentCol += comp.colSpan;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 유효 위치 계산 (통합 함수)
|
||||
// 유효 위치 계산
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 컴포넌트의 유효 위치를 계산합니다.
|
||||
* 컴포넌트의 유효 위치를 계산한다.
|
||||
* 우선순위: 1. 오버라이드 → 2. 자동 재배치 → 3. 원본 위치
|
||||
*
|
||||
* @param componentId 컴포넌트 ID
|
||||
* @param layout 전체 레이아웃 데이터
|
||||
* @param mode 현재 그리드 모드
|
||||
* @param autoResolvedPositions 미리 계산된 자동 재배치 위치 (선택적)
|
||||
*/
|
||||
export function getEffectiveComponentPosition(
|
||||
function getEffectiveComponentPosition(
|
||||
componentId: string,
|
||||
layout: PopLayoutDataV5,
|
||||
layout: PopLayoutData,
|
||||
mode: GridMode,
|
||||
autoResolvedPositions?: Array<{ id: string; position: PopGridPosition }>
|
||||
): PopGridPosition | null {
|
||||
const component = layout.components[componentId];
|
||||
if (!component) return null;
|
||||
|
||||
// 1순위: 오버라이드가 있으면 사용
|
||||
const override = layout.overrides?.[mode]?.positions?.[componentId];
|
||||
if (override) {
|
||||
return { ...component.position, ...override };
|
||||
}
|
||||
|
||||
// 2순위: 자동 재배치된 위치 사용
|
||||
if (autoResolvedPositions) {
|
||||
const autoResolved = autoResolvedPositions.find(p => p.id === componentId);
|
||||
if (autoResolved) {
|
||||
return autoResolved.position;
|
||||
}
|
||||
if (autoResolved) return autoResolved.position;
|
||||
} else {
|
||||
// 자동 재배치 직접 계산
|
||||
const componentsArray = Object.entries(layout.components).map(([id, comp]) => ({
|
||||
id,
|
||||
position: comp.position,
|
||||
}));
|
||||
const resolved = convertAndResolvePositions(componentsArray, mode);
|
||||
const autoResolved = resolved.find(p => p.id === componentId);
|
||||
if (autoResolved) {
|
||||
return autoResolved.position;
|
||||
}
|
||||
if (autoResolved) return autoResolved.position;
|
||||
}
|
||||
|
||||
// 3순위: 원본 위치 (12칸 모드)
|
||||
return component.position;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 컴포넌트의 유효 위치를 일괄 계산합니다.
|
||||
* 숨김 처리된 컴포넌트는 제외됩니다.
|
||||
*
|
||||
* v5.1: 자동 줄바꿈 시스템으로 인해 모든 컴포넌트가 그리드 안에 배치되므로
|
||||
* "화면 밖" 개념이 제거되었습니다.
|
||||
* 모든 컴포넌트의 유효 위치를 일괄 계산한다.
|
||||
* 숨김 처리된 컴포넌트는 제외.
|
||||
*/
|
||||
export function getAllEffectivePositions(
|
||||
layout: PopLayoutDataV5,
|
||||
layout: PopLayoutData,
|
||||
mode: GridMode
|
||||
): Map<string, PopGridPosition> {
|
||||
const result = new Map<string, PopGridPosition>();
|
||||
|
||||
// 숨김 처리된 컴포넌트 ID 목록
|
||||
const hiddenIds = layout.overrides?.[mode]?.hidden || [];
|
||||
|
||||
// 자동 재배치 위치 미리 계산
|
||||
const componentsArray = Object.entries(layout.components).map(([id, comp]) => ({
|
||||
id,
|
||||
position: comp.position,
|
||||
}));
|
||||
const autoResolvedPositions = convertAndResolvePositions(componentsArray, mode);
|
||||
|
||||
// 각 컴포넌트의 유효 위치 계산
|
||||
Object.keys(layout.components).forEach(componentId => {
|
||||
// 숨김 처리된 컴포넌트는 제외
|
||||
if (hiddenIds.includes(componentId)) {
|
||||
return;
|
||||
}
|
||||
if (hiddenIds.includes(componentId)) return;
|
||||
|
||||
const position = getEffectiveComponentPosition(
|
||||
componentId,
|
||||
layout,
|
||||
mode,
|
||||
autoResolvedPositions
|
||||
componentId, layout, mode, autoResolvedPositions
|
||||
);
|
||||
|
||||
// v5.1: 자동 줄바꿈으로 인해 모든 컴포넌트가 그리드 안에 있음
|
||||
// 따라서 추가 필터링 불필요
|
||||
if (position) {
|
||||
result.set(componentId, position);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,128 @@
|
|||
// 레거시 레이아웃 로더
|
||||
// DB에 저장된 V5(12칸) 좌표를 현재 블록 좌표로 변환한다.
|
||||
// DB 데이터는 건드리지 않고, 로드 시 메모리에서만 변환.
|
||||
|
||||
import {
|
||||
PopGridPosition,
|
||||
PopLayoutData,
|
||||
BLOCK_SIZE,
|
||||
BLOCK_GAP,
|
||||
BLOCK_PADDING,
|
||||
getBlockColumns,
|
||||
} from "../types/pop-layout";
|
||||
|
||||
const LEGACY_COLUMNS = 12;
|
||||
const LEGACY_ROW_HEIGHT = 48;
|
||||
const LEGACY_GAP = 16;
|
||||
const DESIGN_WIDTH = 1024;
|
||||
|
||||
function isLegacyGridConfig(layout: PopLayoutData): boolean {
|
||||
if (layout.gridConfig?.rowHeight === BLOCK_SIZE) return false;
|
||||
|
||||
const maxCol = Object.values(layout.components).reduce((max, comp) => {
|
||||
const end = comp.position.col + comp.position.colSpan - 1;
|
||||
return Math.max(max, end);
|
||||
}, 0);
|
||||
|
||||
return maxCol <= LEGACY_COLUMNS;
|
||||
}
|
||||
|
||||
function convertLegacyPosition(
|
||||
pos: PopGridPosition,
|
||||
targetColumns: number,
|
||||
): PopGridPosition {
|
||||
const colRatio = targetColumns / LEGACY_COLUMNS;
|
||||
const rowRatio = (LEGACY_ROW_HEIGHT + LEGACY_GAP) / (BLOCK_SIZE + BLOCK_GAP);
|
||||
|
||||
const newCol = Math.max(1, Math.round((pos.col - 1) * colRatio) + 1);
|
||||
let newColSpan = Math.max(1, Math.round(pos.colSpan * colRatio));
|
||||
const newRowSpan = Math.max(1, Math.round(pos.rowSpan * rowRatio));
|
||||
|
||||
if (newCol + newColSpan - 1 > targetColumns) {
|
||||
newColSpan = targetColumns - newCol + 1;
|
||||
}
|
||||
|
||||
return { col: newCol, row: pos.row, colSpan: newColSpan, rowSpan: newRowSpan };
|
||||
}
|
||||
|
||||
const BLOCK_GRID_CONFIG = {
|
||||
rowHeight: BLOCK_SIZE,
|
||||
gap: BLOCK_GAP,
|
||||
padding: BLOCK_PADDING,
|
||||
};
|
||||
|
||||
/**
|
||||
* DB에서 로드한 레이아웃을 현재 블록 좌표로 변환한다.
|
||||
*
|
||||
* - 12칸 레거시 좌표 → 블록 좌표 변환
|
||||
* - 이미 블록 좌표인 경우 → gridConfig만 보정
|
||||
* - 구 모드별 overrides는 항상 제거 (리플로우가 대체)
|
||||
*/
|
||||
export function loadLegacyLayout(layout: PopLayoutData): PopLayoutData {
|
||||
if (!isLegacyGridConfig(layout)) {
|
||||
return {
|
||||
...layout,
|
||||
gridConfig: BLOCK_GRID_CONFIG,
|
||||
overrides: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const blockColumns = getBlockColumns(DESIGN_WIDTH);
|
||||
|
||||
const rowGroups: Record<number, string[]> = {};
|
||||
Object.entries(layout.components).forEach(([id, comp]) => {
|
||||
const r = comp.position.row;
|
||||
if (!rowGroups[r]) rowGroups[r] = [];
|
||||
rowGroups[r].push(id);
|
||||
});
|
||||
|
||||
const convertedPositions: Record<string, PopGridPosition> = {};
|
||||
Object.entries(layout.components).forEach(([id, comp]) => {
|
||||
convertedPositions[id] = convertLegacyPosition(comp.position, blockColumns);
|
||||
});
|
||||
|
||||
const sortedRows = Object.keys(rowGroups).map(Number).sort((a, b) => a - b);
|
||||
const rowMapping: Record<number, number> = {};
|
||||
let currentRow = 1;
|
||||
for (const legacyRow of sortedRows) {
|
||||
rowMapping[legacyRow] = currentRow;
|
||||
const maxSpan = Math.max(
|
||||
...rowGroups[legacyRow].map(id => convertedPositions[id].rowSpan)
|
||||
);
|
||||
currentRow += maxSpan;
|
||||
}
|
||||
|
||||
const newComponents = { ...layout.components };
|
||||
Object.entries(newComponents).forEach(([id, comp]) => {
|
||||
const converted = convertedPositions[id];
|
||||
const mappedRow = rowMapping[comp.position.row] ?? converted.row;
|
||||
newComponents[id] = {
|
||||
...comp,
|
||||
position: { ...converted, row: mappedRow },
|
||||
};
|
||||
});
|
||||
|
||||
const newModals = layout.modals?.map(modal => {
|
||||
const modalComps = { ...modal.components };
|
||||
Object.entries(modalComps).forEach(([id, comp]) => {
|
||||
modalComps[id] = {
|
||||
...comp,
|
||||
position: convertLegacyPosition(comp.position, blockColumns),
|
||||
};
|
||||
});
|
||||
return {
|
||||
...modal,
|
||||
gridConfig: BLOCK_GRID_CONFIG,
|
||||
components: modalComps,
|
||||
overrides: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...layout,
|
||||
gridConfig: BLOCK_GRID_CONFIG,
|
||||
components: newComponents,
|
||||
overrides: undefined,
|
||||
modals: newModals,
|
||||
};
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@ import {
|
|||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import PopRenderer from "../designer/renderers/PopRenderer";
|
||||
import type { PopLayoutDataV5, PopModalDefinition, GridMode } from "../designer/types/pop-layout";
|
||||
import type { PopLayoutData, PopModalDefinition, GridMode } from "../designer/types/pop-layout";
|
||||
import { detectGridMode, resolveModalWidth } from "../designer/types/pop-layout";
|
||||
import { usePopEvent } from "@/hooks/pop/usePopEvent";
|
||||
import { useConnectionResolver } from "@/hooks/pop/useConnectionResolver";
|
||||
|
|
@ -31,7 +31,7 @@ import { useConnectionResolver } from "@/hooks/pop/useConnectionResolver";
|
|||
|
||||
interface PopViewerWithModalsProps {
|
||||
/** 전체 레이아웃 (모달 정의 포함) */
|
||||
layout: PopLayoutDataV5;
|
||||
layout: PopLayoutData;
|
||||
/** 뷰포트 너비 */
|
||||
viewportWidth: number;
|
||||
/** 화면 ID (이벤트 버스용) */
|
||||
|
|
@ -42,12 +42,15 @@ interface PopViewerWithModalsProps {
|
|||
overrideGap?: number;
|
||||
/** Padding 오버라이드 */
|
||||
overridePadding?: number;
|
||||
/** 부모 화면에서 선택된 행 데이터 (모달 내부 컴포넌트가 sharedData로 조회) */
|
||||
parentRow?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 열린 모달 상태 */
|
||||
interface OpenModal {
|
||||
definition: PopModalDefinition;
|
||||
returnTo?: string;
|
||||
fullscreen?: boolean;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
|
|
@ -61,10 +64,17 @@ export default function PopViewerWithModals({
|
|||
currentMode,
|
||||
overrideGap,
|
||||
overridePadding,
|
||||
parentRow,
|
||||
}: PopViewerWithModalsProps) {
|
||||
const router = useRouter();
|
||||
const [modalStack, setModalStack] = useState<OpenModal[]>([]);
|
||||
const { subscribe, publish } = usePopEvent(screenId);
|
||||
const { subscribe, publish, setSharedData } = usePopEvent(screenId);
|
||||
|
||||
useEffect(() => {
|
||||
if (parentRow) {
|
||||
setSharedData("parentRow", parentRow);
|
||||
}
|
||||
}, [parentRow, setSharedData]);
|
||||
|
||||
// 연결 해석기: layout에 정의된 connections를 이벤트 라우팅으로 변환
|
||||
const stableConnections = useMemo(
|
||||
|
|
@ -96,6 +106,7 @@ export default function PopViewerWithModals({
|
|||
title?: string;
|
||||
mode?: string;
|
||||
returnTo?: string;
|
||||
fullscreen?: boolean;
|
||||
};
|
||||
|
||||
if (data?.modalId) {
|
||||
|
|
@ -104,6 +115,7 @@ export default function PopViewerWithModals({
|
|||
setModalStack(prev => [...prev, {
|
||||
definition: modalDef,
|
||||
returnTo: data.returnTo,
|
||||
fullscreen: data.fullscreen,
|
||||
}]);
|
||||
}
|
||||
}
|
||||
|
|
@ -173,22 +185,27 @@ export default function PopViewerWithModals({
|
|||
|
||||
{/* 모달 스택 렌더링 */}
|
||||
{modalStack.map((modal, index) => {
|
||||
const { definition } = modal;
|
||||
const { definition, fullscreen } = modal;
|
||||
const isTopModal = index === modalStack.length - 1;
|
||||
const closeOnOverlay = definition.frameConfig?.closeOnOverlay !== false;
|
||||
const closeOnEsc = definition.frameConfig?.closeOnEsc !== false;
|
||||
|
||||
const modalLayout: PopLayoutDataV5 = {
|
||||
const modalLayout: PopLayoutData = {
|
||||
...layout,
|
||||
gridConfig: definition.gridConfig,
|
||||
components: definition.components,
|
||||
overrides: definition.overrides,
|
||||
};
|
||||
|
||||
const detectedMode = currentMode || detectGridMode(viewportWidth);
|
||||
const modalWidth = resolveModalWidth(definition.sizeConfig, detectedMode, viewportWidth);
|
||||
const isFull = modalWidth >= viewportWidth;
|
||||
const rendererWidth = isFull ? viewportWidth : modalWidth - 32;
|
||||
const isFull = fullscreen || (() => {
|
||||
const detectedMode = currentMode || detectGridMode(viewportWidth);
|
||||
const modalWidth = resolveModalWidth(definition.sizeConfig, detectedMode, viewportWidth);
|
||||
return modalWidth >= viewportWidth;
|
||||
})();
|
||||
const rendererWidth = isFull
|
||||
? viewportWidth
|
||||
: resolveModalWidth(definition.sizeConfig, currentMode || detectGridMode(viewportWidth), viewportWidth) - 32;
|
||||
const modalWidth = isFull ? viewportWidth : resolveModalWidth(definition.sizeConfig, currentMode || detectGridMode(viewportWidth), viewportWidth);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
|
|
@ -200,7 +217,7 @@ export default function PopViewerWithModals({
|
|||
>
|
||||
<DialogContent
|
||||
className={isFull
|
||||
? "h-dvh max-h-dvh w-screen max-w-[100vw] overflow-auto rounded-none border-none p-0"
|
||||
? "flex h-dvh max-h-dvh w-screen max-w-[100vw] flex-col gap-0 overflow-hidden rounded-none border-none p-0"
|
||||
: "max-h-[90vh] overflow-auto p-0"
|
||||
}
|
||||
style={isFull ? undefined : {
|
||||
|
|
@ -208,14 +225,13 @@ export default function PopViewerWithModals({
|
|||
width: `${modalWidth}px`,
|
||||
}}
|
||||
onInteractOutside={(e) => {
|
||||
// 최상위 모달이 아니면 overlay 클릭 무시 (하위 모달이 먼저 닫히는 것 방지)
|
||||
if (!isTopModal || !closeOnOverlay) e.preventDefault();
|
||||
}}
|
||||
onEscapeKeyDown={(e) => {
|
||||
if (!isTopModal || !closeOnEsc) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<DialogHeader className={isFull ? "px-4 pt-3 pb-2" : "px-4 pt-4 pb-2"}>
|
||||
<DialogHeader className={isFull ? "shrink-0 border-b px-4 py-2" : "px-4 pt-4 pb-2"}>
|
||||
<DialogTitle className="text-base">
|
||||
{definition.title}
|
||||
</DialogTitle>
|
||||
|
|
|
|||
|
|
@ -1165,6 +1165,28 @@ export default function CopyScreenModal({
|
|||
}
|
||||
}
|
||||
|
||||
// 그룹 복제 요약 감사 로그 1건 기록
|
||||
try {
|
||||
await apiClient.post("/audit-log", {
|
||||
action: "COPY",
|
||||
resourceType: "SCREEN",
|
||||
resourceId: String(sourceGroup.id),
|
||||
resourceName: sourceGroup.group_name,
|
||||
summary: `그룹 "${sourceGroup.group_name}" → "${rootGroupName}" 복제 (그룹 ${stats.groups}개, 화면 ${stats.screens}개)${finalCompanyCode !== sourceGroup.company_code ? ` [${sourceGroup.company_code} → ${finalCompanyCode}]` : ""}`,
|
||||
changes: {
|
||||
after: {
|
||||
원본그룹: sourceGroup.group_name,
|
||||
대상그룹: rootGroupName,
|
||||
복제그룹수: stats.groups,
|
||||
복제화면수: stats.screens,
|
||||
대상회사: finalCompanyCode,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (auditError) {
|
||||
console.warn("그룹 복제 감사 로그 기록 실패 (무시):", auditError);
|
||||
}
|
||||
|
||||
toast.success(
|
||||
`그룹 복제가 완료되었습니다! (그룹 ${stats.groups}개 + 화면 ${stats.screens}개)`
|
||||
);
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ interface RealtimePreviewProps {
|
|||
selectedTabComponentId?: string; // 🆕 선택된 탭 컴포넌트 ID
|
||||
onSelectPanelComponent?: (panelSide: "left" | "right", compId: string, comp: any) => void; // 🆕 분할 패널 내부 컴포넌트 선택 콜백
|
||||
selectedPanelComponentId?: string; // 🆕 선택된 분할 패널 컴포넌트 ID
|
||||
onNestedPanelSelect?: (splitPanelId: string, panelSide: "left" | "right", compId: string, comp: any) => void;
|
||||
onResize?: (componentId: string, newSize: { width: number; height: number }) => void; // 🆕 리사이즈 콜백
|
||||
|
||||
// 버튼 액션을 위한 props
|
||||
|
|
@ -150,6 +151,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
|||
selectedTabComponentId, // 🆕 선택된 탭 컴포넌트 ID
|
||||
onSelectPanelComponent, // 🆕 분할 패널 내부 컴포넌트 선택 콜백
|
||||
selectedPanelComponentId, // 🆕 선택된 분할 패널 컴포넌트 ID
|
||||
onNestedPanelSelect,
|
||||
onResize, // 🆕 리사이즈 콜백
|
||||
}) => {
|
||||
// 🆕 화면 다국어 컨텍스트
|
||||
|
|
@ -581,7 +583,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
|||
const needsStripBorder = isV2HorizLabel || isButtonComponent;
|
||||
const safeComponentStyle = needsStripBorder
|
||||
? (() => {
|
||||
const { borderWidth, borderColor, borderStyle, border, borderRadius, ...rest } = componentStyle as any;
|
||||
const { borderWidth, borderColor, borderStyle, border, ...rest } = componentStyle as any;
|
||||
return rest;
|
||||
})()
|
||||
: componentStyle;
|
||||
|
|
@ -768,10 +770,11 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
|||
selectedTabComponentId={selectedTabComponentId}
|
||||
onSelectPanelComponent={onSelectPanelComponent}
|
||||
selectedPanelComponentId={selectedPanelComponentId}
|
||||
onNestedPanelSelect={onNestedPanelSelect}
|
||||
/>
|
||||
</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" && (
|
||||
|
|
@ -782,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>
|
||||
|
|
|
|||
|
|
@ -109,6 +109,8 @@ interface ProcessedRow {
|
|||
mainComponent?: ComponentData;
|
||||
overlayComps: ComponentData[];
|
||||
normalComps: ComponentData[];
|
||||
rowMinY?: number;
|
||||
rowMaxBottom?: number;
|
||||
}
|
||||
|
||||
function FullWidthOverlayRow({
|
||||
|
|
@ -202,6 +204,66 @@ function FullWidthOverlayRow({
|
|||
);
|
||||
}
|
||||
|
||||
function ProportionalRenderer({
|
||||
components,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
renderComponent,
|
||||
}: ResponsiveGridRendererProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerW, setContainerW] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const w = entries[0]?.contentRect.width;
|
||||
if (w && w > 0) setContainerW(w);
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const topLevel = components.filter((c) => !c.parentId);
|
||||
const ratio = containerW > 0 ? containerW / canvasWidth : 1;
|
||||
|
||||
const maxBottom = topLevel.reduce((max, c) => {
|
||||
const bottom = c.position.y + (c.size?.height || 40);
|
||||
return Math.max(max, bottom);
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-screen-runtime="true"
|
||||
className="bg-background relative w-full overflow-x-hidden"
|
||||
style={{ minHeight: containerW > 0 ? `${maxBottom * ratio}px` : "200px" }}
|
||||
>
|
||||
{containerW > 0 &&
|
||||
topLevel.map((component) => {
|
||||
const typeId = getComponentTypeId(component);
|
||||
return (
|
||||
<div
|
||||
key={component.id}
|
||||
data-component-id={component.id}
|
||||
data-component-type={typeId}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${(component.position.x / canvasWidth) * 100}%`,
|
||||
top: `${component.position.y * ratio}px`,
|
||||
width: `${((component.size?.width || 100) / canvasWidth) * 100}%`,
|
||||
height: `${(component.size?.height || 40) * ratio}px`,
|
||||
zIndex: component.position.z || 1,
|
||||
}}
|
||||
>
|
||||
{renderComponent(component)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResponsiveGridRenderer({
|
||||
components,
|
||||
canvasWidth,
|
||||
|
|
@ -211,6 +273,18 @@ export function ResponsiveGridRenderer({
|
|||
const { isMobile } = useResponsive();
|
||||
|
||||
const topLevel = components.filter((c) => !c.parentId);
|
||||
const hasFullWidthComponent = topLevel.some((c) => isFullWidthComponent(c));
|
||||
|
||||
if (!isMobile && !hasFullWidthComponent) {
|
||||
return (
|
||||
<ProportionalRenderer
|
||||
components={components}
|
||||
canvasWidth={canvasWidth}
|
||||
canvasHeight={canvasHeight}
|
||||
renderComponent={renderComponent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const rows = groupComponentsIntoRows(topLevel);
|
||||
const processedRows: ProcessedRow[] = [];
|
||||
|
|
@ -227,6 +301,10 @@ export function ResponsiveGridRenderer({
|
|||
}
|
||||
}
|
||||
|
||||
const allComps = [...fullWidthComps, ...normalComps];
|
||||
const rowMinY = allComps.length > 0 ? Math.min(...allComps.map(c => c.position.y)) : 0;
|
||||
const rowMaxBottom = allComps.length > 0 ? Math.max(...allComps.map(c => c.position.y + (c.size?.height || 40))) : 0;
|
||||
|
||||
if (fullWidthComps.length > 0 && normalComps.length > 0) {
|
||||
for (const fwComp of fullWidthComps) {
|
||||
processedRows.push({
|
||||
|
|
@ -234,6 +312,8 @@ export function ResponsiveGridRenderer({
|
|||
mainComponent: fwComp,
|
||||
overlayComps: normalComps,
|
||||
normalComps: [],
|
||||
rowMinY,
|
||||
rowMaxBottom,
|
||||
});
|
||||
}
|
||||
} else if (fullWidthComps.length > 0) {
|
||||
|
|
@ -243,6 +323,8 @@ export function ResponsiveGridRenderer({
|
|||
mainComponent: fwComp,
|
||||
overlayComps: [],
|
||||
normalComps: [],
|
||||
rowMinY,
|
||||
rowMaxBottom,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
|
|
@ -250,6 +332,8 @@ export function ResponsiveGridRenderer({
|
|||
type: "normal",
|
||||
overlayComps: [],
|
||||
normalComps,
|
||||
rowMinY,
|
||||
rowMaxBottom,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -261,21 +345,71 @@ export function ResponsiveGridRenderer({
|
|||
style={{ minHeight: "200px" }}
|
||||
>
|
||||
{processedRows.map((processedRow, rowIndex) => {
|
||||
const rowMarginTop = (() => {
|
||||
if (rowIndex === 0) return 0;
|
||||
const prevRow = processedRows[rowIndex - 1];
|
||||
const prevBottom = prevRow.rowMaxBottom ?? 0;
|
||||
const currTop = processedRow.rowMinY ?? 0;
|
||||
const designGap = currTop - prevBottom;
|
||||
if (designGap <= 0) return 0;
|
||||
return Math.min(Math.max(Math.round(designGap * 0.5), 4), 48);
|
||||
})();
|
||||
|
||||
if (processedRow.type === "fullwidth" && processedRow.mainComponent) {
|
||||
return (
|
||||
<FullWidthOverlayRow
|
||||
key={`row-${rowIndex}`}
|
||||
main={processedRow.mainComponent}
|
||||
overlayComps={processedRow.overlayComps}
|
||||
canvasWidth={canvasWidth}
|
||||
renderComponent={renderComponent}
|
||||
/>
|
||||
<div key={`row-${rowIndex}`} style={{ marginTop: rowMarginTop > 0 ? `${rowMarginTop}px` : undefined }}>
|
||||
<FullWidthOverlayRow
|
||||
main={processedRow.mainComponent}
|
||||
overlayComps={processedRow.overlayComps}
|
||||
canvasWidth={canvasWidth}
|
||||
renderComponent={renderComponent}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { normalComps } = processedRow;
|
||||
const allButtons = normalComps.every((c) => isButtonComponent(c));
|
||||
const gap = isMobile ? 8 : allButtons ? 8 : getRowGap(normalComps, canvasWidth);
|
||||
|
||||
// 데스크톱에서 버튼만 있는 행: 디자이너의 x, width를 비율로 적용
|
||||
if (allButtons && normalComps.length > 0 && !isMobile) {
|
||||
const rowHeight = Math.max(...normalComps.map(c => c.size?.height || 40));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`row-${rowIndex}`}
|
||||
className="relative w-full flex-shrink-0"
|
||||
style={{
|
||||
height: `${rowHeight}px`,
|
||||
marginTop: rowMarginTop > 0 ? `${rowMarginTop}px` : undefined,
|
||||
}}
|
||||
>
|
||||
{normalComps.map((component) => {
|
||||
const typeId = getComponentTypeId(component);
|
||||
const leftPct = (component.position.x / canvasWidth) * 100;
|
||||
const widthPct = ((component.size?.width || 90) / canvasWidth) * 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={component.id}
|
||||
data-component-id={component.id}
|
||||
data-component-type={typeId}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${leftPct}%`,
|
||||
width: `${widthPct}%`,
|
||||
height: `${component.size?.height || 40}px`,
|
||||
}}
|
||||
>
|
||||
{renderComponent(component)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const gap = isMobile ? 8 : getRowGap(normalComps, canvasWidth);
|
||||
|
||||
const hasFlexHeightComp = normalComps.some((c) => {
|
||||
const h = c.size?.height || 0;
|
||||
|
|
@ -287,10 +421,9 @@ export function ResponsiveGridRenderer({
|
|||
key={`row-${rowIndex}`}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap overflow-hidden",
|
||||
allButtons && "justify-end px-2 py-1",
|
||||
hasFlexHeightComp ? "min-h-0 flex-1" : "flex-shrink-0"
|
||||
)}
|
||||
style={{ gap: `${gap}px` }}
|
||||
style={{ gap: `${gap}px`, marginTop: rowMarginTop > 0 ? `${rowMarginTop}px` : undefined }}
|
||||
>
|
||||
{normalComps.map((component) => {
|
||||
const typeId = getComponentTypeId(component);
|
||||
|
|
@ -334,13 +467,13 @@ export function ResponsiveGridRenderer({
|
|||
style={{
|
||||
width: isFullWidth ? "100%" : undefined,
|
||||
flexBasis: useFlexHeight ? undefined : flexBasis,
|
||||
flexGrow: 1,
|
||||
flexGrow: percentWidth,
|
||||
flexShrink: 1,
|
||||
minWidth: isMobile ? "100%" : undefined,
|
||||
minHeight: useFlexHeight ? "300px" : undefined,
|
||||
height: useFlexHeight ? "100%" : (component.size?.height
|
||||
minHeight: useFlexHeight ? "300px" : (component.size?.height
|
||||
? `${component.size.height}px`
|
||||
: "auto"),
|
||||
: undefined),
|
||||
height: useFlexHeight ? "100%" : "auto",
|
||||
}}
|
||||
>
|
||||
{renderComponent(component)}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
@ -2861,9 +2870,190 @@ export default function ScreenDesigner({
|
|||
}
|
||||
}
|
||||
|
||||
// 🎯 탭 컨테이너 내부 드롭 처리 (중첩 구조 지원)
|
||||
// 🎯 컨테이너 드롭 우선순위: 가장 안쪽(innermost) 컨테이너 우선
|
||||
// 분할패널과 탭 둘 다 감지될 경우, DOM 트리에서 더 가까운 쪽을 우선 처리
|
||||
const tabsContainer = dropTarget.closest('[data-tabs-container="true"]');
|
||||
if (tabsContainer) {
|
||||
const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]');
|
||||
|
||||
// 분할패널이 탭보다 안쪽에 있으면 분할패널 우선 처리
|
||||
const splitPanelFirst =
|
||||
splitPanelContainer &&
|
||||
(!tabsContainer || tabsContainer.contains(splitPanelContainer));
|
||||
|
||||
if (splitPanelFirst && splitPanelContainer) {
|
||||
const containerId = splitPanelContainer.getAttribute("data-component-id");
|
||||
const panelSide = splitPanelContainer.getAttribute("data-panel-side");
|
||||
if (containerId && panelSide) {
|
||||
// 분할 패널을 최상위 또는 중첩(탭 안)에서 찾기
|
||||
let targetComponent: any = layout.components.find((c) => c.id === containerId);
|
||||
let parentTabsId: string | null = null;
|
||||
let parentTabId: string | null = null;
|
||||
let parentSplitId: string | null = null;
|
||||
let parentSplitSide: string | null = null;
|
||||
|
||||
if (!targetComponent) {
|
||||
// 탭 안에 중첩된 분할패널 찾기
|
||||
// top-level: overrides.type / overrides.tabs
|
||||
// nested: componentType / componentConfig.tabs
|
||||
for (const comp of layout.components) {
|
||||
const compType = (comp as any)?.componentType || (comp as any)?.overrides?.type;
|
||||
const compConfig = (comp as any)?.componentConfig || (comp as any)?.overrides || {};
|
||||
|
||||
if (compType === "tabs-widget" || compType === "v2-tabs-widget") {
|
||||
const tabs = compConfig.tabs || [];
|
||||
for (const tab of tabs) {
|
||||
const found = (tab.components || []).find((c: any) => c.id === containerId);
|
||||
if (found) {
|
||||
targetComponent = found;
|
||||
parentTabsId = comp.id;
|
||||
parentTabId = tab.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (targetComponent) break;
|
||||
}
|
||||
|
||||
if (compType === "split-panel-layout" || compType === "v2-split-panel-layout") {
|
||||
for (const side of ["leftPanel", "rightPanel"] as const) {
|
||||
const panelComps = compConfig[side]?.components || [];
|
||||
for (const pc of panelComps) {
|
||||
const pct = pc.componentType || pc.overrides?.type;
|
||||
if (pct === "tabs-widget" || pct === "v2-tabs-widget") {
|
||||
const tabs = (pc.componentConfig || pc.overrides || {}).tabs || [];
|
||||
for (const tab of tabs) {
|
||||
const found = (tab.components || []).find((c: any) => c.id === containerId);
|
||||
if (found) {
|
||||
targetComponent = found;
|
||||
parentSplitId = comp.id;
|
||||
parentSplitSide = side === "leftPanel" ? "left" : "right";
|
||||
parentTabsId = pc.id;
|
||||
parentTabId = tab.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (targetComponent) break;
|
||||
}
|
||||
}
|
||||
if (targetComponent) break;
|
||||
}
|
||||
if (targetComponent) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
const compType = (targetComponent as any)?.componentType;
|
||||
if (targetComponent && (compType === "split-panel-layout" || compType === "v2-split-panel-layout")) {
|
||||
const currentConfig = (targetComponent as any).componentConfig || {};
|
||||
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
|
||||
const panelConfig = currentConfig[panelKey] || {};
|
||||
const currentComponents = panelConfig.components || [];
|
||||
|
||||
const panelRect = splitPanelContainer.getBoundingClientRect();
|
||||
const cs1 = window.getComputedStyle(splitPanelContainer);
|
||||
const dropX = (e.clientX - panelRect.left - (parseFloat(cs1.paddingLeft) || 0)) / zoomLevel;
|
||||
const dropY = (e.clientY - panelRect.top - (parseFloat(cs1.paddingTop) || 0)) / zoomLevel;
|
||||
|
||||
const componentType = component.id || component.componentType || "v2-text-display";
|
||||
|
||||
const newPanelComponent = {
|
||||
id: `panel_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
componentType: componentType,
|
||||
label: component.name || component.label || "새 컴포넌트",
|
||||
position: { x: Math.max(0, dropX), y: Math.max(0, dropY) },
|
||||
size: component.defaultSize || { width: 200, height: 100 },
|
||||
componentConfig: component.defaultConfig || {},
|
||||
};
|
||||
|
||||
const updatedPanelConfig = {
|
||||
...panelConfig,
|
||||
components: [...currentComponents, newPanelComponent],
|
||||
};
|
||||
|
||||
const updatedSplitPanel = {
|
||||
...targetComponent,
|
||||
componentConfig: {
|
||||
...currentConfig,
|
||||
[panelKey]: updatedPanelConfig,
|
||||
},
|
||||
};
|
||||
|
||||
let newLayout;
|
||||
if (parentTabsId && parentTabId) {
|
||||
// 중첩: (최상위 분할패널 →) 탭 → 분할패널
|
||||
const updateTabsComponent = (tabsComp: any) => {
|
||||
const ck = tabsComp.componentConfig ? "componentConfig" : "overrides";
|
||||
const cfg = tabsComp[ck] || {};
|
||||
const tabs = cfg.tabs || [];
|
||||
return {
|
||||
...tabsComp,
|
||||
[ck]: {
|
||||
...cfg,
|
||||
tabs: tabs.map((tab: any) =>
|
||||
tab.id === parentTabId
|
||||
? {
|
||||
...tab,
|
||||
components: (tab.components || []).map((c: any) =>
|
||||
c.id === containerId ? updatedSplitPanel : c,
|
||||
),
|
||||
}
|
||||
: tab,
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
if (parentSplitId && parentSplitSide) {
|
||||
// 최상위 분할패널 → 탭 → 분할패널
|
||||
const pKey = parentSplitSide === "left" ? "leftPanel" : "rightPanel";
|
||||
newLayout = {
|
||||
...layout,
|
||||
components: layout.components.map((c) => {
|
||||
if (c.id === parentSplitId) {
|
||||
const sc = (c as any).componentConfig || {};
|
||||
return {
|
||||
...c,
|
||||
componentConfig: {
|
||||
...sc,
|
||||
[pKey]: {
|
||||
...sc[pKey],
|
||||
components: (sc[pKey]?.components || []).map((pc: any) =>
|
||||
pc.id === parentTabsId ? updateTabsComponent(pc) : pc,
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return c;
|
||||
}),
|
||||
};
|
||||
} else {
|
||||
// 최상위 탭 → 분할패널
|
||||
newLayout = {
|
||||
...layout,
|
||||
components: layout.components.map((c) =>
|
||||
c.id === parentTabsId ? updateTabsComponent(c) : c,
|
||||
),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// 최상위 분할패널
|
||||
newLayout = {
|
||||
...layout,
|
||||
components: layout.components.map((c) => (c.id === containerId ? updatedSplitPanel : c)),
|
||||
};
|
||||
}
|
||||
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
toast.success(`컴포넌트가 ${panelSide === "left" ? "좌측" : "우측"} 패널에 추가되었습니다`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tabsContainer && !splitPanelFirst) {
|
||||
const containerId = tabsContainer.getAttribute("data-component-id");
|
||||
const activeTabId = tabsContainer.getAttribute("data-active-tab-id");
|
||||
if (containerId && activeTabId) {
|
||||
|
|
@ -3004,69 +3194,6 @@ export default function ScreenDesigner({
|
|||
}
|
||||
}
|
||||
|
||||
// 🎯 분할 패널 커스텀 모드 컨테이너 내부 드롭 처리
|
||||
const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]');
|
||||
if (splitPanelContainer) {
|
||||
const containerId = splitPanelContainer.getAttribute("data-component-id");
|
||||
const panelSide = splitPanelContainer.getAttribute("data-panel-side"); // "left" or "right"
|
||||
if (containerId && panelSide) {
|
||||
const targetComponent = layout.components.find((c) => c.id === containerId);
|
||||
const compType = (targetComponent as any)?.componentType;
|
||||
if (targetComponent && (compType === "split-panel-layout" || compType === "v2-split-panel-layout")) {
|
||||
const currentConfig = (targetComponent as any).componentConfig || {};
|
||||
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
|
||||
const panelConfig = currentConfig[panelKey] || {};
|
||||
const currentComponents = panelConfig.components || [];
|
||||
|
||||
// 드롭 위치 계산
|
||||
const panelRect = splitPanelContainer.getBoundingClientRect();
|
||||
const dropX = (e.clientX - panelRect.left) / zoomLevel;
|
||||
const dropY = (e.clientY - panelRect.top) / zoomLevel;
|
||||
|
||||
// 새 컴포넌트 생성
|
||||
const componentType = component.id || component.componentType || "v2-text-display";
|
||||
|
||||
console.log("🎯 분할 패널에 컴포넌트 드롭:", {
|
||||
componentId: component.id,
|
||||
componentType: componentType,
|
||||
panelSide: panelSide,
|
||||
dropPosition: { x: dropX, y: dropY },
|
||||
});
|
||||
|
||||
const newPanelComponent = {
|
||||
id: `panel_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
componentType: componentType,
|
||||
label: component.name || component.label || "새 컴포넌트",
|
||||
position: { x: Math.max(0, dropX), y: Math.max(0, dropY) },
|
||||
size: component.defaultSize || { width: 200, height: 100 },
|
||||
componentConfig: component.defaultConfig || {},
|
||||
};
|
||||
|
||||
const updatedPanelConfig = {
|
||||
...panelConfig,
|
||||
components: [...currentComponents, newPanelComponent],
|
||||
};
|
||||
|
||||
const updatedComponent = {
|
||||
...targetComponent,
|
||||
componentConfig: {
|
||||
...currentConfig,
|
||||
[panelKey]: updatedPanelConfig,
|
||||
},
|
||||
};
|
||||
|
||||
const newLayout = {
|
||||
...layout,
|
||||
components: layout.components.map((c) => (c.id === containerId ? updatedComponent : c)),
|
||||
};
|
||||
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
toast.success(`컴포넌트가 ${panelSide === "left" ? "좌측" : "우측"} 패널에 추가되었습니다`);
|
||||
return; // 분할 패널 처리 완료
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
|
@ -3378,15 +3505,12 @@ export default function ScreenDesigner({
|
|||
e.preventDefault();
|
||||
|
||||
const dragData = e.dataTransfer.getData("application/json");
|
||||
// console.log("🎯 드롭 이벤트:", { dragData });
|
||||
if (!dragData) {
|
||||
// console.log("❌ 드래그 데이터가 없습니다");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedData = JSON.parse(dragData);
|
||||
// console.log("📋 파싱된 데이터:", parsedData);
|
||||
|
||||
// 템플릿 드래그인 경우
|
||||
if (parsedData.type === "template") {
|
||||
|
|
@ -3480,9 +3604,225 @@ export default function ScreenDesigner({
|
|||
}
|
||||
}
|
||||
|
||||
// 🎯 탭 컨테이너 내부에 컬럼 드롭 시 처리 (중첩 구조 지원)
|
||||
// 🎯 컨테이너 감지: innermost 우선 (분할패널 > 탭)
|
||||
const tabsContainer = dropTarget.closest('[data-tabs-container="true"]');
|
||||
if (tabsContainer && type === "column" && column) {
|
||||
const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]');
|
||||
|
||||
// 분할패널이 탭 안에 있으면 분할패널이 innermost → 분할패널 우선
|
||||
const splitPanelFirst =
|
||||
splitPanelContainer &&
|
||||
(!tabsContainer || tabsContainer.contains(splitPanelContainer));
|
||||
|
||||
// 🎯 분할패널 커스텀 모드 컨테이너 내부에 컬럼 드롭 시 처리 (우선 처리)
|
||||
if (splitPanelFirst && splitPanelContainer && type === "column" && column) {
|
||||
const containerId = splitPanelContainer.getAttribute("data-component-id");
|
||||
let panelSide = splitPanelContainer.getAttribute("data-panel-side");
|
||||
|
||||
// panelSide가 없으면 드롭 좌표와 splitRatio로 좌/우 판별
|
||||
if (!panelSide) {
|
||||
const splitRatio = parseInt(splitPanelContainer.getAttribute("data-split-ratio") || "40", 10);
|
||||
const containerRect = splitPanelContainer.getBoundingClientRect();
|
||||
const relativeX = e.clientX - containerRect.left;
|
||||
const splitPoint = containerRect.width * (splitRatio / 100);
|
||||
panelSide = relativeX < splitPoint ? "left" : "right";
|
||||
}
|
||||
|
||||
if (containerId && panelSide) {
|
||||
// 최상위에서 찾기
|
||||
let targetComponent: any = layout.components.find((c) => c.id === containerId);
|
||||
let parentTabsId: string | null = null;
|
||||
let parentTabId: string | null = null;
|
||||
let parentSplitId: string | null = null;
|
||||
let parentSplitSide: string | null = null;
|
||||
|
||||
if (!targetComponent) {
|
||||
// 탭 안 중첩 분할패널 찾기
|
||||
// top-level 컴포넌트: overrides.type / overrides.tabs
|
||||
// nested 컴포넌트: componentType / componentConfig.tabs
|
||||
for (const comp of layout.components) {
|
||||
const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type;
|
||||
const compConfig = (comp as any)?.componentConfig || (comp as any)?.overrides || {};
|
||||
|
||||
if (ct === "tabs-widget" || ct === "v2-tabs-widget") {
|
||||
const tabs = compConfig.tabs || [];
|
||||
for (const tab of tabs) {
|
||||
const found = (tab.components || []).find((c: any) => c.id === containerId);
|
||||
if (found) {
|
||||
targetComponent = found;
|
||||
parentTabsId = comp.id;
|
||||
parentTabId = tab.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (targetComponent) break;
|
||||
}
|
||||
// 분할패널 → 탭 → 분할패널 중첩
|
||||
if (ct === "split-panel-layout" || ct === "v2-split-panel-layout") {
|
||||
for (const side of ["leftPanel", "rightPanel"] as const) {
|
||||
const panelComps = compConfig[side]?.components || [];
|
||||
for (const pc of panelComps) {
|
||||
const pct = pc.componentType || pc.overrides?.type;
|
||||
if (pct === "tabs-widget" || pct === "v2-tabs-widget") {
|
||||
const tabs = (pc.componentConfig || pc.overrides || {}).tabs || [];
|
||||
for (const tab of tabs) {
|
||||
const found = (tab.components || []).find((c: any) => c.id === containerId);
|
||||
if (found) {
|
||||
targetComponent = found;
|
||||
parentSplitId = comp.id;
|
||||
parentSplitSide = side === "leftPanel" ? "left" : "right";
|
||||
parentTabsId = pc.id;
|
||||
parentTabId = tab.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (targetComponent) break;
|
||||
}
|
||||
}
|
||||
if (targetComponent) break;
|
||||
}
|
||||
if (targetComponent) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const compType = (targetComponent as any)?.componentType;
|
||||
if (targetComponent && (compType === "split-panel-layout" || compType === "v2-split-panel-layout")) {
|
||||
const currentConfig = (targetComponent as any).componentConfig || {};
|
||||
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
|
||||
const panelConfig = currentConfig[panelKey] || {};
|
||||
const currentComponents = panelConfig.components || [];
|
||||
|
||||
const panelRect = splitPanelContainer.getBoundingClientRect();
|
||||
const computedStyle = window.getComputedStyle(splitPanelContainer);
|
||||
const padLeft = parseFloat(computedStyle.paddingLeft) || 0;
|
||||
const padTop = parseFloat(computedStyle.paddingTop) || 0;
|
||||
const dropX = (e.clientX - panelRect.left - padLeft) / zoomLevel;
|
||||
const dropY = (e.clientY - panelRect.top - padTop) / zoomLevel;
|
||||
|
||||
const v2Mapping = createV2ConfigFromColumn({
|
||||
widgetType: column.widgetType,
|
||||
columnName: column.columnName,
|
||||
columnLabel: column.columnLabel,
|
||||
codeCategory: column.codeCategory,
|
||||
inputType: column.inputType,
|
||||
required: column.required,
|
||||
detailSettings: column.detailSettings,
|
||||
referenceTable: column.referenceTable,
|
||||
referenceColumn: column.referenceColumn,
|
||||
displayColumn: column.displayColumn,
|
||||
});
|
||||
|
||||
const newPanelComponent = {
|
||||
id: `panel_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
componentType: v2Mapping.componentType,
|
||||
label: column.columnLabel || column.columnName,
|
||||
position: { x: Math.max(0, dropX), y: Math.max(0, dropY) },
|
||||
size: { width: 200, height: 36 },
|
||||
inputType: column.inputType || column.widgetType,
|
||||
widgetType: column.widgetType,
|
||||
componentConfig: {
|
||||
...v2Mapping.componentConfig,
|
||||
columnName: column.columnName,
|
||||
tableName: column.tableName,
|
||||
inputType: column.inputType || column.widgetType,
|
||||
},
|
||||
};
|
||||
|
||||
const updatedSplitPanel = {
|
||||
...targetComponent,
|
||||
componentConfig: {
|
||||
...currentConfig,
|
||||
[panelKey]: {
|
||||
...panelConfig,
|
||||
displayMode: "custom",
|
||||
components: [...currentComponents, newPanelComponent],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let newLayout;
|
||||
|
||||
if (parentSplitId && parentSplitSide && parentTabsId && parentTabId) {
|
||||
// 분할패널 → 탭 → 분할패널 3중 중첩
|
||||
newLayout = {
|
||||
...layout,
|
||||
components: layout.components.map((c) => {
|
||||
if (c.id !== parentSplitId) return c;
|
||||
const sc = (c as any).componentConfig || {};
|
||||
const pk = parentSplitSide === "left" ? "leftPanel" : "rightPanel";
|
||||
return {
|
||||
...c,
|
||||
componentConfig: {
|
||||
...sc,
|
||||
[pk]: {
|
||||
...sc[pk],
|
||||
components: (sc[pk]?.components || []).map((pc: any) => {
|
||||
if (pc.id !== parentTabsId) return pc;
|
||||
return {
|
||||
...pc,
|
||||
componentConfig: {
|
||||
...pc.componentConfig,
|
||||
tabs: (pc.componentConfig?.tabs || []).map((tab: any) => {
|
||||
if (tab.id !== parentTabId) return tab;
|
||||
return {
|
||||
...tab,
|
||||
components: (tab.components || []).map((tc: any) =>
|
||||
tc.id === containerId ? updatedSplitPanel : tc,
|
||||
),
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
} else if (parentTabsId && parentTabId) {
|
||||
// 탭 → 분할패널 2중 중첩
|
||||
newLayout = {
|
||||
...layout,
|
||||
components: layout.components.map((c) => {
|
||||
if (c.id !== parentTabsId) return c;
|
||||
// top-level은 overrides, nested는 componentConfig
|
||||
const configKey = (c as any).componentConfig ? "componentConfig" : "overrides";
|
||||
const tabsConfig = (c as any)[configKey] || {};
|
||||
return {
|
||||
...c,
|
||||
[configKey]: {
|
||||
...tabsConfig,
|
||||
tabs: (tabsConfig.tabs || []).map((tab: any) => {
|
||||
if (tab.id !== parentTabId) return tab;
|
||||
return {
|
||||
...tab,
|
||||
components: (tab.components || []).map((tc: any) =>
|
||||
tc.id === containerId ? updatedSplitPanel : tc,
|
||||
),
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
} else {
|
||||
// 최상위 분할패널
|
||||
newLayout = {
|
||||
...layout,
|
||||
components: layout.components.map((c) => (c.id === containerId ? updatedSplitPanel : c)),
|
||||
};
|
||||
}
|
||||
|
||||
toast.success("컬럼이 분할패널에 추가되었습니다");
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🎯 탭 컨테이너 내부에 컬럼 드롭 시 처리 (중첩 구조 지원)
|
||||
if (tabsContainer && !splitPanelFirst && type === "column" && column) {
|
||||
const containerId = tabsContainer.getAttribute("data-component-id");
|
||||
const activeTabId = tabsContainer.getAttribute("data-active-tab-id");
|
||||
if (containerId && activeTabId) {
|
||||
|
|
@ -3648,9 +3988,8 @@ export default function ScreenDesigner({
|
|||
}
|
||||
}
|
||||
|
||||
// 🎯 분할 패널 커스텀 모드 컨테이너 내부에 컬럼 드롭 시 처리
|
||||
const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]');
|
||||
if (splitPanelContainer && type === "column" && column) {
|
||||
// 🎯 분할 패널 커스텀 모드 (탭 밖 최상위) 컬럼 드롭 처리
|
||||
if (splitPanelContainer && !splitPanelFirst && type === "column" && column) {
|
||||
const containerId = splitPanelContainer.getAttribute("data-component-id");
|
||||
const panelSide = splitPanelContainer.getAttribute("data-panel-side"); // "left" or "right"
|
||||
if (containerId && panelSide) {
|
||||
|
|
@ -3662,12 +4001,11 @@ export default function ScreenDesigner({
|
|||
const panelConfig = currentConfig[panelKey] || {};
|
||||
const currentComponents = panelConfig.components || [];
|
||||
|
||||
// 드롭 위치 계산
|
||||
const panelRect = splitPanelContainer.getBoundingClientRect();
|
||||
const dropX = (e.clientX - panelRect.left) / zoomLevel;
|
||||
const dropY = (e.clientY - panelRect.top) / zoomLevel;
|
||||
const cs2 = window.getComputedStyle(splitPanelContainer);
|
||||
const dropX = (e.clientX - panelRect.left - (parseFloat(cs2.paddingLeft) || 0)) / zoomLevel;
|
||||
const dropY = (e.clientY - panelRect.top - (parseFloat(cs2.paddingTop) || 0)) / zoomLevel;
|
||||
|
||||
// V2 컴포넌트 매핑 사용
|
||||
const v2Mapping = createV2ConfigFromColumn({
|
||||
widgetType: column.widgetType,
|
||||
columnName: column.columnName,
|
||||
|
|
@ -6415,15 +6753,6 @@ export default function ScreenDesigner({
|
|||
const { splitPanelId, panelSide } = selectedPanelComponentInfo;
|
||||
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
|
||||
|
||||
console.log("🔧 updatePanelComponentProperty 호출:", {
|
||||
componentId,
|
||||
path,
|
||||
value,
|
||||
splitPanelId,
|
||||
panelSide,
|
||||
});
|
||||
|
||||
// 🆕 안전한 깊은 경로 업데이트 헬퍼 함수
|
||||
const setNestedValue = (obj: any, pathStr: string, val: any): any => {
|
||||
const result = JSON.parse(JSON.stringify(obj));
|
||||
const parts = pathStr.split(".");
|
||||
|
|
@ -6440,9 +6769,27 @@ export default function ScreenDesigner({
|
|||
return result;
|
||||
};
|
||||
|
||||
// 중첩 구조 포함 분할패널 찾기 헬퍼
|
||||
const findSplitPanelInLayout = (components: any[]): { found: any; path: "top" | "nested"; parentTabId?: string; parentTabTabId?: string } | null => {
|
||||
const direct = components.find((c) => c.id === splitPanelId);
|
||||
if (direct) return { found: direct, path: "top" };
|
||||
for (const comp of components) {
|
||||
const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type;
|
||||
const cfg = (comp as any)?.componentConfig || (comp as any)?.overrides || {};
|
||||
if (ct === "tabs-widget" || ct === "v2-tabs-widget") {
|
||||
for (const tab of (cfg.tabs || [])) {
|
||||
const nested = (tab.components || []).find((c: any) => c.id === splitPanelId);
|
||||
if (nested) return { found: nested, path: "nested", parentTabId: comp.id, parentTabTabId: tab.id };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
setLayout((prevLayout) => {
|
||||
const splitPanelComponent = prevLayout.components.find((c) => c.id === splitPanelId);
|
||||
if (!splitPanelComponent) return prevLayout;
|
||||
const result = findSplitPanelInLayout(prevLayout.components);
|
||||
if (!result) return prevLayout;
|
||||
const splitPanelComponent = result.found;
|
||||
|
||||
const currentConfig = (splitPanelComponent as any).componentConfig || {};
|
||||
const panelConfig = currentConfig[panelKey] || {};
|
||||
|
|
@ -6478,17 +6825,37 @@ export default function ScreenDesigner({
|
|||
},
|
||||
};
|
||||
|
||||
// selectedPanelComponentInfo 업데이트
|
||||
setSelectedPanelComponentInfo((prev) =>
|
||||
prev ? { ...prev, component: updatedComp } : null,
|
||||
);
|
||||
|
||||
return {
|
||||
...prevLayout,
|
||||
components: prevLayout.components.map((c) =>
|
||||
c.id === splitPanelId ? updatedComponent : c,
|
||||
),
|
||||
// 중첩 구조 반영
|
||||
const applyUpdatedSplitPanel = (layout: any, updated: any, info: any) => {
|
||||
if (info.path === "top") {
|
||||
return { ...layout, components: layout.components.map((c: any) => c.id === splitPanelId ? updated : c) };
|
||||
}
|
||||
return {
|
||||
...layout,
|
||||
components: layout.components.map((c: any) => {
|
||||
if (c.id !== info.parentTabId) return c;
|
||||
const cfgKey = c.componentConfig?.tabs ? "componentConfig" : "overrides";
|
||||
const cfg = c[cfgKey] || {};
|
||||
return {
|
||||
...c,
|
||||
[cfgKey]: {
|
||||
...cfg,
|
||||
tabs: (cfg.tabs || []).map((t: any) =>
|
||||
t.id === info.parentTabTabId
|
||||
? { ...t, components: (t.components || []).map((tc: any) => tc.id === splitPanelId ? updated : tc) }
|
||||
: t,
|
||||
),
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
return applyUpdatedSplitPanel(prevLayout, updatedComponent, result);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -6498,8 +6865,23 @@ export default function ScreenDesigner({
|
|||
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
|
||||
|
||||
setLayout((prevLayout) => {
|
||||
const splitPanelComponent = prevLayout.components.find((c) => c.id === splitPanelId);
|
||||
if (!splitPanelComponent) return prevLayout;
|
||||
const findResult = (() => {
|
||||
const direct = prevLayout.components.find((c: any) => c.id === splitPanelId);
|
||||
if (direct) return { found: direct, path: "top" as const };
|
||||
for (const comp of prevLayout.components) {
|
||||
const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type;
|
||||
const cfg = (comp as any)?.componentConfig || (comp as any)?.overrides || {};
|
||||
if (ct === "tabs-widget" || ct === "v2-tabs-widget") {
|
||||
for (const tab of (cfg.tabs || [])) {
|
||||
const nested = (tab.components || []).find((c: any) => c.id === splitPanelId);
|
||||
if (nested) return { found: nested, path: "nested" as const, parentTabId: comp.id, parentTabTabId: tab.id };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
if (!findResult) return prevLayout;
|
||||
const splitPanelComponent = findResult.found;
|
||||
|
||||
const currentConfig = (splitPanelComponent as any).componentConfig || {};
|
||||
const panelConfig = currentConfig[panelKey] || {};
|
||||
|
|
@ -6520,11 +6902,27 @@ export default function ScreenDesigner({
|
|||
|
||||
setSelectedPanelComponentInfo(null);
|
||||
|
||||
if (findResult.path === "top") {
|
||||
return { ...prevLayout, components: prevLayout.components.map((c: any) => c.id === splitPanelId ? updatedComponent : c) };
|
||||
}
|
||||
return {
|
||||
...prevLayout,
|
||||
components: prevLayout.components.map((c) =>
|
||||
c.id === splitPanelId ? updatedComponent : c,
|
||||
),
|
||||
components: prevLayout.components.map((c: any) => {
|
||||
if (c.id !== findResult.parentTabId) return c;
|
||||
const cfgKey = c.componentConfig?.tabs ? "componentConfig" : "overrides";
|
||||
const cfg = c[cfgKey] || {};
|
||||
return {
|
||||
...c,
|
||||
[cfgKey]: {
|
||||
...cfg,
|
||||
tabs: (cfg.tabs || []).map((t: any) =>
|
||||
t.id === findResult.parentTabTabId
|
||||
? { ...t, components: (t.components || []).map((tc: any) => tc.id === splitPanelId ? updatedComponent : tc) }
|
||||
: t,
|
||||
),
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
|
@ -7128,6 +7526,7 @@ export default function ScreenDesigner({
|
|||
onSelectPanelComponent={(panelSide, compId, comp) =>
|
||||
handleSelectPanelComponent(component.id, panelSide, compId, comp)
|
||||
}
|
||||
onNestedPanelSelect={handleSelectPanelComponent}
|
||||
selectedPanelComponentId={
|
||||
selectedPanelComponentInfo?.splitPanelId === component.id
|
||||
? selectedPanelComponentInfo.componentId
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
|
|
@ -194,7 +194,7 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
|
|||
operator: "contains", // 기본 연산자
|
||||
value: "",
|
||||
filterType: cf.filterType,
|
||||
width: cf.width || 200, // 너비 포함 (기본 200px)
|
||||
width: cf.width && cf.width >= 10 && cf.width <= 100 ? cf.width : 25,
|
||||
}));
|
||||
|
||||
// localStorage에 저장 (화면별로 독립적)
|
||||
|
|
@ -334,20 +334,20 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
|
|||
{/* 너비 입력 */}
|
||||
<Input
|
||||
type="number"
|
||||
value={filter.width || 200}
|
||||
value={filter.width && filter.width >= 10 && filter.width <= 100 ? filter.width : 25}
|
||||
onChange={(e) => {
|
||||
const newWidth = parseInt(e.target.value) || 200;
|
||||
const newWidth = Math.min(100, Math.max(10, parseInt(e.target.value) || 25));
|
||||
setColumnFilters((prev) =>
|
||||
prev.map((f) => (f.columnName === filter.columnName ? { ...f, width: newWidth } : f)),
|
||||
);
|
||||
}}
|
||||
disabled={!filter.enabled}
|
||||
placeholder="너비"
|
||||
placeholder="25"
|
||||
className="h-8 w-[80px] text-xs sm:h-9 sm:text-sm"
|
||||
min={50}
|
||||
max={500}
|
||||
min={10}
|
||||
max={100}
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">px</span>
|
||||
<span className="text-muted-foreground text-xs">%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFilters
|
|||
inputType,
|
||||
enabled: false,
|
||||
filterType,
|
||||
width: 200,
|
||||
width: 25,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -271,7 +271,7 @@ export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFilters
|
|||
operator: "contains",
|
||||
value: "",
|
||||
filterType: f.filterType,
|
||||
width: f.width || 200,
|
||||
width: f.width && f.width >= 10 && f.width <= 100 ? f.width : 25,
|
||||
}));
|
||||
onFiltersApplied?.(activeFilters);
|
||||
|
||||
|
|
@ -498,15 +498,15 @@ export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFilters
|
|||
</Select>
|
||||
<Input
|
||||
type="number"
|
||||
min={100}
|
||||
max={400}
|
||||
value={filter.width || 200}
|
||||
min={10}
|
||||
max={100}
|
||||
value={filter.width && filter.width >= 10 && filter.width <= 100 ? filter.width : 25}
|
||||
onChange={(e) =>
|
||||
handleFilterWidthChange(filter.columnName, parseInt(e.target.value) || 200)
|
||||
handleFilterWidthChange(filter.columnName, Math.min(100, Math.max(10, parseInt(e.target.value) || 25)))
|
||||
}
|
||||
className="h-7 w-16 text-center text-xs"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">px</span>
|
||||
<span className="text-muted-foreground text-xs">%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -430,28 +430,28 @@ export function TabsWidget({
|
|||
|
||||
return (
|
||||
<ResponsiveGridRenderer
|
||||
components={componentDataList}
|
||||
canvasWidth={canvasWidth}
|
||||
canvasHeight={canvasHeight}
|
||||
renderComponent={(comp) => (
|
||||
<DynamicComponentRenderer
|
||||
{...restProps}
|
||||
component={comp}
|
||||
formData={formData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
menuObjid={menuObjid}
|
||||
isDesignMode={false}
|
||||
isInteractive={true}
|
||||
selectedRowsData={localSelectedRowsData}
|
||||
onSelectedRowsChange={handleSelectedRowsChange}
|
||||
parentTabId={tab.id}
|
||||
parentTabsComponentId={component.id}
|
||||
{...(screenInfoMap[tab.id]
|
||||
? { tableName: screenInfoMap[tab.id].tableName, screenId: screenInfoMap[tab.id].id }
|
||||
: {})}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
components={componentDataList}
|
||||
canvasWidth={canvasWidth}
|
||||
canvasHeight={canvasHeight}
|
||||
renderComponent={(comp) => (
|
||||
<DynamicComponentRenderer
|
||||
{...restProps}
|
||||
component={comp}
|
||||
formData={formData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
menuObjid={menuObjid}
|
||||
isDesignMode={false}
|
||||
isInteractive={true}
|
||||
selectedRowsData={localSelectedRowsData}
|
||||
onSelectedRowsChange={handleSelectedRowsChange}
|
||||
parentTabId={tab.id}
|
||||
parentTabsComponentId={component.id}
|
||||
{...(screenInfoMap[tab.id]
|
||||
? { tableName: screenInfoMap[tab.id].tableName, screenId: screenInfoMap[tab.id].id }
|
||||
: {})}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import React, { forwardRef, useCallback, useEffect, useMemo, useRef, useState }
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { formatNumber as centralFormatNumber } from "@/lib/formatting";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { V2InputProps, V2InputConfig, V2InputFormat } from "@/types/v2-components";
|
||||
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
|
||||
|
|
@ -61,11 +62,11 @@ export function validateInputFormat(value: string, format: V2InputFormat): { isV
|
|||
return { isValid, errorMessage: isValid ? "" : formatConfig.errorMessage };
|
||||
}
|
||||
|
||||
// 통화 형식 변환
|
||||
// 통화 형식 변환 (공통 formatNumber 사용)
|
||||
function formatCurrency(value: string | number): string {
|
||||
const num = typeof value === "string" ? parseFloat(value.replace(/,/g, "")) : value;
|
||||
if (isNaN(num)) return "";
|
||||
return num.toLocaleString("ko-KR");
|
||||
return centralFormatNumber(num);
|
||||
}
|
||||
|
||||
// 사업자번호 형식 변환
|
||||
|
|
@ -234,7 +235,22 @@ const TextInput = forwardRef<
|
|||
TextInput.displayName = "TextInput";
|
||||
|
||||
/**
|
||||
* 숫자 입력 컴포넌트
|
||||
* 숫자를 콤마 포맷 문자열로 변환 (입력 중 실시간 표시용)
|
||||
* 소수점 입력 중인 경우(끝이 "."이거나 ".0" 등)를 보존
|
||||
*/
|
||||
function toCommaDisplay(raw: string): string {
|
||||
if (raw === "" || raw === "-") return raw;
|
||||
const negative = raw.startsWith("-");
|
||||
const abs = negative ? raw.slice(1) : raw;
|
||||
const dotIdx = abs.indexOf(".");
|
||||
const intPart = dotIdx >= 0 ? abs.slice(0, dotIdx) : abs;
|
||||
const decPart = dotIdx >= 0 ? abs.slice(dotIdx) : "";
|
||||
const formatted = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
return (negative ? "-" : "") + formatted + decPart;
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자 입력 컴포넌트 - 입력 중에도 실시간 천단위 콤마 표시
|
||||
*/
|
||||
const NumberInput = forwardRef<
|
||||
HTMLInputElement,
|
||||
|
|
@ -250,40 +266,112 @@ const NumberInput = forwardRef<
|
|||
className?: string;
|
||||
inputStyle?: React.CSSProperties;
|
||||
}
|
||||
>(({ value, onChange, min, max, step = 1, placeholder, readonly, disabled, className, inputStyle }, ref) => {
|
||||
>(({ value, onChange, min, max, placeholder, readonly, disabled, className, inputStyle }, ref) => {
|
||||
const innerRef = useRef<HTMLInputElement>(null);
|
||||
const combinedRef = (node: HTMLInputElement | null) => {
|
||||
(innerRef as React.MutableRefObject<HTMLInputElement | null>).current = node;
|
||||
if (typeof ref === "function") ref(node);
|
||||
else if (ref) (ref as React.MutableRefObject<HTMLInputElement | null>).current = node;
|
||||
};
|
||||
|
||||
// 콤마 포함된 표시 문자열을 내부 상태로 관리
|
||||
const [displayValue, setDisplayValue] = useState(() => {
|
||||
if (value === undefined || value === null) return "";
|
||||
return centralFormatNumber(value);
|
||||
});
|
||||
|
||||
// 외부 value가 변경되면 표시 값 동기화 (포커스 아닐 때만)
|
||||
const isFocusedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (isFocusedRef.current) return;
|
||||
if (value === undefined || value === null) {
|
||||
setDisplayValue("");
|
||||
} else {
|
||||
setDisplayValue(centralFormatNumber(value));
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
if (val === "") {
|
||||
const input = e.target;
|
||||
const cursorPos = input.selectionStart ?? 0;
|
||||
const oldVal = displayValue;
|
||||
const rawInput = e.target.value;
|
||||
|
||||
// 콤마 제거하여 순수 숫자 문자열 추출
|
||||
const stripped = rawInput.replace(/,/g, "");
|
||||
|
||||
// 빈 값 처리
|
||||
if (stripped === "" || stripped === "-") {
|
||||
setDisplayValue(stripped);
|
||||
onChange?.(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
let num = parseFloat(val);
|
||||
// 숫자 + 소수점만 허용 (입력 중 "123." 같은 중간 상태도 허용)
|
||||
if (!/^-?\d*\.?\d*$/.test(stripped)) return;
|
||||
|
||||
// 새 콤마 포맷 생성
|
||||
const newDisplay = toCommaDisplay(stripped);
|
||||
setDisplayValue(newDisplay);
|
||||
|
||||
// 콤마 개수 차이로 커서 위치 보정
|
||||
const oldCommas = (oldVal.slice(0, cursorPos).match(/,/g) || []).length;
|
||||
const newCommas = (newDisplay.slice(0, cursorPos).match(/,/g) || []).length;
|
||||
const adjustedCursor = cursorPos + (newCommas - oldCommas);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (innerRef.current) {
|
||||
innerRef.current.setSelectionRange(adjustedCursor, adjustedCursor);
|
||||
}
|
||||
});
|
||||
|
||||
// 실제 숫자 값 전달 (소수점 입력 중이면 아직 전달하지 않음)
|
||||
if (stripped.endsWith(".") || stripped.endsWith("-")) return;
|
||||
let num = parseFloat(stripped);
|
||||
if (isNaN(num)) return;
|
||||
|
||||
// 범위 제한
|
||||
if (min !== undefined && num < min) num = min;
|
||||
if (max !== undefined && num > max) num = max;
|
||||
|
||||
onChange?.(num);
|
||||
},
|
||||
[min, max, onChange],
|
||||
[min, max, onChange, displayValue],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
isFocusedRef.current = true;
|
||||
}, []);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
isFocusedRef.current = false;
|
||||
// 블러 시 최종 포맷 정리
|
||||
const stripped = displayValue.replace(/,/g, "");
|
||||
if (stripped === "" || stripped === "-" || stripped === ".") {
|
||||
setDisplayValue("");
|
||||
onChange?.(undefined);
|
||||
return;
|
||||
}
|
||||
const num = parseFloat(stripped);
|
||||
if (!isNaN(num)) {
|
||||
setDisplayValue(centralFormatNumber(num));
|
||||
}
|
||||
}, [displayValue, onChange]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
type="number"
|
||||
value={value ?? ""}
|
||||
ref={combinedRef}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={displayValue}
|
||||
onChange={handleChange}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
placeholder={placeholder || "숫자 입력"}
|
||||
readOnly={readonly}
|
||||
disabled={disabled}
|
||||
className={cn("h-full w-full", className)}
|
||||
style={inputStyle}
|
||||
style={{ ...inputStyle, textAlign: "right" }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -0,0 +1,609 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* V2 품목별 라우팅 설정 패널
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Settings, ChevronDown, ChevronRight, Plus, Trash2, Check, ChevronsUpDown,
|
||||
Database, Monitor, Columns, List, Filter, Eye,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ItemRoutingConfig, ProcessColumnDef, ColumnDef, ItemFilterCondition } from "@/lib/registry/components/v2-item-routing/types";
|
||||
import { defaultConfig } from "@/lib/registry/components/v2-item-routing/config";
|
||||
|
||||
interface V2ItemRoutingConfigPanelProps {
|
||||
config: Partial<ItemRoutingConfig>;
|
||||
onChange: (config: Partial<ItemRoutingConfig>) => void;
|
||||
}
|
||||
|
||||
interface TableInfo { tableName: string; displayName?: string; }
|
||||
interface ColumnInfo { columnName: string; displayName?: string; dataType?: string; }
|
||||
interface ScreenInfo { screenId: number; screenName: string; screenCode: string; }
|
||||
|
||||
// ─── 공용: 테이블 Combobox ───
|
||||
function TableCombobox({ value, onChange, tables, loading }: {
|
||||
value: string; onChange: (v: string) => void; tables: TableInfo[]; loading: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const selected = tables.find((t) => t.tableName === value);
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" aria-expanded={open} className="h-7 w-full justify-between text-xs" disabled={loading}>
|
||||
{loading ? "로딩 중..." : selected ? selected.displayName || selected.tableName : "테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-4 text-center text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||
{tables.map((t) => (
|
||||
<CommandItem key={t.tableName} value={`${t.displayName || ""} ${t.tableName}`}
|
||||
onSelect={() => { onChange(t.tableName); setOpen(false); }} className="text-xs">
|
||||
<Check className={cn("mr-2 h-3 w-3", value === t.tableName ? "opacity-100" : "opacity-0")} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{t.displayName || t.tableName}</span>
|
||||
{t.displayName && <span className="text-[10px] text-muted-foreground">{t.tableName}</span>}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 공용: 컬럼 Combobox ───
|
||||
function ColumnCombobox({ value, onChange, tableName, placeholder }: {
|
||||
value: string; onChange: (v: string, displayName?: string) => void; tableName: string; placeholder?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tableName) { setColumns([]); return; }
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
||||
const res = await tableManagementApi.getColumnList(tableName);
|
||||
if (res.success && res.data?.columns) setColumns(res.data.columns);
|
||||
} catch { /* ignore */ } finally { setLoading(false); }
|
||||
};
|
||||
load();
|
||||
}, [tableName]);
|
||||
|
||||
const selected = columns.find((c) => c.columnName === value);
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" aria-expanded={open} className="h-7 w-full justify-between text-xs" disabled={loading || !tableName}>
|
||||
<span className="truncate">
|
||||
{loading ? "로딩..." : !tableName ? "테이블 먼저 선택" : selected ? selected.displayName || selected.columnName : placeholder || "컬럼 선택"}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[240px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-4 text-center text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||
{columns.map((c) => (
|
||||
<CommandItem key={c.columnName} value={`${c.displayName || ""} ${c.columnName}`}
|
||||
onSelect={() => { onChange(c.columnName, c.displayName); setOpen(false); }} className="text-xs">
|
||||
<Check className={cn("mr-2 h-3 w-3", value === c.columnName ? "opacity-100" : "opacity-0")} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{c.displayName || c.columnName}</span>
|
||||
{c.displayName && <span className="text-[10px] text-muted-foreground">{c.columnName}</span>}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 공용: 화면 Combobox ───
|
||||
function ScreenCombobox({ value, onChange }: { value?: number; onChange: (v?: number) => void; }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [screens, setScreens] = useState<ScreenInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { screenApi } = await import("@/lib/api/screen");
|
||||
const res = await screenApi.getScreens({ page: 1, size: 1000 });
|
||||
if (res.data) {
|
||||
setScreens(res.data.map((s: any) => ({
|
||||
screenId: s.screenId, screenName: s.screenName || `화면 ${s.screenId}`, screenCode: s.screenCode || "",
|
||||
})));
|
||||
}
|
||||
} catch { /* ignore */ } finally { setLoading(false); }
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const selected = screens.find((s) => s.screenId === value);
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" aria-expanded={open} className="h-7 w-full justify-between text-xs" disabled={loading}>
|
||||
<span className="truncate">{loading ? "로딩..." : selected ? selected.screenName : "화면 선택"}</span>
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[260px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="화면 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-4 text-center text-xs">화면을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||
{screens.map((s) => (
|
||||
<CommandItem key={s.screenId} value={`${s.screenName} ${s.screenCode} ${s.screenId}`}
|
||||
onSelect={() => { onChange(s.screenId); setOpen(false); }} className="text-xs">
|
||||
<Check className={cn("mr-2 h-3 w-3", value === s.screenId ? "opacity-100" : "opacity-0")} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{s.screenName}</span>
|
||||
<span className="text-[10px] text-muted-foreground">ID: {s.screenId}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 컬럼 편집 카드 (품목/모달/공정 공용) ───
|
||||
function ColumnEditor({ columns, onChange, tableName, title, icon }: {
|
||||
columns: ColumnDef[];
|
||||
onChange: (cols: ColumnDef[]) => void;
|
||||
tableName: string;
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const addColumn = () => onChange([...columns, { name: "", label: "새 컬럼", width: 100, align: "left" }]);
|
||||
const removeColumn = (idx: number) => onChange(columns.filter((_, i) => i !== idx));
|
||||
const updateColumn = (idx: number, field: keyof ColumnDef, value: string | number) => {
|
||||
const next = [...columns];
|
||||
next[idx] = { ...next[idx], [field]: value };
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button type="button" className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
<Badge variant="secondary" className="text-[10px] h-5">{columns.length}개</Badge>
|
||||
</div>
|
||||
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", open && "rotate-180")} />
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="rounded-b-lg border border-t-0 p-3 space-y-1.5">
|
||||
{columns.map((col, idx) => (
|
||||
<Collapsible key={idx}>
|
||||
<div className="rounded-md border">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button type="button" className="flex w-full items-center gap-1.5 px-2.5 py-1.5 text-left hover:bg-muted/30 transition-colors">
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90 shrink-0" />
|
||||
<span className="text-[10px] text-muted-foreground font-medium shrink-0">#{idx + 1}</span>
|
||||
<span className="text-xs font-medium truncate flex-1 min-w-0">{col.label || col.name || "미설정"}</span>
|
||||
<Badge variant="outline" className="text-[9px] h-4 shrink-0">{col.name || "?"}</Badge>
|
||||
<Button type="button" variant="ghost" size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); removeColumn(idx); }}
|
||||
className="h-5 w-5 p-0 text-muted-foreground hover:text-destructive shrink-0">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="grid grid-cols-2 gap-1.5 border-t px-2.5 py-2">
|
||||
<div className="space-y-0.5">
|
||||
<span className="text-[10px] text-muted-foreground">컬럼</span>
|
||||
<ColumnCombobox value={col.name} onChange={(v, displayName) => {
|
||||
updateColumn(idx, "name", v);
|
||||
if (!col.label || col.label === "새 컬럼" || col.label === col.name) updateColumn(idx, "label", displayName || v);
|
||||
}} tableName={tableName} placeholder="컬럼 선택" />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<span className="text-[10px] text-muted-foreground">표시명</span>
|
||||
<Input value={col.label} onChange={(e) => updateColumn(idx, "label", e.target.value)} className="h-7 text-xs" />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<span className="text-[10px] text-muted-foreground">너비</span>
|
||||
<Input type="number" value={col.width || 100} onChange={(e) => updateColumn(idx, "width", parseInt(e.target.value) || 100)} className="h-7 text-xs" />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<span className="text-[10px] text-muted-foreground">정렬</span>
|
||||
<Select value={col.align || "left"} onValueChange={(v) => updateColumn(idx, "align", v)}>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">좌</SelectItem>
|
||||
<SelectItem value="center">중</SelectItem>
|
||||
<SelectItem value="right">우</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
))}
|
||||
<Button variant="outline" size="sm" className="h-7 w-full gap-1 text-xs border-dashed" onClick={addColumn}>
|
||||
<Plus className="h-3 w-3" /> 컬럼 추가
|
||||
</Button>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 메인 컴포넌트 ───
|
||||
export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> = ({ config: configProp, onChange }) => {
|
||||
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [dataSourceOpen, setDataSourceOpen] = useState(false);
|
||||
const [layoutOpen, setLayoutOpen] = useState(false);
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
|
||||
const config: ItemRoutingConfig = {
|
||||
...defaultConfig,
|
||||
...configProp,
|
||||
dataSource: { ...defaultConfig.dataSource, ...configProp?.dataSource },
|
||||
modals: { ...defaultConfig.modals, ...configProp?.modals },
|
||||
processColumns: configProp?.processColumns?.length ? configProp.processColumns : defaultConfig.processColumns,
|
||||
itemDisplayColumns: configProp?.itemDisplayColumns?.length ? configProp.itemDisplayColumns : defaultConfig.itemDisplayColumns,
|
||||
modalDisplayColumns: configProp?.modalDisplayColumns?.length ? configProp.modalDisplayColumns : defaultConfig.modalDisplayColumns,
|
||||
itemFilterConditions: configProp?.itemFilterConditions || [],
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadTables = async () => {
|
||||
setLoadingTables(true);
|
||||
try {
|
||||
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
||||
const res = await tableManagementApi.getTableList();
|
||||
if (res.success && res.data) {
|
||||
setTables(res.data.map((t: any) => ({ tableName: t.tableName, displayName: t.displayName || t.tableName })));
|
||||
}
|
||||
} catch { /* ignore */ } finally { setLoadingTables(false); }
|
||||
};
|
||||
loadTables();
|
||||
}, []);
|
||||
|
||||
const dispatchConfigEvent = (newConfig: Partial<ItemRoutingConfig>) => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("componentConfigChanged", { detail: { config: { ...config, ...newConfig } } }));
|
||||
}
|
||||
};
|
||||
|
||||
const update = (partial: Partial<ItemRoutingConfig>) => {
|
||||
const merged = { ...configProp, ...partial };
|
||||
onChange(merged);
|
||||
dispatchConfigEvent(partial);
|
||||
};
|
||||
|
||||
const updateDataSource = (field: string, value: string) => {
|
||||
const newDS = { ...config.dataSource, [field]: value };
|
||||
onChange({ ...configProp, dataSource: newDS });
|
||||
dispatchConfigEvent({ dataSource: newDS });
|
||||
};
|
||||
|
||||
const updateModals = (field: string, value?: number) => {
|
||||
const newM = { ...config.modals, [field]: value };
|
||||
onChange({ ...configProp, modals: newM });
|
||||
dispatchConfigEvent({ modals: newM });
|
||||
};
|
||||
|
||||
// 필터 조건 관리
|
||||
const filters = config.itemFilterConditions || [];
|
||||
const addFilter = () => update({ itemFilterConditions: [...filters, { column: "", operator: "equals", value: "" }] });
|
||||
const removeFilter = (idx: number) => update({ itemFilterConditions: filters.filter((_, i) => i !== idx) });
|
||||
const updateFilter = (idx: number, field: keyof ItemFilterCondition, val: string) => {
|
||||
const next = [...filters];
|
||||
next[idx] = { ...next[idx], [field]: val };
|
||||
update({ itemFilterConditions: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* ─── 품목 목록 모드 ─── */}
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<List className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">품목 목록 모드</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">좌측 품목 목록에 표시할 방식을 선택하세요</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button type="button"
|
||||
className={cn("flex flex-col items-center gap-1 rounded-md border px-3 py-2.5 text-xs transition-colors",
|
||||
(config.itemListMode || "all") === "all" ? "border-primary bg-primary/5 text-primary" : "border-input hover:bg-muted/50")}
|
||||
onClick={() => update({ itemListMode: "all" })}>
|
||||
<span className="font-medium">전체 품목</span>
|
||||
<span className="text-[10px] text-muted-foreground">모든 품목 표시</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
className={cn("flex flex-col items-center gap-1 rounded-md border px-3 py-2.5 text-xs transition-colors",
|
||||
config.itemListMode === "registered" ? "border-primary bg-primary/5 text-primary" : "border-input hover:bg-muted/50")}
|
||||
onClick={() => update({ itemListMode: "registered" })}>
|
||||
<span className="font-medium">등록 품목만</span>
|
||||
<span className="text-[10px] text-muted-foreground">선택한 품목만 표시</span>
|
||||
</button>
|
||||
</div>
|
||||
{config.itemListMode === "registered" && (
|
||||
<p className="text-[10px] text-muted-foreground pt-1">
|
||||
현재 화면 ID를 기준으로 품목 목록이 자동 관리됩니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ─── 품목 표시 컬럼 ─── */}
|
||||
<ColumnEditor
|
||||
columns={config.itemDisplayColumns || []}
|
||||
onChange={(cols) => update({ itemDisplayColumns: cols })}
|
||||
tableName={config.dataSource.itemTable}
|
||||
title="품목 목록 컬럼"
|
||||
icon={<Eye className="h-4 w-4 text-muted-foreground" />}
|
||||
/>
|
||||
|
||||
{/* ─── 모달 표시 컬럼 (등록 모드에서만 의미 있지만 항상 설정 가능) ─── */}
|
||||
<ColumnEditor
|
||||
columns={config.modalDisplayColumns || []}
|
||||
onChange={(cols) => update({ modalDisplayColumns: cols })}
|
||||
tableName={config.dataSource.itemTable}
|
||||
title="품목 추가 모달 컬럼"
|
||||
icon={<Columns className="h-4 w-4 text-muted-foreground" />}
|
||||
/>
|
||||
|
||||
{/* ─── 품목 필터 조건 ─── */}
|
||||
<Collapsible open={filterOpen} onOpenChange={setFilterOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button type="button" className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">품목 필터 조건</span>
|
||||
{filters.length > 0 && <Badge variant="secondary" className="text-[10px] h-5">{filters.length}건</Badge>}
|
||||
</div>
|
||||
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", filterOpen && "rotate-180")} />
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="rounded-b-lg border border-t-0 p-3 space-y-2">
|
||||
<p className="text-[10px] text-muted-foreground">품목 조회 시 자동으로 적용되는 필터 조건입니다</p>
|
||||
{filters.map((f, idx) => (
|
||||
<div key={idx} className="flex items-end gap-1.5 rounded-md border p-2">
|
||||
<div className="flex-1 space-y-0.5">
|
||||
<span className="text-[10px] text-muted-foreground">컬럼</span>
|
||||
<ColumnCombobox value={f.column} onChange={(v) => updateFilter(idx, "column", v)}
|
||||
tableName={config.dataSource.itemTable} placeholder="필터 컬럼" />
|
||||
</div>
|
||||
<div className="w-[90px] space-y-0.5">
|
||||
<span className="text-[10px] text-muted-foreground">조건</span>
|
||||
<Select value={f.operator} onValueChange={(v) => updateFilter(idx, "operator", v)}>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="equals">같음</SelectItem>
|
||||
<SelectItem value="contains">포함</SelectItem>
|
||||
<SelectItem value="not_equals">다름</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex-1 space-y-0.5">
|
||||
<span className="text-[10px] text-muted-foreground">값</span>
|
||||
<Input value={f.value} onChange={(e) => updateFilter(idx, "value", e.target.value)}
|
||||
placeholder="필터값" className="h-7 text-xs" />
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="sm"
|
||||
onClick={() => removeFilter(idx)}
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive shrink-0">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button variant="outline" size="sm" className="h-7 w-full gap-1 text-xs border-dashed" onClick={addFilter}>
|
||||
<Plus className="h-3 w-3" /> 필터 추가
|
||||
</Button>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* ─── 모달 연동 ─── */}
|
||||
<Collapsible open={modalOpen} onOpenChange={setModalOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button type="button" className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">모달 연동</span>
|
||||
<Badge variant="secondary" className="text-[10px] h-5">
|
||||
{[config.modals.versionAddScreenId, config.modals.processAddScreenId, config.modals.processEditScreenId].filter(Boolean).length}개
|
||||
</Badge>
|
||||
</div>
|
||||
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", modalOpen && "rotate-180")} />
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="rounded-b-lg border border-t-0 p-3 space-y-2">
|
||||
<p className="text-[10px] text-muted-foreground">버전 추가/공정 추가·수정 시 열리는 화면</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-muted-foreground">버전 추가</span>
|
||||
<ScreenCombobox value={config.modals.versionAddScreenId} onChange={(v) => updateModals("versionAddScreenId", v)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-muted-foreground">공정 추가</span>
|
||||
<ScreenCombobox value={config.modals.processAddScreenId} onChange={(v) => updateModals("processAddScreenId", v)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-muted-foreground">공정 수정</span>
|
||||
<ScreenCombobox value={config.modals.processEditScreenId} onChange={(v) => updateModals("processEditScreenId", v)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* ─── 공정 테이블 컬럼 ─── */}
|
||||
<ColumnEditor
|
||||
columns={config.processColumns}
|
||||
onChange={(cols) => update({ processColumns: cols })}
|
||||
tableName={config.dataSource.routingDetailTable}
|
||||
title="공정 테이블 컬럼"
|
||||
icon={<Columns className="h-4 w-4 text-muted-foreground" />}
|
||||
/>
|
||||
|
||||
{/* ─── 데이터 소스 ─── */}
|
||||
<Collapsible open={dataSourceOpen} onOpenChange={setDataSourceOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button type="button" className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">데이터 소스 설정</span>
|
||||
{config.dataSource.itemTable && (
|
||||
<Badge variant="secondary" className="text-[10px] h-5 truncate max-w-[100px]">{config.dataSource.itemTable}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", dataSourceOpen && "rotate-180")} />
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">품목 테이블</span>
|
||||
<TableCombobox value={config.dataSource.itemTable} onChange={(v) => updateDataSource("itemTable", v)} tables={tables} loading={loadingTables} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">품목명 컬럼</span>
|
||||
<ColumnCombobox value={config.dataSource.itemNameColumn} onChange={(v) => updateDataSource("itemNameColumn", v)} tableName={config.dataSource.itemTable} placeholder="품목명" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">품목코드 컬럼</span>
|
||||
<ColumnCombobox value={config.dataSource.itemCodeColumn} onChange={(v) => updateDataSource("itemCodeColumn", v)} tableName={config.dataSource.itemTable} placeholder="품목코드" />
|
||||
</div>
|
||||
<div className="space-y-1 pt-2">
|
||||
<span className="text-xs text-muted-foreground">라우팅 버전 테이블</span>
|
||||
<TableCombobox value={config.dataSource.routingVersionTable} onChange={(v) => updateDataSource("routingVersionTable", v)} tables={tables} loading={loadingTables} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">품목 FK 컬럼</span>
|
||||
<ColumnCombobox value={config.dataSource.routingVersionFkColumn} onChange={(v) => updateDataSource("routingVersionFkColumn", v)} tableName={config.dataSource.routingVersionTable} placeholder="FK 컬럼" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">버전명 컬럼</span>
|
||||
<ColumnCombobox value={config.dataSource.routingVersionNameColumn} onChange={(v) => updateDataSource("routingVersionNameColumn", v)} tableName={config.dataSource.routingVersionTable} placeholder="버전명" />
|
||||
</div>
|
||||
<div className="space-y-1 pt-2">
|
||||
<span className="text-xs text-muted-foreground">라우팅 상세 테이블</span>
|
||||
<TableCombobox value={config.dataSource.routingDetailTable} onChange={(v) => updateDataSource("routingDetailTable", v)} tables={tables} loading={loadingTables} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">버전 FK 컬럼</span>
|
||||
<ColumnCombobox value={config.dataSource.routingDetailFkColumn} onChange={(v) => updateDataSource("routingDetailFkColumn", v)} tableName={config.dataSource.routingDetailTable} placeholder="FK 컬럼" />
|
||||
</div>
|
||||
<div className="space-y-1 pt-2">
|
||||
<span className="text-xs text-muted-foreground">공정 마스터 테이블</span>
|
||||
<TableCombobox value={config.dataSource.processTable} onChange={(v) => updateDataSource("processTable", v)} tables={tables} loading={loadingTables} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">공정명 컬럼</span>
|
||||
<ColumnCombobox value={config.dataSource.processNameColumn} onChange={(v) => updateDataSource("processNameColumn", v)} tableName={config.dataSource.processTable} placeholder="공정명" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">공정코드 컬럼</span>
|
||||
<ColumnCombobox value={config.dataSource.processCodeColumn} onChange={(v) => updateDataSource("processCodeColumn", v)} tableName={config.dataSource.processTable} placeholder="공정코드" />
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* ─── 레이아웃 & 기타 ─── */}
|
||||
<Collapsible open={layoutOpen} onOpenChange={setLayoutOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button type="button" className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">레이아웃 & 기타</span>
|
||||
</div>
|
||||
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", layoutOpen && "rotate-180")} />
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">좌측 패널 비율 (%)</span>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">품목 목록 패널의 너비</p>
|
||||
</div>
|
||||
<Input type="number" min={20} max={60} value={config.splitRatio || 40}
|
||||
onChange={(e) => update({ splitRatio: parseInt(e.target.value) || 40 })} className="h-7 w-[80px] text-xs" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-muted-foreground">좌측 패널 제목</span>
|
||||
<Input value={config.leftPanelTitle || ""} onChange={(e) => update({ leftPanelTitle: e.target.value })} placeholder="품목 목록" className="h-7 w-[140px] text-xs" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-muted-foreground">우측 패널 제목</span>
|
||||
<Input value={config.rightPanelTitle || ""} onChange={(e) => update({ rightPanelTitle: e.target.value })} placeholder="공정 순서" className="h-7 w-[140px] text-xs" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-muted-foreground">버전 추가 버튼 텍스트</span>
|
||||
<Input value={config.versionAddButtonText || ""} onChange={(e) => update({ versionAddButtonText: e.target.value })} placeholder="+ 라우팅 버전 추가" className="h-7 w-[140px] text-xs" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-muted-foreground">공정 추가 버튼 텍스트</span>
|
||||
<Input value={config.processAddButtonText || ""} onChange={(e) => update({ processAddButtonText: e.target.value })} placeholder="+ 공정 추가" className="h-7 w-[140px] text-xs" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div>
|
||||
<p className="text-sm">첫 번째 버전 자동 선택</p>
|
||||
<p className="text-[11px] text-muted-foreground">품목 선택 시 첫 버전을 자동으로 선택해요</p>
|
||||
</div>
|
||||
<Switch checked={config.autoSelectFirstVersion !== false} onCheckedChange={(checked) => update({ autoSelectFirstVersion: checked })} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div>
|
||||
<p className="text-sm">읽기 전용</p>
|
||||
<p className="text-[11px] text-muted-foreground">추가/수정/삭제 버튼을 숨겨요</p>
|
||||
</div>
|
||||
<Switch checked={config.readonly || false} onCheckedChange={(checked) => update({ readonly: checked })} />
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
V2ItemRoutingConfigPanel.displayName = "V2ItemRoutingConfigPanel";
|
||||
export default V2ItemRoutingConfigPanel;
|
||||
|
|
@ -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;
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue