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

121 KiB

화면관리 시스템 설계 문서

📋 목차

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

🎯 시스템 개요

화면관리 시스템이란?

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

주요 특징

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

🆕 최근 업데이트 (2024.12)

완료된 주요 기능들

  • 컴포넌트 관리 시스템: 드래그앤드롭, 다중 선택, 그룹 드래그, 실시간 위치 업데이트
  • 실시간 속성 편집 시스템: 로컬 상태 기반 즉시 반영, 완벽한 입력/체크박스 실시간 업데이트
  • 속성 편집 시스템: 라벨 관리 (텍스트, 폰트, 색상, 여백), 필수 입력 시 주황색 * 표시
  • 격자 시스템: 동적 격자 설정, 컴포넌트 스냅 및 크기 조정
  • 패널 관리: 플로팅 패널, 수동 크기 조정, 위치 기억
  • 웹타입 지원: text, number, decimal, date, datetime, select, dropdown, textarea, boolean, checkbox, radio, code, entity, file
  • 데이터 테이블 컴포넌트: 완전한 실시간 설정 시스템, 컬럼 관리, 필터링, 페이징
  • 🆕 실시간 데이터 테이블: 실제 PostgreSQL 데이터 조회, 웹타입별 검색 필터, 페이지네이션, 데이터 포맷팅
  • 🆕 화면 저장 후 메뉴 할당: 저장 완료 시 자동 메뉴 할당 모달, 기존 화면 교체 확인, 시각적 피드백 및 자동 목록 복귀

🔧 해결된 기술적 문제들

  • 실시간 속성 편집 완성: 로컬 상태 기반 이중 관리 시스템으로 완벽한 실시간 반영
  • 체크박스 실시간 업데이트: 모든 체크박스의 즉시 상태 변경 및 유지
  • 동적 컴포넌트 상태 관리: ID 기반 컬럼별 개별 상태 관리 및 동기화
  • 라벨 하단 여백 동적 적용: 여백값에 따른 정확한 위치 계산
  • 스타일 속성 개별 업데이트: 초기화 방지를 위한 style.propertyName 방식 적용
  • 다중 드래그 최적화: 지연 없는 실시간 미리보기, 선택 해제 방지
  • 입력값 보존 시스템: 패널 재오픈해도 사용자 입력값 완벽 유지

🎯 개발 진행 상황

  • 현재 완성도: 98% (실시간 편집 시스템 완성, 핵심 기능 완료)
  • 기술 스택: Next.js 15.4.4, TypeScript, Tailwind CSS, Shadcn/ui
  • 상태 관리: 완성된 실시간 속성 편집 패턴 - 로컬 상태 + 글로벌 상태 이중 관리
  • 드래그앤드롭: HTML5 Drag & Drop API 기반 고도화된 시스템
  • 🎯 표준화: 모든 속성 편집 컴포넌트에 실시간 패턴 적용 완료

🎯 현재 테이블 구조와 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. 로컬 상태 + 글로벌 상태 이중 관리

// 1단계: 로컬 상태 정의 (실시간 표시용)
const [localInputs, setLocalInputs] = useState({
  title: component.title || "",
  placeholder: component.placeholder || "",
  // 모든 입력 필드의 현재 값
});

const [localValues, setLocalValues] = useState({
  showButton: component.showButton ?? true,
  enabled: component.enabled ?? false,
  // 모든 체크박스의 현재 상태
});

// 2단계: 컴포넌트 변경 시 자동 동기화
useEffect(() => {
  setLocalInputs({
    title: component.title || "",
    placeholder: component.placeholder || "",
  });

  setLocalValues({
    showButton: component.showButton ?? true,
    enabled: component.enabled ?? false,
  });
}, [component.title, component.placeholder, component.showButton]);

2. 실시간 입력 처리 패턴

// 텍스트 입력 - 즉시 반영
<Input
  value={localInputs.title}
  onChange={(e) => {
    const newValue = e.target.value;
    // 1) 로컬 상태 즉시 업데이트 (화면 반영)
    setLocalInputs(prev => ({ ...prev, title: newValue }));
    // 2) 글로벌 상태 업데이트 (데이터 저장)
    onUpdateProperty("title", newValue);
  }}
/>

// 체크박스 - 즉시 반영
<Checkbox
  checked={localValues.showButton}
  onCheckedChange={(checked) => {
    // 1) 로컬 상태 즉시 업데이트
    setLocalValues(prev => ({ ...prev, showButton: checked as boolean }));
    // 2) 글로벌 상태 업데이트
    onUpdateProperty("showButton", checked);
  }}
/>

3. 동적 컴포넌트별 상태 관리

// 컬럼별 개별 상태 관리 (ID 기반)
const [localColumnInputs, setLocalColumnInputs] = useState<
  Record<string, string>
>({});
const [localColumnCheckboxes, setLocalColumnCheckboxes] = useState<
  Record<string, { visible: boolean; sortable: boolean; searchable: boolean }>
>({});

// 기존 값 보존하면서 새 항목만 추가
useEffect(() => {
  setLocalColumnInputs((prev) => {
    const newInputs = { ...prev };
    component.columns?.forEach((col) => {
      if (!(col.id in newInputs)) {
        // 기존 입력값 보존
        newInputs[col.id] = col.label;
      }
    });
    return newInputs;
  });
}, [component.columns]);

// 동적 입력 처리
<Input
  value={
    localColumnInputs[column.id] !== undefined
      ? localColumnInputs[column.id]
      : column.label
  }
  onChange={(e) => {
    const newValue = e.target.value;
    setLocalColumnInputs((prev) => ({ ...prev, [column.id]: newValue }));
    updateColumn(column.id, { label: newValue });
  }}
/>;

🔧 구현 표준 가이드라인

필수 구현 패턴

  1. 로컬 우선 원칙: 모든 입력은 로컬 상태를 먼저 업데이트
  2. 즉시 반영: 로컬 상태 업데이트와 동시에 컴포넌트 속성 업데이트
  3. 기존값 보존: useEffect에서 기존 로컬 입력값이 있으면 덮어쓰지 않음
  4. 완전한 정리: 항목 삭제 시 관련된 모든 로컬 상태도 함께 정리
  5. 타입 안전성: 모든 상태에 정확한 TypeScript 타입 지정

