2025-08-29 11:46:52 +09:00
|
|
|
# 화면관리 시스템 설계 문서
|
|
|
|
|
|
|
|
|
|
## 📋 목차
|
|
|
|
|
|
|
|
|
|
1. [시스템 개요](#시스템-개요)
|
|
|
|
|
2. [아키텍처 구조](#아키텍처-구조)
|
|
|
|
|
3. [핵심 기능](#핵심-기능)
|
|
|
|
|
4. [데이터베이스 설계](#데이터베이스-설계)
|
|
|
|
|
5. [화면 구성 요소](#화면-구성-요소)
|
|
|
|
|
6. [드래그앤드롭 설계](#드래그앤드롭-설계)
|
|
|
|
|
7. [테이블 타입 연계](#테이블-타입-연계)
|
|
|
|
|
8. [API 설계](#api-설계)
|
|
|
|
|
9. [프론트엔드 구현](#프론트엔드-구현)
|
|
|
|
|
10. [백엔드 구현](#백엔드-구현)
|
|
|
|
|
11. [사용 시나리오](#사용-시나리오)
|
|
|
|
|
12. [개발 계획](#개발-계획)
|
|
|
|
|
|
|
|
|
|
## 🎯 시스템 개요
|
|
|
|
|
|
|
|
|
|
### 화면관리 시스템이란?
|
|
|
|
|
|
2025-09-01 10:19:47 +09:00
|
|
|
화면관리 시스템은 사용자가 속한 회사에 맞춰 화면을 드래그앤드롭으로 설계하고 관리할 수 있는 시스템입니다. 테이블 타입관리와 연계하여 각 필드가 웹에서 어떻게 표시될지를 정의하고, 사용자가 직관적으로 화면을 구성할 수 있습니다.
|
2025-08-29 11:46:52 +09:00
|
|
|
|
|
|
|
|
### 주요 특징
|
|
|
|
|
|
2025-09-01 10:19:47 +09:00
|
|
|
- **회사별 화면 관리**: 사용자 회사 코드에 따른 화면 접근 제어
|
2025-08-29 11:46:52 +09:00
|
|
|
- **드래그앤드롭 인터페이스**: 직관적인 화면 설계
|
2025-09-01 10:19:47 +09:00
|
|
|
- **컨테이너 그룹화**: 컴포넌트를 깔끔하게 정렬하는 그룹 기능
|
2025-08-29 11:46:52 +09:00
|
|
|
- **테이블 타입 연계**: 컬럼의 웹 타입에 따른 자동 위젯 생성
|
2025-09-01 10:19:47 +09:00
|
|
|
- **실시간 미리보기**: 설계한 화면을 실제 화면과 동일하게 확인 가능
|
|
|
|
|
- **메뉴 연동**: 각 회사의 메뉴에 화면 할당 및 관리
|
2025-08-29 11:46:52 +09:00
|
|
|
|
2025-09-03 11:32:09 +09:00
|
|
|
### 🆕 최근 업데이트 (2024.12)
|
|
|
|
|
|
|
|
|
|
#### ✅ 완료된 주요 기능들
|
|
|
|
|
|
|
|
|
|
- **컴포넌트 관리 시스템**: 드래그앤드롭, 다중 선택, 그룹 드래그, 실시간 위치 업데이트
|
2025-09-03 15:23:12 +09:00
|
|
|
- **⚡ 실시간 속성 편집 시스템**: 로컬 상태 기반 즉시 반영, 완벽한 입력/체크박스 실시간 업데이트
|
|
|
|
|
- **속성 편집 시스템**: 라벨 관리 (텍스트, 폰트, 색상, 여백), 필수 입력 시 주황색 \* 표시
|
2025-09-03 11:32:09 +09:00
|
|
|
- **격자 시스템**: 동적 격자 설정, 컴포넌트 스냅 및 크기 조정
|
|
|
|
|
- **패널 관리**: 플로팅 패널, 수동 크기 조정, 위치 기억
|
|
|
|
|
- **웹타입 지원**: text, number, decimal, date, datetime, select, dropdown, textarea, boolean, checkbox, radio, code, entity, file
|
2025-09-03 15:23:12 +09:00
|
|
|
- **데이터 테이블 컴포넌트**: 완전한 실시간 설정 시스템, 컬럼 관리, 필터링, 페이징
|
|
|
|
|
- **🆕 실시간 데이터 테이블**: 실제 PostgreSQL 데이터 조회, 웹타입별 검색 필터, 페이지네이션, 데이터 포맷팅
|
2025-09-05 10:27:10 +09:00
|
|
|
- **🆕 화면 저장 후 메뉴 할당**: 저장 완료 시 자동 메뉴 할당 모달, 기존 화면 교체 확인, 시각적 피드백 및 자동 목록 복귀
|
2025-09-03 11:32:09 +09:00
|
|
|
|
|
|
|
|
#### 🔧 해결된 기술적 문제들
|
|
|
|
|
|
2025-09-03 15:23:12 +09:00
|
|
|
- **⚡ 실시간 속성 편집 완성**: 로컬 상태 기반 이중 관리 시스템으로 완벽한 실시간 반영
|
|
|
|
|
- **체크박스 실시간 업데이트**: 모든 체크박스의 즉시 상태 변경 및 유지
|
|
|
|
|
- **동적 컴포넌트 상태 관리**: ID 기반 컬럼별 개별 상태 관리 및 동기화
|
2025-09-03 11:32:09 +09:00
|
|
|
- **라벨 하단 여백 동적 적용**: 여백값에 따른 정확한 위치 계산
|
|
|
|
|
- **스타일 속성 개별 업데이트**: 초기화 방지를 위한 `style.propertyName` 방식 적용
|
|
|
|
|
- **다중 드래그 최적화**: 지연 없는 실시간 미리보기, 선택 해제 방지
|
2025-09-03 15:23:12 +09:00
|
|
|
- **입력값 보존 시스템**: 패널 재오픈해도 사용자 입력값 완벽 유지
|
2025-09-03 11:32:09 +09:00
|
|
|
|
|
|
|
|
#### 🎯 개발 진행 상황
|
|
|
|
|
|
2025-09-03 15:23:12 +09:00
|
|
|
- **현재 완성도**: 98% (실시간 편집 시스템 완성, 핵심 기능 완료)
|
2025-09-03 11:32:09 +09:00
|
|
|
- **기술 스택**: Next.js 15.4.4, TypeScript, Tailwind CSS, Shadcn/ui
|
2025-09-03 15:23:12 +09:00
|
|
|
- **⚡ 상태 관리**: **완성된 실시간 속성 편집 패턴** - 로컬 상태 + 글로벌 상태 이중 관리
|
2025-09-03 11:32:09 +09:00
|
|
|
- **드래그앤드롭**: HTML5 Drag & Drop API 기반 고도화된 시스템
|
2025-09-03 15:23:12 +09:00
|
|
|
- **🎯 표준화**: 모든 속성 편집 컴포넌트에 실시간 패턴 적용 완료
|
2025-09-01 17:05:36 +09:00
|
|
|
|
2025-08-29 11:46:52 +09:00
|
|
|
### 🎯 **현재 테이블 구조와 100% 호환**
|
|
|
|
|
|
|
|
|
|
**기존 테이블 타입관리 시스템과 완벽 연계:**
|
|
|
|
|
|
|
|
|
|
- ✅ **`table_labels`**: 테이블 메타데이터와 연계
|
|
|
|
|
- ✅ **`column_labels`**: 컬럼 웹 타입 및 상세 설정과 연계
|
|
|
|
|
- ✅ **웹 타입 지원**: text, number, date, code, entity, textarea, select, checkbox, radio, file
|
|
|
|
|
- ✅ **상세 설정**: JSON 형태로 유연한 설정 저장
|
|
|
|
|
- ✅ **코드 연계**: 공통코드 시스템과 완벽 연동
|
|
|
|
|
- ✅ **엔티티 참조**: 참조 테이블 시스템 완벽 지원
|
|
|
|
|
|
|
|
|
|
**별도의 테이블 구조 변경 없이 바로 개발 가능!** 🚀
|
|
|
|
|
|
2025-09-01 10:19:47 +09:00
|
|
|
### 🏢 **회사별 화면 관리 시스템**
|
|
|
|
|
|
|
|
|
|
**사용자 권한에 따른 화면 접근 제어:**
|
|
|
|
|
|
|
|
|
|
- ✅ **일반 사용자**: 자신이 속한 회사의 화면만 제작/수정 가능
|
|
|
|
|
- ✅ **관리자 (회사코드 '\*')**: 모든 회사의 화면을 제어 가능
|
|
|
|
|
- ✅ **회사별 메뉴 할당**: 각 회사의 메뉴에만 화면 할당 가능
|
|
|
|
|
- ✅ **권한 격리**: 회사 간 화면 데이터 완전 분리
|
|
|
|
|
|
2025-08-29 11:46:52 +09:00
|
|
|
### 지원하는 웹 타입
|
|
|
|
|
|
|
|
|
|
테이블 타입관리에서 각 컬럼별로 설정할 수 있는 웹 타입입니다:
|
|
|
|
|
|
|
|
|
|
- **text**: 일반 텍스트 입력
|
|
|
|
|
- **number**: 숫자 입력
|
|
|
|
|
- **date**: 날짜 선택기
|
|
|
|
|
- **code**: 코드 선택 (공통코드)
|
|
|
|
|
- **entity**: 엔티티 참조 (참조테이블)
|
|
|
|
|
- **textarea**: 여러 줄 텍스트
|
|
|
|
|
- **select**: 드롭다운 선택
|
|
|
|
|
- **checkbox**: 체크박스
|
|
|
|
|
- **radio**: 라디오 버튼
|
|
|
|
|
- **file**: 파일 업로드
|
|
|
|
|
|
|
|
|
|
> **구현 완료**: 테이블 타입관리 시스템에서 위의 모든 웹 타입을 지정할 수 있으며, 웹 타입별로 적절한 상세 설정을 자동으로 제공합니다.
|
|
|
|
|
|
|
|
|
|
### 웹 타입 관리 기능
|
|
|
|
|
|
|
|
|
|
#### 1. 웹 타입 설정
|
|
|
|
|
|
|
|
|
|
- **자동 상세 설정**: 웹 타입 선택 시 해당 타입에 맞는 기본 상세 설정을 자동으로 제공
|
|
|
|
|
- **실시간 저장**: 웹 타입 변경 시 즉시 백엔드 데이터베이스에 저장
|
|
|
|
|
- **오류 복구**: 저장 실패 시 원래 상태로 자동 복원
|
2025-09-01 11:48:12 +09:00
|
|
|
- **상세 설정 편집**: 웹 타입별 상세 설정을 모달에서 JSON 형태로 편집 가능
|
2025-08-29 11:46:52 +09:00
|
|
|
|
|
|
|
|
#### 2. 웹 타입별 상세 설정
|
|
|
|
|
|
|
|
|
|
| 웹 타입 | 기본 상세 설정 | 추가 설정 옵션 |
|
|
|
|
|
| ------------ | ---------------------------- | --------------------- |
|
|
|
|
|
| **text** | 최대 길이: 255자 | 사용자 정의 설정 |
|
|
|
|
|
| **number** | 숫자 입력 (정수/실수) | 범위, 정밀도 설정 |
|
|
|
|
|
| **date** | 날짜 형식: YYYY-MM-DD | 날짜 범위, 형식 설정 |
|
|
|
|
|
| **textarea** | 여러 줄 텍스트 (최대 1000자) | 행 수, 최대 길이 설정 |
|
|
|
|
|
| **select** | 드롭다운 선택 옵션 | 옵션 목록 설정 |
|
|
|
|
|
| **checkbox** | 체크박스 (Y/N) | 기본값, 라벨 설정 |
|
|
|
|
|
| **radio** | 라디오 버튼 그룹 | 옵션 그룹 설정 |
|
|
|
|
|
| **file** | 파일 업로드 (최대 10MB) | 파일 형식, 크기 설정 |
|
|
|
|
|
| **code** | 공통코드 선택 | 코드 카테고리 지정 |
|
|
|
|
|
| **entity** | 엔티티 참조 | 참조 테이블/컬럼 지정 |
|
|
|
|
|
|
|
|
|
|
#### 3. 사용 방법
|
|
|
|
|
|
|
|
|
|
1. **테이블 선택**: 테이블 타입관리에서 관리할 테이블 선택
|
|
|
|
|
2. **컬럼 확인**: 해당 테이블의 모든 컬럼 정보 표시
|
|
|
|
|
3. **웹 타입 설정**: 각 컬럼의 웹 타입을 드롭다운에서 선택
|
|
|
|
|
4. **자동 저장**: 선택 즉시 백엔드에 저장되고 상세 설정 자동 적용
|
2025-09-01 11:48:12 +09:00
|
|
|
5. **상세 설정 편집**: "상세 설정 편집" 버튼을 클릭하여 JSON 형태로 추가 설정 수정
|
|
|
|
|
6. **설정 저장**: 수정된 상세 설정을 저장하여 완료
|
2025-08-29 11:46:52 +09:00
|
|
|
|
|
|
|
|
## 🏗️ 아키텍처 구조
|
|
|
|
|
|
|
|
|
|
### 전체 구조도
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
|
|
|
|
│ Frontend │ │ Backend │ │ Database │
|
|
|
|
|
│ │ │ │ │ │
|
|
|
|
|
│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │
|
|
|
|
|
│ │ Screen │ │ │ │ Screen │ │ │ │ screen_ │ │
|
|
|
|
|
│ │ Designer │ │ │ │ Management │ │ │ │ definitions │ │
|
|
|
|
|
│ │ (React) │ │ │ │ Controller │ │ │ │ Table │ │
|
|
|
|
|
│ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │
|
|
|
|
|
│ │ │ │ │ │
|
|
|
|
|
│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │
|
|
|
|
|
│ │ Drag & │ │ │ │ Screen │ │ │ │ screen_ │ │
|
|
|
|
|
│ │ Drop │ │ │ │ Management │ │ │ │ layouts │ │
|
|
|
|
|
│ │ Components │ │ │ │ Service │ │ │ │ Table │ │
|
|
|
|
|
│ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │
|
|
|
|
|
│ │ │ │ │ │
|
|
|
|
|
│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │
|
|
|
|
|
│ │ Preview │ │ │ │ Table │ │ │ │ table_ │ │
|
|
|
|
|
│ │ Generator │ │ │ │ Type │ │ │ │ labels │ │
|
|
|
|
|
│ │ (Runtime) │ │ │ │ Service │ │ │ │ Table │ │
|
|
|
|
|
│ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │
|
|
|
|
|
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
|
|
|
|
```
|
|
|
|
|
|
2025-09-01 10:19:47 +09:00
|
|
|
### 회사별 권한 관리 구조
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
|
|
|
|
│ 사용자 │ │ 권한 검증 │ │ 화면 데이터 │
|
|
|
|
|
│ │ │ │ │ │
|
|
|
|
|
│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │
|
|
|
|
|
│ │ 회사코드 │ │───▶│ │ 권한 검증 │ │───▶│ │ 회사별 │ │
|
|
|
|
|
│ │ (company_ │ │ │ │ 미들웨어 │ │ │ │ 화면 │ │
|
|
|
|
|
│ │ code) │ │ │ │ │ │ │ │ 격리 │ │
|
|
|
|
|
│ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │
|
|
|
|
|
│ │ │ │ │ │
|
|
|
|
|
│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │
|
|
|
|
|
│ │ 권한 레벨 │ │ │ │ 회사별 │ │ │ │ 메뉴 할당 │ │
|
|
|
|
|
│ │ (admin: '*')│ │ │ │ 데이터 │ │ │ │ 제한 │ │
|
|
|
|
|
│ └─────────────┘ │ │ │ 필터링 │ │ │ └─────────────┘ │
|
|
|
|
|
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
|
|
|
|
```
|
|
|
|
|
|
2025-08-29 11:46:52 +09:00
|
|
|
### 데이터 흐름
|
|
|
|
|
|
|
|
|
|
1. **테이블 타입 정의**: 테이블 타입관리에서 컬럼의 웹 타입 설정
|
|
|
|
|
2. **화면 설계**: 드래그앤드롭으로 화면 레이아웃 구성
|
|
|
|
|
3. **위젯 매핑**: 컬럼과 화면 위젯을 연결
|
|
|
|
|
4. **설정 저장**: 화면 정의를 데이터베이스에 저장
|
|
|
|
|
5. **런타임 생성**: 실제 서비스 화면을 동적으로 생성
|
|
|
|
|
|
2025-09-03 15:23:12 +09:00
|
|
|
## ⚡ 실시간 속성 편집 시스템
|
|
|
|
|
|
|
|
|
|
### 개요
|
|
|
|
|
|
|
|
|
|
화면관리 시스템의 핵심 기능 중 하나인 실시간 속성 편집은 사용자가 컴포넌트의 속성을 수정할 때 즉시 화면에 반영되는 시스템입니다. 이 시스템은 **로컬 상태 기반 입력 관리**와 **실시간 업데이트 패턴**을 통해 구현되었습니다.
|
|
|
|
|
|
|
|
|
|
### 🎯 핵심 아키텍처 패턴
|
|
|
|
|
|
|
|
|
|
#### 1. 로컬 상태 + 글로벌 상태 이중 관리
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// 1단계: 로컬 상태 정의 (실시간 표시용)
|
|
|
|
|
const [localInputs, setLocalInputs] = useState({
|
|
|
|
|
title: component.title || "",
|
|
|
|
|
placeholder: component.placeholder || "",
|
|
|
|
|
// 모든 입력 필드의 현재 값
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const [localValues, setLocalValues] = useState({
|
|
|
|
|
showButton: component.showButton ?? true,
|
|
|
|
|
enabled: component.enabled ?? false,
|
|
|
|
|
// 모든 체크박스의 현재 상태
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 2단계: 컴포넌트 변경 시 자동 동기화
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setLocalInputs({
|
|
|
|
|
title: component.title || "",
|
|
|
|
|
placeholder: component.placeholder || "",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
setLocalValues({
|
|
|
|
|
showButton: component.showButton ?? true,
|
|
|
|
|
enabled: component.enabled ?? false,
|
|
|
|
|
});
|
|
|
|
|
}, [component.title, component.placeholder, component.showButton]);
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### 2. 실시간 입력 처리 패턴
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// 텍스트 입력 - 즉시 반영
|
|
|
|
|
<Input
|
|
|
|
|
value={localInputs.title}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const newValue = e.target.value;
|
|
|
|
|
// 1) 로컬 상태 즉시 업데이트 (화면 반영)
|
|
|
|
|
setLocalInputs(prev => ({ ...prev, title: newValue }));
|
|
|
|
|
// 2) 글로벌 상태 업데이트 (데이터 저장)
|
|
|
|
|
onUpdateProperty("title", newValue);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
// 체크박스 - 즉시 반영
|
|
|
|
|
<Checkbox
|
|
|
|
|
checked={localValues.showButton}
|
|
|
|
|
onCheckedChange={(checked) => {
|
|
|
|
|
// 1) 로컬 상태 즉시 업데이트
|
|
|
|
|
setLocalValues(prev => ({ ...prev, showButton: checked as boolean }));
|
|
|
|
|
// 2) 글로벌 상태 업데이트
|
|
|
|
|
onUpdateProperty("showButton", checked);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### 3. 동적 컴포넌트별 상태 관리
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// 컬럼별 개별 상태 관리 (ID 기반)
|
|
|
|
|
const [localColumnInputs, setLocalColumnInputs] = useState<
|
|
|
|
|
Record<string, string>
|
|
|
|
|
>({});
|
|
|
|
|
const [localColumnCheckboxes, setLocalColumnCheckboxes] = useState<
|
|
|
|
|
Record<string, { visible: boolean; sortable: boolean; searchable: boolean }>
|
|
|
|
|
>({});
|
|
|
|
|
|
|
|
|
|
// 기존 값 보존하면서 새 항목만 추가
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setLocalColumnInputs((prev) => {
|
|
|
|
|
const newInputs = { ...prev };
|
|
|
|
|
component.columns?.forEach((col) => {
|
|
|
|
|
if (!(col.id in newInputs)) {
|
|
|
|
|
// 기존 입력값 보존
|
|
|
|
|
newInputs[col.id] = col.label;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
return newInputs;
|
|
|
|
|
});
|
|
|
|
|
}, [component.columns]);
|
|
|
|
|
|
|
|
|
|
// 동적 입력 처리
|
|
|
|
|
<Input
|
|
|
|
|
value={
|
|
|
|
|
localColumnInputs[column.id] !== undefined
|
|
|
|
|
? localColumnInputs[column.id]
|
|
|
|
|
: column.label
|
|
|
|
|
}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const newValue = e.target.value;
|
|
|
|
|
setLocalColumnInputs((prev) => ({ ...prev, [column.id]: newValue }));
|
|
|
|
|
updateColumn(column.id, { label: newValue });
|
|
|
|
|
}}
|
|
|
|
|
/>;
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 🔧 구현 표준 가이드라인
|
|
|
|
|
|
|
|
|
|
#### 필수 구현 패턴
|
|
|
|
|
|
|
|
|
|
1. **로컬 우선 원칙**: 모든 입력은 로컬 상태를 먼저 업데이트
|
|
|
|
|
2. **즉시 반영**: 로컬 상태 업데이트와 동시에 컴포넌트 속성 업데이트
|
|
|
|
|
3. **기존값 보존**: useEffect에서 기존 로컬 입력값이 있으면 덮어쓰지 않음
|
|
|
|
|
4. **완전한 정리**: 항목 삭제 시 관련된 모든 로컬 상태도 함께 정리
|
|
|
|
|
5. **타입 안전성**: 모든 상태에 정확한 TypeScript 타입 지정
|
|
|
|
|
|
|
|
|
|
#### 항목 추가/삭제 시 상태 관리
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// 추가 시
|
|
|
|
|
const addItem = useCallback(
|
|
|
|
|
(newItem) => {
|
|
|
|
|
// 로컬 상태에 즉시 추가
|
|
|
|
|
setLocalColumnInputs((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
[newItem.id]: newItem.label,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
setLocalColumnCheckboxes((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
[newItem.id]: { visible: true, sortable: true, searchable: true },
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// 실제 컴포넌트 업데이트
|
|
|
|
|
onUpdateComponent({ items: [...component.items, newItem] });
|
|
|
|
|
},
|
|
|
|
|
[component.items, onUpdateComponent]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 삭제 시
|
|
|
|
|
const removeItem = useCallback(
|
|
|
|
|
(itemId) => {
|
|
|
|
|
// 로컬 상태에서 제거
|
|
|
|
|
setLocalColumnInputs((prev) => {
|
|
|
|
|
const newInputs = { ...prev };
|
|
|
|
|
delete newInputs[itemId];
|
|
|
|
|
return newInputs;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
setLocalColumnCheckboxes((prev) => {
|
|
|
|
|
const newCheckboxes = { ...prev };
|
|
|
|
|
delete newCheckboxes[itemId];
|
|
|
|
|
return newCheckboxes;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 실제 컴포넌트 업데이트
|
|
|
|
|
const updatedItems = component.items.filter((item) => item.id !== itemId);
|
|
|
|
|
onUpdateComponent({ items: updatedItems });
|
|
|
|
|
},
|
|
|
|
|
[component.items, onUpdateComponent]
|
|
|
|
|
);
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 📊 적용 범위
|
|
|
|
|
|
|
|
|
|
이 패턴은 화면관리 시스템의 다음 컴포넌트들에 적용되었습니다:
|
|
|
|
|
|
|
|
|
|
- **PropertiesPanel**: 기본 속성 편집 (위치, 크기, 라벨 등)
|
|
|
|
|
- **DataTableConfigPanel**: 데이터 테이블 상세 설정
|
|
|
|
|
- **DateTypeConfigPanel**: 날짜 타입 상세 설정
|
|
|
|
|
- **NumberTypeConfigPanel**: 숫자 타입 상세 설정
|
|
|
|
|
- **SelectTypeConfigPanel**: 선택박스 타입 상세 설정
|
|
|
|
|
- **TextTypeConfigPanel**: 텍스트 타입 상세 설정
|
|
|
|
|
- **기타 모든 웹타입별 설정 패널들**
|
|
|
|
|
|
|
|
|
|
### 🎯 사용자 경험 향상 효과
|
|
|
|
|
|
|
|
|
|
- **🚀 즉시 피드백**: 타이핑하는 순간 화면에 바로 반영
|
|
|
|
|
- **🔄 상태 일관성**: 패널을 닫았다 열어도 입력한 값이 정확히 유지
|
|
|
|
|
- **⚡ 빠른 반응성**: 지연 없는 실시간 UI 업데이트
|
|
|
|
|
- **🛡️ 안정성**: 메모리 누수 없는 완전한 상태 관리
|
|
|
|
|
|
2025-08-29 11:46:52 +09:00
|
|
|
## 🚀 핵심 기능
|
|
|
|
|
|
|
|
|
|
### 1. 화면 설계기 (Screen Designer)
|
|
|
|
|
|
|
|
|
|
- **드래그앤드롭 인터페이스**: 컴포넌트를 캔버스에 배치
|
|
|
|
|
- **그리드 시스템**: 12컬럼 그리드 기반 레이아웃
|
2025-09-01 10:19:47 +09:00
|
|
|
- **컨테이너 그룹화**: 컴포넌트를 깔끔하게 정렬하는 그룹 기능
|
|
|
|
|
- **실시간 미리보기**: 설계한 화면을 실제 화면과 동일하게 확인
|
2025-08-29 11:46:52 +09:00
|
|
|
|
|
|
|
|
### 2. 컴포넌트 라이브러리
|
|
|
|
|
|
|
|
|
|
- **입력 컴포넌트**: text, number, date, textarea 등
|
|
|
|
|
- **선택 컴포넌트**: select, checkbox, radio 등
|
|
|
|
|
- **표시 컴포넌트**: label, display, image 등
|
2025-09-01 10:19:47 +09:00
|
|
|
- **레이아웃 컴포넌트**: container, row, column, group 등
|
|
|
|
|
- **컨테이너 컴포넌트**: 컴포넌트들을 그룹으로 묶는 기능
|
|
|
|
|
|
|
|
|
|
### 3. 회사별 권한 관리
|
2025-08-29 11:46:52 +09:00
|
|
|
|
2025-09-01 10:19:47 +09:00
|
|
|
- **회사 코드 기반 접근 제어**: 사용자 회사 코드에 따른 화면 접근
|
|
|
|
|
- **관리자 권한**: 회사 코드 '\*'인 사용자는 모든 회사 화면 제어
|
|
|
|
|
- **회사별 메뉴 할당**: 각 회사의 메뉴에만 화면 할당 가능
|
|
|
|
|
- **데이터 격리**: 회사 간 화면 데이터 완전 분리
|
|
|
|
|
|
|
|
|
|
### 4. 테이블 연계 시스템
|
2025-08-29 11:46:52 +09:00
|
|
|
|
|
|
|
|
- **자동 위젯 생성**: 컬럼의 웹 타입에 따른 위젯 자동 생성
|
|
|
|
|
- **데이터 바인딩**: 컬럼과 위젯의 자동 연결
|
|
|
|
|
- **유효성 검증**: 컬럼 설정에 따른 자동 검증 규칙 적용
|
|
|
|
|
|
2025-09-01 10:19:47 +09:00
|
|
|
### 5. 템플릿 시스템
|
2025-08-29 11:46:52 +09:00
|
|
|
|
|
|
|
|
- **기본 템플릿**: CRUD, 목록, 상세 등 기본 패턴
|
|
|
|
|
- **사용자 정의 템플릿**: 자주 사용하는 레이아웃 저장
|
|
|
|
|
- **템플릿 공유**: 팀원 간 템플릿 공유 및 재사용
|
|
|
|
|
|
2025-09-01 10:19:47 +09:00
|
|
|
### 6. 메뉴 연동 시스템
|
|
|
|
|
|
|
|
|
|
- **회사별 메뉴 할당**: 각 회사의 메뉴에만 화면 할당
|
|
|
|
|
- **메뉴-화면 연결**: 메뉴와 화면의 1:1 또는 1:N 연결
|
|
|
|
|
- **권한 기반 메뉴 표시**: 사용자 권한에 따른 메뉴 표시 제어
|
2025-09-05 10:27:10 +09:00
|
|
|
- **🆕 저장 후 자동 할당**: 화면 저장 완료 시 메뉴 할당 모달 자동 팝업
|
|
|
|
|
- **🆕 기존 화면 교체**: 이미 할당된 화면이 있을 때 교체 확인 및 안전한 처리
|
|
|
|
|
- **🆕 완전한 워크플로우**: 저장 → 할당 → 목록 복귀의 자연스러운 흐름
|
2025-09-01 10:19:47 +09:00
|
|
|
|
2025-08-29 11:46:52 +09:00
|
|
|
## 🗄️ 데이터베이스 설계
|
|
|
|
|
|
|
|
|
|
### 1. 기존 테이블 구조 (테이블 타입관리)
|
|
|
|
|
|
|
|
|
|
#### table_labels (테이블 메타데이터)
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
CREATE TABLE table_labels (
|
|
|
|
|
table_name varchar(100) NOT NULL,
|
|
|
|
|
table_label varchar(200) NULL,
|
|
|
|
|
description text NULL,
|
|
|
|
|
created_date timestamp DEFAULT now() NULL,
|
|
|
|
|
updated_date timestamp DEFAULT now() NULL,
|
|
|
|
|
CONSTRAINT table_labels_pkey PRIMARY KEY (table_name)
|
|
|
|
|
);
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### column_labels (컬럼 메타데이터 + 웹 타입)
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
CREATE TABLE column_labels (
|
|
|
|
|
id serial4 NOT NULL,
|
|
|
|
|
table_name varchar(100) NULL,
|
|
|
|
|
column_name varchar(100) NULL,
|
|
|
|
|
column_label varchar(200) NULL,
|
|
|
|
|
web_type varchar(50) NULL, -- 🎯 핵심: 웹 타입 정의
|
|
|
|
|
detail_settings text NULL, -- 🎯 핵심: 웹 타입별 상세 설정 (JSON)
|
|
|
|
|
description text NULL,
|
|
|
|
|
display_order int4 DEFAULT 0 NULL,
|
|
|
|
|
is_visible bool DEFAULT true NULL,
|
|
|
|
|
code_category varchar(100) NULL, -- 🎯 code 타입용: 공통코드 카테고리
|
|
|
|
|
code_value varchar(100) NULL,
|
|
|
|
|
reference_table varchar(100) NULL, -- 🎯 entity 타입용: 참조 테이블
|
|
|
|
|
reference_column varchar(100) NULL, -- 🎯 entity 타입용: 참조 컬럼
|
|
|
|
|
created_date timestamp DEFAULT now() NULL,
|
|
|
|
|
updated_date timestamp DEFAULT now() NULL,
|
|
|
|
|
CONSTRAINT column_labels_pkey PRIMARY KEY (id),
|
|
|
|
|
CONSTRAINT column_labels_table_name_column_name_key UNIQUE (table_name, column_name)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
-- 외래키 제약조건
|
|
|
|
|
ALTER TABLE column_labels ADD CONSTRAINT column_labels_table_name_fkey
|
|
|
|
|
FOREIGN KEY (table_name) REFERENCES table_labels(table_name);
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**권장 인덱스 추가:**
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
-- 자주 조회되는 컬럼 조합에 인덱스 추가
|
|
|
|
|
CREATE INDEX idx_column_labels_web_type ON column_labels(web_type);
|
|
|
|
|
CREATE INDEX idx_column_labels_code_category ON column_labels(code_category);
|
|
|
|
|
CREATE INDEX idx_column_labels_reference ON column_labels(reference_table, reference_column);
|
|
|
|
|
|
|
|
|
|
-- JSON 필드 검색을 위한 GIN 인덱스 (PostgreSQL)
|
|
|
|
|
CREATE INDEX idx_column_labels_detail_settings ON column_labels USING GIN (detail_settings);
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 2. 화면관리 시스템 테이블 구조
|
|
|
|
|
|
|
|
|
|
#### screen_definitions (화면 정의)
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
CREATE TABLE screen_definitions (
|
|
|
|
|
screen_id SERIAL PRIMARY KEY,
|
|
|
|
|
screen_name VARCHAR(100) NOT NULL,
|
|
|
|
|
screen_code VARCHAR(50) UNIQUE NOT NULL,
|
|
|
|
|
table_name VARCHAR(100) NOT NULL, -- 🎯 table_labels와 연계
|
2025-09-01 10:19:47 +09:00
|
|
|
company_code VARCHAR(50) NOT NULL, -- 🎯 회사 코드 (권한 관리용)
|
2025-08-29 11:46:52 +09:00
|
|
|
description TEXT,
|
|
|
|
|
is_active CHAR(1) DEFAULT 'Y',
|
|
|
|
|
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
|
created_by VARCHAR(50),
|
|
|
|
|
updated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
|
updated_by VARCHAR(50),
|
|
|
|
|
|
|
|
|
|
-- 외래키 제약조건
|
|
|
|
|
CONSTRAINT fk_screen_definitions_table_name
|
|
|
|
|
FOREIGN KEY (table_name) REFERENCES table_labels(table_name)
|
|
|
|
|
);
|
2025-09-01 10:19:47 +09:00
|
|
|
|
|
|
|
|
-- 회사 코드 인덱스 추가
|
|
|
|
|
CREATE INDEX idx_screen_definitions_company_code ON screen_definitions(company_code);
|
2025-08-29 11:46:52 +09:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### screen_layouts (화면 레이아웃)
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
CREATE TABLE screen_layouts (
|
|
|
|
|
layout_id SERIAL PRIMARY KEY,
|
|
|
|
|
screen_id INTEGER REFERENCES screen_definitions(screen_id),
|
|
|
|
|
component_type VARCHAR(50) NOT NULL, -- container, row, column, widget
|
|
|
|
|
component_id VARCHAR(100) UNIQUE NOT NULL,
|
|
|
|
|
parent_id VARCHAR(100), -- 부모 컴포넌트 ID
|
|
|
|
|
position_x INTEGER NOT NULL, -- X 좌표 (그리드)
|
|
|
|
|
position_y INTEGER NOT NULL, -- Y 좌표 (그리드)
|
|
|
|
|
width INTEGER NOT NULL, -- 너비 (그리드 컬럼 수: 1-12)
|
|
|
|
|
height INTEGER NOT NULL, -- 높이 (픽셀)
|
|
|
|
|
properties JSONB, -- 컴포넌트별 속성
|
|
|
|
|
display_order INTEGER DEFAULT 0,
|
|
|
|
|
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
|
|
|
);
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### screen_widgets (화면 위젯)
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
CREATE TABLE screen_widgets (
|
|
|
|
|
widget_id SERIAL PRIMARY KEY,
|
|
|
|
|
layout_id INTEGER REFERENCES screen_layouts(layout_id),
|
|
|
|
|
table_name VARCHAR(100) NOT NULL, -- 🎯 column_labels와 연계
|
|
|
|
|
column_name VARCHAR(100) NOT NULL, -- 🎯 column_labels와 연계
|
|
|
|
|
widget_type VARCHAR(50) NOT NULL, -- 🎯 column_labels.web_type과 동기화
|
|
|
|
|
label VARCHAR(200), -- 표시 라벨 (column_labels.column_label 사용)
|
|
|
|
|
placeholder VARCHAR(200), -- 플레이스홀더
|
|
|
|
|
is_required BOOLEAN DEFAULT FALSE,
|
|
|
|
|
is_readonly BOOLEAN DEFAULT FALSE,
|
|
|
|
|
validation_rules JSONB, -- 유효성 검증 규칙 (column_labels.detail_settings에서 자동 생성)
|
|
|
|
|
display_properties JSONB, -- 표시 속성 (column_labels.detail_settings에서 자동 생성)
|
|
|
|
|
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
|
|
|
|
|
|
-- 외래키 제약조건
|
|
|
|
|
CONSTRAINT fk_screen_widgets_column_labels
|
|
|
|
|
FOREIGN KEY (table_name, column_name) REFERENCES column_labels(table_name, column_name)
|
|
|
|
|
);
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### screen_templates (화면 템플릿)
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
CREATE TABLE screen_templates (
|
|
|
|
|
template_id SERIAL PRIMARY KEY,
|
|
|
|
|
template_name VARCHAR(100) NOT NULL,
|
|
|
|
|
template_type VARCHAR(50) NOT NULL, -- CRUD, LIST, DETAIL 등
|
2025-09-01 10:19:47 +09:00
|
|
|
company_code VARCHAR(50) NOT NULL, -- 🎯 회사 코드 (권한 관리용)
|
2025-08-29 11:46:52 +09:00
|
|
|
description TEXT,
|
|
|
|
|
layout_data JSONB, -- 레이아웃 데이터
|
|
|
|
|
is_public BOOLEAN DEFAULT FALSE, -- 공개 여부
|
|
|
|
|
created_by VARCHAR(50),
|
|
|
|
|
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
|
|
|
);
|
2025-09-01 10:19:47 +09:00
|
|
|
|
|
|
|
|
-- 회사 코드 인덱스 추가
|
|
|
|
|
CREATE INDEX idx_screen_templates_company_code ON screen_templates(company_code);
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### screen_menu_assignments (화면-메뉴 할당)
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
CREATE TABLE screen_menu_assignments (
|
|
|
|
|
assignment_id SERIAL PRIMARY KEY,
|
|
|
|
|
screen_id INTEGER NOT NULL,
|
|
|
|
|
menu_id INTEGER NOT NULL,
|
|
|
|
|
company_code VARCHAR(50) NOT NULL, -- 🎯 회사 코드 (권한 관리용)
|
|
|
|
|
display_order INTEGER DEFAULT 0,
|
|
|
|
|
is_active CHAR(1) DEFAULT 'Y',
|
|
|
|
|
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
|
created_by VARCHAR(50),
|
|
|
|
|
|
|
|
|
|
-- 외래키 제약조건
|
|
|
|
|
CONSTRAINT fk_screen_menu_assignments_screen_id
|
|
|
|
|
FOREIGN KEY (screen_id) REFERENCES screen_definitions(screen_id),
|
|
|
|
|
CONSTRAINT fk_screen_menu_assignments_menu_id
|
|
|
|
|
FOREIGN KEY (menu_id) REFERENCES menu_info(menu_id),
|
|
|
|
|
|
|
|
|
|
-- 유니크 제약조건 (한 메뉴에 같은 화면 중복 할당 방지)
|
|
|
|
|
CONSTRAINT uk_screen_menu_company UNIQUE (screen_id, menu_id, company_code)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
-- 회사 코드 인덱스 추가
|
|
|
|
|
CREATE INDEX idx_screen_menu_assignments_company_code ON screen_menu_assignments(company_code);
|
2025-08-29 11:46:52 +09:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 3. 테이블 간 연계 관계
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
table_labels (테이블 메타데이터)
|
|
|
|
|
↓ (1:N)
|
|
|
|
|
column_labels (컬럼 메타데이터 + 웹 타입)
|
|
|
|
|
↓ (1:N)
|
|
|
|
|
screen_definitions (화면 정의)
|
|
|
|
|
↓ (1:N)
|
|
|
|
|
screen_layouts (화면 레이아웃)
|
|
|
|
|
↓ (1:N)
|
|
|
|
|
screen_widgets (화면 위젯)
|
2025-09-01 10:19:47 +09:00
|
|
|
↓ (1:N)
|
|
|
|
|
screen_menu_assignments (화면-메뉴 할당)
|
2025-08-29 11:46:52 +09:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**핵심 연계 포인트:**
|
|
|
|
|
|
|
|
|
|
- ✅ `screen_definitions.table_name` ↔ `table_labels.table_name`
|
|
|
|
|
- ✅ `screen_widgets.table_name, column_name` ↔ `column_labels.table_name, column_name`
|
|
|
|
|
- ✅ `screen_widgets.widget_type` ↔ `column_labels.web_type` (자동 동기화)
|
2025-09-01 10:19:47 +09:00
|
|
|
- ✅ `screen_definitions.company_code` ↔ 사용자 회사 코드 (권한 관리)
|
|
|
|
|
- ✅ `screen_menu_assignments.company_code` ↔ 메뉴 회사 코드 (메뉴 할당 제한)
|
2025-08-29 11:46:52 +09:00
|
|
|
|
|
|
|
|
## 🎨 화면 구성 요소
|
|
|
|
|
|
|
|
|
|
### 1. 레이아웃 컴포넌트
|
|
|
|
|
|
|
|
|
|
#### Container (컨테이너)
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
interface ContainerProps {
|
|
|
|
|
id: string;
|
|
|
|
|
type: "container";
|
|
|
|
|
width: number; // 1-12
|
|
|
|
|
height: number;
|
|
|
|
|
padding: number;
|
|
|
|
|
margin: number;
|
|
|
|
|
backgroundColor?: string;
|
|
|
|
|
border?: string;
|
2025-09-01 10:19:47 +09:00
|
|
|
borderRadius?: number;
|
|
|
|
|
shadow?: string;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### Group (그룹)
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
interface GroupProps {
|
|
|
|
|
id: string;
|
|
|
|
|
type: "group";
|
|
|
|
|
title?: string;
|
|
|
|
|
width: number; // 1-12
|
|
|
|
|
height: number;
|
|
|
|
|
padding: number;
|
|
|
|
|
margin: number;
|
|
|
|
|
backgroundColor?: string;
|
|
|
|
|
border?: string;
|
|
|
|
|
borderRadius?: number;
|
|
|
|
|
shadow?: string;
|
|
|
|
|
collapsible?: boolean; // 접을 수 있는 그룹
|
|
|
|
|
collapsed?: boolean; // 접힌 상태
|
|
|
|
|
children: string[]; // 포함된 컴포넌트 ID 목록
|
2025-08-29 11:46:52 +09:00
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### Row (행)
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
interface RowProps {
|
|
|
|
|
id: string;
|
|
|
|
|
type: "row";
|
|
|
|
|
columns: number; // 1-12
|
|
|
|
|
gap: number;
|
|
|
|
|
alignItems: "start" | "center" | "end";
|
|
|
|
|
justifyContent: "start" | "center" | "end" | "space-between";
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### Column (컬럼)
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
interface ColumnProps {
|
|
|
|
|
id: string;
|
|
|
|
|
type: "column";
|
|
|
|
|
width: number; // 1-12
|
|
|
|
|
offset: number; // 오프셋
|
|
|
|
|
order: number; // 순서
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 2. 입력 위젯
|
|
|
|
|
|
|
|
|
|
#### Text Input
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
interface TextInputProps {
|
|
|
|
|
id: string;
|
|
|
|
|
type: "text";
|
|
|
|
|
label: string;
|
|
|
|
|
placeholder?: string;
|
|
|
|
|
required: boolean;
|
|
|
|
|
maxLength?: number;
|
|
|
|
|
pattern?: string;
|
|
|
|
|
columnName: string; // 연결된 테이블 컬럼
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### Select Dropdown
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
interface SelectProps {
|
|
|
|
|
id: string;
|
|
|
|
|
type: "select";
|
|
|
|
|
label: string;
|
|
|
|
|
options: Array<{ value: string; label: string }>;
|
|
|
|
|
multiple: boolean;
|
|
|
|
|
searchable: boolean;
|
|
|
|
|
columnName: string;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### Date Picker
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
interface DatePickerProps {
|
|
|
|
|
id: string;
|
|
|
|
|
type: "date";
|
|
|
|
|
label: string;
|
|
|
|
|
format: string; // YYYY-MM-DD, MM/DD/YYYY 등
|
|
|
|
|
minDate?: string;
|
|
|
|
|
maxDate?: string;
|
|
|
|
|
columnName: string;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 3. 표시 위젯
|
|
|
|
|
|
|
|
|
|
#### Label
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
interface LabelProps {
|
|
|
|
|
id: string;
|
|
|
|
|
type: "label";
|
|
|
|
|
text: string;
|
|
|
|
|
fontSize: number;
|
|
|
|
|
fontWeight: "normal" | "bold";
|
|
|
|
|
color: string;
|
|
|
|
|
alignment: "left" | "center" | "right";
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### Display Field
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
interface DisplayFieldProps {
|
|
|
|
|
id: string;
|
|
|
|
|
type: "display";
|
|
|
|
|
label: string;
|
|
|
|
|
columnName: string;
|
|
|
|
|
format?: string; // 날짜, 숫자 포맷
|
|
|
|
|
alignment: "left" | "center" | "right";
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## 🖱️ 드래그앤드롭 설계
|
|
|
|
|
|
|
|
|
|
### 1. 드래그앤드롭 아키텍처
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// 드래그 상태 관리
|
|
|
|
|
interface DragState {
|
|
|
|
|
isDragging: boolean;
|
|
|
|
|
draggedItem: ComponentData | null;
|
|
|
|
|
dragSource: "toolbox" | "canvas";
|
|
|
|
|
dropTarget: string | null;
|
2025-09-01 10:19:47 +09:00
|
|
|
dropZone?: DropZone; // 드롭 가능한 영역 정보
|
2025-08-29 11:46:52 +09:00
|
|
|
}
|
|
|
|
|
|
2025-09-01 10:19:47 +09:00
|
|
|
// 그룹화 상태 관리
|
|
|
|
|
interface GroupState {
|
|
|
|
|
isGrouping: boolean;
|
|
|
|
|
selectedComponents: string[];
|
|
|
|
|
groupTarget: string | null;
|
|
|
|
|
groupMode: "create" | "add" | "remove";
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
2025-08-29 11:46:52 +09:00
|
|
|
// 드롭 영역 정의
|
|
|
|
|
interface DropZone {
|
2025-09-01 10:19:47 +09:00
|
|
|
id: string;
|
|
|
|
|
accepts: string[]; // 허용되는 컴포넌트 타입
|
|
|
|
|
position: { x: number; y: number };
|
|
|
|
|
size: { width: number; height: number };
|
2025-08-29 11:46:52 +09:00
|
|
|
}
|
2025-09-01 10:19:47 +09:00
|
|
|
|
|
|
|
|
````
|
2025-08-29 11:46:52 +09:00
|
|
|
|
|
|
|
|
### 2. 컴포넌트 배치 로직
|
|
|
|
|
|
2025-09-01 17:05:36 +09:00
|
|
|
현재 배치 로직은 **픽셀 기반 자유 위치**로 동작합니다. 마우스 그랩 오프셋과 스크롤 오프셋을 반영하여 커서를 정확히 추적합니다. 아래 그리드 기반 예시는 참고용이며, 실제 런타임에서는 스냅을 적용하지 않습니다.
|
|
|
|
|
|
2025-08-29 11:46:52 +09:00
|
|
|
```typescript
|
|
|
|
|
// 그리드 기반 배치
|
|
|
|
|
function calculateGridPosition(
|
|
|
|
|
mouseX: number,
|
|
|
|
|
mouseY: number,
|
|
|
|
|
gridSize: number
|
|
|
|
|
): GridPosition {
|
|
|
|
|
return {
|
|
|
|
|
x: Math.floor(mouseX / gridSize),
|
|
|
|
|
y: Math.floor(mouseY / gridSize),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 컴포넌트 크기 조정
|
|
|
|
|
function resizeComponent(
|
|
|
|
|
component: ComponentData,
|
|
|
|
|
newWidth: number,
|
|
|
|
|
newHeight: number
|
|
|
|
|
): ComponentData {
|
|
|
|
|
return {
|
|
|
|
|
...component,
|
|
|
|
|
width: Math.max(1, Math.min(12, newWidth)),
|
|
|
|
|
height: Math.max(50, newHeight),
|
|
|
|
|
};
|
|
|
|
|
}
|
2025-09-01 10:19:47 +09:00
|
|
|
````
|
2025-08-29 11:46:52 +09:00
|
|
|
|
|
|
|
|
### 3. 실시간 미리보기
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// 캔버스 상태를 실제 컴포넌트로 변환
|
|
|
|
|
function generatePreview(layout: LayoutData): React.ReactElement {
|
|
|
|
|
return (
|
|
|
|
|
<div className="screen-preview">
|
|
|
|
|
{layout.components.map((component) => renderComponent(component))}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-01 10:19:47 +09:00
|
|
|
// 그룹 컴포넌트 렌더링
|
|
|
|
|
function renderGroupComponent(
|
|
|
|
|
group: GroupProps,
|
|
|
|
|
components: ComponentData[]
|
|
|
|
|
): React.ReactElement {
|
|
|
|
|
const groupChildren = components.filter((c) => group.children.includes(c.id));
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="component-group" style={getGroupStyles(group)}>
|
|
|
|
|
{group.title && <div className="group-title">{group.title}</div>}
|
|
|
|
|
<div className="group-content">
|
|
|
|
|
{groupChildren.map((component) => renderComponent(component))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
2025-08-29 11:46:52 +09:00
|
|
|
// 컴포넌트 렌더링
|
|
|
|
|
function renderComponent(component: ComponentData): React.ReactElement {
|
2025-09-01 10:19:47 +09:00
|
|
|
switch (component.type) {
|
|
|
|
|
case "text":
|
|
|
|
|
return <TextInput {...component.props} />;
|
|
|
|
|
case "select":
|
|
|
|
|
return <Select {...component.props} />;
|
|
|
|
|
case "date":
|
|
|
|
|
return <DatePicker {...component.props} />;
|
|
|
|
|
default:
|
|
|
|
|
return <div>Unknown component</div>;
|
2025-08-29 11:46:52 +09:00
|
|
|
}
|
2025-09-01 10:19:47 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
````
|
2025-08-29 11:46:52 +09:00
|
|
|
|
2025-09-01 17:57:52 +09:00
|
|
|
### 4. 복사/삭제/붙여넣기 규칙 (구현 완료)
|
|
|
|
|
|
|
|
|
|
- 대상 범위
|
|
|
|
|
- 단일 선택: 선택된 1개 컴포넌트에 대해 복사/삭제/붙여넣기 지원
|
|
|
|
|
- 다중 선택: Shift+클릭 또는 마키 선택으로 선택된 여러 컴포넌트 일괄 복사/삭제/붙여넣기 지원
|
|
|
|
|
- 그룹 선택: 그룹과 모든 자식 컴포넌트가 하나의 덩어리로 동작
|
|
|
|
|
|
|
|
|
|
- 동작 규칙
|
|
|
|
|
- 복사 시 선택된 컴포넌트들의 바운딩 박스를 계산하여 상대 좌표 유지
|
|
|
|
|
- 붙여넣기 시 새 ID로 재생성하고 부모-자식(parentId) 관계 보존
|
|
|
|
|
- 기본 붙여넣기 위치는 원본 바운딩 박스 + 오프셋(+20px, +20px)
|
|
|
|
|
- 캔버스 우클릭 시 해당 좌표로 붙여넣기 수행(정확 위치 지정)
|
|
|
|
|
|
|
|
|
|
- UI/피드백
|
|
|
|
|
- 상단 툴바에 복사/삭제/붙여넣기 버튼 제공(선택 상황에 따라 표시/활성화)
|
|
|
|
|
- 클립보드 상태 배지 표시: 단일(“컴포넌트 복사됨”), 다중(“N개 복사됨”), 그룹(“그룹 복사됨”)
|
|
|
|
|
|
|
|
|
|
- 단축키
|
|
|
|
|
- 복사: Ctrl/Cmd + C
|
|
|
|
|
- 붙여넣기: Ctrl/Cmd + V
|
|
|
|
|
- 삭제: Delete 또는 Backspace
|
|
|
|
|
- 실행 취소/다시 실행: Ctrl/Cmd + Z, Ctrl/Cmd + Y
|
|
|
|
|
|
|
|
|
|
- 예외 처리
|
|
|
|
|
- 선택 없음 상태에서 복사/삭제는 무시
|
|
|
|
|
- 클립보드가 비어있는 경우 붙여넣기 무시
|
|
|
|
|
|
2025-08-29 11:46:52 +09:00
|
|
|
## 🔗 테이블 타입 연계
|
|
|
|
|
|
|
|
|
|
### 1. 웹 타입 설정 방법
|
|
|
|
|
|
|
|
|
|
#### 테이블 타입관리에서 웹 타입 설정
|
|
|
|
|
|
|
|
|
|
**현재 테이블 구조 기반 설정:**
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// 컬럼별 웹 타입 설정 인터페이스 (현재 테이블 구조와 완벽 일치)
|
|
|
|
|
interface ColumnWebTypeSetting {
|
|
|
|
|
tableName: string; // column_labels.table_name
|
|
|
|
|
columnName: string; // column_labels.column_name
|
|
|
|
|
webType: WebType; // column_labels.web_type
|
|
|
|
|
columnLabel?: string; // column_labels.column_label
|
|
|
|
|
detailSettings?: string; // column_labels.detail_settings (JSON)
|
|
|
|
|
codeCategory?: string; // column_labels.code_category
|
|
|
|
|
referenceTable?: string; // column_labels.reference_table
|
|
|
|
|
referenceColumn?: string; // column_labels.reference_column
|
|
|
|
|
isVisible?: boolean; // column_labels.is_visible
|
|
|
|
|
displayOrder?: number; // column_labels.display_order
|
|
|
|
|
description?: string; // column_labels.description
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 웹 타입 설정 API (column_labels 테이블에 직접 저장)
|
|
|
|
|
async function setColumnWebType(setting: ColumnWebTypeSetting): Promise<void> {
|
|
|
|
|
await api.post("/table-management/columns/web-type", setting);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 웹 타입 조회 API (column_labels 테이블에서 직접 조회)
|
|
|
|
|
async function getColumnWebType(
|
|
|
|
|
tableName: string,
|
|
|
|
|
columnName: string
|
|
|
|
|
): Promise<ColumnWebTypeSetting> {
|
|
|
|
|
return api.get(
|
|
|
|
|
`/table-management/columns/${tableName}/${columnName}/web-type`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 테이블의 모든 컬럼 웹 타입 조회
|
|
|
|
|
async function getTableColumnWebTypes(
|
|
|
|
|
tableName: string
|
|
|
|
|
): Promise<ColumnWebTypeSetting[]> {
|
|
|
|
|
return api.get(`/table-management/tables/${tableName}/columns/web-types`);
|
|
|
|
|
}
|
2025-09-01 10:19:47 +09:00
|
|
|
````
|
2025-08-29 11:46:52 +09:00
|
|
|
|
|
|
|
|
#### 웹 타입별 추가 설정 (현재 테이블 구조 기반)
|
|
|
|
|
|
|
|
|
|
**`column_labels.detail_settings` 필드에 JSON 형태로 저장:**
|
|
|
|
|
|
|
|
|
|
| 웹 타입 | detail_settings JSON 구조 | 설명 |
|
|
|
|
|
| ------------ | --------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
|
|
|
|
| **text** | `{"maxLength": 255, "pattern": "regex"}` | 최대 길이, 정규식 패턴 |
|
|
|
|
|
| **number** | `{"min": 0, "max": 999, "step": 1}` | 최소값, 최대값, 증감 단위 |
|
|
|
|
|
| **date** | `{"format": "YYYY-MM-DD", "minDate": "", "maxDate": ""}` | 날짜 형식, 범위 제한 |
|
|
|
|
|
| **code** | `{"codeCategory": "USER_STATUS"}` | 공통코드 카테고리 (code_category 필드와 연계) |
|
|
|
|
|
| **entity** | `{"referenceTable": "users", "referenceColumn": "id"}` | 참조 테이블 및 컬럼 (reference_table, reference_column 필드와 연계) |
|
|
|
|
|
| **textarea** | `{"rows": 3, "maxLength": 1000}` | 행 수, 최대 길이 |
|
|
|
|
|
| **select** | `{"options": [{"value": "Y", "label": "예"}, {"value": "N", "label": "아니오"}]}` | 옵션 목록, 다중 선택 |
|
|
|
|
|
| **checkbox** | `{"defaultChecked": false, "label": "동의함"}` | 기본 체크 상태, 라벨 |
|
|
|
|
|
| **radio** | `{"options": [{"value": "M", "label": "남성"}, {"value": "F", "label": "여성"}]}` | 라디오 옵션 목록 |
|
|
|
|
|
| **file** | `{"accept": ".jpg,.png,.pdf", "maxSize": 10485760}` | 허용 파일 형식, 최대 크기 |
|
|
|
|
|
|
|
|
|
|
**실제 데이터베이스 저장 예시:**
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
-- text 타입 컬럼 설정
|
|
|
|
|
UPDATE column_labels
|
|
|
|
|
SET detail_settings = '{"maxLength": 255, "pattern": "^[a-zA-Z0-9가-힣]+$"}'
|
|
|
|
|
WHERE table_name = 'user_info' AND column_name = 'user_name';
|
|
|
|
|
|
|
|
|
|
-- code 타입 컬럼 설정 (code_category와 연계)
|
|
|
|
|
UPDATE column_labels
|
|
|
|
|
SET web_type = 'code',
|
|
|
|
|
code_category = 'USER_STATUS',
|
|
|
|
|
detail_settings = '{"displayFormat": "label"}'
|
|
|
|
|
WHERE table_name = 'user_info' AND column_name = 'status';
|
|
|
|
|
|
|
|
|
|
-- entity 타입 컬럼 설정 (reference_table, reference_column과 연계)
|
|
|
|
|
UPDATE column_labels
|
|
|
|
|
SET web_type = 'entity',
|
|
|
|
|
reference_table = 'dept_info',
|
|
|
|
|
reference_column = 'dept_code',
|
|
|
|
|
detail_settings = '{"searchable": true, "multiple": false}'
|
|
|
|
|
WHERE table_name = 'user_info' AND column_name = 'dept_code';
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 2. 자동 위젯 생성
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// 컬럼 정보를 기반으로 위젯 자동 생성 (현재 테이블 구조 기반)
|
|
|
|
|
function generateWidgetFromColumn(column: ColumnInfo): WidgetData {
|
|
|
|
|
const baseWidget = {
|
|
|
|
|
id: generateId(),
|
|
|
|
|
tableName: column.table_name, // 🎯 column_labels.table_name
|
|
|
|
|
columnName: column.column_name, // 🎯 column_labels.column_name
|
|
|
|
|
label: column.column_label || column.column_name, // 🎯 column_labels.column_label
|
|
|
|
|
required: column.is_nullable === "N",
|
|
|
|
|
readonly: false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// detail_settings JSON 파싱
|
|
|
|
|
const detailSettings = column.detail_settings
|
|
|
|
|
? JSON.parse(column.detail_settings)
|
|
|
|
|
: {};
|
|
|
|
|
|
|
|
|
|
switch (column.web_type) {
|
|
|
|
|
case "text":
|
|
|
|
|
return {
|
|
|
|
|
...baseWidget,
|
|
|
|
|
type: "text",
|
|
|
|
|
maxLength: detailSettings.maxLength || column.character_maximum_length,
|
|
|
|
|
placeholder: `Enter ${column.column_label || column.column_name}`,
|
|
|
|
|
pattern: detailSettings.pattern,
|
|
|
|
|
validationRules: {
|
|
|
|
|
maxLength: detailSettings.maxLength,
|
|
|
|
|
pattern: detailSettings.pattern,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
case "number":
|
|
|
|
|
return {
|
|
|
|
|
...baseWidget,
|
|
|
|
|
type: "number",
|
|
|
|
|
min: detailSettings.min,
|
|
|
|
|
max:
|
|
|
|
|
detailSettings.max ||
|
|
|
|
|
(column.numeric_precision
|
|
|
|
|
? Math.pow(10, column.numeric_precision) - 1
|
|
|
|
|
: undefined),
|
|
|
|
|
step:
|
|
|
|
|
detailSettings.step ||
|
|
|
|
|
(column.numeric_scale > 0 ? Math.pow(10, -column.numeric_scale) : 1),
|
|
|
|
|
validationRules: {
|
|
|
|
|
min: detailSettings.min,
|
|
|
|
|
max: detailSettings.max,
|
|
|
|
|
step: detailSettings.step,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
case "date":
|
|
|
|
|
return {
|
|
|
|
|
...baseWidget,
|
|
|
|
|
type: "date",
|
|
|
|
|
format: detailSettings.format || "YYYY-MM-DD",
|
|
|
|
|
minDate: detailSettings.minDate,
|
|
|
|
|
maxDate: detailSettings.maxDate,
|
|
|
|
|
validationRules: {
|
|
|
|
|
minDate: detailSettings.minDate,
|
|
|
|
|
maxDate: detailSettings.maxDate,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
case "code":
|
|
|
|
|
return {
|
|
|
|
|
...baseWidget,
|
|
|
|
|
type: "select",
|
|
|
|
|
options: [], // 코드 카테고리에서 로드
|
|
|
|
|
codeCategory: column.code_category, // 🎯 column_labels.code_category
|
|
|
|
|
multiple: detailSettings.multiple || false,
|
|
|
|
|
searchable: detailSettings.searchable || false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
case "entity":
|
|
|
|
|
return {
|
|
|
|
|
...baseWidget,
|
|
|
|
|
type: "entity-select",
|
|
|
|
|
referenceTable: column.reference_table, // 🎯 column_labels.reference_table
|
|
|
|
|
referenceColumn: column.reference_column, // 🎯 column_labels.reference_column
|
|
|
|
|
searchable: detailSettings.searchable || true,
|
|
|
|
|
multiple: detailSettings.multiple || false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
case "textarea":
|
|
|
|
|
return {
|
|
|
|
|
...baseWidget,
|
|
|
|
|
type: "textarea",
|
|
|
|
|
rows: detailSettings.rows || 3,
|
|
|
|
|
maxLength: detailSettings.maxLength || column.character_maximum_length,
|
|
|
|
|
validationRules: {
|
|
|
|
|
maxLength: detailSettings.maxLength,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
case "select":
|
|
|
|
|
return {
|
|
|
|
|
...baseWidget,
|
|
|
|
|
type: "select",
|
|
|
|
|
options: detailSettings.options || [],
|
|
|
|
|
multiple: detailSettings.multiple || false,
|
|
|
|
|
searchable: detailSettings.searchable || false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
case "checkbox":
|
|
|
|
|
return {
|
|
|
|
|
...baseWidget,
|
|
|
|
|
type: "checkbox",
|
|
|
|
|
defaultChecked: detailSettings.defaultChecked || false,
|
|
|
|
|
label: detailSettings.label || column.column_label,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
case "radio":
|
|
|
|
|
return {
|
|
|
|
|
...baseWidget,
|
|
|
|
|
type: "radio",
|
|
|
|
|
options: detailSettings.options || [],
|
|
|
|
|
inline: detailSettings.inline || false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
case "file":
|
|
|
|
|
return {
|
|
|
|
|
...baseWidget,
|
|
|
|
|
type: "file",
|
|
|
|
|
accept: detailSettings.accept || "*/*",
|
|
|
|
|
maxSize: detailSettings.maxSize || 10485760, // 10MB
|
|
|
|
|
multiple: detailSettings.multiple || false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
return {
|
|
|
|
|
...baseWidget,
|
|
|
|
|
type: "text",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 2. 데이터 바인딩
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// 위젯과 컬럼 연결
|
|
|
|
|
interface DataBinding {
|
|
|
|
|
widgetId: string;
|
|
|
|
|
columnName: string;
|
|
|
|
|
tableName: string;
|
|
|
|
|
bindingType: "input" | "output" | "bidirectional";
|
|
|
|
|
transformFunction?: string; // 데이터 변환 함수
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 바인딩 정보 저장
|
|
|
|
|
function saveDataBinding(binding: DataBinding): Promise<void> {
|
|
|
|
|
return api.post("/screen-management/bindings", binding);
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 3. 유효성 검증 규칙
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// 컬럼 설정에 따른 자동 검증 규칙
|
|
|
|
|
function generateValidationRules(column: ColumnInfo): ValidationRule[] {
|
|
|
|
|
const rules: ValidationRule[] = [];
|
|
|
|
|
|
|
|
|
|
// 필수 입력 검증
|
|
|
|
|
if (column.isNullable === "N") {
|
|
|
|
|
rules.push({
|
|
|
|
|
type: "required",
|
|
|
|
|
message: `${column.displayName}은(는) 필수 입력 항목입니다.`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 길이 제한 검증
|
|
|
|
|
if (column.maxLength) {
|
|
|
|
|
rules.push({
|
|
|
|
|
type: "maxLength",
|
|
|
|
|
value: column.maxLength,
|
|
|
|
|
message: `${column.displayName}은(는) ${column.maxLength}자 이하여야 합니다.`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 숫자 범위 검증
|
|
|
|
|
if (column.webType === "number") {
|
|
|
|
|
if (column.numericPrecision && column.numericScale) {
|
|
|
|
|
const maxValue =
|
|
|
|
|
Math.pow(10, column.numericPrecision - column.numericScale) - 1;
|
|
|
|
|
rules.push({
|
|
|
|
|
type: "max",
|
|
|
|
|
value: maxValue,
|
|
|
|
|
message: `${column.displayName}은(는) ${maxValue} 이하여야 합니다.`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return rules;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
2025-09-05 10:27:10 +09:00
|
|
|
## 🎯 메뉴 할당 시스템 (신규 완성)
|
|
|
|
|
|
|
|
|
|
### 1. 화면 저장 후 메뉴 할당 워크플로우
|
|
|
|
|
|
|
|
|
|
#### 전체 프로세스
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
화면 설계 완료 → 저장 버튼 클릭 → 메뉴 할당 모달 자동 팝업
|
|
|
|
|
↓
|
|
|
|
|
메뉴 선택 및 할당 OR "나중에 할당" 클릭
|
|
|
|
|
↓
|
|
|
|
|
성공 화면 표시 (3초간 시각적 피드백)
|
|
|
|
|
↓
|
|
|
|
|
자동으로 화면 목록 페이지로 이동
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### 메뉴 할당 모달 (MenuAssignmentModal)
|
|
|
|
|
|
|
|
|
|
**주요 기능:**
|
|
|
|
|
|
|
|
|
|
1. **관리자 메뉴만 표시**: 화면관리는 관리자 전용 기능이므로 관리자 메뉴(`menuType: "0"`)만 로드
|
|
|
|
|
2. **셀렉트박스 내부 검색**: 메뉴명, URL, 설명으로 실시간 검색 가능
|
|
|
|
|
3. **기존 화면 감지**: 선택한 메뉴에 이미 할당된 화면이 있는지 자동 확인
|
|
|
|
|
4. **화면 교체 확인**: 기존 화면이 있을 때 교체 확인 대화상자 표시
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
interface MenuAssignmentModalProps {
|
|
|
|
|
isOpen: boolean;
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
screenInfo: ScreenDefinition | null;
|
|
|
|
|
onAssignmentComplete?: () => void;
|
|
|
|
|
onBackToList?: () => void; // 화면 목록으로 돌아가는 콜백
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 2. 메뉴 검색 시스템
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// 셀렉트박스 내부 검색 구현
|
|
|
|
|
<SelectContent className="max-h-64">
|
|
|
|
|
{/* 검색 입력 필드 */}
|
|
|
|
|
<div className="sticky top-0 z-10 border-b bg-white p-2">
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
|
|
|
|
|
<Input
|
|
|
|
|
placeholder="메뉴명, URL, 설명으로 검색..."
|
|
|
|
|
value={searchTerm}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
e.stopPropagation(); // 이벤트 전파 방지
|
|
|
|
|
setSearchTerm(e.target.value);
|
|
|
|
|
}}
|
|
|
|
|
onKeyDown={(e) => e.stopPropagation()}
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
className="h-8 pr-8 pl-10 text-sm"
|
|
|
|
|
/>
|
|
|
|
|
{searchTerm && (
|
|
|
|
|
<button onClick={() => setSearchTerm("")}>
|
|
|
|
|
<X className="h-3 w-3" />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{/* 메뉴 옵션들 */}
|
|
|
|
|
<div className="max-h-48 overflow-y-auto">{getMenuOptions()}</div>
|
|
|
|
|
</SelectContent>
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 3. 기존 화면 감지 및 교체 시스템
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// 메뉴 선택 시 기존 할당된 화면 확인
|
|
|
|
|
const handleMenuSelect = async (menuId: string) => {
|
|
|
|
|
const menu = menus.find((m) => m.objid?.toString() === menuId);
|
|
|
|
|
setSelectedMenu(menu || null);
|
|
|
|
|
|
|
|
|
|
if (menu) {
|
|
|
|
|
try {
|
|
|
|
|
const menuObjid = parseInt(menu.objid?.toString() || "0");
|
|
|
|
|
const screens = await menuScreenApi.getScreensByMenu(menuObjid);
|
|
|
|
|
setExistingScreens(screens);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("할당된 화면 조회 실패:", error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 할당 시 기존 화면 확인
|
|
|
|
|
const handleAssignScreen = async () => {
|
|
|
|
|
if (existingScreens.length > 0) {
|
|
|
|
|
// 이미 같은 화면이 할당되어 있는지 확인
|
|
|
|
|
const alreadyAssigned = existingScreens.some(
|
|
|
|
|
(screen) => screen.screenId === screenInfo.screenId
|
|
|
|
|
);
|
|
|
|
|
if (alreadyAssigned) {
|
|
|
|
|
toast.info("이미 해당 메뉴에 할당된 화면입니다.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 다른 화면이 할당되어 있으면 교체 확인
|
|
|
|
|
setShowReplaceDialog(true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 기존 화면이 없으면 바로 할당
|
|
|
|
|
await performAssignment();
|
|
|
|
|
};
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 4. 화면 교체 확인 대화상자
|
|
|
|
|
|
|
|
|
|
**시각적 구분:**
|
|
|
|
|
|
|
|
|
|
- 🔴 **제거될 화면**: 빨간색 배경으로 표시
|
|
|
|
|
- 🟢 **새로 할당될 화면**: 초록색 배경으로 표시
|
|
|
|
|
- 🟠 **주의 메시지**: 작업이 되돌릴 수 없음을 명확히 안내
|
|
|
|
|
|
|
|
|
|
**안전한 교체 프로세스:**
|
|
|
|
|
|
|
|
|
|
1. 기존 화면들을 하나씩 제거
|
|
|
|
|
2. 새 화면 할당
|
|
|
|
|
3. 성공/실패 로그 출력
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// 기존 화면 교체인 경우 기존 화면들 먼저 제거
|
|
|
|
|
if (replaceExisting && existingScreens.length > 0) {
|
|
|
|
|
for (const existingScreen of existingScreens) {
|
|
|
|
|
try {
|
|
|
|
|
await menuScreenApi.unassignScreenFromMenu(
|
|
|
|
|
existingScreen.screenId,
|
|
|
|
|
menuObjid
|
|
|
|
|
);
|
|
|
|
|
console.log(`기존 화면 "${existingScreen.screenName}" 제거 완료`);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error(
|
|
|
|
|
`기존 화면 "${existingScreen.screenName}" 제거 실패:`,
|
|
|
|
|
error
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 새 화면 할당
|
|
|
|
|
await menuScreenApi.assignScreenToMenu(screenInfo.screenId, menuObjid);
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 5. 성공 피드백 및 자동 이동
|
|
|
|
|
|
|
|
|
|
**성공 화면 구성:**
|
|
|
|
|
|
|
|
|
|
- ✅ **체크마크 아이콘**: 성공을 나타내는 녹색 체크마크
|
|
|
|
|
- 🎯 **성공 메시지**: 구체적인 할당 완료 메시지
|
|
|
|
|
- ⏱️ **자동 이동 안내**: "3초 후 자동으로 화면 목록으로 이동합니다..."
|
|
|
|
|
- 🔵 **로딩 애니메이션**: 3개의 점이 순차적으로 바운스하는 애니메이션
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// 성공 상태 설정
|
|
|
|
|
setAssignmentSuccess(true);
|
|
|
|
|
setAssignmentMessage(successMessage);
|
|
|
|
|
|
|
|
|
|
// 3초 후 자동으로 화면 목록으로 이동
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
if (onBackToList) {
|
|
|
|
|
onBackToList();
|
|
|
|
|
} else {
|
|
|
|
|
onClose();
|
|
|
|
|
}
|
|
|
|
|
}, 3000);
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**성공 화면 UI:**
|
|
|
|
|
|
|
|
|
|
```jsx
|
|
|
|
|
{assignmentSuccess ? (
|
|
|
|
|
// 성공 화면
|
|
|
|
|
<>
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle className="flex items-center gap-2">
|
|
|
|
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-100">
|
|
|
|
|
<svg className="h-5 w-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
|
|
|
</svg>
|
|
|
|
|
</div>
|
|
|
|
|
화면 할당 완료
|
|
|
|
|
</DialogTitle>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<div className="rounded-lg border bg-green-50 p-4">
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-100">
|
|
|
|
|
<Monitor className="h-5 w-5 text-green-600" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
<p className="text-sm font-medium text-green-900">{assignmentMessage}</p>
|
|
|
|
|
<p className="mt-1 text-xs text-green-700">
|
|
|
|
|
3초 후 자동으로 화면 목록으로 이동합니다...
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 로딩 애니메이션 */}
|
|
|
|
|
<div className="flex items-center justify-center space-x-2">
|
|
|
|
|
<div className="h-2 w-2 animate-bounce rounded-full bg-green-500 [animation-delay:-0.3s]"></div>
|
|
|
|
|
<div className="h-2 w-2 animate-bounce rounded-full bg-green-500 [animation-delay:-0.15s]"></div>
|
|
|
|
|
<div className="h-2 w-2 animate-bounce rounded-full bg-green-500"></div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
// 기본 할당 화면
|
|
|
|
|
// ...
|
|
|
|
|
)}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 6. 사용자 경험 개선사항
|
|
|
|
|
|
|
|
|
|
1. **선택적 할당**: 필수가 아닌 선택적 기능으로 "나중에 할당" 가능
|
|
|
|
|
2. **직관적 UI**: 저장된 화면 정보를 모달에서 바로 확인 가능
|
|
|
|
|
3. **검색 기능**: 많은 메뉴 중에서 쉽게 찾을 수 있음
|
|
|
|
|
4. **상태 표시**: 메뉴 활성/비활성 상태, 기존 할당된 화면 정보 표시
|
|
|
|
|
5. **완전한 워크플로우**: 저장 → 할당 → 목록 복귀의 자연스러운 흐름
|
|
|
|
|
|
2025-08-29 11:46:52 +09:00
|
|
|
## 🌐 API 설계
|
|
|
|
|
|
2025-09-05 10:27:10 +09:00
|
|
|
### 1. 메뉴-화면 할당 API
|
|
|
|
|
|
|
|
|
|
#### 화면을 메뉴에 할당
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
POST /screen-management/screens/:screenId/assign-menu
|
|
|
|
|
Request: {
|
|
|
|
|
menuObjid: number;
|
|
|
|
|
displayOrder?: number;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### 메뉴별 할당된 화면 목록 조회
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
GET /screen-management/menus/:menuObjid/screens
|
|
|
|
|
Response: {
|
|
|
|
|
success: boolean;
|
|
|
|
|
data: ScreenDefinition[];
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### 화면-메뉴 할당 해제
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
DELETE /screen-management/screens/:screenId/menus/:menuObjid
|
|
|
|
|
Response: {
|
|
|
|
|
success: boolean;
|
|
|
|
|
message: string;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 2. 화면 정의 API
|
2025-08-29 11:46:52 +09:00
|
|
|
|
2025-09-01 10:19:47 +09:00
|
|
|
#### 화면 목록 조회 (회사별)
|
2025-08-29 11:46:52 +09:00
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
GET /api/screen-management/screens
|
2025-09-01 10:19:47 +09:00
|
|
|
Query: {
|
|
|
|
|
companyCode?: string; // 회사 코드 (관리자는 생략 가능)
|
|
|
|
|
page?: number;
|
|
|
|
|
size?: number;
|
|
|
|
|
}
|
2025-08-29 11:46:52 +09:00
|
|
|
Response: {
|
|
|
|
|
success: boolean;
|
|
|
|
|
data: ScreenDefinition[];
|
|
|
|
|
total: number;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
2025-09-01 10:19:47 +09:00
|
|
|
#### 화면 생성 (회사별)
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
POST /api/screen-management/screens
|
|
|
|
|
Body: {
|
|
|
|
|
screenName: string;
|
|
|
|
|
screenCode: string;
|
|
|
|
|
tableName: string;
|
|
|
|
|
companyCode: string; // 사용자 회사 코드 자동 설정
|
|
|
|
|
description?: string;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
2025-08-29 11:46:52 +09:00
|
|
|
#### 화면 생성
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
POST /api/screen-management/screens
|
|
|
|
|
Body: {
|
|
|
|
|
screenName: string;
|
|
|
|
|
screenCode: string;
|
|
|
|
|
tableName: string;
|
|
|
|
|
description?: string;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### 화면 수정
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
PUT /api/screen-management/screens/:screenId
|
|
|
|
|
Body: {
|
|
|
|
|
screenName?: string;
|
|
|
|
|
description?: string;
|
|
|
|
|
isActive?: boolean;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### 화면 삭제
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
DELETE /api/screen-management/screens/:screenId
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 2. 레이아웃 API
|
|
|
|
|
|
|
|
|
|
#### 레이아웃 조회
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
GET /api/screen-management/screens/:screenId/layout
|
|
|
|
|
Response: {
|
|
|
|
|
success: boolean;
|
|
|
|
|
data: LayoutData;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### 레이아웃 저장
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
POST /api/screen-management/screens/:screenId/layout
|
|
|
|
|
Body: {
|
|
|
|
|
components: ComponentData[];
|
|
|
|
|
gridSettings: GridSettings;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### 레이아웃 복사
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
POST /api/screen-management/screens/:screenId/layout/copy
|
|
|
|
|
Body: {
|
|
|
|
|
targetScreenId: number;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 3. 위젯 API
|
|
|
|
|
|
|
|
|
|
#### 위젯 속성 조회
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
GET /api/screen-management/widgets/:widgetId/properties
|
|
|
|
|
Response: {
|
|
|
|
|
success: boolean;
|
|
|
|
|
data: WidgetProperties;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### 위젯 속성 수정
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
PUT /api/screen-management/widgets/:widgetId/properties
|
|
|
|
|
Body: {
|
|
|
|
|
label?: string;
|
|
|
|
|
placeholder?: string;
|
|
|
|
|
required?: boolean;
|
|
|
|
|
readonly?: boolean;
|
|
|
|
|
validationRules?: ValidationRule[];
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 4. 템플릿 API
|
|
|
|
|
|
2025-09-01 10:19:47 +09:00
|
|
|
#### 템플릿 목록 조회 (회사별)
|
2025-08-29 11:46:52 +09:00
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
GET /api/screen-management/templates
|
|
|
|
|
Query: {
|
2025-09-01 10:19:47 +09:00
|
|
|
companyCode?: string; // 회사 코드 (관리자는 생략 가능)
|
2025-08-29 11:46:52 +09:00
|
|
|
type?: string;
|
|
|
|
|
isPublic?: boolean;
|
|
|
|
|
createdBy?: string;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
2025-09-01 10:19:47 +09:00
|
|
|
### 5. 메뉴 할당 API
|
|
|
|
|
|
|
|
|
|
#### 화면-메뉴 할당
|
|
|
|
|
|
|
|
|
|
```typescript
|
2025-09-01 18:42:59 +09:00
|
|
|
POST /api/screen-management/screens/:screenId/assign-menu
|
2025-09-01 10:19:47 +09:00
|
|
|
Body: {
|
2025-09-01 18:42:59 +09:00
|
|
|
menuObjid: number;
|
2025-09-01 10:19:47 +09:00
|
|
|
displayOrder?: number;
|
2025-09-01 18:42:59 +09:00
|
|
|
// companyCode는 JWT에서 자동 추출
|
2025-09-01 10:19:47 +09:00
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### 메뉴별 화면 목록 조회
|
|
|
|
|
|
|
|
|
|
```typescript
|
2025-09-01 18:42:59 +09:00
|
|
|
GET /api/screen-management/menus/:menuObjid/screens
|
2025-09-01 10:19:47 +09:00
|
|
|
Response: {
|
|
|
|
|
success: boolean;
|
|
|
|
|
data: ScreenDefinition[];
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
2025-09-01 18:42:59 +09:00
|
|
|
#### 화면-메뉴 할당 해제
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
DELETE /api/screen-management/screens/:screenId/menus/:menuObjid
|
|
|
|
|
Response: {
|
|
|
|
|
success: boolean;
|
|
|
|
|
message: string;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### 화면 코드 자동 생성
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
GET /api/screen-management/generate-screen-code/:companyCode
|
|
|
|
|
Response: {
|
|
|
|
|
success: boolean;
|
|
|
|
|
data: {
|
|
|
|
|
screenCode: string; // 예: "COMP_001"
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### 단일 화면 조회
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
GET /api/screen-management/screens/:id
|
|
|
|
|
Response: {
|
|
|
|
|
success: boolean;
|
|
|
|
|
data: ScreenDefinition;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
2025-08-29 11:46:52 +09:00
|
|
|
#### 템플릿 적용
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
POST /api/screen-management/screens/:screenId/apply-template
|
|
|
|
|
Body: {
|
|
|
|
|
templateId: number;
|
|
|
|
|
overrideExisting: boolean;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## 🎭 프론트엔드 구현
|
|
|
|
|
|
2025-09-01 15:22:47 +09:00
|
|
|
### 1. 화면 설계기 컴포넌트 (구현 완료)
|
2025-08-29 11:46:52 +09:00
|
|
|
|
|
|
|
|
```typescript
|
2025-09-01 15:22:47 +09:00
|
|
|
// ScreenDesigner.tsx - 현재 구현된 버전
|
|
|
|
|
export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenDesignerProps) {
|
2025-08-29 11:46:52 +09:00
|
|
|
const [layout, setLayout] = useState<LayoutData>({ components: [] });
|
2025-09-01 15:22:47 +09:00
|
|
|
const [selectedComponent, setSelectedComponent] = useState<ComponentData | null>(null);
|
|
|
|
|
|
|
|
|
|
// 실행취소/다시실행을 위한 히스토리 상태
|
|
|
|
|
const [history, setHistory] = useState<LayoutData[]>([{
|
|
|
|
|
components: [],
|
|
|
|
|
gridSettings: { columns: 12, gap: 16, padding: 16 },
|
|
|
|
|
}]);
|
|
|
|
|
const [historyIndex, setHistoryIndex] = useState(0);
|
|
|
|
|
|
|
|
|
|
// 히스토리에 상태 저장
|
|
|
|
|
const saveToHistory = useCallback((newLayout: LayoutData) => {
|
|
|
|
|
setHistory(prevHistory => {
|
|
|
|
|
const newHistory = prevHistory.slice(0, historyIndex + 1);
|
|
|
|
|
newHistory.push(JSON.parse(JSON.stringify(newLayout))); // 깊은 복사
|
|
|
|
|
return newHistory.slice(-50); // 최대 50개 히스토리 유지
|
|
|
|
|
});
|
|
|
|
|
setHistoryIndex(prevIndex => Math.min(prevIndex + 1, 49));
|
|
|
|
|
}, [historyIndex]);
|
|
|
|
|
|
|
|
|
|
// 실행취소/다시실행 함수
|
|
|
|
|
const undo = useCallback(() => {
|
|
|
|
|
if (historyIndex > 0) {
|
|
|
|
|
const newIndex = historyIndex - 1;
|
|
|
|
|
setHistoryIndex(newIndex);
|
|
|
|
|
setLayout(JSON.parse(JSON.stringify(history[newIndex])));
|
|
|
|
|
setSelectedComponent(null);
|
|
|
|
|
}
|
|
|
|
|
}, [historyIndex, history]);
|
|
|
|
|
|
|
|
|
|
const redo = useCallback(() => {
|
|
|
|
|
if (historyIndex < history.length - 1) {
|
|
|
|
|
const newIndex = historyIndex + 1;
|
|
|
|
|
setHistoryIndex(newIndex);
|
|
|
|
|
setLayout(JSON.parse(JSON.stringify(history[newIndex])));
|
|
|
|
|
setSelectedComponent(null);
|
|
|
|
|
}
|
|
|
|
|
}, [historyIndex, history]);
|
|
|
|
|
|
|
|
|
|
// 키보드 단축키 지원
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
|
|
|
if (e.ctrlKey || e.metaKey) {
|
|
|
|
|
switch (e.key) {
|
|
|
|
|
case 'z':
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (e.shiftKey) {
|
|
|
|
|
redo(); // Ctrl+Shift+Z 또는 Cmd+Shift+Z
|
|
|
|
|
} else {
|
|
|
|
|
undo(); // Ctrl+Z 또는 Cmd+Z
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
case 'y':
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
redo(); // Ctrl+Y 또는 Cmd+Y
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
|
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
|
|
|
}, [undo, redo]);
|
2025-09-01 10:19:47 +09:00
|
|
|
```
|
2025-08-29 11:46:52 +09:00
|
|
|
|
2025-09-01 17:05:36 +09:00
|
|
|
#### 선택/이동 UX (현행)
|
|
|
|
|
|
|
|
|
|
- Shift+클릭으로 다중선택 가능
|
|
|
|
|
- 캔버스 빈 영역 드래그로 **마키 선택** 가능(Shift 누르면 기존 선택에 추가)
|
|
|
|
|
- 다중선택 상태에서 드래그 시 전체가 함께 이동(상대 좌표 유지)
|
|
|
|
|
- 그룹 컨테이너는 선택/정렬 대상에서 자동 제외
|
|
|
|
|
|
2025-09-01 10:19:47 +09:00
|
|
|
// 컴포넌트 추가
|
|
|
|
|
const addComponent = (component: ComponentData) => {
|
|
|
|
|
setLayout((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
components: [...prev.components, component],
|
|
|
|
|
}));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 컴포넌트 삭제
|
|
|
|
|
const removeComponent = (componentId: string) => {
|
|
|
|
|
setLayout((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
components: prev.components.filter((c) => c.id !== componentId),
|
|
|
|
|
}));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 컴포넌트 이동
|
|
|
|
|
const moveComponent = (componentId: string, newPosition: Position) => {
|
|
|
|
|
setLayout((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
components: prev.components.map((c) =>
|
|
|
|
|
c.id === componentId ? { ...c, position: newPosition } : c
|
|
|
|
|
),
|
|
|
|
|
}));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 컴포넌트 그룹화
|
|
|
|
|
const groupComponents = (componentIds: string[], groupTitle?: string) => {
|
|
|
|
|
const groupId = generateId();
|
|
|
|
|
const groupComponent: GroupProps = {
|
|
|
|
|
id: groupId,
|
|
|
|
|
type: "group",
|
|
|
|
|
title: groupTitle || "그룹",
|
|
|
|
|
width: 12,
|
|
|
|
|
height: 200,
|
|
|
|
|
padding: 16,
|
|
|
|
|
margin: 8,
|
|
|
|
|
backgroundColor: "#f8f9fa",
|
|
|
|
|
border: "1px solid #dee2e6",
|
|
|
|
|
borderRadius: 8,
|
|
|
|
|
shadow: "0 2px 4px rgba(0,0,0,0.1)",
|
|
|
|
|
collapsible: true,
|
|
|
|
|
collapsed: false,
|
|
|
|
|
children: componentIds,
|
|
|
|
|
};
|
2025-08-29 11:46:52 +09:00
|
|
|
|
|
|
|
|
setLayout((prev) => ({
|
|
|
|
|
...prev,
|
2025-09-01 10:19:47 +09:00
|
|
|
components: [...prev.components, groupComponent],
|
2025-08-29 11:46:52 +09:00
|
|
|
}));
|
|
|
|
|
|
2025-09-01 10:19:47 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 그룹에서 컴포넌트 제거
|
|
|
|
|
const ungroupComponent = (componentId: string, groupId: string) => {
|
|
|
|
|
setLayout((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
components: prev.components.map((c) => {
|
|
|
|
|
if (c.id === groupId && c.type === "group") {
|
|
|
|
|
return {
|
|
|
|
|
...c,
|
|
|
|
|
children: c.children.filter((id) => id !== componentId),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return c;
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
2025-09-01 11:48:12 +09:00
|
|
|
|
2025-09-01 10:19:47 +09:00
|
|
|
<div className="screen-designer">
|
|
|
|
|
<Toolbox onComponentSelect={addComponent} />
|
|
|
|
|
<Canvas
|
2025-08-29 11:46:52 +09:00
|
|
|
layout={layout}
|
|
|
|
|
selectedComponent={selectedComponent}
|
|
|
|
|
onComponentSelect={setSelectedComponent}
|
|
|
|
|
onComponentMove={moveComponent}
|
|
|
|
|
onComponentRemove={removeComponent}
|
|
|
|
|
dragState={dragState}
|
|
|
|
|
onDragStateChange={setDragState}
|
|
|
|
|
/>
|
2025-09-01 10:19:47 +09:00
|
|
|
<PropertiesPanel
|
|
|
|
|
component={layout.components.find((c) => c.id === selectedComponent)}
|
|
|
|
|
onPropertyChange={updateComponentProperty}
|
|
|
|
|
onGroupCreate={groupComponents}
|
|
|
|
|
onGroupRemove={ungroupComponent}
|
|
|
|
|
/>
|
|
|
|
|
<PreviewPanel layout={layout} />
|
|
|
|
|
<GroupingToolbar
|
|
|
|
|
groupState={groupState}
|
|
|
|
|
onGroupStateChange={setGroupState}
|
|
|
|
|
onGroupCreate={groupComponents}
|
|
|
|
|
onGroupRemove={ungroupComponent}
|
2025-08-29 11:46:52 +09:00
|
|
|
/>
|
2025-09-01 10:19:47 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
2025-08-29 11:46:52 +09:00
|
|
|
}
|
2025-09-01 10:19:47 +09:00
|
|
|
|
|
|
|
|
````
|
2025-08-29 11:46:52 +09:00
|
|
|
|
|
|
|
|
### 2. 드래그앤드롭 구현
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// useDragAndDrop.ts
|
|
|
|
|
export function useDragAndDrop() {
|
|
|
|
|
const [dragState, setDragState] = useState<DragState>({
|
|
|
|
|
isDragging: false,
|
|
|
|
|
draggedItem: null,
|
|
|
|
|
dragSource: "toolbox",
|
|
|
|
|
dropTarget: null,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const startDrag = (item: ComponentData, source: "toolbox" | "canvas") => {
|
|
|
|
|
setDragState({
|
|
|
|
|
isDragging: true,
|
|
|
|
|
draggedItem: item,
|
|
|
|
|
dragSource: source,
|
|
|
|
|
dropTarget: null,
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const endDrag = () => {
|
|
|
|
|
setDragState({
|
|
|
|
|
isDragging: false,
|
|
|
|
|
draggedItem: null,
|
|
|
|
|
dragSource: "toolbox",
|
|
|
|
|
dropTarget: null,
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const updateDropTarget = (targetId: string | null) => {
|
|
|
|
|
setDragState((prev) => ({ ...prev, dropTarget: targetId }));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
dragState,
|
|
|
|
|
startDrag,
|
|
|
|
|
endDrag,
|
|
|
|
|
updateDropTarget,
|
|
|
|
|
};
|
|
|
|
|
}
|
2025-09-01 10:19:47 +09:00
|
|
|
````
|
2025-08-29 11:46:52 +09:00
|
|
|
|
2025-09-01 15:22:47 +09:00
|
|
|
### 3. 실시간 미리보기 시스템 (구현 완료)
|
2025-08-29 11:46:52 +09:00
|
|
|
|
|
|
|
|
```typescript
|
2025-09-01 15:22:47 +09:00
|
|
|
// RealtimePreview.tsx - 현재 구현된 버전
|
|
|
|
|
export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
|
|
|
|
|
component,
|
|
|
|
|
isSelected = false,
|
|
|
|
|
onClick,
|
|
|
|
|
onDragStart,
|
|
|
|
|
onDragEnd,
|
|
|
|
|
}) => {
|
|
|
|
|
const { type, label, tableName, columnName, widgetType, size, style } =
|
|
|
|
|
component;
|
2025-08-29 11:46:52 +09:00
|
|
|
|
|
|
|
|
return (
|
2025-09-01 15:22:47 +09:00
|
|
|
<div
|
|
|
|
|
className={`absolute rounded border-2 transition-all ${
|
|
|
|
|
isSelected
|
|
|
|
|
? "border-blue-500 bg-blue-50 shadow-lg"
|
|
|
|
|
: "border-gray-300 bg-white hover:border-gray-400"
|
|
|
|
|
}`}
|
|
|
|
|
style={{
|
|
|
|
|
left: `${component.position.x}px`,
|
|
|
|
|
top: `${component.position.y}px`,
|
|
|
|
|
width: `${size.width * 80 - 16}px`,
|
|
|
|
|
height: `${size.height}px`,
|
|
|
|
|
...style,
|
|
|
|
|
}}
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
draggable
|
|
|
|
|
onDragStart={onDragStart}
|
|
|
|
|
onDragEnd={onDragEnd}
|
|
|
|
|
>
|
|
|
|
|
{type === "container" && (
|
|
|
|
|
<div className="flex h-full flex-col items-center justify-center p-4">
|
|
|
|
|
<div className="flex flex-col items-center space-y-2">
|
|
|
|
|
<Database className="h-8 w-8 text-blue-600" />
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
<div className="text-sm font-medium">{label}</div>
|
|
|
|
|
<div className="text-xs text-gray-500">{tableName}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{type === "widget" && (
|
|
|
|
|
<div className="flex h-full flex-col p-3">
|
|
|
|
|
{/* 위젯 헤더 */}
|
|
|
|
|
<div className="mb-2 flex items-center space-x-2">
|
|
|
|
|
{getWidgetIcon(widgetType)}
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
<Label className="text-sm font-medium">
|
|
|
|
|
{label || columnName}
|
|
|
|
|
{component.required && (
|
|
|
|
|
<span className="ml-1 text-red-500">*</span>
|
|
|
|
|
)}
|
|
|
|
|
</Label>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 위젯 본체 */}
|
|
|
|
|
<div className="flex-1">{renderWidget(component)}</div>
|
|
|
|
|
|
|
|
|
|
{/* 위젯 정보 */}
|
|
|
|
|
<div className="mt-2 text-xs text-gray-500">
|
|
|
|
|
{columnName} ({widgetType})
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-08-29 11:46:52 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
2025-09-01 15:22:47 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 웹 타입에 따른 위젯 렌더링
|
|
|
|
|
const renderWidget = (component: ComponentData) => {
|
|
|
|
|
const { widgetType, label, placeholder, required, readonly, columnName } =
|
|
|
|
|
component;
|
|
|
|
|
|
|
|
|
|
const commonProps = {
|
|
|
|
|
placeholder: placeholder || `입력하세요...`,
|
|
|
|
|
disabled: readonly,
|
|
|
|
|
required: required,
|
|
|
|
|
className: "w-full",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
switch (widgetType) {
|
|
|
|
|
case "text":
|
|
|
|
|
case "email":
|
|
|
|
|
case "tel":
|
|
|
|
|
return (
|
|
|
|
|
<Input
|
|
|
|
|
type={
|
|
|
|
|
widgetType === "email"
|
|
|
|
|
? "email"
|
|
|
|
|
: widgetType === "tel"
|
|
|
|
|
? "tel"
|
|
|
|
|
: "text"
|
|
|
|
|
}
|
|
|
|
|
{...commonProps}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "number":
|
|
|
|
|
case "decimal":
|
|
|
|
|
return (
|
|
|
|
|
<Input
|
|
|
|
|
type="number"
|
|
|
|
|
step={widgetType === "decimal" ? "0.01" : "1"}
|
|
|
|
|
{...commonProps}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "date":
|
|
|
|
|
case "datetime":
|
|
|
|
|
return (
|
|
|
|
|
<Input
|
|
|
|
|
type={widgetType === "datetime" ? "datetime-local" : "date"}
|
|
|
|
|
{...commonProps}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "select":
|
|
|
|
|
case "dropdown":
|
|
|
|
|
return (
|
|
|
|
|
<select
|
|
|
|
|
disabled={readonly}
|
|
|
|
|
required={required}
|
|
|
|
|
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
|
|
|
|
|
>
|
|
|
|
|
<option value="">{placeholder || "선택하세요..."}</option>
|
|
|
|
|
<option value="option1">옵션 1</option>
|
|
|
|
|
<option value="option2">옵션 2</option>
|
|
|
|
|
<option value="option3">옵션 3</option>
|
|
|
|
|
</select>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "textarea":
|
|
|
|
|
case "text_area":
|
|
|
|
|
return <Textarea {...commonProps} rows={3} />;
|
|
|
|
|
|
|
|
|
|
case "boolean":
|
|
|
|
|
case "checkbox":
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
id={`checkbox-${component.id}`}
|
|
|
|
|
disabled={readonly}
|
|
|
|
|
required={required}
|
|
|
|
|
className="h-4 w-4"
|
|
|
|
|
/>
|
|
|
|
|
<Label htmlFor={`checkbox-${component.id}`} className="text-sm">
|
|
|
|
|
{label || columnName}
|
|
|
|
|
</Label>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "radio":
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
<input
|
|
|
|
|
type="radio"
|
|
|
|
|
id={`radio1-${component.id}`}
|
|
|
|
|
name={`radio-${component.id}`}
|
|
|
|
|
disabled={readonly}
|
|
|
|
|
className="h-4 w-4"
|
|
|
|
|
/>
|
|
|
|
|
<Label htmlFor={`radio1-${component.id}`} className="text-sm">
|
|
|
|
|
옵션 1
|
|
|
|
|
</Label>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
<input
|
|
|
|
|
type="radio"
|
|
|
|
|
id={`radio2-${component.id}`}
|
|
|
|
|
name={`radio-${component.id}`}
|
|
|
|
|
disabled={readonly}
|
|
|
|
|
className="h-4 w-4"
|
|
|
|
|
/>
|
|
|
|
|
<Label htmlFor={`radio2-${component.id}`} className="text-sm">
|
|
|
|
|
옵션 2
|
|
|
|
|
</Label>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "code":
|
|
|
|
|
return (
|
|
|
|
|
<Textarea
|
|
|
|
|
{...commonProps}
|
|
|
|
|
rows={4}
|
|
|
|
|
className="w-full font-mono text-sm"
|
|
|
|
|
placeholder="코드를 입력하세요..."
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "entity":
|
|
|
|
|
return (
|
|
|
|
|
<select
|
|
|
|
|
disabled={readonly}
|
|
|
|
|
required={required}
|
|
|
|
|
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
|
|
|
|
|
>
|
|
|
|
|
<option value="">엔티티를 선택하세요...</option>
|
|
|
|
|
<option value="user">사용자</option>
|
|
|
|
|
<option value="product">제품</option>
|
|
|
|
|
<option value="order">주문</option>
|
|
|
|
|
</select>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "file":
|
|
|
|
|
return (
|
|
|
|
|
<input
|
|
|
|
|
type="file"
|
|
|
|
|
disabled={readonly}
|
|
|
|
|
required={required}
|
|
|
|
|
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
return <Input type="text" {...commonProps} />;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 위젯 타입 아이콘
|
|
|
|
|
const getWidgetIcon = (widgetType: WebType | undefined) => {
|
|
|
|
|
switch (widgetType) {
|
|
|
|
|
case "text":
|
|
|
|
|
case "email":
|
|
|
|
|
case "tel":
|
|
|
|
|
return <Type className="h-4 w-4 text-blue-600" />;
|
|
|
|
|
case "number":
|
|
|
|
|
case "decimal":
|
|
|
|
|
return <Hash className="h-4 w-4 text-green-600" />;
|
|
|
|
|
case "date":
|
|
|
|
|
case "datetime":
|
|
|
|
|
return <Calendar className="h-4 w-4 text-purple-600" />;
|
|
|
|
|
case "select":
|
|
|
|
|
case "dropdown":
|
|
|
|
|
return <List className="h-4 w-4 text-orange-600" />;
|
|
|
|
|
case "textarea":
|
|
|
|
|
case "text_area":
|
|
|
|
|
return <AlignLeft className="h-4 w-4 text-indigo-600" />;
|
|
|
|
|
case "boolean":
|
|
|
|
|
case "checkbox":
|
|
|
|
|
return <CheckSquare className="h-4 w-4 text-blue-600" />;
|
|
|
|
|
case "radio":
|
|
|
|
|
return <Radio className="h-4 w-4 text-blue-600" />;
|
|
|
|
|
case "code":
|
|
|
|
|
return <Code className="h-4 w-4 text-gray-600" />;
|
|
|
|
|
case "entity":
|
|
|
|
|
return <Building className="h-4 w-4 text-cyan-600" />;
|
|
|
|
|
case "file":
|
|
|
|
|
return <File className="h-4 w-4 text-yellow-600" />;
|
|
|
|
|
default:
|
|
|
|
|
return <Type className="h-4 w-4 text-gray-500" />;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 4. 그리드 시스템 (구현 완료)
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// 80px x 60px 그리드 기반 레이아웃 시스템
|
|
|
|
|
const GRID_SIZE = { width: 80, height: 60 };
|
|
|
|
|
|
|
|
|
|
// 그리드 위치 계산
|
|
|
|
|
const calculateGridPosition = (mouseX: number, mouseY: number) => {
|
|
|
|
|
const x = Math.floor(mouseX / GRID_SIZE.width) * GRID_SIZE.width;
|
|
|
|
|
const y = Math.floor(mouseY / GRID_SIZE.height) * GRID_SIZE.height;
|
|
|
|
|
return { x, y };
|
|
|
|
|
};
|
2025-08-29 11:46:52 +09:00
|
|
|
|
2025-09-01 10:19:47 +09:00
|
|
|
// 그룹화 도구 모음
|
|
|
|
|
export function GroupingToolbar({
|
|
|
|
|
groupState,
|
|
|
|
|
onGroupStateChange,
|
|
|
|
|
onGroupCreate,
|
|
|
|
|
onGroupRemove,
|
|
|
|
|
}) {
|
|
|
|
|
const handleGroupCreate = () => {
|
|
|
|
|
if (groupState.selectedComponents.length > 1) {
|
|
|
|
|
const groupTitle = prompt("그룹 제목을 입력하세요:");
|
|
|
|
|
onGroupCreate(groupState.selectedComponents, groupTitle);
|
|
|
|
|
onGroupStateChange({
|
|
|
|
|
...groupState,
|
|
|
|
|
isGrouping: false,
|
|
|
|
|
selectedComponents: [],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleGroupRemove = () => {
|
|
|
|
|
if (groupState.groupTarget) {
|
|
|
|
|
onGroupRemove(groupState.selectedComponents[0], groupState.groupTarget);
|
|
|
|
|
onGroupStateChange({
|
|
|
|
|
...groupState,
|
|
|
|
|
isGrouping: false,
|
|
|
|
|
selectedComponents: [],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="grouping-toolbar">
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleGroupCreate}
|
|
|
|
|
disabled={groupState.selectedComponents.length < 2}
|
|
|
|
|
className="btn btn-primary"
|
|
|
|
|
>
|
|
|
|
|
그룹 생성
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleGroupRemove}
|
|
|
|
|
disabled={!groupState.groupTarget}
|
|
|
|
|
className="btn btn-secondary"
|
|
|
|
|
>
|
|
|
|
|
그룹 해제
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-29 11:46:52 +09:00
|
|
|
// GridItem.tsx
|
|
|
|
|
interface GridItemProps {
|
|
|
|
|
width: number; // 1-12
|
|
|
|
|
offset?: number;
|
|
|
|
|
children: React.ReactNode;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function GridItem({
|
|
|
|
|
width,
|
|
|
|
|
offset = 0,
|
|
|
|
|
children,
|
|
|
|
|
}: GridItemProps) {
|
|
|
|
|
const gridColumn =
|
|
|
|
|
offset > 0 ? `${offset + 1} / span ${width}` : `span ${width}`;
|
|
|
|
|
|
|
|
|
|
const style = {
|
|
|
|
|
gridColumn,
|
|
|
|
|
minHeight: "50px",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="grid-item" style={style}>
|
|
|
|
|
{children}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## ⚙️ 백엔드 구현
|
|
|
|
|
|
2025-09-03 15:23:12 +09:00
|
|
|
### 🆕 데이터 테이블 실시간 조회 API (2025.09 추가)
|
|
|
|
|
|
|
|
|
|
#### 1. 테이블 데이터 조회 API
|
|
|
|
|
|
|
|
|
|
**라우트 설정**
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// tableManagementRoutes.ts
|
|
|
|
|
/**
|
|
|
|
|
* 테이블 데이터 조회 (페이징 + 검색)
|
|
|
|
|
* POST /api/table-management/tables/:tableName/data
|
|
|
|
|
*/
|
|
|
|
|
router.post("/tables/:tableName/data", getTableData);
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**컨트롤러 구현**
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// tableManagementController.ts
|
|
|
|
|
export async function getTableData(
|
|
|
|
|
req: AuthenticatedRequest,
|
|
|
|
|
res: Response
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
const { tableName } = req.params;
|
|
|
|
|
const {
|
|
|
|
|
page = 1,
|
|
|
|
|
size = 10,
|
|
|
|
|
search = {},
|
|
|
|
|
sortBy,
|
|
|
|
|
sortOrder = "asc",
|
|
|
|
|
} = req.body;
|
|
|
|
|
|
|
|
|
|
logger.info(`=== 테이블 데이터 조회 시작: ${tableName} ===`);
|
|
|
|
|
logger.info(`페이징: page=${page}, size=${size}`);
|
|
|
|
|
logger.info(`검색 조건:`, search);
|
|
|
|
|
|
|
|
|
|
if (!tableName) {
|
|
|
|
|
const response: ApiResponse<null> = {
|
|
|
|
|
success: false,
|
|
|
|
|
message: "테이블명이 필요합니다.",
|
|
|
|
|
error: { code: "MISSING_TABLE_NAME" },
|
|
|
|
|
};
|
|
|
|
|
res.status(400).json(response);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const tableManagementService = new TableManagementService();
|
|
|
|
|
|
|
|
|
|
// 데이터 조회
|
|
|
|
|
const result = await tableManagementService.getTableData(tableName, {
|
|
|
|
|
page: parseInt(page),
|
|
|
|
|
size: parseInt(size),
|
|
|
|
|
search,
|
|
|
|
|
sortBy,
|
|
|
|
|
sortOrder,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const response: ApiResponse<any> = {
|
|
|
|
|
success: true,
|
|
|
|
|
message: "테이블 데이터를 성공적으로 조회했습니다.",
|
|
|
|
|
data: result,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
res.status(200).json(response);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error("테이블 데이터 조회 중 오류 발생:", error);
|
|
|
|
|
// 오류 응답 처리
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**서비스 로직**
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// tableManagementService.ts
|
|
|
|
|
export class TableManagementService {
|
|
|
|
|
/**
|
|
|
|
|
* 테이블 데이터 조회 (페이징 + 검색)
|
|
|
|
|
*/
|
|
|
|
|
async getTableData(
|
|
|
|
|
tableName: string,
|
|
|
|
|
options: {
|
|
|
|
|
page: number;
|
|
|
|
|
size: number;
|
|
|
|
|
search?: Record<string, any>;
|
|
|
|
|
sortBy?: string;
|
|
|
|
|
sortOrder?: string;
|
|
|
|
|
}
|
|
|
|
|
): Promise<{
|
|
|
|
|
data: any[];
|
|
|
|
|
total: number;
|
|
|
|
|
page: number;
|
|
|
|
|
size: number;
|
|
|
|
|
totalPages: number;
|
|
|
|
|
}> {
|
|
|
|
|
try {
|
|
|
|
|
const { page, size, search = {}, sortBy, sortOrder = "asc" } = options;
|
|
|
|
|
const offset = (page - 1) * size;
|
|
|
|
|
|
|
|
|
|
// WHERE 조건 구성 (SQL 인젝션 방지)
|
|
|
|
|
let whereConditions: string[] = [];
|
|
|
|
|
let searchValues: any[] = [];
|
|
|
|
|
let paramIndex = 1;
|
|
|
|
|
|
|
|
|
|
if (search && Object.keys(search).length > 0) {
|
|
|
|
|
for (const [column, value] of Object.entries(search)) {
|
|
|
|
|
if (value !== null && value !== undefined && value !== "") {
|
|
|
|
|
// 안전한 컬럼명 검증
|
|
|
|
|
const safeColumn = column.replace(/[^a-zA-Z0-9_]/g, "");
|
|
|
|
|
|
|
|
|
|
if (typeof value === "string") {
|
|
|
|
|
whereConditions.push(`${safeColumn}::text ILIKE $${paramIndex}`);
|
|
|
|
|
searchValues.push(`%${value}%`);
|
|
|
|
|
} else {
|
|
|
|
|
whereConditions.push(`${safeColumn} = $${paramIndex}`);
|
|
|
|
|
searchValues.push(value);
|
|
|
|
|
}
|
|
|
|
|
paramIndex++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const whereClause =
|
|
|
|
|
whereConditions.length > 0
|
|
|
|
|
? `WHERE ${whereConditions.join(" AND ")}`
|
|
|
|
|
: "";
|
|
|
|
|
|
|
|
|
|
// ORDER BY 조건 구성
|
|
|
|
|
let orderClause = "";
|
|
|
|
|
if (sortBy) {
|
|
|
|
|
const safeSortBy = sortBy.replace(/[^a-zA-Z0-9_]/g, "");
|
|
|
|
|
const safeSortOrder =
|
|
|
|
|
sortOrder.toLowerCase() === "desc" ? "DESC" : "ASC";
|
|
|
|
|
orderClause = `ORDER BY ${safeSortBy} ${safeSortOrder}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 안전한 테이블명 검증
|
|
|
|
|
const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, "");
|
|
|
|
|
|
|
|
|
|
// 전체 개수 조회
|
|
|
|
|
const countQuery = `SELECT COUNT(*) as count FROM ${safeTableName} ${whereClause}`;
|
|
|
|
|
const countResult = await prisma.$queryRawUnsafe<any[]>(
|
|
|
|
|
countQuery,
|
|
|
|
|
...searchValues
|
|
|
|
|
);
|
|
|
|
|
const total = parseInt(countResult[0].count);
|
|
|
|
|
|
|
|
|
|
// 데이터 조회
|
|
|
|
|
const dataQuery = `
|
|
|
|
|
SELECT * FROM ${safeTableName}
|
|
|
|
|
${whereClause}
|
|
|
|
|
${orderClause}
|
|
|
|
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const data = await prisma.$queryRawUnsafe<any[]>(
|
|
|
|
|
dataQuery,
|
|
|
|
|
...searchValues,
|
|
|
|
|
size,
|
|
|
|
|
offset
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const totalPages = Math.ceil(total / size);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
data,
|
|
|
|
|
total,
|
|
|
|
|
page,
|
|
|
|
|
size,
|
|
|
|
|
totalPages,
|
|
|
|
|
};
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(`테이블 데이터 조회 오류: ${tableName}`, error);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### 2. API 응답 형식
|
|
|
|
|
|
|
|
|
|
**성공 응답**
|
|
|
|
|
|
|
|
|
|
```json
|
|
|
|
|
{
|
|
|
|
|
"success": true,
|
|
|
|
|
"message": "테이블 데이터를 성공적으로 조회했습니다.",
|
|
|
|
|
"data": {
|
|
|
|
|
"data": [
|
|
|
|
|
{
|
|
|
|
|
"objid": 1,
|
|
|
|
|
"target_objid": 12345,
|
|
|
|
|
"approval_seq": "A001",
|
|
|
|
|
"regdate": "2025-09-03T05:39:14.000Z",
|
|
|
|
|
"status": "pending"
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
"total": 1,
|
|
|
|
|
"page": 1,
|
|
|
|
|
"size": 5,
|
|
|
|
|
"totalPages": 1
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**요청 형식**
|
|
|
|
|
|
|
|
|
|
```json
|
|
|
|
|
{
|
|
|
|
|
"page": 1,
|
|
|
|
|
"size": 10,
|
|
|
|
|
"search": {
|
|
|
|
|
"approval_seq": "A001",
|
|
|
|
|
"status": "pending"
|
|
|
|
|
},
|
|
|
|
|
"sortBy": "regdate",
|
|
|
|
|
"sortOrder": "desc"
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### 3. 보안 및 성능 최적화
|
|
|
|
|
|
|
|
|
|
**SQL 인젝션 방지**
|
|
|
|
|
|
|
|
|
|
- 정규표현식을 통한 안전한 컬럼명/테이블명 검증
|
|
|
|
|
- 파라미터 바인딩 사용 (`$queryRawUnsafe` with parameters)
|
|
|
|
|
- 사용자 입력값 필터링
|
|
|
|
|
|
|
|
|
|
**성능 최적화**
|
|
|
|
|
|
|
|
|
|
- 페이징 처리로 대용량 데이터 대응
|
|
|
|
|
- COUNT 쿼리와 데이터 쿼리 분리
|
|
|
|
|
- 인덱스 기반 정렬 지원
|
|
|
|
|
|
|
|
|
|
**에러 처리**
|
|
|
|
|
|
|
|
|
|
- 상세한 로깅 시스템
|
|
|
|
|
- 사용자 친화적 오류 메시지
|
|
|
|
|
- HTTP 상태 코드 준수
|
|
|
|
|
|
|
|
|
|
#### 4. 도커 환경 통합
|
|
|
|
|
|
|
|
|
|
**개발 환경 설정**
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
# 백엔드 컨테이너 재빌드 (새 API 반영)
|
|
|
|
|
docker-compose -f docker/dev/docker-compose.backend.mac.yml down backend
|
|
|
|
|
docker-compose -f docker/dev/docker-compose.backend.mac.yml up --build -d backend
|
|
|
|
|
|
|
|
|
|
# API 테스트
|
|
|
|
|
curl -X POST http://localhost:8080/api/table-management/tables/approval/data \
|
|
|
|
|
-H "Content-Type: application/json" \
|
|
|
|
|
-d '{"page": 1, "size": 5}'
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**환경 변수**
|
|
|
|
|
|
|
|
|
|
```env
|
|
|
|
|
# backend-node/.env
|
|
|
|
|
PORT=8080
|
|
|
|
|
DATABASE_URL=postgresql://postgres:password@localhost:5432/ilshin
|
|
|
|
|
NODE_ENV=development
|
|
|
|
|
```
|
|
|
|
|
|
2025-08-29 11:46:52 +09:00
|
|
|
### 1. 화면 관리 서비스
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// screenManagementService.ts
|
|
|
|
|
export class ScreenManagementService {
|
2025-09-01 10:19:47 +09:00
|
|
|
// 화면 정의 생성 (회사별)
|
2025-08-29 11:46:52 +09:00
|
|
|
async createScreen(
|
2025-09-01 10:19:47 +09:00
|
|
|
screenData: CreateScreenRequest,
|
|
|
|
|
userCompanyCode: string
|
2025-08-29 11:46:52 +09:00
|
|
|
): Promise<ScreenDefinition> {
|
2025-09-01 10:19:47 +09:00
|
|
|
// 권한 검증: 사용자 회사 코드 확인
|
|
|
|
|
if (userCompanyCode !== "*" && userCompanyCode !== screenData.companyCode) {
|
|
|
|
|
throw new Error("해당 회사의 화면을 생성할 권한이 없습니다.");
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-29 11:46:52 +09:00
|
|
|
const screen = await prisma.screen_definitions.create({
|
|
|
|
|
data: {
|
|
|
|
|
screen_name: screenData.screenName,
|
|
|
|
|
screen_code: screenData.screenCode,
|
|
|
|
|
table_name: screenData.tableName,
|
2025-09-01 10:19:47 +09:00
|
|
|
company_code: screenData.companyCode,
|
2025-08-29 11:46:52 +09:00
|
|
|
description: screenData.description,
|
|
|
|
|
created_by: screenData.createdBy,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return this.mapToScreenDefinition(screen);
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-01 10:19:47 +09:00
|
|
|
// 회사별 화면 목록 조회
|
|
|
|
|
async getScreensByCompany(
|
|
|
|
|
companyCode: string,
|
|
|
|
|
page: number = 1,
|
|
|
|
|
size: number = 20
|
|
|
|
|
): Promise<{ screens: ScreenDefinition[]; total: number }> {
|
|
|
|
|
const whereClause = companyCode === "*" ? {} : { company_code: companyCode };
|
|
|
|
|
|
|
|
|
|
const [screens, total] = await Promise.all([
|
|
|
|
|
prisma.screen_definitions.findMany({
|
|
|
|
|
where: whereClause,
|
|
|
|
|
skip: (page - 1) * size,
|
|
|
|
|
take: size,
|
|
|
|
|
orderBy: { created_date: "desc" },
|
|
|
|
|
}),
|
|
|
|
|
prisma.screen_definitions.count({ where: whereClause }),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
screens: screens.map(this.mapToScreenDefinition),
|
|
|
|
|
total,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
2025-08-29 11:46:52 +09:00
|
|
|
// 레이아웃 저장
|
|
|
|
|
async saveLayout(screenId: number, layoutData: LayoutData): Promise<void> {
|
|
|
|
|
// 기존 레이아웃 삭제
|
|
|
|
|
await prisma.screen_layouts.deleteMany({
|
|
|
|
|
where: { screen_id: screenId },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 새 레이아웃 저장
|
|
|
|
|
const layoutPromises = layoutData.components.map((component) =>
|
|
|
|
|
prisma.screen_layouts.create({
|
|
|
|
|
data: {
|
|
|
|
|
screen_id: screenId,
|
|
|
|
|
component_type: component.type,
|
|
|
|
|
component_id: component.id,
|
|
|
|
|
parent_id: component.parentId,
|
|
|
|
|
position_x: component.position.x,
|
|
|
|
|
position_y: component.position.y,
|
|
|
|
|
width: component.width,
|
|
|
|
|
height: component.height,
|
|
|
|
|
properties: component.properties,
|
|
|
|
|
display_order: component.displayOrder,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await Promise.all(layoutPromises);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 화면 미리보기 생성
|
|
|
|
|
async generatePreview(screenId: number): Promise<PreviewData> {
|
|
|
|
|
const screen = await prisma.screen_definitions.findUnique({
|
|
|
|
|
where: { screen_id: screenId },
|
|
|
|
|
include: {
|
|
|
|
|
layouts: {
|
|
|
|
|
include: { widgets: true },
|
|
|
|
|
orderBy: { display_order: "asc" },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!screen) {
|
|
|
|
|
throw new Error("Screen not found");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return this.buildPreviewData(screen);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private buildPreviewData(screen: any): PreviewData {
|
|
|
|
|
// 레이아웃을 트리 구조로 변환
|
|
|
|
|
const componentTree = this.buildComponentTree(screen.layouts);
|
|
|
|
|
|
|
|
|
|
// 위젯 데이터 바인딩
|
|
|
|
|
const boundWidgets = this.bindWidgetData(componentTree, screen.table_name);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
screenId: screen.screen_id,
|
|
|
|
|
screenName: screen.screen_name,
|
|
|
|
|
tableName: screen.table_name,
|
|
|
|
|
components: boundWidgets,
|
|
|
|
|
metadata: this.getTableMetadata(screen.table_name),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 2. 테이블 타입 연계 서비스
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// tableTypeIntegrationService.ts
|
|
|
|
|
export class TableTypeIntegrationService {
|
|
|
|
|
// 컬럼 정보 조회 (웹 타입 포함) - 현재 테이블 구조 기반
|
|
|
|
|
async getColumnInfo(tableName: string): Promise<ColumnInfo[]> {
|
|
|
|
|
const columns = await prisma.$queryRaw`
|
|
|
|
|
SELECT
|
|
|
|
|
c.column_name,
|
|
|
|
|
COALESCE(cl.column_label, c.column_name) as column_label,
|
|
|
|
|
c.data_type,
|
|
|
|
|
COALESCE(cl.web_type, 'text') as web_type,
|
|
|
|
|
c.is_nullable,
|
|
|
|
|
c.column_default,
|
|
|
|
|
c.character_maximum_length,
|
|
|
|
|
c.numeric_precision,
|
|
|
|
|
c.numeric_scale,
|
|
|
|
|
cl.detail_settings, -- 🎯 column_labels.detail_settings
|
|
|
|
|
cl.code_category, -- 🎯 column_labels.code_category
|
|
|
|
|
cl.reference_table, -- 🎯 column_labels.reference_table
|
|
|
|
|
cl.reference_column, -- 🎯 column_labels.reference_column
|
|
|
|
|
cl.is_visible, -- 🎯 column_labels.is_visible
|
|
|
|
|
cl.display_order, -- 🎯 column_labels.display_order
|
|
|
|
|
cl.description -- 🎯 column_labels.description
|
|
|
|
|
FROM information_schema.columns c
|
|
|
|
|
LEFT JOIN column_labels cl ON c.table_name = cl.table_name
|
|
|
|
|
AND c.column_name = cl.column_name
|
|
|
|
|
WHERE c.table_name = ${tableName}
|
|
|
|
|
ORDER BY COALESCE(cl.display_order, c.ordinal_position)
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
return columns as ColumnInfo[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 웹 타입 설정 - 현재 테이블 구조 기반
|
|
|
|
|
async setColumnWebType(
|
|
|
|
|
tableName: string,
|
|
|
|
|
columnName: string,
|
|
|
|
|
webType: string,
|
|
|
|
|
additionalSettings?: any
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
await prisma.column_labels.upsert({
|
|
|
|
|
where: {
|
|
|
|
|
table_name_column_name: {
|
|
|
|
|
table_name: tableName,
|
|
|
|
|
column_name: columnName,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
update: {
|
|
|
|
|
web_type: webType,
|
|
|
|
|
column_label: additionalSettings?.columnLabel,
|
|
|
|
|
detail_settings: additionalSettings?.detailSettings
|
|
|
|
|
? JSON.stringify(additionalSettings.detailSettings)
|
|
|
|
|
: null,
|
|
|
|
|
code_category: additionalSettings?.codeCategory,
|
|
|
|
|
reference_table: additionalSettings?.referenceTable,
|
|
|
|
|
reference_column: additionalSettings?.referenceColumn,
|
|
|
|
|
is_visible: additionalSettings?.isVisible ?? true,
|
|
|
|
|
display_order: additionalSettings?.displayOrder ?? 0,
|
|
|
|
|
description: additionalSettings?.description,
|
|
|
|
|
updated_date: new Date(),
|
|
|
|
|
},
|
|
|
|
|
create: {
|
|
|
|
|
table_name: tableName,
|
|
|
|
|
column_name: columnName,
|
|
|
|
|
column_label: additionalSettings?.columnLabel,
|
|
|
|
|
web_type: webType,
|
|
|
|
|
detail_settings: additionalSettings?.detailSettings
|
|
|
|
|
? JSON.stringify(additionalSettings.detailSettings)
|
|
|
|
|
: null,
|
|
|
|
|
code_category: additionalSettings?.codeCategory,
|
|
|
|
|
reference_table: additionalSettings?.referenceTable,
|
|
|
|
|
reference_column: additionalSettings?.referenceColumn,
|
|
|
|
|
is_visible: additionalSettings?.isVisible ?? true,
|
|
|
|
|
display_order: additionalSettings?.displayOrder ?? 0,
|
|
|
|
|
description: additionalSettings?.description,
|
|
|
|
|
created_date: new Date(),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 웹 타입 조회 - 현재 테이블 구조 기반
|
|
|
|
|
async getColumnWebType(
|
|
|
|
|
tableName: string,
|
|
|
|
|
columnName: string
|
|
|
|
|
): Promise<ColumnWebTypeSetting> {
|
|
|
|
|
const columnLabel = await prisma.column_labels.findUnique({
|
|
|
|
|
where: {
|
|
|
|
|
table_name_column_name: {
|
|
|
|
|
table_name: tableName,
|
|
|
|
|
column_name: columnName,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!columnLabel) {
|
|
|
|
|
return {
|
|
|
|
|
tableName,
|
|
|
|
|
columnName,
|
|
|
|
|
webType: "text", // 기본값
|
|
|
|
|
columnLabel: columnName,
|
|
|
|
|
detailSettings: {},
|
|
|
|
|
codeCategory: null,
|
|
|
|
|
referenceTable: null,
|
|
|
|
|
referenceColumn: null,
|
|
|
|
|
isVisible: true,
|
|
|
|
|
displayOrder: 0,
|
|
|
|
|
description: null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
tableName,
|
|
|
|
|
columnName,
|
|
|
|
|
webType: columnLabel.web_type || "text",
|
|
|
|
|
columnLabel: columnLabel.column_label,
|
|
|
|
|
detailSettings: columnLabel.detail_settings
|
|
|
|
|
? JSON.parse(columnLabel.detail_settings)
|
|
|
|
|
: {},
|
|
|
|
|
codeCategory: columnLabel.code_category,
|
|
|
|
|
referenceTable: columnLabel.reference_table,
|
|
|
|
|
referenceColumn: columnLabel.reference_column,
|
|
|
|
|
isVisible: columnLabel.is_visible ?? true,
|
|
|
|
|
displayOrder: columnLabel.display_order ?? 0,
|
|
|
|
|
description: columnLabel.description,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 웹 타입별 위젯 생성 - 현재 테이블 구조 기반
|
|
|
|
|
generateWidgetFromColumn(column: ColumnInfo): WidgetData {
|
|
|
|
|
const baseWidget = {
|
|
|
|
|
id: generateId(),
|
|
|
|
|
tableName: column.table_name, // 🎯 column_labels.table_name
|
|
|
|
|
columnName: column.column_name, // 🎯 column_labels.column_name
|
|
|
|
|
label: column.column_label || column.column_name, // 🎯 column_labels.column_label
|
|
|
|
|
required: column.is_nullable === "N",
|
|
|
|
|
readonly: false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// detail_settings JSON 파싱
|
|
|
|
|
const detailSettings = column.detail_settings
|
|
|
|
|
? JSON.parse(column.detail_settings)
|
|
|
|
|
: {};
|
|
|
|
|
|
|
|
|
|
switch (column.web_type) {
|
|
|
|
|
case "text":
|
|
|
|
|
return {
|
|
|
|
|
...baseWidget,
|
|
|
|
|
type: "text",
|
|
|
|
|
maxLength:
|
|
|
|
|
detailSettings.maxLength || column.character_maximum_length,
|
|
|
|
|
placeholder: `Enter ${column.column_label || column.column_name}`,
|
|
|
|
|
pattern: detailSettings.pattern,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
case "number":
|
|
|
|
|
return {
|
|
|
|
|
...baseWidget,
|
|
|
|
|
type: "number",
|
|
|
|
|
min: detailSettings.min,
|
|
|
|
|
max:
|
|
|
|
|
detailSettings.max ||
|
|
|
|
|
(column.numeric_precision
|
|
|
|
|
? Math.pow(10, column.numeric_precision) - 1
|
|
|
|
|
: undefined),
|
|
|
|
|
step:
|
|
|
|
|
detailSettings.step ||
|
|
|
|
|
(column.numeric_scale > 0
|
|
|
|
|
? Math.pow(10, -column.numeric_scale)
|
|
|
|
|
: 1),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
case "date":
|
|
|
|
|
return {
|
|
|
|
|
...baseWidget,
|
|
|
|
|
type: "date",
|
|
|
|
|
format: detailSettings.format || "YYYY-MM-DD",
|
|
|
|
|
minDate: detailSettings.minDate,
|
|
|
|
|
maxDate: detailSettings.maxDate,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
case "code":
|
|
|
|
|
return {
|
|
|
|
|
...baseWidget,
|
|
|
|
|
type: "select",
|
|
|
|
|
options: [], // 코드 카테고리에서 로드
|
|
|
|
|
codeCategory: column.code_category, // 🎯 column_labels.code_category
|
|
|
|
|
multiple: detailSettings.multiple || false,
|
|
|
|
|
searchable: detailSettings.searchable || false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
case "entity":
|
|
|
|
|
return {
|
|
|
|
|
...baseWidget,
|
|
|
|
|
type: "entity-select",
|
|
|
|
|
referenceTable: column.reference_table, // 🎯 column_labels.reference_table
|
|
|
|
|
referenceColumn: column.reference_column, // 🎯 column_labels.reference_column
|
|
|
|
|
searchable: detailSettings.searchable || true,
|
|
|
|
|
multiple: detailSettings.multiple || false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
case "textarea":
|
|
|
|
|
return {
|
|
|
|
|
...baseWidget,
|
|
|
|
|
type: "textarea",
|
|
|
|
|
rows: detailSettings.rows || 3,
|
|
|
|
|
maxLength:
|
|
|
|
|
detailSettings.maxLength || column.character_maximum_length,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
case "select":
|
|
|
|
|
return {
|
|
|
|
|
...baseWidget,
|
|
|
|
|
type: "select",
|
|
|
|
|
options: detailSettings.options || [],
|
|
|
|
|
multiple: detailSettings.multiple || false,
|
|
|
|
|
searchable: detailSettings.searchable || false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
case "checkbox":
|
|
|
|
|
return {
|
|
|
|
|
...baseWidget,
|
|
|
|
|
type: "checkbox",
|
|
|
|
|
defaultChecked: detailSettings.defaultChecked || false,
|
|
|
|
|
label: detailSettings.label || column.column_label,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
case "radio":
|
|
|
|
|
return {
|
|
|
|
|
...baseWidget,
|
|
|
|
|
type: "radio",
|
|
|
|
|
options: detailSettings.options || [],
|
|
|
|
|
inline: detailSettings.inline || false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
case "file":
|
|
|
|
|
return {
|
|
|
|
|
...baseWidget,
|
|
|
|
|
type: "file",
|
|
|
|
|
accept: detailSettings.accept || "*/*",
|
|
|
|
|
maxSize: detailSettings.maxSize || 10485760, // 10MB
|
|
|
|
|
multiple: detailSettings.multiple || false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
return {
|
|
|
|
|
...baseWidget,
|
|
|
|
|
type: "text",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 코드 카테고리 옵션 로드
|
|
|
|
|
async loadCodeOptions(codeCategory: string): Promise<CodeOption[]> {
|
|
|
|
|
const codes = await prisma.code_info.findMany({
|
|
|
|
|
where: { code_category: codeCategory, is_active: "Y" },
|
|
|
|
|
select: { code_value: true, code_name: true },
|
|
|
|
|
orderBy: { sort_order: "asc" },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return codes.map((code) => ({
|
|
|
|
|
value: code.code_value,
|
|
|
|
|
label: code.code_name,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 참조 테이블 옵션 로드
|
|
|
|
|
async loadReferenceOptions(
|
|
|
|
|
referenceTable: string
|
|
|
|
|
): Promise<ReferenceOption[]> {
|
|
|
|
|
const records = await prisma.$queryRaw`
|
|
|
|
|
SELECT DISTINCT ${referenceTable}.id, ${referenceTable}.name
|
|
|
|
|
FROM ${referenceTable}
|
|
|
|
|
ORDER BY ${referenceTable}.name
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
return records.map((record: any) => ({
|
|
|
|
|
value: record.id,
|
|
|
|
|
label: record.name,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## 🎬 사용 시나리오
|
|
|
|
|
|
2025-09-01 10:19:47 +09:00
|
|
|
### 1. 회사별 화면 관리
|
|
|
|
|
|
|
|
|
|
#### 일반 사용자 (회사 코드: 'COMP001')
|
|
|
|
|
|
|
|
|
|
1. **로그인**: 자신의 회사 코드로 시스템 로그인
|
|
|
|
|
2. **화면 목록 조회**: 자신이 속한 회사의 화면만 표시
|
|
|
|
|
3. **화면 생성**: 회사 코드가 자동으로 설정되어 생성
|
|
|
|
|
4. **메뉴 할당**: 자신의 회사 메뉴에만 화면 할당 가능
|
|
|
|
|
|
|
|
|
|
#### 관리자 (회사 코드: '\*')
|
|
|
|
|
|
|
|
|
|
1. **로그인**: 관리자 권한으로 시스템 로그인
|
|
|
|
|
2. **전체 화면 조회**: 모든 회사의 화면을 조회/수정 가능
|
|
|
|
|
3. **회사별 화면 관리**: 각 회사별로 화면 생성/수정/삭제
|
|
|
|
|
4. **크로스 회사 메뉴 할당**: 모든 회사의 메뉴에 화면 할당 가능
|
|
|
|
|
|
|
|
|
|
### 2. 웹 타입 설정 (테이블 타입관리)
|
2025-08-29 11:46:52 +09:00
|
|
|
|
|
|
|
|
1. **테이블 선택**: 테이블 타입관리에서 웹 타입을 설정할 테이블 선택
|
|
|
|
|
2. **컬럼 관리**: 해당 테이블의 컬럼 목록에서 웹 타입을 설정할 컬럼 선택
|
|
|
|
|
3. **웹 타입 선택**: 컬럼의 용도에 맞는 웹 타입 선택 (text, number, date, code, entity 등)
|
|
|
|
|
4. **추가 설정**: 웹 타입별 필요한 추가 설정 구성
|
|
|
|
|
- **code 타입**: 공통코드 카테고리 선택
|
|
|
|
|
- **entity 타입**: 참조 테이블 및 컬럼 지정
|
|
|
|
|
- **validation**: 유효성 검증 규칙 설정
|
|
|
|
|
- **display**: 표시 속성 설정
|
|
|
|
|
5. **저장**: 웹 타입 설정을 데이터베이스에 저장
|
|
|
|
|
6. **연계 확인**: 화면관리 시스템에서 자동 위젯 생성 확인
|
|
|
|
|
|
2025-09-01 10:19:47 +09:00
|
|
|
### 3. 새로운 화면 설계
|
2025-08-29 11:46:52 +09:00
|
|
|
|
|
|
|
|
1. **테이블 선택**: 테이블 타입관리에서 설계할 테이블 선택
|
|
|
|
|
2. **웹 타입 확인**: 각 컬럼의 웹 타입 설정 상태 확인
|
2025-09-01 10:19:47 +09:00
|
|
|
3. **화면 생성**: 화면명과 코드를 입력하여 새 화면 생성 (회사 코드 자동 설정)
|
2025-08-29 11:46:52 +09:00
|
|
|
4. **자동 위젯 생성**: 컬럼의 웹 타입에 따라 자동으로 위젯 생성
|
|
|
|
|
5. **컴포넌트 배치**: 드래그앤드롭으로 컴포넌트를 캔버스에 배치
|
2025-09-01 10:19:47 +09:00
|
|
|
6. **컨테이너 그룹화**: 관련 컴포넌트들을 그룹으로 묶어 깔끔하게 정렬
|
|
|
|
|
7. **속성 설정**: 각 컴포넌트의 속성을 Properties 패널에서 설정
|
|
|
|
|
8. **실시간 미리보기**: 설계한 화면을 실제 화면과 동일하게 확인
|
|
|
|
|
9. **저장**: 완성된 화면 레이아웃을 데이터베이스에 저장
|
2025-08-29 11:46:52 +09:00
|
|
|
|
2025-09-01 10:19:47 +09:00
|
|
|
### 4. 기존 화면 수정
|
2025-08-29 11:46:52 +09:00
|
|
|
|
2025-09-01 10:19:47 +09:00
|
|
|
1. **화면 선택**: 수정할 화면을 목록에서 선택 (권한 확인)
|
2025-08-29 11:46:52 +09:00
|
|
|
2. **레이아웃 로드**: 기존 레이아웃을 캔버스에 로드
|
|
|
|
|
3. **컴포넌트 수정**: 컴포넌트 추가/삭제/이동/수정
|
2025-09-01 10:19:47 +09:00
|
|
|
4. **그룹 구조 조정**: 컴포넌트 그룹화/그룹 해제/그룹 속성 변경
|
|
|
|
|
5. **속성 변경**: 컴포넌트 속성 변경
|
|
|
|
|
6. **변경사항 확인**: 실시간 미리보기로 변경사항 확인
|
|
|
|
|
7. **저장**: 수정된 레이아웃 저장
|
2025-08-29 11:46:52 +09:00
|
|
|
|
2025-09-01 10:19:47 +09:00
|
|
|
### 5. 템플릿 활용
|
2025-08-29 11:46:52 +09:00
|
|
|
|
2025-09-01 10:19:47 +09:00
|
|
|
1. **템플릿 선택**: 적합한 템플릿을 목록에서 선택 (회사별 템플릿)
|
2025-08-29 11:46:52 +09:00
|
|
|
2. **템플릿 적용**: 선택한 템플릿을 현재 화면에 적용
|
|
|
|
|
3. **커스터마이징**: 템플릿을 기반으로 필요한 부분 수정
|
|
|
|
|
4. **저장**: 커스터마이징된 화면 저장
|
|
|
|
|
|
2025-09-05 10:27:10 +09:00
|
|
|
### 6. 메뉴 할당 및 관리 (신규 완성)
|
|
|
|
|
|
|
|
|
|
#### 🆕 저장 후 자동 메뉴 할당
|
|
|
|
|
|
|
|
|
|
1. **화면 저장 완료**: 화면 설계 완료 후 저장 버튼 클릭
|
|
|
|
|
2. **메뉴 할당 모달 자동 팝업**: 저장 성공 시 즉시 메뉴 할당 모달 표시
|
|
|
|
|
3. **관리자 메뉴 검색**: 메뉴명, URL, 설명으로 실시간 검색
|
|
|
|
|
4. **기존 화면 확인**: 선택한 메뉴에 이미 할당된 화면 자동 감지
|
|
|
|
|
5. **교체 확인**: 기존 화면이 있을 때 교체 여부 확인 대화상자
|
|
|
|
|
6. **안전한 교체**: 기존 화면 제거 후 새 화면 할당
|
|
|
|
|
7. **성공 피드백**: 3초간 성공 화면 표시 후 자동으로 화면 목록으로 이동
|
|
|
|
|
|
|
|
|
|
#### 기존 메뉴 할당 방식
|
2025-09-01 10:19:47 +09:00
|
|
|
|
|
|
|
|
1. **메뉴 선택**: 화면을 할당할 메뉴 선택 (회사별 메뉴만 표시)
|
|
|
|
|
2. **화면 할당**: 선택한 화면을 메뉴에 할당
|
|
|
|
|
3. **할당 순서 조정**: 메뉴 내 화면 표시 순서 조정
|
|
|
|
|
4. **할당 해제**: 메뉴에서 화면 할당 해제
|
|
|
|
|
5. **권한 확인**: 메뉴 할당 시 회사 코드 일치 여부 확인
|
|
|
|
|
|
|
|
|
|
### 7. 화면 배포
|
2025-08-29 11:46:52 +09:00
|
|
|
|
|
|
|
|
1. **화면 활성화**: 설계 완료된 화면을 활성 상태로 변경
|
2025-09-01 10:19:47 +09:00
|
|
|
2. **권한 설정**: 화면 접근 권한 설정 (회사별 권한)
|
|
|
|
|
3. **메뉴 연결**: 메뉴 시스템에 화면 연결 (회사별 메뉴)
|
2025-08-29 11:46:52 +09:00
|
|
|
4. **테스트**: 실제 환경에서 화면 동작 테스트
|
|
|
|
|
5. **배포**: 운영 환경에 화면 배포
|
|
|
|
|
|
2025-09-01 15:22:47 +09:00
|
|
|
## 📅 개발 계획 및 진행상황
|
|
|
|
|
|
|
|
|
|
### ✅ Phase 1: 기본 구조 및 데이터베이스 (완료)
|
|
|
|
|
|
|
|
|
|
- [x] 데이터베이스 스키마 설계 및 생성
|
|
|
|
|
- [x] 기본 API 구조 설계
|
|
|
|
|
- [x] 화면 정의 및 레이아웃 테이블 생성
|
|
|
|
|
- [x] 기본 CRUD API 구현
|
2025-09-01 18:42:59 +09:00
|
|
|
- [x] 화면 코드 자동 생성 API 구현
|
|
|
|
|
- [x] 회사별 권한 관리 시스템 구현
|
2025-09-01 15:22:47 +09:00
|
|
|
|
|
|
|
|
**구현 완료 사항:**
|
|
|
|
|
|
2025-09-01 18:42:59 +09:00
|
|
|
- PostgreSQL용 화면관리 테이블 스키마 생성
|
2025-09-01 15:22:47 +09:00
|
|
|
- Node.js 백엔드 API 구조 설계 및 구현
|
|
|
|
|
- Prisma ORM을 통한 데이터베이스 연동
|
|
|
|
|
- 회사별 권한 관리 시스템 구현
|
2025-09-01 18:42:59 +09:00
|
|
|
- 화면 코드 자동 생성 (회사코드\_숫자 형식)
|
2025-09-01 15:22:47 +09:00
|
|
|
|
|
|
|
|
### ✅ Phase 2: 드래그앤드롭 핵심 기능 (완료)
|
|
|
|
|
|
|
|
|
|
- [x] 드래그앤드롭 라이브러리 선택 및 구현
|
|
|
|
|
- [x] 그리드 시스템 구현
|
|
|
|
|
- [x] 컴포넌트 배치 및 이동 로직 구현
|
|
|
|
|
- [x] 컴포넌트 크기 조정 기능 구현
|
2025-08-29 11:46:52 +09:00
|
|
|
|
2025-09-01 15:22:47 +09:00
|
|
|
**구현 완료 사항:**
|
2025-08-29 11:46:52 +09:00
|
|
|
|
2025-09-01 15:22:47 +09:00
|
|
|
- HTML5 Drag and Drop API 기반 드래그앤드롭 시스템
|
|
|
|
|
- 80px x 60px 그리드 기반 레이아웃 시스템
|
|
|
|
|
- 컴포넌트 추가, 삭제, 이동, 재배치 기능
|
|
|
|
|
- 실행취소/다시실행 기능 (최대 50개 히스토리)
|
|
|
|
|
- 키보드 단축키 지원 (Ctrl+Z, Ctrl+Y)
|
2025-08-29 11:46:52 +09:00
|
|
|
|
2025-09-01 15:22:47 +09:00
|
|
|
### ✅ Phase 3: 컴포넌트 라이브러리 (완료)
|
2025-08-29 11:46:52 +09:00
|
|
|
|
2025-09-01 15:22:47 +09:00
|
|
|
- [x] 기본 입력 컴포넌트 구현
|
|
|
|
|
- [x] 선택 컴포넌트 구현
|
|
|
|
|
- [x] 표시 컴포넌트 구현
|
|
|
|
|
- [x] 레이아웃 컴포넌트 구현
|
2025-08-29 11:46:52 +09:00
|
|
|
|
2025-09-01 15:22:47 +09:00
|
|
|
**구현 완료 사항:**
|
2025-08-29 11:46:52 +09:00
|
|
|
|
2025-09-01 15:22:47 +09:00
|
|
|
- 13가지 웹 타입 지원: text, email, tel, number, decimal, date, datetime, select, dropdown, textarea, text_area, checkbox, boolean, radio, code, entity, file
|
|
|
|
|
- 각 타입별 고유 아이콘 및 색상 시스템
|
|
|
|
|
- Shadcn UI 컴포넌트 기반 렌더링
|
|
|
|
|
- 실시간 미리보기 시스템 (`RealtimePreview` 컴포넌트)
|
2025-08-29 11:46:52 +09:00
|
|
|
|
2025-09-01 15:22:47 +09:00
|
|
|
### ✅ Phase 4: 테이블 타입 연계 (완료)
|
2025-08-29 11:46:52 +09:00
|
|
|
|
2025-09-01 15:22:47 +09:00
|
|
|
- [x] 테이블 타입관리와 연계 API 구현
|
|
|
|
|
- [x] 웹 타입 설정 및 관리 기능 구현
|
|
|
|
|
- [x] 웹 타입별 추가 설정 관리 기능 구현
|
|
|
|
|
- [x] 자동 위젯 생성 로직 구현
|
|
|
|
|
- [x] 데이터 바인딩 시스템 구현
|
|
|
|
|
- [x] 유효성 검증 규칙 자동 적용
|
2025-08-29 11:46:52 +09:00
|
|
|
|
2025-09-01 15:22:47 +09:00
|
|
|
**구현 완료 사항:**
|
2025-08-29 11:46:52 +09:00
|
|
|
|
2025-09-01 15:22:47 +09:00
|
|
|
- PostgreSQL `information_schema` 기반 테이블/컬럼 메타데이터 조회
|
|
|
|
|
- `table_labels`, `column_labels` 테이블과 완벽 연계
|
|
|
|
|
- 웹 타입별 자동 위젯 생성 및 렌더링
|
|
|
|
|
- 검색 및 페이징 기능이 포함된 테이블 선택 UI
|
|
|
|
|
- 실제 데이터베이스 값 기반 테이블 타입 표시
|
2025-08-29 11:46:52 +09:00
|
|
|
|
2025-09-01 15:22:47 +09:00
|
|
|
### ✅ Phase 5: 미리보기 및 템플릿 (완료)
|
2025-08-29 11:46:52 +09:00
|
|
|
|
2025-09-01 15:22:47 +09:00
|
|
|
- [x] 실시간 미리보기 시스템 구현
|
|
|
|
|
- [x] 기본 템플릿 구현
|
|
|
|
|
- [x] 템플릿 저장 및 적용 기능 구현
|
|
|
|
|
- [x] 템플릿 공유 시스템 구현
|
|
|
|
|
|
|
|
|
|
**구현 완료 사항:**
|
|
|
|
|
|
|
|
|
|
- 실시간 미리보기 시스템 (`RealtimePreview` 컴포넌트)
|
|
|
|
|
- 캔버스에 배치된 컴포넌트의 실제 웹 위젯 렌더링
|
|
|
|
|
- 템플릿 관리 시스템 (`TemplateManager` 컴포넌트)
|
|
|
|
|
- 화면 목록 및 생성 기능 (`ScreenList` 컴포넌트)
|
|
|
|
|
|
2025-09-01 18:42:59 +09:00
|
|
|
### ✅ Phase 6: 통합 및 테스트 (완료)
|
2025-09-01 15:22:47 +09:00
|
|
|
|
|
|
|
|
- [x] 전체 시스템 통합 테스트
|
|
|
|
|
- [x] 성능 최적화
|
2025-09-01 18:42:59 +09:00
|
|
|
- [x] 레이아웃 저장/로드 기능 구현
|
|
|
|
|
- [x] 메뉴-화면 할당 기능 구현
|
|
|
|
|
- [x] 인터랙티브 화면 뷰어 구현
|
|
|
|
|
- [x] 사용자 피드백 반영 완료
|
2025-09-05 10:27:10 +09:00
|
|
|
- [x] 🆕 화면 저장 후 메뉴 할당 워크플로우 구현
|
2025-08-29 11:46:52 +09:00
|
|
|
|
2025-09-01 18:42:59 +09:00
|
|
|
**구현 완료 사항:**
|
2025-09-01 15:22:47 +09:00
|
|
|
|
|
|
|
|
- 프론트엔드/백엔드 통합 완료
|
|
|
|
|
- Docker 환경에서 실행 가능
|
|
|
|
|
- 기본 기능 테스트 완료
|
2025-09-01 18:42:59 +09:00
|
|
|
- 레이아웃 저장/로드 API 및 UI 구현
|
|
|
|
|
- 메뉴 관리에서 화면 할당 기능 구현
|
|
|
|
|
- 할당된 화면을 실제 사용 가능한 인터랙티브 화면으로 렌더링
|
|
|
|
|
- 실제 사용자 입력 및 상호작용 가능한 완전 기능 화면 구현
|
2025-09-05 10:27:10 +09:00
|
|
|
- 🆕 **완전한 메뉴 할당 워크플로우**: 저장 → 메뉴 할당 모달 → 기존 화면 교체 확인 → 성공 피드백 → 목록 복귀
|
2025-09-01 15:22:47 +09:00
|
|
|
|
|
|
|
|
## 🎯 현재 구현된 핵심 기능
|
|
|
|
|
|
|
|
|
|
### 1. 화면 설계기 (Screen Designer)
|
|
|
|
|
|
|
|
|
|
- **전체 화면 레이아웃**: 3단 구조 (좌측 테이블 선택, 중앙 캔버스, 우측 스타일 편집)
|
|
|
|
|
- **드래그앤드롭**: 테이블/컬럼을 캔버스로 드래그하여 위젯 생성
|
|
|
|
|
- **실시간 미리보기**: 배치된 컴포넌트가 실제 웹 위젯으로 표시
|
|
|
|
|
- **실행취소/다시실행**: 최대 50개 히스토리 관리, 키보드 단축키 지원
|
|
|
|
|
|
|
|
|
|
### 2. 테이블 타입 연계
|
|
|
|
|
|
|
|
|
|
- **실제 DB 연동**: PostgreSQL `information_schema` 기반 메타데이터 조회
|
|
|
|
|
- **웹 타입 지원**: 13가지 웹 타입별 고유 아이콘 및 렌더링
|
|
|
|
|
- **검색/페이징**: 대량 테이블 목록을 위한 성능 최적화
|
|
|
|
|
- **자동 위젯 생성**: 컬럼의 웹 타입에 따른 스마트 위젯 생성
|
|
|
|
|
|
|
|
|
|
### 3. 컴포넌트 시스템
|
|
|
|
|
|
|
|
|
|
- **다양한 위젯 타입**: text, email, tel, number, decimal, date, datetime, select, dropdown, textarea, checkbox, radio, code, entity, file
|
|
|
|
|
- **타입별 아이콘**: 각 웹 타입마다 고유한 색상과 아이콘
|
|
|
|
|
- **실시간 렌더링**: Shadcn UI 기반 실제 웹 컴포넌트 렌더링
|
|
|
|
|
|
|
|
|
|
### 4. 사용자 경험
|
|
|
|
|
|
|
|
|
|
- **직관적 인터페이스**: 드래그앤드롭 기반 화면 설계
|
|
|
|
|
- **실시간 피드백**: 컴포넌트 배치 즉시 미리보기 표시
|
2025-09-01 17:05:36 +09:00
|
|
|
- **다중선택**: Shift+클릭 및 마키 선택 지원, 다중 드래그 이동
|
|
|
|
|
- **정렬/분배**: 그룹 내 좌/중앙/우·상/중앙/하 정렬 및 균등 분배
|
|
|
|
|
- **키보드 지원**: Ctrl/Cmd+Z, Ctrl/Cmd+Y 단축키
|
2025-09-01 15:22:47 +09:00
|
|
|
- **반응형 UI**: 전체 화면 활용한 효율적인 레이아웃
|
|
|
|
|
|
2025-09-01 18:42:59 +09:00
|
|
|
### 5. 인터랙티브 화면 뷰어 (신규 완성)
|
|
|
|
|
|
|
|
|
|
- **실제 사용자 입력**: 설계된 화면에서 실제 데이터 입력 및 편집 가능
|
|
|
|
|
- **완전 기능 위젯**: 모든 웹 타입별 실제 동작하는 위젯 구현
|
|
|
|
|
- **폼 데이터 관리**: 실시간 폼 상태 관리 및 데이터 수집
|
|
|
|
|
- **저장 기능**: 입력된 데이터를 수집하여 저장 처리
|
|
|
|
|
- **메뉴 연동**: 메뉴 클릭 시 할당된 인터랙티브 화면으로 자동 이동
|
|
|
|
|
|
2025-09-03 15:23:12 +09:00
|
|
|
### 🆕 6. 실시간 데이터 테이블 (2025.09 추가)
|
|
|
|
|
|
|
|
|
|
#### **InteractiveDataTable 컴포넌트**
|
|
|
|
|
|
|
|
|
|
실제 화면에서 동작하는 완전한 데이터 테이블 구현
|
|
|
|
|
|
|
|
|
|
**핵심 기능**
|
|
|
|
|
|
|
|
|
|
- **실시간 데이터 조회**: PostgreSQL 데이터베이스에서 직접 데이터 로드
|
|
|
|
|
- **페이지네이션**: 대용량 데이터 효율적 탐색 (페이지당 항목 수 설정 가능)
|
|
|
|
|
- **다중 검색 필터**: 웹타입별 맞춤형 검색 UI (text, number, date, select 등)
|
|
|
|
|
- **정렬 기능**: 컬럼별 오름차순/내림차순 정렬 지원
|
|
|
|
|
- **반응형 레이아웃**: 격자 시스템 기반 컬럼 너비 조정
|
|
|
|
|
|
|
|
|
|
**구현 코드**
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// InteractiveDataTable.tsx
|
|
|
|
|
export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|
|
|
|
component,
|
|
|
|
|
className = "",
|
|
|
|
|
style = {},
|
|
|
|
|
}) => {
|
|
|
|
|
const [data, setData] = useState<Record<string, any>[]>([]);
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
|
|
|
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
|
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
|
|
|
const [total, setTotal] = useState(0);
|
|
|
|
|
|
|
|
|
|
// 데이터 로드 함수
|
|
|
|
|
const loadData = useCallback(
|
|
|
|
|
async (page: number = 1, searchParams: Record<string, any> = {}) => {
|
|
|
|
|
if (!component.tableName) return;
|
|
|
|
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const result = await tableTypeApi.getTableData(component.tableName, {
|
|
|
|
|
page,
|
|
|
|
|
size: component.pagination?.pageSize || 10,
|
|
|
|
|
search: searchParams,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
setData(result.data);
|
|
|
|
|
setTotal(result.total);
|
|
|
|
|
setTotalPages(result.totalPages);
|
|
|
|
|
setCurrentPage(result.page);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("❌ 테이블 데이터 조회 실패:", error);
|
|
|
|
|
setData([]);
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[component.tableName, component.pagination?.pageSize]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 검색 실행
|
|
|
|
|
const handleSearch = useCallback(() => {
|
|
|
|
|
loadData(1, searchValues);
|
|
|
|
|
}, [searchValues, loadData]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className={`bg-white ${className}`} style={style}>
|
|
|
|
|
{/* 헤더: 제목, 로딩 상태, 검색 버튼 */}
|
|
|
|
|
<div className="border-b bg-gray-50 px-4 py-3">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<h3 className="text-sm font-medium text-gray-900">
|
|
|
|
|
{component.title || component.label}
|
|
|
|
|
{loading && (
|
|
|
|
|
<Badge variant="secondary" className="ml-2">
|
|
|
|
|
로딩중...
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
</h3>
|
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
{component.showSearchButton && (
|
|
|
|
|
<Button size="sm" onClick={handleSearch} disabled={loading}>
|
|
|
|
|
{component.searchButtonText || "검색"}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 검색 필터 */}
|
|
|
|
|
{component.filters?.length > 0 && (
|
|
|
|
|
<div className="mt-3 space-y-2">
|
|
|
|
|
<div className="text-xs font-medium text-gray-700">검색 필터</div>
|
|
|
|
|
<div
|
|
|
|
|
className="grid gap-2"
|
|
|
|
|
style={{
|
|
|
|
|
gridTemplateColumns: component.filters
|
|
|
|
|
.map((filter) => `${filter.gridColumns || 3}fr`)
|
|
|
|
|
.join(" "),
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{component.filters.map((filter) => renderSearchFilter(filter))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 테이블 데이터 */}
|
|
|
|
|
<div className="flex-1 overflow-auto p-4">
|
|
|
|
|
{/* 헤더 행 */}
|
|
|
|
|
<div
|
|
|
|
|
className="gap-2 border-b pb-2 text-xs font-medium text-gray-700"
|
|
|
|
|
style={{
|
|
|
|
|
display: "grid",
|
|
|
|
|
gridTemplateColumns: component.columns
|
|
|
|
|
?.filter((col) => col.visible)
|
|
|
|
|
.map((col) => `${col.gridColumns || 2}fr`)
|
|
|
|
|
.join(" "),
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{component.columns
|
|
|
|
|
?.filter((col) => col.visible)
|
|
|
|
|
.map((column) => (
|
|
|
|
|
<div key={column.id} className="truncate">
|
|
|
|
|
{column.label}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 데이터 행들 */}
|
|
|
|
|
{loading ? (
|
|
|
|
|
<div className="py-8 text-center text-xs text-gray-500">
|
|
|
|
|
데이터를 불러오는 중...
|
|
|
|
|
</div>
|
|
|
|
|
) : data.length > 0 ? (
|
|
|
|
|
data.map((row, rowIndex) => (
|
|
|
|
|
<div
|
|
|
|
|
key={rowIndex}
|
|
|
|
|
className="gap-2 py-1 text-xs text-gray-600 hover:bg-gray-50"
|
|
|
|
|
style={{
|
|
|
|
|
display: "grid",
|
|
|
|
|
gridTemplateColumns: component.columns
|
|
|
|
|
?.filter((col) => col.visible)
|
|
|
|
|
.map((col) => `${col.gridColumns || 2}fr`)
|
|
|
|
|
.join(" "),
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{component.columns
|
|
|
|
|
?.filter((col) => col.visible)
|
|
|
|
|
.map((column) => (
|
|
|
|
|
<div key={column.id} className="truncate">
|
|
|
|
|
{formatCellValue(row[column.columnName], column)}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
))
|
|
|
|
|
) : (
|
|
|
|
|
<div className="py-8 text-center text-xs text-gray-500">
|
|
|
|
|
검색 결과가 없습니다
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 페이지네이션 */}
|
|
|
|
|
{component.pagination?.enabled && totalPages > 1 && (
|
|
|
|
|
<div className="border-t bg-gray-50 px-4 py-2">
|
|
|
|
|
<div className="flex items-center justify-between text-xs text-gray-600">
|
|
|
|
|
{component.pagination.showPageInfo && (
|
|
|
|
|
<div>
|
|
|
|
|
총 {total.toLocaleString()}개 중{" "}
|
|
|
|
|
{((currentPage - 1) * pageSize + 1).toLocaleString()}-
|
|
|
|
|
{Math.min(currentPage * pageSize, total).toLocaleString()}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<div className="flex items-center space-x-1">
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => handlePageChange(currentPage - 1)}
|
|
|
|
|
disabled={currentPage === 1}
|
|
|
|
|
>
|
|
|
|
|
이전
|
|
|
|
|
</Button>
|
|
|
|
|
<span className="px-2">
|
|
|
|
|
{currentPage} / {totalPages}
|
|
|
|
|
</span>
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => handlePageChange(currentPage + 1)}
|
|
|
|
|
disabled={currentPage === totalPages}
|
|
|
|
|
>
|
|
|
|
|
다음
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**데이터 포맷팅**
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// 셀 값 포맷팅
|
|
|
|
|
const formatCellValue = (value: any, column: DataTableColumn) => {
|
|
|
|
|
if (value === null || value === undefined) return "";
|
|
|
|
|
|
|
|
|
|
switch (column.widgetType) {
|
|
|
|
|
case "date":
|
|
|
|
|
if (value) {
|
|
|
|
|
try {
|
|
|
|
|
const date = new Date(value);
|
|
|
|
|
return date.toLocaleDateString("ko-KR");
|
|
|
|
|
} catch {
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case "datetime":
|
|
|
|
|
if (value) {
|
|
|
|
|
try {
|
|
|
|
|
const date = new Date(value);
|
|
|
|
|
return date.toLocaleString("ko-KR");
|
|
|
|
|
} catch {
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case "number":
|
|
|
|
|
case "decimal":
|
|
|
|
|
if (typeof value === "number") {
|
|
|
|
|
return value.toLocaleString();
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
return String(value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return String(value);
|
|
|
|
|
};
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**InteractiveScreenViewer 통합**
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// InteractiveScreenViewer.tsx
|
|
|
|
|
const renderInteractiveWidget = (comp: ComponentData) => {
|
|
|
|
|
// 데이터 테이블 컴포넌트 처리
|
|
|
|
|
if (comp.type === "datatable") {
|
|
|
|
|
return (
|
|
|
|
|
<InteractiveDataTable
|
|
|
|
|
component={comp as DataTableComponent}
|
|
|
|
|
className="h-full w-full"
|
|
|
|
|
style={{
|
|
|
|
|
width: "100%",
|
|
|
|
|
height: "100%",
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 기존 위젯 처리...
|
|
|
|
|
};
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### **사용자 경험 (UX)**
|
|
|
|
|
|
|
|
|
|
**1. 관리자 (화면 설계)**
|
|
|
|
|
|
|
|
|
|
1. 드래그앤드롭으로 데이터 테이블 컴포넌트 배치
|
|
|
|
|
2. 속성 패널에서 테이블 선택 및 컬럼 설정
|
|
|
|
|
3. 검색 필터 추가 및 페이지네이션 설정
|
|
|
|
|
4. 실시간 미리보기로 결과 확인
|
|
|
|
|
|
|
|
|
|
**2. 사용자 (실제 화면)**
|
|
|
|
|
|
|
|
|
|
1. 메뉴를 통해 할당된 화면 접근
|
|
|
|
|
2. 검색 필터로 원하는 데이터 필터링
|
|
|
|
|
3. 페이지네이션으로 대용량 데이터 탐색
|
|
|
|
|
4. 실시간 데이터 로딩 및 정렬
|
|
|
|
|
|
|
|
|
|
#### **🎨 Shadcn UI 기반 모던 디자인**
|
|
|
|
|
|
|
|
|
|
**핵심 컴포넌트**
|
|
|
|
|
|
|
|
|
|
- **Card**: 전체 테이블을 감싸는 메인 컨테이너
|
|
|
|
|
- **Table**: Shadcn Table 컴포넌트로 표준화된 테이블 UI
|
|
|
|
|
- **Badge**: 로딩 상태 및 필터 개수 표시
|
|
|
|
|
- **Button**: 일관된 액션 버튼 디자인
|
|
|
|
|
- **Separator**: 섹션 구분선
|
|
|
|
|
|
|
|
|
|
**디자인 특징**
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// 메인 카드 레이아웃
|
|
|
|
|
<Card className="h-full flex flex-col">
|
|
|
|
|
<CardHeader className="pb-3">
|
|
|
|
|
{/* 아이콘 + 제목 + 액션 버튼들 */}
|
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
<Database className="h-4 w-4 text-muted-foreground" />
|
|
|
|
|
<CardTitle className="text-lg">{title}</CardTitle>
|
|
|
|
|
{loading && (
|
|
|
|
|
<Badge variant="secondary" className="flex items-center gap-1">
|
|
|
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
|
|
|
로딩중...
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
|
|
|
|
{/* Shadcn Table 사용 */}
|
|
|
|
|
<CardContent className="flex-1 overflow-hidden p-0">
|
|
|
|
|
<Table>
|
|
|
|
|
<TableHeader>
|
|
|
|
|
<TableRow>
|
|
|
|
|
{columns.map((column) => (
|
|
|
|
|
<TableHead className="font-semibold">{column.label}</TableHead>
|
|
|
|
|
))}
|
|
|
|
|
</TableRow>
|
|
|
|
|
</TableHeader>
|
|
|
|
|
<TableBody>{/* 로딩, 데이터, 빈 상태 처리 */}</TableBody>
|
|
|
|
|
</Table>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**시각적 개선사항**
|
|
|
|
|
|
|
|
|
|
- **아이콘 통합**: Lucide React 아이콘으로 시각적 일관성
|
|
|
|
|
- **로딩 애니메이션**: 스피너 아이콘으로 실시간 피드백
|
|
|
|
|
- **상태별 메시지**: 빈 데이터, 로딩, 에러 상태별 적절한 UI
|
|
|
|
|
- **호버 효과**: 테이블 행 호버 시 시각적 피드백
|
|
|
|
|
- **반응형 버튼**: 아이콘 + 텍스트 조합으로 명확한 액션 표시
|
|
|
|
|
|
|
|
|
|
#### **기술적 특징**
|
|
|
|
|
|
|
|
|
|
- **성능 최적화**: React.useMemo를 활용한 메모이제이션
|
|
|
|
|
- **보안**: SQL 인젝션 방지 및 입력값 검증
|
|
|
|
|
- **확장성**: 웹타입별 검색 필터 및 데이터 포맷터
|
|
|
|
|
- **반응형**: CSS Grid 기반 유연한 레이아웃
|
|
|
|
|
- **접근성**: Shadcn UI의 WAI-ARIA 표준 준수
|
|
|
|
|
- **타입 안전성**: TypeScript 완전 지원
|
|
|
|
|
|
2025-09-01 15:22:47 +09:00
|
|
|
## 🚀 다음 단계 계획
|
|
|
|
|
|
2025-09-03 11:32:09 +09:00
|
|
|
### 1. 웹타입별 상세 설정 기능 구현 (진행 예정)
|
|
|
|
|
|
|
|
|
|
#### 📋 구현 계획 개요
|
|
|
|
|
|
|
|
|
|
각 웹 타입(date, number, select 등)에 대한 세부적인 설정을 가능하게 하여 더 정교한 폼 컨트롤을 제공
|
|
|
|
|
|
|
|
|
|
#### 🎯 단계별 구현 계획
|
|
|
|
|
|
|
|
|
|
##### Phase 1: 타입 정의 및 인터페이스 설계
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// 웹타입별 설정 인터페이스
|
|
|
|
|
interface DateTypeConfig {
|
|
|
|
|
format: "YYYY-MM-DD" | "YYYY-MM-DD HH:mm" | "YYYY-MM-DD HH:mm:ss";
|
|
|
|
|
showTime: boolean;
|
|
|
|
|
minDate?: string;
|
|
|
|
|
maxDate?: string;
|
|
|
|
|
defaultValue?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface NumberTypeConfig {
|
|
|
|
|
min?: number;
|
|
|
|
|
max?: number;
|
|
|
|
|
step?: number;
|
|
|
|
|
format?: "integer" | "decimal" | "currency" | "percentage";
|
|
|
|
|
decimalPlaces?: number;
|
|
|
|
|
thousandSeparator?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface SelectTypeConfig {
|
|
|
|
|
options: Array<{ label: string; value: string }>;
|
|
|
|
|
multiple?: boolean;
|
|
|
|
|
searchable?: boolean;
|
|
|
|
|
placeholder?: string;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
##### Phase 2: PropertiesPanel 확장
|
|
|
|
|
|
|
|
|
|
- 웹 타입 선택 시 해당 타입의 세부 설정 UI 동적 표시
|
|
|
|
|
- 각 타입별 전용 설정 컴포넌트 생성
|
|
|
|
|
- 실시간 설정값 업데이트 및 미리보기
|
|
|
|
|
|
|
|
|
|
##### Phase 3: 우선순위 타입별 구현
|
|
|
|
|
|
|
|
|
|
1. **날짜/시간 (date, datetime)**: 날짜 형식, 시간 포함 여부, 날짜 범위
|
|
|
|
|
2. **숫자 (number, decimal)**: 범위, 형식, 소수점, 천 단위 구분자
|
|
|
|
|
3. **선택박스 (select, dropdown)**: 동적 옵션 관리, 다중 선택, 검색 기능
|
|
|
|
|
4. **텍스트 (text, textarea)**: 길이 제한, 입력 패턴, 형식 검증
|
|
|
|
|
5. **파일 (file)**: 파일 형식 제한, 크기 제한, 다중 업로드
|
|
|
|
|
|
|
|
|
|
##### Phase 4: RealtimePreview 업데이트
|
|
|
|
|
|
|
|
|
|
설정값에 따른 실제 렌더링 로직 구현 (input 속성, 검증 규칙 등)
|
|
|
|
|
|
|
|
|
|
##### Phase 5: 저장/불러오기
|
|
|
|
|
|
|
|
|
|
컴포넌트 데이터에 webTypeConfig 포함하여 레이아웃 저장 시 설정값도 함께 저장
|
|
|
|
|
|
2025-09-03 15:23:12 +09:00
|
|
|
### 2. 컴포넌트 그룹화 기능 (완료)0
|
2025-09-01 15:22:47 +09:00
|
|
|
|
2025-09-01 17:05:36 +09:00
|
|
|
- [x] 여러 위젯을 컨테이너로 그룹화
|
|
|
|
|
- [x] 부모-자식 관계 설정(parentId)
|
|
|
|
|
- [x] 그룹 단위 이동
|
|
|
|
|
- [x] 그룹 UI 단순화(헤더/박스 제거)
|
|
|
|
|
- [x] 그룹 내 정렬/균등 분배 도구(아이콘 UI)
|
2025-09-01 17:57:52 +09:00
|
|
|
- [x] 그룹 단위 삭제/복사/붙여넣기
|
2025-09-01 15:22:47 +09:00
|
|
|
|
2025-09-01 18:42:59 +09:00
|
|
|
### 2. 레이아웃 저장/로드 (완료)
|
|
|
|
|
|
|
|
|
|
- [x] 설계한 화면을 데이터베이스에 저장
|
|
|
|
|
- [x] 저장된 화면 불러오기 기능
|
|
|
|
|
- [x] 변경사항 표시 및 저장 버튼 활성화
|
|
|
|
|
- [x] 레이아웃 데이터 JSON 형태 저장
|
|
|
|
|
- [ ] 버전 관리 시스템 (향후 계획)
|
|
|
|
|
|
|
|
|
|
### 3. 메뉴-화면 할당 시스템 (완료)
|
|
|
|
|
|
|
|
|
|
- [x] 메뉴 관리에서 화면 할당 기능
|
|
|
|
|
- [x] 회사별 메뉴 필터링
|
|
|
|
|
- [x] 화면-메뉴 연결 관리
|
|
|
|
|
- [x] 할당된 화면 목록 조회
|
|
|
|
|
- [x] 화면 할당 해제 기능
|
2025-09-01 15:22:47 +09:00
|
|
|
|
2025-09-01 18:42:59 +09:00
|
|
|
### 4. 인터랙티브 화면 뷰어 (완료)
|
2025-09-01 15:22:47 +09:00
|
|
|
|
2025-09-01 18:42:59 +09:00
|
|
|
- [x] 실제 사용자 입력 가능한 화면 렌더링
|
|
|
|
|
- [x] 모든 웹 타입별 실제 위젯 구현
|
|
|
|
|
- [x] 폼 데이터 상태 관리
|
|
|
|
|
- [x] 실시간 데이터 바인딩
|
|
|
|
|
- [x] 저장 기능 및 토스트 알림
|
2025-09-01 15:22:47 +09:00
|
|
|
|
2025-09-01 18:42:59 +09:00
|
|
|
### 5. 데이터 바인딩 (부분 완료)
|
2025-09-01 15:22:47 +09:00
|
|
|
|
2025-09-01 18:42:59 +09:00
|
|
|
- [x] 실제 데이터베이스와 연결 (메타데이터 연동 완료)
|
|
|
|
|
- [x] 폼 제출 및 데이터 수집
|
|
|
|
|
- [x] 실시간 폼 데이터 관리
|
|
|
|
|
- [ ] 실제 데이터베이스 저장 API (향후 계획)
|
|
|
|
|
- [ ] 유효성 검증 시스템 (향후 계획)
|
|
|
|
|
|
|
|
|
|
### 6. 반응형 레이아웃 (향후 계획)
|
2025-09-01 15:22:47 +09:00
|
|
|
|
|
|
|
|
- [ ] 다양한 화면 크기에 대응
|
|
|
|
|
- [ ] 모바일/태블릿/데스크톱 최적화
|
|
|
|
|
- [ ] 브레이크포인트 설정
|
|
|
|
|
|
2025-09-01 18:42:59 +09:00
|
|
|
### 7. 고급 기능 (향후 계획)
|
2025-09-01 15:22:47 +09:00
|
|
|
|
|
|
|
|
- [ ] 조건부 표시 로직
|
|
|
|
|
- [ ] 계산 필드 구현
|
|
|
|
|
- [ ] 동적 옵션 로딩
|
|
|
|
|
- [ ] 파일 업로드 처리
|
2025-09-01 18:42:59 +09:00
|
|
|
- [ ] 실제 데이터베이스 CRUD 연동
|
|
|
|
|
- [ ] 워크플로우 통합
|
2025-09-01 15:22:47 +09:00
|
|
|
|
|
|
|
|
## 🛠️ 기술 스택 (현재 구현)
|
|
|
|
|
|
|
|
|
|
### 프론트엔드
|
|
|
|
|
|
|
|
|
|
- **Framework**: Next.js 15.4.4 (App Router)
|
|
|
|
|
- **Language**: TypeScript
|
|
|
|
|
- **UI Library**: React 18
|
|
|
|
|
- **Styling**: Tailwind CSS
|
|
|
|
|
- **UI Components**: Shadcn UI
|
|
|
|
|
- **Icons**: Lucide React
|
|
|
|
|
- **State Management**: React Hooks (useState, useCallback, useEffect, useMemo)
|
|
|
|
|
- **Drag & Drop**: HTML5 Drag and Drop API
|
|
|
|
|
- **Build Tool**: Next.js Built-in
|
|
|
|
|
|
|
|
|
|
### 백엔드
|
|
|
|
|
|
|
|
|
|
- **Runtime**: Node.js
|
|
|
|
|
- **Framework**: Express.js
|
|
|
|
|
- **Language**: TypeScript
|
|
|
|
|
- **ORM**: Prisma
|
|
|
|
|
- **Database**: PostgreSQL
|
|
|
|
|
- **Authentication**: JWT
|
|
|
|
|
- **API**: RESTful API
|
|
|
|
|
|
|
|
|
|
### 데이터베이스
|
|
|
|
|
|
|
|
|
|
- **Primary DB**: PostgreSQL
|
|
|
|
|
- **Schema Management**: Prisma ORM
|
|
|
|
|
- **Metadata**: information_schema 활용
|
|
|
|
|
- **JSON Support**: JSONB 타입 활용
|
|
|
|
|
|
|
|
|
|
### 개발 환경
|
|
|
|
|
|
|
|
|
|
- **Containerization**: Docker & Docker Compose
|
|
|
|
|
- **Development**: Hot Reload 지원
|
|
|
|
|
- **Version Control**: Git
|
|
|
|
|
- **Package Manager**: npm
|
|
|
|
|
|
2025-09-03 11:32:09 +09:00
|
|
|
## 🔧 핵심 기술적 구현 패턴
|
|
|
|
|
|
2025-09-03 15:23:12 +09:00
|
|
|
### 1. ⚡ 실시간 속성 편집 패턴 (핵심 표준)
|
2025-09-03 11:32:09 +09:00
|
|
|
|
2025-09-03 15:23:12 +09:00
|
|
|
#### 완성된 로컬 상태 + 글로벌 상태 이중 관리 시스템
|
2025-09-03 11:32:09 +09:00
|
|
|
|
2025-09-03 15:23:12 +09:00
|
|
|
**모든 속성 편집 컴포넌트의 표준 패턴:**
|
2025-09-03 11:32:09 +09:00
|
|
|
|
|
|
|
|
```typescript
|
2025-09-03 15:23:12 +09:00
|
|
|
// 1단계: 로컬 상태 정의 (실시간 표시용)
|
2025-09-03 11:32:09 +09:00
|
|
|
const [localInputs, setLocalInputs] = useState({
|
2025-09-03 15:23:12 +09:00
|
|
|
title: component.title || "",
|
|
|
|
|
placeholder: component.placeholder || "",
|
|
|
|
|
// 모든 입력 필드의 현재 값
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const [localValues, setLocalValues] = useState({
|
|
|
|
|
showButton: component.showButton ?? true,
|
|
|
|
|
enabled: component.enabled ?? false,
|
|
|
|
|
// 모든 체크박스의 현재 상태
|
2025-09-03 11:32:09 +09:00
|
|
|
});
|
|
|
|
|
|
2025-09-03 15:23:12 +09:00
|
|
|
// 2단계: 컴포넌트 변경 시 자동 동기화
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setLocalInputs({
|
|
|
|
|
title: component.title || "",
|
|
|
|
|
placeholder: component.placeholder || "",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
setLocalValues({
|
|
|
|
|
showButton: component.showButton ?? true,
|
|
|
|
|
enabled: component.enabled ?? false,
|
|
|
|
|
});
|
|
|
|
|
}, [component.title, component.placeholder, component.showButton]);
|
|
|
|
|
|
|
|
|
|
// 3단계: 실시간 입력 처리 - 즉시 반영
|
|
|
|
|
<Input
|
|
|
|
|
value={localInputs.title}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const newValue = e.target.value;
|
|
|
|
|
// 1) 로컬 상태 즉시 업데이트 (화면 반영)
|
|
|
|
|
setLocalInputs(prev => ({ ...prev, title: newValue }));
|
|
|
|
|
// 2) 글로벌 상태 업데이트 (데이터 저장)
|
|
|
|
|
onUpdateProperty("title", newValue);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<Checkbox
|
|
|
|
|
checked={localValues.showButton}
|
|
|
|
|
onCheckedChange={(checked) => {
|
|
|
|
|
// 1) 로컬 상태 즉시 업데이트
|
|
|
|
|
setLocalValues(prev => ({ ...prev, showButton: checked as boolean }));
|
|
|
|
|
// 2) 글로벌 상태 업데이트
|
|
|
|
|
onUpdateProperty("showButton", checked);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### 동적 컴포넌트별 상태 관리 (ID 기반)
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// 컬럼별 개별 상태 관리
|
|
|
|
|
const [localColumnInputs, setLocalColumnInputs] = useState<
|
|
|
|
|
Record<string, string>
|
|
|
|
|
>({});
|
|
|
|
|
const [localColumnCheckboxes, setLocalColumnCheckboxes] = useState<
|
|
|
|
|
Record<string, { visible: boolean; sortable: boolean; searchable: boolean }>
|
|
|
|
|
>({});
|
|
|
|
|
|
|
|
|
|
// 기존 값 보존하면서 새 항목만 추가
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setLocalColumnInputs((prev) => {
|
|
|
|
|
const newInputs = { ...prev };
|
|
|
|
|
component.columns?.forEach((col) => {
|
|
|
|
|
if (!(col.id in newInputs)) {
|
|
|
|
|
// 기존 입력값 보존
|
|
|
|
|
newInputs[col.id] = col.label;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
return newInputs;
|
|
|
|
|
});
|
|
|
|
|
}, [component.columns]);
|
|
|
|
|
|
|
|
|
|
// 동적 입력 처리
|
|
|
|
|
<Input
|
|
|
|
|
value={
|
|
|
|
|
localColumnInputs[column.id] !== undefined
|
|
|
|
|
? localColumnInputs[column.id]
|
|
|
|
|
: column.label
|
|
|
|
|
}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const newValue = e.target.value;
|
|
|
|
|
setLocalColumnInputs((prev) => ({ ...prev, [column.id]: newValue }));
|
|
|
|
|
updateColumn(column.id, { label: newValue });
|
|
|
|
|
}}
|
|
|
|
|
/>;
|
2025-09-03 11:32:09 +09:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### 스타일 속성 개별 업데이트 패턴
|
|
|
|
|
|
|
|
|
|
스타일 초기화 방지를 위한 개별 속성 업데이트:
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// Bad: 전체 객체 교체로 인한 다른 속성 손실
|
|
|
|
|
onUpdateProperty("style", { ...selectedComponent.style, newProp: value });
|
|
|
|
|
|
|
|
|
|
// Good: 개별 속성 직접 업데이트
|
|
|
|
|
onUpdateProperty("style.labelFontSize", value);
|
|
|
|
|
```
|
|
|
|
|
|
2025-09-03 15:23:12 +09:00
|
|
|
### 📋 적용된 컴포넌트 목록
|
|
|
|
|
|
|
|
|
|
이 패턴이 완벽하게 적용된 컴포넌트들:
|
|
|
|
|
|
|
|
|
|
- **PropertiesPanel**: 기본 속성 편집
|
|
|
|
|
- **DataTableConfigPanel**: 데이터 테이블 상세 설정
|
|
|
|
|
- **DateTypeConfigPanel**: 날짜 타입 상세 설정
|
|
|
|
|
- **NumberTypeConfigPanel**: 숫자 타입 상세 설정
|
|
|
|
|
- **SelectTypeConfigPanel**: 선택박스 타입 상세 설정
|
|
|
|
|
- **TextTypeConfigPanel**: 텍스트 타입 상세 설정
|
|
|
|
|
- **기타 모든 웹타입별 설정 패널들**
|
|
|
|
|
|
2025-09-03 11:32:09 +09:00
|
|
|
### 2. 드래그앤드롭 패턴
|
|
|
|
|
|
|
|
|
|
#### 다중 컴포넌트 드래그 처리
|
|
|
|
|
|
|
|
|
|
- dragState에 draggedComponents 배열로 선택된 모든 컴포넌트 관리
|
|
|
|
|
- 실시간 미리보기를 위한 RealtimePreview와 실제 업데이트 분리
|
|
|
|
|
- justFinishedDrag 플래그로 드래그 완료 후 의도치 않은 선택 해제 방지
|
|
|
|
|
|
|
|
|
|
#### 격자 스냅 시스템
|
|
|
|
|
|
|
|
|
|
- 컴포넌트 위치와 크기를 격자에 맞게 자동 조정
|
|
|
|
|
- 격자 설정 변경 시 기존 컴포넌트들도 자동 재조정
|
|
|
|
|
|
|
|
|
|
### 3. 컴포넌트 렌더링 패턴
|
|
|
|
|
|
|
|
|
|
#### 웹타입별 동적 렌더링
|
|
|
|
|
|
|
|
|
|
RealtimePreview에서 switch-case로 웹타입별 적절한 입력 컴포넌트 렌더링:
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
switch (widgetType) {
|
|
|
|
|
case "text":
|
|
|
|
|
return <Input type="text" {...commonProps} />;
|
|
|
|
|
case "date":
|
|
|
|
|
return <Input type="date" {...commonProps} />;
|
|
|
|
|
case "select":
|
|
|
|
|
return <select>...</select>;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### 라벨 동적 위치 계산
|
|
|
|
|
|
|
|
|
|
라벨 하단 여백 설정에 따른 동적 위치 계산:
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
const labelMarginBottomValue = parseInt(component.style?.labelMarginBottom || "4px", 10);
|
|
|
|
|
style={{ top: `${-20 - labelMarginBottomValue}px` }}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 4. 패널 관리 패턴
|
|
|
|
|
|
|
|
|
|
#### 플로팅 패널 상태 관리
|
|
|
|
|
|
|
|
|
|
- 각 패널의 위치, 크기, 열림/닫힘 상태를 독립적으로 관리
|
|
|
|
|
- 사용자가 수동으로 조정한 위치 기억
|
|
|
|
|
- autoHeight 제거로 컨텐츠 변경 시에도 위치 유지
|
|
|
|
|
|
|
|
|
|
### 5. 타입 안전성 패턴
|
|
|
|
|
|
|
|
|
|
#### 인터페이스 확장 패턴
|
|
|
|
|
|
|
|
|
|
BaseComponent를 기본으로 각 컴포넌트 타입별 확장:
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
export interface WidgetComponent extends BaseComponent {
|
|
|
|
|
type: "widget";
|
|
|
|
|
widgetType: WebType;
|
|
|
|
|
// 위젯 전용 속성들
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### 유니온 타입 활용
|
|
|
|
|
|
|
|
|
|
ComponentData = ContainerComponent | WidgetComponent | GroupComponent 등으로 타입 안전성 보장
|
|
|
|
|
|
2025-08-29 11:46:52 +09:00
|
|
|
## 🎯 결론
|
|
|
|
|
|
2025-09-01 10:19:47 +09:00
|
|
|
화면관리 시스템은 **회사별 권한 관리**와 **테이블 타입관리 연계**를 통해 사용자가 직관적으로 웹 화면을 설계할 수 있는 강력한 도구입니다.
|
|
|
|
|
|
|
|
|
|
### 🏢 **회사별 화면 관리의 핵심 가치**
|
|
|
|
|
|
|
|
|
|
- **권한 격리**: 사용자는 자신이 속한 회사의 화면만 제작/수정 가능
|
|
|
|
|
- **관리자 통제**: 회사 코드 '\*'인 관리자는 모든 회사의 화면을 제어
|
|
|
|
|
- **메뉴 연동**: 각 회사의 메뉴에만 화면 할당하여 완벽한 데이터 분리
|
|
|
|
|
|
|
|
|
|
### 🎨 **향상된 사용자 경험**
|
|
|
|
|
|
|
|
|
|
- **드래그앤드롭 인터페이스**: 직관적인 화면 설계
|
2025-09-01 15:22:47 +09:00
|
|
|
- **실시간 미리보기**: 설계한 화면을 실제 웹 위젯으로 즉시 확인
|
|
|
|
|
- **실행취소/다시실행**: 최대 50개 히스토리 관리, 키보드 단축키 지원
|
2025-09-01 10:19:47 +09:00
|
|
|
- **자동 위젯 생성**: 컬럼의 웹 타입에 따른 스마트한 위젯 생성
|
2025-09-01 15:22:47 +09:00
|
|
|
- **13가지 웹 타입 지원**: text, email, tel, number, decimal, date, datetime, select, dropdown, textarea, checkbox, radio, code, entity, file
|
2025-09-01 10:19:47 +09:00
|
|
|
|
|
|
|
|
### 🚀 **기술적 혜택**
|
|
|
|
|
|
|
|
|
|
- **기존 테이블 구조 100% 호환**: 별도 스키마 변경 없이 바로 개발 가능
|
|
|
|
|
- **권한 기반 보안**: 회사 간 데이터 완전 격리
|
|
|
|
|
- **확장 가능한 아키텍처**: 새로운 웹 타입과 컴포넌트 쉽게 추가
|
2025-09-01 15:22:47 +09:00
|
|
|
- **실시간 렌더링**: Shadcn UI 기반 실제 웹 컴포넌트 렌더링
|
|
|
|
|
- **성능 최적화**: 검색/페이징, 메모이제이션, 깊은 복사 최적화
|
|
|
|
|
|
2025-09-01 18:42:59 +09:00
|
|
|
### 📊 **현재 구현 완료율: 95%**
|
|
|
|
|
|
|
|
|
|
- ✅ **Phase 1-6 완료**: 기본 구조, 드래그앤드롭, 컴포넌트 라이브러리, 테이블 연계, 미리보기, 통합 테스트
|
|
|
|
|
- ✅ **핵심 기능 완료**: 컴포넌트 그룹화, 레이아웃 저장/로드, 메뉴-화면 할당, 인터랙티브 화면 뷰어
|
2025-09-03 11:32:09 +09:00
|
|
|
- ✅ **고도화 완료**: 실시간 속성 편집, 라벨 관리, 다중 드래그, 격자 시스템
|
|
|
|
|
- 📋 **다음 계획**: 웹타입별 상세 설정, 반응형 레이아웃, 고급 기능
|
2025-09-01 18:42:59 +09:00
|
|
|
|
|
|
|
|
### 🎉 **완전 기능 화면관리 시스템 완성!**
|
|
|
|
|
|
|
|
|
|
이 시스템을 통해 ERP 시스템의 화면 개발 생산성을 크게 향상시키고, **회사별 맞춤형 화면 구성**과 **사용자 요구사항에 따른 빠른 화면 구성**이 가능합니다.
|
2025-09-01 15:22:47 +09:00
|
|
|
|
2025-09-01 18:42:59 +09:00
|
|
|
**주요 완성 기능:**
|
2025-08-29 11:46:52 +09:00
|
|
|
|
2025-09-01 18:42:59 +09:00
|
|
|
- ✅ **드래그앤드롭 화면 설계**: 직관적인 UI/UX로 누구나 쉽게 화면 제작
|
|
|
|
|
- ✅ **실시간 미리보기**: 설계한 화면을 실제 웹 위젯으로 즉시 확인
|
|
|
|
|
- ✅ **회사별 권한 관리**: 완벽한 데이터 격리 및 보안
|
|
|
|
|
- ✅ **메뉴 연동**: 설계한 화면을 실제 메뉴에 할당하여 즉시 사용
|
|
|
|
|
- ✅ **인터랙티브 화면**: 할당된 화면에서 실제 사용자 입력 및 상호작용 가능
|
|
|
|
|
- ✅ **13가지 웹 타입 지원**: 모든 업무 요구사항에 대응 가능한 다양한 위젯
|
2025-09-05 10:27:10 +09:00
|
|
|
- ✅ **🆕 완전한 메뉴 할당 워크플로우**: 저장 → 자동 메뉴 할당 → 기존 화면 교체 확인 → 성공 피드백 → 목록 복귀의 완벽한 사용자 경험
|