ERP-node/docs/화면관리_시스템_설계.md

79 KiB

화면관리 시스템 설계 문서

📋 목차

  1. 시스템 개요
  2. 아키텍처 구조
  3. 핵심 기능
  4. 데이터베이스 설계
  5. 화면 구성 요소
  6. 드래그앤드롭 설계
  7. 테이블 타입 연계
  8. API 설계
  9. 프론트엔드 구현
  10. 백엔드 구현
  11. 사용 시나리오
  12. 개발 계획

🎯 시스템 개요

화면관리 시스템이란?

화면관리 시스템은 사용자가 속한 회사에 맞춰 화면을 드래그앤드롭으로 설계하고 관리할 수 있는 시스템입니다. 테이블 타입관리와 연계하여 각 필드가 웹에서 어떻게 표시될지를 정의하고, 사용자가 직관적으로 화면을 구성할 수 있습니다.

주요 특징

  • 회사별 화면 관리: 사용자 회사 코드에 따른 화면 접근 제어
  • 드래그앤드롭 인터페이스: 직관적인 화면 설계
  • 컨테이너 그룹화: 컴포넌트를 깔끔하게 정렬하는 그룹 기능
  • 테이블 타입 연계: 컬럼의 웹 타입에 따른 자동 위젯 생성
  • 실시간 미리보기: 설계한 화면을 실제 화면과 동일하게 확인 가능
  • 메뉴 연동: 각 회사의 메뉴에 화면 할당 및 관리

🆕 최근 업데이트 (요약)

  • 픽셀 기반 자유 이동: 격자 스냅을 제거하고 커서를 따라 정확히 이동하도록 구현. 그리드 라인은 시각적 가이드만 유지
  • 멀티 선택 강화: 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. 사용 방법

  1. 테이블 선택: 테이블 타입관리에서 관리할 테이블 선택
  2. 컬럼 확인: 해당 테이블의 모든 컬럼 정보 표시
  3. 웹 타입 설정: 각 컬럼의 웹 타입을 드롭다운에서 선택
  4. 자동 저장: 선택 즉시 백엔드에 저장되고 상세 설정 자동 적용
  5. 상세 설정 편집: "상세 설정 편집" 버튼을 클릭하여 JSON 형태로 추가 설정 수정
  6. 설정 저장: 수정된 상세 설정을 저장하여 완료

🏗️ 아키텍처 구조

전체 구조도

┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│   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. 테이블 타입 정의: 테이블 타입관리에서 컬럼의 웹 타입 설정
  2. 화면 설계: 드래그앤드롭으로 화면 레이아웃 구성
  3. 위젯 매핑: 컬럼과 화면 위젯을 연결
  4. 설정 저장: 화면 정의를 데이터베이스에 저장
  5. 런타임 생성: 실제 서비스 화면을 동적으로 생성

🚀 핵심 기능

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_nametable_labels.table_name
  • screen_widgets.table_name, column_namecolumn_labels.table_name, column_name
  • screen_widgets.widget_typecolumn_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

Unknown component
; } }


### 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 (

c.id === selectedComponent)} onPropertyChange={updateComponentProperty} onGroupCreate={groupComponents} onGroupRemove={ungroupComponent} />
); }

### 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')

  1. 로그인: 자신의 회사 코드로 시스템 로그인
  2. 화면 목록 조회: 자신이 속한 회사의 화면만 표시
  3. 화면 생성: 회사 코드가 자동으로 설정되어 생성
  4. 메뉴 할당: 자신의 회사 메뉴에만 화면 할당 가능

관리자 (회사 코드: '*')

  1. 로그인: 관리자 권한으로 시스템 로그인
  2. 전체 화면 조회: 모든 회사의 화면을 조회/수정 가능
  3. 회사별 화면 관리: 각 회사별로 화면 생성/수정/삭제
  4. 크로스 회사 메뉴 할당: 모든 회사의 메뉴에 화면 할당 가능