항목 추가/삭제 시 상태 관리

// 추가 시
const addItem = useCallback(
  (newItem) => {
    // 로컬 상태에 즉시 추가
    setLocalColumnInputs((prev) => ({
      ...prev,
      [newItem.id]: newItem.label,
    }));

    setLocalColumnCheckboxes((prev) => ({
      ...prev,
      [newItem.id]: { visible: true, sortable: true, searchable: true },
    }));

    // 실제 컴포넌트 업데이트
    onUpdateComponent({ items: [...component.items, newItem] });
  },
  [component.items, onUpdateComponent]
);

// 삭제 시
const removeItem = useCallback(
  (itemId) => {
    // 로컬 상태에서 제거
    setLocalColumnInputs((prev) => {
      const newInputs = { ...prev };
      delete newInputs[itemId];
      return newInputs;
    });

    setLocalColumnCheckboxes((prev) => {
      const newCheckboxes = { ...prev };
      delete newCheckboxes[itemId];
      return newCheckboxes;
    });

    // 실제 컴포넌트 업데이트
    const updatedItems = component.items.filter((item) => item.id !== itemId);
    onUpdateComponent({ items: updatedItems });
  },
  [component.items, onUpdateComponent]
);

📊 적용 범위

이 패턴은 화면관리 시스템의 다음 컴포넌트들에 적용되었습니다:

  • PropertiesPanel: 기본 속성 편집 (위치, 크기, 라벨 등)
  • DataTableConfigPanel: 데이터 테이블 상세 설정
  • DateTypeConfigPanel: 날짜 타입 상세 설정
  • NumberTypeConfigPanel: 숫자 타입 상세 설정
  • SelectTypeConfigPanel: 선택박스 타입 상세 설정
  • TextTypeConfigPanel: 텍스트 타입 상세 설정
  • 기타 모든 웹타입별 설정 패널들

🎯 사용자 경험 향상 효과

  • 🚀 즉시 피드백: 타이핑하는 순간 화면에 바로 반영
  • 🔄 상태 일관성: 패널을 닫았다 열어도 입력한 값이 정확히 유지
  • 빠른 반응성: 지연 없는 실시간 UI 업데이트
  • 🛡️ 안정성: 메모리 누수 없는 완전한 상태 관리

🚀 핵심 기능

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;
}

🎯 메뉴 할당 시스템 (신규 완성)

1. 화면 저장 후 메뉴 할당 워크플로우

전체 프로세스

화면 설계 완료 → 저장 버튼 클릭 → 메뉴 할당 모달 자동 팝업
    ↓
메뉴 선택 및 할당 OR "나중에 할당" 클릭
    ↓
성공 화면 표시 (3초간 시각적 피드백)
    ↓
자동으로 화면 목록 페이지로 이동

메뉴 할당 모달 (MenuAssignmentModal)

주요 기능:

  1. 관리자 메뉴만 표시: 화면관리는 관리자 전용 기능이므로 관리자 메뉴(menuType: "0")만 로드
  2. 셀렉트박스 내부 검색: 메뉴명, URL, 설명으로 실시간 검색 가능
  3. 기존 화면 감지: 선택한 메뉴에 이미 할당된 화면이 있는지 자동 확인
  4. 화면 교체 확인: 기존 화면이 있을 때 교체 확인 대화상자 표시
interface MenuAssignmentModalProps {
  isOpen: boolean;
  onClose: () => void;
  screenInfo: ScreenDefinition | null;
  onAssignmentComplete?: () => void;
  onBackToList?: () => void; // 화면 목록으로 돌아가는 콜백
}

2. 메뉴 검색 시스템

// 셀렉트박스 내부 검색 구현
<SelectContent className="max-h-64">
  {/* 검색 입력 필드 */}
  <div className="sticky top-0 z-10 border-b bg-white p-2">
    <div className="relative">
      <Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
      <Input
        placeholder="메뉴명, URL, 설명으로 검색..."
        value={searchTerm}
        onChange={(e) => {
          e.stopPropagation(); // 이벤트 전파 방지
          setSearchTerm(e.target.value);
        }}
        onKeyDown={(e) => e.stopPropagation()}
        onClick={(e) => e.stopPropagation()}
        className="h-8 pr-8 pl-10 text-sm"
      />
      {searchTerm && (
        <button onClick={() => setSearchTerm("")}>
          <X className="h-3 w-3" />
        </button>
      )}
    </div>
  </div>
  {/* 메뉴 옵션들 */}
  <div className="max-h-48 overflow-y-auto">{getMenuOptions()}</div>
</SelectContent>

3. 기존 화면 감지 및 교체 시스템

// 메뉴 선택 시 기존 할당된 화면 확인
const handleMenuSelect = async (menuId: string) => {
  const menu = menus.find((m) => m.objid?.toString() === menuId);
  setSelectedMenu(menu || null);

  if (menu) {
    try {
      const menuObjid = parseInt(menu.objid?.toString() || "0");
      const screens = await menuScreenApi.getScreensByMenu(menuObjid);
      setExistingScreens(screens);
    } catch (error) {
      console.error("할당된 화면 조회 실패:", error);
    }
  }
};

// 할당 시 기존 화면 확인
const handleAssignScreen = async () => {
  if (existingScreens.length > 0) {
    // 이미 같은 화면이 할당되어 있는지 확인
    const alreadyAssigned = existingScreens.some(
      (screen) => screen.screenId === screenInfo.screenId
    );
    if (alreadyAssigned) {
      toast.info("이미 해당 메뉴에 할당된 화면입니다.");
      return;
    }

    // 다른 화면이 할당되어 있으면 교체 확인
    setShowReplaceDialog(true);
    return;
  }

  // 기존 화면이 없으면 바로 할당
  await performAssignment();
};

4. 화면 교체 확인 대화상자

시각적 구분:

  • 🔴 제거될 화면: 빨간색 배경으로 표시
  • 🟢 새로 할당될 화면: 초록색 배경으로 표시
  • 🟠 주의 메시지: 작업이 되돌릴 수 없음을 명확히 안내

