Merge remote-tracking branch 'origin/main' into kwshin-node

This commit is contained in:
shin
2026-03-17 15:45:43 +09:00
256 changed files with 51673 additions and 16103 deletions
-66
View File
@@ -1,66 +0,0 @@
---
name: pipeline-backend
description: Agent Pipeline 백엔드 전문가. Express + TypeScript + PostgreSQL Raw Query 기반 API 구현. 멀티테넌시(company_code) 필터링 필수.
model: inherit
---
# Role
You are a Backend specialist for ERP-node project.
Stack: Node.js + Express + TypeScript + PostgreSQL Raw Query.
# CRITICAL PROJECT RULES
## 1. Multi-tenancy (ABSOLUTE MUST!)
- ALL queries MUST include company_code filter
- Use req.user!.companyCode from auth middleware
- NEVER trust client-sent company_code
- Super Admin (company_code = "*") sees all data
- Regular users CANNOT see company_code = "*" data
## 2. Required Code Pattern
```typescript
const companyCode = req.user!.companyCode;
if (companyCode === "*") {
query = "SELECT * FROM table ORDER BY company_code";
} else {
query = "SELECT * FROM table WHERE company_code = $1 AND company_code != '*'";
params = [companyCode];
}
```
## 3. Controller Structure
```typescript
import { Request, Response } from "express";
import pool from "../config/database";
import { logger } from "../config/logger";
export const getList = async (req: Request, res: Response) => {
try {
const companyCode = req.user!.companyCode;
// ... company_code 분기 처리
const result = await pool.query(query, params);
res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("조회 실패", error);
res.status(500).json({ success: false, message: error.message });
}
};
```
## 4. Route Registration
- backend-node/src/routes/index.ts에 import 추가 필수
- authenticateToken 미들웨어 적용 필수
# Your Domain
- backend-node/src/controllers/
- backend-node/src/services/
- backend-node/src/routes/
- backend-node/src/middleware/
# Code Rules
1. TypeScript strict mode
2. Error handling with try/catch
3. Comments in Korean
4. Follow existing code patterns
5. Use logger for important operations
6. Parameter binding ($1, $2) for SQL injection prevention
-182
View File
@@ -1,182 +0,0 @@
# WACE ERP 파이프라인 공통 룰 (모든 에이전트 필수 준수)
## 1. 화면 유형 구분 (절대 규칙!)
이 시스템은 **관리자 메뉴**와 **사용자 메뉴**가 완전히 다른 방식으로 동작한다.
기능 구현 시 반드시 어느 유형인지 먼저 판단하라.
### 관리자 메뉴 (Admin)
- **구현 방식**: React 코드 기반 페이지 (`.tsx` 파일)
- **경로**: `frontend/app/(main)/admin/{기능명}/page.tsx`
- **메뉴 등록**: `menu_info` 테이블에 INSERT 필수 (코드만 만들고 메뉴 등록 안 하면 미완성!)
- **대상**: 시스템 설정, 사용자 관리, 결재 관리, 코드 관리 등
- **특징**: 하드코딩된 UI, 관리자만 접근
### 사용자 메뉴 (User/Screen) - 절대 하드코딩 금지!!!
- **구현 방식**: 로우코드 기반 (DB에 JSON으로 화면 구성 저장)
- **데이터 저장**: `screen_layouts_v2` 테이블에 V2 JSON 형식 보관
- **화면 디자이너**: 스크린 디자이너로 드래그앤드롭 구성
- **V2 컴포넌트**: `frontend/lib/registry/components/v2-*` 디렉토리
- **대상**: 일반 업무 화면, BOM, 문서 관리, 포장/적재, 금형 관리 등
- **특징**: 코드 수정 없이 화면 구성 변경 가능
- **절대 금지**: React `.tsx` 페이지 파일로 직접 UI를 하드코딩하는 것!
### 판단 기준
| 질문 | 관리자 메뉴 | 사용자 메뉴 |
|------|-------------|-------------|
| 누가 쓰나? | 시스템 관리자 | 일반 사용자 |
| 화면 구조 고정? | 고정 (코드) | 유동적 (JSON) |
| URL 패턴 | `/admin/*` | `/screen/{screen_code}` |
| 메뉴 등록 | `menu_info` INSERT | `screen_definitions` + `menu_info` INSERT |
| 프론트엔드 코드 | `frontend/app/(main)/admin/` 하위에 page.tsx 작성 | **코드 작성 금지!** DB에 스크린 정의만 등록 |
### 사용자 메뉴 구현 방법 (반드시 이 방식으로!)
**절대 규칙: 사용자 메뉴는 React 페이지(.tsx)를 직접 만들지 않는다!**
이미 `/screen/[screenCode]/page.tsx``/screens/[screenId]/page.tsx` 렌더링 시스템이 존재한다.
새 화면이 필요하면 DB에 등록만 하면 자동으로 렌더링된다.
#### Step 1: screen_definitions에 화면 등록
```sql
INSERT INTO screen_definitions (screen_name, screen_code, table_name, company_code, is_active)
VALUES ('포장/적재정보 관리', 'COMPANY_7_PKG', 'pkg_unit', 'COMPANY_7', 'Y')
RETURNING screen_id;
```
- `screen_code`: `{company_code}_{기능약어}` 형식 (예: COMPANY_7_PKG)
- `table_name`: 메인 테이블명 (V2 컴포넌트가 이 테이블 기준으로 동작)
- `company_code`: 대상 회사 코드
#### Step 2: screen_layouts_v2에 V2 레이아웃 JSON 등록
```sql
INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data)
VALUES (
{screen_id},
'COMPANY_7',
1,
'기본 레이어',
'{
"version": "2.0",
"components": [
{
"id": "comp_split_1",
"url": "@/lib/registry/components/v2-split-panel-layout",
"position": {"x": 0, "y": 0},
"size": {"width": 1200, "height": 800},
"displayOrder": 0,
"overrides": {
"leftTitle": "포장단위 목록",
"rightTitle": "상세 정보",
"splitRatio": 40,
"leftTableName": "pkg_unit",
"rightTableName": "pkg_unit",
"tabs": [
{"id": "basic", "label": "기본정보"},
{"id": "items", "label": "매칭품목"}
]
}
}
]
}'::jsonb
);
```
- V2 컴포넌트 목록: v2-split-panel-layout, v2-table-list, v2-table-search-widget, v2-repeater, v2-button-primary, v2-tabs-widget 등
- 상세 컴포넌트 가이드: `.cursor/rules/component-development-guide.mdc` 참조
#### Step 3: menu_info에 메뉴 등록
```sql
-- 먼저 부모 메뉴 objid 조회
-- SELECT objid, menu_name_kor FROM menu_info WHERE company_code = '{회사코드}' AND menu_name_kor LIKE '%물류%';
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, seq, menu_url, screen_code, company_code, status)
VALUES (
(SELECT COALESCE(MAX(objid), 0) + 1 FROM menu_info),
2, -- 2 = 메뉴 항목
{_objid}, -- 상위 메뉴의 objid
'포장/적재정보',
10, -- 정렬 순서
'/screen/COMPANY_7_PKG', -- /screen/{screen_code} 형식 (절대!)
'COMPANY_7_PKG', -- screen_definitions.screen_code와 일치
'COMPANY_7',
'Y'
);
```
**핵심**: `menu_url`은 반드시 `/screen/{screen_code}` 형식이어야 한다!
프론트엔드가 이 URL을 받아 `screen_definitions`에서 screen_id를 찾고, `screen_layouts_v2`에서 레이아웃을 로드한다.
## 2. 관리자 메뉴 등록 (코드 구현 후 필수!)
관리자 기능을 코드로 만들었으면 반드시 `menu_info`에 등록해야 한다.
```sql
-- 예시: 결재 템플릿 관리 메뉴 등록
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, seq, menu_url, company_code, status)
VALUES (
(SELECT COALESCE(MAX(objid), 0) + 1 FROM menu_info),
2, {_objid}, '결재 템플릿', 40, '/admin/approvalTemplate', '대상회사코드', 'Y'
);
```
- 기존 메뉴 구조를 먼저 조회해서 parent_obj_id, seq 등을 맞춰라
- company_code 별로 등록이 필요할 수 있다
- menu_auth_group 권한 매핑도 필요하면 추가
## 3. 하드코딩 금지 / 범용성 필수
- 특정 회사에만 동작하는 코드 금지
- 특정 사용자 ID에 의존하는 로직 금지
- 매직 넘버 사용 금지 (상수 또는 설정 파일로 관리)
- 하드코딩 색상 금지 (CSS 변수 사용: bg-primary, text-destructive 등)
- 하드코딩 URL 금지 (환경 변수 또는 API 클라이언트 사용)
## 4. 테스트 환경 정보
- **테스트 계정**: userId=`wace`, password=`qlalfqjsgh11`
- **역할**: SUPER_ADMIN (company_code = "*")
- **개발 프론트엔드**: http://localhost:9771
- **개발 백엔드 API**: http://localhost:8080
- **개발 DB**: postgresql://postgres:ph0909!!@39.117.244.52:11132/plm
## 5. 기능 구현 완성 체크리스트
기능 하나를 "완성"이라고 말하려면 아래를 전부 충족해야 한다:
### 공통
- [ ] DB: 마이그레이션 작성 + 실행 완료
- [ ] DB: company_code 컬럼 + 인덱스 존재
- [ ] BE: API 엔드포인트 구현 + 라우트 등록 (app.ts에 import + use 추가!)
- [ ] BE: company_code 필터링 적용
- [ ] 빌드 통과: 백엔드 tsc + 프론트엔드 tsc
### 관리자 메뉴인 경우
- [ ] FE: `frontend/app/(main)/admin/{기능}/page.tsx` 작성
- [ ] FE: API 클라이언트 함수 작성 (lib/api/)
- [ ] DB: `menu_info` INSERT (menu_url = `/admin/{기능}`)
### 사용자 메뉴인 경우 (코드 작성 금지!)
- [ ] DB: `screen_definitions` INSERT (screen_code, table_name, company_code)
- [ ] DB: `screen_layouts_v2` INSERT (V2 레이아웃 JSON)
- [ ] DB: `menu_info` INSERT (menu_url = `/screen/{screen_code}`)
- [ ] BE: 필요한 경우 전용 API 작성 (범용 table-management API로 커버 안 되는 경우만)
- [ ] FE: .tsx 페이지 파일 만들지 않음 (이미 `/screens/[screenId]/page.tsx`가 렌더링)
## 6. 절대 하지 말 것
1. 페이지 파일만 만들고 메뉴 등록 안 하기 (미완성!)
2. fetch() 직접 사용 (lib/api/ 클라이언트 필수)
3. company_code 필터링 빠뜨리기
4. 하드코딩 색상/URL/사용자ID 사용
5. Card 안에 Card 중첩 (중첩 박스 금지)
6. 백엔드 재실행하기 (nodemon이 자동 재시작)
7. **사용자 메뉴를 React 하드코딩(.tsx)으로 만들기 (절대 금지!!!)**
- `frontend/app/(main)/production/`, `frontend/app/(main)/warehouse/` 등에 page.tsx 파일을 만들어서 직접 UI를 코딩하는 것은 절대 금지
- 사용자 메뉴는 반드시 `screen_definitions` + `screen_layouts_v2` + `menu_info` DB 등록 방식으로 구현
- 이미 `/screen/[screenCode]``/screens/[screenId]` 렌더링 시스템이 존재함
- 백엔드 API(controller/routes)와 프론트엔드 API 클라이언트(lib/api/)는 필요하면 코드로 작성 가능
- 하지만 프론트엔드 화면 UI 자체는 DB의 V2 레이아웃 JSON으로만 구성
-50
View File
@@ -1,50 +0,0 @@
---
name: pipeline-db
description: Agent Pipeline DB 전문가. PostgreSQL 스키마 설계, 마이그레이션 작성 및 실행. 모든 테이블에 company_code 필수.
model: inherit
---
# Role
You are a Database specialist for ERP-node project.
Stack: PostgreSQL + Raw Query (no ORM). Migrations in db/migrations/.
# CRITICAL PROJECT RULES
## 1. Multi-tenancy (ABSOLUTE MUST!)
- ALL tables MUST have company_code VARCHAR(20) NOT NULL
- ALL queries MUST filter by company_code
- JOINs MUST include company_code matching condition
- CREATE INDEX on company_code for every table
## 2. Migration Rules
- File naming: NNN_description.sql
- Always include company_code column
- Always create index on company_code
- Use IF NOT EXISTS for idempotent migrations
- Use TIMESTAMPTZ for dates (not TIMESTAMP)
## 3. MIGRATION EXECUTION (절대 규칙!)
마이그레이션 SQL 파일을 생성한 후, 반드시 직접 실행해서 테이블을 생성해라.
절대 사용자에게 "직접 실행해주세요"라고 떠넘기지 마라.
Docker 환경:
```bash
DOCKER_HOST=unix:///Users/gbpark/.orbstack/run/docker.sock docker exec pms-backend-mac node -e "
const {Pool}=require('pg');
const p=new Pool({connectionString:process.env.DATABASE_URL,ssl:false});
const fs=require('fs');
const sql=fs.readFileSync('/app/db/migrations/파일명.sql','utf8');
p.query(sql).then(()=>{console.log('OK');p.end()}).catch(e=>{console.error(e.message);p.end();process.exit(1)})
"
```
# Your Domain
- db/migrations/
- SQL schema design
- Query optimization
# Code Rules
1. PostgreSQL syntax only
2. Parameter binding ($1, $2)
3. Use COALESCE for NULL handling
4. Use TIMESTAMPTZ for dates
-92
View File
@@ -1,92 +0,0 @@
---
name: pipeline-frontend
description: Agent Pipeline 프론트엔드 전문가. Next.js 14 + React + TypeScript + shadcn/ui 기반 화면 구현. fetch 직접 사용 금지, lib/api/ 클라이언트 필수.
model: inherit
---
# Role
You are a Frontend specialist for ERP-node project.
Stack: Next.js 14 + React + TypeScript + Tailwind CSS + shadcn/ui.
# CRITICAL PROJECT RULES
## 1. API Client (ABSOLUTE RULE!)
- NEVER use fetch() directly!
- ALWAYS use lib/api/ clients (Axios-based)
- 환경별 URL 자동 처리: v1.vexplor.com → api.vexplor.com, localhost → localhost:8080
## 2. shadcn/ui Style Rules
- Use CSS variables: bg-primary, text-muted-foreground (하드코딩 색상 금지)
- No nested boxes: Card inside Card is FORBIDDEN
- Responsive: mobile-first approach (flex-col md:flex-row)
## 3. V2 Component Standard
V2 컴포넌트를 만들거나 수정할 때 반드시 이 규격을 따라야 한다.
### 폴더 구조 (필수)
```
frontend/lib/registry/components/v2-{name}/
├── index.ts # createComponentDefinition() 호출
├── types.ts # Config extends ComponentConfig
├── {Name}Component.tsx # React 함수 컴포넌트
├── {Name}Renderer.tsx # extends AutoRegisteringComponentRenderer + registerSelf()
├── {Name}ConfigPanel.tsx # ConfigPanelBuilder 사용
└── config.ts # 기본 설정값 상수
```
### ConfigPanel 규칙 (절대!)
- 반드시 ConfigPanelBuilder 또는 ConfigSection 사용
- 직접 JSX로 설정 UI 작성 금지
## 4. API Client 생성 패턴
```typescript
// frontend/lib/api/yourModule.ts
import apiClient from "@/lib/api/client";
export async function getYourData(id: number) {
const response = await apiClient.get(`/api/your-endpoint/${id}`);
return response.data;
}
```
# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다!!!
**이 프로젝트는 로우코드 스크린 디자이너 시스템을 사용한다.**
사용자 업무 화면(포장관리, 금형관리, BOM, 문서관리 등)은 절대 React 페이지(.tsx)로 직접 UI를 하드코딩하지 않는다!
## 금지 패턴 (절대 하지 말 것)
```
frontend/app/(main)/production/packaging/page.tsx ← 이런 파일 만들지 마라!
frontend/app/(main)/warehouse/something/page.tsx ← 이런 파일 만들지 마라!
```
## 올바른 패턴
사용자 화면은 DB에 등록만 하면 자동으로 렌더링된다:
1. `screen_definitions` 테이블에 화면 등록 (screen_code, table_name 등)
2. `screen_layouts_v2` 테이블에 V2 레이아웃 JSON 등록 (v2-split-panel-layout, v2-table-list 등)
3. `menu_info` 테이블에 메뉴 등록 (menu_url = `/screen/{screen_code}`)
이미 존재하는 렌더링 시스템:
- `/screen/[screenCode]/page.tsx` → screenCode를 screenId로 변환
- `/screens/[screenId]/page.tsx` → screen_layouts_v2에서 V2 레이아웃 로드 → DynamicComponentRenderer로 렌더링
## 프론트엔드 에이전트가 할 수 있는 것
- `frontend/lib/api/` 하위에 API 클라이언트 함수 작성 (백엔드와 통신)
- V2 컴포넌트 자체를 수정/신규 개발 (`frontend/lib/registry/components/v2-*/`)
- 관리자 메뉴(`/admin/*`)는 React 페이지 코딩 가능
## 프론트엔드 에이전트가 할 수 없는 것
- 사용자 메뉴 화면을 React 페이지로 직접 코딩하는 것
# Your Domain
- frontend/components/
- frontend/app/
- frontend/lib/
- frontend/hooks/
# Code Rules
1. TypeScript strict mode
2. React functional components with hooks
3. Prefer shadcn/ui components
4. Use cn() utility for conditional classes
5. Comments in Korean
-64
View File
@@ -1,64 +0,0 @@
---
name: pipeline-ui
description: Agent Pipeline UI/UX 디자인 전문가. 모던 엔터프라이즈 UI 구현. CSS 변수 필수, 하드코딩 색상 금지, 반응형 필수.
model: inherit
---
# Role
You are a UI/UX Design specialist for the ERP-node project.
Stack: Next.js 14 + React + TypeScript + Tailwind CSS + shadcn/ui + lucide-react icons.
# Design Philosophy
- Apple-level polish with enterprise functionality
- Consistent spacing, typography, color usage
- Subtle animations and micro-interactions
- Dark mode compatible using CSS variables
# CRITICAL STYLE RULES
## 1. Color System (CSS Variables ONLY)
- bg-background / text-foreground (base)
- bg-primary / text-primary-foreground (actions)
- bg-muted / text-muted-foreground (secondary)
- bg-destructive / text-destructive-foreground (danger)
FORBIDDEN: bg-gray-50, text-blue-500, bg-white, text-black
## 2. Layout Rules
- No nested boxes (Card inside Card FORBIDDEN)
- Spacing: p-6 for cards, space-y-4 for forms, gap-4 for grids
- Mobile-first responsive: flex-col md:flex-row
## 3. Typography
- Page title: text-3xl font-bold
- Section: text-xl font-semibold
- Body: text-sm
- Helper: text-xs text-muted-foreground
## 4. Components
- ALWAYS use shadcn/ui components
- Use cn() for conditional classes
- Use lucide-react for ALL icons
# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다!!!
사용자 업무 화면(포장관리, 금형관리, BOM 등)의 UI는 DB의 `screen_layouts_v2` 테이블에 V2 레이아웃 JSON으로 정의된다.
React 페이지(.tsx)로 직접 UI를 하드코딩하는 것은 절대 금지!
UI 에이전트가 할 수 있는 것:
- V2 컴포넌트 자체의 스타일/UX 개선 (`frontend/lib/registry/components/v2-*/`)
- 관리자 메뉴(`/admin/*`) 페이지의 UI 개선
- 공통 UI 컴포넌트(`frontend/components/ui/`) 스타일 개선
UI 에이전트가 할 수 없는 것:
- 사용자 메뉴 화면을 React 페이지로 직접 코딩
# Your Domain
- frontend/components/ (UI components)
- frontend/app/ (pages - 관리자 메뉴만)
- frontend/lib/registry/components/v2-*/ (V2 컴포넌트)
# Output Rules
1. TypeScript strict mode
2. "use client" for client components
3. Comments in Korean
4. MINIMAL targeted changes when modifying existing files
-57
View File
@@ -1,57 +0,0 @@
---
name: pipeline-verifier
description: Agent Pipeline 검증 전문가. 구현 완료 후 실제 동작 검증. 빈 껍데기 탐지, 패턴 준수 확인, 멀티테넌시 검증.
model: fast
readonly: true
---
# Role
You are a skeptical validator for the ERP-node project.
Your job is to verify that work claimed as complete actually works.
# Verification Checklist
## 1. Multi-tenancy (최우선)
- [ ] 모든 SQL에 company_code 필터 존재
- [ ] req.user!.companyCode 사용 (클라이언트 입력 아님)
- [ ] INSERT에 company_code 포함
- [ ] JOIN에 company_code 매칭 조건 존재
- [ ] company_code = "*" 최고관리자 예외 처리
## 2. Empty Shell Detection (빈 껍데기)
- [ ] API가 실제 DB 쿼리 실행 (mock 아님)
- [ ] 컴포넌트가 실제 데이터 로딩 (하드코딩 아님)
- [ ] TODO/FIXME/placeholder 없음
- [ ] 타입만 정의하고 구현 없는 함수 없음
## 3. Pattern Compliance (패턴 준수)
- [ ] Frontend: fetch 직접 사용 안 함 (lib/api/ 사용)
- [ ] Frontend: CSS 변수 사용 (하드코딩 색상 없음)
- [ ] Frontend: V2 컴포넌트 규격 준수
- [ ] Backend: logger 사용
- [ ] Backend: try/catch 에러 처리
## 4. Integration Check
- [ ] Route가 index.ts에 등록됨
- [ ] Import 경로 정확
- [ ] Export 존재
- [ ] TypeScript 타입 일치
# Reporting Format
```
## 검증 결과: [PASS/FAIL]
### 통과 항목
- item 1
- item 2
### 실패 항목
- item 1: 구체적 이유
- item 2: 구체적 이유
### 권장 수정사항
- fix 1
- fix 2
```
Do not accept claims at face value. Check the actual code.
+38
View File
@@ -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 (최고 관리자)
+1
View File
@@ -153,6 +153,7 @@ backend-node/uploads/
uploads/
*.jpg
*.jpeg
*.png
*.gif
*.pdf
*.doc
Binary file not shown.

Before

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1,318 @@
/**
* 탑씰(company_7) 버튼 스타일 일괄 변경 스크립트
*
* 사용법:
* npx ts-node scripts/btn-bulk-update-company7.ts --test # 1건만 테스트 (ROLLBACK)
* npx ts-node scripts/btn-bulk-update-company7.ts --run # 전체 실행 (COMMIT)
* npx ts-node scripts/btn-bulk-update-company7.ts --backup # 백업 테이블만 생성
* npx ts-node scripts/btn-bulk-update-company7.ts --restore # 백업에서 원복
*/
import { Pool } from "pg";
// ── 배포 DB 연결 ──
const pool = new Pool({
connectionString:
"postgresql://postgres:vexplor0909!!@211.115.91.141:11134/vexplor",
});
const COMPANY_CODE = "COMPANY_7";
const BACKUP_TABLE = "screen_layouts_v2_backup_20260313";
// ── 액션별 기본 아이콘 매핑 (frontend/lib/button-icon-map.tsx 기준) ──
const actionIconMap: Record<string, string> = {
save: "Check",
delete: "Trash2",
edit: "Pencil",
navigate: "ArrowRight",
modal: "Maximize2",
transferData: "SendHorizontal",
excel_download: "Download",
excel_upload: "Upload",
quickInsert: "Zap",
control: "Settings",
barcode_scan: "ScanLine",
operation_control: "Truck",
event: "Send",
copy: "Copy",
};
const FALLBACK_ICON = "SquareMousePointer";
function getIconForAction(actionType?: string): string {
if (actionType && actionIconMap[actionType]) {
return actionIconMap[actionType];
}
return FALLBACK_ICON;
}
// ── 버튼 컴포넌트인지 판별 (최상위 + 탭 내부 둘 다 지원) ──
function isTopLevelButton(comp: any): boolean {
return (
comp.url?.includes("v2-button-primary") ||
comp.overrides?.type === "v2-button-primary"
);
}
function isTabChildButton(comp: any): boolean {
return comp.componentType === "v2-button-primary";
}
function isButtonComponent(comp: any): boolean {
return isTopLevelButton(comp) || isTabChildButton(comp);
}
// ── 탭 위젯인지 판별 ──
function isTabsWidget(comp: any): boolean {
return (
comp.url?.includes("v2-tabs-widget") ||
comp.overrides?.type === "v2-tabs-widget"
);
}
// ── 버튼 스타일 변경 (최상위 버튼용: overrides 사용) ──
function applyButtonStyle(config: any, actionType: string | undefined) {
const iconName = getIconForAction(actionType);
config.displayMode = "icon-text";
config.icon = {
name: iconName,
type: "lucide",
size: "보통",
...(config.icon?.color ? { color: config.icon.color } : {}),
};
config.iconTextPosition = "right";
config.iconGap = 6;
if (!config.style) config.style = {};
delete config.style.width; // 레거시 하드코딩 너비 제거 (size.width만 사용)
config.style.borderRadius = "8px";
config.style.labelColor = "#FFFFFF";
config.style.fontSize = "12px";
config.style.fontWeight = "normal";
config.style.labelTextAlign = "left";
if (actionType === "delete") {
config.style.backgroundColor = "#F04544";
} else if (actionType === "excel_upload" || actionType === "excel_download") {
config.style.backgroundColor = "#212121";
} else {
config.style.backgroundColor = "#3B83F6";
}
}
function updateButtonStyle(comp: any): boolean {
if (isTopLevelButton(comp)) {
const overrides = comp.overrides || {};
const actionType = overrides.action?.type;
if (!comp.size) comp.size = {};
comp.size.height = 40;
applyButtonStyle(overrides, actionType);
comp.overrides = overrides;
return true;
}
if (isTabChildButton(comp)) {
const config = comp.componentConfig || {};
const actionType = config.action?.type;
if (!comp.size) comp.size = {};
comp.size.height = 40;
applyButtonStyle(config, actionType);
comp.componentConfig = config;
// 탭 내부 버튼은 렌더러가 comp.style (최상위)에서 스타일을 읽음
if (!comp.style) comp.style = {};
comp.style.borderRadius = "8px";
comp.style.labelColor = "#FFFFFF";
comp.style.fontSize = "12px";
comp.style.fontWeight = "normal";
comp.style.labelTextAlign = "left";
comp.style.backgroundColor = config.style.backgroundColor;
return true;
}
return false;
}
// ── 백업 테이블 생성 ──
async function createBackup() {
console.log(`\n=== 백업 테이블 생성: ${BACKUP_TABLE} ===`);
const exists = await pool.query(
`SELECT to_regclass($1) AS tbl`,
[BACKUP_TABLE],
);
if (exists.rows[0].tbl) {
console.log(`백업 테이블이 이미 존재합니다: ${BACKUP_TABLE}`);
const count = await pool.query(`SELECT COUNT(*) FROM ${BACKUP_TABLE}`);
console.log(`기존 백업 레코드 수: ${count.rows[0].count}`);
return;
}
await pool.query(
`CREATE TABLE ${BACKUP_TABLE} AS
SELECT * FROM screen_layouts_v2
WHERE company_code = $1`,
[COMPANY_CODE],
);
const count = await pool.query(`SELECT COUNT(*) FROM ${BACKUP_TABLE}`);
console.log(`백업 완료. 레코드 수: ${count.rows[0].count}`);
}
// ── 백업에서 원복 ──
async function restoreFromBackup() {
console.log(`\n=== 백업에서 원복: ${BACKUP_TABLE} ===`);
const result = await pool.query(
`UPDATE screen_layouts_v2 AS target
SET layout_data = backup.layout_data,
updated_at = backup.updated_at
FROM ${BACKUP_TABLE} AS backup
WHERE target.screen_id = backup.screen_id
AND target.company_code = backup.company_code
AND target.layer_id = backup.layer_id`,
);
console.log(`원복 완료. 변경된 레코드 수: ${result.rowCount}`);
}
// ── 메인: 버튼 일괄 변경 ──
async function updateButtons(testMode: boolean) {
const modeLabel = testMode ? "테스트 (1건, ROLLBACK)" : "전체 실행 (COMMIT)";
console.log(`\n=== 버튼 일괄 변경 시작 [${modeLabel}] ===`);
// company_7 레코드 조회
const rows = await pool.query(
`SELECT screen_id, layer_id, company_code, layout_data
FROM screen_layouts_v2
WHERE company_code = $1
ORDER BY screen_id, layer_id`,
[COMPANY_CODE],
);
console.log(`대상 레코드 수: ${rows.rowCount}`);
if (!rows.rowCount) {
console.log("변경할 레코드가 없습니다.");
return;
}
const client = await pool.connect();
try {
await client.query("BEGIN");
let totalUpdated = 0;
let totalButtons = 0;
const targetRows = testMode ? [rows.rows[0]] : rows.rows;
for (const row of targetRows) {
const layoutData = row.layout_data;
if (!layoutData?.components || !Array.isArray(layoutData.components)) {
continue;
}
let buttonsInRow = 0;
for (const comp of layoutData.components) {
// 최상위 버튼 처리
if (updateButtonStyle(comp)) {
buttonsInRow++;
}
// 탭 위젯 내부 버튼 처리
if (isTabsWidget(comp)) {
const tabs = comp.overrides?.tabs || [];
for (const tab of tabs) {
const tabComps = tab.components || [];
for (const tabComp of tabComps) {
if (updateButtonStyle(tabComp)) {
buttonsInRow++;
}
}
}
}
}
if (buttonsInRow > 0) {
await client.query(
`UPDATE screen_layouts_v2
SET layout_data = $1, updated_at = NOW()
WHERE screen_id = $2 AND company_code = $3 AND layer_id = $4`,
[JSON.stringify(layoutData), row.screen_id, row.company_code, row.layer_id],
);
totalUpdated++;
totalButtons += buttonsInRow;
console.log(
` screen_id=${row.screen_id}, layer_id=${row.layer_id} → 버튼 ${buttonsInRow}개 변경`,
);
// 테스트 모드: 변경 전후 비교를 위해 첫 번째 버튼 출력
if (testMode) {
const sampleBtn = layoutData.components.find(isButtonComponent);
if (sampleBtn) {
console.log("\n--- 변경 후 샘플 버튼 ---");
console.log(JSON.stringify(sampleBtn, null, 2));
}
}
}
}
console.log(`\n--- 결과 ---`);
console.log(`변경된 레코드: ${totalUpdated}`);
console.log(`변경된 버튼: ${totalButtons}`);
if (testMode) {
await client.query("ROLLBACK");
console.log("\n[테스트 모드] ROLLBACK 완료. 실제 DB 변경 없음.");
} else {
await client.query("COMMIT");
console.log("\nCOMMIT 완료.");
}
} catch (err) {
await client.query("ROLLBACK");
console.error("\n에러 발생. ROLLBACK 완료.", err);
throw err;
} finally {
client.release();
}
}
// ── CLI 진입점 ──
async function main() {
const arg = process.argv[2];
if (!arg || !["--test", "--run", "--backup", "--restore"].includes(arg)) {
console.log("사용법:");
console.log(" --test : 1건 테스트 (ROLLBACK, DB 변경 없음)");
console.log(" --run : 전체 실행 (COMMIT)");
console.log(" --backup : 백업 테이블 생성");
console.log(" --restore : 백업에서 원복");
process.exit(1);
}
try {
if (arg === "--backup") {
await createBackup();
} else if (arg === "--restore") {
await restoreFromBackup();
} else if (arg === "--test") {
await createBackup();
await updateButtons(true);
} else if (arg === "--run") {
await createBackup();
await updateButtons(false);
}
} catch (err) {
console.error("스크립트 실행 실패:", err);
process.exit(1);
} finally {
await pool.end();
}
}
main();
+4
View File
@@ -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); // 카테고리 값 관리
@@ -314,13 +314,14 @@ router.post(
async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { ruleId } = req.params;
const { formData } = req.body; // 폼 데이터 (카테고리 기반 채번 시 사용)
const { formData, manualInputValue } = req.body;
try {
const previewCode = await numberingRuleService.previewCode(
ruleId,
companyCode,
formData
formData,
manualInputValue
);
return res.json({ success: true, data: { generatedCode: previewCode } });
} catch (error: any) {
@@ -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,233 @@
/**
* 생산계획 컨트롤러
*/
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,
});
}
}
// ─── 자동 스케줄 미리보기 (실제 INSERT 없이 예상 결과 반환) ───
export async function previewSchedule(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
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.previewSchedule(companyCode, items, options || {});
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 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 previewSemiSchedule(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
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.previewSemiSchedule(
companyCode,
plan_ids,
options || {}
);
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 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,
});
}
}
@@ -2058,6 +2058,119 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
});
});
// 6. v2-repeater 컴포넌트에서 selectedTable/foreignKey 추출
const v2RepeaterQuery = `
SELECT DISTINCT
sd.screen_id,
sd.screen_name,
sd.table_name as main_table,
comp->'overrides'->>'type' as component_type,
comp->'overrides'->>'selectedTable' as sub_table,
comp->'overrides'->>'foreignKey' as foreign_key,
comp->'overrides'->>'parentTable' as parent_table
FROM screen_definitions sd
JOIN screen_layouts_v2 slv2 ON sd.screen_id = slv2.screen_id,
jsonb_array_elements(slv2.layout_data->'components') as comp
WHERE sd.screen_id = ANY($1)
AND comp->'overrides'->>'type' = 'v2-repeater'
AND comp->'overrides'->>'selectedTable' IS NOT NULL
`;
const v2RepeaterResult = await pool.query(v2RepeaterQuery, [screenIds]);
v2RepeaterResult.rows.forEach((row: any) => {
const screenId = row.screen_id;
const mainTable = row.main_table;
const subTable = row.sub_table;
const foreignKey = row.foreign_key;
if (!subTable || subTable === mainTable) return;
if (!screenSubTables[screenId]) {
screenSubTables[screenId] = {
screenId,
screenName: row.screen_name,
mainTable: mainTable || '',
subTables: [],
};
}
const exists = screenSubTables[screenId].subTables.some(
(st) => st.tableName === subTable
);
if (!exists) {
screenSubTables[screenId].subTables.push({
tableName: subTable,
componentType: 'v2-repeater',
relationType: 'rightPanelRelation',
fieldMappings: foreignKey ? [{
sourceField: 'id',
targetField: foreignKey,
sourceDisplayName: 'ID',
targetDisplayName: foreignKey,
}] : undefined,
});
}
});
logger.info("v2-repeater 서브 테이블 추출 완료", {
screenIds,
v2RepeaterCount: v2RepeaterResult.rows.length,
});
// 7. rightPanel.components 내부의 componentConfig.detailTable 추출 (v2-bom-tree 등)
const v2DetailTableQuery = `
SELECT DISTINCT
sd.screen_id,
sd.screen_name,
sd.table_name as main_table,
inner_comp->>'type' as component_type,
inner_comp->'componentConfig'->>'detailTable' as sub_table,
inner_comp->'componentConfig'->>'foreignKey' as foreign_key
FROM screen_definitions sd
JOIN screen_layouts_v2 slv2 ON sd.screen_id = slv2.screen_id,
jsonb_array_elements(slv2.layout_data->'components') as comp,
jsonb_array_elements(
COALESCE(
comp->'overrides'->'rightPanel'->'components',
comp->'overrides'->'leftPanel'->'components',
'[]'::jsonb
)
) as inner_comp
WHERE sd.screen_id = ANY($1)
AND inner_comp->'componentConfig'->>'detailTable' IS NOT NULL
`;
const v2DetailTableResult = await pool.query(v2DetailTableQuery, [screenIds]);
v2DetailTableResult.rows.forEach((row: any) => {
const screenId = row.screen_id;
const mainTable = row.main_table;
const subTable = row.sub_table;
const foreignKey = row.foreign_key;
if (!subTable || subTable === mainTable) return;
if (!screenSubTables[screenId]) {
screenSubTables[screenId] = {
screenId,
screenName: row.screen_name,
mainTable: mainTable || '',
subTables: [],
};
}
const exists = screenSubTables[screenId].subTables.some(
(st) => st.tableName === subTable
);
if (!exists) {
screenSubTables[screenId].subTables.push({
tableName: subTable,
componentType: row.component_type || 'v2-bom-tree',
relationType: 'rightPanelRelation',
fieldMappings: foreignKey ? [{
sourceField: 'id',
targetField: foreignKey,
sourceDisplayName: 'ID',
targetDisplayName: foreignKey,
}] : undefined,
});
}
});
logger.info("v2-bom-tree/detailTable 서브 테이블 추출 완료", {
screenIds,
v2DetailTableCount: v2DetailTableResult.rows.length,
});
// ============================================================
// 저장 테이블 정보 추출
// ============================================================
+52 -7
View File
@@ -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,42 @@
/**
* 생산계획 라우트
*/
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/preview", productionController.previewSchedule);
// 자동 스케줄 생성
router.post("/generate-schedule", productionController.generateSchedule);
// 스케줄 병합
router.post("/merge-schedules", productionController.mergeSchedules);
// 반제품 계획 미리보기
router.post("/generate-semi-schedule/preview", productionController.previewSemiSchedule);
// 반제품 계획 자동 생성
router.post("/generate-semi-schedule", productionController.generateSemiSchedule);
// 스케줄 분할
router.post("/plan/:id/split", productionController.splitSchedule);
export default router;
@@ -823,6 +823,76 @@ export class EntityJoinService {
return [];
}
}
/**
* 콤마 구분 다중값 해결 (겸직 부서 등)
* entity join이 NULL인데 소스값에 콤마가 있으면 개별 코드를 각각 조회해서 라벨로 변환
*/
async resolveCommaValues(
data: Record<string, any>[],
joinConfigs: EntityJoinConfig[]
): Promise<Record<string, any>[]> {
if (!data.length || !joinConfigs.length) return data;
for (const config of joinConfigs) {
const sourceCol = config.sourceColumn;
const displayCol = config.displayColumns?.[0] || config.displayColumn;
if (!displayCol || displayCol === "none") continue;
const aliasCol = config.aliasColumn || `${sourceCol}_${displayCol}`;
const labelCol = `${sourceCol}_label`;
const codesSet = new Set<string>();
const rowsToResolve: number[] = [];
data.forEach((row, idx) => {
const srcVal = row[sourceCol];
if (!srcVal || typeof srcVal !== "string" || !srcVal.includes(",")) return;
const joinedVal = row[aliasCol] || row[labelCol];
if (joinedVal && joinedVal !== "") return;
rowsToResolve.push(idx);
srcVal.split(",").map((v: string) => v.trim()).filter(Boolean).forEach((code: string) => codesSet.add(code));
});
if (codesSet.size === 0) continue;
const codes = Array.from(codesSet);
const refCol = config.referenceColumn || "id";
const placeholders = codes.map((_, i) => `$${i + 1}`).join(",");
try {
const result = await query<Record<string, any>>(
`SELECT "${refCol}"::TEXT as _key, "${displayCol}"::TEXT as _label
FROM ${config.referenceTable}
WHERE "${refCol}"::TEXT IN (${placeholders})`,
codes
);
const labelMap = new Map<string, string>();
result.forEach((r) => labelMap.set(r._key, r._label));
for (const idx of rowsToResolve) {
const srcVal = data[idx][sourceCol] as string;
const resolvedLabels = srcVal
.split(",")
.map((v: string) => v.trim())
.filter(Boolean)
.map((code: string) => labelMap.get(code) || code)
.join(", ");
data[idx][aliasCol] = resolvedLabels;
data[idx][labelCol] = resolvedLabels;
}
logger.info(`콤마 구분 entity 값 해결: ${sourceCol}${codesSet.size}개 코드, ${rowsToResolve.length}개 행`);
} catch (err) {
logger.warn(`콤마 구분 entity 값 해결 실패: ${sourceCol}`, err);
}
}
return data;
}
}
export const entityJoinService = new EntityJoinService();
+526 -300
View File
@@ -39,7 +39,9 @@ function joinPartsWithSeparators(partValues: string[], sortedParts: any[], globa
result += val;
if (idx < partValues.length - 1) {
const sep = sortedParts[idx].separatorAfter ?? sortedParts[idx].autoConfig?.separatorAfter ?? globalSeparator;
result += sep;
if (val || !result.endsWith(sep)) {
result += sep;
}
}
});
return result;
@@ -74,16 +76,22 @@ class NumberingRuleService {
*/
private async buildPrefixKey(
rule: NumberingRuleConfig,
formData?: Record<string, any>
formData?: Record<string, any>,
manualValues?: string[]
): Promise<string> {
const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
const prefixParts: string[] = [];
let manualIndex = 0;
for (const part of sortedParts) {
if (part.partType === "sequence") continue;
if (part.generationMethod === "manual") {
// 수동 입력 파트는 prefix에서 제외 (값이 매번 달라질 수 있으므로)
const manualValue = manualValues?.[manualIndex] || "";
manualIndex++;
if (manualValue) {
prefixParts.push(manualValue);
}
continue;
}
@@ -227,6 +235,312 @@ class NumberingRuleService {
);
return result.rows[0].current_sequence;
}
/**
* 카운터를 특정 값 이상으로 동기화 (GREATEST 사용)
* 테이블 내 실제 최대값이 카운터보다 높을 때 카운터를 맞춰줌
*/
private async setSequenceForPrefix(
client: any,
ruleId: string,
companyCode: string,
prefixKey: string,
targetSequence: number
): Promise<number> {
const result = await client.query(
`INSERT INTO numbering_rule_sequences (rule_id, company_code, prefix_key, current_sequence, last_allocated_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (rule_id, company_code, prefix_key)
DO UPDATE SET current_sequence = GREATEST(numbering_rule_sequences.current_sequence, $4),
last_allocated_at = NOW()
RETURNING current_sequence`,
[ruleId, companyCode, prefixKey, targetSequence]
);
return result.rows[0].current_sequence;
}
/**
* 대상 테이블에서 해당 회사의 최대 시퀀스 번호를 조회
* 코드의 prefix/suffix 패턴을 기반으로 sequence 부분만 추출하여 MAX 계산
*/
private async getMaxSequenceFromTable(
client: any,
tableName: string,
columnName: string,
codePrefix: string,
codeSuffix: string,
seqLength: number,
companyCode: string
): Promise<number> {
try {
// 테이블에 company_code 컬럼이 있는지 확인
const colCheck = await client.query(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[tableName]
);
const hasCompanyCode = colCheck.rows.length > 0;
// 대상 컬럼 존재 여부 확인
const targetColCheck = await client.query(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = $2`,
[tableName, columnName]
);
if (targetColCheck.rows.length === 0) {
logger.warn(`getMaxSequenceFromTable: 컬럼 없음 ${tableName}.${columnName}`);
return 0;
}
// prefix와 suffix 사이의 sequence 부분을 추출하기 위한 위치 계산
const prefixLen = codePrefix.length;
const seqStart = prefixLen + 1; // SQL SUBSTRING은 1-based
// LIKE 패턴: prefix + N자리 숫자 + suffix
const likePattern = codePrefix + "%" + codeSuffix;
let sql: string;
let params: any[];
if (hasCompanyCode && companyCode !== "*") {
sql = `
SELECT MAX(
CAST(SUBSTRING("${columnName}" FROM $1 FOR $2) AS INTEGER)
) as max_seq
FROM "${tableName}"
WHERE "${columnName}" LIKE $3
AND company_code = $4
AND LENGTH("${columnName}") = $5
AND SUBSTRING("${columnName}" FROM $1 FOR $2) ~ '^[0-9]+$'
`;
params = [seqStart, seqLength, likePattern, companyCode, prefixLen + seqLength + codeSuffix.length];
} else {
sql = `
SELECT MAX(
CAST(SUBSTRING("${columnName}" FROM $1 FOR $2) AS INTEGER)
) as max_seq
FROM "${tableName}"
WHERE "${columnName}" LIKE $3
AND LENGTH("${columnName}") = $4
AND SUBSTRING("${columnName}" FROM $1 FOR $2) ~ '^[0-9]+$'
`;
params = [seqStart, seqLength, likePattern, prefixLen + seqLength + codeSuffix.length];
}
const result = await client.query(sql, params);
const maxSeq = result.rows[0]?.max_seq ?? 0;
logger.info("getMaxSequenceFromTable 결과", {
tableName, columnName, codePrefix, codeSuffix,
seqLength, companyCode, maxSeq,
});
return maxSeq;
} catch (error: any) {
logger.warn("getMaxSequenceFromTable 실패 (카운터 폴백)", {
tableName, columnName, error: error.message,
});
return 0;
}
}
/**
* 규칙의 파트 구성에서 sequence 파트 전후의 prefix/suffix를 계산
* allocateCode/previewCode에서 비-sequence 파트 값이 이미 계산된 후 호출
*/
private buildCodePrefixSuffix(
partValues: string[],
sortedParts: any[],
globalSeparator: string
): { prefix: string; suffix: string; seqIndex: number; seqLength: number } | null {
const seqIndex = sortedParts.findIndex((p: any) => p.partType === "sequence");
if (seqIndex === -1) return null;
const seqLength = sortedParts[seqIndex].autoConfig?.sequenceLength || 3;
// prefix: sequence 파트 이전의 모든 파트값 + 구분자
let prefix = "";
for (let i = 0; i < seqIndex; i++) {
prefix += partValues[i];
const sep = sortedParts[i].separatorAfter ?? sortedParts[i].autoConfig?.separatorAfter ?? globalSeparator;
prefix += sep;
}
// suffix: sequence 파트 이후의 모든 파트값 + 구분자
let suffix = "";
for (let i = seqIndex + 1; i < partValues.length; i++) {
const sep = sortedParts[i - 1].separatorAfter ?? sortedParts[i - 1].autoConfig?.separatorAfter ?? globalSeparator;
if (i === seqIndex + 1) {
// sequence 파트 바로 뒤 구분자
const seqSep = sortedParts[seqIndex].separatorAfter ?? sortedParts[seqIndex].autoConfig?.separatorAfter ?? globalSeparator;
suffix += seqSep;
}
suffix += partValues[i];
if (i < partValues.length - 1) {
const nextSep = sortedParts[i].separatorAfter ?? sortedParts[i].autoConfig?.separatorAfter ?? globalSeparator;
suffix += nextSep;
}
}
return { prefix, suffix, seqIndex, seqLength };
}
/**
* 비-sequence 파트의 값을 계산하여 prefix/suffix 패턴 구축에 사용
* sequence 파트는 빈 문자열로 반환 (이후 buildCodePrefixSuffix에서 처리)
*/
private async computeNonSequenceValues(
rule: NumberingRuleConfig,
formData?: Record<string, any>
): Promise<string[]> {
const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
return Promise.all(sortedParts.map(async (part: any) => {
if (part.partType === "sequence") return "";
if (part.generationMethod === "manual") return "";
const autoConfig = part.autoConfig || {};
switch (part.partType) {
case "text":
return autoConfig.textValue || "TEXT";
case "number": {
const length = autoConfig.numberLength || 3;
const value = autoConfig.numberValue || 1;
return String(value).padStart(length, "0");
}
case "date": {
const dateFormat = autoConfig.dateFormat || "YYYYMMDD";
if (autoConfig.useColumnValue && autoConfig.sourceColumnName && formData) {
const columnValue = formData[autoConfig.sourceColumnName];
if (columnValue) {
const dateValue = columnValue instanceof Date ? columnValue : new Date(columnValue);
if (!isNaN(dateValue.getTime())) {
return this.formatDate(dateValue, dateFormat);
}
}
}
return this.formatDate(new Date(), dateFormat);
}
case "category": {
const categoryKey = autoConfig.categoryKey;
const categoryMappings = autoConfig.categoryMappings || [];
if (!categoryKey || !formData) return "";
const colName = categoryKey.includes(".") ? categoryKey.split(".")[1] : categoryKey;
const selectedValue = formData[colName];
if (!selectedValue) return "";
const selectedValueStr = String(selectedValue);
let mapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === selectedValueStr) return true;
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) return true;
if (m.categoryValueLabel === selectedValueStr) return true;
return false;
});
if (!mapping) {
try {
const pool = getPool();
const [ct, cc] = categoryKey.includes(".") ? categoryKey.split(".") : [categoryKey, categoryKey];
const cvResult = await pool.query(
`SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
[ct, cc, selectedValueStr]
);
if (cvResult.rows.length > 0) {
mapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === String(cvResult.rows[0].value_id)) return true;
if (m.categoryValueLabel === cvResult.rows[0].value_label) return true;
return false;
});
}
} catch { /* ignore */ }
}
return mapping?.format || "";
}
case "reference": {
const refColumn = autoConfig.referenceColumnName;
if (refColumn && formData && formData[refColumn]) {
return String(formData[refColumn]);
}
return "";
}
default:
return "";
}
}));
}
/**
* 대상 테이블 기반으로 실제 최대 시퀀스를 확인하고,
* 카운터와 비교하여 더 높은 값 + 1을 반환
*/
private async resolveNextSequence(
client: any,
rule: NumberingRuleConfig,
companyCode: string,
ruleId: string,
prefixKey: string,
formData?: Record<string, any>
): Promise<number> {
// 1. 현재 저장된 카운터 조회
const currentCounter = await this.getSequenceForPrefix(
client, ruleId, companyCode, prefixKey
);
let baseSequence = currentCounter;
// 2. 규칙에 tableName/columnName이 설정되어 있으면 대상 테이블에서 MAX 조회
if (rule.tableName && rule.columnName) {
try {
const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
const patternValues = await this.computeNonSequenceValues(rule, formData);
const psInfo = this.buildCodePrefixSuffix(patternValues, sortedParts, rule.separator || "");
if (psInfo) {
const maxFromTable = await this.getMaxSequenceFromTable(
client, rule.tableName, rule.columnName,
psInfo.prefix, psInfo.suffix, psInfo.seqLength, companyCode
);
if (maxFromTable > baseSequence) {
logger.info("테이블 내 최대값이 카운터보다 높음 → 동기화", {
ruleId, companyCode, currentCounter, maxFromTable,
});
baseSequence = maxFromTable;
}
}
} catch (error: any) {
logger.warn("테이블 기반 MAX 조회 실패, 카운터 기반 폴백", {
ruleId, error: error.message,
});
}
}
// 3. 다음 시퀀스 = base + 1
const nextSequence = baseSequence + 1;
// 4. 카운터를 동기화 (GREATEST 사용)
await this.setSequenceForPrefix(client, ruleId, companyCode, prefixKey, nextSequence);
// 5. 호환성을 위해 numbering_rules.current_sequence도 업데이트
await client.query(
"UPDATE numbering_rules SET current_sequence = GREATEST(COALESCE(current_sequence, 0), $3) WHERE rule_id = $1 AND company_code = $2",
[ruleId, companyCode, nextSequence]
);
logger.info("resolveNextSequence 완료", {
ruleId, companyCode, prefixKey, currentCounter, baseSequence, nextSequence,
});
return nextSequence;
}
/**
* 규칙 목록 조회 (전체)
*/
@@ -1078,22 +1392,59 @@ class NumberingRuleService {
* @param ruleId 채번 규칙 ID
* @param companyCode 회사 코드
* @param formData 폼 데이터 (카테고리 기반 채번 시 사용)
* @param manualInputValue 수동 입력 값 (접두어별 순번 조회용)
*/
async previewCode(
ruleId: string,
companyCode: string,
formData?: Record<string, any>
formData?: Record<string, any>,
manualInputValue?: string
): Promise<string> {
const rule = await this.getRuleById(ruleId, companyCode);
if (!rule) throw new Error("규칙을 찾을 수 없습니다");
// prefix_key 기반 순번 조회
const prefixKey = await this.buildPrefixKey(rule, formData);
const pool = getPool();
const currentSeq = await this.getSequenceForPrefix(pool, ruleId, companyCode, prefixKey);
// 수동 파트가 있는데 입력값이 없으면 레거시 공용 시퀀스 조회를 건너뜀
const hasManualPart = rule.parts.some((p: any) => p.generationMethod === "manual");
const skipSequenceLookup = hasManualPart && !manualInputValue;
logger.info("미리보기: prefix_key 기반 순번 조회", {
ruleId, prefixKey, currentSeq,
// prefix_key 기반 순번 조회 + 테이블 내 최대값과 비교
const manualValues = manualInputValue ? [manualInputValue] : undefined;
const prefixKey = await this.buildPrefixKey(rule, formData, manualValues);
const pool = getPool();
const currentSeq = skipSequenceLookup
? 0
: await this.getSequenceForPrefix(pool, ruleId, companyCode, prefixKey);
// 대상 테이블에서 실제 최대 시퀀스 조회
let baseSeq = currentSeq;
if (rule.tableName && rule.columnName) {
try {
const sortedPartsForPattern = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
const patternValues = await this.computeNonSequenceValues(rule, formData);
const psInfo = this.buildCodePrefixSuffix(patternValues, sortedPartsForPattern, rule.separator || "");
if (psInfo) {
const maxFromTable = await this.getMaxSequenceFromTable(
pool, rule.tableName, rule.columnName,
psInfo.prefix, psInfo.suffix, psInfo.seqLength, companyCode
);
if (maxFromTable > baseSeq) {
logger.info("미리보기: 테이블 내 최대값이 카운터보다 높음", {
ruleId, companyCode, currentSeq, maxFromTable,
});
baseSeq = maxFromTable;
}
}
} catch (error: any) {
logger.warn("미리보기: 테이블 기반 MAX 조회 실패, 카운터 기반 폴백", {
ruleId, error: error.message,
});
}
}
logger.info("미리보기: 순번 조회 완료", {
ruleId, prefixKey, currentSeq, baseSeq, skipSequenceLookup,
});
const parts = await Promise.all(rule.parts
@@ -1108,7 +1459,8 @@ class NumberingRuleService {
switch (part.partType) {
case "sequence": {
const length = autoConfig.sequenceLength || 3;
const nextSequence = currentSeq + 1;
const startFrom = autoConfig.startFrom || 1;
const nextSequence = baseSeq + startFrom;
return String(nextSequence).padStart(length, "0");
}
@@ -1150,110 +1502,8 @@ class NumberingRuleService {
return autoConfig.textValue || "TEXT";
}
case "category": {
// 카테고리 기반 코드 생성
const categoryKey = autoConfig.categoryKey; // 예: "item_info.material"
const categoryMappings = autoConfig.categoryMappings || [];
if (!categoryKey || !formData) {
logger.warn("카테고리 키 또는 폼 데이터 없음", {
categoryKey,
hasFormData: !!formData,
});
return "";
}
// categoryKey에서 컬럼명 추출 (예: "item_info.material" -> "material")
const columnName = categoryKey.includes(".")
? categoryKey.split(".")[1]
: categoryKey;
// 폼 데이터에서 해당 컬럼의 값 가져오기
const selectedValue = formData[columnName];
logger.info("카테고리 파트 처리", {
categoryKey,
columnName,
selectedValue,
formDataKeys: Object.keys(formData),
mappingsCount: categoryMappings.length,
});
if (!selectedValue) {
logger.warn("카테고리 값이 선택되지 않음", {
columnName,
formDataKeys: Object.keys(formData),
});
return "";
}
// 카테고리 매핑에서 해당 값에 대한 형식 찾기
// selectedValue는 valueCode일 수 있음 (V2Select에서 valueCode를 value로 사용)
const selectedValueStr = String(selectedValue);
let mapping = categoryMappings.find((m: any) => {
// ID로 매칭 (기존 방식: V2Select가 valueId를 사용하던 경우)
if (m.categoryValueId?.toString() === selectedValueStr)
return true;
// valueCode로 매칭 (매핑에 categoryValueCode가 있는 경우)
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr)
return true;
// 라벨로 매칭 (폴백)
if (m.categoryValueLabel === selectedValueStr) return true;
return false;
});
// 매핑을 못 찾았으면 category_values 테이블에서 valueCode → valueId 역변환 시도
if (!mapping) {
try {
const pool = getPool();
const [catTableName, catColumnName] = categoryKey.includes(".")
? categoryKey.split(".")
: [categoryKey, categoryKey];
const cvResult = await pool.query(
`SELECT value_id, value_code, value_label FROM category_values
WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
[catTableName, catColumnName, selectedValueStr]
);
if (cvResult.rows.length > 0) {
const resolvedId = cvResult.rows[0].value_id;
const resolvedLabel = cvResult.rows[0].value_label;
mapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === String(resolvedId)) return true;
if (m.categoryValueLabel === resolvedLabel) return true;
return false;
});
if (mapping) {
logger.info("카테고리 매핑 역변환 성공 (valueCode→valueId)", {
valueCode: selectedValueStr,
resolvedId,
resolvedLabel,
format: mapping.format,
});
}
}
} catch (lookupError: any) {
logger.warn("카테고리 값 역변환 조회 실패", { error: lookupError.message });
}
}
if (mapping) {
logger.info("카테고리 매핑 적용", {
selectedValue,
format: mapping.format,
categoryValueLabel: mapping.categoryValueLabel,
});
return mapping.format || "";
}
logger.warn("카테고리 매핑을 찾을 수 없음", {
selectedValue,
availableMappings: categoryMappings.map((m: any) => ({
id: m.categoryValueId,
label: m.categoryValueLabel,
})),
});
return "";
}
case "category":
return this.resolveCategoryFormat(autoConfig, formData);
case "reference": {
const refColumn = autoConfig.referenceColumnName;
@@ -1302,154 +1552,46 @@ class NumberingRuleService {
const rule = await this.getRuleById(ruleId, companyCode);
if (!rule) throw new Error("규칙을 찾을 수 없습니다");
// prefix_key 기반 순번: 순번 이외 파트 조합으로 prefix 생성
const prefixKey = await this.buildPrefixKey(rule, formData);
const hasSequence = rule.parts.some((p: any) => p.partType === "sequence");
// 순번이 있으면 prefix_key 기반으로 UPSERT하여 다음 순번 획득
let allocatedSequence = 0;
if (hasSequence) {
allocatedSequence = await this.incrementSequenceForPrefix(
client, ruleId, companyCode, prefixKey
);
// 호환성을 위해 기존 current_sequence도 업데이트
await client.query(
"UPDATE numbering_rules SET current_sequence = current_sequence + 1 WHERE rule_id = $1 AND company_code = $2",
[ruleId, companyCode]
);
}
logger.info("allocateCode: prefix_key 기반 순번 할당", {
ruleId, prefixKey, allocatedSequence,
});
// 수동 입력 파트가 있고, 사용자가 입력한 코드가 있으면 수동 입력 부분 추출
// 1단계: 수동 값 추출 (buildPrefixKey 전에 수행해야 prefix_key에 포함 가능)
const manualParts = rule.parts.filter(
(p: any) => p.generationMethod === "manual"
);
let extractedManualValues: string[] = [];
if (manualParts.length > 0 && userInputCode) {
const previewParts = await Promise.all(rule.parts
.sort((a: any, b: any) => a.order - b.order)
.map(async (part: any) => {
if (part.generationMethod === "manual") {
return "____";
}
const autoConfig = part.autoConfig || {};
switch (part.partType) {
case "sequence": {
const length = autoConfig.sequenceLength || 3;
return "X".repeat(length);
}
case "text":
return autoConfig.textValue || "";
case "date":
return "DATEPART";
case "category": {
const catKey2 = autoConfig.categoryKey;
const catMappings2 = autoConfig.categoryMappings || [];
extractedManualValues = await this.extractManualValuesFromInput(
rule, userInputCode, formData
);
if (!catKey2 || !formData) {
return "CATEGORY";
}
const colName2 = catKey2.includes(".")
? catKey2.split(".")[1]
: catKey2;
const selVal2 = formData[colName2];
if (!selVal2) {
return "CATEGORY";
}
const selValStr2 = String(selVal2);
let catMapping2 = catMappings2.find((m: any) => {
if (m.categoryValueId?.toString() === selValStr2) return true;
if (m.categoryValueCode && m.categoryValueCode === selValStr2) return true;
if (m.categoryValueLabel === selValStr2) return true;
return false;
});
if (!catMapping2) {
try {
const pool2 = getPool();
const [ct2, cc2] = catKey2.includes(".") ? catKey2.split(".") : [catKey2, catKey2];
const cvr2 = await pool2.query(
`SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
[ct2, cc2, selValStr2]
);
if (cvr2.rows.length > 0) {
const rid2 = cvr2.rows[0].value_id;
const rlabel2 = cvr2.rows[0].value_label;
catMapping2 = catMappings2.find((m: any) => {
if (m.categoryValueId?.toString() === String(rid2)) return true;
if (m.categoryValueLabel === rlabel2) return true;
return false;
});
}
} catch { /* ignore */ }
}
return catMapping2?.format || "CATEGORY";
}
case "reference": {
const refCol2 = autoConfig.referenceColumnName;
if (refCol2 && formData && formData[refCol2]) {
return String(formData[refCol2]);
}
return "REF";
}
default:
return "";
}
}));
const sortedPartsForTemplate = rule.parts.sort((a: any, b: any) => a.order - b.order);
const previewTemplate = joinPartsWithSeparators(previewParts, sortedPartsForTemplate, rule.separator || "");
const templateParts = previewTemplate.split("____");
if (templateParts.length > 1) {
let remainingCode = userInputCode;
for (let i = 0; i < templateParts.length - 1; i++) {
const prefix = templateParts[i];
const suffix = templateParts[i + 1];
if (prefix && remainingCode.startsWith(prefix)) {
remainingCode = remainingCode.slice(prefix.length);
}
if (suffix) {
const suffixStart = suffix.replace(/X+|DATEPART/g, "");
const manualEndIndex = suffixStart
? remainingCode.indexOf(suffixStart)
: remainingCode.length;
if (manualEndIndex > 0) {
extractedManualValues.push(
remainingCode.slice(0, manualEndIndex)
);
remainingCode = remainingCode.slice(manualEndIndex);
}
} else {
extractedManualValues.push(remainingCode);
}
}
// 템플릿 파싱 실패 시 userInputCode 전체를 수동 값으로 사용 (수동 파트 1개인 경우만)
if (extractedManualValues.length === 0 && manualParts.length === 1) {
extractedManualValues = [userInputCode];
logger.info("수동 값 추출 폴백: userInputCode 전체 사용", { userInputCode });
}
}
logger.info(
`수동 입력 값 추출: userInputCode=${userInputCode}, previewTemplate=${previewTemplate}, extractedManualValues=${JSON.stringify(extractedManualValues)}`
// 2단계: prefix_key 빌드 (수동 값 포함)
const prefixKey = await this.buildPrefixKey(rule, formData, extractedManualValues);
const hasSequence = rule.parts.some((p: any) => p.partType === "sequence");
// 3단계: 순번이 있으면 prefix_key 기반 UPSERT + 테이블 내 최대값 비교하여 다음 순번 결정
let allocatedSequence = 0;
if (hasSequence) {
allocatedSequence = await this.resolveNextSequence(
client, rule, companyCode, ruleId, prefixKey, formData
);
}
logger.info("allocateCode: prefix_key + 테이블 기반 순번 할당", {
ruleId, prefixKey, allocatedSequence, extractedManualValues,
});
let manualPartIndex = 0;
const parts = await Promise.all(rule.parts
.sort((a: any, b: any) => a.order - b.order)
.map(async (part: any) => {
if (part.generationMethod === "manual") {
const manualValue =
extractedManualValues[manualPartIndex] ||
part.manualConfig?.value ||
"";
const manualValue = extractedManualValues[manualPartIndex] || "";
manualPartIndex++;
return manualValue;
}
@@ -1459,7 +1601,9 @@ class NumberingRuleService {
switch (part.partType) {
case "sequence": {
const length = autoConfig.sequenceLength || 3;
return String(allocatedSequence).padStart(length, "0");
const startFrom = autoConfig.startFrom || 1;
const actualSequence = allocatedSequence + startFrom - 1;
return String(actualSequence).padStart(length, "0");
}
case "number": {
@@ -1496,65 +1640,14 @@ class NumberingRuleService {
return autoConfig.textValue || "TEXT";
}
case "category": {
const categoryKey = autoConfig.categoryKey;
const categoryMappings = autoConfig.categoryMappings || [];
if (!categoryKey || !formData) {
return "";
}
const columnName = categoryKey.includes(".")
? categoryKey.split(".")[1]
: categoryKey;
const selectedValue = formData[columnName];
if (!selectedValue) {
return "";
}
const selectedValueStr = String(selectedValue);
let allocMapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === selectedValueStr) return true;
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) return true;
if (m.categoryValueLabel === selectedValueStr) return true;
return false;
});
if (!allocMapping) {
try {
const pool3 = getPool();
const [ct3, cc3] = categoryKey.includes(".") ? categoryKey.split(".") : [categoryKey, categoryKey];
const cvr3 = await pool3.query(
`SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
[ct3, cc3, selectedValueStr]
);
if (cvr3.rows.length > 0) {
const rid3 = cvr3.rows[0].value_id;
const rlabel3 = cvr3.rows[0].value_label;
allocMapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === String(rid3)) return true;
if (m.categoryValueLabel === rlabel3) return true;
return false;
});
}
} catch { /* ignore */ }
}
if (allocMapping) {
return allocMapping.format || "";
}
return "";
}
case "category":
return this.resolveCategoryFormat(autoConfig, formData);
case "reference": {
const refColumn = autoConfig.referenceColumnName;
if (refColumn && formData && formData[refColumn]) {
return String(formData[refColumn]);
}
logger.warn("reference 파트: 참조 컬럼 값 없음", { refColumn, formDataKeys: formData ? Object.keys(formData) : [] });
return "";
}
@@ -1593,6 +1686,139 @@ class NumberingRuleService {
return this.allocateCode(ruleId, companyCode);
}
/**
* 사용자 입력 코드에서 수동 파트 값을 추출
* 템플릿 기반 파싱으로 수동 입력 위치("____")에 해당하는 값을 분리
*/
private async extractManualValuesFromInput(
rule: NumberingRuleConfig,
userInputCode: string,
formData?: Record<string, any>
): Promise<string[]> {
const extractedValues: string[] = [];
const previewParts = await Promise.all(rule.parts
.sort((a: any, b: any) => a.order - b.order)
.map(async (part: any) => {
if (part.generationMethod === "manual") {
return "____";
}
const autoConfig = part.autoConfig || {};
switch (part.partType) {
case "sequence": {
const length = autoConfig.sequenceLength || 3;
return "X".repeat(length);
}
case "text":
return autoConfig.textValue || "";
case "date":
return "DATEPART";
case "category":
return this.resolveCategoryFormat(autoConfig, formData);
case "reference": {
const refColumn = autoConfig.referenceColumnName;
if (refColumn && formData && formData[refColumn]) {
return String(formData[refColumn]);
}
return "";
}
default:
return "";
}
}));
const sortedPartsForTemplate = rule.parts.sort((a: any, b: any) => a.order - b.order);
const previewTemplate = joinPartsWithSeparators(previewParts, sortedPartsForTemplate, rule.separator || "");
const templateParts = previewTemplate.split("____");
if (templateParts.length > 1) {
let remainingCode = userInputCode;
for (let i = 0; i < templateParts.length - 1; i++) {
const prefix = templateParts[i];
const suffix = templateParts[i + 1];
if (prefix && remainingCode.startsWith(prefix)) {
remainingCode = remainingCode.slice(prefix.length);
}
if (suffix) {
const suffixStart = suffix.replace(/X+|DATEPART/g, "");
const manualEndIndex = suffixStart
? remainingCode.indexOf(suffixStart)
: remainingCode.length;
if (manualEndIndex > 0) {
extractedValues.push(
remainingCode.slice(0, manualEndIndex)
);
remainingCode = remainingCode.slice(manualEndIndex);
}
} else {
extractedValues.push(remainingCode);
}
}
}
logger.info(
`수동 입력 값 추출: userInputCode=${userInputCode}, previewTemplate=${previewTemplate}, extractedManualValues=${JSON.stringify(extractedValues)}`
);
return extractedValues;
}
/**
* 카테고리 매핑에서 format 값을 해석
* categoryKey + formData로 선택된 값을 찾고, 매핑 테이블에서 format 반환
*/
private async resolveCategoryFormat(
autoConfig: Record<string, any>,
formData?: Record<string, any>
): Promise<string> {
const categoryKey = autoConfig.categoryKey;
const categoryMappings = autoConfig.categoryMappings || [];
if (!categoryKey || !formData) return "";
const columnName = categoryKey.includes(".")
? categoryKey.split(".")[1]
: categoryKey;
const selectedValue = formData[columnName];
if (!selectedValue) return "";
const selectedValueStr = String(selectedValue);
let mapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === selectedValueStr) return true;
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) return true;
if (m.categoryValueLabel === selectedValueStr) return true;
return false;
});
// 매핑 못 찾으면 category_values에서 valueCode → valueId 역변환
if (!mapping) {
try {
const pool = getPool();
const [tableName, colName] = categoryKey.includes(".")
? categoryKey.split(".")
: [categoryKey, categoryKey];
const result = await pool.query(
`SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
[tableName, colName, selectedValueStr]
);
if (result.rows.length > 0) {
const resolvedId = result.rows[0].value_id;
const resolvedLabel = result.rows[0].value_label;
mapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === String(resolvedId)) return true;
if (m.categoryValueLabel === resolvedLabel) return true;
return false;
});
}
} catch { /* ignore */ }
}
return mapping?.format || "";
}
private formatDate(date: Date, format: string): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
@@ -0,0 +1,879 @@
/**
* 생산계획 서비스
* - 수주 데이터 조회 (품목별 그룹핑)
* - 안전재고 부족분 조회
* - 자동 스케줄 생성
* - 스케줄 병합
* - 반제품 계획 자동 생성
* - 스케줄 분할
*/
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;
}
/**
* 자동 스케줄 미리보기 (DB 변경 없이 예상 결과만 반환)
*/
export async function previewSchedule(
companyCode: string,
items: GenerateScheduleItem[],
options: GenerateScheduleOptions
) {
const pool = getPool();
const productType = options.product_type || "완제품";
const safetyLeadTime = options.safety_lead_time || 1;
const previews: any[] = [];
const deletedSchedules: any[] = [];
const keptSchedules: any[] = [];
for (const item of items) {
if (options.recalculate_unstarted) {
// 삭제 대상(planned) 상세 조회
const deleteResult = await pool.query(
`SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status
FROM production_plan_mng
WHERE company_code = $1 AND item_code = $2
AND COALESCE(product_type, '완제품') = $3
AND status = 'planned'`,
[companyCode, item.item_code, productType]
);
deletedSchedules.push(...deleteResult.rows);
// 유지 대상(진행중 등) 상세 조회
const keptResult = await pool.query(
`SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status, completed_qty
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]
);
keptSchedules.push(...keptResult.rows);
}
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 orderCountResult = await pool.query(
`SELECT COUNT(*) AS cnt FROM sales_order_mng
WHERE company_code = $1 AND part_code = $2 AND part_code IS NOT NULL`,
[companyCode, item.item_code]
);
const orderCount = parseInt(orderCountResult.rows[0].cnt, 10);
previews.push({
item_code: item.item_code,
item_name: item.item_name,
required_qty: requiredQty,
daily_capacity: dailyCapacity,
hourly_capacity: item.hourly_capacity || 100,
production_days: productionDays,
start_date: startDate.toISOString().split("T")[0],
end_date: endDate.toISOString().split("T")[0],
due_date: item.earliest_due_date,
order_count: orderCount,
status: "planned",
});
}
const summary = {
total: previews.length + keptSchedules.length,
new_count: previews.length,
kept_count: keptSchedules.length,
deleted_count: deletedSchedules.length,
};
logger.info("자동 스케줄 미리보기", { companyCode, summary });
return { summary, previews, deletedSchedules, keptSchedules };
}
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);
}
// 계획번호 생성 (YYYYMMDD-NNNN 형식)
const todayStr = new Date().toISOString().split("T")[0].replace(/-/g, "");
const planNoResult = await client.query(
`SELECT COUNT(*) + 1 AS next_no
FROM production_plan_mng
WHERE company_code = $1 AND plan_no LIKE $2`,
[companyCode, `PP-${todayStr}-%`]
);
const nextNo = parseInt(planNoResult.rows[0].next_no, 10) || 1;
const planNo = `PP-${todayStr}-${String(nextNo).padStart(4, "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();
}
}
// ─── 반제품 BOM 소요량 조회 (공통) ───
async function getBomChildItems(
client: any,
companyCode: string,
itemCode: string
) {
const bomQuery = `
SELECT
bd.child_item_id,
ii.item_name AS child_item_name,
ii.item_number 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 result = await client.query(bomQuery, [companyCode, itemCode]);
return result.rows;
}
// ─── 반제품 계획 미리보기 (실제 DB 변경 없음) ───
export async function previewSemiSchedule(
companyCode: string,
planIds: number[],
options: { considerStock?: boolean; excludeUsed?: boolean }
) {
const pool = getPool();
const placeholders = planIds.map((_, i) => `$${i + 2}`).join(", ");
const plansResult = await pool.query(
`SELECT * FROM production_plan_mng
WHERE company_code = $1 AND id IN (${placeholders})
AND product_type = '완제품'`,
[companyCode, ...planIds]
);
const previews: any[] = [];
const existingSemiPlans: any[] = [];
for (const plan of plansResult.rows) {
// 이미 존재하는 반제품 계획 조회
const existingResult = await pool.query(
`SELECT * FROM production_plan_mng
WHERE company_code = $1 AND parent_plan_id = $2 AND product_type = '반제품'`,
[companyCode, plan.id]
);
existingSemiPlans.push(...existingResult.rows);
const bomItems = await getBomChildItems(pool, companyCode, plan.item_code);
for (const bomItem of bomItems) {
let requiredQty = (parseFloat(plan.plan_qty) || 0) * (parseFloat(bomItem.bom_qty) || 1);
if (options.considerStock) {
const stockResult = await pool.query(
`SELECT COALESCE(SUM(CAST(current_qty AS 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 semiStartDate = new Date(plan.start_date);
semiStartDate.setDate(semiStartDate.getDate() - (parseInt(plan.lead_time) || 1));
previews.push({
parent_plan_id: plan.id,
parent_plan_no: plan.plan_no,
parent_item_name: plan.item_name,
item_code: bomItem.child_item_code || bomItem.child_item_id,
item_name: bomItem.child_item_name || bomItem.child_item_id,
plan_qty: requiredQty,
bom_qty: parseFloat(bomItem.bom_qty) || 1,
start_date: semiStartDate.toISOString().split("T")[0],
end_date: typeof semiDueDate === "string"
? semiDueDate.split("T")[0]
: new Date(semiDueDate).toISOString().split("T")[0],
due_date: typeof semiDueDate === "string"
? semiDueDate.split("T")[0]
: new Date(semiDueDate).toISOString().split("T")[0],
product_type: "반제품",
status: "planned",
});
}
}
// 기존 반제품 중 삭제 대상 (status = planned)
const deletedSchedules = existingSemiPlans.filter(
(s) => s.status === "planned"
);
// 기존 반제품 중 유지 대상 (진행중 등)
const keptSchedules = existingSemiPlans.filter(
(s) => s.status !== "planned" && s.status !== "completed"
);
const summary = {
total: previews.length + keptSchedules.length,
new_count: previews.length,
deleted_count: deletedSchedules.length,
kept_count: keptSchedules.length,
parent_count: plansResult.rowCount,
};
return { summary, previews, deletedSchedules, keptSchedules };
}
// ─── 반제품 계획 자동 생성 ───
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})
AND product_type = '완제품'`,
[companyCode, ...planIds]
);
// 기존 planned 상태 반제품 삭제
for (const plan of plansResult.rows) {
await client.query(
`DELETE FROM production_plan_mng
WHERE company_code = $1 AND parent_plan_id = $2
AND product_type = '반제품' AND status = 'planned'`,
[companyCode, plan.id]
);
}
const newSemiPlans: any[] = [];
const todayStr = new Date().toISOString().split("T")[0].replace(/-/g, "");
for (const plan of plansResult.rows) {
const bomItems = await getBomChildItems(client, companyCode, plan.item_code);
for (const bomItem of bomItems) {
let requiredQty = (parseFloat(plan.plan_qty) || 0) * (parseFloat(bomItem.bom_qty) || 1);
if (options.considerStock) {
const stockResult = await client.query(
`SELECT COALESCE(SUM(CAST(current_qty AS 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() - (parseInt(plan.lead_time) || 1));
// plan_no 생성 (PP-YYYYMMDD-SXXX 형식, S = 반제품)
const planNoResult = await client.query(
`SELECT COUNT(*) + 1 AS next_no
FROM production_plan_mng
WHERE company_code = $1 AND plan_no LIKE $2`,
[companyCode, `PP-${todayStr}-S%`]
);
const nextNo = parseInt(planNoResult.rows[0].next_no, 10) || 1;
const planNo = `PP-${todayStr}-S${String(nextNo).padStart(3, "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.split("T")[0] : new Date(semiEndDate).toISOString().split("T")[0],
typeof semiDueDate === "string" ? semiDueDate.split("T")[0] : new Date(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();
}
}
@@ -3588,12 +3588,15 @@ export class TableManagementService {
`✅ [executeJoinQuery] 조회 완료: ${dataResult?.length}개 행`
);
const data = Array.isArray(dataResult) ? dataResult : [];
let data = Array.isArray(dataResult) ? dataResult : [];
const total =
Array.isArray(countResult) && countResult.length > 0
? Number((countResult[0] as any).total)
: 0;
// 콤마 구분 다중값 후처리 (겸직 부서 등)
data = await entityJoinService.resolveCommaValues(data, joinConfigs);
const queryTime = Date.now() - startTime;
return {
+1 -1
View File
@@ -12,7 +12,7 @@ services:
environment:
- NODE_ENV=development
- PORT=8080
- DATABASE_URL=postgresql://postgres:vexplor0909!!@211.115.91.141:11134/vexplor
- DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm
- JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024
- JWT_EXPIRES_IN=24h
- CORS_ORIGIN=http://localhost:9771
+620
View File
@@ -0,0 +1,620 @@
# POP 작업진행 관리 설계서
> 작성일: 2026-03-13
> 목적: POP 시스템에서 작업지시 기반으로 라우팅/작업기준정보를 조회하고, 공정별 작업 진행 상태를 관리하는 구조 설계
---
## 1. 핵심 설계 원칙
**작업지시에 라우팅ID, 작업기준정보ID 등을 별도 컬럼으로 넣지 않는다.**
- 작업지시(`work_instruction`)에는 `item_id`(품목 ID)만 있으면 충분
- 품목 → 라우팅 → 작업기준정보는 마스터 데이터 체인으로 조회
- 작업 진행 상태만 별도 테이블에서 관리
---
## 2. 기존 테이블 구조 (마스터 데이터)
### 2-1. ER 다이어그램
> GitHub / VSCode Mermaid 플러그인에서 렌더링됩니다.
```mermaid
erDiagram
%% ========== 마스터 데이터 (변경 없음) ==========
item_info {
varchar id PK "UUID"
varchar item_number "품번"
varchar item_name "품명"
varchar company_code "회사코드"
}
item_routing_version {
varchar id PK "UUID"
varchar item_code "품번 (= item_info.item_number)"
varchar version_name "버전명"
boolean is_default "기본버전 여부"
varchar company_code "회사코드"
}
item_routing_detail {
varchar id PK "UUID"
varchar routing_version_id FK "→ item_routing_version.id"
varchar seq_no "공정순서 10,20,30..."
varchar process_code FK "→ process_mng.process_code"
varchar is_required "필수/선택"
varchar is_fixed_order "고정/선택"
varchar standard_time "표준시간(분)"
varchar company_code "회사코드"
}
process_mng {
varchar id PK "UUID"
varchar process_code "공정코드"
varchar process_name "공정명"
varchar process_type "공정유형"
varchar company_code "회사코드"
}
process_work_item {
varchar id PK "UUID"
varchar routing_detail_id FK "→ item_routing_detail.id"
varchar work_phase "PRE / IN / POST"
varchar title "작업항목명"
varchar is_required "Y/N"
int sort_order "정렬순서"
varchar company_code "회사코드"
}
process_work_item_detail {
varchar id PK "UUID"
varchar work_item_id FK "→ process_work_item.id"
varchar detail_type "check/inspect/input/procedure/info"
varchar content "내용"
varchar input_type "입력타입"
varchar inspection_code "검사코드"
varchar unit "단위"
varchar lower_limit "하한값"
varchar upper_limit "상한값"
varchar company_code "회사코드"
}
%% ========== 트랜잭션 데이터 ==========
work_instruction {
varchar id PK "UUID"
varchar work_instruction_no "작업지시번호"
varchar item_id FK "→ item_info.id ★핵심★"
varchar status "waiting/in_progress/completed/cancelled"
varchar qty "지시수량"
varchar completed_qty "완성수량"
varchar worker "작업자"
varchar company_code "회사코드"
}
work_order_process {
varchar id PK "UUID"
varchar wo_id FK "→ work_instruction.id"
varchar routing_detail_id FK "→ item_routing_detail.id ★추가★"
varchar seq_no "공정순서"
varchar process_code "공정코드"
varchar process_name "공정명"
varchar status "waiting/in_progress/completed/skipped"
varchar plan_qty "계획수량"
varchar good_qty "양품수량"
varchar defect_qty "불량수량"
timestamp started_at "시작시간"
timestamp completed_at "완료시간"
varchar company_code "회사코드"
}
work_order_work_item {
varchar id PK "UUID ★신규★"
varchar company_code "회사코드"
varchar work_order_process_id FK "→ work_order_process.id"
varchar work_item_id FK "→ process_work_item.id"
varchar work_phase "PRE/IN/POST"
varchar status "pending/completed/skipped/failed"
varchar completed_by "완료자"
timestamp completed_at "완료시간"
}
work_order_work_item_result {
varchar id PK "UUID ★신규★"
varchar company_code "회사코드"
varchar work_order_work_item_id FK "→ work_order_work_item.id"
varchar work_item_detail_id FK "→ process_work_item_detail.id"
varchar detail_type "check/inspect/input/procedure"
varchar result_value "결과값"
varchar is_passed "Y/N/null"
varchar recorded_by "기록자"
timestamp recorded_at "기록시간"
}
%% ========== 관계 ==========
%% 마스터 체인: 품목 → 라우팅 → 작업기준정보
item_info ||--o{ item_routing_version : "item_number = item_code"
item_routing_version ||--o{ item_routing_detail : "id = routing_version_id"
item_routing_detail }o--|| process_mng : "process_code"
item_routing_detail ||--o{ process_work_item : "id = routing_detail_id"
process_work_item ||--o{ process_work_item_detail : "id = work_item_id"
%% 트랜잭션: 작업지시 → 공정진행 → 작업기준정보 진행
work_instruction }o--|| item_info : "item_id = id"
work_instruction ||--o{ work_order_process : "id = wo_id"
work_order_process }o--|| item_routing_detail : "routing_detail_id = id"
work_order_process ||--o{ work_order_work_item : "id = work_order_process_id"
work_order_work_item }o--|| process_work_item : "work_item_id = id"
work_order_work_item ||--o{ work_order_work_item_result : "id = work_order_work_item_id"
work_order_work_item_result }o--|| process_work_item_detail : "work_item_detail_id = id"
```
### 2-1-1. 관계 요약 (텍스트)
```
[마스터 데이터 체인 - 조회용, 변경 없음]
item_info ─── 1:N ───→ item_routing_version ─── 1:N ───→ item_routing_detail
(품목) item_number (라우팅 버전) routing_ (공정별 상세)
= item_code version_id
process_mng ◄───┘ process_code (공정 마스터)
├── 1:N ───→ process_work_item ─── 1:N ───→ process_work_item_detail
│ (작업기준정보) (작업기준정보 상세)
│ routing_detail_id work_item_id
[트랜잭션 데이터 - 상태 관리] │
work_instruction ─── 1:N ───→ work_order_process ─┘ routing_detail_id (★추가★)
(작업지시) wo_id (공정별 진행)
item_id → item_info │
├── 1:N ───→ work_order_work_item ─── 1:N ───→ work_order_work_item_result
│ (작업기준정보 진행) (상세 결과값)
│ work_order_process_id work_order_work_item_id
│ work_item_id → process_work_item work_item_detail_id → process_work_item_detail
│ ★신규 테이블★ ★신규 테이블★
```
### 2-2. 마스터 테이블 상세
#### item_info (품목 마스터)
| 컬럼 | 설명 | 비고 |
|------|------|------|
| id | PK (UUID) | |
| item_number | 품번 | item_routing_version.item_code와 매칭 |
| item_name | 품명 | |
| company_code | 회사코드 | 멀티테넌시 |
#### item_routing_version (라우팅 버전)
| 컬럼 | 설명 | 비고 |
|------|------|------|
| id | PK (UUID) | |
| item_code | 품번 | item_info.item_number와 매칭 |
| version_name | 버전명 | 예: "기본 라우팅", "버전2" |
| is_default | 기본 버전 여부 | true/false, 기본 버전을 사용 |
| company_code | 회사코드 | |
#### item_routing_detail (라우팅 상세 - 공정별)
| 컬럼 | 설명 | 비고 |
|------|------|------|
| id | PK (UUID) | |
| routing_version_id | FK → item_routing_version.id | |
| seq_no | 공정 순서 | 10, 20, 30... |
| process_code | 공정코드 | FK → process_mng.process_code |
| is_required | 필수/선택 | "필수" / "선택" |
| is_fixed_order | 순서고정 여부 | "고정" / "선택" |
| work_type | 작업유형 | |
| standard_time | 표준시간(분) | |
| outsource_supplier | 외주업체 | |
| company_code | 회사코드 | |
#### process_work_item (작업기준정보)
| 컬럼 | 설명 | 비고 |
|------|------|------|
| id | PK (UUID) | |
| routing_detail_id | FK → item_routing_detail.id | |
| work_phase | 작업단계 | PRE(작업전) / IN(작업중) / POST(작업후) |
| title | 작업항목명 | 예: "장비 체크", "소재 준비" |
| is_required | 필수여부 | Y/N |
| sort_order | 정렬순서 | |
| description | 설명 | |
| company_code | 회사코드 | |
#### process_work_item_detail (작업기준정보 상세)
| 컬럼 | 설명 | 비고 |
|------|------|------|
| id | PK (UUID) | |
| work_item_id | FK → process_work_item.id | |
| detail_type | 상세유형 | check(체크) / inspect(검사) / input(입력) / procedure(절차) / info(정보) |
| content | 내용 | 예: "소음검사", "치수검사" |
| input_type | 입력타입 | select, text 등 |
| inspection_code | 검사코드 | |
| inspection_method | 검사방법 | |
| unit | 단위 | |
| lower_limit | 하한값 | |
| upper_limit | 상한값 | |
| is_required | 필수여부 | Y/N |
| sort_order | 정렬순서 | |
| company_code | 회사코드 | |
---
## 3. 작업 진행 테이블 (트랜잭션 데이터)
### 3-1. work_instruction (작업지시) - 기존 테이블
| 컬럼 | 설명 | 비고 |
|------|------|------|
| id | PK (UUID) | |
| work_instruction_no | 작업지시번호 | 예: WO-2026-001 |
| **item_id** | **FK → item_info.id** | **이것만으로 라우팅/작업기준정보 전부 조회 가능** |
| status | 작업지시 상태 | waiting / in_progress / completed / cancelled |
| qty | 지시수량 | |
| completed_qty | 완성수량 | |
| work_team | 작업팀 | |
| worker | 작업자 | |
| equipment_id | 설비 | |
| start_date | 시작일 | |
| end_date | 종료일 | |
| remark | 비고 | |
| company_code | 회사코드 | |
> **routing 컬럼**: 현재 존재하지만 사용하지 않음 (null). 라우팅 버전을 지정하고 싶으면 이 컬럼에 `item_routing_version.id`를 넣어 특정 버전을 지정할 수 있음. 없으면 `is_default=true` 버전 자동 사용.
### 3-2. work_order_process (공정별 진행) - 기존 테이블, 변경 필요
작업지시가 생성될 때, 해당 품목의 라우팅 공정을 복사해서 이 테이블에 INSERT.
| 컬럼 | 설명 | 비고 |
|------|------|------|
| id | PK (UUID) | |
| wo_id | FK → work_instruction.id | 작업지시 참조 |
| **routing_detail_id** | **FK → item_routing_detail.id** | **추가 필요 - 라우팅 상세 참조** |
| seq_no | 공정 순서 | 라우팅에서 복사 |
| process_code | 공정코드 | 라우팅에서 복사 |
| process_name | 공정명 | 라우팅에서 복사 (비정규화, 조회 편의) |
| is_required | 필수여부 | 라우팅에서 복사 |
| is_fixed_order | 순서고정 | 라우팅에서 복사 |
| standard_time | 표준시간 | 라우팅에서 복사 |
| **status** | **공정 상태** | **waiting / in_progress / completed / skipped** |
| plan_qty | 계획수량 | |
| input_qty | 투입수량 | |
| good_qty | 양품수량 | |
| defect_qty | 불량수량 | |
| equipment_code | 사용설비 | |
| accepted_by | 접수자 | |
| accepted_at | 접수시간 | |
| started_at | 시작시간 | |
| completed_at | 완료시간 | |
| remark | 비고 | |
| company_code | 회사코드 | |
### 3-3. work_order_work_item (작업기준정보별 진행) - 신규 테이블
POP에서 작업자가 각 작업기준정보 항목을 체크/입력할 때 사용.
| 컬럼 | 설명 | 비고 |
|------|------|------|
| id | PK (UUID) | gen_random_uuid() |
| company_code | 회사코드 | 멀티테넌시 |
| work_order_process_id | FK → work_order_process.id | 어떤 작업지시의 어떤 공정인지 |
| work_item_id | FK → process_work_item.id | 어떤 작업기준정보인지 |
| work_phase | 작업단계 | PRE / IN / POST (마스터에서 복사) |
| status | 완료상태 | pending / completed / skipped / failed |
| completed_by | 완료자 | 작업자 ID |
| completed_at | 완료시간 | |
| created_date | 생성일 | |
| updated_date | 수정일 | |
| writer | 작성자 | |
### 3-4. work_order_work_item_result (작업기준정보 상세 결과) - 신규 테이블
작업기준정보의 상세 항목(체크, 검사, 입력 등)에 대한 실제 결과값 저장.
| 컬럼 | 설명 | 비고 |
|------|------|------|
| id | PK (UUID) | gen_random_uuid() |
| company_code | 회사코드 | 멀티테넌시 |
| work_order_work_item_id | FK → work_order_work_item.id | |
| work_item_detail_id | FK → process_work_item_detail.id | 어떤 상세항목인지 |
| detail_type | 상세유형 | check / inspect / input / procedure (마스터에서 복사) |
| result_value | 결과값 | 체크: "Y"/"N", 검사: 측정값, 입력: 입력값 |
| is_passed | 합격여부 | Y / N / null(해당없음) |
| remark | 비고 | 불합격 사유 등 |
| recorded_by | 기록자 | |
| recorded_at | 기록시간 | |
| created_date | 생성일 | |
| updated_date | 수정일 | |
| writer | 작성자 | |
---
## 4. POP 데이터 플로우
### 4-1. 작업지시 등록 시 (ERP 측)
```
[작업지시 생성]
├── 1. work_instruction INSERT (item_id, qty, status='waiting' 등)
├── 2. item_id → item_info.item_number 조회
├── 3. item_number → item_routing_version 조회 (is_default=true 또는 지정 버전)
├── 4. routing_version_id → item_routing_detail 조회 (공정 목록)
└── 5. 각 공정별로 work_order_process INSERT
├── wo_id = work_instruction.id
├── routing_detail_id = item_routing_detail.id ← 핵심!
├── seq_no, process_code, process_name 복사
├── status = 'waiting'
└── plan_qty = work_instruction.qty
```
### 4-2. POP 작업 조회 시
```
[POP 화면: 작업지시 선택]
├── 1. work_instruction 목록 조회 (status = 'waiting' or 'in_progress')
├── 2. 선택한 작업지시의 공정 목록 조회
│ SELECT wop.*, pm.process_name
│ FROM work_order_process wop
│ LEFT JOIN process_mng pm ON wop.process_code = pm.process_code
│ WHERE wop.wo_id = {작업지시ID}
│ ORDER BY CAST(wop.seq_no AS int)
└── 3. 선택한 공정의 작업기준정보 조회 (마스터 데이터 참조)
SELECT pwi.*, pwid.*
FROM process_work_item pwi
LEFT JOIN process_work_item_detail pwid ON pwi.id = pwid.work_item_id
WHERE pwi.routing_detail_id = {work_order_process.routing_detail_id}
ORDER BY pwi.work_phase, pwi.sort_order, pwid.sort_order
```
### 4-3. POP 작업 실행 시
```
[작업자가 공정 시작]
├── 1. work_order_process UPDATE
│ SET status = 'in_progress', started_at = NOW(), accepted_by = {작업자}
├── 2. work_instruction UPDATE (첫 공정 시작 시)
│ SET status = 'in_progress'
├── 3. 작업기준정보 항목별 체크/입력 시
│ ├── work_order_work_item UPSERT (항목별 상태)
│ └── work_order_work_item_result UPSERT (상세 결과값)
└── 4. 공정 완료 시
├── work_order_process UPDATE
│ SET status = 'completed', completed_at = NOW(),
│ good_qty = {양품}, defect_qty = {불량}
└── (모든 공정 완료 시)
work_instruction UPDATE
SET status = 'completed', completed_qty = {최종양품}
```
---
## 5. 핵심 조회 쿼리
### 5-1. 작업지시 → 전체 공정 + 작업기준정보 한방 조회
```sql
-- 작업지시의 공정별 진행 현황 + 작업기준정보
SELECT
wi.work_instruction_no,
wi.qty,
wi.status as wi_status,
ii.item_number,
ii.item_name,
wop.id as process_id,
wop.seq_no,
wop.process_code,
wop.process_name,
wop.status as process_status,
wop.plan_qty,
wop.good_qty,
wop.defect_qty,
wop.started_at,
wop.completed_at,
wop.routing_detail_id,
-- 작업기준정보는 routing_detail_id로 마스터 조회
pwi.id as work_item_id,
pwi.work_phase,
pwi.title as work_item_title,
pwi.is_required as work_item_required
FROM work_instruction wi
JOIN item_info ii ON wi.item_id = ii.id
JOIN work_order_process wop ON wi.id = wop.wo_id
LEFT JOIN process_work_item pwi ON wop.routing_detail_id = pwi.routing_detail_id
WHERE wi.id = $1
AND wi.company_code = $2
ORDER BY CAST(wop.seq_no AS int), pwi.work_phase, pwi.sort_order;
```
### 5-2. 특정 공정의 작업기준정보 + 진행 상태 조회
```sql
-- POP에서 특정 공정 선택 시: 마스터 + 진행 상태 조인
SELECT
pwi.id as work_item_id,
pwi.work_phase,
pwi.title,
pwi.is_required,
pwid.id as detail_id,
pwid.detail_type,
pwid.content,
pwid.input_type,
pwid.inspection_code,
pwid.inspection_method,
pwid.unit,
pwid.lower_limit,
pwid.upper_limit,
-- 진행 상태
wowi.status as item_status,
wowi.completed_by,
wowi.completed_at,
-- 결과값
wowir.result_value,
wowir.is_passed,
wowir.remark as result_remark
FROM process_work_item pwi
LEFT JOIN process_work_item_detail pwid
ON pwi.id = pwid.work_item_id
LEFT JOIN work_order_work_item wowi
ON wowi.work_item_id = pwi.id
AND wowi.work_order_process_id = $1 -- work_order_process.id
LEFT JOIN work_order_work_item_result wowir
ON wowir.work_order_work_item_id = wowi.id
AND wowir.work_item_detail_id = pwid.id
WHERE pwi.routing_detail_id = $2 -- work_order_process.routing_detail_id
ORDER BY
CASE pwi.work_phase WHEN 'PRE' THEN 1 WHEN 'IN' THEN 2 WHEN 'POST' THEN 3 END,
pwi.sort_order,
pwid.sort_order;
```
---
## 6. 변경사항 요약
### 6-1. 기존 테이블 변경
| 테이블 | 변경내용 |
|--------|---------|
| work_order_process | `routing_detail_id VARCHAR(500)` 컬럼 추가 |
### 6-2. 신규 테이블
| 테이블 | 용도 |
|--------|------|
| work_order_work_item | 작업지시 공정별 작업기준정보 진행 상태 |
| work_order_work_item_result | 작업기준정보 상세 항목의 실제 결과값 |
### 6-3. 건드리지 않는 것
| 테이블 | 이유 |
|--------|------|
| work_instruction | item_id만 있으면 충분. 라우팅/작업기준정보 ID 추가 불필요 |
| item_routing_version | 마스터 데이터, 변경 없음 |
| item_routing_detail | 마스터 데이터, 변경 없음 |
| process_work_item | 마스터 데이터, 변경 없음 |
| process_work_item_detail | 마스터 데이터, 변경 없음 |
---
## 7. DDL (마이그레이션 SQL)
```sql
-- 1. work_order_process에 routing_detail_id 추가
ALTER TABLE work_order_process
ADD COLUMN IF NOT EXISTS routing_detail_id VARCHAR(500);
CREATE INDEX IF NOT EXISTS idx_wop_routing_detail_id
ON work_order_process(routing_detail_id);
-- 2. 작업기준정보별 진행 상태 테이블
CREATE TABLE IF NOT EXISTS work_order_work_item (
id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
company_code VARCHAR(500) NOT NULL,
work_order_process_id VARCHAR(500) NOT NULL,
work_item_id VARCHAR(500) NOT NULL,
work_phase VARCHAR(500),
status VARCHAR(500) DEFAULT 'pending',
completed_by VARCHAR(500),
completed_at TIMESTAMP,
created_date TIMESTAMP DEFAULT NOW(),
updated_date TIMESTAMP DEFAULT NOW(),
writer VARCHAR(500)
);
CREATE INDEX idx_wowi_process_id ON work_order_work_item(work_order_process_id);
CREATE INDEX idx_wowi_work_item_id ON work_order_work_item(work_item_id);
CREATE INDEX idx_wowi_company_code ON work_order_work_item(company_code);
-- 3. 작업기준정보 상세 결과 테이블
CREATE TABLE IF NOT EXISTS work_order_work_item_result (
id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
company_code VARCHAR(500) NOT NULL,
work_order_work_item_id VARCHAR(500) NOT NULL,
work_item_detail_id VARCHAR(500) NOT NULL,
detail_type VARCHAR(500),
result_value VARCHAR(500),
is_passed VARCHAR(500),
remark TEXT,
recorded_by VARCHAR(500),
recorded_at TIMESTAMP DEFAULT NOW(),
created_date TIMESTAMP DEFAULT NOW(),
updated_date TIMESTAMP DEFAULT NOW(),
writer VARCHAR(500)
);
CREATE INDEX idx_wowir_work_order_work_item_id ON work_order_work_item_result(work_order_work_item_id);
CREATE INDEX idx_wowir_detail_id ON work_order_work_item_result(work_item_detail_id);
CREATE INDEX idx_wowir_company_code ON work_order_work_item_result(company_code);
```
---
## 8. 상태값 정의
### work_instruction.status (작업지시 상태)
| 값 | 의미 |
|----|------|
| waiting | 대기 |
| in_progress | 진행중 |
| completed | 완료 |
| cancelled | 취소 |
### work_order_process.status (공정 상태)
| 값 | 의미 |
|----|------|
| waiting | 대기 (아직 시작 안 함) |
| in_progress | 진행중 (작업자가 시작) |
| completed | 완료 |
| skipped | 건너뜀 (선택 공정인 경우) |
### work_order_work_item.status (작업기준정보 항목 상태)
| 값 | 의미 |
|----|------|
| pending | 미완료 |
| completed | 완료 |
| skipped | 건너뜀 |
| failed | 실패 (검사 불합격 등) |
### work_order_work_item_result.is_passed (검사 합격여부)
| 값 | 의미 |
|----|------|
| Y | 합격 |
| N | 불합격 |
| null | 해당없음 (체크/입력 항목) |
---
## 9. 설계 의도 요약
1. **마스터와 트랜잭션 분리**: 라우팅/작업기준정보는 마스터(템플릿), 실제 진행은 트랜잭션 테이블에서 관리
2. **조회 경로**: `work_instruction.item_id``item_info.item_number``item_routing_version``item_routing_detail``process_work_item``process_work_item_detail`
3. **진행 경로**: `work_order_process.routing_detail_id`로 마스터 작업기준정보를 참조하되, 실제 진행/결과는 `work_order_work_item` + `work_order_work_item_result`에 저장
4. **중복 저장 최소화**: 작업지시에 공정/작업기준정보 ID를 넣지 않음. 품목만 있으면 전부 파생 조회 가능
5. **work_order_process**: 작업지시 생성 시 라우팅 공정을 복사하는 이유는 진행 중 수량/상태/시간 등 트랜잭션 데이터를 기록해야 하기 때문 (마스터가 변경되어도 이미 발행된 작업지시의 공정은 유지)
---
## 10. 주의사항
- `work_order_process`에 공정 정보를 복사(스냅샷)하는 이유: 마스터 라우팅이 나중에 변경되어도 이미 진행 중인 작업지시의 공정 구성은 영향받지 않아야 함
- `routing_detail_id`는 "이 공정이 어떤 마스터 라우팅에서 왔는지" 추적용. 작업기준정보 조회 키로 사용
- POP에서 작업기준정보를 표시할 때는 항상 마스터(`process_work_item`)를 조회하고, 결과만 트랜잭션 테이블에 저장
- 모든 테이블에 `company_code` 필수 (멀티테넌시)
@@ -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 (타임라인, 모바일 스타일)로 개별 개발 필요
@@ -531,7 +531,7 @@ function detectConflicts(schedules: ScheduleItem[], resourceId: string): Schedul
- [x] 레지스트리 등록
- [x] 문서화 (README.md)
#### v2-timeline-scheduler ✅ 구현 완료 (2026-01-30)
#### v2-timeline-scheduler ✅ 구현 완료 (2026-01-30, 업데이트: 2026-03-13)
- [x] 타입 정의 완료
- [x] 기본 구조 생성
@@ -539,16 +539,20 @@ function detectConflicts(schedules: ScheduleItem[], resourceId: string): Schedul
- [x] TimelineGrid (배경)
- [x] ResourceColumn (리소스)
- [x] ScheduleBar 기본 렌더링
- [x] 드래그 이동 (기본)
- [x] 리사이즈 (기본)
- [x] 드래그 이동 (실제 로직: deltaX → 날짜 계산 → API 저장 → toast)
- [x] 리사이즈 (실제 로직: 시작/종료 핸들 → 기간 변경 → API 저장 → toast)
- [x] 줌 레벨 전환
- [x] 날짜 네비게이션
- [ ] 충돌 감지 (향후)
- [ ] 가상 스크롤 (향후)
- [x] 충돌 감지 (같은 리소스 겹침 → ring-destructive + AlertTriangle)
- [x] 마일스톤 표시 (시작일 = 종료일 → 다이아몬드 마커)
- [x] 범례 표시 (TimelineLegend: 상태별 색상 + 마일스톤 + 충돌)
- [x] 반응형 공통 CSS 적용 (text-[10px] sm:text-sm 패턴)
- [x] staticFilters 지원 (커스텀 테이블 필터링)
- [x] 가상 스크롤 (@tanstack/react-virtual, 30개 이상 리소스 시 자동 활성화)
- [x] 설정 패널 구현
- [x] API 연동
- [x] 레지스트리 등록
- [ ] 테스트 완료
- [x] 테스트 완료 (20개 테스트 전체 통과 - 충돌감지 11건 + 날짜계산 9건)
- [x] 문서화 (README.md)
---
@@ -892,3 +892,79 @@ if (process.env.NODE_ENV === "development") {
});
}
```
---
## 12. 생산기간(리드타임) 산출 - 현재 상태 및 개선 방안
> 작성일: 2026-03-16 | 상태: 검토 대기 (스키마 변경 전 상의 필요)
### 12.1 현재 구현 상태
**생산일수 계산 로직** (`productionPlanService.ts`):
```
생산일수 = ceil(계획수량 / 일생산능력)
종료일 = 납기일 - 안전리드타임
시작일 = 종료일 - 생산일수
```
**현재 기본값 (하드코딩):**
| 항목 | 현재값 | 위치 |
|------|--------|------|
| 일생산능력 (daily_capacity) | 800 EA/일 | `productionPlanService.ts` 기본값 |
| 시간당 능력 (hourly_capacity) | 100 EA/시간 | `productionPlanService.ts` 기본값 |
| 안전리드타임 (safety_lead_time) | 1일 | 옵션 기본값 |
| 반제품 리드타임 (lead_time) | 1일 | `production_plan_mng` 기본값 |
**문제점:**
- `item_info`에 생산 파라미터 컬럼이 없음
- 모든 품목이 동일한 기본값(800EA/일)으로 계산됨
- 업체별/품목별 생산능력 차이를 반영 불가
### 12.2 개선 방향 (상의 후 결정)
**1단계 (품목 마스터 기반) - 권장:**
`item_info` 테이블에 컬럼 추가:
- `lead_time_days`: 리드타임 (일)
- `daily_capacity`: 일생산능력
- `min_lot_size`: 최소 생산 단위 (선택)
- `setup_time`: 셋업시간 (선택)
자동 스케줄 생성 시 품목 마스터 조회 → 값 없으면 기본값 사용 (하위 호환)
**2단계 (설비별 능력) - 고객 요청 시:**
별도 테이블 `item_equipment_capacity`:
- 품목 + 설비 조합별 생산능력 관리
- 동일 품목이라도 설비에 따라 능력 다를 때
**3단계 (공정 라우팅) - 대기업 대응:**
공정 순서 + 공정별 소요시간 전체 관리
- 현재 시점에서는 불필요
### 12.3 반제품 계획 생성 현황
**구현 완료 항목:**
- API: `POST /production/generate-semi-schedule/preview` (미리보기)
- API: `POST /production/generate-semi-schedule` (실제 생성)
- BOM 기반 소요량 자동 계산
- 타임라인 컴포넌트 내 "반제품 계획 생성" 버튼 (완제품 탭에서만 표시)
- 반제품 탭: linkedFilter 제거, staticFilters만 사용 (전체 반제품 표시)
**반제품 생산기간 계산:**
- 반제품 납기일 = 완제품 시작일
- 반제품 시작일 = 완제품 시작일 - lead_time (기본 1일)
- BOM 소요량 = 완제품 계획수량 x BOM 수량
**테스트 BOM 데이터:**
| 완제품 | 반제품 | BOM 수량 |
|--------|--------|----------|
| ITEM-001 (탑씰 Type A) | SEMI-001 (탑씰 필름 A) | 2 EA/개 |
| ITEM-001 (탑씰 Type A) | SEMI-002 (탑씰 접착제) | 0.5 KG/개 |
| ITEM-002 (탑씰 Type B) | SEMI-003 (탑씰 필름 B) | 3 EA/개 |
| ITEM-002 (탑씰 Type B) | SEMI-004 (탑씰 코팅제) | 0.3 KG/개 |
@@ -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 구조만
**별도 설정 필요한 것**: 저장 테이블, 버튼 액션, 조건 처리, 다중 행 처리
File diff suppressed because it is too large Load Diff
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` (빨간색) | 지연 |
@@ -0,0 +1,451 @@
# 생산계획 화면 (TOPSEAL_PP_MAIN) 테스트 시나리오
> **화면 URL**: `http://localhost:9771/screens/3985`
> **로그인 정보**: `topseal_admin` / `qlalfqjsgh11`
> **작성일**: 2026-03-16
---
## 사전 조건
- 백엔드 서버 (포트 8080) 실행 중
- 프론트엔드 서버 (포트 9771) 실행 중
- `topseal_admin` 계정으로 로그인 완료
- 사이드바 > 생산관리 > 생산계획 메뉴 클릭하여 화면 진입
### 현재 테스트 데이터 현황
| 구분 | 건수 | 상세 |
|------|:----:|------|
| 완제품 생산계획 | 7건 | planned(3), in_progress(3), completed(1) |
| 반제품 생산계획 | 6건 | planned(2), in_progress(2), completed(1) |
| 설비(리소스) | 10개 | CNC밀링#1~#2, 머시닝센터#1, 레이저절단기, 프레스기#1, 용접기#1, 도장설비#1, 조립라인#1, 검사대#1~#2 |
| 수주 데이터 | 10건 | sales_order_mng |
---
## TC-01. 화면 레이아웃 확인
### 목적
화면이 설계대로 좌/우 분할 패널로 렌더링되는지 확인
### 테스트 단계
1. 생산계획 화면 진입
2. 좌측 패널에 "수주 데이터" 탭이 보이는지 확인
3. 우측 패널에 "완제품" / "반제품" 탭이 보이는지 확인
4. 분할 패널 비율이 약 45:55인지 확인
### 예상 결과
- [ ] 좌측: "수주데이터" 탭 + "안전재고 부족분" 탭
- [ ] 우측: "완제품" 탭 + "반제품" 탭
- [ ] 하단에 버튼들 (새로고침, 자동 스케줄, 병합, 반제품계획, 저장) 표시
- [ ] 좌측 하단에 "선택 품목 불러오기" 버튼 표시
---
## TC-02. 좌측 패널 - 수주데이터 그룹 테이블
### 목적
v2-table-grouped 컴포넌트의 그룹화 및 접기/펼치기 기능 확인
### 테스트 단계
1. "수주데이터" 탭 선택
2. 데이터가 품목코드(part_code) 기준으로 그룹화되었는지 확인
3. 그룹 헤더 행에 품명, 품목코드가 표시되는지 확인
4. 그룹 헤더 클릭하여 접기/펼치기 토글
5. "전체 펼치기" / "전체 접기" 버튼 동작 확인
6. 그룹별 합계(수주량, 출고량, 잔량) 표시 확인
### 예상 결과
- [ ] 데이터가 part_code 기준으로 그룹화되어 표시
- [ ] 그룹 헤더에 `{품명} ({품목코드})` 형식으로 표시
- [ ] 그룹 헤더 클릭 시 하위 행 접기/펼치기 동작
- [ ] 전체 펼치기/접기 버튼 정상 동작
- [ ] 그룹별 수주량/출고량/잔량 합계 표시
---
## TC-03. 좌측 패널 - 체크박스 선택
### 목적
그룹 테이블에서 체크박스 선택이 정상 동작하는지 확인
### 테스트 단계
1. 개별 행 체크박스 선택/해제
2. 그룹 헤더 체크박스로 그룹 전체 선택/해제
3. 다른 그룹의 행도 동시 선택 가능한지 확인
4. 선택된 행이 하이라이트되는지 확인
### 예상 결과
- [ ] 개별 행 체크박스 선택/해제 정상
- [ ] 그룹 체크박스로 하위 전체 선택/해제
- [ ] 여러 그룹에서 동시 선택 가능
- [ ] 선택된 행 시각적 구분 (하이라이트)
---
## TC-04. 우측 패널 - 완제품 타임라인 기본 표시
### 목적
v2-timeline-scheduler의 기본 렌더링 및 데이터 표시 확인
### 테스트 단계
1. "완제품" 탭 선택 (기본 선택)
2. 타임라인 헤더에 날짜가 표시되는지 확인
3. 리소스(설비) 목록이 좌측에 표시되는지 확인
4. 스케줄 바가 해당 설비/날짜에 표시되는지 확인
5. 스케줄 바에 품명이 표시되는지 확인
6. 오늘 날짜 라인(빨간 세로선)이 표시되는지 확인
### 예상 결과
- [ ] 타임라인 헤더에 날짜 표시 (월 그룹 + 일별)
- [ ] 좌측 리소스 열에 설비명 표시 (프레스기#1, CNC밀링머신#1 등)
- [ ] 7건의 완제품 스케줄 바가 올바른 위치에 표시
- [ ] 스케줄 바에 item_name 표시
- [ ] 오늘 날짜 (2026-03-16) 위치에 빨간 세로선 표시
- [ ] "반제품" 데이터는 보이지 않음 (staticFilters 적용 확인)
---
## TC-05. 타임라인 - 상태별 색상 표시
### 목적
스케줄 상태에 따른 색상 구분 확인
### 테스트 단계
1. 완제품 탭에서 스케줄 바 색상 확인
2. 각 상태별 색상이 다른지 확인
### 예상 결과
- [ ] `planned` (계획): 파란색 (#3b82f6)
- [ ] `in_progress` (진행): 초록색 (#10b981)
- [ ] `completed` (완료): 회색 (#6b7280)
- [ ] `delayed` (지연): 빨간색 (#ef4444) - 해당 데이터 있으면
- [ ] 상태별 색상이 명확히 구분됨
---
## TC-06. 타임라인 - 진행률 표시
### 목적
스케줄 바 내부에 진행률이 시각적으로 표시되는지 확인
### 테스트 단계
1. 진행률이 있는 스케줄 바 확인
2. 바 내부에 진행률 비율만큼 채워진 영역 확인
3. 진행률 퍼센트 텍스트 표시 확인
### 예상 결과
- [ ] `탑씰 Type A` (id:103): 40% 진행률 표시
- [ ] `탑씰 Type B` (id:2): 25% 진행률 표시
- [ ] `탑씰 Type C` (id:105): 25% 진행률 표시
- [ ] `탑씰 Type A` (id:4): 100% 진행률 표시 (완료)
- [ ] 바 내부에 진행 영역이 색이 다르게 채워짐
---
## TC-07. 타임라인 - 줌 레벨 전환
### 목적
일/주/월 줌 레벨 전환이 정상 동작하는지 확인
### 테스트 단계
1. 툴바에서 "주" (기본) 줌 레벨 확인
2. "일" 줌 레벨로 전환 -> 날짜 간격 변화 확인
3. "월" 줌 레벨로 전환 -> 날짜 간격 변화 확인
4. 다시 "주" 줌 레벨로 복귀
### 예상 결과
- [ ] "일" 모드: 날짜 셀이 넓어지고, 하루 단위로 상세 표시
- [ ] "주" 모드: 기본 크기, 주 단위 표시
- [ ] "월" 모드: 날짜 셀이 좁아지고, 월 단위로 축소 표시
- [ ] 줌 레벨 전환 시 스케줄 바 위치/크기가 자동 조정
---
## TC-08. 타임라인 - 날짜 네비게이션
### 목적
이전/다음/오늘 버튼으로 타임라인 이동이 정상 동작하는지 확인
### 테스트 단계
1. 툴바에서 현재 표시 날짜 확인
2. "다음" 버튼 클릭 -> 다음 주(또는 기간)로 이동
3. "이전" 버튼 클릭 -> 이전 주로 이동
4. "오늘" 버튼 클릭 -> 현재 날짜 영역으로 이동
5. 2월 초 데이터가 있으므로 충분히 이전으로 이동하여 과거 데이터 확인
### 예상 결과
- [ ] "다음" 클릭 시 타임라인이 오른쪽(미래)으로 이동
- [ ] "이전" 클릭 시 타임라인이 왼쪽(과거)으로 이동
- [ ] "오늘" 클릭 시 2026-03-16 부근으로 이동
- [ ] 날짜 헤더의 표시 날짜가 변경됨
- [ ] 이동 후에도 스케줄 바가 올바른 위치에 표시
---
## TC-09. 타임라인 - 드래그 이동
### 목적
스케줄 바를 드래그하여 날짜를 변경하는 기능 확인
### 테스트 단계
1. 완제품 탭에서 `planned` 상태의 스케줄 바 선택 (예: 탑씰 Type A, id:106)
2. 스케줄 바를 마우스로 클릭하고 좌/우로 드래그
3. 드래그 중 바가 마우스를 따라 이동하는지 확인 (시각적 피드백)
4. 마우스 놓기 후 결과 확인
5. 성공 시 토스트 알림 확인
6. DB에 start_date/end_date가 변경되었는지 확인
### 예상 결과
- [ ] 스케줄 바 드래그 시 시각적으로 이동 (opacity 변화)
- [ ] 드래그 완료 후 "스케줄이 이동되었습니다" 토스트 표시
- [ ] 날짜가 드래그 거리만큼 변경 (시작일/종료일 동일 간격 유지)
- [ ] 실패 시 "스케줄 이동 실패" 에러 토스트 표시 후 원래 위치로 복귀
---
## TC-10. 타임라인 - 리사이즈 (기간 조정)
### 목적
스케줄 바의 시작/종료 핸들을 드래그하여 기간을 변경하는 기능 확인
### 테스트 단계
1. 완제품 탭에서 스케줄 바에 마우스 호버
2. 바 좌측/우측에 리사이즈 핸들이 나타나는지 확인
3. 우측 핸들을 오른쪽으로 드래그 -> 종료일 연장
4. 좌측 핸들을 오른쪽으로 드래그 -> 시작일 변경
5. 성공 시 토스트 알림 확인
### 예상 결과
- [ ] 바 호버 시 좌/우측에 리사이즈 핸들(세로 바) 표시
- [ ] 우측 핸들 드래그 시 종료일만 변경 (시작일 유지)
- [ ] 좌측 핸들 드래그 시 시작일만 변경 (종료일 유지)
- [ ] 리사이즈 완료 후 "스케줄 기간이 변경되었습니다" 토스트 표시
- [ ] 바 크기가 변경된 기간에 맞게 조정
---
## TC-11. 타임라인 - 충돌 감지
### 목적
같은 설비에 시간이 겹치는 스케줄이 있을 때 충돌 표시가 되는지 확인
### 테스트 단계
1. 충돌 데이터 확인:
- 프레스기#1 (equipment_id=11): id:103 (03/10~03/17), id:4 (01/28~01/30) → 겹치지 않아서 충돌 없음
- 조립라인#1 (equipment_id=14): id:5 (02/01~02/02), id:6 (02/01~02/02) → 기간 겹침! (반제품)
2. 반제품 탭으로 이동하여 조립라인#1의 충돌 확인
3. 또는 드래그로 충돌 상황을 만들어서 확인
### 예상 결과
- [ ] 충돌 스케줄 바에 빨간 외곽선 (`ring-destructive`) 표시
- [ ] 충돌 스케줄 바에 경고 아이콘 (AlertTriangle) 표시
- [ ] 툴바에 "충돌 N건" 배지 표시 (빨간색)
- [ ] 충돌이 없는 경우 배지 미표시
---
## TC-12. 타임라인 - 범례 (Legend)
### 목적
하단 범례가 정상 표시되는지 확인
### 테스트 단계
1. 타임라인 하단에 범례 영역이 표시되는지 확인
2. 상태별 색상 스와치가 표시되는지 확인
3. 마일스톤 아이콘이 표시되는지 확인
4. 충돌 표시 범례가 표시되는지 확인
### 예상 결과
- [ ] "계획" (파란색), "진행" (초록색), "완료" (회색), "지연" (빨간색), "취소" (연회색) 표시
- [ ] "마일스톤" 다이아몬드 아이콘 표시
- [ ] "충돌" 빨간 테두리 아이콘 표시 (showConflicts 설정 시)
- [ ] 범례가 타임라인 하단에 깔끔하게 배치
---
## TC-13. 반제품 탭 전환
### 목적
반제품 탭으로 전환 시 반제품 데이터만 필터링되어 표시되는지 확인 (staticFilters)
### 테스트 단계
1. 우측 패널에서 "반제품" 탭 클릭
2. 표시되는 스케줄이 반제품만인지 확인
3. 완제품 데이터가 보이지 않는지 확인
4. 다시 "완제품" 탭 클릭하여 전환 확인
### 예상 결과
- [ ] "반제품" 탭 클릭 시 반제품 스케줄만 표시 (4건)
- [ ] 반제품 리소스: 조립라인#1, 용접기#1, 레이저절단기
- [ ] 완제품 데이터는 표시되지 않음
- [ ] "완제품" 탭 복귀 시 완제품 데이터만 표시
---
## TC-14. 버튼 - 새로고침
### 목적
"새로고침" 버튼 클릭 시 데이터가 다시 로드되는지 확인
### 테스트 단계
1. 우측 패널 하단의 "새로고침" 버튼 클릭
2. 타임라인 데이터가 다시 로드되는지 확인
3. 토스트 알림 확인
### 예상 결과
- [ ] 클릭 시 API 호출 (GET /api/production/order-summary)
- [ ] 성공 시 "데이터를 새로고침했습니다." 토스트 표시
- [ ] 타임라인 데이터 갱신
---
## TC-15. 버튼 - 자동 스케줄
### 목적
좌측 테이블에서 수주 데이터를 선택한 후 자동 스케줄 생성이 되는지 확인
### 테스트 단계
1. 좌측 패널에서 수주 데이터 행 1개 이상 체크박스 선택
2. "자동 스케줄" 버튼 클릭
3. 확인 다이얼로그 표시 확인 ("선택한 품목의 자동 스케줄을 생성하시겠습니까?")
4. "확인" 클릭
5. 결과 확인
### 예상 결과
- [ ] 확인 다이얼로그 표시
- [ ] 성공 시 "자동 스케줄이 생성되었습니다." 토스트 표시
- [ ] 우측 타임라인에 새로운 스케줄 바 추가
- [ ] 실패 시 에러 메시지 표시
- [ ] 선택 없이 클릭 시 적절한 안내 메시지
---
## TC-16. 버튼 - 선택 품목 불러오기
### 목적
좌측 수주 데이터에서 선택한 품목을 생산계획으로 불러오는 기능 확인
### 테스트 단계
1. 좌측 수주데이터 탭에서 품목 선택 (체크박스)
2. "선택 품목 불러오기" 버튼 클릭
3. 확인 다이얼로그 ("선택한 품목의 생산계획을 생성하시겠습니까?")
4. 결과 확인
### 예상 결과
- [ ] 확인 다이얼로그 표시
- [ ] 성공 시 "선택 품목이 불러와졌습니다." 토스트 표시
- [ ] 타임라인 자동 새로고침
---
## TC-17. 버튼 - 저장
### 목적
변경된 생산계획 데이터가 저장되는지 확인
### 테스트 단계
1. 타임라인에서 스케줄 바 드래그 또는 리사이즈로 데이터 변경
2. "저장" 버튼 클릭
3. 저장 결과 확인
### 예상 결과
- [ ] 성공 시 "생산계획이 저장되었습니다." 토스트 표시
- [ ] 변경 사항이 DB에 반영
---
## TC-18. 반응형 CSS 확인
### 목적
공통 반응형 CSS가 올바르게 적용되었는지 확인
### 테스트 단계
1. 브라우저 창 너비를 640px 이하로 줄이기 (모바일)
2. 텍스트 크기, 버튼 크기, 패딩 변화 확인
3. 브라우저 창 너비를 1280px 이상으로 늘리기 (데스크톱)
4. 원래 크기로 복귀 확인
### 예상 결과
- [ ] 모바일(~640px): 텍스트 `text-[10px]`, 작은 버튼, 좁은 패딩
- [ ] 데스크톱(640px~): 텍스트 `text-sm`, 기본 버튼, 넓은 패딩
- [ ] 줌 버튼, 네비게이션 버튼, 리소스명, 날짜 헤더 모두 반응형 적용
- [ ] 스케줄 바 내부 텍스트도 반응형 (text-[10px] sm:text-xs)
- [ ] 범례 텍스트도 반응형
---
## TC-19. 마일스톤 표시
### 목적
시작일과 종료일이 같은 스케줄이 마일스톤(다이아몬드)으로 표시되는지 확인
### 테스트 단계
1. DB에 마일스톤 테스트 데이터 추가:
```sql
INSERT INTO production_plan_mng (id, item_name, product_type, status, start_date, end_date, equipment_id, progress_rate, company_code)
VALUES (200, '마일스톤 테스트', '완제품', 'planned', '2026-03-20', '2026-03-20', 9, '0', 'COMPANY_7');
```
2. 새로고침 후 해당 날짜에 다이아몬드 마커가 표시되는지 확인
3. 호버 시 정보 표시 확인
### 예상 결과
- [ ] 시작일 = 종료일인 스케줄은 바 대신 다이아몬드 마커로 표시
- [ ] 다이아몬드가 45도 회전된 정사각형으로 표시
- [ ] 호버 시 효과 적용
---
## TC-20. 안전재고 부족분 탭
### 목적
좌측 패널의 "안전재고 부족분" 탭이 정상 동작하는지 확인
### 테스트 단계
1. 좌측 패널에서 "안전재고 부족분" 탭 클릭
2. inventory_stock 테이블 데이터가 표시되는지 확인
3. 빈 데이터인 경우 빈 상태 메시지 확인
### 예상 결과
- [ ] 탭 전환 정상 동작
- [ ] 데이터 있으면: 품목코드, 현재고, 안전재고, 창고, 최근입고일 표시
- [ ] 데이터 없으면: "안전재고 부족분 데이터가 없습니다" 메시지
---
## 알려진 이슈 / 참고 사항
| 번호 | 내용 | 심각도 |
|:----:|------|:------:|
| 1 | "1 Issue" 배지가 화면 좌측 하단에 표시됨 (원인 미확인) | 낮음 |
| 2 | 생산계획 화면 URL 직접 접근 시 회사정보 화면(138)이 먼저 보일 수 있음 → 사이드바 메뉴를 통해 접근 권장 | 중간 |
| 3 | 설비(equipment_info)의 equipment_group이 null → 리소스 그룹핑 미표시 | 낮음 |
| 4 | 가상 스크롤은 리소스(설비) 30개 이상일 때 자동 활성화 (현재 10개라 비활성) | 참고 |
---
## 테스트 결과 요약
| TC | 항목 | 결과 | 비고 |
|:--:|------|:----:|------|
| 01 | 화면 레이아웃 | | |
| 02 | 수주데이터 그룹 테이블 | | |
| 03 | 체크박스 선택 | | |
| 04 | 완제품 타임라인 기본 표시 | | |
| 05 | 상태별 색상 | | |
| 06 | 진행률 표시 | | |
| 07 | 줌 레벨 전환 | | |
| 08 | 날짜 네비게이션 | | |
| 09 | 드래그 이동 | | |
| 10 | 리사이즈 | | |
| 11 | 충돌 감지 | | |
| 12 | 범례 | | |
| 13 | 반제품 탭 전환 | | |
| 14 | 새로고침 버튼 | | |
| 15 | 자동 스케줄 버튼 | | |
| 16 | 선택 품목 불러오기 | | |
| 17 | 저장 버튼 | | |
| 18 | 반응형 CSS | | |
| 19 | 마일스톤 표시 | | |
| 20 | 안전재고 부족분 탭 | | |
@@ -323,7 +323,7 @@ interface ButtonComponentConfig {
| 파일 | 내용 |
|------|------|
| `frontend/lib/button-icon-map.ts` | 버튼 액션별 추천 아이콘 매핑 + 아이콘 동적 렌더링 유틸 |
| `frontend/lib/button-icon-map.tsx` | 버튼 액션별 추천 아이콘 매핑 + 아이콘 동적 렌더링 유틸 |
---
@@ -338,3 +338,52 @@ interface ButtonComponentConfig {
- 외부 SVG 붙여넣기도 지원 → 관리자가 회사 로고 등 자체 아이콘을 등록 가능
- lucide 커스텀 아이콘은 `componentConfig.customIcons`에, SVG 아이콘은 `componentConfig.customSvgIcons`에 저장
- lucide 아이콘 렌더링: 아이콘 이름 → 컴포넌트 매핑, SVG 아이콘 렌더링: `dangerouslySetInnerHTML` + DOMPurify 정화
- **동적 아이콘 로딩**: `iconMap`에 명시적으로 import되지 않은 lucide 아이콘도 `getLucideIcon()` 호출 시 `lucide-react`의 전체 아이콘(`icons`)에서 자동 조회 후 캐싱 → 화면 관리에서 선택한 모든 lucide 아이콘이 실제 화면에서도 렌더링됨
- **커스텀 아이콘 전역 관리 (미구현)**: 커스텀 아이콘을 버튼별(`componentConfig`)이 아닌 시스템 전역(`custom_icon_registry` 테이블)으로 관리하여, 한번 추가한 커스텀 아이콘이 모든 화면의 모든 버튼에서 사용 가능하도록 확장 예정
---
## [미구현] 커스텀 아이콘 전역 관리
### 현재 문제
- 커스텀 아이콘이 `componentConfig.customIcons`에 저장 → **해당 버튼에서만** 보임
- 저장1 버튼에 추가한 커스텀 아이콘이 저장2 버튼, 다른 화면에서는 안 보임
- 같은 아이콘을 쓰려면 매번 검색해서 다시 추가해야 함
### 변경 후 동작
- 커스텀 아이콘을 **회사(company_code) 단위 전역**으로 관리
- 어떤 화면의 어떤 버튼에서든 커스텀 아이콘 추가 → 모든 화면의 모든 버튼에서 커스텀란에 표시
- 버튼 액션 종류와 무관하게 모든 커스텀 아이콘이 노출
### DB 테이블 (신규)
```sql
CREATE TABLE custom_icon_registry (
id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
company_code VARCHAR(500) NOT NULL,
icon_name VARCHAR(500) NOT NULL,
icon_type VARCHAR(500) DEFAULT 'lucide', -- 'lucide' | 'svg'
svg_data TEXT, -- SVG일 경우 원본 데이터
created_date TIMESTAMP DEFAULT now(),
updated_date TIMESTAMP DEFAULT now(),
writer VARCHAR(500)
);
CREATE INDEX idx_custom_icon_registry_company ON custom_icon_registry(company_code);
```
### 백엔드 API (신규)
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/custom-icons` | 커스텀 아이콘 목록 조회 (company_code 필터) |
| POST | `/api/custom-icons` | 커스텀 아이콘 추가 |
| DELETE | `/api/custom-icons/:id` | 커스텀 아이콘 삭제 |
### 프론트엔드 변경
- `ButtonConfigPanel` — 커스텀 아이콘 조회/추가/삭제를 API 호출로 변경
- 기존 `componentConfig.customIcons` 데이터는 하위 호환으로 병합 표시 (점진적 마이그레이션)
- `componentConfig.customSvgIcons`도 동일하게 전역 테이블로 이관
@@ -145,8 +145,24 @@
- **결정**: lucide-react에서 export되는 전체 아이콘 이름 목록을 검색 가능
- **근거**: 관리자가 "어떤 아이콘이 있는지" 모르므로 검색 기능이 필수
- **구현**: lucide 아이콘 이름 배열을 상수로 관리하고, CommandInput으로 필터링
- **주의**: 전체 아이콘 컴포넌트를 import하지 않고, 이름 배열만 관리 → 선택 시에만 해당 아이콘을 매핑에 추가
- **구현**: `lucide-react``icons` 객체에서 `Object.keys()`로 전체 이름 목록을 가져오고, CommandInput으로 필터링
- **주의**: `allLucideIcons``button-icon-map.tsx`에서 re-export하여 import를 중앙화
### 18. 커스텀 아이콘 전역 관리 (미구현)
- **결정**: 커스텀 아이콘을 버튼별(`componentConfig`) → 시스템 전역(`custom_icon_registry` 테이블)으로 변경
- **근거**: 현재는 버튼 A에서 추가한 커스텀 아이콘이 버튼 B, 다른 화면에서 안 보여 매번 재등록 필요. 아이콘은 시각적 자원이므로 액션이나 화면에 종속될 이유가 없음
- **범위 검토**: 버튼별 < 화면 단위 < **시스템 전역(채택)** — 같은 아이콘을 여러 화면에서 재사용하는 ERP 특성에 시스템 전역이 가장 적합
- **저장**: `custom_icon_registry` 테이블 (company_code 멀티테넌시), lucide 이름 또는 SVG 데이터 저장
- **하위 호환**: 기존 `componentConfig.customIcons` 데이터는 병합 표시 후 점진적 마이그레이션
### 19. 동적 아이콘 로딩 (getLucideIcon fallback)
- **결정**: `getLucideIcon(name)``iconMap`에 없는 아이콘을 `lucide-react``icons` 전체 객체에서 동적으로 조회 후 캐싱
- **근거**: 화면 관리에서 커스텀 lucide 아이콘을 선택하면 `componentConfig.customIcons`에 이름만 저장됨. 디자이너 세션에서는 `addToIconMap()`으로 런타임에 등록되지만, 실제 화면(뷰어) 로드 시에는 `iconMap`에 해당 아이콘이 없어 렌더링 실패. `icons` fallback을 추가하면 **어떤 lucide 아이콘이든 이름만으로 자동 렌더링**
- **구현**: `button-icon-map.tsx``import { icons as allLucideIcons } from "lucide-react"` 추가, `getLucideIcon()`에서 `iconMap` miss 시 `allLucideIcons[name]` 조회 후 `iconMap`에 캐싱
- **번들 영향**: `icons` 전체 객체 import로 번들 크기 증가 (~100-200KB). ERP 애플리케이션 특성상 수용 가능한 수준이며, 관리자가 선택한 모든 아이콘이 실제 화면에서 동작하는 것이 더 중요
- **대안 검토**: 뷰어 로드 시 `customIcons`를 순회하여 개별 등록 → 기각 (모든 뷰어 컴포넌트에 로직 추가 필요, 누락 위험)
---
@@ -159,7 +175,7 @@
| 뷰어 렌더링 (수정) | `frontend/components/screen/InteractiveScreenViewer.tsx` | 버튼 렌더링 분기 (2041~2059행) |
| 위젯 (수정) | `frontend/components/screen/widgets/types/ButtonWidget.tsx` | 위젯 기반 버튼 렌더링 (67~86행) |
| 최적화 버튼 (수정) | `frontend/components/screen/OptimizedButtonComponent.tsx` | 최적화된 버튼 렌더링 (643~674행) |
| 아이콘 매핑 (신규) | `frontend/lib/button-icon-map.ts` | 액션별 추천 아이콘 + 동적 렌더링 유틸 |
| 아이콘 매핑 (신규) | `frontend/lib/button-icon-map.tsx` | 액션별 추천 아이콘 + 동적 렌더링 유틸 + allLucideIcons fallback |
| 타입 정의 (참고) | `frontend/types/screen.ts` | ComponentData, componentConfig 타입 |
---
@@ -169,17 +185,21 @@
### lucide-react 아이콘 동적 렌더링
```typescript
// button-icon-map.ts
import { Check, Save, Trash2, Pencil, ... } from "lucide-react";
// button-icon-map.tsx
import { Check, Save, ..., icons as allLucideIcons, type LucideIcon } from "lucide-react";
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
Check, Save, Trash2, Pencil, ...
};
// 추천 아이콘은 명시적 import, 나머지는 동적 조회
const iconMap: Record<string, LucideIcon> = { Check, Save, ... };
export function renderButtonIcon(name: string, size: string | number) {
const IconComponent = iconMap[name];
if (!IconComponent) return null;
return <IconComponent style={getIconSizeStyle(size)} />;
export function getLucideIcon(name: string): LucideIcon | undefined {
if (iconMap[name]) return iconMap[name];
// iconMap에 없으면 lucide-react 전체에서 동적 조회 후 캐싱
const found = allLucideIcons[name as keyof typeof allLucideIcons];
if (found) {
iconMap[name] = found;
return found;
}
return undefined;
}
```
@@ -125,12 +125,30 @@
- [x] 커스텀 아이콘 삭제 시 디폴트 아이콘으로 복귀 → 아이콘 모드 유지 확인
- [x] deprecated 액션에서 디폴트 폴백 아이콘(SquareMousePointer) 표시 확인
### 6단계: 정리
### 6단계: 동적 아이콘 로딩 (뷰어 렌더링 누락 수정)
- [x] TypeScript 컴파일 에러 없음 확인 (우리 파일 6개 모두 0 에러)
- [x] `button-icon-map.tsx``icons as allLucideIcons` import 추가
- [x] `getLucideIcon()``iconMap` miss 시 `allLucideIcons` fallback 조회 + 캐싱
- [x] `allLucideIcons``button-icon-map.tsx`에서 re-export (import 중앙화)
- [x] `ButtonConfigPanel.tsx``lucide-react` 직접 import 제거, `button-icon-map`에서 import로 통합
- [x] 화면 관리에서 선택한 커스텀 lucide 아이콘이 실제 화면(뷰어)에서도 렌더링됨 확인
### 7단계: 정리
- [x] TypeScript 컴파일 에러 없음 확인
- [x] 불필요한 import 없음 확인
- [x] 문서 3개 최신화 (동적 로딩 반영)
- [x] 이 체크리스트 완료 표시 업데이트
### 8단계: 커스텀 아이콘 전역 관리 (미구현)
- [ ] `custom_icon_registry` 테이블 마이그레이션 SQL 작성 및 실행 (개발섭 + 본섭)
- [ ] 백엔드 API 구현 (GET/POST/DELETE `/api/custom-icons`)
- [ ] 프론트엔드 API 클라이언트 함수 추가 (`lib/api/`)
- [ ] `ButtonConfigPanel` — 커스텀 아이콘 조회/추가/삭제를 전역 API로 변경
- [ ] 기존 `componentConfig.customIcons` 하위 호환 병합 처리
- [ ] 검증: 화면 A에서 추가한 커스텀 아이콘이 화면 B에서도 보이는지 확인
---
## 변경 이력
@@ -156,3 +174,6 @@
| 2026-03-04 | 텍스트 위치 4방향 설정 추가 (왼쪽/오른쪽/위쪽/아래쪽) |
| 2026-03-04 | 버튼 테두리 이중 적용 수정 — position wrapper에서 border strip, border shorthand 제거 |
| 2026-03-04 | 프리셋 라벨 한글화 (작게/보통/크게/매우 크게), 라벨 "아이콘 크기 비율"로 변경 |
| 2026-03-13 | 동적 아이콘 로딩 — `getLucideIcon()` fallback으로 `allLucideIcons` 조회+캐싱, import 중앙화 |
| 2026-03-13 | 문서 3개 최신화 (계획서 설계 원칙, 맥락노트 결정사항 #18, 체크리스트 6-7단계) |
| 2026-03-13 | 커스텀 아이콘 전역 관리 계획 추가 (8단계, 미구현) — DB 테이블 + API + 프론트 변경 예정 |
@@ -0,0 +1,171 @@
# BTN - 버튼 UI 스타일 기준정보
## 1. 스타일 기준
### 공통 스타일
| 항목 | 값 |
|---|---|
| 높이 | 40px |
| 표시모드 | 아이콘 + 텍스트 (icon-text) |
| 아이콘 | 액션별 첫 번째 기본 아이콘 (자동 선택) |
| 아이콘 크기 비율 | 보통 |
| 아이콘-텍스트 간격 | 6px |
| 텍스트 위치 | 오른쪽 (아이콘 왼쪽, 텍스트 오른쪽) |
| 테두리 모서리 | 8px |
| 테두리 색상/두께 | 없음 (투명, borderWidth: 0) |
| 텍스트 색상 | #FFFFFF (흰색) |
| 텍스트 크기 | 12px |
| 텍스트 굵기 | normal (보통) |
| 텍스트 정렬 | 왼쪽 |
### 배경색 (액션별)
| 액션 타입 | 배경색 | 비고 |
|---|---|---|
| `delete` | `#F04544` | 빨간색 |
| `excel_download`, `excel_upload`, `multi_table_excel_upload` | `#212121` | 검정색 |
| 그 외 모든 액션 | `#3B83F6` | 파란색 (기본값) |
배경색은 디자이너에서 액션을 변경하면 자동으로 바뀐다.
### 너비 (텍스트 글자수별)
| 글자수 | 너비 |
|---|---|
| 6글자 이하 | 140px |
| 7글자 이상 | 160px |
### 액션별 기본 아이콘
디자이너에서 표시모드를 "아이콘" 또는 "아이콘+텍스트"로 변경하면 액션에 맞는 첫 번째 아이콘이 자동 선택된다.
소스: `frontend/lib/button-icon-map.tsx` > `actionIconMap`
| action.type | 기본 아이콘 |
|---|---|
| `save` | Check |
| `delete` | Trash2 |
| `edit` | Pencil |
| `navigate` | ArrowRight |
| `modal` | Maximize2 |
| `transferData` | SendHorizontal |
| `excel_download` | Download |
| `excel_upload` | Upload |
| `quickInsert` | Zap |
| `control` | Settings |
| `barcode_scan` | ScanLine |
| `operation_control` | Truck |
| `event` | Send |
| `copy` | Copy |
| (그 외/없음) | SquareMousePointer |
---
## 2. 코드 반영 현황
### 컴포넌트 기본값 (신규 버튼 생성 시 적용)
| 파일 | 내용 |
|---|---|
| `frontend/lib/registry/components/v2-button-primary/index.ts` | defaultConfig, defaultSize (140x40) |
| `frontend/lib/registry/components/v2-button-primary/config.ts` | ButtonPrimaryDefaultConfig |
### 액션 변경 시 배경색 자동 변경
| 파일 | 내용 |
|---|---|
| `frontend/components/screen/config-panels/ButtonConfigPanel.tsx` | 액션 변경 시 배경색/텍스트색 자동 설정 |
### 렌더링 배경색 우선순위
| 파일 | 내용 |
|---|---|
| `frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx` | 배경색 결정 우선순위 개선 |
배경색 결정 순서:
1. `webTypeConfig.backgroundColor`
2. `componentConfig.backgroundColor`
3. `component.style.backgroundColor`
4. `componentConfig.style.backgroundColor`
5. `component.style.labelColor` (레거시 호환)
6. 액션별 기본 배경색 (`#F04544` / `#212121` / `#3B83F6`)
### 미반영 (추후 작업)
- split-panel 내부 버튼의 코드 기본값 (split-panel 컴포넌트가 자체 생성하는 버튼)
---
## 3. DB 데이터 매핑 (layout_data JSON)
버튼은 `layout_data.components[]` 배열 안에 `url``v2-button-primary`인 컴포넌트로 저장된다.
| 항목 | JSON 위치 | 값 |
|---|---|---|
| 높이 | `size.height` | `40` |
| 너비 | `size.width` | `140` 또는 `160` |
| 표시모드 | `overrides.displayMode` | `"icon-text"` |
| 아이콘 이름 | `overrides.icon.name` | 액션별 영문 이름 |
| 아이콘 타입 | `overrides.icon.type` | `"lucide"` |
| 아이콘 크기 | `overrides.icon.size` | `"보통"` |
| 텍스트 위치 | `overrides.iconTextPosition` | `"right"` |
| 아이콘-텍스트 간격 | `overrides.iconGap` | `6` |
| 테두리 모서리 | `overrides.style.borderRadius` | `"8px"` |
| 텍스트 색상 | `overrides.style.labelColor` | `"#FFFFFF"` |
| 텍스트 크기 | `overrides.style.fontSize` | `"12px"` |
| 텍스트 굵기 | `overrides.style.fontWeight` | `"normal"` |
| 텍스트 정렬 | `overrides.style.labelTextAlign` | `"left"` |
| 배경색 | `overrides.style.backgroundColor` | 액션별 색상 |
버튼이 위치하는 구조별 경로:
- 일반 버튼: `layout_data.components[]`
- 탭 위젯 내부: `layout_data.components[].overrides.tabs[].components[]`
- split-panel 내부: `layout_data.components[].overrides.rightPanel.components[]`
---
## 4. 탑씰(COMPANY_7) 일괄 변경 작업 기록
### 대상
- **회사**: 탑씰 (company_code = 'COMPANY_7')
- **테이블**: screen_layouts_v2 (배포서버)
- **스크립트**: `backend-node/scripts/btn-bulk-update-company7.ts`
- **백업 테이블**: `screen_layouts_v2_backup_company7`
### 작업 이력
| 날짜 | 작업 내용 | 비고 |
|---|---|---|
| 2026-03-13 | 백업 테이블 생성 | |
| 2026-03-13 | 전체 버튼 공통 스타일 일괄 적용 | 높이, 아이콘, 텍스트 스타일, 배경색, 모서리 |
| 2026-03-13 | 탭 위젯 내부 버튼 스타일 보정 | componentConfig + root style 양쪽 적용 |
| 2026-03-13 | fontWeight "400" → "normal" 보정 | |
| 2026-03-13 | overrides.style.width 제거 | size.width와 충돌 방지 |
| 2026-03-13 | save 액션 55개에 "저장" 텍스트 명시 | |
| 2026-03-13 | "엑셀다운로드" → "Excel" 텍스트 통일 | |
| 2026-03-13 | Excel 버튼 배경색 #212121 통일 | |
| 2026-03-13 | 전체 버튼 너비 140px 통일 | |
| 2026-03-13 | 7글자 이상 버튼 너비 160px 재조정 | |
| 2026-03-13 | split-panel 내부 버튼 스타일 적용 | BOM관리 등 7개 버튼 |
### 스킵 항목
- `transferData` 액션의 텍스트 없는 버튼 1개 (screen=5976)
### 알려진 이슈
- **반응형 너비 불일치**: 디자이너에서 설정한 `size.width`가 실제 화면(`ResponsiveGridRenderer`)에서 반영되지 않을 수 있음. 버튼 wrapper에 `width` 속성이 누락되어 flex shrink-to-fit 동작으로 너비가 줄어드는 현상. 세로(height)는 정상 반영됨.
### 원복 (필요 시)
```sql
UPDATE screen_layouts_v2 AS target
SET layout_data = backup.layout_data
FROM screen_layouts_v2_backup_company7 AS backup
WHERE target.layout_id = backup.layout_id;
```
### 백업 테이블 정리
```sql
DROP TABLE screen_layouts_v2_backup_company7;
```
@@ -0,0 +1,420 @@
# [계획서] 품번 수동 접두어 채번 - 접두어별 독립 순번 생성
> 관련 문서: [맥락노트](./MPN[맥락]-품번-수동접두어채번.md) | [체크리스트](./MPN[체크]-품번-수동접두어채번.md)
## 개요
기준정보 - 품목 정보 등록 모달에서 품번(`item_number`) 채번의 세 가지 문제를 해결합니다.
1. **BULK1 덮어쓰기 문제**: 사용자가 "ㅁㅁㅁ"을 입력해도 수동 값 추출이 실패하여 DB 숨은 값 `manualConfig.value = "BULK1"`로 덮어씌워짐
2. **순번 공유 문제**: `buildPrefixKey`가 수동 파트를 건너뛰어 모든 접두어가 같은 시퀀스 카운터를 공유함
3. **연속 구분자(--) 문제**: 카테고리가 비었을 때 `joinPartsWithSeparators`가 빈 파트에도 구분자를 붙여 `--` 발생 + 템플릿 불일치로 수동 값 추출 실패 → `userInputCode` 전체(구분자 포함)가 수동 값이 됨
---
## 현재 동작
### 채번 규칙 구성 (옵션설정 > 코드설정)
```
규칙1(카테고리/재질, 자동) → "-" → 규칙2(문자, 직접입력) → "-" → 규칙3(순번, 자동, 3자리, 시작=5)
```
### 실제 저장 흐름 (사용자가 "ㅁㅁㅁ" 입력 시)
1. 모달 열림 → `_numberingRuleId` 설정됨 (TextInputComponent L117-128)
2. 사용자가 "ㅁㅁㅁ" 입력 → `formData.item_number = "ㅁㅁㅁ"`
3. 저장 클릭 → `buttonActions.ts``_numberingRuleId` 확인 → `allocateCode(ruleId, "ㅁㅁㅁ", formData)` 호출
4. 백엔드: 템플릿 기반 수동 값 추출 시도 → **실패** (입력 "ㅁㅁㅁ"이 템플릿 "CATEGORY-____-XXX"와 불일치)
5. 폴백: `manualConfig.value = "BULK1"` 사용 → **사용자 입력 "ㅁㅁㅁ" 완전 무시됨**
6. `buildPrefixKey`가 수동 파트를 건너뜀 → prefix_key에 접두어 미포함 → 공유 카운터 사용
7. 결과: **-BULK1-015** (사용자가 뭘 입력하든 항상 BULK1, 항상 공유 카운터)
### 문제 1: 순번 공유 (buildPrefixKey)
**위치**: `numberingRuleService.ts` L85-88
```typescript
if (part.generationMethod === "manual") {
// 수동 입력 파트는 prefix에서 제외 (값이 매번 달라질 수 있으므로)
continue; // ← 접두어별 순번 분리를 막는 원인
}
```
`continue` 때문에 수동 입력값이 prefix_key에 포함되지 않습니다.
"ㅁㅁㅁ", "ㅇㅇㅇ", "BULK1" 전부 **같은 시퀀스 카운터를 공유**합니다.
### 문제 2: BULK1 덮어쓰기 (추출 실패 + manualConfig.value 폴백)
**발생 흐름**:
1. 사용자가 "ㅁㅁㅁ" 입력 → `userInputCode = "ㅁㅁㅁ"` 으로 `allocateCode` 호출
2. `allocateCode` 내부에서 **prefix_key를 먼저 빌드** (L1306) → 수동 값 추출은 그 이후 (L1332-1442)
3. 템플릿 기반 수동 값 추출 시도 (L1411-1436):
```
템플릿: "카테고리값-____-XXX" (카테고리값-수동입력위치-순번)
사용자 입력: "ㅁㅁㅁ"
```
4. "ㅁㅁㅁ"은 "카테고리값-"으로 시작하지 않음 → `startsWith` 불일치 → **추출 실패**`extractedManualValues = []`
5. 코드 조합 단계 (L1448-1454)에서 폴백 체인 동작:
```typescript
const manualValue =
extractedManualValues[0] || // undefined (추출 실패)
part.manualConfig?.value || // "BULK1" (DB 숨은 값) ← 여기서 덮어씌워짐
"";
```
6. 결과: `-BULK1-015` (사용자 입력 "ㅁㅁㅁ"이 완전히 무시됨)
**DB 숨은 값 원인**:
- DB `numbering_rule_parts.manual_config` 컬럼에 `{"value": "BULK1", "placeholder": "..."}` 저장됨
- `ManualConfigPanel.tsx`에는 `placeholder` 입력란만 있고 **`value` 입력란이 없음**
- 플레이스홀더 수정 시 `{ ...config, placeholder: ... }` 스프레드로 기존 `value: "BULK1"`이 계속 보존됨
### 문제 3: 연속 구분자(--) 문제
**발생 흐름**:
1. 카테고리 미선택 → 카테고리 파트 값 = `""` (빈 문자열)
2. `joinPartsWithSeparators`가 빈 파트에도 구분자 `-`를 추가 → 연속 빈 파트 시 `--` 발생
3. 사용자 입력 필드에 `-제발-015` 형태로 표시 (선행 `-`)
4. `extractManualValuesFromInput`에서 템플릿이 `CATEGORY-____-XXX`로 생성됨 (실제 값 `""` 대신 플레이스홀더 `"CATEGORY"` 사용)
5. 입력 `-제발-015``CATEGORY-`로 시작하지 않음 → 추출 실패
6. 폴백: `userInputCode` 전체 `-제발-015`가 수동 값이 됨
7. 코드 조합: `""` + `-` + `-제발-015` + `-` + `003` = `--제발-015-003`
### 정상 동작 확인된 부분
| 항목 | 상태 | 근거 |
|------|------|------|
| `_numberingRuleId` 유지 | 정상 | 사용자 입력해도 allocateCode가 호출됨 |
| 시퀀스 증가 | 정상 | 순번이 증가하고 있음 (015 등) |
| 코드 조합 | 정상 | 구분자, 파트 순서 등 올바르게 결합됨 |
### 비정상 확인된 부분
| 항목 | 상태 | 근거 |
|------|------|------|
| 수동 값 추출 | **실패** | 사용자 입력 "ㅁㅁㅁ"이 템플릿과 불일치 → 추출 실패 → BULK1 폴백 |
| prefix_key 분리 | **실패** | `buildPrefixKey`가 수동 파트 skip → 모든 접두어가 같은 시퀀스 공유 |
| 연속 구분자 | **실패** | 빈 파트에 구분자 추가 + 템플릿 플레이스홀더 불일치 → `--` 발생 |
---
## 변경 후 동작
### prefix_key에 수동 파트 값 포함
```
현재: prefix_key = 카테고리값만 (수동 파트 무시)
변경: prefix_key = 카테고리값 + "|" + 수동입력값
```
### allocateCode 실행 순서 변경
```
현재: buildPrefixKey → 시퀀스 할당 → 수동 값 추출 → 코드 조합
변경: 수동 값 추출 → buildPrefixKey(수동 값 포함) → 시퀀스 할당 → 코드 조합
```
### 순번 동작
```
"ㅁㅁㅁ" 첫 등록 → prefix_key="카테고리|ㅁㅁㅁ", sequence=1 → -ㅁㅁㅁ-001
"ㅁㅁㅁ" 두번째 → prefix_key="카테고리|ㅁㅁㅁ", sequence=2 → -ㅁㅁㅁ-002
"ㅇㅇㅇ" 첫 등록 → prefix_key="카테고리|ㅇㅇㅇ", sequence=1 → -ㅇㅇㅇ-001
"ㅁㅁㅁ" 세번째 → prefix_key="카테고리|ㅁㅁㅁ", sequence=3 → -ㅁㅁㅁ-003
```
### BULK1 폴백 제거 (코드 + DB 이중 조치)
```
코드: 폴백 체인에서 manualConfig.value 제거 → extractedManualValues만 사용
DB: manual_config에서 "value": "BULK1" 키 제거 → 유령 기본값 정리
```
### 연속 구분자 방지 + 템플릿 정합성 복원
```
joinPartsWithSeparators: 빈 파트 뒤에 이미 구분자가 있으면 중복 추가하지 않음
extractManualValuesFromInput: 카테고리/참조 빈 값 시 "" 반환 (플레이스홀더 "CATEGORY"/"REF" 대신)
→ 템플릿이 실제 코드 구조와 일치 → 추출 성공 → -- 방지
```
---
## 시각적 예시
| 사용자 입력 | 현재 동작 | 원인 | 변경 후 동작 |
|------------|----------|------|-------------|
| `ㅁㅁㅁ` (첫번째) | `-BULK1-015` | 추출 실패 → BULK1 폴백 + 공유 카운터 | `카테고리값-ㅁㅁㅁ-001` |
| `ㅁㅁㅁ` (두번째) | `-BULK1-016` | 동일 | `카테고리값-ㅁㅁㅁ-002` |
| `ㅇㅇㅇ` (첫번째) | `-BULK1-017` | 동일 | `카테고리값-ㅇㅇㅇ-001` |
| (입력 안 함) | `-BULK1-018` | manualConfig.value 폴백 | 에러 반환 (수동 파트 필수 입력) |
| 카테고리 비었을 때 | `--제발-015-003` | 빈 파트 구분자 중복 + 템플릿 불일치 | `-제발-001` |
---
## 아키텍처
```mermaid
sequenceDiagram
participant User as 사용자
participant BA as buttonActions.ts
participant API as allocateNumberingCode API
participant NRS as numberingRuleService
participant DB as numbering_rule_sequences
User->>BA: 저장 클릭 (item_number = "ㅁㅁㅁ")
BA->>API: allocateCode(ruleId, "ㅁㅁㅁ", formData)
API->>NRS: allocateCode()
Note over NRS: 1단계: 수동 값 추출 (buildPrefixKey 전에 수행)
NRS->>NRS: extractManualValuesFromInput("ㅁㅁㅁ")
Note over NRS: 템플릿 파싱 실패 → 폴백: userInputCode 전체 사용
NRS->>NRS: extractedManualValues = ["ㅁㅁㅁ"]
Note over NRS: 2단계: prefix_key 빌드 (수동 값 포함)
NRS->>NRS: buildPrefixKey(rule, formData, ["ㅁㅁㅁ"])
Note over NRS: prefix_key = "카테고리값|ㅁㅁㅁ"
Note over NRS: 3단계: 시퀀스 할당
NRS->>DB: UPSERT sequences (prefix_key="카테고리값|ㅁㅁㅁ")
DB-->>NRS: current_sequence = 1
Note over NRS: 4단계: 코드 조합
NRS->>NRS: 카테고리값 + "-" + "ㅁㅁㅁ" + "-" + "001"
NRS-->>API: "카테고리값-ㅁㅁㅁ-001"
API-->>BA: generatedCode
BA->>BA: formData.item_number = "카테고리값-ㅁㅁㅁ-001"
```
---
## 변경 대상 파일
| 파일 | 변경 내용 | 규모 |
|------|----------|------|
| `backend-node/src/services/numberingRuleService.ts` | `buildPrefixKey``manualValues` 파라미터 추가, `allocateCode`에서 수동 값 추출 순서 변경 + 폴백 체인 정리, `extractManualValuesFromInput` 헬퍼 분리, `joinPartsWithSeparators` 연속 구분자 방지, 템플릿 카테고리/참조 플레이스홀더를 실제값으로 변경, `previewCode``manualInputValue` 파라미터 추가 + `startFrom` 적용 | ~80줄 |
| `backend-node/src/controllers/numberingRuleController.ts` | preview 엔드포인트에 `manualInputValue` body 파라미터 수신 추가 | ~2줄 |
| `frontend/lib/api/numberingRule.ts` | `previewNumberingCode``manualInputValue` 파라미터 추가 | ~3줄 |
| `frontend/components/v2/V2Input.tsx` | 수동 입력값 변경 시 디바운스(300ms) preview API 호출 + suffix(순번) 실시간 갱신 | ~35줄 |
| `db/migrations/1053_remove_bulk1_manual_config_value.sql` | `numbering_rule_parts.manual_config`에서 `value: "BULK1"` 제거 | SQL 1건 |
### buildPrefixKey 호출부 영향 분석
| 호출부 | 위치 | `manualValues` 전달 | 영향 |
|--------|------|---------------------|------|
| `previewCode` | L1091 | `manualInputValue` 전달 시 포함 | 접두어별 정확한 순번 조회 |
| `allocateCode` | L1332 | 전달 | prefix_key에 수동 값 포함됨 |
### 멀티테넌시 체크
| 항목 | 상태 | 근거 |
|------|------|------|
| `buildPrefixKey` | 영향 없음 | 시그니처만 확장, company_code 관련 변경 없음 |
| `allocateCode` | 이미 준수 | L1302에서 `companyCode`로 규칙 조회, L1313에서 시퀀스 할당 시 `companyCode` 전달 |
| `joinPartsWithSeparators` | 영향 없음 | 순수 문자열 조합 함수, company_code 무관 |
| DB 마이그레이션 | 해당 없음 | JSONB 내부 값 정리, company_code 무관 |
---
## 코드 설계
### 1. `joinPartsWithSeparators` 수정 - 연속 구분자 방지
**위치**: L36-48
**변경**: 빈 파트 뒤에 이미 구분자가 있으면 중복 추가하지 않음
```typescript
function joinPartsWithSeparators(partValues: string[], sortedParts: any[], globalSeparator: string): string {
let result = "";
partValues.forEach((val, idx) => {
result += val;
if (idx < partValues.length - 1) {
const sep = sortedParts[idx].separatorAfter ?? sortedParts[idx].autoConfig?.separatorAfter ?? globalSeparator;
if (val || !result.endsWith(sep)) {
result += sep;
}
}
});
return result;
}
```
### 2. `buildPrefixKey` 수정 - 수동 파트 값을 prefix에 포함
**위치**: L75-88
**변경**: 세 번째 파라미터 `manualValues` 추가. 전달되면 prefix_key에 포함.
```typescript
private async buildPrefixKey(
rule: NumberingRuleConfig,
formData?: Record<string, any>,
manualValues?: string[]
): Promise<string> {
const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
const prefixParts: string[] = [];
let manualIndex = 0;
for (const part of sortedParts) {
if (part.partType === "sequence") continue;
if (part.generationMethod === "manual") {
const manualValue = manualValues?.[manualIndex] || "";
manualIndex++;
if (manualValue) {
prefixParts.push(manualValue);
}
continue;
}
// ... 나머지 기존 로직 (text, date, category, reference 등) 그대로 유지 ...
}
return prefixParts.join("|");
}
```
**하위 호환성**: `manualValues`는 optional. `previewCode`(L1091)는 전달하지 않으므로 동작 변화 없음.
### 3. `allocateCode` 수정 - 수동 값 추출 순서 변경 + 폴백 정리
**위치**: L1290-1584
**핵심 변경 2가지**:
(A) 기존에는 `buildPrefixKey`(L1306) → 수동 값 추출(L1332) 순서였으나, **수동 값 추출 → `buildPrefixKey`** 순서로 변경.
(B) 코드 조합 단계(L1448-1454)에서 `manualConfig.value` 폴백 제거.
```typescript
async allocateCode(ruleId, companyCode, formData?, userInputCode?) {
// ... 규칙 조회 ...
// 1단계: 수동 파트 값 추출 (buildPrefixKey 호출 전에 수행)
const manualParts = rule.parts.filter(p => p.generationMethod === "manual");
let extractedManualValues: string[] = [];
if (manualParts.length > 0 && userInputCode) {
extractedManualValues = await this.extractManualValuesFromInput(
rule, userInputCode, formData
);
// 폴백: 추출 실패 시 userInputCode 전체를 수동 값으로 사용
if (extractedManualValues.length === 0 && manualParts.length === 1) {
extractedManualValues = [userInputCode];
}
}
// 2단계: 수동 값을 포함하여 prefix_key 빌드
const prefixKey = await this.buildPrefixKey(rule, formData, extractedManualValues);
// 3단계: 시퀀스 할당 (기존 로직 그대로)
// 4단계: 코드 조합 (manualConfig.value 폴백 제거)
// 기존: extractedManualValues[i] || part.manualConfig?.value || ""
// 변경: extractedManualValues[i] || ""
}
```
### 4. `extractManualValuesFromInput` 헬퍼 분리 + 템플릿 정합성 복원
기존 `allocateCode` 내부의 수동 값 추출 로직(L1332-1442)을 별도 private 메서드로 추출.
로직 자체는 변경 없음, 위치만 이동.
카테고리/참조 파트의 빈 값 처리를 실제 코드 생성과 일치시킴.
```typescript
private async extractManualValuesFromInput(
rule: NumberingRuleConfig,
userInputCode: string,
formData?: Record<string, any>
): Promise<string[]> {
// 기존 L1332-1442의 로직을 그대로 이동
// 변경: 카테고리/참조 빈 값 시 "CATEGORY"/"REF" 대신 "" 반환
// → 템플릿이 실제 코드 구조와 일치 → 추출 성공률 향상
}
```
### 5. DB 마이그레이션 - BULK1 유령 기본값 제거
**파일**: `db/migrations/1053_remove_bulk1_manual_config_value.sql`
`numbering_rule_parts.manual_config` 컬럼에서 `value` 키를 제거합니다.
```sql
-- manual_config에서 "value" 키 제거 (BULK1 유령 기본값 정리)
UPDATE numbering_rule_parts
SET manual_config = manual_config - 'value'
WHERE generation_method = 'manual'
AND manual_config ? 'value'
AND manual_config->>'value' = 'BULK1';
```
> PostgreSQL JSONB 연산자 `-`를 사용하여 특정 키만 제거.
> `manual_config`의 나머지 필드(`placeholder` 등)는 유지됨.
> "BULK1" 값을 가진 레코드만 대상으로 하여 안전성 확보.
---
## 설계 원칙
- **변경 범위 최소화**: `numberingRuleService.ts` 코드 변경 + DB 마이그레이션 1건
- **이중 조치**: 코드에서 `manualConfig.value` 폴백 제거 + DB에서 유령 값 정리
- `buildPrefixKey``manualValues`는 optional → 기존 호출부(`previewCode` 등)에 영향 없음
- `allocateCode` 내부 로직 순서만 변경 (추출 → prefix_key 빌드), 새 로직 추가 아님
- 수동 값 추출 로직은 기존 코드를 헬퍼로 분리할 뿐, 로직 자체는 변경 없음
- DB 마이그레이션은 "BULK1" 값만 정확히 타겟팅하여 부작용 방지
- `TextInputComponent.tsx` 변경 불필요 (현재 동작이 올바름)
- 프론트엔드 변경 없음 → 프론트엔드 테스트 불필요
- `joinPartsWithSeparators`는 연속 구분자만 방지, 기존 구분자 구조 유지
- 템플릿 카테고리/참조 빈 값을 실제 코드와 일치시켜 추출 성공률 향상
---
## 실시간 순번 미리보기 (추가 기능)
### 배경
품목 등록 모달에서 수동 입력 세그먼트 우측에 표시되는 순번(suffix)이 입력값과 무관하게 고정되어 있었음. 사용자가 "ㅇㅇ"을 입력하면 해당 접두어로 이미 몇 개가 등록되었는지에 따라 순번이 달라져야 함.
### 목표 동작
```
모달 열림 : -[입력하시오]-005 (startFrom=5 기반 기본 순번)
"ㅇㅇ" 입력 : -[ㅇㅇ]-005 (기존 "ㅇㅇ" 등록 0건)
저장 후 재입력 "ㅇㅇ": -[ㅇㅇ]-006 (기존 "ㅇㅇ" 등록 1건)
```
### 아키텍처
```mermaid
sequenceDiagram
participant User as 사용자
participant V2 as V2Input
participant API as previewNumberingCode
participant BE as numberingRuleService.previewCode
participant DB as numbering_rule_sequences
User->>V2: 수동 입력 "ㅇㅇ"
Note over V2: 디바운스 300ms
V2->>API: preview(ruleId, formData, "ㅇㅇ")
API->>BE: previewCode(ruleId, companyCode, formData, "ㅇㅇ")
BE->>BE: buildPrefixKey(rule, formData, ["ㅇㅇ"])
Note over BE: prefix_key = "카테고리|ㅇㅇ"
BE->>DB: getSequenceForPrefix(prefix_key)
DB-->>BE: currentSeq = 0
Note over BE: nextSequence = 0 + startFrom(5) = 5
BE-->>API: "-____-005"
API-->>V2: generatedCode
V2->>V2: suffix = "-005" 갱신
Note over V2: 화면 표시: -[ㅇㅇ]-005
```
### 변경 내용
1. **백엔드 컨트롤러**: preview 엔드포인트가 `req.body.manualInputValue` 수신
2. **백엔드 서비스**: `previewCode``manualInputValue`를 받아 `buildPrefixKey`에 전달 → 접두어별 정확한 시퀀스 조회
3. **백엔드 서비스**: 수동 파트가 있는데 `manualInputValue`가 없는 초기 상태 → 레거시 공용 시퀀스 조회 건너뜀, `currentSeq = 0` 사용 → `startFrom` 기본값 표시
4. **프론트엔드 API**: `previewNumberingCode``manualInputValue` 파라미터 추가
5. **V2Input**: `manualInputValue` 변경 시 디바운스(300ms) preview API 재호출 → `numberingTemplateRef` 갱신 → suffix 실시간 업데이트
6. **V2Input**: 카테고리 변경 시 초기 useEffect에서도 현재 `manualInputValue`를 preview에 전달 → 카테고리 변경/삭제 시 순번 즉시 반영
7. **코드 정리**: 카테고리 해석 로직 3곳 중복 → `resolveCategoryFormat` 헬퍼로 통합 (약 100줄 감소)
@@ -0,0 +1,161 @@
# [맥락노트] 품번 수동 접두어 채번 - 접두어별 독립 순번 생성
> 관련 문서: [계획서](./MPN[계획]-품번-수동접두어채번.md) | [체크리스트](./MPN[체크]-품번-수동접두어채번.md)
---
## 왜 이 작업을 하는가
- 기준정보 - 품목정보 등록 모달에서 품번 인풋에 사용자가 값을 입력해도 무시되고 "BULK1"로 저장됨
- 서로 다른 접두어("ㅁㅁㅁ", "ㅇㅇㅇ")를 입력해도 전부 같은 시퀀스 카운터를 공유함
- 카테고리 미선택 시 `--제발-015-003` 처럼 연속 구분자가 발생함
- 사용자 입력이 반영되고, 접두어별로 독립된 순번이 부여되어야 함
---
## 핵심 결정 사항과 근거
### 1. 수동 값 추출을 buildPrefixKey 전으로 이동
- **결정**: `allocateCode` 내부에서 수동 값 추출 → buildPrefixKey 순서로 변경
- **근거**: 기존에는 buildPrefixKey(L1306)가 먼저 실행된 후 수동 값 추출(L1332)이 진행됨. 수동 값이 prefix_key에 포함되려면 추출이 먼저 되어야 함
- **대안 검토**: buildPrefixKey 내부에서 직접 추출 → 기각 (역할 분리 위반, previewCode 호출에도 영향)
### 2. buildPrefixKey에 수동 파트 값 포함
- **결정**: `manualValues` optional 파라미터 추가, 전달되면 prefix_key에 포함
- **근거**: 기존 `continue`(L85-87)로 수동 파트가 prefix_key에서 제외되어 모든 접두어가 같은 시퀀스를 공유함
- **하위호환**: optional 파라미터이므로 `previewCode`(L1091) 등 기존 호출부는 영향 없음
### 3. 템플릿 파싱 실패 시 userInputCode 전체를 수동 값으로 사용
- **결정**: 수동 파트가 1개이고 템플릿 기반 추출이 실패하면 `userInputCode` 전체를 수동 값으로 사용
- **근거**: 사용자가 "ㅁㅁㅁ"처럼 접두어 부분만 입력하면 템플릿 "카테고리값-____-XXX"와 불일치. `startsWith` 조건 실패로 추출이 안 됨. 이 경우 입력 전체가 수동 값임
- **제한**: 수동 파트가 2개 이상이면 이 폴백 불가 (어디서 분리할지 알 수 없음)
### 4. 코드 조합에서 manualConfig.value 폴백 제거
- **결정**: `extractedManualValues[i] || part.manualConfig?.value || ""``extractedManualValues[i] || ""`
- **근거**: `manualConfig.value`는 UI에서 입력/편집할 수 없는 유령 필드. `ManualConfigPanel.tsx``value` 입력란이 없어 DB에 한번 저장되면 스프레드 연산자로 계속 보존됨
- **이중 조치**: 코드에서 폴백 제거 + DB 마이그레이션으로 기존 "BULK1" 값 정리
### 5. DB 마이그레이션은 BULK1만 타겟팅
- **결정**: `manual_config->>'value' = 'BULK1'` 조건으로 한정
- **근거**: 다른 value가 의도적으로 설정된 경우가 있을 수 있음. 확인된 문제("BULK1")만 정리하여 부작용 방지
- **대안 검토**: 전체 `manual_config.value` 키 제거 → 보류 (운영 판단 필요)
### 6. extractManualValuesFromInput 헬퍼 분리
- **결정**: 기존 `allocateCode` 내부의 수동 값 추출 로직(L1332-1442)을 별도 private 메서드로 추출
- **근거**: 추출 로직이 약 110줄로 `allocateCode`가 과도하게 비대함. 헬퍼로 분리하면 순서 변경도 자연스러움
- **원칙**: 로직 자체는 변경 없음, 위치만 이동 (구조적 변경과 행위적 변경 분리)
### 7. 프론트엔드 변경 불필요
- **결정**: 프론트엔드 코드 수정 없음
- **근거**: `_numberingRuleId`가 사용자 입력 시에도 유지되고 있음 확인. `buttonActions.ts`가 정상적으로 `allocateCode`를 호출함. 문제는 백엔드 로직에만 있음
### 8. joinPartsWithSeparators 연속 구분자 방지
- **결정**: 빈 파트 뒤에 이미 같은 구분자가 있으면 중복 추가하지 않음
- **근거**: 카테고리가 비면 파트 값 `""` + 구분자 `-`가 반복되어 `--` 발생. 구분자 구조(`-ㅁㅁㅁ-001`)는 유지하되 연속(`--`)만 방지
- **조건**: `if (val || !result.endsWith(sep))` — 값이 있으면 항상 추가, 값이 없으면 이미 같은 구분자로 끝나면 스킵
### 9. 템플릿 카테고리/참조 플레이스홀더를 실제값으로 변경
- **결정**: `extractManualValuesFromInput` 내부의 카테고리/참조 빈 값 반환을 `"CATEGORY"`/`"REF"``""`로 변경
- **근거**: 실제 코드 생성에서 빈 카테고리는 `""`인데 템플릿에서 `"CATEGORY"`를 쓰면 구조 불일치로 추출 실패. 로그로 확인: `userInputCode=-제발-015, previewTemplate=CATEGORY-____-XXX, extractedManualValues=[]`
- **카테고리 있을 때**: `catMapping2?.format` 반환은 수정 전후 동일하여 영향 없음
---
## 관련 파일 위치
| 구분 | 파일 경로 | 설명 |
|------|----------|------|
| 수정 대상 | `backend-node/src/services/numberingRuleService.ts` | joinPartsWithSeparators(L36), buildPrefixKey(L75), extractManualValuesFromInput(신규), allocateCode(L1296) |
| 신규 생성 | `db/migrations/1053_remove_bulk1_manual_config_value.sql` | BULK1 유령 값 정리 마이그레이션 |
| 변경 없음 | `frontend/components/screen/widgets/TextInputComponent.tsx` | _numberingRuleId 유지 확인 완료 |
| 변경 없음 | `frontend/lib/registry/components/numbering-rule/config.ts` | 채번 설정 레지스트리 |
| 변경 없음 | `frontend/components/screen/config-panels/NumberConfigPanel.tsx` | 채번 규칙 설정 패널 |
| 참고 | `backend-node/src/controllers/numberingRuleController.ts` | allocateNumberingCode 컨트롤러 |
---
## 기술 참고
### allocateCode 실행 순서 (변경 전 → 후)
```
변경 전: buildPrefixKey(L1306) → 시퀀스 할당 → 수동 값 추출(L1332) → 코드 조합
변경 후: 수동 값 추출 → buildPrefixKey(수동 값 포함) → 시퀀스 할당 → 코드 조합
```
### prefix_key 구성 (변경 전 → 후)
```
변경 전: "카테고리값" (수동 파트 무시, 모든 접두어가 같은 키)
변경 후: "카테고리값|ㅁㅁㅁ" (수동 파트 포함, 접두어별 독립 키)
```
### 폴백 체인 (변경 전 → 후)
```
변경 전: extractedManualValues[i] || manualConfig.value || ""
변경 후: extractedManualValues[i] || ""
```
### joinPartsWithSeparators 연속 구분자 방지 (변경 전 → 후)
```
변경 전: "" + "-" + "" + "-" + "ㅁㅁㅁ" → "--ㅁㅁㅁ"
변경 후: "" + "-" (이미 "-"로 끝남, 스킵) + "ㅁㅁㅁ" → "-ㅁㅁㅁ"
```
### 템플릿 정합성 (변경 전 → 후)
```
변경 전: 카테고리 비었을 때 템플릿 = "CATEGORY-____-XXX" / 입력 = "-제발-015" → 불일치 → 추출 실패
변경 후: 카테고리 비었을 때 템플릿 = "-____-XXX" / 입력 = "-제발-015" → 일치 → 추출 성공
```
### 10. 실시간 순번 미리보기 구현 방식
- **결정**: V2Input에서 `manualInputValue` 변경 시 디바운스(300ms)로 preview API를 재호출하여 suffix(순번)를 갱신
- **근거**: 기존 preview API는 `manualInputValue` 없이 호출되어 모든 접두어가 같은 기본 순번을 표시함. 접두어별 정확한 순번을 보여주려면 preview 시점에도 수동 값을 전달하여 해당 prefix_key의 시퀀스를 조회해야 함
- **대안 검토**: 프론트엔드에서 카운트 API를 별도 호출 → 기각 (기존 `previewCode` 흐름 재사용이 프로젝트 관행에 부합)
- **디바운스 300ms**: 사용자 타이핑 중 과도한 API 호출 방지. 프로젝트 기존 패턴(검색 디바운스 등)과 동일
### 11. previewCode에 manualInputValue 전달
- **결정**: `previewCode` 시그니처에 `manualInputValue?: string` 추가, `buildPrefixKey``[manualInputValue]`로 전달
- **근거**: `buildPrefixKey`가 이미 `manualValues` optional 파라미터를 지원하므로 자연스럽게 확장 가능. 순번 조회 시 접두어별 독립 시퀀스를 정확히 반영함
- **하위호환**: optional 파라미터이므로 기존 호출(`formData`만 전달)에 영향 없음
### 12. 초기 상태에서 레거시 시퀀스 조회 방지
- **결정**: `previewCode`에서 수동 파트가 있는데 `manualInputValue`가 없으면 시퀀스 조회를 건너뛰고 `currentSeq = 0` 사용
- **근거**: 수정 전에는 모든 할당이 수동 파트 없는 공용 prefix_key를 사용했으므로 레거시 시퀀스가 누적되어 있음(예: 16). 모달 초기 상태에서 이 공용 키를 조회하면 `-016`이 표시됨. 아직 어떤 접두어인지 모르는 상태이므로 `startFrom` 기본값을 보여주는 것이 정확함
- **`currentSeq = 0` + `startFrom`**: `nextSequence = 0 + startFrom(5) = 5``-005` 표시. 사용자가 입력하면 디바운스 preview가 해당 접두어의 실제 시퀀스를 조회
### 13. 카테고리 변경 시 수동 입력값 포함하여 순번 재조회
- **결정**: 초기 useEffect(카테고리 변경 트리거)에서 `previewNumberingCode` 호출 시 현재 `manualInputValue`도 함께 전달
- **근거**: 카테고리를 바꾸거나 삭제하면 prefix_key가 달라지므로 순번도 달라져야 함. 기존에는 입력값 변경과 카테고리 변경이 별도 트리거여서 카테고리 변경 시 수동 값이 누락됨
- **빈 입력값 처리**: `manualInputValue || undefined`로 처리하여 빈 문자열일 때는 기존처럼 `skipSequenceLookup` 작동
### 14. 카테고리 해석 로직 resolveCategoryFormat 헬퍼 통합
- **결정**: `previewCode`, `allocateCode`, `extractManualValuesFromInput` 3곳에 복붙된 카테고리 매핑 해석 로직을 `resolveCategoryFormat` private 메서드로 추출
- **근거**: 동일 로직 약 50줄이 3곳에 복사되어 있었음 (변수명만 pool2/ct2/cc2 등으로 다름). 한 곳을 수정하면 나머지도 동일하게 수정해야 하는 유지보수 위험
- **원칙**: 구조적 변경만 수행 (로직 변경 없음)
### BULK1이 DB에 남아있는 이유
```
ManualConfigPanel.tsx: placeholder 입력란만 존재 (value 입력란 없음)
플레이스홀더 수정 시: { ...existingConfig, placeholder: newValue }
→ 기존 config에 value: "BULK1"이 있으면 스프레드로 계속 보존됨
→ UI에서 제거 불가능한 유령 값
```
@@ -0,0 +1,100 @@
# [체크리스트] 품번 수동 접두어 채번 - 접두어별 독립 순번 생성
> 관련 문서: [계획서](./MPN[계획]-품번-수동접두어채번.md) | [맥락노트](./MPN[맥락]-품번-수동접두어채번.md)
---
## 공정 상태
- 전체 진행률: **100%** (전체 완료)
- 현재 단계: 완료
---
## 구현 체크리스트
### 1단계: 구조적 변경 (행위 변경 없음)
- [x] `numberingRuleService.ts`에서 수동 값 추출 로직을 `extractManualValuesFromInput` private 메서드로 분리
- [x] 기존 `allocateCode` 내부에서 분리한 메서드 호출로 교체
- [x] 기존 동작과 동일한지 확인 (구조적 변경만, 행위 변경 없음)
### 2단계: buildPrefixKey 수정
- [x] `buildPrefixKey` 시그니처에 `manualValues?: string[]` 파라미터 추가
- [x] 수동 파트 처리 로직 변경: `continue``manualValues`에서 값 꺼내 `prefixParts`에 추가
- [x] `previewCode` 호출부에 영향 없음 확인 (optional 파라미터)
### 3단계: allocateCode 순서 변경 + 폴백 정리
- [x] 수동 값 추출 로직을 `buildPrefixKey` 호출 전으로 이동
- [x] 수동 파트 1개 + 추출 실패 시 `userInputCode` 전체를 수동 값으로 사용하는 폴백 추가
- [x] `buildPrefixKey` 호출 시 `extractedManualValues`를 세 번째 인자로 전달
- [x] 코드 조합 단계에서 `part.manualConfig?.value` 폴백 제거
### 4단계: DB 마이그레이션
- [x] `db/migrations/1053_remove_bulk1_manual_config_value.sql` 작성
- [x] `manual_config->>'value' = 'BULK1'` 조건으로 JSONB에서 `value` 키 제거
- [x] 마이그레이션 실행 (9건 정리 완료)
### 5단계: 연속 구분자(--) 방지
- [x] `joinPartsWithSeparators`에서 빈 파트 뒤 연속 구분자 방지 로직 추가
- [x] `extractManualValuesFromInput`에서 카테고리/참조 빈 값 시 `""` 반환 (템플릿 정합성)
### 6단계: 검증
- [x] 카테고리 선택 + 수동입력 "ㅁㅁㅁ" → 카테고리값-ㅁㅁㅁ-001 생성 확인
- [x] 카테고리 미선택 + 수동입력 "ㅁㅁㅁ" → -ㅁㅁㅁ-001 생성 확인 (-- 아님)
- [x] 같은 접두어 "ㅁㅁㅁ" 재등록 → -ㅁㅁㅁ-002 순번 증가 확인
- [x] 다른 접두어 "ㅇㅇㅇ" 등록 → -ㅇㅇㅇ-001 독립 시퀀스 확인
- [x] 수동 파트 없는 채번 규칙 동작 영향 없음 확인
- [x] previewCode (미리보기) 동작 영향 없음 확인
- [x] BULK1이 더 이상 생성되지 않음 확인
### 7단계: 실시간 순번 미리보기
- [x] 백엔드 컨트롤러: preview 엔드포인트에 `manualInputValue` body 파라미터 수신 추가
- [x] 백엔드 서비스: `previewCode``manualInputValue` 파라미터 추가, `buildPrefixKey`에 전달
- [x] 프론트엔드 API: `previewNumberingCode``manualInputValue` 파라미터 추가
- [x] V2Input: `manualInputValue` 변경 시 디바운스(300ms) preview API 호출 + suffix 갱신
- [x] 백엔드 서비스: 초기 상태(수동 입력 없음) 시 레거시 공용 시퀀스 조회 건너뜀 → startFrom 기본값 표시
- [x] V2Input: 카테고리 변경 시 초기 useEffect에서도 `manualInputValue` 전달 → 순번 즉시 반영
- [x] 린트 에러 없음 확인
### 8단계: 코드 정리
- [x] 카테고리 해석 로직 3곳 중복 → `resolveCategoryFormat` 헬퍼 추출 (약 100줄 감소)
- [x] 임시 변수명 정리 (pool2/ct2/cc2 등 복붙 흔적 제거)
- [x] 린트 에러 없음 확인
### 9단계: 정리
- [x] 계획서/맥락노트/체크리스트 최신화
---
## 알려진 이슈 (보류)
| 이슈 | 설명 | 상태 |
|------|------|------|
| 저장 실패 시 순번 갭 | allocateCode와 saveFormData가 별도 트랜잭션이라 저장 실패해도 순번 소비됨 | 보류 |
| 유령 데이터 | 중복 품명으로 간헐적 저장 성공 + 리스트 미노출 | 보류 |
---
## 변경 이력
| 날짜 | 내용 |
|------|------|
| 2026-03-11 | 계획서, 맥락노트, 체크리스트 작성 완료 |
| 2026-03-11 | 1-4단계 구현 완료 |
| 2026-03-11 | 5단계 추가 구현 (연속 구분자 방지 + 템플릿 정합성 복원) |
| 2026-03-11 | 계맥체 최신화 완료. 문제 4-5 보류 |
| 2026-03-12 | 7단계 실시간 순번 미리보기 구현 완료 (백엔드/프론트엔드 4파일) |
| 2026-03-12 | 계맥체 최신화 완료 |
| 2026-03-12 | 초기 상태 레거시 시퀀스 조회 방지 수정 + 계맥체 반영 |
| 2026-03-12 | 카테고리 변경 시 수동 입력값 포함 순번 재조회 수정 |
| 2026-03-12 | resolveCategoryFormat 헬퍼 추출 코드 정리 + 계맥체 최신화 |
| 2026-03-12 | 6단계 검증 완료. 전체 완료 |
@@ -1,11 +1,10 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ArrowLeft, Plus, RefreshCw, Search, LayoutGrid, LayoutList, TestTube2 } from "lucide-react";
import ScreenList from "@/components/screen/ScreenList";
import { Plus, RefreshCw, Search, X, LayoutGrid, LayoutList, TestTube2, Database, MoreHorizontal, PanelLeftClose, PanelLeftOpen } from "lucide-react";
import ScreenDesigner from "@/components/screen/ScreenDesigner";
import TemplateManager from "@/components/screen/TemplateManager";
import { ScreenGroupTreeView } from "@/components/screen/ScreenGroupTreeView";
@@ -15,11 +14,19 @@ import { ScrollToTop } from "@/components/common/ScrollToTop";
import { ScreenDefinition } from "@/types/screen";
import { screenApi } from "@/lib/api/screen";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import CreateScreenModal from "@/components/screen/CreateScreenModal";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet";
// 단계별 진행을 위한 타입 정의
type Step = "list" | "design" | "template" | "v2-test";
type ViewMode = "tree" | "table";
type ViewMode = "flow" | "card";
export default function ScreenManagementPage() {
const searchParams = useSearchParams();
@@ -28,11 +35,15 @@ export default function ScreenManagementPage() {
const [selectedGroup, setSelectedGroup] = useState<{ id: number; name: string; company_code?: string } | null>(null);
const [focusedScreenIdInGroup, setFocusedScreenIdInGroup] = useState<number | null>(null);
const [stepHistory, setStepHistory] = useState<Step[]>(["list"]);
const [viewMode, setViewMode] = useState<ViewMode>("tree");
const [viewMode, setViewMode] = useState<ViewMode>("flow");
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [isDetailOpen, setIsDetailOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const tableCount = useMemo(() => new Set(screens.map((s) => s.tableName).filter(Boolean)).size, [screens]);
// 화면 목록 로드
const loadScreens = useCallback(async () => {
@@ -102,6 +113,7 @@ export default function ScreenManagementPage() {
// 화면 선택 핸들러 (개별 화면 선택 시 그룹 선택 해제)
const handleScreenSelect = (screen: ScreenDefinition) => {
setSelectedScreen(screen);
setIsDetailOpen(true);
setSelectedGroup(null); // 그룹 선택 해제
};
@@ -159,96 +171,126 @@ export default function ScreenManagementPage() {
return (
<div className="flex h-screen flex-col bg-background overflow-hidden">
{/* 페이지 헤더 */}
<div className="flex-shrink-0 border-b bg-background px-6 py-4">
<div className="flex-shrink-0 border-b border-border/50 bg-background/95 backdrop-blur-md px-6 py-3">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> </p>
<div className="flex items-center gap-3">
<h1 className="text-xl font-bold tracking-tight"> </h1>
<Badge variant="secondary" className="text-xs">{screens.length} </Badge>
</div>
<div className="flex items-center gap-2">
{/* V2 컴포넌트 테스트 버튼 */}
<Button
variant="outline"
onClick={() => goToNextStep("v2-test")}
className="gap-2"
>
<TestTube2 className="h-4 w-4" />
V2
</Button>
{/* 뷰 모드 전환 */}
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as ViewMode)}>
<TabsList className="h-9">
<TabsTrigger value="tree" className="gap-1.5 px-3">
<TabsList className="h-9 bg-muted/50 border border-border/50">
<TabsTrigger value="flow" className="gap-1.5 px-3 text-xs">
<LayoutGrid className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="table" className="gap-1.5 px-3">
<TabsTrigger value="card" className="gap-1.5 px-3 text-xs">
<LayoutList className="h-4 w-4" />
</TabsTrigger>
</TabsList>
</Tabs>
<Button variant="outline" size="icon" onClick={loadScreens}>
<RefreshCw className="h-4 w-4" />
</Button>
<Button onClick={() => setIsCreateOpen(true)} className="gap-2">
<Button onClick={() => setIsCreateOpen(true)} className="gap-2 shadow-sm hover:shadow-md transition-shadow">
<Plus className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => goToNextStep("v2-test")}>
<TestTube2 className="h-4 w-4 mr-2" />
V2
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
{/* 메인 콘텐츠 */}
{viewMode === "tree" ? (
{viewMode === "flow" ? (
<div className="flex-1 overflow-hidden flex">
{/* 왼쪽: 트리 구조 */}
<div className="w-[350px] min-w-[280px] max-w-[450px] flex flex-col border-r bg-background">
{/* 검색 */}
<div className="flex-shrink-0 p-3 border-b">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="화면 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 h-9"
/>
{/* 왼쪽: 트리 구조 (접기/펼기 지원) */}
<div className={`flex flex-col border-r border-border/50 bg-background/80 backdrop-blur-sm transition-all duration-300 ease-in-out ${
sidebarCollapsed ? "w-[48px] min-w-[48px]" : "w-[320px] min-w-[280px] max-w-[400px]"
}`}>
{/* 사이드바 헤더 */}
<div className="flex-shrink-0 flex items-center justify-between p-2 border-b border-border/50">
{!sidebarCollapsed && <span className="text-xs font-medium text-muted-foreground px-1"></span>}
<Button
variant="ghost"
size="icon"
className={`h-7 w-7 ${sidebarCollapsed ? "mx-auto" : "ml-auto"}`}
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
>
{sidebarCollapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
</Button>
</div>
{/* 사이드바 접힘 시 아이콘 컬럼 */}
{sidebarCollapsed && (
<div className="flex-1 flex flex-col items-center gap-2 py-3">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setSidebarCollapsed(false)}>
<Search className="h-4 w-4 text-muted-foreground" />
</Button>
<div className="mt-auto pb-2">
<Badge variant="secondary" className="text-[10px] px-1.5">{screens.length}</Badge>
</div>
</div>
</div>
{/* 트리 뷰 */}
<div className="flex-1 overflow-hidden">
<ScreenGroupTreeView
screens={filteredScreens}
selectedScreen={selectedScreen}
onScreenSelect={handleScreenSelect}
onScreenDesign={handleDesignScreen}
searchTerm={searchTerm}
onGroupSelect={(group) => {
setSelectedGroup(group);
setSelectedScreen(null); // 화면 선택 해제
setFocusedScreenIdInGroup(null); // 포커스 초기화
}}
onScreenSelectInGroup={(group, screenId) => {
// 그룹 내 화면 클릭 시
const isNewGroup = selectedGroup?.id !== group.id;
if (isNewGroup) {
// 새 그룹 진입: 포커싱 없이 시작 (첫 진입 시 망가지는 문제 방지)
setSelectedGroup(group);
setFocusedScreenIdInGroup(null);
} else {
// 같은 그룹 내에서 다른 화면 클릭: 포커싱 유지
setFocusedScreenIdInGroup(screenId);
}
setSelectedScreen(null);
}}
/>
</div>
)}
{/* 사이드바 펼침 시 전체 UI */}
{!sidebarCollapsed && (
<>
{/* 검색 */}
<div className="flex-shrink-0 p-3 border-b border-border/50">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="화면 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 h-9 rounded-xl bg-muted/30 border-border/50 focus:bg-background focus:ring-2 focus:ring-primary/30 transition-colors"
/>
</div>
</div>
{/* 트리 뷰 */}
<div className="flex-1 overflow-hidden">
<ScreenGroupTreeView
screens={filteredScreens}
selectedScreen={selectedScreen}
onScreenSelect={handleScreenSelect}
onScreenDesign={handleDesignScreen}
searchTerm={searchTerm}
onGroupSelect={(group) => {
setSelectedGroup(group);
setSelectedScreen(null);
setFocusedScreenIdInGroup(null);
}}
onScreenSelectInGroup={(group, screenId) => {
const isNewGroup = selectedGroup?.id !== group.id;
if (isNewGroup) {
setSelectedGroup(group);
setFocusedScreenIdInGroup(null);
} else {
setFocusedScreenIdInGroup(screenId);
}
setSelectedScreen(null);
}}
/>
</div>
</>
)}
</div>
{/* 오른쪽: 관계 시각화 (React Flow) */}
<div className="flex-1 overflow-hidden">
<div className="flex-1 overflow-hidden bg-muted/10">
<ScreenRelationFlow
screen={selectedScreen}
selectedGroup={selectedGroup}
@@ -257,21 +299,150 @@ export default function ScreenManagementPage() {
</div>
</div>
) : (
// 테이블 뷰 (기존 ScreenList 사용)
<div className="flex-1 overflow-auto p-6">
<ScreenList
onScreenSelect={handleScreenSelect}
selectedScreen={selectedScreen}
onDesignScreen={handleDesignScreen}
/>
<div className="flex-1 overflow-auto p-6 bg-muted/30 dark:bg-background">
{/* 카드 뷰 상단: 검색 + 카운트 */}
<div className="flex items-center gap-3 mb-5">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="화면 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 h-9 rounded-xl bg-card dark:bg-card border-border/50 shadow-sm focus:bg-card focus:ring-2 focus:ring-primary/30 transition-colors"
/>
{searchTerm && (
<button
type="button"
onClick={() => setSearchTerm("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label="검색어 지우기"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<span className="text-xs text-muted-foreground">{filteredScreens.length} </span>
</div>
<div className="grid grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-3">
{filteredScreens.map((screen) => {
const screenType = (screen as { screenType?: string }).screenType || "form";
const isSelected = selectedScreen?.screenId === screen.screenId;
const isRecentlyModified = screen.updatedDate && (Date.now() - new Date(screen.updatedDate).getTime()) < 7 * 24 * 60 * 60 * 1000;
const typeColorClass = screenType === "grid"
? "from-primary to-primary/20"
: screenType === "dashboard"
? "from-warning to-warning/20"
: "from-success to-success/20";
const glowClass = screenType === "grid"
? "hover:shadow-[0_8px_24px_-4px_rgba(0,0,0,0.1),0_0_20px_hsl(var(--primary)/0.1)] dark:hover:shadow-[0_8px_24px_-4px_rgba(0,0,0,0.4),0_0_24px_hsl(var(--primary)/0.15)]"
: screenType === "dashboard"
? "hover:shadow-[0_8px_24px_-4px_rgba(0,0,0,0.1),0_0_20px_hsl(var(--warning)/0.1)] dark:hover:shadow-[0_8px_24px_-4px_rgba(0,0,0,0.4),0_0_24px_hsl(var(--warning)/0.12)]"
: "hover:shadow-[0_8px_24px_-4px_rgba(0,0,0,0.1),0_0_20px_hsl(var(--success)/0.1)] dark:hover:shadow-[0_8px_24px_-4px_rgba(0,0,0,0.4),0_0_24px_hsl(var(--success)/0.12)]";
const badgeBgClass = screenType === "grid"
? "bg-primary/8 dark:bg-primary/15 text-primary"
: screenType === "dashboard"
? "bg-warning/8 dark:bg-warning/15 text-warning"
: "bg-success/8 dark:bg-success/15 text-success";
return (
<div
key={screen.screenId}
className={`group relative overflow-hidden rounded-[12px] cursor-pointer transition-all duration-250 ease-[cubic-bezier(0.4,0,0.2,1)] ${
isSelected
? "border border-primary bg-primary/5 dark:bg-primary/8 shadow-[0_0_0_2px_hsl(var(--primary)/0.22),0_1px_3px_rgba(0,0,0,0.06)] dark:shadow-[0_0_0_2px_hsl(var(--primary)/0.3),0_1px_4px_rgba(0,0,0,0.3)]"
: `border border-transparent bg-card shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] dark:shadow-[0_1px_4px_rgba(0,0,0,0.35),0_0_1px_rgba(0,0,0,0.2)] hover:-translate-y-[2px] ${glowClass}`
}`}
onClick={() => handleScreenSelect(screen)}
onDoubleClick={() => handleDesignScreen(screen)}
>
{/* 좌측 그라데이션 액센트 바 */}
<div className={`absolute left-0 top-3 bottom-3 w-[3px] rounded-r-full bg-gradient-to-b ${typeColorClass} transition-all duration-250 group-hover:top-1 group-hover:bottom-1 group-hover:w-[4px]`} />
{isSelected && (
<div className={`absolute left-0 top-0 bottom-0 w-[4px] bg-gradient-to-b ${typeColorClass}`} />
)}
<div className="pl-[14px] pr-4 py-4">
{/* Row 1: 이름 + 타입 뱃지 */}
<div className="flex items-center gap-2 mb-1">
<div className="text-[15px] font-bold leading-snug truncate flex-1 min-w-0 tracking-[-0.3px]">{screen.screenName}</div>
<span className={`text-[11px] font-semibold px-2.5 py-[3px] rounded-md flex-shrink-0 ${badgeBgClass}`}>
{screenType === "grid" ? "그리드" : screenType === "dashboard" ? "대시보드" : "폼"}
</span>
</div>
{/* Row 2: 스크린 코드 */}
<div className="text-[12px] font-mono text-muted-foreground tracking-[-0.3px] truncate mb-3">{screen.screenCode}</div>
{/* Row 3: 테이블 칩 + 메타 */}
<div className="flex items-center gap-1.5 flex-wrap">
<span className="inline-flex items-center gap-1.5 text-[12px] font-medium text-foreground/80 dark:text-foreground/70 px-2.5 py-1 rounded-md bg-muted/60 dark:bg-muted/40">
<Database className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-mono text-[11px]">{screen.tableLabel || screen.tableName || "—"}</span>
</span>
</div>
{/* Row 4: 날짜 + 수정 상태 */}
<div className="flex items-center justify-between mt-3 pt-2.5 border-t border-border/20 dark:border-border/10">
<span className="text-[12px] font-mono text-muted-foreground">
{screen.updatedDate ? new Date(screen.updatedDate).toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" }) : screen.createdDate ? new Date(screen.createdDate).toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" }) : ""}
</span>
{isRecentlyModified && (
<span className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<span className="relative inline-block w-[6px] h-[6px] rounded-full bg-success screen-card-pulse-dot" />
</span>
)}
</div>
</div>
</div>
);
})}
</div>
{filteredScreens.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<Search className="h-8 w-8 mb-3 opacity-30" />
<p className="text-sm"> </p>
</div>
)}
</div>
)}
{/* 화면 디테일 Sheet */}
<Sheet open={isDetailOpen} onOpenChange={setIsDetailOpen}>
<SheetContent className="w-[420px] sm:max-w-[420px]">
<SheetHeader>
<SheetTitle className="text-base">{selectedScreen?.screenName || "화면 상세"}</SheetTitle>
<SheetDescription className="text-xs font-mono">{selectedScreen?.screenCode}</SheetDescription>
</SheetHeader>
{selectedScreen && (
<div className="mt-6 space-y-4">
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground"></span>
<span className="text-xs font-mono">{selectedScreen.tableName || "없음"}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground"> ID</span>
<span className="text-xs font-mono">{selectedScreen.screenId}</span>
</div>
</div>
<div className="flex gap-2 pt-4 border-t border-border/50">
<Button className="flex-1" onClick={() => { handleDesignScreen(selectedScreen); setIsDetailOpen(false); }}>
</Button>
<Button variant="outline" onClick={() => setIsDetailOpen(false)}>
</Button>
</div>
</div>
)}
</SheetContent>
</Sheet>
{/* 화면 생성 모달 */}
<CreateScreenModal
isOpen={isCreateOpen}
onClose={() => setIsCreateOpen(false)}
onSuccess={() => {
open={isCreateOpen}
onOpenChange={setIsCreateOpen}
onCreated={() => {
setIsCreateOpen(false);
loadScreens();
}}
File diff suppressed because it is too large Load Diff
@@ -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
+18 -12
View File
@@ -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;
}
/* 그리드선 숨기기 */
@@ -427,6 +418,21 @@ select {
border-spacing: 0 !important;
}
/* ===== 카드 펄스 도트 애니메이션 ===== */
@keyframes screen-card-pulse {
0%, 100% { opacity: 0; transform: scale(1); }
50% { opacity: 0.35; transform: scale(2); }
}
.screen-card-pulse-dot::after {
content: '';
position: absolute;
inset: -3px;
border-radius: 50%;
background: hsl(var(--success));
opacity: 0;
animation: screen-card-pulse 2.5s ease-in-out infinite;
}
/* ===== 저장 테이블 막대기 애니메이션 ===== */
@keyframes saveBarDrop {
0% {
@@ -0,0 +1,498 @@
"use client";
import React, { useMemo } from "react";
import { X, Type, Settings2, Tag, ToggleLeft, ChevronDown, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Badge } from "@/components/ui/badge";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ColumnTypeInfo, TableInfo, SecondLevelMenu } from "./types";
import { INPUT_TYPE_COLORS } from "./types";
import type { ReferenceTableColumn } from "@/lib/api/entityJoin";
import type { NumberingRuleConfig } from "@/types/numbering-rule";
export interface ColumnDetailPanelProps {
column: ColumnTypeInfo | null;
tables: TableInfo[];
referenceTableColumns: Record<string, ReferenceTableColumn[]>;
secondLevelMenus: SecondLevelMenu[];
numberingRules: NumberingRuleConfig[];
onColumnChange: (field: keyof ColumnTypeInfo, value: unknown) => void;
onClose: () => void;
onLoadReferenceColumns?: (tableName: string) => void;
/** 코드 카테고리 옵션 (value, label) */
codeCategoryOptions?: Array<{ value: string; label: string }>;
/** 참조 테이블 옵션 (value, label) */
referenceTableOptions?: Array<{ value: string; label: string }>;
}
export function ColumnDetailPanel({
column,
tables,
referenceTableColumns,
numberingRules,
onColumnChange,
onClose,
onLoadReferenceColumns,
codeCategoryOptions = [],
referenceTableOptions = [],
}: ColumnDetailPanelProps) {
const [advancedOpen, setAdvancedOpen] = React.useState(false);
const [entityTableOpen, setEntityTableOpen] = React.useState(false);
const [entityColumnOpen, setEntityColumnOpen] = React.useState(false);
const [numberingOpen, setNumberingOpen] = React.useState(false);
const typeConf = column ? INPUT_TYPE_COLORS[column.inputType || "text"] : null;
const refColumns = column?.referenceTable
? referenceTableColumns[column.referenceTable] ?? []
: [];
React.useEffect(() => {
if (column?.referenceTable && column.referenceTable !== "none") {
onLoadReferenceColumns?.(column.referenceTable);
}
}, [column?.referenceTable, onLoadReferenceColumns]);
const advancedCount = useMemo(() => {
if (!column) return 0;
let n = 0;
if (column.defaultValue != null && column.defaultValue !== "") n++;
if (column.maxLength != null && column.maxLength > 0) n++;
return n;
}, [column]);
if (!column) return null;
const refTableOpts = referenceTableOptions.length
? referenceTableOptions
: [{ value: "none", label: "선택 안함" }, ...tables.map((t) => ({ value: t.tableName, label: t.displayName || t.tableName }))];
return (
<div className="flex h-full w-full flex-col border-l bg-card">
{/* 헤더 */}
<div className="flex flex-shrink-0 items-center justify-between border-b px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
{typeConf && (
<span className={cn("rounded px-2 py-0.5 text-xs", typeConf.bgColor, typeConf.color)}>
{typeConf.label}
</span>
)}
<span className="truncate font-mono text-sm font-medium">{column.columnName}</span>
</div>
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={onClose} aria-label="닫기">
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 space-y-4 overflow-y-auto p-4">
{/* [섹션 1] 데이터 타입 선택 */}
<section className="space-y-2">
<div>
<p className="text-sm font-semibold"> ?</p>
<p className="text-xs text-muted-foreground"> </p>
</div>
<div className="grid grid-cols-3 gap-1.5">
{Object.entries(INPUT_TYPE_COLORS).map(([type, conf]) => {
const isSelected = (column.inputType || "text") === type;
return (
<button
key={type}
type="button"
onClick={() => onColumnChange("inputType", type)}
className={cn(
"flex flex-col items-center gap-1 rounded-lg border px-1.5 py-2.5 text-center transition-all",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/30"
: "border-border hover:border-primary/30 hover:bg-accent/50",
)}
>
<span className={cn(
"text-base font-bold leading-none",
isSelected ? "text-primary" : conf.color,
)}>
{conf.iconChar}
</span>
<span className={cn(
"text-[11px] font-semibold leading-tight",
isSelected ? "text-primary" : "text-foreground",
)}>
{conf.label}
</span>
<span className="text-[9px] leading-tight text-muted-foreground">
{conf.desc}
</span>
</button>
);
})}
</div>
</section>
{/* [섹션 2] 타입별 상세 설정 */}
{column.inputType === "entity" && (
<section className="space-y-3">
<div className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-muted-foreground" />
<Label className="text-sm font-medium"> </Label>
</div>
{/* 참조 테이블 */}
<div className="space-y-1">
<Label className="text-[11px] font-medium text-muted-foreground"> </Label>
<Popover open={entityTableOpen} onOpenChange={setEntityTableOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-9 w-full justify-between text-xs"
>
{column.referenceTable && column.referenceTable !== "none"
? refTableOpts.find((o) => o.value === column.referenceTable)?.label ?? column.referenceTable
: "테이블 선택..."}
<ChevronsUpDown className="ml-2 h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
<CommandList className="max-h-[200px]">
<CommandEmpty className="py-2 text-center text-xs"> .</CommandEmpty>
<CommandGroup>
{refTableOpts.map((opt) => (
<CommandItem
key={opt.value}
value={`${opt.label} ${opt.value}`}
onSelect={() => {
onColumnChange("referenceTable", opt.value === "none" ? undefined : opt.value);
if (opt.value !== "none") onLoadReferenceColumns?.(opt.value);
setEntityTableOpen(false);
}}
className="text-xs"
>
<Check
className={cn("mr-2 h-3 w-3", column.referenceTable === opt.value ? "opacity-100" : "opacity-0")}
/>
{opt.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 조인 컬럼 */}
{column.referenceTable && column.referenceTable !== "none" && (
<div className="space-y-1">
<Label className="text-[11px] font-medium text-muted-foreground"> ()</Label>
<Popover open={entityColumnOpen} onOpenChange={setEntityColumnOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={refColumns.length === 0}
className="h-9 w-full justify-between text-xs"
>
{column.referenceColumn && column.referenceColumn !== "none"
? column.referenceColumn
: "컬럼 선택..."}
<ChevronsUpDown className="ml-2 h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
<CommandList className="max-h-[200px]">
<CommandEmpty className="py-2 text-center text-xs"> .</CommandEmpty>
<CommandGroup>
<CommandItem
value="none"
onSelect={() => {
onColumnChange("referenceColumn", undefined);
setEntityColumnOpen(false);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", !column.referenceColumn ? "opacity-100" : "opacity-0")} />
</CommandItem>
{refColumns.map((refCol) => (
<CommandItem
key={refCol.columnName}
value={`${refCol.displayName ?? ""} ${refCol.columnName}`}
onSelect={() => {
onColumnChange("referenceColumn", refCol.columnName);
setEntityColumnOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
column.referenceColumn === refCol.columnName ? "opacity-100" : "opacity-0",
)}
/>
{refCol.columnName}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
{/* 참조 요약 미니맵 */}
{column.referenceTable && column.referenceTable !== "none" && column.referenceColumn && (
<div className="flex items-center gap-2 rounded-md bg-violet-50 px-3 py-2">
<span className="font-mono text-[11px] font-semibold text-violet-600">
{column.referenceTable}
</span>
<span className="text-muted-foreground text-[10px]"></span>
<span className="font-mono text-[11px] font-semibold text-violet-600">
{column.referenceColumn}
</span>
</div>
)}
</section>
)}
{column.inputType === "code" && (
<section className="space-y-2">
<div className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-muted-foreground" />
<Label className="text-sm font-medium"> </Label>
</div>
<div className="space-y-2">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"> </Label>
<Select
value={column.codeCategory ?? "none"}
onValueChange={(v) => onColumnChange("codeCategory", v === "none" ? undefined : v)}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue placeholder="코드 선택" />
</SelectTrigger>
<SelectContent>
{[{ value: "none", label: "선택 안함" }, ...codeCategoryOptions].map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{column.codeCategory && column.codeCategory !== "none" && (
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"> </Label>
<Select
value={column.hierarchyRole ?? "none"}
onValueChange={(v) =>
onColumnChange("hierarchyRole", v === "none" ? undefined : (v as "large" | "medium" | "small"))
}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue placeholder="일반" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="large"></SelectItem>
<SelectItem value="medium"></SelectItem>
<SelectItem value="small"></SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
</section>
)}
{column.inputType === "category" && (
<section className="space-y-2">
<div className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-muted-foreground" />
<Label className="text-sm font-medium"> </Label>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"> (.)</Label>
<Input
value={column.categoryRef ?? ""}
onChange={(e) => onColumnChange("categoryRef", e.target.value || null)}
placeholder="테이블명.컬럼명"
className="h-9 text-xs"
/>
</div>
</section>
)}
{column.inputType === "numbering" && (
<section className="space-y-2">
<div className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-muted-foreground" />
<Label className="text-sm font-medium"> </Label>
</div>
<Popover open={numberingOpen} onOpenChange={setNumberingOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className="h-9 w-full justify-between text-xs">
{column.numberingRuleId
? numberingRules.find((r) => r.ruleId === column.numberingRuleId)?.ruleName ?? column.numberingRuleId
: "규칙 선택..."}
<ChevronsUpDown className="ml-2 h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start">
<Command>
<CommandInput placeholder="규칙 검색..." className="h-8 text-xs" />
<CommandList className="max-h-[200px]">
<CommandEmpty className="py-2 text-center text-xs"> .</CommandEmpty>
<CommandGroup>
<CommandItem
value="none"
onSelect={() => {
onColumnChange("numberingRuleId", undefined);
setNumberingOpen(false);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", !column.numberingRuleId ? "opacity-100" : "opacity-0")} />
</CommandItem>
{numberingRules.map((r) => (
<CommandItem
key={r.ruleId}
value={`${r.ruleName} ${r.ruleId}`}
onSelect={() => {
onColumnChange("numberingRuleId", r.ruleId);
setNumberingOpen(false);
}}
className="text-xs"
>
<Check
className={cn("mr-2 h-3 w-3", column.numberingRuleId === r.ruleId ? "opacity-100" : "opacity-0")}
/>
{r.ruleName}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</section>
)}
{/* [섹션 3] 표시 이름 */}
<section className="space-y-2">
<div className="flex items-center gap-2">
<Tag className="h-4 w-4 text-muted-foreground" />
<Label className="text-sm font-medium"> </Label>
</div>
<Input
value={column.displayName ?? ""}
onChange={(e) => onColumnChange("displayName", e.target.value)}
placeholder={column.columnName}
className="h-9 text-sm"
/>
</section>
{/* [섹션 4] 표시 옵션 */}
<section className="space-y-2">
<div className="flex items-center gap-2">
<ToggleLeft className="h-4 w-4 text-muted-foreground" />
<Label className="text-sm font-medium"> </Label>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium"> </p>
<p className="text-xs text-muted-foreground"> .</p>
</div>
<Switch
checked={column.isNullable === "NO"}
onCheckedChange={(checked) => onColumnChange("isNullable", checked ? "NO" : "YES")}
aria-label="필수 입력"
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium"> </p>
<p className="text-xs text-muted-foreground"> .</p>
</div>
<Switch
checked={false}
onCheckedChange={() => {}}
disabled
aria-label="읽기 전용 (향후 확장)"
/>
</div>
</div>
</section>
{/* [섹션 5] 고급 설정 */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between py-1 text-left"
aria-expanded={advancedOpen}
>
<div className="flex items-center gap-2">
{advancedOpen ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<span className="text-sm font-medium"> </span>
{advancedCount > 0 && (
<Badge variant="secondary" className="text-xs">
{advancedCount}
</Badge>
)}
</div>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="space-y-3 pt-2">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Input
value={column.defaultValue ?? ""}
onChange={(e) => onColumnChange("defaultValue", e.target.value)}
placeholder="기본값"
className="h-9 text-xs"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"> </Label>
<Input
type="number"
value={column.maxLength ?? ""}
onChange={(e) => {
const v = e.target.value;
onColumnChange("maxLength", v === "" ? undefined : Number(v));
}}
placeholder="숫자"
className="h-9 text-xs"
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
</div>
);
}
@@ -0,0 +1,281 @@
"use client";
import React, { useMemo } from "react";
import { MoreHorizontal, Database, Layers, FileStack } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import type { ColumnTypeInfo } from "./types";
import { INPUT_TYPE_COLORS, getColumnGroup } from "./types";
export interface ColumnGridConstraints {
primaryKey: { columns: string[] };
indexes: Array<{ columns: string[]; isUnique: boolean }>;
}
export interface ColumnGridProps {
columns: ColumnTypeInfo[];
selectedColumn: string | null;
onSelectColumn: (columnName: string) => void;
onColumnChange: (columnName: string, field: keyof ColumnTypeInfo, value: unknown) => void;
constraints: ColumnGridConstraints;
typeFilter?: string | null;
getColumnIndexState?: (columnName: string) => { isPk: boolean; hasIndex: boolean };
onPkToggle?: (columnName: string, checked: boolean) => void;
onIndexToggle?: (columnName: string, checked: boolean) => void;
}
function getIndexState(
columnName: string,
constraints: ColumnGridConstraints,
): { isPk: boolean; hasIndex: boolean } {
const isPk = constraints.primaryKey.columns.includes(columnName);
const hasIndex = constraints.indexes.some(
(idx) => !idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName,
);
return { isPk, hasIndex };
}
/** 그룹 헤더 라벨 */
const GROUP_LABELS: Record<string, { icon: React.ElementType; label: string }> = {
basic: { icon: FileStack, label: "기본 정보" },
reference: { icon: Layers, label: "참조 정보" },
meta: { icon: Database, label: "메타 정보" },
};
export function ColumnGrid({
columns,
selectedColumn,
onSelectColumn,
onColumnChange,
constraints,
typeFilter = null,
getColumnIndexState: externalGetIndexState,
onPkToggle,
onIndexToggle,
}: ColumnGridProps) {
const getIdxState = useMemo(
() => externalGetIndexState ?? ((name: string) => getIndexState(name, constraints)),
[constraints, externalGetIndexState],
);
/** typeFilter 적용 후 그룹별로 정렬 */
const filteredAndGrouped = useMemo(() => {
const filtered =
typeFilter != null ? columns.filter((c) => (c.inputType || "text") === typeFilter) : columns;
const groups = { basic: [] as ColumnTypeInfo[], reference: [] as ColumnTypeInfo[], meta: [] as ColumnTypeInfo[] };
for (const col of filtered) {
const group = getColumnGroup(col);
groups[group].push(col);
}
return groups;
}, [columns, typeFilter]);
const totalFiltered =
filteredAndGrouped.basic.length + filteredAndGrouped.reference.length + filteredAndGrouped.meta.length;
return (
<div className="flex flex-1 flex-col overflow-hidden">
<div
className="grid flex-shrink-0 items-center border-b bg-muted/50 px-4 py-2 text-xs font-semibold text-foreground"
style={{ gridTemplateColumns: "4px 140px 1fr 100px 160px 40px" }}
>
<span />
<span> · </span>
<span>/</span>
<span></span>
<span className="text-center">PK / NN / IDX / UQ</span>
<span />
</div>
<div className="flex-1 overflow-y-auto">
{totalFiltered === 0 ? (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
{typeFilter ? "해당 타입의 컬럼이 없습니다." : "컬럼이 없습니다."}
</div>
) : (
(["basic", "reference", "meta"] as const).map((groupKey) => {
const list = filteredAndGrouped[groupKey];
if (list.length === 0) return null;
const { icon: Icon, label } = GROUP_LABELS[groupKey];
return (
<div key={groupKey} className="space-y-1 py-2">
<div className="flex items-center gap-2 border-b border-border/60 px-4 pb-1.5">
<Icon className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{label}
</span>
<Badge variant="secondary" className="text-xs">
{list.length}
</Badge>
</div>
{list.map((column) => {
const typeConf = INPUT_TYPE_COLORS[column.inputType || "text"] || INPUT_TYPE_COLORS.text;
const idxState = getIdxState(column.columnName);
const isSelected = selectedColumn === column.columnName;
return (
<div
key={column.columnName}
role="button"
tabIndex={0}
onClick={() => onSelectColumn(column.columnName)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelectColumn(column.columnName);
}
}}
className={cn(
"grid min-h-12 cursor-pointer items-center gap-2 rounded-md border px-4 py-2 transition-colors",
"grid-cols-[4px_140px_1fr_100px_160px_40px]",
"bg-card border-transparent hover:border-border hover:shadow-sm",
isSelected && "border-primary/30 bg-primary/5 shadow-sm",
)}
>
{/* 4px 색상바 (타입별 진한 색) */}
<div className={cn("h-full min-h-8 w-1 rounded-full", typeConf.barColor)} />
{/* 라벨 + 컬럼명 */}
<div className="min-w-0">
<div className="truncate text-sm font-medium">
{column.displayName || column.columnName}
</div>
<div className="truncate font-mono text-xs text-muted-foreground">
{column.columnName}
</div>
</div>
{/* 참조/설정 칩 */}
<div className="flex min-w-0 flex-wrap gap-1">
{column.inputType === "entity" && column.referenceTable && column.referenceTable !== "none" && (
<>
<Badge variant="outline" className="text-xs font-normal">
{column.referenceTable}
</Badge>
<span className="text-muted-foreground text-xs"></span>
<Badge variant="outline" className="text-xs font-normal">
{column.referenceColumn || "—"}
</Badge>
</>
)}
{column.inputType === "code" && (
<span className="text-muted-foreground truncate text-xs">
{column.codeCategory ?? "—"} · {column.defaultValue ?? ""}
</span>
)}
{column.inputType === "numbering" && column.numberingRuleId && (
<Badge variant="outline" className="text-xs font-normal">
{column.numberingRuleId}
</Badge>
)}
{column.inputType !== "entity" &&
column.inputType !== "code" &&
column.inputType !== "numbering" &&
(column.defaultValue ? (
<span className="text-muted-foreground truncate text-xs">{column.defaultValue}</span>
) : (
<span className="text-muted-foreground/60 text-xs"></span>
))}
</div>
{/* 타입 뱃지 */}
<div className={cn("rounded-md border px-2 py-0.5 text-xs", typeConf.bgColor, typeConf.color)}>
<span className="mr-1 inline-block h-1.5 w-1.5 rounded-full bg-current opacity-70" />
{typeConf.label}
</div>
{/* PK / NN / IDX / UQ (클릭 토글) */}
<div className="flex flex-wrap items-center justify-center gap-1">
<button
type="button"
className={cn(
"rounded border px-1.5 py-0.5 text-[10px] font-bold transition-colors",
idxState.isPk
? "border-blue-200 bg-blue-50 text-blue-600"
: "border-border text-muted-foreground/40 hover:border-blue-200 hover:text-blue-400",
)}
onClick={(e) => {
e.stopPropagation();
onPkToggle?.(column.columnName, !idxState.isPk);
}}
title="Primary Key 토글"
>
PK
</button>
<button
type="button"
className={cn(
"rounded border px-1.5 py-0.5 text-[10px] font-bold transition-colors",
column.isNullable === "NO"
? "border-amber-200 bg-amber-50 text-amber-600"
: "border-border text-muted-foreground/40 hover:border-amber-200 hover:text-amber-400",
)}
onClick={(e) => {
e.stopPropagation();
onColumnChange(column.columnName, "isNullable", column.isNullable === "NO" ? "YES" : "NO");
}}
title="Not Null 토글"
>
NN
</button>
<button
type="button"
className={cn(
"rounded border px-1.5 py-0.5 text-[10px] font-bold transition-colors",
idxState.hasIndex
? "border-emerald-200 bg-emerald-50 text-emerald-600"
: "border-border text-muted-foreground/40 hover:border-emerald-200 hover:text-emerald-400",
)}
onClick={(e) => {
e.stopPropagation();
onIndexToggle?.(column.columnName, !idxState.hasIndex);
}}
title="Index 토글"
>
IDX
</button>
<button
type="button"
className={cn(
"rounded border px-1.5 py-0.5 text-[10px] font-bold transition-colors",
column.isUnique === "YES"
? "border-violet-200 bg-violet-50 text-violet-600"
: "border-border text-muted-foreground/40 hover:border-violet-200 hover:text-violet-400",
)}
onClick={(e) => {
e.stopPropagation();
onColumnChange(column.columnName, "isUnique", column.isUnique === "YES" ? "NO" : "YES");
}}
title="Unique 토글"
>
UQ
</button>
</div>
<div className="flex items-center justify-center">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
onSelectColumn(column.columnName);
}}
aria-label="상세 설정"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</div>
</div>
);
})}
</div>
);
})
)}
</div>
</div>
);
}
@@ -0,0 +1,114 @@
"use client";
import React, { useMemo } from "react";
import { cn } from "@/lib/utils";
import type { ColumnTypeInfo } from "./types";
import { INPUT_TYPE_COLORS } from "./types";
export interface TypeOverviewStripProps {
columns: ColumnTypeInfo[];
activeFilter?: string | null;
onFilterChange?: (type: string | null) => void;
}
/** inputType별 카운트 계산 */
function countByInputType(columns: ColumnTypeInfo[]): Record<string, number> {
const counts: Record<string, number> = {};
for (const col of columns) {
const t = col.inputType || "text";
counts[t] = (counts[t] || 0) + 1;
}
return counts;
}
/** 도넛 차트용 비율 (0~1) 배열 및 라벨 순서 */
function getDonutSegments(counts: Record<string, number>, total: number): Array<{ type: string; ratio: number }> {
const order = Object.keys(INPUT_TYPE_COLORS);
return order
.filter((type) => (counts[type] || 0) > 0)
.map((type) => ({ type, ratio: (counts[type] || 0) / total }));
}
export function TypeOverviewStrip({
columns,
activeFilter = null,
onFilterChange,
}: TypeOverviewStripProps) {
const { counts, total, segments } = useMemo(() => {
const counts = countByInputType(columns);
const total = columns.length || 1;
const segments = getDonutSegments(counts, total);
return { counts, total, segments };
}, [columns]);
/** stroke-dasharray: 비율만큼 둘레에 할당 (둘레 100 기준) */
const circumference = 100;
let offset = 0;
const segmentPaths = segments.map(({ type, ratio }) => {
const length = ratio * circumference;
const dashArray = `${length} ${circumference - length}`;
const dashOffset = -offset;
offset += length;
const conf = INPUT_TYPE_COLORS[type] || { color: "text-muted-foreground", bgColor: "bg-muted" };
return {
type,
dashArray,
dashOffset,
...conf,
};
});
return (
<div className="flex flex-shrink-0 items-center gap-3 border-b bg-muted/30 px-5 py-2.5">
{/* SVG 도넛 (원형 stroke) */}
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center">
<svg className="h-10 w-10 -rotate-90" viewBox="0 0 36 36">
{segmentPaths.map((seg) => (
<g key={seg.type} className={cn(seg.color, "opacity-80")}>
<circle
cx="18"
cy="18"
r="14"
fill="none"
stroke="currentColor"
strokeWidth="6"
strokeDasharray={seg.dashArray}
strokeDashoffset={seg.dashOffset}
aria-hidden
/>
</g>
))}
{segments.length === 0 && (
<circle cx="18" cy="18" r="14" fill="none" stroke="currentColor" strokeWidth="6" className="text-muted-foreground/50" />
)}
</svg>
</div>
{/* 타입 칩 목록 (클릭 시 필터 토글) */}
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
{Object.entries(counts)
.sort((a, b) => (b[1] ?? 0) - (a[1] ?? 0))
.map(([type]) => {
const conf = INPUT_TYPE_COLORS[type] || { color: "text-muted-foreground", bgColor: "bg-muted", label: type };
const isActive = activeFilter === null || activeFilter === type;
return (
<button
key={type}
type="button"
onClick={() => onFilterChange?.(activeFilter === type ? null : type)}
className={cn(
"rounded-md border px-2 py-1 text-xs font-medium transition-colors",
conf.bgColor,
conf.color,
"border-current/20",
isActive ? "ring-1 ring-ring" : "opacity-70 hover:opacity-100",
)}
>
{conf.label} {counts[type]}
</button>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,77 @@
/**
*
* page.tsx에서 /
*/
export interface TableInfo {
tableName: string;
displayName: string;
description: string;
columnCount: number;
}
export interface ColumnTypeInfo {
columnName: string;
displayName: string;
inputType: string;
detailSettings: string;
description: string;
isNullable: string;
isUnique: string;
defaultValue?: string;
maxLength?: number;
numericPrecision?: number;
numericScale?: number;
codeCategory?: string;
codeValue?: string;
referenceTable?: string;
referenceColumn?: string;
displayColumn?: string;
categoryMenus?: number[];
hierarchyRole?: "large" | "medium" | "small";
numberingRuleId?: string;
categoryRef?: string | null;
}
export interface SecondLevelMenu {
menuObjid: number;
menuName: string;
parentMenuName: string;
screenCode?: string;
}
/** 컬럼 그룹 분류 */
export type ColumnGroup = "basic" | "reference" | "meta";
/** 타입별 색상 매핑 (다크모드 호환 레이어 사용) */
export interface TypeColorConfig {
color: string;
bgColor: string;
barColor: string;
label: string;
desc: string;
iconChar: string;
}
/** 입력 타입별 색상 맵 - iconChar는 카드 선택용 시각 아이콘 */
export const INPUT_TYPE_COLORS: Record<string, TypeColorConfig> = {
text: { color: "text-slate-600", bgColor: "bg-slate-50", barColor: "bg-slate-400", label: "텍스트", desc: "일반 텍스트 입력", iconChar: "T" },
number: { color: "text-indigo-600", bgColor: "bg-indigo-50", barColor: "bg-indigo-500", label: "숫자", desc: "숫자만 입력", iconChar: "#" },
date: { color: "text-amber-600", bgColor: "bg-amber-50", barColor: "bg-amber-500", label: "날짜", desc: "날짜 선택", iconChar: "D" },
code: { color: "text-emerald-600", bgColor: "bg-emerald-50", barColor: "bg-emerald-500", label: "코드", desc: "공통코드 선택", iconChar: "{}" },
entity: { color: "text-violet-600", bgColor: "bg-violet-50", barColor: "bg-violet-500", label: "테이블 참조", desc: "다른 테이블 연결", iconChar: "⊞" },
select: { color: "text-cyan-600", bgColor: "bg-cyan-50", barColor: "bg-cyan-500", label: "셀렉트", desc: "직접 옵션 선택", iconChar: "☰" },
checkbox: { color: "text-pink-600", bgColor: "bg-pink-50", barColor: "bg-pink-500", label: "체크박스", desc: "예/아니오 선택", iconChar: "☑" },
numbering: { color: "text-orange-600", bgColor: "bg-orange-50", barColor: "bg-orange-500", label: "채번", desc: "자동 번호 생성", iconChar: "≡" },
category: { color: "text-teal-600", bgColor: "bg-teal-50", barColor: "bg-teal-500", label: "카테고리", desc: "등록된 선택지", iconChar: "⊟" },
textarea: { color: "text-indigo-600", bgColor: "bg-indigo-50", barColor: "bg-indigo-400", label: "여러 줄", desc: "긴 텍스트 입력", iconChar: "≡" },
radio: { color: "text-rose-600", bgColor: "bg-rose-50", barColor: "bg-rose-500", label: "라디오", desc: "하나만 선택", iconChar: "◉" },
};
/** 컬럼 그룹 판별 */
export function getColumnGroup(col: ColumnTypeInfo): ColumnGroup {
const metaCols = ["id", "created_date", "updated_date", "writer", "company_code"];
if (metaCols.includes(col.columnName)) return "meta";
if (["entity", "code", "category"].includes(col.inputType)) return "reference";
return "basic";
}
+40 -148
View File
@@ -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;
}
+243 -151
View File
@@ -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 || [];
// 이미 숨겨져 있으면 무시
+4 -3
View File
@@ -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>
@@ -0,0 +1,70 @@
"use client";
import React from "react";
import { BaseEdge, getBezierPath, type EdgeProps } from "@xyflow/react";
// 커스텀 애니메이션 엣지 — bezier 곡선 + 흐르는 파티클 + 글로우 레이어
export function AnimatedFlowEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style,
markerEnd,
data,
}: EdgeProps) {
const [edgePath] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
const strokeColor = (style?.stroke as string) || "hsl(var(--primary))";
const strokeW = (style?.strokeWidth as number) || 2;
const isActive = data?.active !== false;
const duration = data?.duration || "3s";
const filterId = `edge-glow-${id}`;
return (
<>
{/* 글로우용 SVG 필터 정의 (엣지별 고유 ID) */}
<defs>
<filter id={filterId} x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceGraphic" stdDeviation="2" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* 글로우 레이어 */}
<path
d={edgePath}
fill="none"
stroke={strokeColor}
strokeWidth={strokeW + 4}
strokeOpacity={0.12}
filter={`url(#${filterId})`}
/>
{/* 메인 엣지 */}
<BaseEdge id={id} path={edgePath} style={style} markerEnd={markerEnd} />
{/* 흐르는 파티클 */}
{isActive && (
<>
<circle r="3" fill={strokeColor} filter={`url(#${filterId})`}>
<animateMotion dur={duration} repeatCount="indefinite" path={edgePath} />
</circle>
<circle r="1.5" fill="white" opacity="0.85">
<animateMotion dur={duration} repeatCount="indefinite" path={edgePath} />
</circle>
</>
)}
</>
);
}
@@ -1338,7 +1338,6 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
size: {
...splitAdjustedComponent.size,
width: undefined as unknown as number,
height: undefined as unknown as number,
},
} : {}),
}
@@ -577,13 +577,13 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
// - 버튼 컴포넌트: buttonElementStyle에서 자체 border 적용
const isV2HorizLabel = !!(
componentStyle &&
(componentStyle.labelDisplay === true || componentStyle.labelDisplay === "true") &&
componentStyle.labelDisplay !== false && componentStyle.labelDisplay !== "false" &&
(componentStyle.labelPosition === "left" || componentStyle.labelPosition === "right")
);
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;
@@ -774,7 +774,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
/>
</div>
{/* 선택된 컴포넌트 정보 표시 - 🔧 오른쪽으로 이동 (라벨과 겹치지 않도록) */}
{/* 선택된 컴포넌트 정보 표시 */}
{isSelected && (
<div className="bg-primary text-primary-foreground absolute -top-7 right-0 rounded-md px-2.5 py-1 text-xs font-medium shadow-sm">
{type === "widget" && (
@@ -785,7 +785,18 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
)}
{type !== "widget" && (
<div className="flex items-center gap-1.5">
<span>{component.componentConfig?.type || type}</span>
<span>{(() => {
const ft = (component as any).componentConfig?.fieldType;
if (ft) {
const labels: Record<string, string> = {
text: "텍스트", number: "숫자", textarea: "여러줄",
select: "셀렉트", category: "카테고리", entity: "엔티티",
numbering: "채번",
};
return labels[ft] || ft;
}
return (component as any).componentConfig?.type || componentType || type;
})()}</span>
</div>
)}
</div>
@@ -2,8 +2,6 @@
import React, { useRef, useState, useEffect } from "react";
import { ComponentData } from "@/types/screen";
import { useResponsive } from "@/lib/hooks/useResponsive";
import { cn } from "@/lib/utils";
interface ResponsiveGridRendererProps {
components: ComponentData[];
@@ -12,60 +10,6 @@ interface ResponsiveGridRendererProps {
renderComponent: (component: ComponentData) => React.ReactNode;
}
const FULL_WIDTH_TYPES = new Set([
"table-list",
"v2-table-list",
"table-search-widget",
"v2-table-search-widget",
"conditional-container",
"split-panel-layout",
"split-panel-layout2",
"v2-split-panel-layout",
"screen-split-panel",
"v2-split-line",
"flow-widget",
"v2-tab-container",
"tab-container",
"tabs-widget",
"v2-tabs-widget",
]);
const FLEX_GROW_TYPES = new Set([
"table-list",
"v2-table-list",
"split-panel-layout",
"split-panel-layout2",
"v2-split-panel-layout",
"screen-split-panel",
"v2-tab-container",
"tab-container",
"tabs-widget",
"v2-tabs-widget",
]);
function groupComponentsIntoRows(
components: ComponentData[],
threshold: number = 30
): ComponentData[][] {
if (components.length === 0) return [];
const sorted = [...components].sort((a, b) => a.position.y - b.position.y);
const rows: ComponentData[][] = [];
let currentRow: ComponentData[] = [];
let currentRowY = -Infinity;
for (const comp of sorted) {
if (comp.position.y - currentRowY > threshold) {
if (currentRow.length > 0) rows.push(currentRow);
currentRow = [comp];
currentRowY = comp.position.y;
} else {
currentRow.push(comp);
}
}
if (currentRow.length > 0) rows.push(currentRow);
return rows.map((row) => row.sort((a, b) => a.position.x - b.position.x));
}
function getComponentTypeId(component: ComponentData): string {
const direct =
(component as any).componentType || (component as any).widgetType;
@@ -78,52 +22,16 @@ function getComponentTypeId(component: ComponentData): string {
return component.type || "";
}
function isButtonComponent(component: ComponentData): boolean {
return getComponentTypeId(component).includes("button");
}
function isFullWidthComponent(component: ComponentData): boolean {
return FULL_WIDTH_TYPES.has(getComponentTypeId(component));
}
function shouldFlexGrow(component: ComponentData): boolean {
return FLEX_GROW_TYPES.has(getComponentTypeId(component));
}
function getPercentageWidth(componentWidth: number, canvasWidth: number): number {
const pct = (componentWidth / canvasWidth) * 100;
return pct >= 95 ? 100 : pct;
}
function getRowGap(row: ComponentData[], canvasWidth: number): number {
if (row.length < 2) return 0;
const totalW = row.reduce((s, c) => s + (c.size?.width || 100), 0);
const gap = canvasWidth - totalW;
const cnt = row.length - 1;
if (gap <= 0 || cnt <= 0) return 8;
return Math.min(Math.max(Math.round(gap / cnt), 4), 24);
}
interface ProcessedRow {
type: "normal" | "fullwidth";
mainComponent?: ComponentData;
overlayComps: ComponentData[];
normalComps: ComponentData[];
rowMinY?: number;
rowMaxBottom?: number;
}
function FullWidthOverlayRow({
main,
overlayComps,
/**
* .
* , .
*/
function ProportionalRenderer({
components,
canvasWidth,
canvasHeight,
renderComponent,
}: {
main: ComponentData;
overlayComps: ComponentData[];
canvasWidth: number;
renderComponent: (component: ComponentData) => React.ReactNode;
}) {
}: ResponsiveGridRendererProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [containerW, setContainerW] = useState(0);
@@ -138,68 +46,42 @@ function FullWidthOverlayRow({
return () => ro.disconnect();
}, []);
const compFlexGrow = shouldFlexGrow(main);
const mainY = main.position.y;
const scale = containerW > 0 ? containerW / canvasWidth : 1;
const topLevel = components.filter((c) => !c.parentId);
const ratio = containerW > 0 ? containerW / canvasWidth : 1;
const minButtonY = Math.min(...overlayComps.map((c) => c.position.y));
const rawYOffset = minButtonY - mainY;
const maxBtnH = Math.max(
...overlayComps.map((c) => c.size?.height || 40)
);
const yOffset = rawYOffset + (maxBtnH / 2) * (1 - scale);
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}
className={cn(
"relative flex w-full flex-col",
compFlexGrow ? "min-h-0 flex-1" : "flex-shrink-0"
)}
data-screen-runtime="true"
className="bg-background relative w-full overflow-x-hidden"
style={{ minHeight: containerW > 0 ? `${maxBottom * ratio}px` : "200px" }}
>
<div
data-component-id={main.id}
data-component-type={getComponentTypeId(main)}
className="min-h-0 min-w-0"
style={{
width: "100%",
height: compFlexGrow ? "100%" : "auto",
minHeight: compFlexGrow ? "300px" : undefined,
flexGrow: 1,
}}
>
{renderComponent(main)}
</div>
{overlayComps.length > 0 && containerW > 0 && (
<div
className="pointer-events-none absolute left-0 z-10"
style={{
top: `${yOffset}px`,
width: `${canvasWidth}px`,
height: `${maxBtnH}px`,
transform: `scale(${scale})`,
transformOrigin: "top left",
}}
>
{overlayComps.map((comp) => (
{containerW > 0 &&
topLevel.map((component) => {
const typeId = getComponentTypeId(component);
return (
<div
key={comp.id}
data-component-id={comp.id}
data-component-type={getComponentTypeId(comp)}
className="pointer-events-auto absolute"
key={component.id}
data-component-id={component.id}
data-component-type={typeId}
style={{
left: `${comp.position.x}px`,
top: `${comp.position.y - minButtonY}px`,
width: `${comp.size?.width || 90}px`,
height: `${comp.size?.height || 40}px`,
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(comp)}
{renderComponent(component)}
</div>
))}
</div>
)}
);
})}
</div>
);
}
@@ -210,170 +92,13 @@ export function ResponsiveGridRenderer({
canvasHeight,
renderComponent,
}: ResponsiveGridRendererProps) {
const { isMobile } = useResponsive();
const topLevel = components.filter((c) => !c.parentId);
const rows = groupComponentsIntoRows(topLevel);
const processedRows: ProcessedRow[] = [];
for (const row of rows) {
const fullWidthComps: ComponentData[] = [];
const normalComps: ComponentData[] = [];
for (const comp of row) {
if (isFullWidthComponent(comp)) {
fullWidthComps.push(comp);
} else {
normalComps.push(comp);
}
}
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({
type: "fullwidth",
mainComponent: fwComp,
overlayComps: normalComps,
normalComps: [],
rowMinY,
rowMaxBottom,
});
}
} else if (fullWidthComps.length > 0) {
for (const fwComp of fullWidthComps) {
processedRows.push({
type: "fullwidth",
mainComponent: fwComp,
overlayComps: [],
normalComps: [],
rowMinY,
rowMaxBottom,
});
}
} else {
processedRows.push({
type: "normal",
overlayComps: [],
normalComps,
rowMinY,
rowMaxBottom,
});
}
}
return (
<div
data-screen-runtime="true"
className="bg-background flex h-full w-full flex-col overflow-x-hidden"
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 (
<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);
const hasFlexHeightComp = normalComps.some((c) => {
const h = c.size?.height || 0;
return h / canvasHeight >= 0.8;
});
return (
<div
key={`row-${rowIndex}`}
className={cn(
"flex w-full flex-wrap overflow-hidden",
allButtons && "justify-end px-2 py-1",
hasFlexHeightComp ? "min-h-0 flex-1" : "flex-shrink-0"
)}
style={{ gap: `${gap}px`, marginTop: rowMarginTop > 0 ? `${rowMarginTop}px` : undefined }}
>
{normalComps.map((component) => {
const typeId = getComponentTypeId(component);
const isButton = isButtonComponent(component);
const isFullWidth = isMobile && !isButton;
if (isButton) {
return (
<div
key={component.id}
data-component-id={component.id}
data-component-type={typeId}
className="flex-shrink-0"
style={{
height: component.size?.height
? `${component.size.height}px`
: "40px",
}}
>
{renderComponent(component)}
</div>
);
}
const percentWidth = isFullWidth
? 100
: getPercentageWidth(component.size?.width || 100, canvasWidth);
const flexBasis = isFullWidth
? "100%"
: `calc(${percentWidth}% - ${gap}px)`;
const heightPct = (component.size?.height || 0) / canvasHeight;
const useFlexHeight = heightPct >= 0.8;
return (
<div
key={component.id}
data-component-id={component.id}
data-component-type={typeId}
className={cn("min-w-0 overflow-hidden", useFlexHeight && "min-h-0 flex-1")}
style={{
width: isFullWidth ? "100%" : undefined,
flexBasis: useFlexHeight ? undefined : flexBasis,
flexGrow: 1,
flexShrink: 1,
minWidth: isMobile ? "100%" : undefined,
minHeight: useFlexHeight ? "300px" : (component.size?.height
? `${component.size.height}px`
: undefined),
height: useFlexHeight ? "100%" : "auto",
}}
>
{renderComponent(component)}
</div>
);
})}
</div>
);
})}
</div>
<ProportionalRenderer
components={components}
canvasWidth={canvasWidth}
canvasHeight={canvasHeight}
renderComponent={renderComponent}
/>
);
}
@@ -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) => {
@@ -37,7 +37,8 @@ import {
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/useAuth";
import { getCompanyList, Company } from "@/lib/api/company";
import { getCompanyList } from "@/lib/api/company";
import type { Company } from "@/types/company";
import {
DropdownMenu,
DropdownMenuContent,
@@ -1106,7 +1107,7 @@ export function ScreenGroupTreeView({
{/* 그룹 헤더 */}
<div
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer hover:bg-accent transition-colors",
"flex items-center gap-2 rounded-lg px-2 py-1.5 cursor-pointer hover:bg-muted/40 transition-colors",
"text-sm font-medium group/item",
isMatching && "bg-primary/5 dark:bg-primary/10" // 검색 일치 하이라이트 (연한 배경)
)}
@@ -1119,12 +1120,12 @@ export function ScreenGroupTreeView({
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
{isExpanded ? (
<FolderOpen className="h-4 w-4 shrink-0 text-amber-500" />
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-warning/15"><FolderOpen className="h-3.5 w-3.5 text-warning" /></span>
) : (
<Folder className="h-4 w-4 shrink-0 text-amber-500" />
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-warning/15"><Folder className="h-3.5 w-3.5 text-warning" /></span>
)}
<span className={cn("truncate flex-1", isMatching && "font-medium text-primary/80")}>{group.group_name}</span>
<Badge variant="secondary" className="text-xs">
<Badge variant="secondary" className="text-xs font-mono">
{groupScreens.length}
</Badge>
{/* 그룹 메뉴 버튼 */}
@@ -1157,7 +1158,8 @@ export function ScreenGroupTreeView({
{/* 그룹 내 하위 그룹들 */}
{isExpanded && childGroups.length > 0 && (
<div className="ml-6 mt-1 space-y-0.5">
<div className="relative ml-6 mt-1 space-y-0.5">
<div className="absolute left-[14px] top-0 bottom-0 w-px bg-border/40" />
{childGroups.map((childGroup) => {
const childGroupId = String(childGroup.id);
const isChildExpanded = expandedGroups.has(childGroupId) || shouldAutoExpandForSearch.has(childGroup.id); // 검색 시 상위 그룹만 자동 확장
@@ -1172,7 +1174,7 @@ export function ScreenGroupTreeView({
{/* 중분류 헤더 */}
<div
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer hover:bg-accent transition-colors",
"flex items-center gap-2 rounded-lg px-2 py-1.5 cursor-pointer hover:bg-muted/40 transition-colors",
"text-xs font-medium group/item",
isChildMatching && "bg-primary/5 dark:bg-primary/10"
)}
@@ -1185,12 +1187,12 @@ export function ScreenGroupTreeView({
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
)}
{isChildExpanded ? (
<FolderOpen className="h-3 w-3 shrink-0 text-primary" />
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-primary/15"><FolderOpen className="h-3.5 w-3.5 text-primary" /></span>
) : (
<Folder className="h-3 w-3 shrink-0 text-primary" />
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-primary/15"><Folder className="h-3.5 w-3.5 text-primary" /></span>
)}
<span className={cn("truncate flex-1", isChildMatching && "font-medium text-primary/80")}>{childGroup.group_name}</span>
<Badge variant="secondary" className="text-[10px] h-4">
<Badge variant="secondary" className="text-[10px] h-4 font-mono">
{childScreens.length}
</Badge>
<DropdownMenu>
@@ -1222,7 +1224,8 @@ export function ScreenGroupTreeView({
{/* 중분류 내 손자 그룹들 (소분류) */}
{isChildExpanded && grandChildGroups.length > 0 && (
<div className="ml-6 mt-1 space-y-0.5">
<div className="relative ml-6 mt-1 space-y-0.5">
<div className="absolute left-[14px] top-0 bottom-0 w-px bg-border/30" />
{grandChildGroups.map((grandChild) => {
const grandChildId = String(grandChild.id);
const isGrandExpanded = expandedGroups.has(grandChildId) || shouldAutoExpandForSearch.has(grandChild.id); // 검색 시 상위 그룹만 자동 확장
@@ -1234,7 +1237,7 @@ export function ScreenGroupTreeView({
{/* 소분류 헤더 */}
<div
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer hover:bg-accent transition-colors",
"flex items-center gap-2 rounded-lg px-2 py-1.5 cursor-pointer hover:bg-muted/40 transition-colors",
"text-xs group/item",
isGrandMatching && "bg-primary/5 dark:bg-primary/10"
)}
@@ -1247,12 +1250,12 @@ export function ScreenGroupTreeView({
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
)}
{isGrandExpanded ? (
<FolderOpen className="h-3 w-3 shrink-0 text-emerald-500" />
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-success/15"><FolderOpen className="h-3.5 w-3.5 text-success" /></span>
) : (
<Folder className="h-3 w-3 shrink-0 text-emerald-500" />
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-success/15"><Folder className="h-3.5 w-3.5 text-success" /></span>
)}
<span className={cn("truncate flex-1", isGrandMatching && "font-medium text-primary/80")}>{grandChild.group_name}</span>
<Badge variant="outline" className="text-[10px] h-4">
<Badge variant="outline" className="text-[10px] h-4 font-mono">
{grandScreens.length}
</Badge>
<DropdownMenu>
@@ -1294,9 +1297,9 @@ export function ScreenGroupTreeView({
<div
key={screen.screenId}
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors",
"text-xs hover:bg-accent",
selectedScreen?.screenId === screen.screenId && "bg-accent"
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors duration-150",
"text-xs hover:bg-muted/60",
selectedScreen?.screenId === screen.screenId && "bg-primary/10 border-l-2 border-primary"
)}
onClick={() => handleScreenClickInGroup(screen, grandChild)}
onDoubleClick={() => handleScreenDoubleClick(screen)}
@@ -1330,9 +1333,9 @@ export function ScreenGroupTreeView({
<div
key={screen.screenId}
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors",
"text-xs hover:bg-accent",
selectedScreen?.screenId === screen.screenId && "bg-accent"
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors duration-150",
"text-xs hover:bg-muted/60",
selectedScreen?.screenId === screen.screenId && "bg-primary/10 border-l-2 border-primary"
)}
onClick={() => handleScreenClickInGroup(screen, childGroup)}
onDoubleClick={() => handleScreenDoubleClick(screen)}
@@ -1366,9 +1369,9 @@ export function ScreenGroupTreeView({
<div
key={screen.screenId}
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors",
"text-sm hover:bg-accent group/screen",
selectedScreen?.screenId === screen.screenId && "bg-accent"
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors duration-150",
"text-sm hover:bg-muted/60 group/screen",
selectedScreen?.screenId === screen.screenId && "bg-primary/10 border-l-2 border-primary"
)}
onClick={() => handleScreenClickInGroup(screen, group)}
onDoubleClick={() => handleScreenDoubleClick(screen)}
@@ -1393,7 +1396,7 @@ export function ScreenGroupTreeView({
<div className="mb-1">
<div
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer hover:bg-accent transition-colors",
"flex items-center gap-2 rounded-lg px-2 py-1.5 cursor-pointer hover:bg-muted/40 transition-colors",
"text-sm font-medium text-muted-foreground"
)}
onClick={() => toggleGroup("ungrouped")}
@@ -1405,7 +1408,7 @@ export function ScreenGroupTreeView({
)}
<Folder className="h-4 w-4 shrink-0" />
<span className="truncate flex-1"></span>
<Badge variant="outline" className="text-xs">
<Badge variant="outline" className="text-xs font-mono">
{ungroupedScreens.length}
</Badge>
</div>
@@ -1416,9 +1419,9 @@ export function ScreenGroupTreeView({
<div
key={screen.screenId}
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors",
"text-sm hover:bg-accent",
selectedScreen?.screenId === screen.screenId && "bg-accent"
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors duration-150",
"text-sm hover:bg-muted/60",
selectedScreen?.screenId === screen.screenId && "bg-primary/10 border-l-2 border-primary"
)}
onClick={() => handleScreenClick(screen)}
onDoubleClick={() => handleScreenDoubleClick(screen)}
@@ -2096,15 +2099,15 @@ export function ScreenGroupTreeView({
onClick={() => handleSync("menu-to-screen")}
disabled={isSyncing}
variant="outline"
className="w-full justify-start gap-2 border-emerald-200 bg-emerald-50/50 hover:bg-emerald-100/70 hover:border-emerald-300"
className="w-full justify-start gap-2 border-success/20 bg-success/5 hover:bg-success/10 hover:border-success/30"
>
{isSyncing && syncDirection === "menu-to-screen" ? (
<Loader2 className="h-4 w-4 animate-spin text-emerald-600" />
<Loader2 className="h-4 w-4 animate-spin text-success" />
) : (
<FolderInput className="h-4 w-4 text-emerald-600" />
<FolderInput className="h-4 w-4 text-success" />
)}
<span className="flex-1 text-left text-emerald-700"> </span>
<span className="text-xs text-emerald-500/70">
<span className="flex-1 text-left text-success"> </span>
<span className="text-xs text-success/70">
</span>
</Button>
+194 -247
View File
@@ -11,10 +11,25 @@ import {
MousePointer2,
Key,
Link2,
Columns3,
} from "lucide-react";
import { ScreenLayoutSummary } from "@/lib/api/screenGroup";
// 글로우 펄스 애니메이션 CSS 주입
if (typeof document !== "undefined") {
const styleId = "glow-pulse-animation";
if (!document.getElementById(styleId)) {
const style = document.createElement("style");
style.id = styleId;
style.textContent = `
@keyframes glow-pulse {
from { filter: drop-shadow(0 0 4px hsl(var(--primary) / 0.25)) drop-shadow(0 0 10px hsl(var(--primary) / 0.12)); }
to { filter: drop-shadow(0 0 6px hsl(var(--primary) / 0.35)) drop-shadow(0 0 16px hsl(var(--primary) / 0.18)); }
}
`;
document.head.appendChild(style);
}
}
// ========== 타입 정의 ==========
// 화면 노드 데이터 인터페이스
@@ -107,42 +122,14 @@ const getScreenTypeIcon = (screenType?: string) => {
}
};
// 화면 타입별 색상 (헤더)
const getScreenTypeColor = (screenType?: string, isMain?: boolean) => {
if (!isMain) return "bg-slate-400";
switch (screenType) {
case "grid":
return "bg-violet-500";
case "dashboard":
return "bg-amber-500";
case "action":
return "bg-rose-500";
default:
return "bg-primary";
}
// 화면 타입별 색상 (헤더) - 더 이상 그라데이션 미사용
const getScreenTypeColor = (_screenType?: string, _isMain?: boolean) => {
return "";
};
// 화면 역할(screenRole)에 따른 색상
const getScreenRoleColor = (screenRole?: string) => {
if (!screenRole) return "bg-slate-400";
// 역할명에 포함된 키워드로 색상 결정
const role = screenRole.toLowerCase();
if (role.includes("그리드") || role.includes("grid") || role.includes("메인") || role.includes("main") || role.includes("list")) {
return "bg-violet-500"; // 보라색 - 메인 그리드
}
if (role.includes("등록") || role.includes("폼") || role.includes("form") || role.includes("register") || role.includes("input")) {
return "bg-primary"; // 파란색 - 등록 폼
}
if (role.includes("액션") || role.includes("action") || role.includes("이벤트") || role.includes("event") || role.includes("클릭")) {
return "bg-rose-500"; // 빨간색 - 액션/이벤트
}
if (role.includes("상세") || role.includes("detail") || role.includes("popup") || role.includes("팝업")) {
return "bg-amber-500"; // 주황색 - 상세/팝업
}
return "bg-slate-400"; // 기본 회색
// 화면 역할(screenRole)에 따른 색상 - 더 이상 그라데이션 미사용
const getScreenRoleColor = (_screenRole?: string) => {
return "";
};
// 화면 타입별 라벨
@@ -161,36 +148,26 @@ const getScreenTypeLabel = (screenType?: string) => {
// ========== 화면 노드 (상단) - 미리보기 표시 ==========
export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
const { label, subLabel, isMain, tableName, layoutSummary, isInGroup, isFocused, isFaded, screenRole } = data;
const { label, isMain, tableName, layoutSummary, isFocused, isFaded } = data;
const screenType = layoutSummary?.screenType || "form";
// 그룹 모드에서는 screenRole 기반 색상, 그렇지 않으면 screenType 기반 색상
// isFocused일 때 색상 활성화, isFaded일 때 회색
let headerColor: string;
if (isInGroup) {
if (isFaded) {
headerColor = "bg-muted/60"; // 흑백 처리 - 더 확실한 회색
} else {
// 포커스되었거나 아직 아무것도 선택 안됐을 때: 역할별 색상
headerColor = getScreenRoleColor(screenRole);
}
} else {
headerColor = getScreenTypeColor(screenType, isMain);
}
return (
<div
className={`group relative flex h-[320px] w-[260px] flex-col overflow-hidden rounded-lg border bg-card shadow-md transition-all cursor-pointer ${
className={`group relative flex h-[240px] w-[240px] flex-col overflow-hidden rounded-[10px] border bg-card dark:bg-card/80 backdrop-blur-sm transition-all cursor-pointer ${
isFocused
? "border-2 border-primary ring-4 ring-primary/50 shadow-xl scale-105"
? "border-primary/40 shadow-[0_0_0_1px_hsl(var(--primary)/0.4)] scale-[1.03]"
: isFaded
? "border-border opacity-50"
: "border-border hover:shadow-lg hover:ring-2 hover:ring-primary/20"
? "opacity-40 border-border/40 dark:border-border/10 shadow-[0_4px_24px_-8px_rgba(0,0,0,0.08)] dark:shadow-[0_4px_24px_-8px_rgba(0,0,0,0.5)]"
: "border-border/40 dark:border-border/10 shadow-[0_4px_24px_-8px_rgba(0,0,0,0.08)] dark:shadow-[0_4px_24px_-8px_rgba(0,0,0,0.5)] hover:border-border/50 dark:hover:border-border/20 hover:shadow-[0_4px_24px_-8px_rgba(0,0,0,0.08)] dark:hover:shadow-[0_4px_24px_-8px_rgba(0,0,0,0.5)] hover:-translate-y-0.5"
}`}
style={{
filter: isFaded ? "grayscale(100%)" : "none",
filter: isFaded
? "grayscale(100%)"
: isFocused
? "drop-shadow(0 0 8px hsl(var(--primary) / 0.5)) drop-shadow(0 0 20px hsl(var(--primary) / 0.25))"
: "none",
transition: "all 0.3s ease",
transform: isFocused ? "scale(1.02)" : "scale(1)",
animation: isFocused ? "glow-pulse 2s ease-in-out infinite alternate" : "none",
}}
>
{/* Handles */}
@@ -198,78 +175,49 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
type="target"
position={Position.Left}
id="left"
className="!h-2 !w-2 !border-2 !border-background !bg-primary opacity-0 transition-opacity group-hover:opacity-100"
className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-all duration-300 group-hover:opacity-100 group-hover:shadow-[0_0_6px_hsl(var(--primary)/0.5)]"
/>
<Handle
type="source"
position={Position.Right}
id="right"
className="!h-2 !w-2 !border-2 !border-background !bg-primary opacity-0 transition-opacity group-hover:opacity-100"
className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-all duration-300 group-hover:opacity-100 group-hover:shadow-[0_0_6px_hsl(var(--primary)/0.5)]"
/>
<Handle
type="source"
position={Position.Bottom}
id="bottom"
className="!h-2 !w-2 !border-2 !border-background !bg-primary opacity-0 transition-opacity group-hover:opacity-100"
className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-all duration-300 group-hover:opacity-100 group-hover:shadow-[0_0_6px_hsl(var(--primary)/0.5)]"
/>
{/* 헤더 (컬러) */}
<div className={`flex items-center gap-2 px-3 py-2 text-white ${headerColor} transition-colors duration-300`}>
<Monitor className="h-4 w-4" />
<span className="flex-1 truncate text-xs font-semibold">{label}</span>
{(isMain || isFocused) && <span className="flex h-2 w-2 rounded-full bg-white/80 animate-pulse" />}
{/* 헤더: 그라디언트 제거, 모노크롬 */}
<div className="flex items-center gap-2 border-b border-border/40 dark:border-border/10 bg-muted/50 dark:bg-muted/30 px-3 py-2 transition-colors duration-300">
<div className="flex h-6 w-6 items-center justify-center rounded bg-primary/10 text-primary">
<Monitor className="h-3.5 w-3.5" />
</div>
<div className="flex-1 min-w-0">
<div className="truncate text-xs font-bold text-foreground">{label}</div>
{tableName && <div className="truncate text-[9px] text-muted-foreground font-mono">{tableName}</div>}
</div>
{(isMain || isFocused) && <span className="flex h-2 w-2 rounded-full bg-foreground/[0.12] dark:bg-foreground/8 animate-pulse" />}
</div>
{/* 화면 미리보기 영역 (컴팩트) */}
<div className="h-[140px] overflow-hidden bg-muted/50 p-2">
<div className="h-[110px] overflow-hidden p-2.5">
{layoutSummary ? (
<ScreenPreview layoutSummary={layoutSummary} screenType={screenType} />
) : (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<div className="flex h-full flex-col items-center justify-center text-muted-foreground/70 dark:text-muted-foreground/40">
{getScreenTypeIcon(screenType)}
<span className="mt-1 text-[10px]">: {label}</span>
</div>
)}
</div>
{/* 필드 매핑 영역 */}
<div className="flex-1 overflow-hidden border-t border-border bg-card px-2 py-1.5">
<div className="mb-1 flex items-center gap-1 text-[9px] font-medium text-muted-foreground">
<Columns3 className="h-3 w-3" />
<span> </span>
<span className="ml-auto text-[8px] text-muted-foreground/70">
{layoutSummary?.layoutItems?.filter(i => i.label && !i.componentKind?.includes('button')).length || 0}
</span>
</div>
<div className="flex flex-col gap-0.5 overflow-y-auto" style={{ maxHeight: '80px' }}>
{layoutSummary?.layoutItems
?.filter(item => item.label && !item.componentKind?.includes('button'))
?.slice(0, 6)
?.map((item, idx) => (
<div key={idx} className="flex items-center gap-1 rounded bg-slate-50 px-1.5 py-0.5">
<div className={`h-1.5 w-1.5 rounded-full ${
item.componentKind === 'table-list' ? 'bg-violet-400' :
item.componentKind?.includes('select') ? 'bg-amber-400' :
'bg-slate-400'
}`} />
<span className="flex-1 truncate text-[9px] text-slate-600">{item.label}</span>
<span className="text-[8px] text-slate-400">{item.componentKind?.split('-')[0] || 'field'}</span>
</div>
)) || (
<div className="text-center text-[9px] text-slate-400 py-2"> </div>
)}
</div>
</div>
{/* 푸터 (테이블 정보) */}
<div className="flex items-center justify-between border-t border-border bg-muted/30 px-3 py-1.5">
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
<Database className="h-3 w-3" />
<span className="max-w-[120px] truncate font-mono">{tableName || "No Table"}</span>
</div>
<span className="rounded bg-muted px-1.5 py-0.5 text-[9px] font-medium text-muted-foreground">
{getScreenTypeLabel(screenType)}
</span>
{/* 푸터 (타입 칩 + 컴포넌트 수) */}
<div className="flex items-center justify-between border-t border-border/40 dark:border-border/10 bg-background dark:bg-background/50 px-3 py-1.5">
<span className="text-[9px] font-medium px-[7px] py-[2px] rounded bg-primary/10 text-primary">{getScreenTypeLabel(screenType)}</span>
<span className="text-[9px] text-muted-foreground">{layoutSummary?.totalComponents ?? 0} </span>
</div>
</div>
);
@@ -280,33 +228,33 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
const getComponentColor = (componentKind: string) => {
// 테이블/그리드 관련
if (componentKind === "table-list" || componentKind === "data-grid") {
return "bg-violet-200 border-violet-400";
return "bg-primary/20 border-primary/40";
}
// 검색 필터
if (componentKind === "table-search-widget" || componentKind === "search-filter") {
return "bg-pink-200 border-pink-400";
return "bg-destructive/20 border-destructive/40";
}
// 버튼 관련
if (componentKind?.includes("button")) {
return "bg-blue-300 border-primary";
return "bg-primary/30 border-primary";
}
// 입력 필드
if (componentKind?.includes("input") || componentKind?.includes("text")) {
return "bg-slate-200 border-slate-400";
return "bg-muted border-border";
}
// 셀렉트/드롭다운
if (componentKind?.includes("select") || componentKind?.includes("dropdown")) {
return "bg-amber-200 border-amber-400";
return "bg-warning/20 border-warning/40";
}
// 차트
if (componentKind?.includes("chart")) {
return "bg-emerald-200 border-emerald-400";
return "bg-success/20 border-success/40";
}
// 커스텀 위젯
if (componentKind === "custom") {
return "bg-pink-200 border-pink-400";
return "bg-destructive/20 border-destructive/40";
}
return "bg-slate-100 border-slate-300";
return "bg-muted/50 border-border";
};
// ========== 화면 미리보기 컴포넌트 - 화면 타입별 간단한 일러스트 ==========
@@ -316,130 +264,114 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
}) => {
const { totalComponents, widgetCounts } = layoutSummary;
// 그리드 화면 일러스트
// 그리드 화면 일러스트 (모노크롬)
if (screenType === "grid") {
return (
<div className="flex h-full flex-col gap-2 rounded-lg border border-border bg-muted/30 p-3">
return (
<div className="flex h-full flex-col gap-2 rounded-lg border border-border/30 dark:border-border/5 bg-muted/30 dark:bg-muted/10 p-3">
{/* 상단 툴바 */}
<div className="flex items-center gap-2">
<div className="h-4 w-16 rounded bg-pink-400/80 shadow-sm" />
<div className="h-4 w-16 rounded bg-foreground/[0.15] dark:bg-foreground/10" />
<div className="flex-1" />
<div className="h-4 w-8 rounded bg-primary shadow-sm" />
<div className="h-4 w-8 rounded bg-primary shadow-sm" />
<div className="h-4 w-8 rounded bg-rose-500 shadow-sm" />
<div className="h-4 w-8 rounded bg-foreground/[0.18] dark:bg-foreground/12" />
<div className="h-4 w-8 rounded bg-foreground/[0.18] dark:bg-foreground/12" />
<div className="h-4 w-8 rounded bg-foreground/[0.12] dark:bg-foreground/8" />
</div>
{/* 테이블 헤더 */}
<div className="flex gap-1 rounded-t-md bg-violet-500 px-2 py-2 shadow-sm">
<div className="flex gap-1 rounded-t-md bg-foreground/[0.18] dark:bg-foreground/12 px-2 py-2">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-2.5 flex-1 rounded bg-white/40" />
<div key={i} className="h-2.5 flex-1 rounded bg-foreground/[0.12] dark:bg-foreground/8" />
))}
</div>
{/* 테이블 행들 */}
<div className="flex flex-1 flex-col gap-1 overflow-hidden">
{[...Array(7)].map((_, i) => (
<div key={i} className={`flex gap-1 rounded px-2 py-1.5 ${i % 2 === 0 ? "bg-muted" : "bg-card"}`}>
<div key={i} className={`flex gap-1 rounded px-2 py-1.5 ${i % 2 === 0 ? "bg-muted/30 dark:bg-muted/10" : "bg-card"}`}>
{[...Array(5)].map((_, j) => (
<div key={j} className="h-2 flex-1 rounded bg-muted-foreground/30" />
<div key={j} className="h-2 flex-1 rounded bg-foreground/[0.1] dark:bg-foreground/6" />
))}
</div>
))}
</div>
{/* 페이지네이션 */}
<div className="flex items-center justify-center gap-2 pt-1">
<div className="h-2.5 w-4 rounded bg-muted-foreground/40" />
<div className="h-2.5 w-4 rounded bg-primary" />
<div className="h-2.5 w-4 rounded bg-muted-foreground/40" />
<div className="h-2.5 w-4 rounded bg-muted-foreground/40" />
</div>
{/* 컴포넌트 수 */}
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
{totalComponents}
<div className="h-2.5 w-4 rounded bg-foreground/[0.12] dark:bg-foreground/8" />
<div className="h-2.5 w-4 rounded bg-foreground/[0.18] dark:bg-foreground/12" />
<div className="h-2.5 w-4 rounded bg-foreground/[0.12] dark:bg-foreground/8" />
<div className="h-2.5 w-4 rounded bg-foreground/[0.12] dark:bg-foreground/8" />
</div>
</div>
);
}
// 폼 화면 일러스트
// 폼 화면 일러스트 (모노크롬)
if (screenType === "form") {
return (
<div className="flex h-full flex-col gap-3 rounded-lg border border-border bg-muted/30 p-3">
<div className="flex h-full flex-col gap-3 rounded-lg border border-border/30 dark:border-border/5 bg-muted/30 dark:bg-muted/10 p-3">
{/* 폼 필드들 */}
{[...Array(6)].map((_, i) => (
<div key={i} className="flex items-center gap-3">
<div className="h-2.5 w-14 rounded bg-muted-foreground/50" />
<div className="h-5 flex-1 rounded-md border border-border bg-card shadow-sm" />
<div className="h-2.5 w-14 rounded bg-foreground/[0.12] dark:bg-foreground/8" />
<div className="h-5 flex-1 rounded-md border border-border/30 dark:border-border/5 bg-card" />
</div>
))}
{/* 버튼 영역 */}
<div className="mt-auto flex justify-end gap-2 border-t border-border pt-3">
<div className="h-5 w-14 rounded-md bg-muted-foreground/40 shadow-sm" />
<div className="h-5 w-14 rounded-md bg-primary shadow-sm" />
</div>
{/* 컴포넌트 수 */}
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
{totalComponents}
<div className="mt-auto flex justify-end gap-2 border-t border-border/30 dark:border-border/5 pt-3">
<div className="h-5 w-14 rounded-md bg-foreground/[0.12] dark:bg-foreground/8" />
<div className="h-5 w-14 rounded-md bg-foreground/[0.18] dark:bg-foreground/12" />
</div>
</div>
);
}
// 대시보드 화면 일러스트
// 대시보드 화면 일러스트 (모노크롬)
if (screenType === "dashboard") {
return (
<div className="grid h-full grid-cols-2 gap-2 rounded-lg border border-border bg-muted/30 p-3">
<div className="grid h-full grid-cols-2 gap-2 rounded-lg border border-border/30 dark:border-border/5 bg-muted/30 dark:bg-muted/10 p-3">
{/* 카드/차트들 */}
<div className="rounded-lg bg-emerald-100 p-2 shadow-sm">
<div className="mb-2 h-2.5 w-10 rounded bg-emerald-400" />
<div className="h-10 rounded-md bg-emerald-300/80" />
<div className="rounded-lg bg-foreground/[0.08] dark:bg-foreground/5 p-2">
<div className="mb-2 h-2.5 w-10 rounded bg-foreground/[0.15] dark:bg-foreground/10" />
<div className="h-10 rounded-md bg-foreground/[0.12] dark:bg-foreground/8" />
</div>
<div className="rounded-lg bg-amber-100 p-2 shadow-sm">
<div className="mb-2 h-2.5 w-10 rounded bg-amber-400" />
<div className="h-10 rounded-md bg-amber-300/80" />
</div>
<div className="col-span-2 rounded-lg bg-primary/10 p-2 shadow-sm">
<div className="mb-2 h-2.5 w-12 rounded bg-primary/70" />
<div className="rounded-lg bg-foreground/[0.08] dark:bg-foreground/5 p-2">
<div className="mb-2 h-2.5 w-10 rounded bg-foreground/[0.15] dark:bg-foreground/10" />
<div className="h-10 rounded-md bg-foreground/[0.12] dark:bg-foreground/8" />
</div>
<div className="col-span-2 rounded-lg bg-foreground/[0.08] dark:bg-foreground/5 p-2">
<div className="mb-2 h-2.5 w-12 rounded bg-foreground/[0.15] dark:bg-foreground/10" />
<div className="flex h-14 items-end gap-1">
{[...Array(10)].map((_, i) => (
<div
key={i}
className="flex-1 rounded-t bg-primary/70/80"
className="flex-1 rounded-t bg-foreground/[0.15] dark:bg-foreground/10"
style={{ height: `${25 + Math.random() * 75}%` }}
/>
))}
</div>
</div>
{/* 컴포넌트 수 */}
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
{totalComponents}
</div>
</div>
);
}
// 액션 화면 일러스트 (버튼 중심)
if (screenType === "action") {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-lg border border-border bg-muted/30 p-3">
<div className="rounded-full bg-muted p-4 text-muted-foreground">
<MousePointer2 className="h-10 w-10" />
</div>
<div className="flex gap-3">
<div className="h-7 w-16 rounded-md bg-primary shadow-sm" />
<div className="h-7 w-16 rounded-md bg-muted-foreground/40 shadow-sm" />
</div>
<div className="text-xs font-medium text-muted-foreground"> </div>
{/* 컴포넌트 수 */}
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
{totalComponents}
</div>
</div>
);
}
// 기본 (알 수 없는 타입)
// 액션 화면 일러스트 (모노크롬)
if (screenType === "action") {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-lg border border-border/30 dark:border-border/5 bg-muted/30 dark:bg-muted/10 p-3">
<div className="rounded-full bg-foreground/[0.08] dark:bg-foreground/5 p-4 text-muted-foreground">
<MousePointer2 className="h-10 w-10" />
</div>
<div className="flex gap-3">
<div className="h-7 w-16 rounded-md bg-foreground/[0.18] dark:bg-foreground/12" />
<div className="h-7 w-16 rounded-md bg-foreground/[0.12] dark:bg-foreground/8" />
</div>
<div className="text-xs font-medium text-muted-foreground"> </div>
</div>
);
}
// 기본 (알 수 없는 타입, 모노크롬)
return (
<div className="flex h-full flex-col items-center justify-center gap-3 rounded-lg border border-slate-200 bg-muted/30 text-slate-400">
<div className="rounded-full bg-slate-100 p-4">
<div className="flex h-full flex-col items-center justify-center gap-3 rounded-lg border border-border/30 dark:border-border/5 bg-muted/30 dark:bg-muted/10 text-muted-foreground">
<div className="rounded-full bg-foreground/[0.08] dark:bg-foreground/5 p-4">
{getScreenTypeIcon(screenType)}
</div>
<span className="text-sm font-medium">{totalComponents} </span>
@@ -574,21 +506,21 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
return (
<div
className={`group relative flex w-[260px] flex-col overflow-visible rounded-xl border shadow-md ${
// 1. 필터 테이블 (마스터-디테일의 디테일 테이블): 항상 보라색 테두리
className={`group relative flex w-[260px] flex-col overflow-visible rounded-[10px] border bg-card dark:bg-card/80 backdrop-blur-sm shadow-[0_4px_24px_-8px_rgba(0,0,0,0.08)] dark:shadow-[0_4px_24px_-8px_rgba(0,0,0,0.5)] ${
// 1. 필터 테이블 (마스터-디테일의 디테일 테이블)
isFilterTable
? "border-2 border-violet-500 ring-2 ring-violet-500/20 shadow-lg bg-violet-50/50"
// 2. 필터 관련 테이블 (마스터 또는 디테일) 포커스 시: 진한 보라색
? "border-primary/30 shadow-[0_0_0_1px_hsl(var(--primary)/0.3)]"
// 2. 필터 관련 테이블 포커스 시
: (hasFilterRelation || isFilterSource)
? "border-2 border-violet-500 ring-4 ring-violet-500/30 shadow-xl bg-violet-50"
// 3. 순수 포커스 (필터 관계 없음): 초록색
? "border-primary/30 shadow-[0_0_0_1px_hsl(var(--primary)/0.3),0_0_24px_-8px_hsl(var(--primary)/0.1)]"
// 3. 순수 포커스
: isFocused
? "border-2 border-emerald-500 ring-4 ring-emerald-500/30 shadow-xl bg-card"
? "border-primary/30 shadow-[0_0_0_1px_hsl(var(--primary)/0.3),0_0_24px_-8px_hsl(var(--primary)/0.1)] bg-card"
// 4. 흐리게 처리
: isFaded
? "border-border opacity-60 bg-card"
? "opacity-60 bg-card border-border/40 dark:border-border/10"
// 5. 기본
: "border-border hover:shadow-lg hover:ring-2 hover:ring-emerald-500/20 bg-card"
: "border-border/40 dark:border-border/10 hover:border-border/50 dark:hover:border-border/20"
}`}
style={{
filter: isFaded ? "grayscale(80%)" : "none",
@@ -602,7 +534,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
className="absolute -left-1.5 top-1 bottom-1 w-0.5 z-20 rounded-full transition-all duration-500 ease-out"
title={hasSaveTarget ? "저장 대상 테이블" : undefined}
style={{
background: 'linear-gradient(to bottom, transparent 0%, #f472b6 15%, #f472b6 85%, transparent 100%)',
background: `linear-gradient(to bottom, transparent 0%, hsl(var(--destructive)) 15%, hsl(var(--destructive)) 85%, transparent 100%)`,
opacity: hasSaveTarget ? 1 : 0,
transform: hasSaveTarget ? 'scaleY(1)' : 'scaleY(0)',
transformOrigin: 'top',
@@ -616,7 +548,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
type="target"
position={Position.Top}
id="top"
className="!h-2 !w-2 !border-2 !border-background !bg-emerald-500 opacity-0 transition-opacity group-hover:opacity-100"
className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100"
/>
{/* top source: 메인테이블 → 메인테이블 연결용 (위쪽으로 나가는 선) */}
<Handle
@@ -624,25 +556,25 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
position={Position.Top}
id="top_source"
style={{ top: -4 }}
className="!h-2 !w-2 !border-2 !border-background !bg-amber-500 opacity-0 transition-opacity group-hover:opacity-100"
className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100"
/>
<Handle
type="target"
position={Position.Left}
id="left"
className="!h-2 !w-2 !border-2 !border-background !bg-emerald-500 opacity-0 transition-opacity group-hover:opacity-100"
className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100"
/>
<Handle
type="source"
position={Position.Right}
id="right"
className="!h-2 !w-2 !border-2 !border-background !bg-emerald-500 opacity-0 transition-opacity group-hover:opacity-100"
className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100"
/>
<Handle
type="source"
position={Position.Bottom}
id="bottom"
className="!h-2 !w-2 !border-2 !border-background !bg-amber-500 opacity-0 transition-opacity group-hover:opacity-100"
className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100"
/>
{/* bottom target: 메인테이블 ← 메인테이블 연결용 (아래에서 들어오는 선) */}
<Handle
@@ -650,18 +582,18 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
position={Position.Bottom}
id="bottom_target"
style={{ bottom: -4 }}
className="!h-2 !w-2 !border-2 !border-background !bg-amber-500 opacity-0 transition-opacity group-hover:opacity-100"
className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100"
/>
{/* 헤더 (필터 관계: 보라색, 필터 소스: 보라색, 메인: 초록색, 기본: 슬레이트) */}
<div className={`flex items-center gap-2 px-3 py-1.5 text-white rounded-t-xl transition-colors duration-700 ease-in-out ${
isFaded ? "bg-muted-foreground" : (hasFilterRelation || isFilterSource) ? "bg-violet-600" : isMain ? "bg-emerald-600" : "bg-slate-500"
}`}>
<Database className="h-3.5 w-3.5 shrink-0" />
{/* 헤더: 그라디언트 제거, bg-muted/30 + 아이콘 박스 */}
<div className="flex items-center gap-2.5 px-3.5 py-2.5 border-b border-border/40 dark:border-border/10 bg-muted/50 dark:bg-muted/30 rounded-t-[10px] transition-colors duration-700 ease-in-out">
<div className="flex h-7 w-7 items-center justify-center rounded-[7px] bg-cyan-500/10 shrink-0">
<Database className="h-3.5 w-3.5 text-cyan-400" />
</div>
<div className="flex-1 min-w-0">
<div className="truncate text-[11px] font-semibold">{label}</div>
<div className="truncate text-[11px] font-semibold text-foreground font-mono">{label}</div>
{/* 필터 관계에 따른 문구 변경 */}
<div className="truncate text-[9px] opacity-80">
<div className="truncate text-[9px] font-mono text-muted-foreground/70 dark:text-muted-foreground/40 tracking-[-0.3px]">
{isFilterSource
? "마스터 테이블 (필터 소스)"
: hasFilterRelation
@@ -670,8 +602,8 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
</div>
</div>
{hasActiveColumns && (
<span className="rounded-full bg-white/20 px-1.5 py-0.5 text-[8px] shrink-0">
{displayColumns.length}
<span className="text-[9px] font-mono text-muted-foreground/70 dark:text-muted-foreground/40 px-1.5 py-0.5 rounded bg-foreground/[0.08] dark:bg-foreground/5 border border-border/40 dark:border-border/10 tracking-[-0.3px] shrink-0">
{displayColumns.length} ref
</span>
)}
</div>
@@ -679,7 +611,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
{/* 컬럼 목록 - 컴팩트하게 (부드러운 높이 전환 + 스크롤) */}
{/* 뱃지도 이 영역 안에 포함되어 높이 계산에 반영됨 */}
<div
className="p-1.5 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-transparent"
className="p-1.5 overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent"
style={{
height: `${debouncedHeight}px`,
maxHeight: `${MAX_HEIGHT}px`,
@@ -699,7 +631,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
{/* 필터 뱃지 */}
{filterRefs.length > 0 && (
<span
className="flex items-center gap-1 rounded-full bg-violet-600 px-2 py-px text-white font-semibold shadow-sm"
className="flex items-center gap-1 rounded-full bg-primary px-2 py-px text-primary-foreground font-semibold shadow-sm"
title={`마스터-디테일 필터링\n${filterRefs.map(r => `${r.fromTable}.${r.fromColumn || 'id'}${r.toColumn}`).join('\n')}`}
>
<Link2 className="h-3 w-3" />
@@ -707,14 +639,14 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
</span>
)}
{filterRefs.length > 0 && (
<span className="text-violet-700 font-medium truncate">
<span className="text-primary font-medium truncate">
{filterRefs.map(r => `${r.fromTableLabel || r.fromTable}.${r.fromColumnLabel || r.fromColumn || 'id'}`).join(', ')}
</span>
)}
{/* 참조 뱃지 */}
{lookupRefs.length > 0 && (
<span
className="flex items-center gap-1 rounded-full bg-amber-500 px-2 py-px text-white font-semibold shadow-sm"
className="flex items-center gap-1 rounded-full bg-warning px-2 py-px text-warning-foreground font-semibold shadow-sm"
title={`코드 참조 (lookup)\n${lookupRefs.map(r => `${r.fromTable}${r.toColumn}`).join('\n')}`}
>
{lookupRefs.length}
@@ -745,33 +677,37 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
key={col.name}
className={`flex items-center gap-1 rounded px-1.5 py-0.5 transition-all duration-300 ${
isJoinColumn
? "bg-amber-100 border border-orange-300 shadow-sm"
? "bg-warning/10 border border-warning/20 shadow-sm"
: isFilterColumn || isFilterSourceColumn
? "bg-violet-100 border border-violet-300 shadow-sm" // 필터 컬럼/필터 소스: 보라색
? "bg-primary/10 border border-primary/20 shadow-sm" // 필터 컬럼/필터 소스
: isHighlighted
? "bg-primary/10 border border-primary/40 shadow-sm"
: hasActiveColumns
? "bg-slate-100"
: "bg-slate-50 hover:bg-slate-100"
? "bg-muted"
: "bg-muted/50 hover:bg-muted/80 transition-colors"
}`}
style={{
animation: hasActiveColumns ? `fadeIn 0.5s ease-out ${idx * 80}ms forwards` : undefined,
opacity: hasActiveColumns ? 0 : 1,
}}
>
{/* PK/FK/조인/필터 아이콘 */}
{isJoinColumn && <Link2 className="h-2.5 w-2.5 text-amber-500" />}
{(isFilterColumn || isFilterSourceColumn) && !isJoinColumn && <Link2 className="h-2.5 w-2.5 text-violet-500" />}
{!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isPrimaryKey && <Key className="h-2.5 w-2.5 text-amber-500" />}
{!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isForeignKey && !col.isPrimaryKey && <Link2 className="h-2.5 w-2.5 text-primary" />}
{!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && !col.isPrimaryKey && !col.isForeignKey && <div className="w-2.5" />}
{/* 3px 세로 마커 (PK/FK/조인/필터) */}
<div
className={`w-[3px] h-[14px] rounded-sm flex-shrink-0 ${
isJoinColumn ? "bg-amber-400"
: (isFilterColumn || isFilterSourceColumn) ? "bg-primary opacity-80"
: col.isPrimaryKey ? "bg-amber-400"
: col.isForeignKey ? "bg-primary opacity-80"
: "bg-muted-foreground/20"
}`}
/>
{/* 컬럼명 */}
<span className={`flex-1 truncate font-mono text-[9px] font-medium ${
isJoinColumn ? "text-orange-700"
: (isFilterColumn || isFilterSourceColumn) ? "text-violet-700"
: isHighlighted ? "text-primary"
: "text-slate-700"
isJoinColumn ? "text-amber-400"
: (isFilterColumn || isFilterSourceColumn) ? "text-primary"
: isHighlighted ? "text-primary"
: "text-foreground"
}`}>
{col.name}
</span>
@@ -781,63 +717,74 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
<>
{/* 조인 참조 테이블 표시 (joinColumnRefs에서) */}
{joinRefMap.has(colOriginal) && (
<span className="rounded bg-amber-100 px-1 text-[7px] text-amber-600">
<span className="rounded bg-warning/20 px-1 text-[7px] text-warning">
{joinRefMap.get(colOriginal)?.refTableLabel}
</span>
)}
{/* 필드 매핑 참조 표시 (fieldMappingMap에서, joinRefMap에 없는 경우) */}
{!joinRefMap.has(colOriginal) && fieldMappingMap.has(colOriginal) && (
<span className="rounded bg-amber-100 px-1 text-[7px] text-amber-600">
<span className="rounded bg-warning/20 px-1 text-[7px] text-warning">
{fieldMappingMap.get(colOriginal)?.sourceDisplayName}
</span>
)}
<span className="rounded bg-orange-200 px-1 text-[7px] text-orange-700"></span>
<span className="rounded bg-warning/20 px-1 text-[7px] text-warning"></span>
</>
)}
{isFilterColumn && !isJoinColumn && (
<span className="rounded bg-violet-200 px-1 text-[7px] text-violet-700"></span>
<span className="rounded bg-primary/20 px-1 text-[7px] text-primary"></span>
)}
{/* 메인 테이블에서 필터 소스로 사용되는 컬럼: "필터" + "사용" 둘 다 표시 */}
{isFilterSourceColumn && !isJoinColumn && !isFilterColumn && (
<>
<span className="rounded bg-violet-200 px-1 text-[7px] text-violet-700"></span>
<span className="rounded bg-primary/20 px-1 text-[7px] text-primary"></span>
{isHighlighted && (
<span className="rounded bg-blue-200 px-1 text-[7px] text-primary"></span>
<span className="rounded bg-primary/20 px-1 text-[7px] text-primary"></span>
)}
</>
)}
{isHighlighted && !isJoinColumn && !isFilterColumn && !isFilterSourceColumn && (
<span className="rounded bg-blue-200 px-1 text-[7px] text-primary"></span>
<span className="rounded bg-primary/20 px-1 text-[7px] text-primary"></span>
)}
{/* 타입 */}
<span className="text-[8px] text-slate-400">{col.type}</span>
<span className="text-[8px] text-muted-foreground/60 dark:text-muted-foreground/30 font-mono tracking-[-0.3px]">{col.type}</span>
</div>
);
})}
{/* 더 많은 컬럼이 있을 경우 표시 */}
{remainingCount > 0 && (
<div className="text-center text-[8px] text-slate-400 py-0.5">
<div className="text-center text-[8px] text-muted-foreground py-0.5">
+ {remainingCount}
</div>
)}
</div>
) : (
<div className="flex flex-col items-center justify-center py-2 text-muted-foreground">
<Database className="h-4 w-4 text-slate-300" />
<span className="mt-0.5 text-[8px] text-slate-400"> </span>
<Database className="h-4 w-4 text-muted-foreground" />
<span className="mt-0.5 text-[8px] text-muted-foreground"> </span>
</div>
)}
</div>
{/* 푸터 (컴팩트) */}
<div className="flex items-center justify-between border-t border-border bg-muted/30 px-2 py-1">
<span className="text-[9px] text-muted-foreground">PostgreSQL</span>
{columns && (
<span className="text-[9px] text-muted-foreground">
{hasActiveColumns ? `${displayColumns.length}/${totalCount}` : totalCount}
</span>
)}
{/* 푸터: cols + PK/FK 카운트 */}
<div className="flex items-center justify-between border-t border-border/40 dark:border-border/10 px-3.5 py-1.5 bg-background dark:bg-background/50">
<span className="text-[9px] text-muted-foreground/70 dark:text-muted-foreground/40 font-mono tracking-[-0.3px]">
{hasActiveColumns ? `${displayColumns.length}/${totalCount}` : totalCount} cols
</span>
<div className="flex gap-2.5 text-[9px] font-mono tracking-[-0.3px]">
{columns?.some(c => c.isPrimaryKey) && (
<span className="flex items-center gap-1">
<span className="w-1 h-1 rounded-full bg-amber-400" />
<span className="text-muted-foreground/70 dark:text-muted-foreground/40">PK {columns.filter(c => c.isPrimaryKey).length}</span>
</span>
)}
{columns?.some(c => c.isForeignKey) && (
<span className="flex items-center gap-1">
<span className="w-1 h-1 rounded-full bg-primary" />
<span className="text-muted-foreground/70 dark:text-muted-foreground/40">FK {columns.filter(c => c.isForeignKey).length}</span>
</span>
)}
</div>
</div>
{/* CSS 애니메이션 정의 */}
@@ -861,10 +808,10 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
export const LegacyScreenNode = ScreenNode;
export const AggregateNode: React.FC<{ data: any }> = ({ data }) => {
return (
<div className="rounded-lg border-2 border-purple-300 bg-card p-3 shadow-lg">
<Handle type="target" position={Position.Left} id="left" className="!h-3 !w-3 !bg-purple-500" />
<Handle type="source" position={Position.Right} id="right" className="!h-3 !w-3 !bg-purple-500" />
<div className="flex items-center gap-2 text-purple-600">
<div className="rounded-lg border-2 border-primary/40 bg-card p-3 shadow-lg">
<Handle type="target" position={Position.Left} id="left" className="!h-3 !w-3 !bg-primary" />
<Handle type="source" position={Position.Right} id="right" className="!h-3 !w-3 !bg-primary" />
<div className="flex items-center gap-2 text-primary">
<Table2 className="h-4 w-4" />
<span className="text-sm font-semibold">{data.label || "Aggregate"}</span>
</div>
+248 -76
View File
@@ -4,6 +4,7 @@ import React, { useState, useEffect, useCallback } from "react";
import {
ReactFlow,
Controls,
MiniMap,
Background,
BackgroundVariant,
Node,
@@ -34,22 +35,31 @@ import {
import { getTableColumns, ColumnTypeInfo } from "@/lib/api/tableManagement";
import { ScreenSettingModal } from "./ScreenSettingModal";
import { TableSettingModal } from "./TableSettingModal";
import { AnimatedFlowEdge } from "./AnimatedFlowEdge";
import { Monitor, Database, FolderOpen } from "lucide-react";
// 관계 유형별 색상 정의
// 관계 유형별 색상 정의 (CSS 변수 기반 - 다크모드 자동 대응)
const RELATION_COLORS: Record<VisualRelationType, { stroke: string; strokeLight: string; label: string }> = {
filter: { stroke: '#8b5cf6', strokeLight: '#c4b5fd', label: '마스터-디테일' }, // 보라색
hierarchy: { stroke: '#06b6d4', strokeLight: '#a5f3fc', label: '계층 구조' }, // 시안색
lookup: { stroke: '#f59e0b', strokeLight: '#fcd34d', label: '코드 참조' }, // 주황색 (기존)
mapping: { stroke: '#10b981', strokeLight: '#6ee7b7', label: '데이터 매핑' }, // 녹색
join: { stroke: '#f97316', strokeLight: '#fdba74', label: '엔티티 조인' }, // orange-500 (기존 주황색)
filter: { stroke: 'hsl(var(--primary))', strokeLight: 'hsl(var(--primary) / 0.4)', label: '마스터-디테일' },
hierarchy: { stroke: 'hsl(var(--info))', strokeLight: 'hsl(var(--info) / 0.4)', label: '계층 구조' },
lookup: { stroke: 'hsl(var(--warning))', strokeLight: 'hsl(var(--warning) / 0.4)', label: '코드 참조' },
mapping: { stroke: 'hsl(var(--success))', strokeLight: 'hsl(var(--success) / 0.4)', label: '데이터 매핑' },
join: { stroke: 'hsl(var(--warning))', strokeLight: 'hsl(var(--warning) / 0.4)', label: '엔티티 조인' },
};
// 엣지 필터 카테고리 (UI 토글용)
type EdgeCategory = 'main' | 'filter' | 'join' | 'lookup' | 'flow';
// 노드 타입 등록
const nodeTypes = {
screenNode: ScreenNode,
tableNode: TableNode,
};
const edgeTypes = {
animatedFlow: AnimatedFlowEdge,
};
// 레이아웃 상수
const SCREEN_Y = 50; // 화면 노드 Y 위치 (상단)
const TABLE_Y = 420; // 메인 테이블 노드 Y 위치 (중단)
@@ -89,6 +99,15 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
// 그룹 내 포커스된 화면 ID (그룹 모드에서만 사용)
const [focusedScreenId, setFocusedScreenId] = useState<number | null>(null);
// 엣지 필터 상태 (유형별 표시/숨김)
const [edgeFilterState, setEdgeFilterState] = useState<Record<EdgeCategory, boolean>>({
main: true,
filter: true,
join: true,
lookup: false,
flow: true,
});
// 노드 설정 모달 상태
const [isSettingModalOpen, setIsSettingModalOpen] = useState(false);
const [settingModalNode, setSettingModalNode] = useState<{
@@ -414,7 +433,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
isFaded = focusedScreenId !== null && !isFocused;
} else {
// 개별 화면 모드: 메인 화면(선택된 화면)만 포커스, 연결 화면은 흐리게
isFocused = isMain;
isFocused = !!isMain;
isFaded = !isMain && screenList.length > 1;
}
@@ -426,7 +445,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
label: scr.screenName,
subLabel: selectedGroup ? `${roleLabel} (#${scr.displayOrder || idx + 1})` : (isMain ? "메인 화면" : "연결 화면"),
type: "screen",
isMain: selectedGroup ? idx === 0 : isMain,
isMain: selectedGroup ? idx === 0 : !!isMain,
tableName: scr.tableName,
layoutSummary: summary,
// 화면 포커스 관련 속성 (그룹 모드 & 개별 모드 공통)
@@ -687,14 +706,15 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
target: `screen-${nextScreen.screenId}`,
sourceHandle: "right",
targetHandle: "left",
type: "smoothstep",
type: "animatedFlow",
label: `${i + 1}`,
labelStyle: { fontSize: 11, fill: "#0ea5e9", fontWeight: 600 },
labelStyle: { fontSize: 11, fill: "hsl(var(--info))", fontWeight: 600 },
labelBgStyle: { fill: "hsl(var(--card))", stroke: "hsl(var(--border))", strokeWidth: 1 },
labelBgPadding: [4, 2] as [number, number],
markerEnd: { type: MarkerType.ArrowClosed, color: "#0ea5e9" },
markerEnd: { type: MarkerType.ArrowClosed, color: "hsl(var(--info))" },
animated: true,
style: { stroke: "#0ea5e9", strokeWidth: 2 },
style: { stroke: "hsl(var(--info))", strokeWidth: 2 },
data: { edgeCategory: 'flow' as EdgeCategory },
});
}
}
@@ -709,12 +729,13 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
target: `table-${scr.tableName}`,
sourceHandle: "bottom",
targetHandle: "top",
type: "smoothstep",
type: "animatedFlow",
animated: true, // 모든 메인 테이블 연결은 애니메이션
style: {
stroke: "#3b82f6",
stroke: "hsl(var(--primary))",
strokeWidth: 2,
},
data: { edgeCategory: 'main' as EdgeCategory },
});
}
});
@@ -748,15 +769,16 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
target: targetNodeId,
sourceHandle: "bottom",
targetHandle: "top",
type: "smoothstep",
type: "animatedFlow",
animated: true,
style: {
stroke: "#3b82f6",
stroke: "hsl(var(--primary))",
strokeWidth: 2,
strokeDasharray: "5,5", // 점선으로 필터 관계 표시
},
data: {
sourceScreenId,
edgeCategory: 'filter' as EdgeCategory,
},
});
@@ -793,7 +815,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
target: refTargetNodeId,
sourceHandle: "bottom",
targetHandle: "bottom_target",
type: "smoothstep",
type: "animatedFlow",
animated: false,
style: {
stroke: RELATION_COLORS.join.strokeLight, // 초기값 (연한색)
@@ -809,6 +831,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
sourceScreenId,
isFilterJoin: true,
visualRelationType: 'join',
edgeCategory: 'join' as EdgeCategory,
},
});
});
@@ -901,7 +924,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
target: `table-${referencedTable}`, // 참조당하는 테이블
sourceHandle: "bottom", // 하단에서 나감 (서브테이블 구간으로)
targetHandle: "bottom_target", // 하단으로 들어감
type: "smoothstep",
type: "animatedFlow",
animated: false,
style: {
stroke: relationColor.strokeLight, // 관계 유형별 연한 색상
@@ -919,6 +942,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
referrerTable,
referencedTable,
visualRelationType, // 관계 유형 저장
edgeCategory: (visualRelationType === 'lookup' ? 'lookup' : 'join') as EdgeCategory,
},
});
}
@@ -944,7 +968,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
target: `subtable-${subTable.tableName}`,
sourceHandle: "bottom",
targetHandle: "top",
type: "smoothstep",
type: "animatedFlow",
markerEnd: {
type: MarkerType.ArrowClosed,
color: relationColor.strokeLight
@@ -959,6 +983,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
data: {
sourceScreenId,
visualRelationType,
edgeCategory: (visualRelationType === 'lookup' ? 'lookup' : 'join') as EdgeCategory,
},
});
});
@@ -973,7 +998,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
target: `table-${join.join_table}`,
sourceHandle: "bottom",
targetHandle: "bottom_target",
type: "smoothstep",
type: "animatedFlow",
markerEnd: {
type: MarkerType.ArrowClosed,
color: RELATION_COLORS.join.strokeLight
@@ -985,31 +1010,33 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
strokeDasharray: "8,4",
opacity: 0.5,
},
data: { visualRelationType: 'join' },
data: { visualRelationType: 'join', edgeCategory: 'join' as EdgeCategory },
});
}
});
// 테이블 관계 엣지 (추가 관계)
// 테이블 관계 엣지 (추가 관계) - 참조용 화면(개별 모드: screen, 그룹 모드: screenList[0])
const refScreen = screen ?? screenList[0];
relations.forEach((rel: any, idx: number) => {
if (rel.table_name && rel.table_name !== screen.tableName) {
if (rel.table_name && rel.table_name !== refScreen.tableName) {
// 화면 → 연결 테이블
const edgeExists = newEdges.some(
(e) => e.source === `screen-${screen.screenId}` && e.target === `table-${rel.table_name}`
(e) => e.source === `screen-${refScreen.screenId}` && e.target === `table-${rel.table_name}`
);
if (!edgeExists) {
newEdges.push({
id: `edge-rel-${idx}`,
source: `screen-${screen.screenId}`,
source: `screen-${refScreen.screenId}`,
target: `table-${rel.table_name}`,
sourceHandle: "bottom",
targetHandle: "top",
type: "smoothstep",
type: "animatedFlow",
label: rel.relation_type === "join" ? "조인" : rel.crud_operations || "",
labelStyle: { fontSize: 9, fill: "#10b981" },
labelStyle: { fontSize: 9, fill: "hsl(var(--success))" },
labelBgStyle: { fill: "hsl(var(--card))", stroke: "hsl(var(--border))", strokeWidth: 1 },
labelBgPadding: [3, 2] as [number, number],
style: { stroke: "#10b981", strokeWidth: 1.5 },
style: { stroke: "hsl(var(--success))", strokeWidth: 1.5 },
data: { edgeCategory: (rel.relation_type === 'lookup' ? 'lookup' : 'join') as EdgeCategory },
});
}
}
@@ -1017,23 +1044,24 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
// 데이터 흐름 엣지 (화면 간)
flows
.filter((flow: any) => flow.source_screen_id === screen.screenId)
.filter((flow: any) => flow.source_screen_id === refScreen.screenId)
.forEach((flow: any, idx: number) => {
if (flow.target_screen_id) {
newEdges.push({
id: `edge-flow-${idx}`,
source: `screen-${screen.screenId}`,
source: `screen-${refScreen.screenId}`,
target: `screen-${flow.target_screen_id}`,
sourceHandle: "right",
targetHandle: "left",
type: "smoothstep",
type: "animatedFlow",
animated: true,
label: flow.flow_label || flow.flow_type || "이동",
labelStyle: { fontSize: 10, fill: "#8b5cf6", fontWeight: 500 },
labelStyle: { fontSize: 10, fill: "hsl(var(--primary))", fontWeight: 500 },
labelBgStyle: { fill: "hsl(var(--card))", stroke: "hsl(var(--border))", strokeWidth: 1 },
labelBgPadding: [4, 2] as [number, number],
markerEnd: { type: MarkerType.ArrowClosed, color: "#8b5cf6" },
style: { stroke: "#8b5cf6", strokeWidth: 2 },
markerEnd: { type: MarkerType.ArrowClosed, color: "hsl(var(--primary))" },
style: { stroke: "hsl(var(--primary))", strokeWidth: 2 },
data: { edgeCategory: 'flow' as EdgeCategory },
});
}
});
@@ -1134,7 +1162,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
// 화면 노드 우클릭
if (node.id.startsWith("screen-")) {
const screenId = parseInt(node.id.replace("screen-", ""));
const nodeData = node.data as ScreenNodeData;
const nodeData = node.data as unknown as ScreenNodeData;
const mainTable = screenTableMap[screenId];
// 해당 화면의 서브 테이블 (필터 테이블) 정보
@@ -1248,7 +1276,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
// 메인 테이블 노드 더블클릭
if (node.id.startsWith("table-") && !node.id.startsWith("table-sub-")) {
const tableName = node.id.replace("table-", "");
const nodeData = node.data as TableNodeData;
const nodeData = node.data as unknown as TableNodeData;
// 이 테이블을 사용하는 화면 찾기
const screenId = Object.entries(screenTableMap).find(
@@ -1293,7 +1321,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
// 서브 테이블 노드 더블클릭
if (node.id.startsWith("subtable-")) {
const tableName = node.id.replace("subtable-", "");
const nodeData = node.data as TableNodeData;
const nodeData = node.data as unknown as TableNodeData;
// 이 서브 테이블을 사용하는 화면 찾기
const screenId = Object.entries(screenSubTableMap).find(
@@ -1460,6 +1488,32 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
});
}
// lookup 필터 OFF일 때: lookup 연결만 있는 테이블 노드를 dim 처리
const lookupOnlyNodes = new Set<string>();
if (!edgeFilterState.lookup) {
const nodeEdgeCategories = new Map<string, Set<EdgeCategory>>();
edges.forEach((edge) => {
const category = (edge.data as any)?.edgeCategory as EdgeCategory | undefined;
if (!category) return;
[edge.source, edge.target].forEach((nodeId) => {
if (!nodeEdgeCategories.has(nodeId)) {
nodeEdgeCategories.set(nodeId, new Set());
}
nodeEdgeCategories.get(nodeId)!.add(category);
});
});
nodeEdgeCategories.forEach((categories, nodeId) => {
if (nodeId.startsWith("table-") || nodeId.startsWith("subtable-")) {
const hasVisibleCategory = Array.from(categories).some(
(cat) => cat !== "lookup" && edgeFilterState[cat]
);
if (!hasVisibleCategory) {
lookupOnlyNodes.add(nodeId);
}
}
});
}
return nodes.map((node) => {
// 화면 노드 스타일링 (포커스가 있을 때만)
if (node.id.startsWith("screen-")) {
@@ -1755,7 +1809,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
...node.data,
isFocused: isFocusedTable,
isRelated: isRelatedTable,
isFaded: focusedScreenId !== null && !isActiveTable,
isFaded: (focusedScreenId !== null && !isActiveTable) || lookupOnlyNodes.has(node.id),
highlightedColumns: isActiveTable ? highlightedColumns : [],
joinColumns: isActiveTable ? joinColumns : [],
joinColumnRefs: focusedJoinColumnRefs.length > 0 ? focusedJoinColumnRefs : undefined, // 조인 컬럼 참조 정보
@@ -1798,12 +1852,6 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
}
});
}
// 디버깅 로그
console.log(`서브테이블 ${subTableName} (${subTableInfo?.relationType}):`, {
fieldMappings: subTableInfo?.fieldMappings,
extractedJoinColumns: subTableJoinColumns
});
}
// 서브 테이블의 highlightedColumns도 추가 (화면에서 서브테이블 컬럼을 직접 사용하는 경우)
@@ -1872,7 +1920,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
data: {
...node.data,
isFocused: isActiveSubTable,
isFaded: !isActiveSubTable,
isFaded: !isActiveSubTable || lookupOnlyNodes.has(node.id),
highlightedColumns: isActiveSubTable ? subTableHighlightedColumns : [],
joinColumns: isActiveSubTable ? subTableJoinColumns : [],
fieldMappings: isActiveSubTable ? displayFieldMappings : [],
@@ -1883,7 +1931,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
return node;
});
}, [nodes, selectedGroup, focusedScreenId, screenSubTableMap, subTablesDataMap, screenUsedColumnsMap, screenTableMap, tableColumns]);
}, [nodes, selectedGroup, focusedScreenId, screenSubTableMap, subTablesDataMap, screenUsedColumnsMap, screenTableMap, tableColumns, edgeFilterState, edges]);
// 포커스에 따른 엣지 스타일링 (그룹 모드 & 개별 화면 모드)
const styledEdges = React.useMemo(() => {
@@ -1903,9 +1951,9 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
animated: isConnected,
style: {
...edge.style,
stroke: isConnected ? "#8b5cf6" : "#d1d5db",
strokeWidth: isConnected ? 2 : 1,
opacity: isConnected ? 1 : 0.3,
stroke: isConnected ? "hsl(var(--primary))" : "hsl(var(--border))",
strokeWidth: isConnected ? 2.5 : 1,
opacity: isConnected ? 1 : 0.2,
},
};
}
@@ -1920,10 +1968,10 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
animated: isMyConnection,
style: {
...edge.style,
stroke: isMyConnection ? "#3b82f6" : "#d1d5db",
strokeWidth: isMyConnection ? 2 : 1,
stroke: isMyConnection ? "hsl(var(--primary))" : "hsl(var(--border))",
strokeWidth: isMyConnection ? 2.5 : 1,
strokeDasharray: isMyConnection ? undefined : "5,5",
opacity: isMyConnection ? 1 : 0.3,
opacity: isMyConnection ? 1 : 0.2,
},
};
}
@@ -1998,11 +2046,11 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
target: targetNodeId,
sourceHandle: 'bottom', // 고정: 서브테이블 구간 통과
targetHandle: 'bottom_target', // 고정: 서브테이블 구간 통과
type: 'smoothstep',
type: "animatedFlow",
animated: true,
style: {
stroke: relationColor.stroke, // 관계 유형별 색상
strokeWidth: 2,
strokeWidth: 2.5,
strokeDasharray: '8,4',
},
markerEnd: {
@@ -2040,9 +2088,9 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
animated: isConnected,
style: {
...edge.style,
stroke: isConnected ? "#8b5cf6" : "#d1d5db",
strokeWidth: isConnected ? 2 : 1,
opacity: isConnected ? 1 : 0.3,
stroke: isConnected ? "hsl(var(--primary))" : "hsl(var(--border))",
strokeWidth: isConnected ? 2.5 : 1,
opacity: isConnected ? 1 : 0.2,
},
};
}
@@ -2076,8 +2124,8 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
animated: true,
style: {
...edge.style,
stroke: "#3b82f6",
strokeWidth: 2,
stroke: "hsl(var(--primary))",
strokeWidth: 2.5,
strokeDasharray: "5,5",
opacity: 1,
},
@@ -2095,10 +2143,10 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
animated: isMyConnection,
style: {
...edge.style,
stroke: isMyConnection ? "#3b82f6" : "#d1d5db",
strokeWidth: isMyConnection ? 2 : 1,
stroke: isMyConnection ? "hsl(var(--primary))" : "hsl(var(--border))",
strokeWidth: isMyConnection ? 2.5 : 1,
strokeDasharray: isMyConnection ? undefined : "5,5",
opacity: isMyConnection ? 1 : 0.3,
opacity: isMyConnection ? 1 : 0.2,
},
};
}
@@ -2155,7 +2203,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
stroke: isActive ? relationColor.stroke : relationColor.strokeLight,
strokeWidth: isActive ? 2.5 : 1.5,
strokeDasharray: "8,4",
opacity: isActive ? 1 : 0.3,
opacity: isActive ? 1 : 0.2,
},
markerEnd: {
type: MarkerType.ArrowClosed,
@@ -2179,7 +2227,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
stroke: RELATION_COLORS.join.strokeLight,
strokeWidth: 1.5,
strokeDasharray: "6,4",
opacity: 0.3,
opacity: 0.2,
},
markerEnd: {
type: MarkerType.ArrowClosed,
@@ -2206,7 +2254,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
style: {
...edge.style,
stroke: RELATION_COLORS.join.stroke,
strokeWidth: 2,
strokeWidth: 2.5,
strokeDasharray: "6,4",
opacity: 1,
},
@@ -2282,8 +2330,19 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
});
// 기존 엣지 + 조인 관계 엣지 합치기
return [...styledOriginalEdges, ...joinEdges];
}, [edges, nodes, selectedGroup, focusedScreenId, screen, screenSubTableMap, subTablesDataMap, screenTableMap]);
const allEdges = [...styledOriginalEdges, ...joinEdges];
// 엣지 필터 적용 (edgeFilterState에 따라 숨김)
return allEdges.map((edge) => {
const category = (edge.data as any)?.edgeCategory as EdgeCategory | undefined;
if (category && !edgeFilterState[category]) {
return {
...edge,
hidden: true,
};
}
return edge;
});
}, [edges, nodes, selectedGroup, focusedScreenId, screen, screenSubTableMap, subTablesDataMap, screenTableMap, edgeFilterState]);
// 그룹의 화면 목록 (데이터 흐름 설정용) - 모든 조건부 return 전에 선언해야 함
const groupScreensList = React.useMemo(() => {
@@ -2300,10 +2359,38 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
// 조건부 렌더링 (모든 훅 선언 후에 위치해야 함)
if (!screen && !selectedGroup) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
<div className="text-center">
<p className="text-sm"> </p>
<p className="text-sm"> </p>
<div className="flex h-full flex-col items-center justify-center gap-6 p-8">
<div className="relative">
<div className="flex items-center gap-4 opacity-30">
<div className="h-16 w-24 rounded-lg border-2 border-dashed border-primary/40 flex items-center justify-center">
<Monitor className="h-6 w-6 text-primary/60" />
</div>
<div className="h-px w-12 border-t-2 border-dashed border-border" />
<div className="h-12 w-20 rounded-lg border-2 border-dashed border-info/40 flex items-center justify-center">
<Database className="h-5 w-5 text-info/60" />
</div>
</div>
</div>
<div className="text-center max-w-sm">
<h3 className="text-lg font-semibold mb-2"> </h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<br/>
.
</p>
</div>
<div className="flex gap-8 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary/10 text-primary text-[10px] font-bold">1</span>
<span> </span>
</div>
<div className="flex items-center gap-2">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary/10 text-primary text-[10px] font-bold">2</span>
<span> </span>
</div>
<div className="flex items-center gap-2">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary/10 text-primary text-[10px] font-bold">3</span>
<span> </span>
</div>
</div>
</div>
);
@@ -2318,10 +2405,60 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
}
return (
<div className="h-full w-full">
<div className="relative h-full w-full">
{/* 선택 정보 바 (캔버스 상단) */}
{(screen || selectedGroup) && (
<div className="absolute top-0 left-0 right-0 z-10 flex items-center gap-3 border-b bg-card dark:bg-card/80 backdrop-blur-sm px-4 py-2">
{selectedGroup && (
<>
<FolderOpen className="h-4 w-4 text-warning" />
<span className="text-sm font-medium">{selectedGroup.name}</span>
</>
)}
{screen && !selectedGroup && (
<>
<Monitor className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">{screen.screenName}</span>
<span className="text-xs text-muted-foreground/80 dark:text-muted-foreground/50 font-mono">{screen.screenCode}</span>
</>
)}
<div className="h-4 w-px bg-border/50 dark:bg-border/30 mx-1" />
<span className="text-[10px] font-medium text-muted-foreground/80 dark:text-muted-foreground/50"></span>
{(
[
{ key: "main" as EdgeCategory, label: "메인", color: "bg-primary", defaultOn: true },
{ key: "filter" as EdgeCategory, label: "마스터-디테일", color: "bg-[hsl(var(--info))]", defaultOn: true },
{ key: "join" as EdgeCategory, label: "엔티티 조인", color: "bg-amber-400", defaultOn: true },
{ key: "lookup" as EdgeCategory, label: "코드 참조", color: "bg-warning", defaultOn: false },
] as const
).map(({ key, label, color, defaultOn }) => {
const isOn = edgeFilterState[key];
const count = edges.filter((e) => (e.data as any)?.edgeCategory === key).length;
return (
<button
key={key}
type="button"
onClick={() => setEdgeFilterState((prev) => ({ ...prev, [key]: !prev[key] }))}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-medium transition-all duration-200 ${
isOn
? "bg-foreground/[0.08] dark:bg-foreground/5 border border-border/40 dark:border-border/20 text-foreground/80"
: `border text-muted-foreground/70 dark:text-muted-foreground/40 ${!defaultOn ? "border-dashed border-border/40 dark:border-border/20" : "border-border/40 dark:border-border/10"}`
}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${color} transition-opacity ${isOn ? "opacity-100 shadow-sm" : "opacity-50 dark:opacity-30"}`} />
{label}
<span className="text-[9px] text-muted-foreground/70 dark:text-muted-foreground/40 font-mono">{count}</span>
</button>
);
})}
</div>
)}
{/* isViewReady가 false면 숨김 처리하여 깜빡임 방지 */}
<div className={`h-full w-full transition-opacity duration-0 ${isViewReady ? "opacity-100" : "opacity-0"}`}>
<ReactFlow
className="[&_.react-flow__node]:transition-all [&_.react-flow__node]:duration-300"
nodes={styledNodes}
edges={styledEdges}
onNodesChange={onNodesChange}
@@ -2329,12 +2466,42 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
onNodeClick={handleNodeClick}
onNodeContextMenu={handleNodeContextMenu}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
minZoom={0.3}
maxZoom={1.5}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="hsl(var(--border))" />
<Controls position="bottom-right" />
<svg style={{ position: "absolute", width: 0, height: 0 }}>
<defs>
<filter id="edge-glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
</svg>
<Background id="bg-dots" variant={BackgroundVariant.Dots} gap={16} size={0.5} color="hsl(var(--border) / 0.3)" />
<Background id="bg-lines" variant={BackgroundVariant.Lines} gap={120} color="hsl(var(--border) / 0.08)" />
<Controls position="top-right" />
<MiniMap
position="bottom-right"
nodeColor={(node) => {
if (node.type === "screenNode") return "hsl(var(--primary))";
if (node.type === "tableNode") return "hsl(var(--warning))";
return "hsl(var(--muted-foreground))";
}}
nodeStrokeWidth={2}
zoomable
pannable
style={{
background: "hsl(var(--card) / 0.8)",
border: "1px solid hsl(var(--border) / 0.5)",
borderRadius: "8px",
marginBottom: "8px",
}}
/>
</ReactFlow>
</div>
@@ -2353,7 +2520,6 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
fieldMappings={settingModalNode.existingConfig?.fieldMappings}
componentCount={0}
onSaveSuccess={handleRefreshVisualization}
isPop={isPop}
/>
)}
@@ -2367,7 +2533,13 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
screenId={settingModalNode.screenId}
joinColumnRefs={settingModalNode.existingConfig?.joinColumnRefs}
referencedBy={settingModalNode.existingConfig?.referencedBy}
columns={settingModalNode.existingConfig?.columns}
columns={settingModalNode.existingConfig?.columns?.map((col) => ({
column: col.originalName ?? col.name,
label: col.name,
type: col.type,
isPK: col.isPrimaryKey,
isFK: col.isForeignKey,
}))}
filterColumns={settingModalNode.existingConfig?.filterColumns}
onSaveSuccess={handleRefreshVisualization}
/>
+321 -145
View File
@@ -8,7 +8,6 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -240,14 +239,14 @@ export function ScreenSettingModal({
componentCount = 0,
onSaveSuccess,
}: ScreenSettingModalProps) {
const [activeTab, setActiveTab] = useState("overview");
const [loading, setLoading] = useState(false);
const [dataFlows, setDataFlows] = useState<DataFlow[]>([]);
const [layoutItems, setLayoutItems] = useState<LayoutItem[]>([]);
const [iframeKey, setIframeKey] = useState(0); // iframe 새로고침용 키
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }); // 화면 캔버스 크기
const [showDesignerModal, setShowDesignerModal] = useState(false); // 화면 디자이너 모달
const [showTableSettingModal, setShowTableSettingModal] = useState(false); // 테이블 설정 모달
const [buttonControls, setButtonControls] = useState<ButtonControlInfo[]>([]);
const [iframeKey, setIframeKey] = useState(0);
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
const [showDesignerModal, setShowDesignerModal] = useState(false);
const [showTableSettingModal, setShowTableSettingModal] = useState(false);
const [tableSettingTarget, setTableSettingTarget] = useState<{ tableName: string; tableLabel?: string } | null>(null);
// 그룹 내 화면 목록 및 현재 선택된 화면
@@ -338,12 +337,56 @@ export function ScreenSettingModal({
if (layoutResponse.success && layoutResponse.data) {
const screenLayout = layoutResponse.data[currentScreenId];
setLayoutItems(screenLayout?.layoutItems || []);
// 캔버스 크기 저장 (화면 프리뷰에 사용)
setCanvasSize({
width: screenLayout?.canvasWidth || 0,
height: screenLayout?.canvasHeight || 0,
});
}
// 3. 버튼 정보 추출 (읽기 전용 요약용)
try {
const rawLayout = await screenApi.getLayout(currentScreenId);
if (rawLayout?.components) {
const buttons: ButtonControlInfo[] = [];
const extractButtons = (components: any[]) => {
for (const comp of components) {
const config = comp.componentConfig || {};
const isButton =
comp.widgetType === "button" || comp.webType === "button" ||
comp.type === "button" || config.webType === "button" ||
comp.componentType?.includes("button") || comp.componentKind?.includes("button");
if (isButton) {
const webTypeConfig = comp.webTypeConfig || {};
const action = config.action || {};
buttons.push({
id: comp.id || comp.componentId || `btn-${buttons.length}`,
label: config.text || comp.label || comp.title || comp.name || "버튼",
actionType: typeof action === "string" ? action : (action.type || "custom"),
confirmMessage: action.confirmationMessage || action.confirmMessage || config.confirmMessage,
confirmationEnabled: action.confirmationEnabled ?? (!!action.confirmationMessage || !!action.confirmMessage),
backgroundColor: webTypeConfig.backgroundColor || config.backgroundColor || comp.style?.backgroundColor,
textColor: webTypeConfig.textColor || config.textColor || comp.style?.color,
borderRadius: webTypeConfig.borderRadius || config.borderRadius || comp.style?.borderRadius,
linkedFlows: webTypeConfig.dataflowConfig?.flowConfigs?.map((fc: any) => ({
id: fc.flowId, name: fc.flowName, timing: fc.executionTiming || "after",
})) || (webTypeConfig.dataflowConfig?.flowConfig ? [{
id: webTypeConfig.dataflowConfig.flowConfig.flowId,
name: webTypeConfig.dataflowConfig.flowConfig.flowName,
timing: webTypeConfig.dataflowConfig.flowConfig.executionTiming || "after",
}] : []),
});
}
if (comp.children && Array.isArray(comp.children)) extractButtons(comp.children);
if (comp.componentConfig?.children && Array.isArray(comp.componentConfig.children)) extractButtons(comp.componentConfig.children);
if (comp.items && Array.isArray(comp.items)) extractButtons(comp.items);
}
};
extractButtons(rawLayout.components);
setButtonControls(buttons);
}
} catch (btnError) {
console.error("버튼 정보 추출 실패:", btnError);
}
} catch (error) {
console.error("데이터 로드 실패:", error);
} finally {
@@ -360,162 +403,295 @@ export function ScreenSettingModal({
// 새로고침 (데이터 + iframe)
const handleRefresh = useCallback(() => {
loadData();
setIframeKey(prev => prev + 1); // iframe 새로고침
setIframeKey(prev => prev + 1);
}, [loadData]);
// 통계 계산
const stats = useMemo(() => {
const totalJoins = filterTables.reduce((sum, ft) => sum + (ft.joinColumnRefs?.length || 0), 0);
const layoutColumnsSet = new Set<string>();
layoutItems.forEach((item) => {
if (item.usedColumns) item.usedColumns.forEach((col) => layoutColumnsSet.add(col));
if (item.bindField) layoutColumnsSet.add(item.bindField);
});
const inputCount = layoutItems.filter(i => !i.widgetType?.includes("button") && !i.componentKind?.includes("table")).length;
const gridCount = layoutItems.filter(i => i.componentKind?.includes("table") || i.componentKind?.includes("grid")).length;
return {
tableCount: 1 + filterTables.length,
fieldCount: layoutColumnsSet.size || fieldMappings.length,
joinCount: totalJoins,
flowCount: dataFlows.length,
inputCount,
gridCount,
buttonCount: buttonControls.length,
};
}, [filterTables, fieldMappings, dataFlows, layoutItems, buttonControls]);
// 연결된 플로우 총 개수
const linkedFlowCount = useMemo(() => {
return buttonControls.reduce((sum, btn) => sum + (btn.linkedFlows?.length || 0), 0);
}, [buttonControls]);
return (
<>
<Dialog open={isOpen && !showDesignerModal} onOpenChange={onClose}>
<DialogContent className="flex h-[90vh] max-h-[950px] w-[98vw] max-w-[1600px] flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2 text-lg">
<Settings2 className="h-5 w-5 text-primary" />
:
{groupScreens.length > 1 ? (
<Select
value={currentScreenId.toString()}
onValueChange={(value) => handleScreenChange(parseInt(value, 10))}
>
<SelectTrigger className="h-8 w-auto min-w-[200px] max-w-[400px] text-base font-semibold">
<SelectValue placeholder="화면 선택" />
</SelectTrigger>
<SelectContent>
{groupScreens.map((screen) => (
<SelectItem key={screen.screen_id} value={screen.screen_id.toString()}>
{screen.screen_name}
{screen.screen_role && (
<span className="ml-2 text-xs text-muted-foreground">
({screen.screen_role})
</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span>{currentScreenName}</span>
<DialogContent className="flex h-[90vh] max-h-[950px] w-[98vw] max-w-[1600px] flex-col overflow-hidden border-border/40 bg-background/95 backdrop-blur-xl">
{/* V3 Header */}
<DialogHeader className="flex-shrink-0 pb-0">
<DialogTitle className="flex items-center gap-3 text-base">
<span className="h-2 w-2 rounded-full bg-emerald-500 shadow-[0_0_6px_rgba(34,197,94,0.4)]" />
<span className="font-bold tracking-tight">{currentScreenName}</span>
{groupScreens.length > 1 && (
<>
<span className="h-3.5 w-px bg-border" />
<Select
value={currentScreenId.toString()}
onValueChange={(value) => handleScreenChange(parseInt(value, 10))}
>
<SelectTrigger className="h-7 w-auto min-w-[140px] max-w-[280px] border-border/40 bg-muted/30 text-xs font-medium">
<SelectValue placeholder="화면 선택" />
</SelectTrigger>
<SelectContent>
{groupScreens.map((screen) => (
<SelectItem key={screen.screen_id} value={screen.screen_id.toString()}>
{screen.screen_name}
{screen.screen_role && (
<span className="ml-1 text-muted-foreground">({screen.screen_role})</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
<span className="ml-auto font-mono text-[10px] text-muted-foreground/60">#{currentScreenId}</span>
<Button variant="ghost" size="sm" onClick={handleRefresh} className="h-7 w-7 p-0" title="새로고침">
<RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
</Button>
</DialogTitle>
<DialogDescription className="text-sm">
, , .
</DialogDescription>
<DialogDescription className="sr-only"> </DialogDescription>
</DialogHeader>
{/* 2컬럼 레이아웃: 왼쪽 탭(좁게) + 오른쪽 프리뷰(넓게) */}
<div className="flex min-h-0 flex-1 gap-3">
{/* 왼쪽: 탭 컨텐츠 (40%) */}
<div className="flex min-h-0 w-[40%] flex-col rounded-lg border bg-card">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex min-h-0 flex-1 flex-col"
>
<div className="flex flex-shrink-0 items-center justify-between border-b p-2">
<TabsList className="h-8">
<TabsTrigger value="overview" className="gap-1 text-xs px-2">
<Database className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="table-setting" className="gap-1 text-xs px-2" disabled={!mainTable}>
<Settings2 className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="control-management" className="gap-1 text-xs px-2">
<Zap className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="data-flow" className="gap-1 text-xs px-2">
<GitBranch className="h-3 w-3" />
</TabsTrigger>
</TabsList>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
className="h-7 w-7 p-0"
title="새로고침"
>
<RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowDesignerModal(true)}
className="h-7 px-2 text-xs gap-1"
title="화면 디자이너에서 상세 편집"
>
<ExternalLink className="h-3 w-3" />
</Button>
{/* V3 Body: Left Info Panel + Right Preview */}
<div className="flex min-h-0 flex-1 gap-3 pt-2">
{/* 왼쪽: 정보 패널 (탭 없음, 단일 스크롤) */}
<div className="flex min-h-0 w-[380px] flex-shrink-0 flex-col rounded-lg border border-border/40">
<div className="flex-1 overflow-y-auto p-4 [&::-webkit-scrollbar]:w-[2px] [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border/50">
{/* 1. 내러티브 요약 */}
<div className="mb-4 rounded-lg border border-primary/10 bg-gradient-to-br from-primary/[0.04] to-blue-500/[0.02] p-3">
<p className="text-xs leading-relaxed text-muted-foreground">
<span className="font-semibold text-foreground">{currentMainTable || "테이블 미연결"}</span>
{stats.fieldCount > 0 && <> <span className="font-bold text-primary">{stats.fieldCount}</span> .</>}
{filterTables.length > 0 && <><br /> {filterTables.length}{stats.joinCount > 0 && <>, {stats.joinCount}</>} .</>}
</p>
</div>
{/* 2. 속성 테이블 */}
<div className="mb-4 space-y-0.5">
<div className="flex h-[30px] items-center rounded-md px-2 text-[11px] transition-colors hover:bg-muted/30">
<span className="w-[76px] flex-shrink-0 text-[10px] font-medium text-muted-foreground"> </span>
<span className="min-w-0 flex-1 truncate font-mono text-[10px] font-semibold">{currentMainTable || "-"}</span>
{stats.fieldCount > 0 && <span className="flex-shrink-0 font-mono text-[9px] text-muted-foreground">{stats.fieldCount} </span>}
</div>
{filterTables.map((ft, idx) => (
<div key={idx} className="flex h-[30px] items-center rounded-md px-2 text-[11px] transition-colors hover:bg-muted/30">
<span className="w-[76px] flex-shrink-0 text-[10px] font-medium text-muted-foreground"> </span>
<span className="min-w-0 flex-1 truncate font-mono text-[10px] font-semibold text-emerald-500">{ft.tableName}</span>
<span className="flex-shrink-0 font-mono text-[9px] text-muted-foreground">FK</span>
</div>
))}
{filterTables.some(ft => ft.joinColumnRefs && ft.joinColumnRefs.length > 0) && (
<div className="flex h-[30px] items-center rounded-md px-2 text-[11px] transition-colors hover:bg-muted/30">
<span className="w-[76px] flex-shrink-0 text-[10px] font-medium text-muted-foreground"> </span>
<span className="min-w-0 flex-1 truncate text-[10px] font-semibold">
{filterTables.flatMap(ft => ft.joinColumnRefs || []).map((j, i) => (
<span key={i}>{i > 0 && ", "}<span className="font-mono">{j.column}</span> <span className="font-mono text-amber-500">{j.refTable}</span></span>
))}
</span>
<span className="flex-shrink-0 font-mono text-[9px] text-muted-foreground">{stats.joinCount}</span>
</div>
)}
<div className="flex h-[30px] items-center rounded-md px-2 text-[11px] transition-colors hover:bg-muted/30">
<span className="w-[76px] flex-shrink-0 text-[10px] font-medium text-muted-foreground"></span>
<span className="min-w-0 flex-1 truncate text-[10px] font-semibold">
{stats.inputCount > 0 && <> {stats.inputCount}</>}
{stats.gridCount > 0 && <>{stats.inputCount > 0 && " · "} {stats.gridCount}</>}
{stats.buttonCount > 0 && <>{(stats.inputCount > 0 || stats.gridCount > 0) && " · "} {stats.buttonCount}</>}
{stats.inputCount === 0 && stats.gridCount === 0 && stats.buttonCount === 0 && `${componentCount}`}
</span>
</div>
</div>
{/* 탭 1: 화면 개요 */}
<TabsContent value="overview" className="mt-0 min-h-0 flex-1 overflow-auto p-3">
<OverviewTab
screenId={currentScreenId}
screenName={currentScreenName}
mainTable={currentMainTable}
mainTableLabel={currentMainTableLabel}
filterTables={filterTables}
fieldMappings={fieldMappings}
componentCount={componentCount}
dataFlows={dataFlows}
layoutItems={layoutItems}
loading={loading}
onRefresh={handleRefresh}
onOpenTableSetting={handleOpenTableSetting}
/>
</TabsContent>
<div className="my-4 h-px bg-border/40" />
{/* 탭 2: 테이블 설정 */}
<TabsContent value="table-setting" className="mt-0 min-h-0 flex-1 overflow-hidden p-0">
{mainTable && (
<TableSettingModal
isOpen={true}
onClose={() => {}} // 탭에서는 닫기 불필요
tableName={mainTable}
tableLabel={mainTableLabel}
screenId={currentScreenId}
onSaveSuccess={handleRefresh}
isEmbedded={true} // 임베드 모드
/>
{/* 3. 테이블 섹션 */}
<div className="mb-4">
<div className="mb-2 flex items-center gap-1.5 border-b border-border/30 pb-2">
<div className="flex h-4 w-4 items-center justify-center rounded bg-blue-500/10">
<Database className="h-2.5 w-2.5 text-blue-500" />
</div>
<span className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></span>
<Badge variant="secondary" className="ml-auto h-4 rounded-full px-1.5 text-[9px] font-bold bg-blue-500/10 text-blue-500 hover:bg-blue-500/10">{stats.tableCount}</Badge>
</div>
<p className="mb-2 text-[10px] text-muted-foreground/70"> "설정" </p>
<div className="space-y-1">
{currentMainTable && (
<div className="flex items-center gap-2.5 rounded-lg border border-border/40 bg-muted/20 p-2.5 transition-colors hover:border-border/60 hover:bg-muted/30">
<div className="h-6 w-[3px] flex-shrink-0 rounded-full bg-blue-500" />
<div className="min-w-0 flex-1">
<div className="truncate font-mono text-[11px] font-semibold">{currentMainTable}</div>
<div className="text-[9px] text-muted-foreground"> · {stats.fieldCount} </div>
</div>
<Button variant="outline" size="sm" className="h-6 px-2.5 text-[10px] font-semibold border-border/50" onClick={() => handleOpenTableSetting(currentMainTable, currentMainTableLabel)}></Button>
</div>
)}
{filterTables.map((ft, idx) => (
<div key={idx} className="flex items-center gap-2.5 rounded-lg border border-border/40 bg-muted/20 p-2.5 transition-colors hover:border-border/60 hover:bg-muted/30">
<div className="h-6 w-[3px] flex-shrink-0 rounded-full bg-emerald-500" />
<div className="min-w-0 flex-1">
<div className="truncate font-mono text-[11px] font-semibold">{ft.tableName}</div>
<div className="text-[9px] text-muted-foreground">{ft.filterKeyMapping ? ` · FK: ${ft.filterKeyMapping.filterTableColumn}` : ""}</div>
</div>
<Button variant="outline" size="sm" className="h-6 px-2.5 text-[10px] font-semibold border-border/50" onClick={() => handleOpenTableSetting(ft.tableName, ft.tableLabel)}></Button>
</div>
))}
</div>
</div>
<div className="my-4 h-px bg-border/40" />
{/* 4. 버튼 섹션 (읽기 전용) */}
<div className="mb-4">
<div className="mb-2 flex items-center gap-1.5 border-b border-border/30 pb-2">
<div className="flex h-4 w-4 items-center justify-center rounded bg-amber-500/10">
<MousePointer className="h-2.5 w-2.5 text-amber-500" />
</div>
<span className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></span>
<Badge variant="secondary" className="ml-auto h-4 rounded-full px-1.5 text-[9px] font-bold bg-amber-500/10 text-amber-500 hover:bg-amber-500/10">{stats.buttonCount}</Badge>
</div>
<p className="mb-2 text-[10px] text-muted-foreground/70"> </p>
{buttonControls.length > 0 ? (
<div className="space-y-0">
{buttonControls.map((btn) => (
<div key={btn.id} className="flex items-center gap-2 border-b border-border/20 py-1.5 last:border-b-0">
<span
className="flex h-5 flex-shrink-0 items-center rounded px-2 text-[9px] font-bold"
style={{
backgroundColor: btn.backgroundColor ? `${btn.backgroundColor}20` : "hsl(var(--muted))",
color: btn.textColor || btn.backgroundColor || "hsl(var(--foreground))",
}}
>{btn.label}</span>
<div className="min-w-0 flex-1">
<div className="text-[10px] text-muted-foreground">{btn.actionType?.toUpperCase() || "CUSTOM"}</div>
{btn.confirmMessage && <div className="truncate text-[9px] italic text-muted-foreground/60">"{btn.confirmMessage}"</div>}
</div>
{btn.linkedFlows && btn.linkedFlows.length > 0 && (
<Badge variant="secondary" className="h-4 rounded px-1.5 text-[8px] font-bold bg-violet-500/10 text-violet-500 hover:bg-violet-500/10">
{btn.linkedFlows.length}
</Badge>
)}
</div>
))}
</div>
) : (
<div className="py-4 text-center text-[10px] text-muted-foreground/50"> </div>
)}
</TabsContent>
</div>
{/* 탭 3: 제어 관리 */}
<TabsContent value="control-management" className="mt-0 min-h-0 flex-1 overflow-auto p-3">
<ControlManagementTab
screenId={currentScreenId}
groupId={groupId}
layoutItems={layoutItems}
loading={loading}
onRefresh={handleRefresh}
/>
</TabsContent>
<div className="my-4 h-px bg-border/40" />
{/* 탭 3: 데이터 흐름 */}
<TabsContent value="data-flow" className="mt-0 min-h-0 flex-1 overflow-auto p-3">
<DataFlowTab
screenId={currentScreenId}
groupId={groupId}
dataFlows={dataFlows}
loading={loading}
onReload={loadData}
onSaveSuccess={onSaveSuccess}
/>
</TabsContent>
</Tabs>
{/* 5. 데이터 흐름 섹션 */}
<div className="mb-4">
<div className="mb-2 flex items-center gap-1.5 border-b border-border/30 pb-2">
<div className="flex h-4 w-4 items-center justify-center rounded bg-rose-500/10">
<ArrowRight className="h-2.5 w-2.5 text-rose-500" />
</div>
<span className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"> </span>
<Badge variant="secondary" className="ml-auto h-4 rounded-full px-1.5 text-[9px] font-bold bg-rose-500/10 text-rose-500 hover:bg-rose-500/10">{stats.flowCount}</Badge>
</div>
{dataFlows.length > 0 ? (
<div className="space-y-1">
{dataFlows.map((flow) => (
<div key={flow.id} className="flex items-center gap-2.5 rounded-lg border border-border/40 bg-muted/20 p-2.5">
<div className="h-6 w-[3px] flex-shrink-0 rounded-full bg-rose-500" />
<div className="min-w-0 flex-1">
<div className="truncate text-[11px] font-semibold">{flow.source_action || flow.flow_type} {flow.target_screen_name || `화면 ${flow.target_screen_id}`}</div>
<div className="text-[9px] text-muted-foreground">{flow.flow_type}{flow.flow_label ? ` · ${flow.flow_label}` : ""}</div>
</div>
<Button variant="ghost" size="sm" className="h-5 w-5 p-0 text-muted-foreground hover:text-destructive" onClick={async () => {
if (!confirm("정말 삭제하시겠습니까?")) return;
const res = await deleteDataFlow(flow.id);
if (res.success) { toast.success("삭제되었습니다."); loadData(); onSaveSuccess?.(); }
else toast.error("삭제 실패");
}}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
) : (
<div className="rounded-lg border border-dashed border-border/40 py-5 text-center">
<ArrowRight className="mx-auto mb-1 h-4 w-4 text-muted-foreground/30" />
<div className="text-[11px] font-medium text-muted-foreground/60"> </div>
<div className="text-[9px] text-muted-foreground/40"> </div>
</div>
)}
<Button variant="outline" size="sm" className="mt-2 h-7 w-full text-[10px] font-semibold border-border/40 text-muted-foreground" onClick={() => toast.info("데이터 흐름 추가는 준비 중이에요")}>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<div className="my-4 h-px bg-border/40" />
{/* 6. 플로우 연동 섹션 */}
<div>
<div className="mb-2 flex items-center gap-1.5 border-b border-border/30 pb-2">
<div className="flex h-4 w-4 items-center justify-center rounded bg-violet-500/10">
<Link2 className="h-2.5 w-2.5 text-violet-500" />
</div>
<span className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"> </span>
<Badge variant="secondary" className="ml-auto h-4 rounded-full px-1.5 text-[9px] font-bold bg-violet-500/10 text-violet-500 hover:bg-violet-500/10">{linkedFlowCount}</Badge>
</div>
{linkedFlowCount > 0 ? (
<div className="space-y-1">
{buttonControls.filter(b => b.linkedFlows && b.linkedFlows.length > 0).flatMap(btn =>
(btn.linkedFlows || []).map(flow => (
<div key={`${btn.id}-${flow.id}`} className="flex items-center gap-2.5 rounded-lg border border-violet-500/10 bg-violet-500/[0.03] p-2.5">
<div className="h-6 w-[3px] flex-shrink-0 rounded-full bg-violet-500" />
<div className="min-w-0 flex-1">
<div className="truncate text-[11px] font-semibold">{flow.name || `플로우 #${flow.id}`}</div>
<div className="text-[9px] text-muted-foreground">{btn.label} · {flow.timing === "before" ? "실행 전" : "실행 후"}</div>
</div>
</div>
))
)}
</div>
) : (
<div className="py-3 text-center text-[10px] text-muted-foreground/50"> </div>
)}
</div>
</div>
{/* CTA: 화면 디자이너 열기 */}
<div className="flex-shrink-0 border-t border-border/40 p-3">
<Button
className="h-8 w-full gap-1.5 bg-gradient-to-r from-primary to-blue-500 text-[11px] font-bold tracking-tight text-primary-foreground shadow-md shadow-primary/20 transition-all hover:shadow-lg hover:shadow-primary/30"
onClick={() => setShowDesignerModal(true)}
>
<ExternalLink className="h-3 w-3" />
</Button>
</div>
</div>
{/* 오른쪽: 화면 프리뷰 (60%, 항상 표시) */}
<div className="flex min-h-0 w-[60%] flex-col overflow-hidden rounded-lg border bg-card">
<PreviewTab
screenId={currentScreenId}
screenName={currentScreenName}
companyCode={companyCode}
{/* 오른쪽: 화면 프리뷰 */}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border border-border/40">
<PreviewTab
screenId={currentScreenId}
screenName={currentScreenName}
companyCode={companyCode}
iframeKey={iframeKey}
canvasWidth={canvasSize.width}
canvasHeight={canvasSize.height}
@@ -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}
/>
);
};
@@ -102,7 +102,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
const fetchScreens = async () => {
try {
setScreensLoading(true);
const response = await apiClient.get("/screen-management/screens");
const response = await apiClient.get("/screen-management/screens?size=1000");
if (response.data.success && Array.isArray(response.data.data)) {
const screenList = response.data.data.map((screen: any) => ({
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>
+180 -22
View File
@@ -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";
@@ -33,7 +34,8 @@ const FORMAT_PATTERNS: Record<V2InputFormat, { pattern: RegExp; placeholder: str
errorMessage: "올바른 이메일 형식이 아닙니다",
},
tel: {
pattern: /^\d{2,3}-\d{3,4}-\d{4}$/,
pattern:
/^(01[016789]|02|0[3-7]1|0[3-6][2-5]|050[2-8]|070|080)-\d{3,4}-\d{4}$|^(15|16|18)\d{2}-\d{4}$/,
placeholder: "010-1234-5678",
errorMessage: "올바른 전화번호 형식이 아닙니다",
},
@@ -61,11 +63,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);
}
// 사업자번호 형식 변환
@@ -79,8 +81,34 @@ function formatBizNo(value: string): string {
// 전화번호 형식 변환
function formatTel(value: string): string {
const digits = value.replace(/\D/g, "");
if (digits.length === 0) return "";
// 대표번호: 15xx, 16xx, 18xx → 4-4
if (/^(15|16|18)/.test(digits)) {
if (digits.length <= 4) return digits;
return `${digits.slice(0, 4)}-${digits.slice(4, 8)}`;
}
// 서울: 02 → 2-4-4
if (digits.startsWith("02")) {
if (digits.length <= 2) return digits;
if (digits.length <= 6) return `${digits.slice(0, 2)}-${digits.slice(2)}`;
if (digits.length <= 10) return `${digits.slice(0, 2)}-${digits.slice(2, 6)}-${digits.slice(6)}`;
return `${digits.slice(0, 2)}-${digits.slice(2, 6)}-${digits.slice(6, 10)}`;
}
// 안심번호: 050x → 4-4-4
if (/^050[2-8]/.test(digits)) {
if (digits.length <= 4) return digits;
if (digits.length <= 8) return `${digits.slice(0, 4)}-${digits.slice(4)}`;
if (digits.length <= 12) return `${digits.slice(0, 4)}-${digits.slice(4, 8)}-${digits.slice(8)}`;
return `${digits.slice(0, 4)}-${digits.slice(4, 8)}-${digits.slice(8, 12)}`;
}
// 나머지 (010, 031, 070, 080 등)
if (digits.length <= 3) return digits;
if (digits.length <= 7) return `${digits.slice(0, 3)}-${digits.slice(3)}`;
if (digits.length === 10) return `${digits.slice(0, 3)}-${digits.slice(3, 6)}-${digits.slice(6)}`;
if (digits.length <= 11) return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}`;
return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7, 11)}`;
}
@@ -234,7 +262,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 +293,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" }}
/>
);
});
@@ -676,7 +791,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
// 채번 코드 생성 (formDataRef.current 사용하여 최신 formData 전달)
const currentFormData = formDataRef.current;
const previewResponse = await previewNumberingCode(numberingRuleId, currentFormData);
const previewResponse = await previewNumberingCode(numberingRuleId, currentFormData, manualInputValue || undefined);
if (previewResponse.success && previewResponse.data?.generatedCode) {
const generatedCode = previewResponse.data.generatedCode;
@@ -764,6 +879,49 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
};
}, [columnName, manualInputValue, propsInputType, config.inputType, config.type]);
// 수동 입력값 변경 시 디바운스로 순번 미리보기 갱신
useEffect(() => {
const inputType = propsInputType || config.inputType || config.type || "text";
if (inputType !== "numbering") return;
if (!numberingTemplateRef.current?.includes("____")) return;
const ruleId = numberingRuleIdRef.current;
if (!ruleId) return;
// 사용자가 한 번도 입력하지 않은 초기 상태면 스킵
if (!userEditedNumberingRef.current) return;
const debounceTimer = setTimeout(async () => {
try {
const currentFormData = formDataRef.current;
const resp = await previewNumberingCode(ruleId, currentFormData, manualInputValue || undefined);
if (resp.success && resp.data?.generatedCode) {
const newTemplate = resp.data.generatedCode;
if (newTemplate.includes("____")) {
numberingTemplateRef.current = newTemplate;
const parts = newTemplate.split("____");
const prefix = parts[0] || "";
const suffix = parts.length > 1 ? parts.slice(1).join("") : "";
const combined = prefix + manualInputValue + suffix;
setAutoGeneratedValue(combined);
onChange?.(combined);
if (onFormDataChange && columnName) {
onFormDataChange(columnName, combined);
}
}
}
} catch {
/* 미리보기 실패 시 기존 suffix 유지 */
}
}, 300);
return () => clearTimeout(debounceTimer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [manualInputValue]);
// 실제 표시할 값 (자동생성 값 또는 props value)
const displayValue = autoGeneratedValue ?? value;
@@ -997,7 +1155,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
const hasCustomRadius = !!style?.borderRadius;
const customTextStyle: React.CSSProperties = {};
if (style?.color) customTextStyle.color = style.color;
if (style?.color) customTextStyle.color = getAdaptiveLabelColor(style.color);
if (style?.fontSize) customTextStyle.fontSize = style.fontSize;
if (style?.fontWeight) customTextStyle.fontWeight = style.fontWeight;
if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"];
@@ -1044,8 +1202,8 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
ref={ref}
id={id}
className={cn(
"flex flex-col gap-1",
labelPos === "left" ? "sm:flex-row sm:items-center" : "sm:flex-row-reverse sm:items-center",
"flex gap-1",
labelPos === "left" ? "flex-row items-center" : "flex-row-reverse items-center",
)}
style={{
width: componentWidth,
@@ -1060,7 +1218,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
color: getAdaptiveLabelColor(style?.labelColor),
fontWeight: style?.labelFontWeight || "500",
}}
className="w-full text-sm font-medium whitespace-nowrap sm:w-[120px] sm:shrink-0"
className="text-sm font-medium whitespace-nowrap w-[120px] shrink-0"
>
{actualLabel}
{required && <span className="ml-0.5 text-amber-500">*</span>}
+3 -3
View File
@@ -1291,8 +1291,8 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>((props, ref) =
ref={ref}
id={id}
className={cn(
"flex flex-col gap-1",
labelPos === "left" ? "sm:flex-row sm:items-center" : "sm:flex-row-reverse sm:items-center",
"flex gap-1",
labelPos === "left" ? "flex-row items-center" : "flex-row-reverse items-center",
isDesignMode && "pointer-events-none",
)}
style={{
@@ -1308,7 +1308,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>((props, ref) =
color: getAdaptiveLabelColor(style?.labelColor),
fontWeight: style?.labelFontWeight || "500",
}}
className="w-full text-sm font-medium whitespace-nowrap sm:w-[120px] sm:shrink-0"
className="text-sm font-medium whitespace-nowrap w-[120px] shrink-0"
>
{label}
{required && <span className="ml-0.5 text-amber-500">*</span>}
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,757 @@
"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, metaInputType?: string): FieldType {
// DB input_type이 전달된 경우 (데이터타입관리에서 변경 시) 우선 적용
if (metaInputType && metaInputType !== "direct" && metaInputType !== "auto") {
const dbType = metaInputType as FieldType;
if (["text", "number", "textarea", "numbering", "select", "category", "entity"].includes(dbType)) {
return dbType;
}
}
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, metaInputType);
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;

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