2. 웹 타입 설정 (테이블 타입관리)

  1. 테이블 선택: 테이블 타입관리에서 웹 타입을 설정할 테이블 선택
  2. 컬럼 관리: 해당 테이블의 컬럼 목록에서 웹 타입을 설정할 컬럼 선택
  3. 웹 타입 선택: 컬럼의 용도에 맞는 웹 타입 선택 (text, number, date, code, entity 등)
  4. 추가 설정: 웹 타입별 필요한 추가 설정 구성
    • code 타입: 공통코드 카테고리 선택
    • entity 타입: 참조 테이블 및 컬럼 지정
    • validation: 유효성 검증 규칙 설정
    • display: 표시 속성 설정
  5. 저장: 웹 타입 설정을 데이터베이스에 저장
  6. 연계 확인: 화면관리 시스템에서 자동 위젯 생성 확인

3. 새로운 화면 설계

  1. 테이블 선택: 테이블 타입관리에서 설계할 테이블 선택
  2. 웹 타입 확인: 각 컬럼의 웹 타입 설정 상태 확인
  3. 화면 생성: 화면명과 코드를 입력하여 새 화면 생성 (회사 코드 자동 설정)
  4. 자동 위젯 생성: 컬럼의 웹 타입에 따라 자동으로 위젯 생성
  5. 컴포넌트 배치: 드래그앤드롭으로 컴포넌트를 캔버스에 배치
  6. 컨테이너 그룹화: 관련 컴포넌트들을 그룹으로 묶어 깔끔하게 정렬
  7. 속성 설정: 각 컴포넌트의 속성을 Properties 패널에서 설정
  8. 실시간 미리보기: 설계한 화면을 실제 화면과 동일하게 확인
  9. 저장: 완성된 화면 레이아웃을 데이터베이스에 저장

4. 기존 화면 수정

  1. 화면 선택: 수정할 화면을 목록에서 선택 (권한 확인)
  2. 레이아웃 로드: 기존 레이아웃을 캔버스에 로드
  3. 컴포넌트 수정: 컴포넌트 추가/삭제/이동/수정
  4. 그룹 구조 조정: 컴포넌트 그룹화/그룹 해제/그룹 속성 변경
  5. 속성 변경: 컴포넌트 속성 변경
  6. 변경사항 확인: 실시간 미리보기로 변경사항 확인
  7. 저장: 수정된 레이아웃 저장

5. 템플릿 활용

  1. 템플릿 선택: 적합한 템플릿을 목록에서 선택 (회사별 템플릿)
  2. 템플릿 적용: 선택한 템플릿을 현재 화면에 적용
  3. 커스터마이징: 템플릿을 기반으로 필요한 부분 수정
  4. 저장: 커스터마이징된 화면 저장

6. 메뉴 할당 및 관리

  1. 메뉴 선택: 화면을 할당할 메뉴 선택 (회사별 메뉴만 표시)
  2. 화면 할당: 선택한 화면을 메뉴에 할당
  3. 할당 순서 조정: 메뉴 내 화면 표시 순서 조정
  4. 할당 해제: 메뉴에서 화면 할당 해제
  5. 권한 확인: 메뉴 할당 시 회사 코드 일치 여부 확인

7. 화면 배포

  1. 화면 활성화: 설계 완료된 화면을 활성 상태로 변경
  2. 권한 설정: 화면 접근 권한 설정 (회사별 권한)
  3. 메뉴 연결: 메뉴 시스템에 화면 연결 (회사별 메뉴)
  4. 테스트: 실제 환경에서 화면 동작 테스트
  5. 배포: 운영 환경에 화면 배포

📅 개발 계획 및 진행상황

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 시스템의 화면 개발 생산성을 크게 향상시키고, 회사별 맞춤형 화면 구성사용자 요구사항에 따른 빠른 화면 구성이 가능해질 것입니다.