안전한 교체 프로세스:

  1. 기존 화면들을 하나씩 제거
  2. 새 화면 할당
  3. 성공/실패 로그 출력
// 기존 화면 교체인 경우 기존 화면들 먼저 제거
if (replaceExisting && existingScreens.length > 0) {
  for (const existingScreen of existingScreens) {
    try {
      await menuScreenApi.unassignScreenFromMenu(
        existingScreen.screenId,
        menuObjid
      );
      console.log(`기존 화면 "${existingScreen.screenName}" 제거 완료`);
    } catch (error) {
      console.error(
        `기존 화면 "${existingScreen.screenName}" 제거 실패:`,
        error
      );
    }
  }
}

// 새 화면 할당
await menuScreenApi.assignScreenToMenu(screenInfo.screenId, menuObjid);

5. 성공 피드백 및 자동 이동

성공 화면 구성:

  • 체크마크 아이콘: 성공을 나타내는 녹색 체크마크
  • 🎯 성공 메시지: 구체적인 할당 완료 메시지
  • ⏱️ 자동 이동 안내: "3초 후 자동으로 화면 목록으로 이동합니다..."
  • 🔵 로딩 애니메이션: 3개의 점이 순차적으로 바운스하는 애니메이션
// 성공 상태 설정
setAssignmentSuccess(true);
setAssignmentMessage(successMessage);

// 3초 후 자동으로 화면 목록으로 이동
setTimeout(() => {
  if (onBackToList) {
    onBackToList();
  } else {
    onClose();
  }
}, 3000);

성공 화면 UI:

{assignmentSuccess ? (
  // 성공 화면
  <>
    <DialogHeader>
      <DialogTitle className="flex items-center gap-2">
        <div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-100">
          <svg className="h-5 w-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
          </svg>
        </div>
        화면 할당 완료
      </DialogTitle>
    </DialogHeader>

    <div className="space-y-4">
      <div className="rounded-lg border bg-green-50 p-4">
        <div className="flex items-center gap-3">
          <div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-100">
            <Monitor className="h-5 w-5 text-green-600" />
          </div>
          <div className="flex-1">
            <p className="text-sm font-medium text-green-900">{assignmentMessage}</p>
            <p className="mt-1 text-xs text-green-700">
              3  자동으로 화면 목록으로 이동합니다...
            </p>
          </div>
        </div>
      </div>

      {/* 로딩 애니메이션 */}
      <div className="flex items-center justify-center space-x-2">
        <div className="h-2 w-2 animate-bounce rounded-full bg-green-500 [animation-delay:-0.3s]"></div>
        <div className="h-2 w-2 animate-bounce rounded-full bg-green-500 [animation-delay:-0.15s]"></div>
        <div className="h-2 w-2 animate-bounce rounded-full bg-green-500"></div>
      </div>
    </div>
  </>
) : (
  // 기본 할당 화면
  // ...
)}

6. 사용자 경험 개선사항

  1. 선택적 할당: 필수가 아닌 선택적 기능으로 "나중에 할당" 가능
  2. 직관적 UI: 저장된 화면 정보를 모달에서 바로 확인 가능
  3. 검색 기능: 많은 메뉴 중에서 쉽게 찾을 수 있음
  4. 상태 표시: 메뉴 활성/비활성 상태, 기존 할당된 화면 정보 표시
  5. 완전한 워크플로우: 저장 → 할당 → 목록 복귀의 자연스러운 흐름

🌐 API 설계

1. 메뉴-화면 할당 API

화면을 메뉴에 할당

POST /screen-management/screens/:screenId/assign-menu
Request: {
  menuObjid: number;
  displayOrder?: number;
}

메뉴별 할당된 화면 목록 조회

GET /screen-management/menus/:menuObjid/screens
Response: {
  success: boolean;
  data: ScreenDefinition[];
}

화면-메뉴 할당 해제

DELETE /screen-management/screens/:screenId/menus/:menuObjid
Response: {
  success: boolean;
  message: string;
}

2. 화면 정의 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/assign-menu
Body: {
  menuObjid: number;
  displayOrder?: number;
  // companyCode는 JWT에서 자동 추출
}

메뉴별 화면 목록 조회

GET /api/screen-management/menus/:menuObjid/screens
Response: {
  success: boolean;
  data: ScreenDefinition[];
}

화면-메뉴 할당 해제

DELETE /api/screen-management/screens/:screenId/menus/:menuObjid
Response: {
  success: boolean;
  message: string;
}

화면 코드 자동 생성

GET /api/screen-management/generate-screen-code/:companyCode
Response: {
  success: boolean;
  data: {
    screenCode: string; // 예: "COMP_001"
  };
}

단일 화면 조회

GET /api/screen-management/screens/:id
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>
  );
}

⚙️ 백엔드 구현

🆕 데이터 테이블 실시간 조회 API (2025.09 추가)

1. 테이블 데이터 조회 API

라우트 설정

// tableManagementRoutes.ts
/**
 * 테이블 데이터 조회 (페이징 + 검색)
 * POST /api/table-management/tables/:tableName/data
 */
router.post("/tables/:tableName/data", getTableData);

컨트롤러 구현

// tableManagementController.ts
export async function getTableData(
  req: AuthenticatedRequest,
  res: Response
): Promise<void> {
  try {
    const { tableName } = req.params;
    const {
      page = 1,
      size = 10,
      search = {},
      sortBy,
      sortOrder = "asc",
    } = req.body;

    logger.info(`=== 테이블 데이터 조회 시작: ${tableName} ===`);
    logger.info(`페이징: page=${page}, size=${size}`);
    logger.info(`검색 조건:`, search);

    if (!tableName) {
      const response: ApiResponse<null> = {
        success: false,
        message: "테이블명이 필요합니다.",
        error: { code: "MISSING_TABLE_NAME" },
      };
      res.status(400).json(response);
      return;
    }

    const tableManagementService = new TableManagementService();

    // 데이터 조회
    const result = await tableManagementService.getTableData(tableName, {
      page: parseInt(page),
      size: parseInt(size),
      search,
      sortBy,
      sortOrder,
    });

    const response: ApiResponse<any> = {
      success: true,
      message: "테이블 데이터를 성공적으로 조회했습니다.",
      data: result,
    };

    res.status(200).json(response);
  } catch (error) {
    logger.error("테이블 데이터 조회 중 오류 발생:", error);
    // 오류 응답 처리
  }
}

