79 KiB
화면관리 시스템 설계 문서
📋 목차
🎯 시스템 개요
화면관리 시스템이란?
화면관리 시스템은 사용자가 속한 회사에 맞춰 화면을 드래그앤드롭으로 설계하고 관리할 수 있는 시스템입니다. 테이블 타입관리와 연계하여 각 필드가 웹에서 어떻게 표시될지를 정의하고, 사용자가 직관적으로 화면을 구성할 수 있습니다.
주요 특징
- 회사별 화면 관리: 사용자 회사 코드에 따른 화면 접근 제어
- 드래그앤드롭 인터페이스: 직관적인 화면 설계
- 컨테이너 그룹화: 컴포넌트를 깔끔하게 정렬하는 그룹 기능
- 테이블 타입 연계: 컬럼의 웹 타입에 따른 자동 위젯 생성
- 실시간 미리보기: 설계한 화면을 실제 화면과 동일하게 확인 가능
- 메뉴 연동: 각 회사의 메뉴에 화면 할당 및 관리
🆕 최근 업데이트 (요약)
- 픽셀 기반 자유 이동: 격자 스냅을 제거하고 커서를 따라 정확히 이동하도록 구현. 그리드 라인은 시각적 가이드만 유지
- 멀티 선택 강화: Shift+클릭 + 드래그 박스(마키)로 다중선택 가능. 그룹 컨테이너는 선택에서 자동 제외
- 다중 드래그 이동: 다중선택 항목을 함께 이동(상대 위치 유지). 스크롤/그랩 오프셋 반영으로 튐 현상 제거
- 그룹 UI 간소화: 그룹 헤더/테두리 박스 제거(투명 컨테이너). 그룹 내부에만 집중
- 그룹 내 정렬/분배 툴: 좌/가로중앙/우, 상/세로중앙/하 정렬 + 가로/세로 균등 분배 추가(아이콘 UI)
- 왼쪽 목록 UX: 검색·페이징 도입으로 대량 테이블 로딩 지연 완화
- Undo/Redo: 최대 50단계, 단축키(Ctrl/Cmd+Z, Ctrl/Cmd+Y)
- 위젯 타입 렌더링 보강: code/entity/file 포함 실제 위젯 형태로 표시
- 복사/삭제/붙여넣기 범용화: 단일/다중/그룹 선택 모두에서 동작. 우클릭 위치 붙여넣기, 단축키(Ctrl/Cmd+C, Ctrl/Cmd+V, Delete) 지원, 상단 툴바 버튼 제공. 클립보드 상태 배지 표시(예: "3개 복사됨", "그룹 복사됨").
🎯 현재 테이블 구조와 100% 호환
기존 테이블 타입관리 시스템과 완벽 연계:
- ✅
table_labels: 테이블 메타데이터와 연계 - ✅
column_labels: 컬럼 웹 타입 및 상세 설정과 연계 - ✅ 웹 타입 지원: text, number, date, code, entity, textarea, select, checkbox, radio, file
- ✅ 상세 설정: JSON 형태로 유연한 설정 저장
- ✅ 코드 연계: 공통코드 시스템과 완벽 연동
- ✅ 엔티티 참조: 참조 테이블 시스템 완벽 지원
별도의 테이블 구조 변경 없이 바로 개발 가능! 🚀
🏢 회사별 화면 관리 시스템
사용자 권한에 따른 화면 접근 제어:
- ✅ 일반 사용자: 자신이 속한 회사의 화면만 제작/수정 가능
- ✅ 관리자 (회사코드 '*'): 모든 회사의 화면을 제어 가능
- ✅ 회사별 메뉴 할당: 각 회사의 메뉴에만 화면 할당 가능
- ✅ 권한 격리: 회사 간 화면 데이터 완전 분리
지원하는 웹 타입
테이블 타입관리에서 각 컬럼별로 설정할 수 있는 웹 타입입니다:
- text: 일반 텍스트 입력
- number: 숫자 입력
- date: 날짜 선택기
- code: 코드 선택 (공통코드)
- entity: 엔티티 참조 (참조테이블)
- textarea: 여러 줄 텍스트
- select: 드롭다운 선택
- checkbox: 체크박스
- radio: 라디오 버튼
- file: 파일 업로드
구현 완료: 테이블 타입관리 시스템에서 위의 모든 웹 타입을 지정할 수 있으며, 웹 타입별로 적절한 상세 설정을 자동으로 제공합니다.
웹 타입 관리 기능
1. 웹 타입 설정
- 자동 상세 설정: 웹 타입 선택 시 해당 타입에 맞는 기본 상세 설정을 자동으로 제공
- 실시간 저장: 웹 타입 변경 시 즉시 백엔드 데이터베이스에 저장
- 오류 복구: 저장 실패 시 원래 상태로 자동 복원
- 상세 설정 편집: 웹 타입별 상세 설정을 모달에서 JSON 형태로 편집 가능
2. 웹 타입별 상세 설정
| 웹 타입 | 기본 상세 설정 | 추가 설정 옵션 |
|---|---|---|
| text | 최대 길이: 255자 | 사용자 정의 설정 |
| number | 숫자 입력 (정수/실수) | 범위, 정밀도 설정 |
| date | 날짜 형식: YYYY-MM-DD | 날짜 범위, 형식 설정 |
| textarea | 여러 줄 텍스트 (최대 1000자) | 행 수, 최대 길이 설정 |
| select | 드롭다운 선택 옵션 | 옵션 목록 설정 |
| checkbox | 체크박스 (Y/N) | 기본값, 라벨 설정 |
| radio | 라디오 버튼 그룹 | 옵션 그룹 설정 |
| file | 파일 업로드 (최대 10MB) | 파일 형식, 크기 설정 |
| code | 공통코드 선택 | 코드 카테고리 지정 |
| entity | 엔티티 참조 | 참조 테이블/컬럼 지정 |
3. 사용 방법
- 테이블 선택: 테이블 타입관리에서 관리할 테이블 선택
- 컬럼 확인: 해당 테이블의 모든 컬럼 정보 표시
- 웹 타입 설정: 각 컬럼의 웹 타입을 드롭다운에서 선택
- 자동 저장: 선택 즉시 백엔드에 저장되고 상세 설정 자동 적용
- 상세 설정 편집: "상세 설정 편집" 버튼을 클릭하여 JSON 형태로 추가 설정 수정
- 설정 저장: 수정된 상세 설정을 저장하여 완료
🏗️ 아키텍처 구조
전체 구조도
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 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 │ │
│ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
회사별 권한 관리 구조
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 사용자 │ │ 권한 검증 │ │ 화면 데이터 │
│ │ │ │ │ │
│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │
│ │ 회사코드 │ │───▶│ │ 권한 검증 │ │───▶│ │ 회사별 │ │
│ │ (company_ │ │ │ │ 미들웨어 │ │ │ │ 화면 │ │
│ │ code) │ │ │ │ │ │ │ │ 격리 │ │
│ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │
│ │ │ │ │ │
│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │
│ │ 권한 레벨 │ │ │ │ 회사별 │ │ │ │ 메뉴 할당 │ │
│ │ (admin: '*')│ │ │ │ 데이터 │ │ │ │ 제한 │ │
│ └─────────────┘ │ │ │ 필터링 │ │ │ └─────────────┘ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
데이터 흐름
- 테이블 타입 정의: 테이블 타입관리에서 컬럼의 웹 타입 설정
- 화면 설계: 드래그앤드롭으로 화면 레이아웃 구성
- 위젯 매핑: 컬럼과 화면 위젯을 연결
- 설정 저장: 화면 정의를 데이터베이스에 저장
- 런타임 생성: 실제 서비스 화면을 동적으로 생성
🚀 핵심 기능
1. 화면 설계기 (Screen Designer)
- 드래그앤드롭 인터페이스: 컴포넌트를 캔버스에 배치
- 그리드 시스템: 12컬럼 그리드 기반 레이아웃
- 컨테이너 그룹화: 컴포넌트를 깔끔하게 정렬하는 그룹 기능
- 실시간 미리보기: 설계한 화면을 실제 화면과 동일하게 확인
2. 컴포넌트 라이브러리
- 입력 컴포넌트: text, number, date, textarea 등
- 선택 컴포넌트: select, checkbox, radio 등
- 표시 컴포넌트: label, display, image 등
- 레이아웃 컴포넌트: container, row, column, group 등
- 컨테이너 컴포넌트: 컴포넌트들을 그룹으로 묶는 기능
3. 회사별 권한 관리
- 회사 코드 기반 접근 제어: 사용자 회사 코드에 따른 화면 접근
- 관리자 권한: 회사 코드 '*'인 사용자는 모든 회사 화면 제어
- 회사별 메뉴 할당: 각 회사의 메뉴에만 화면 할당 가능
- 데이터 격리: 회사 간 화면 데이터 완전 분리
4. 테이블 연계 시스템
- 자동 위젯 생성: 컬럼의 웹 타입에 따른 위젯 자동 생성
- 데이터 바인딩: 컬럼과 위젯의 자동 연결
- 유효성 검증: 컬럼 설정에 따른 자동 검증 규칙 적용
5. 템플릿 시스템
- 기본 템플릿: CRUD, 목록, 상세 등 기본 패턴
- 사용자 정의 템플릿: 자주 사용하는 레이아웃 저장
- 템플릿 공유: 팀원 간 템플릿 공유 및 재사용
6. 메뉴 연동 시스템
- 회사별 메뉴 할당: 각 회사의 메뉴에만 화면 할당
- 메뉴-화면 연결: 메뉴와 화면의 1:1 또는 1:N 연결
- 권한 기반 메뉴 표시: 사용자 권한에 따른 메뉴 표시 제어
🗄️ 데이터베이스 설계
1. 기존 테이블 구조 (테이블 타입관리)
table_labels (테이블 메타데이터)
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 (컬럼 메타데이터 + 웹 타입)
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);
권장 인덱스 추가:
-- 자주 조회되는 컬럼 조합에 인덱스 추가
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 (화면 정의)
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와 연계
company_code VARCHAR(50) NOT NULL, -- 🎯 회사 코드 (권한 관리용)
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)
);
-- 회사 코드 인덱스 추가
CREATE INDEX idx_screen_definitions_company_code ON screen_definitions(company_code);
screen_layouts (화면 레이아웃)
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 (화면 위젯)
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 (화면 템플릿)
CREATE TABLE screen_templates (
template_id SERIAL PRIMARY KEY,
template_name VARCHAR(100) NOT NULL,
template_type VARCHAR(50) NOT NULL, -- CRUD, LIST, DETAIL 등
company_code VARCHAR(50) NOT NULL, -- 🎯 회사 코드 (권한 관리용)
description TEXT,
layout_data JSONB, -- 레이아웃 데이터
is_public BOOLEAN DEFAULT FALSE, -- 공개 여부
created_by VARCHAR(50),
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 회사 코드 인덱스 추가
CREATE INDEX idx_screen_templates_company_code ON screen_templates(company_code);
screen_menu_assignments (화면-메뉴 할당)
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);
3. 테이블 간 연계 관계
table_labels (테이블 메타데이터)
↓ (1:N)
column_labels (컬럼 메타데이터 + 웹 타입)
↓ (1:N)
screen_definitions (화면 정의)
↓ (1:N)
screen_layouts (화면 레이아웃)
↓ (1:N)
screen_widgets (화면 위젯)
↓ (1:N)
screen_menu_assignments (화면-메뉴 할당)
핵심 연계 포인트:
- ✅
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(자동 동기화) - ✅
screen_definitions.company_code↔ 사용자 회사 코드 (권한 관리) - ✅
screen_menu_assignments.company_code↔ 메뉴 회사 코드 (메뉴 할당 제한)
🎨 화면 구성 요소
1. 레이아웃 컴포넌트
Container (컨테이너)
interface ContainerProps {
id: string;
type: "container";
width: number; // 1-12
height: number;
padding: number;
margin: number;
backgroundColor?: string;
border?: string;
borderRadius?: number;
shadow?: string;
}
Group (그룹)
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 목록
}
Row (행)
interface RowProps {
id: string;
type: "row";
columns: number; // 1-12
gap: number;
alignItems: "start" | "center" | "end";
justifyContent: "start" | "center" | "end" | "space-between";
}
Column (컬럼)
interface ColumnProps {
id: string;
type: "column";
width: number; // 1-12
offset: number; // 오프셋
order: number; // 순서
}
2. 입력 위젯
Text Input
interface TextInputProps {
id: string;
type: "text";
label: string;
placeholder?: string;
required: boolean;
maxLength?: number;
pattern?: string;
columnName: string; // 연결된 테이블 컬럼
}
Select Dropdown
interface SelectProps {
id: string;
type: "select";
label: string;
options: Array<{ value: string; label: string }>;
multiple: boolean;
searchable: boolean;
columnName: string;
}
Date Picker
interface DatePickerProps {
id: string;
type: "date";
label: string;
format: string; // YYYY-MM-DD, MM/DD/YYYY 등
minDate?: string;
maxDate?: string;
columnName: string;
}
3. 표시 위젯
Label
interface LabelProps {
id: string;
type: "label";
text: string;
fontSize: number;
fontWeight: "normal" | "bold";
color: string;
alignment: "left" | "center" | "right";
}
Display Field
interface DisplayFieldProps {
id: string;
type: "display";
label: string;
columnName: string;
format?: string; // 날짜, 숫자 포맷
alignment: "left" | "center" | "right";
}
🖱️ 드래그앤드롭 설계
1. 드래그앤드롭 아키텍처
// 드래그 상태 관리
interface DragState {
isDragging: boolean;
draggedItem: ComponentData | null;
dragSource: "toolbox" | "canvas";
dropTarget: string | null;
dropZone?: DropZone; // 드롭 가능한 영역 정보
}
// 그룹화 상태 관리
interface GroupState {
isGrouping: boolean;
selectedComponents: string[];
groupTarget: string | null;
groupMode: "create" | "add" | "remove";
}
// 드롭 영역 정의 interface DropZone { id: string; accepts: string[]; // 허용되는 컴포넌트 타입 position: { x: number; y: number }; size: { width: number; height: number }; }
### 2. 컴포넌트 배치 로직
현재 배치 로직은 **픽셀 기반 자유 위치**로 동작합니다. 마우스 그랩 오프셋과 스크롤 오프셋을 반영하여 커서를 정확히 추적합니다. 아래 그리드 기반 예시는 참고용이며, 실제 런타임에서는 스냅을 적용하지 않습니다.
```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),
};
}
3. 실시간 미리보기
// 캔버스 상태를 실제 컴포넌트로 변환
function generatePreview(layout: LayoutData): React.ReactElement {
return (
<div className="screen-preview">
{layout.components.map((component) => renderComponent(component))}
</div>
);
}
// 그룹 컴포넌트 렌더링
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>
);
}
// 컴포넌트 렌더링 function renderComponent(component: ComponentData): React.ReactElement { switch (component.type) { case "text": return <TextInput {...component.props} />; case "select": return <Select {...component.props} />; case "date": return <DatePicker {...component.props} />; default: return
### 4. 복사/삭제/붙여넣기 규칙 (구현 완료)
- 대상 범위
- 단일 선택: 선택된 1개 컴포넌트에 대해 복사/삭제/붙여넣기 지원
- 다중 선택: Shift+클릭 또는 마키 선택으로 선택된 여러 컴포넌트 일괄 복사/삭제/붙여넣기 지원
- 그룹 선택: 그룹과 모든 자식 컴포넌트가 하나의 덩어리로 동작
- 동작 규칙
- 복사 시 선택된 컴포넌트들의 바운딩 박스를 계산하여 상대 좌표 유지
- 붙여넣기 시 새 ID로 재생성하고 부모-자식(parentId) 관계 보존
- 기본 붙여넣기 위치는 원본 바운딩 박스 + 오프셋(+20px, +20px)
- 캔버스 우클릭 시 해당 좌표로 붙여넣기 수행(정확 위치 지정)
- UI/피드백
- 상단 툴바에 복사/삭제/붙여넣기 버튼 제공(선택 상황에 따라 표시/활성화)
- 클립보드 상태 배지 표시: 단일(“컴포넌트 복사됨”), 다중(“N개 복사됨”), 그룹(“그룹 복사됨”)
- 단축키
- 복사: Ctrl/Cmd + C
- 붙여넣기: Ctrl/Cmd + V
- 삭제: Delete 또는 Backspace
- 실행 취소/다시 실행: Ctrl/Cmd + Z, Ctrl/Cmd + Y
- 예외 처리
- 선택 없음 상태에서 복사/삭제는 무시
- 클립보드가 비어있는 경우 붙여넣기 무시
## 🔗 테이블 타입 연계
### 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`);
}
웹 타입별 추가 설정 (현재 테이블 구조 기반)
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} |
허용 파일 형식, 최대 크기 |
실제 데이터베이스 저장 예시:
-- 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. 자동 위젯 생성
// 컬럼 정보를 기반으로 위젯 자동 생성 (현재 테이블 구조 기반)
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. 데이터 바인딩
// 위젯과 컬럼 연결
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. 유효성 검증 규칙
// 컬럼 설정에 따른 자동 검증 규칙
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;
}
🌐 API 설계
1. 화면 정의 API
화면 목록 조회 (회사별)
GET /api/screen-management/screens
Query: {
companyCode?: string; // 회사 코드 (관리자는 생략 가능)
page?: number;
size?: number;
}
Response: {
success: boolean;
data: ScreenDefinition[];
total: number;
}
화면 생성 (회사별)
POST /api/screen-management/screens
Body: {
screenName: string;
screenCode: string;
tableName: string;
companyCode: string; // 사용자 회사 코드 자동 설정
description?: string;
}
화면 생성
POST /api/screen-management/screens
Body: {
screenName: string;
screenCode: string;
tableName: string;
description?: string;
}
화면 수정
PUT /api/screen-management/screens/:screenId
Body: {
screenName?: string;
description?: string;
isActive?: boolean;
}
화면 삭제
DELETE /api/screen-management/screens/:screenId
2. 레이아웃 API
레이아웃 조회
GET /api/screen-management/screens/:screenId/layout
Response: {
success: boolean;
data: LayoutData;
}
레이아웃 저장
POST /api/screen-management/screens/:screenId/layout
Body: {
components: ComponentData[];
gridSettings: GridSettings;
}
레이아웃 복사
POST /api/screen-management/screens/:screenId/layout/copy
Body: {
targetScreenId: number;
}
3. 위젯 API
위젯 속성 조회
GET /api/screen-management/widgets/:widgetId/properties
Response: {
success: boolean;
data: WidgetProperties;
}
위젯 속성 수정
PUT /api/screen-management/widgets/:widgetId/properties
Body: {
label?: string;
placeholder?: string;
required?: boolean;
readonly?: boolean;
validationRules?: ValidationRule[];
}
4. 템플릿 API
템플릿 목록 조회 (회사별)
GET /api/screen-management/templates
Query: {
companyCode?: string; // 회사 코드 (관리자는 생략 가능)
type?: string;
isPublic?: boolean;
createdBy?: string;
}
5. 메뉴 할당 API
화면-메뉴 할당
POST /api/screen-management/screens/:screenId/menu-assignments
Body: {
menuId: number;
companyCode: string; // 사용자 회사 코드 자동 설정
displayOrder?: number;
}
메뉴별 화면 목록 조회
GET /api/screen-management/menus/:menuId/screens
Query: {
companyCode: string; // 회사 코드 필수
}
Response: {
success: boolean;
data: ScreenDefinition[];
}
템플릿 적용
POST /api/screen-management/screens/:screenId/apply-template
Body: {
templateId: number;
overrideExisting: boolean;
}
🎭 프론트엔드 구현
1. 화면 설계기 컴포넌트 (구현 완료)
// ScreenDesigner.tsx - 현재 구현된 버전
export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenDesignerProps) {
const [layout, setLayout] = useState<LayoutData>({ components: [] });
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]);
선택/이동 UX (현행)
- Shift+클릭으로 다중선택 가능
- 캔버스 빈 영역 드래그로 마키 선택 가능(Shift 누르면 기존 선택에 추가)
- 다중선택 상태에서 드래그 시 전체가 함께 이동(상대 좌표 유지)
- 그룹 컨테이너는 선택/정렬 대상에서 자동 제외
// 컴포넌트 추가 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, };
setLayout((prev) => ({
...prev,
components: [...prev.components, groupComponent],
}));
};
// 그룹에서 컴포넌트 제거 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 (
### 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,
};
}
3. 실시간 미리보기 시스템 (구현 완료)
// RealtimePreview.tsx - 현재 구현된 버전
export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
component,
isSelected = false,
onClick,
onDragStart,
onDragEnd,
}) => {
const { type, label, tableName, columnName, widgetType, size, style } =
component;
return (
<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>
)}
</div>
);
};
// 웹 타입에 따른 위젯 렌더링
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. 그리드 시스템 (구현 완료)
// 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 };
};
// 그룹화 도구 모음
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>
);
}
// 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>
);
}
⚙️ 백엔드 구현
1. 화면 관리 서비스
// screenManagementService.ts
export class ScreenManagementService {
// 화면 정의 생성 (회사별)
async createScreen(
screenData: CreateScreenRequest,
userCompanyCode: string
): Promise<ScreenDefinition> {
// 권한 검증: 사용자 회사 코드 확인
if (userCompanyCode !== "*" && userCompanyCode !== screenData.companyCode) {
throw new Error("해당 회사의 화면을 생성할 권한이 없습니다.");
}
const screen = await prisma.screen_definitions.create({
data: {
screen_name: screenData.screenName,
screen_code: screenData.screenCode,
table_name: screenData.tableName,
company_code: screenData.companyCode,
description: screenData.description,
created_by: screenData.createdBy,
},
});
return this.mapToScreenDefinition(screen);
}
// 회사별 화면 목록 조회
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,
};
// 레이아웃 저장
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. 테이블 타입 연계 서비스
// 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,
}));
}
}
🎬 사용 시나리오
1. 회사별 화면 관리
일반 사용자 (회사 코드: 'COMP001')
- 로그인: 자신의 회사 코드로 시스템 로그인
- 화면 목록 조회: 자신이 속한 회사의 화면만 표시
- 화면 생성: 회사 코드가 자동으로 설정되어 생성
- 메뉴 할당: 자신의 회사 메뉴에만 화면 할당 가능
관리자 (회사 코드: '*')
- 로그인: 관리자 권한으로 시스템 로그인
- 전체 화면 조회: 모든 회사의 화면을 조회/수정 가능
- 회사별 화면 관리: 각 회사별로 화면 생성/수정/삭제
- 크로스 회사 메뉴 할당: 모든 회사의 메뉴에 화면 할당 가능
2. 웹 타입 설정 (테이블 타입관리)
- 테이블 선택: 테이블 타입관리에서 웹 타입을 설정할 테이블 선택
- 컬럼 관리: 해당 테이블의 컬럼 목록에서 웹 타입을 설정할 컬럼 선택
- 웹 타입 선택: 컬럼의 용도에 맞는 웹 타입 선택 (text, number, date, code, entity 등)
- 추가 설정: 웹 타입별 필요한 추가 설정 구성
- code 타입: 공통코드 카테고리 선택
- entity 타입: 참조 테이블 및 컬럼 지정
- validation: 유효성 검증 규칙 설정
- display: 표시 속성 설정
- 저장: 웹 타입 설정을 데이터베이스에 저장
- 연계 확인: 화면관리 시스템에서 자동 위젯 생성 확인
3. 새로운 화면 설계
- 테이블 선택: 테이블 타입관리에서 설계할 테이블 선택
- 웹 타입 확인: 각 컬럼의 웹 타입 설정 상태 확인
- 화면 생성: 화면명과 코드를 입력하여 새 화면 생성 (회사 코드 자동 설정)
- 자동 위젯 생성: 컬럼의 웹 타입에 따라 자동으로 위젯 생성
- 컴포넌트 배치: 드래그앤드롭으로 컴포넌트를 캔버스에 배치
- 컨테이너 그룹화: 관련 컴포넌트들을 그룹으로 묶어 깔끔하게 정렬
- 속성 설정: 각 컴포넌트의 속성을 Properties 패널에서 설정
- 실시간 미리보기: 설계한 화면을 실제 화면과 동일하게 확인
- 저장: 완성된 화면 레이아웃을 데이터베이스에 저장
4. 기존 화면 수정
- 화면 선택: 수정할 화면을 목록에서 선택 (권한 확인)
- 레이아웃 로드: 기존 레이아웃을 캔버스에 로드
- 컴포넌트 수정: 컴포넌트 추가/삭제/이동/수정
- 그룹 구조 조정: 컴포넌트 그룹화/그룹 해제/그룹 속성 변경
- 속성 변경: 컴포넌트 속성 변경
- 변경사항 확인: 실시간 미리보기로 변경사항 확인
- 저장: 수정된 레이아웃 저장
5. 템플릿 활용
- 템플릿 선택: 적합한 템플릿을 목록에서 선택 (회사별 템플릿)
- 템플릿 적용: 선택한 템플릿을 현재 화면에 적용
- 커스터마이징: 템플릿을 기반으로 필요한 부분 수정
- 저장: 커스터마이징된 화면 저장
6. 메뉴 할당 및 관리
- 메뉴 선택: 화면을 할당할 메뉴 선택 (회사별 메뉴만 표시)
- 화면 할당: 선택한 화면을 메뉴에 할당
- 할당 순서 조정: 메뉴 내 화면 표시 순서 조정
- 할당 해제: 메뉴에서 화면 할당 해제
- 권한 확인: 메뉴 할당 시 회사 코드 일치 여부 확인
7. 화면 배포
- 화면 활성화: 설계 완료된 화면을 활성 상태로 변경
- 권한 설정: 화면 접근 권한 설정 (회사별 권한)
- 메뉴 연결: 메뉴 시스템에 화면 연결 (회사별 메뉴)
- 테스트: 실제 환경에서 화면 동작 테스트
- 배포: 운영 환경에 화면 배포
📅 개발 계획 및 진행상황
✅ Phase 1: 기본 구조 및 데이터베이스 (완료)
- 데이터베이스 스키마 설계 및 생성
- 기본 API 구조 설계
- 화면 정의 및 레이아웃 테이블 생성
- 기본 CRUD API 구현
구현 완료 사항:
- PostgreSQL용 화면관리 테이블 스키마 생성 (
db/screen_management_schema.sql) - Node.js 백엔드 API 구조 설계 및 구현
- Prisma ORM을 통한 데이터베이스 연동
- 회사별 권한 관리 시스템 구현
✅ Phase 2: 드래그앤드롭 핵심 기능 (완료)
- 드래그앤드롭 라이브러리 선택 및 구현
- 그리드 시스템 구현
- 컴포넌트 배치 및 이동 로직 구현
- 컴포넌트 크기 조정 기능 구현
구현 완료 사항:
- HTML5 Drag and Drop API 기반 드래그앤드롭 시스템
- 80px x 60px 그리드 기반 레이아웃 시스템
- 컴포넌트 추가, 삭제, 이동, 재배치 기능
- 실행취소/다시실행 기능 (최대 50개 히스토리)
- 키보드 단축키 지원 (Ctrl+Z, Ctrl+Y)
✅ Phase 3: 컴포넌트 라이브러리 (완료)
- 기본 입력 컴포넌트 구현
- 선택 컴포넌트 구현
- 표시 컴포넌트 구현
- 레이아웃 컴포넌트 구현
구현 완료 사항:
- 13가지 웹 타입 지원: text, email, tel, number, decimal, date, datetime, select, dropdown, textarea, text_area, checkbox, boolean, radio, code, entity, file
- 각 타입별 고유 아이콘 및 색상 시스템
- Shadcn UI 컴포넌트 기반 렌더링
- 실시간 미리보기 시스템 (
RealtimePreview컴포넌트)
✅ Phase 4: 테이블 타입 연계 (완료)
- 테이블 타입관리와 연계 API 구현
- 웹 타입 설정 및 관리 기능 구현
- 웹 타입별 추가 설정 관리 기능 구현
- 자동 위젯 생성 로직 구현
- 데이터 바인딩 시스템 구현
- 유효성 검증 규칙 자동 적용
구현 완료 사항:
- PostgreSQL
information_schema기반 테이블/컬럼 메타데이터 조회 table_labels,column_labels테이블과 완벽 연계- 웹 타입별 자동 위젯 생성 및 렌더링
- 검색 및 페이징 기능이 포함된 테이블 선택 UI
- 실제 데이터베이스 값 기반 테이블 타입 표시
✅ Phase 5: 미리보기 및 템플릿 (완료)
- 실시간 미리보기 시스템 구현
- 기본 템플릿 구현
- 템플릿 저장 및 적용 기능 구현
- 템플릿 공유 시스템 구현
구현 완료 사항:
- 실시간 미리보기 시스템 (
RealtimePreview컴포넌트) - 캔버스에 배치된 컴포넌트의 실제 웹 위젯 렌더링
- 템플릿 관리 시스템 (
TemplateManager컴포넌트) - 화면 목록 및 생성 기능 (
ScreenList컴포넌트)
🔄 Phase 6: 통합 및 테스트 (진행중)
- 전체 시스템 통합 테스트
- 성능 최적화
- 사용자 테스트 및 피드백 반영
- 문서화 및 사용자 가이드 작성
현재 진행상황:
- 프론트엔드/백엔드 통합 완료
- Docker 환경에서 실행 가능
- 기본 기능 테스트 완료
- 사용자 피드백 반영 중
🎯 현재 구현된 핵심 기능
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. 사용자 경험
- 직관적 인터페이스: 드래그앤드롭 기반 화면 설계
- 실시간 피드백: 컴포넌트 배치 즉시 미리보기 표시
- 다중선택: Shift+클릭 및 마키 선택 지원, 다중 드래그 이동
- 정렬/분배: 그룹 내 좌/중앙/우·상/중앙/하 정렬 및 균등 분배
- 키보드 지원: Ctrl/Cmd+Z, Ctrl/Cmd+Y 단축키
- 반응형 UI: 전체 화면 활용한 효율적인 레이아웃
🚀 다음 단계 계획
1. 컴포넌트 그룹화 기능
- 여러 위젯을 컨테이너로 그룹화
- 부모-자식 관계 설정(parentId)
- 그룹 단위 이동
- 그룹 UI 단순화(헤더/박스 제거)
- 그룹 내 정렬/균등 분배 도구(아이콘 UI)
- 그룹 단위 삭제/복사/붙여넣기
2. 레이아웃 저장/로드
- 설계한 화면을 데이터베이스에 저장 (프론트 통합 진행 필요)
- 저장된 화면 불러오기 기능
- 버전 관리 시스템
3. 데이터 바인딩
- 실제 데이터베이스와 연결 (메타데이터 연동은 완료)
- 폼 제출 및 데이터 저장
- 유효성 검증 시스템
4. 반응형 레이아웃
- 다양한 화면 크기에 대응
- 모바일/태블릿/데스크톱 최적화
- 브레이크포인트 설정
5. 고급 기능
- 조건부 표시 로직
- 계산 필드 구현
- 동적 옵션 로딩
- 파일 업로드 처리
🛠️ 기술 스택 (현재 구현)
프론트엔드
- 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
🎯 결론
화면관리 시스템은 회사별 권한 관리와 테이블 타입관리 연계를 통해 사용자가 직관적으로 웹 화면을 설계할 수 있는 강력한 도구입니다.
🏢 회사별 화면 관리의 핵심 가치
- 권한 격리: 사용자는 자신이 속한 회사의 화면만 제작/수정 가능
- 관리자 통제: 회사 코드 '*'인 관리자는 모든 회사의 화면을 제어
- 메뉴 연동: 각 회사의 메뉴에만 화면 할당하여 완벽한 데이터 분리
🎨 향상된 사용자 경험
- 드래그앤드롭 인터페이스: 직관적인 화면 설계
- 실시간 미리보기: 설계한 화면을 실제 웹 위젯으로 즉시 확인
- 실행취소/다시실행: 최대 50개 히스토리 관리, 키보드 단축키 지원
- 자동 위젯 생성: 컬럼의 웹 타입에 따른 스마트한 위젯 생성
- 13가지 웹 타입 지원: text, email, tel, number, decimal, date, datetime, select, dropdown, textarea, checkbox, radio, code, entity, file
🚀 기술적 혜택
- 기존 테이블 구조 100% 호환: 별도 스키마 변경 없이 바로 개발 가능
- 권한 기반 보안: 회사 간 데이터 완전 격리
- 확장 가능한 아키텍처: 새로운 웹 타입과 컴포넌트 쉽게 추가
- 실시간 렌더링: Shadcn UI 기반 실제 웹 컴포넌트 렌더링
- 성능 최적화: 검색/페이징, 메모이제이션, 깊은 복사 최적화
📊 현재 구현 완료율: 85%
- ✅ Phase 1-5 완료: 기본 구조, 드래그앤드롭, 컴포넌트 라이브러리, 테이블 연계, 미리보기
- 🔄 Phase 6 진행중: 통합 테스트 및 사용자 피드백 반영
- 📋 다음 단계: 컴포넌트 그룹화, 레이아웃 저장/로드, 데이터 바인딩
이 시스템을 통해 ERP 시스템의 화면 개발 생산성을 크게 향상시키고, 회사별 맞춤형 화면 구성과 사용자 요구사항에 따른 빠른 화면 구성이 가능해질 것입니다.