서비스 로직

// tableManagementService.ts
export class TableManagementService {
  /**
   * 테이블 데이터 조회 (페이징 + 검색)
   */
  async getTableData(
    tableName: string,
    options: {
      page: number;
      size: number;
      search?: Record<string, any>;
      sortBy?: string;
      sortOrder?: string;
    }
  ): Promise<{
    data: any[];
    total: number;
    page: number;
    size: number;
    totalPages: number;
  }> {
    try {
      const { page, size, search = {}, sortBy, sortOrder = "asc" } = options;
      const offset = (page - 1) * size;

      // WHERE 조건 구성 (SQL 인젝션 방지)
      let whereConditions: string[] = [];
      let searchValues: any[] = [];
      let paramIndex = 1;

      if (search && Object.keys(search).length > 0) {
        for (const [column, value] of Object.entries(search)) {
          if (value !== null && value !== undefined && value !== "") {
            // 안전한 컬럼명 검증
            const safeColumn = column.replace(/[^a-zA-Z0-9_]/g, "");

            if (typeof value === "string") {
              whereConditions.push(`${safeColumn}::text ILIKE $${paramIndex}`);
              searchValues.push(`%${value}%`);
            } else {
              whereConditions.push(`${safeColumn} = $${paramIndex}`);
              searchValues.push(value);
            }
            paramIndex++;
          }
        }
      }

      const whereClause =
        whereConditions.length > 0
          ? `WHERE ${whereConditions.join(" AND ")}`
          : "";

      // ORDER BY 조건 구성
      let orderClause = "";
      if (sortBy) {
        const safeSortBy = sortBy.replace(/[^a-zA-Z0-9_]/g, "");
        const safeSortOrder =
          sortOrder.toLowerCase() === "desc" ? "DESC" : "ASC";
        orderClause = `ORDER BY ${safeSortBy} ${safeSortOrder}`;
      }

      // 안전한 테이블명 검증
      const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, "");

      // 전체 개수 조회
      const countQuery = `SELECT COUNT(*) as count FROM ${safeTableName} ${whereClause}`;
      const countResult = await prisma.$queryRawUnsafe<any[]>(
        countQuery,
        ...searchValues
      );
      const total = parseInt(countResult[0].count);

      // 데이터 조회
      const dataQuery = `
        SELECT * FROM ${safeTableName} 
        ${whereClause} 
        ${orderClause}
        LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
      `;

      const data = await prisma.$queryRawUnsafe<any[]>(
        dataQuery,
        ...searchValues,
        size,
        offset
      );

      const totalPages = Math.ceil(total / size);

      return {
        data,
        total,
        page,
        size,
        totalPages,
      };
    } catch (error) {
      logger.error(`테이블 데이터 조회 오류: ${tableName}`, error);
      throw error;
    }
  }
}

2. API 응답 형식

성공 응답

{
  "success": true,
  "message": "테이블 데이터를 성공적으로 조회했습니다.",
  "data": {
    "data": [
      {
        "objid": 1,
        "target_objid": 12345,
        "approval_seq": "A001",
        "regdate": "2025-09-03T05:39:14.000Z",
        "status": "pending"
      }
    ],
    "total": 1,
    "page": 1,
    "size": 5,
    "totalPages": 1
  }
}

요청 형식

{
  "page": 1,
  "size": 10,
  "search": {
    "approval_seq": "A001",
    "status": "pending"
  },
  "sortBy": "regdate",
  "sortOrder": "desc"
}

3. 보안 및 성능 최적화

SQL 인젝션 방지

  • 정규표현식을 통한 안전한 컬럼명/테이블명 검증
  • 파라미터 바인딩 사용 ($queryRawUnsafe with parameters)
  • 사용자 입력값 필터링

성능 최적화

  • 페이징 처리로 대용량 데이터 대응
  • COUNT 쿼리와 데이터 쿼리 분리
  • 인덱스 기반 정렬 지원

에러 처리

  • 상세한 로깅 시스템
  • 사용자 친화적 오류 메시지
  • HTTP 상태 코드 준수

4. 도커 환경 통합

개발 환경 설정

# 백엔드 컨테이너 재빌드 (새 API 반영)
docker-compose -f docker/dev/docker-compose.backend.mac.yml down backend
docker-compose -f docker/dev/docker-compose.backend.mac.yml up --build -d backend

# API 테스트
curl -X POST http://localhost:8080/api/table-management/tables/approval/data \
  -H "Content-Type: application/json" \
  -d '{"page": 1, "size": 5}'

환경 변수

# backend-node/.env
PORT=8080
DATABASE_URL=postgresql://postgres:password@localhost:5432/ilshin
NODE_ENV=development

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. 관리자 메뉴 검색: 메뉴명, URL, 설명으로 실시간 검색
  4. 기존 화면 확인: 선택한 메뉴에 이미 할당된 화면 자동 감지
  5. 교체 확인: 기존 화면이 있을 때 교체 여부 확인 대화상자
  6. 안전한 교체: 기존 화면 제거 후 새 화면 할당
  7. 성공 피드백: 3초간 성공 화면 표시 후 자동으로 화면 목록으로 이동

기존 메뉴 할당 방식

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

7. 화면 배포

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

📅 개발 계획 및 진행상황

Phase 1: 기본 구조 및 데이터베이스 (완료)

  • 데이터베이스 스키마 설계 및 생성
  • 기본 API 구조 설계
  • 화면 정의 및 레이아웃 테이블 생성
  • 기본 CRUD API 구현
  • 화면 코드 자동 생성 API 구현
  • 회사별 권한 관리 시스템 구현

구현 완료 사항:

  • PostgreSQL용 화면관리 테이블 스키마 생성
  • 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 환경에서 실행 가능
  • 기본 기능 테스트 완료
  • 레이아웃 저장/로드 API 및 UI 구현
  • 메뉴 관리에서 화면 할당 기능 구현
  • 할당된 화면을 실제 사용 가능한 인터랙티브 화면으로 렌더링
  • 실제 사용자 입력 및 상호작용 가능한 완전 기능 화면 구현
  • 🆕 완전한 메뉴 할당 워크플로우: 저장 → 메뉴 할당 모달 → 기존 화면 교체 확인 → 성공 피드백 → 목록 복귀

🎯 현재 구현된 핵심 기능

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: 전체 화면 활용한 효율적인 레이아웃

5. 인터랙티브 화면 뷰어 (신규 완성)

  • 실제 사용자 입력: 설계된 화면에서 실제 데이터 입력 및 편집 가능
  • 완전 기능 위젯: 모든 웹 타입별 실제 동작하는 위젯 구현
  • 폼 데이터 관리: 실시간 폼 상태 관리 및 데이터 수집
  • 저장 기능: 입력된 데이터를 수집하여 저장 처리
  • 메뉴 연동: 메뉴 클릭 시 할당된 인터랙티브 화면으로 자동 이동

🆕 6. 실시간 데이터 테이블 (2025.09 추가)

InteractiveDataTable 컴포넌트

실제 화면에서 동작하는 완전한 데이터 테이블 구현

핵심 기능

  • 실시간 데이터 조회: PostgreSQL 데이터베이스에서 직접 데이터 로드
  • 페이지네이션: 대용량 데이터 효율적 탐색 (페이지당 항목 수 설정 가능)
  • 다중 검색 필터: 웹타입별 맞춤형 검색 UI (text, number, date, select 등)
  • 정렬 기능: 컬럼별 오름차순/내림차순 정렬 지원
  • 반응형 레이아웃: 격자 시스템 기반 컬럼 너비 조정

구현 코드

// InteractiveDataTable.tsx
export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
  component,
  className = "",
  style = {},
}) => {
  const [data, setData] = useState<Record<string, any>[]>([]);
  const [loading, setLoading] = useState(false);
  const [searchValues, setSearchValues] = useState<Record<string, any>>({});
  const [currentPage, setCurrentPage] = useState(1);
  const [totalPages, setTotalPages] = useState(1);
  const [total, setTotal] = useState(0);

  // 데이터 로드 함수
  const loadData = useCallback(
    async (page: number = 1, searchParams: Record<string, any> = {}) => {
      if (!component.tableName) return;

      setLoading(true);
      try {
        const result = await tableTypeApi.getTableData(component.tableName, {
          page,
          size: component.pagination?.pageSize || 10,
          search: searchParams,
        });

        setData(result.data);
        setTotal(result.total);
        setTotalPages(result.totalPages);
        setCurrentPage(result.page);
      } catch (error) {
        console.error("❌ 테이블 데이터 조회 실패:", error);
        setData([]);
      } finally {
        setLoading(false);
      }
    },
    [component.tableName, component.pagination?.pageSize]
  );

  // 검색 실행
  const handleSearch = useCallback(() => {
    loadData(1, searchValues);
  }, [searchValues, loadData]);

  return (
    <div className={`bg-white ${className}`} style={style}>
      {/* 헤더: 제목, 로딩 상태, 검색 버튼 */}
      <div className="border-b bg-gray-50 px-4 py-3">
        <div className="flex items-center justify-between">
          <h3 className="text-sm font-medium text-gray-900">
            {component.title || component.label}
            {loading && (
              <Badge variant="secondary" className="ml-2">
                로딩중...
              </Badge>
            )}
          </h3>
          <div className="flex items-center space-x-2">
            {component.showSearchButton && (
              <Button size="sm" onClick={handleSearch} disabled={loading}>
                {component.searchButtonText || "검색"}
              </Button>
            )}
          </div>
        </div>

        {/* 검색 필터 */}
        {component.filters?.length > 0 && (
          <div className="mt-3 space-y-2">
            <div className="text-xs font-medium text-gray-700">검색 필터</div>
            <div
              className="grid gap-2"
              style={{
                gridTemplateColumns: component.filters
                  .map((filter) => `${filter.gridColumns || 3}fr`)
                  .join(" "),
              }}
            >
              {component.filters.map((filter) => renderSearchFilter(filter))}
            </div>
          </div>
        )}
      </div>

      {/* 테이블 데이터 */}
      <div className="flex-1 overflow-auto p-4">
        {/* 헤더 행 */}
        <div
          className="gap-2 border-b pb-2 text-xs font-medium text-gray-700"
          style={{
            display: "grid",
            gridTemplateColumns: component.columns
              ?.filter((col) => col.visible)
              .map((col) => `${col.gridColumns || 2}fr`)
              .join(" "),
          }}
        >
          {component.columns
            ?.filter((col) => col.visible)
            .map((column) => (
              <div key={column.id} className="truncate">
                {column.label}
              </div>
            ))}
        </div>

        {/* 데이터 행들 */}
        {loading ? (
          <div className="py-8 text-center text-xs text-gray-500">
            데이터를 불러오는 ...
          </div>
        ) : data.length > 0 ? (
          data.map((row, rowIndex) => (
            <div
              key={rowIndex}
              className="gap-2 py-1 text-xs text-gray-600 hover:bg-gray-50"
              style={{
                display: "grid",
                gridTemplateColumns: component.columns
                  ?.filter((col) => col.visible)
                  .map((col) => `${col.gridColumns || 2}fr`)
                  .join(" "),
              }}
            >
              {component.columns
                ?.filter((col) => col.visible)
                .map((column) => (
                  <div key={column.id} className="truncate">
                    {formatCellValue(row[column.columnName], column)}
                  </div>
                ))}
            </div>
          ))
        ) : (
          <div className="py-8 text-center text-xs text-gray-500">
            검색 결과가 없습니다
          </div>
        )}
      </div>

      {/* 페이지네이션 */}
      {component.pagination?.enabled && totalPages > 1 && (
        <div className="border-t bg-gray-50 px-4 py-2">
          <div className="flex items-center justify-between text-xs text-gray-600">
            {component.pagination.showPageInfo && (
              <div>
                 {total.toLocaleString()} {" "}
                {((currentPage - 1) * pageSize + 1).toLocaleString()}-
                {Math.min(currentPage * pageSize, total).toLocaleString()}
              </div>
            )}
            <div className="flex items-center space-x-1">
              <Button
                size="sm"
                variant="outline"
                onClick={() => handlePageChange(currentPage - 1)}
                disabled={currentPage === 1}
              >
                이전
              </Button>
              <span className="px-2">
                {currentPage} / {totalPages}
              </span>
              <Button
                size="sm"
                variant="outline"
                onClick={() => handlePageChange(currentPage + 1)}
                disabled={currentPage === totalPages}
              >
                다음
              </Button>
            </div>
          </div>
        </div>
      )}
    </div>
  );
};

데이터 포맷팅

// 셀 값 포맷팅
const formatCellValue = (value: any, column: DataTableColumn) => {
  if (value === null || value === undefined) return "";

  switch (column.widgetType) {
    case "date":
      if (value) {
        try {
          const date = new Date(value);
          return date.toLocaleDateString("ko-KR");
        } catch {
          return value;
        }
      }
      break;

    case "datetime":
      if (value) {
        try {
          const date = new Date(value);
          return date.toLocaleString("ko-KR");
        } catch {
          return value;
        }
      }
      break;

    case "number":
    case "decimal":
      if (typeof value === "number") {
        return value.toLocaleString();
      }
      break;

    default:
      return String(value);
  }

  return String(value);
};

InteractiveScreenViewer 통합

// InteractiveScreenViewer.tsx
const renderInteractiveWidget = (comp: ComponentData) => {
  // 데이터 테이블 컴포넌트 처리
  if (comp.type === "datatable") {
    return (
      <InteractiveDataTable
        component={comp as DataTableComponent}
        className="h-full w-full"
        style={{
          width: "100%",
          height: "100%",
        }}
      />
    );
  }

  // 기존 위젯 처리...
};

사용자 경험 (UX)

1. 관리자 (화면 설계)

  1. 드래그앤드롭으로 데이터 테이블 컴포넌트 배치
  2. 속성 패널에서 테이블 선택 및 컬럼 설정
  3. 검색 필터 추가 및 페이지네이션 설정
  4. 실시간 미리보기로 결과 확인

2. 사용자 (실제 화면)

  1. 메뉴를 통해 할당된 화면 접근
  2. 검색 필터로 원하는 데이터 필터링
  3. 페이지네이션으로 대용량 데이터 탐색
  4. 실시간 데이터 로딩 및 정렬

🎨 Shadcn UI 기반 모던 디자인

핵심 컴포넌트

  • Card: 전체 테이블을 감싸는 메인 컨테이너
  • Table: Shadcn Table 컴포넌트로 표준화된 테이블 UI
  • Badge: 로딩 상태 및 필터 개수 표시
  • Button: 일관된 액션 버튼 디자인
  • Separator: 섹션 구분선

디자인 특징

// 메인 카드 레이아웃
<Card className="h-full flex flex-col">
  <CardHeader className="pb-3">
    {/* 아이콘 + 제목 + 액션 버튼들 */}
    <div className="flex items-center space-x-2">
      <Database className="h-4 w-4 text-muted-foreground" />
      <CardTitle className="text-lg">{title}</CardTitle>
      {loading && (
        <Badge variant="secondary" className="flex items-center gap-1">
          <Loader2 className="h-3 w-3 animate-spin" />
          로딩중...
        </Badge>
      )}
    </div>
  </CardHeader>

  {/* Shadcn Table 사용 */}
  <CardContent className="flex-1 overflow-hidden p-0">
    <Table>
      <TableHeader>
        <TableRow>
          {columns.map((column) => (
            <TableHead className="font-semibold">{column.label}</TableHead>
          ))}
        </TableRow>
      </TableHeader>
      <TableBody>{/* 로딩, 데이터, 빈 상태 처리 */}</TableBody>
    </Table>
  </CardContent>
</Card>

시각적 개선사항

  • 아이콘 통합: Lucide React 아이콘으로 시각적 일관성
  • 로딩 애니메이션: 스피너 아이콘으로 실시간 피드백
  • 상태별 메시지: 빈 데이터, 로딩, 에러 상태별 적절한 UI
  • 호버 효과: 테이블 행 호버 시 시각적 피드백
  • 반응형 버튼: 아이콘 + 텍스트 조합으로 명확한 액션 표시

기술적 특징

  • 성능 최적화: React.useMemo를 활용한 메모이제이션
  • 보안: SQL 인젝션 방지 및 입력값 검증
  • 확장성: 웹타입별 검색 필터 및 데이터 포맷터
  • 반응형: CSS Grid 기반 유연한 레이아웃
  • 접근성: Shadcn UI의 WAI-ARIA 표준 준수
  • 타입 안전성: TypeScript 완전 지원

🚀 다음 단계 계획

1. 웹타입별 상세 설정 기능 구현 (진행 예정)

📋 구현 계획 개요

각 웹 타입(date, number, select 등)에 대한 세부적인 설정을 가능하게 하여 더 정교한 폼 컨트롤을 제공

🎯 단계별 구현 계획

Phase 1: 타입 정의 및 인터페이스 설계
// 웹타입별 설정 인터페이스
interface DateTypeConfig {
  format: "YYYY-MM-DD" | "YYYY-MM-DD HH:mm" | "YYYY-MM-DD HH:mm:ss";
  showTime: boolean;
  minDate?: string;
  maxDate?: string;
  defaultValue?: string;
}

interface NumberTypeConfig {
  min?: number;
  max?: number;
  step?: number;
  format?: "integer" | "decimal" | "currency" | "percentage";
  decimalPlaces?: number;
  thousandSeparator?: boolean;
}

interface SelectTypeConfig {
  options: Array<{ label: string; value: string }>;
  multiple?: boolean;
  searchable?: boolean;
  placeholder?: string;
}
Phase 2: PropertiesPanel 확장
  • 웹 타입 선택 시 해당 타입의 세부 설정 UI 동적 표시
  • 각 타입별 전용 설정 컴포넌트 생성
  • 실시간 설정값 업데이트 및 미리보기
Phase 3: 우선순위 타입별 구현
  1. 날짜/시간 (date, datetime): 날짜 형식, 시간 포함 여부, 날짜 범위
  2. 숫자 (number, decimal): 범위, 형식, 소수점, 천 단위 구분자
  3. 선택박스 (select, dropdown): 동적 옵션 관리, 다중 선택, 검색 기능
  4. 텍스트 (text, textarea): 길이 제한, 입력 패턴, 형식 검증
  5. 파일 (file): 파일 형식 제한, 크기 제한, 다중 업로드
Phase 4: RealtimePreview 업데이트

설정값에 따른 실제 렌더링 로직 구현 (input 속성, 검증 규칙 등)

Phase 5: 저장/불러오기

컴포넌트 데이터에 webTypeConfig 포함하여 레이아웃 저장 시 설정값도 함께 저장

2. 컴포넌트 그룹화 기능 (완료)0

  • 여러 위젯을 컨테이너로 그룹화
  • 부모-자식 관계 설정(parentId)
  • 그룹 단위 이동
  • 그룹 UI 단순화(헤더/박스 제거)
  • 그룹 내 정렬/균등 분배 도구(아이콘 UI)
  • 그룹 단위 삭제/복사/붙여넣기

2. 레이아웃 저장/로드 (완료)

  • 설계한 화면을 데이터베이스에 저장
  • 저장된 화면 불러오기 기능
  • 변경사항 표시 및 저장 버튼 활성화
  • 레이아웃 데이터 JSON 형태 저장
  • 버전 관리 시스템 (향후 계획)

3. 메뉴-화면 할당 시스템 (완료)

  • 메뉴 관리에서 화면 할당 기능
  • 회사별 메뉴 필터링
  • 화면-메뉴 연결 관리
  • 할당된 화면 목록 조회
  • 화면 할당 해제 기능

4. 인터랙티브 화면 뷰어 (완료)

  • 실제 사용자 입력 가능한 화면 렌더링
  • 모든 웹 타입별 실제 위젯 구현
  • 폼 데이터 상태 관리
  • 실시간 데이터 바인딩
  • 저장 기능 및 토스트 알림

5. 데이터 바인딩 (부분 완료)

  • 실제 데이터베이스와 연결 (메타데이터 연동 완료)
  • 폼 제출 및 데이터 수집
  • 실시간 폼 데이터 관리
  • 실제 데이터베이스 저장 API (향후 계획)
  • 유효성 검증 시스템 (향후 계획)

6. 반응형 레이아웃 (향후 계획)

  • 다양한 화면 크기에 대응
  • 모바일/태블릿/데스크톱 최적화
  • 브레이크포인트 설정

7. 고급 기능 (향후 계획)

  • 조건부 표시 로직
  • 계산 필드 구현
  • 동적 옵션 로딩
  • 파일 업로드 처리
  • 실제 데이터베이스 CRUD 연동
  • 워크플로우 통합

🛠️ 기술 스택 (현재 구현)

프론트엔드

  • 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

🔧 핵심 기술적 구현 패턴

1. 실시간 속성 편집 패턴 (핵심 표준)

완성된 로컬 상태 + 글로벌 상태 이중 관리 시스템

모든 속성 편집 컴포넌트의 표준 패턴:

// 1단계: 로컬 상태 정의 (실시간 표시용)
const [localInputs, setLocalInputs] = useState({
  title: component.title || "",
  placeholder: component.placeholder || "",
  // 모든 입력 필드의 현재 값
});

const [localValues, setLocalValues] = useState({
  showButton: component.showButton ?? true,
  enabled: component.enabled ?? false,
  // 모든 체크박스의 현재 상태
});

// 2단계: 컴포넌트 변경 시 자동 동기화
useEffect(() => {
  setLocalInputs({
    title: component.title || "",
    placeholder: component.placeholder || "",
  });

  setLocalValues({
    showButton: component.showButton ?? true,
    enabled: component.enabled ?? false,
  });
}, [component.title, component.placeholder, component.showButton]);

// 3단계: 실시간 입력 처리 - 즉시 반영
<Input
  value={localInputs.title}
  onChange={(e) => {
    const newValue = e.target.value;
    // 1) 로컬 상태 즉시 업데이트 (화면 반영)
    setLocalInputs(prev => ({ ...prev, title: newValue }));
    // 2) 글로벌 상태 업데이트 (데이터 저장)
    onUpdateProperty("title", newValue);
  }}
/>

<Checkbox
  checked={localValues.showButton}
  onCheckedChange={(checked) => {
    // 1) 로컬 상태 즉시 업데이트
    setLocalValues(prev => ({ ...prev, showButton: checked as boolean }));
    // 2) 글로벌 상태 업데이트
    onUpdateProperty("showButton", checked);
  }}
/>

동적 컴포넌트별 상태 관리 (ID 기반)

// 컬럼별 개별 상태 관리
const [localColumnInputs, setLocalColumnInputs] = useState<
  Record<string, string>
>({});
const [localColumnCheckboxes, setLocalColumnCheckboxes] = useState<
  Record<string, { visible: boolean; sortable: boolean; searchable: boolean }>
>({});

// 기존 값 보존하면서 새 항목만 추가
useEffect(() => {
  setLocalColumnInputs((prev) => {
    const newInputs = { ...prev };
    component.columns?.forEach((col) => {
      if (!(col.id in newInputs)) {
        // 기존 입력값 보존
        newInputs[col.id] = col.label;
      }
    });
    return newInputs;
  });
}, [component.columns]);

// 동적 입력 처리
<Input
  value={
    localColumnInputs[column.id] !== undefined
      ? localColumnInputs[column.id]
      : column.label
  }
  onChange={(e) => {
    const newValue = e.target.value;
    setLocalColumnInputs((prev) => ({ ...prev, [column.id]: newValue }));
    updateColumn(column.id, { label: newValue });
  }}
/>;

스타일 속성 개별 업데이트 패턴

스타일 초기화 방지를 위한 개별 속성 업데이트:

// Bad: 전체 객체 교체로 인한 다른 속성 손실
onUpdateProperty("style", { ...selectedComponent.style, newProp: value });

// Good: 개별 속성 직접 업데이트
onUpdateProperty("style.labelFontSize", value);

📋 적용된 컴포넌트 목록

이 패턴이 완벽하게 적용된 컴포넌트들:

  • PropertiesPanel: 기본 속성 편집
  • DataTableConfigPanel: 데이터 테이블 상세 설정
  • DateTypeConfigPanel: 날짜 타입 상세 설정
  • NumberTypeConfigPanel: 숫자 타입 상세 설정
  • SelectTypeConfigPanel: 선택박스 타입 상세 설정
  • TextTypeConfigPanel: 텍스트 타입 상세 설정
  • 기타 모든 웹타입별 설정 패널들

2. 드래그앤드롭 패턴

다중 컴포넌트 드래그 처리

  • dragState에 draggedComponents 배열로 선택된 모든 컴포넌트 관리
  • 실시간 미리보기를 위한 RealtimePreview와 실제 업데이트 분리
  • justFinishedDrag 플래그로 드래그 완료 후 의도치 않은 선택 해제 방지

격자 스냅 시스템

  • 컴포넌트 위치와 크기를 격자에 맞게 자동 조정
  • 격자 설정 변경 시 기존 컴포넌트들도 자동 재조정

3. 컴포넌트 렌더링 패턴

웹타입별 동적 렌더링

RealtimePreview에서 switch-case로 웹타입별 적절한 입력 컴포넌트 렌더링:

switch (widgetType) {
  case "text":
    return <Input type="text" {...commonProps} />;
  case "date":
    return <Input type="date" {...commonProps} />;
  case "select":
    return <select>...</select>;
}

라벨 동적 위치 계산

라벨 하단 여백 설정에 따른 동적 위치 계산:

const labelMarginBottomValue = parseInt(component.style?.labelMarginBottom || "4px", 10);
style={{ top: `${-20 - labelMarginBottomValue}px` }}

4. 패널 관리 패턴

플로팅 패널 상태 관리

  • 각 패널의 위치, 크기, 열림/닫힘 상태를 독립적으로 관리
  • 사용자가 수동으로 조정한 위치 기억
  • autoHeight 제거로 컨텐츠 변경 시에도 위치 유지

5. 타입 안전성 패턴

인터페이스 확장 패턴

BaseComponent를 기본으로 각 컴포넌트 타입별 확장:

export interface WidgetComponent extends BaseComponent {
  type: "widget";
  widgetType: WebType;
  // 위젯 전용 속성들
}

유니온 타입 활용

ComponentData = ContainerComponent | WidgetComponent | GroupComponent 등으로 타입 안전성 보장

🎯 결론

화면관리 시스템은 회사별 권한 관리테이블 타입관리 연계를 통해 사용자가 직관적으로 웹 화면을 설계할 수 있는 강력한 도구입니다.

🏢 회사별 화면 관리의 핵심 가치

  • 권한 격리: 사용자는 자신이 속한 회사의 화면만 제작/수정 가능
  • 관리자 통제: 회사 코드 '*'인 관리자는 모든 회사의 화면을 제어
  • 메뉴 연동: 각 회사의 메뉴에만 화면 할당하여 완벽한 데이터 분리

🎨 향상된 사용자 경험

  • 드래그앤드롭 인터페이스: 직관적인 화면 설계
  • 실시간 미리보기: 설계한 화면을 실제 웹 위젯으로 즉시 확인
  • 실행취소/다시실행: 최대 50개 히스토리 관리, 키보드 단축키 지원
  • 자동 위젯 생성: 컬럼의 웹 타입에 따른 스마트한 위젯 생성
  • 13가지 웹 타입 지원: text, email, tel, number, decimal, date, datetime, select, dropdown, textarea, checkbox, radio, code, entity, file

🚀 기술적 혜택

  • 기존 테이블 구조 100% 호환: 별도 스키마 변경 없이 바로 개발 가능
  • 권한 기반 보안: 회사 간 데이터 완전 격리
  • 확장 가능한 아키텍처: 새로운 웹 타입과 컴포넌트 쉽게 추가
  • 실시간 렌더링: Shadcn UI 기반 실제 웹 컴포넌트 렌더링
  • 성능 최적화: 검색/페이징, 메모이제이션, 깊은 복사 최적화

📊 현재 구현 완료율: 95%

  • Phase 1-6 완료: 기본 구조, 드래그앤드롭, 컴포넌트 라이브러리, 테이블 연계, 미리보기, 통합 테스트
  • 핵심 기능 완료: 컴포넌트 그룹화, 레이아웃 저장/로드, 메뉴-화면 할당, 인터랙티브 화면 뷰어
  • 고도화 완료: 실시간 속성 편집, 라벨 관리, 다중 드래그, 격자 시스템
  • 📋 다음 계획: 웹타입별 상세 설정, 반응형 레이아웃, 고급 기능

🎉 완전 기능 화면관리 시스템 완성!

이 시스템을 통해 ERP 시스템의 화면 개발 생산성을 크게 향상시키고, 회사별 맞춤형 화면 구성사용자 요구사항에 따른 빠른 화면 구성이 가능합니다.

주요 완성 기능:

  • 드래그앤드롭 화면 설계: 직관적인 UI/UX로 누구나 쉽게 화면 제작
  • 실시간 미리보기: 설계한 화면을 실제 웹 위젯으로 즉시 확인
  • 회사별 권한 관리: 완벽한 데이터 격리 및 보안
  • 메뉴 연동: 설계한 화면을 실제 메뉴에 할당하여 즉시 사용
  • 인터랙티브 화면: 할당된 화면에서 실제 사용자 입력 및 상호작용 가능
  • 13가지 웹 타입 지원: 모든 업무 요구사항에 대응 가능한 다양한 위젯
  • 🆕 완전한 메뉴 할당 워크플로우: 저장 → 자동 메뉴 할당 → 기존 화면 교체 확인 → 성공 피드백 → 목록 복귀의 완벽한 사용자 경험