1609 lines
43 KiB
Markdown
1609 lines
43 KiB
Markdown
|
|
# 화면 임베딩 및 데이터 전달 시스템 구현 계획서
|
||
|
|
|
||
|
|
## 📋 목차
|
||
|
|
1. [개요](#개요)
|
||
|
|
2. [현재 문제점](#현재-문제점)
|
||
|
|
3. [목표](#목표)
|
||
|
|
4. [시스템 아키텍처](#시스템-아키텍처)
|
||
|
|
5. [데이터베이스 설계](#데이터베이스-설계)
|
||
|
|
6. [타입 정의](#타입-정의)
|
||
|
|
7. [컴포넌트 구조](#컴포넌트-구조)
|
||
|
|
8. [API 설계](#api-설계)
|
||
|
|
9. [구현 단계](#구현-단계)
|
||
|
|
10. [사용 시나리오](#사용-시나리오)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 개요
|
||
|
|
|
||
|
|
### 배경
|
||
|
|
현재 화면관리 시스템은 단일 화면 단위로만 동작하며, 화면 간 데이터 전달이나 화면 임베딩이 불가능합니다. 실무에서는 "입고 등록"과 같이 **좌측에서 데이터를 선택하고 우측으로 전달하여 처리하는** 복잡한 워크플로우가 필요합니다.
|
||
|
|
|
||
|
|
### 핵심 요구사항
|
||
|
|
- **화면 임베딩**: 기존 화면을 다른 화면 안에 재사용
|
||
|
|
- **데이터 전달**: 한 화면에서 선택한 데이터를 다른 화면의 컴포넌트로 전달
|
||
|
|
- **유연한 매핑**: 테이블뿐만 아니라 입력 필드, 셀렉트 박스, 리피터 등 모든 컴포넌트에 데이터 주입 가능
|
||
|
|
- **변환 함수**: 합계, 평균, 개수 등 데이터 변환 지원
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 현재 문제점
|
||
|
|
|
||
|
|
### 1. 화면 재사용 불가
|
||
|
|
- 각 화면은 독립적으로만 동작
|
||
|
|
- 동일한 기능을 여러 화면에서 중복 구현
|
||
|
|
|
||
|
|
### 2. 화면 간 데이터 전달 불가
|
||
|
|
- 한 화면에서 선택한 데이터를 다른 화면으로 전달할 수 없음
|
||
|
|
- 사용자가 수동으로 복사/붙여넣기 해야 함
|
||
|
|
|
||
|
|
### 3. 복잡한 워크플로우 구현 불가
|
||
|
|
- "발주 목록 조회 → 품목 선택 → 입고 등록"과 같은 프로세스를 단일 화면에서 처리 불가
|
||
|
|
- 여러 화면을 오가며 작업해야 하는 불편함
|
||
|
|
|
||
|
|
### 4. 컴포넌트별 데이터 주입 불가
|
||
|
|
- 테이블에만 데이터를 추가할 수 있음
|
||
|
|
- 입력 필드, 셀렉트 박스 등에 자동으로 값을 설정할 수 없음
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 목표
|
||
|
|
|
||
|
|
### 주요 목표
|
||
|
|
1. **화면 임베딩 시스템 구축**: 기존 화면을 컨테이너로 사용
|
||
|
|
2. **범용 데이터 전달 시스템**: 모든 컴포넌트 타입 지원
|
||
|
|
3. **시각적 매핑 설정 UI**: 드래그앤드롭으로 매핑 규칙 설정
|
||
|
|
4. **실시간 미리보기**: 데이터 전달 결과를 즉시 확인
|
||
|
|
|
||
|
|
### 부가 목표
|
||
|
|
- 조건부 데이터 전달 (필터링)
|
||
|
|
- 데이터 변환 함수 (합계, 평균, 개수 등)
|
||
|
|
- 양방향 데이터 동기화
|
||
|
|
- 트랜잭션 지원 (전체 성공 또는 전체 실패)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 시스템 아키텍처
|
||
|
|
|
||
|
|
### 전체 구조
|
||
|
|
|
||
|
|
```
|
||
|
|
┌─────────────────────────────────────────────────────────┐
|
||
|
|
│ Screen Split Panel │
|
||
|
|
│ │
|
||
|
|
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||
|
|
│ │ Left Screen │ │ Right Screen │ │
|
||
|
|
│ │ (Source) │ │ (Target) │ │
|
||
|
|
│ │ │ │ │ │
|
||
|
|
│ │ ┌────────────┐ │ │ ┌────────────┐ │ │
|
||
|
|
│ │ │ Table │ │ │ │ Form │ │ │
|
||
|
|
│ │ │ (Select) │ │ │ │ │ │ │
|
||
|
|
│ │ └────────────┘ │ │ └────────────┘ │ │
|
||
|
|
│ │ │ │ │ │
|
||
|
|
│ │ [✓] Row 1 │ │ Input: ____ │ │
|
||
|
|
│ │ [✓] Row 2 │ │ Select: [ ] │ │
|
||
|
|
│ │ [ ] Row 3 │ │ │ │
|
||
|
|
│ │ │ │ ┌────────────┐ │ │
|
||
|
|
│ └──────────────────┘ │ │ Table │ │ │
|
||
|
|
│ │ │ │ (Append) │ │ │
|
||
|
|
│ │ │ └────────────┘ │ │
|
||
|
|
│ ▼ │ │ │
|
||
|
|
│ [선택 품목 추가] ──────────▶│ Row 1 (Added) │ │
|
||
|
|
│ │ Row 2 (Added) │ │
|
||
|
|
│ └──────────────────┘ │
|
||
|
|
└─────────────────────────────────────────────────────────┘
|
||
|
|
```
|
||
|
|
|
||
|
|
### 레이어 구조
|
||
|
|
|
||
|
|
```
|
||
|
|
┌─────────────────────────────────────────┐
|
||
|
|
│ Presentation Layer (UI) │
|
||
|
|
│ - ScreenSplitPanel │
|
||
|
|
│ - EmbeddedScreen │
|
||
|
|
│ - DataMappingConfig │
|
||
|
|
└─────────────────────────────────────────┘
|
||
|
|
│
|
||
|
|
┌─────────────────────────────────────────┐
|
||
|
|
│ Business Logic Layer │
|
||
|
|
│ - DataTransferService │
|
||
|
|
│ - MappingEngine │
|
||
|
|
│ - TransformFunctions │
|
||
|
|
└─────────────────────────────────────────┘
|
||
|
|
│
|
||
|
|
┌─────────────────────────────────────────┐
|
||
|
|
│ Data Access Layer │
|
||
|
|
│ - screen_embedding (테이블) │
|
||
|
|
│ - screen_data_transfer (테이블) │
|
||
|
|
│ - component_data_receiver (인터페이스) │
|
||
|
|
└─────────────────────────────────────────┘
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 데이터베이스 설계
|
||
|
|
|
||
|
|
### 1. screen_embedding (화면 임베딩 설정)
|
||
|
|
|
||
|
|
```sql
|
||
|
|
CREATE TABLE screen_embedding (
|
||
|
|
id SERIAL PRIMARY KEY,
|
||
|
|
|
||
|
|
-- 부모 화면 (컨테이너)
|
||
|
|
parent_screen_id INTEGER NOT NULL REFERENCES screen_info(screen_id),
|
||
|
|
|
||
|
|
-- 자식 화면 (임베드될 화면)
|
||
|
|
child_screen_id INTEGER NOT NULL REFERENCES screen_info(screen_id),
|
||
|
|
|
||
|
|
-- 임베딩 위치
|
||
|
|
position VARCHAR(20) NOT NULL, -- 'left', 'right', 'top', 'bottom', 'center'
|
||
|
|
|
||
|
|
-- 임베딩 모드
|
||
|
|
mode VARCHAR(20) NOT NULL, -- 'view', 'select', 'form', 'edit'
|
||
|
|
|
||
|
|
-- 추가 설정
|
||
|
|
config JSONB,
|
||
|
|
-- {
|
||
|
|
-- "width": "50%",
|
||
|
|
-- "height": "100%",
|
||
|
|
-- "resizable": true,
|
||
|
|
-- "multiSelect": true,
|
||
|
|
-- "showToolbar": true
|
||
|
|
-- }
|
||
|
|
|
||
|
|
-- 멀티테넌시
|
||
|
|
company_code VARCHAR(20) NOT NULL,
|
||
|
|
|
||
|
|
-- 메타데이터
|
||
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
|
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||
|
|
created_by VARCHAR(50),
|
||
|
|
|
||
|
|
CONSTRAINT fk_parent_screen FOREIGN KEY (parent_screen_id)
|
||
|
|
REFERENCES screen_info(screen_id) ON DELETE CASCADE,
|
||
|
|
CONSTRAINT fk_child_screen FOREIGN KEY (child_screen_id)
|
||
|
|
REFERENCES screen_info(screen_id) ON DELETE CASCADE
|
||
|
|
);
|
||
|
|
|
||
|
|
-- 인덱스
|
||
|
|
CREATE INDEX idx_screen_embedding_parent ON screen_embedding(parent_screen_id, company_code);
|
||
|
|
CREATE INDEX idx_screen_embedding_child ON screen_embedding(child_screen_id, company_code);
|
||
|
|
```
|
||
|
|
|
||
|
|
### 2. screen_data_transfer (데이터 전달 설정)
|
||
|
|
|
||
|
|
```sql
|
||
|
|
CREATE TABLE screen_data_transfer (
|
||
|
|
id SERIAL PRIMARY KEY,
|
||
|
|
|
||
|
|
-- 소스 화면 (데이터 제공)
|
||
|
|
source_screen_id INTEGER NOT NULL REFERENCES screen_info(screen_id),
|
||
|
|
|
||
|
|
-- 타겟 화면 (데이터 수신)
|
||
|
|
target_screen_id INTEGER NOT NULL REFERENCES screen_info(screen_id),
|
||
|
|
|
||
|
|
-- 소스 컴포넌트 (선택 영역)
|
||
|
|
source_component_id VARCHAR(100),
|
||
|
|
source_component_type VARCHAR(50), -- 'table', 'list', 'grid'
|
||
|
|
|
||
|
|
-- 데이터 수신자 설정 (JSONB 배열)
|
||
|
|
data_receivers JSONB NOT NULL,
|
||
|
|
-- [
|
||
|
|
-- {
|
||
|
|
-- "targetComponentId": "table-입고처리품목",
|
||
|
|
-- "targetComponentType": "table",
|
||
|
|
-- "mode": "append",
|
||
|
|
-- "mappingRules": [
|
||
|
|
-- {
|
||
|
|
-- "sourceField": "품목코드",
|
||
|
|
-- "targetField": "품목코드",
|
||
|
|
-- "transform": null
|
||
|
|
-- }
|
||
|
|
-- ],
|
||
|
|
-- "condition": {
|
||
|
|
-- "field": "상태",
|
||
|
|
-- "operator": "equals",
|
||
|
|
-- "value": "승인"
|
||
|
|
-- }
|
||
|
|
-- }
|
||
|
|
-- ]
|
||
|
|
|
||
|
|
-- 전달 버튼 설정
|
||
|
|
button_config JSONB,
|
||
|
|
-- {
|
||
|
|
-- "label": "선택 품목 추가",
|
||
|
|
-- "position": "center",
|
||
|
|
-- "icon": "ArrowRight",
|
||
|
|
-- "validation": {
|
||
|
|
-- "requireSelection": true,
|
||
|
|
-- "minSelection": 1,
|
||
|
|
-- "maxSelection": 100,
|
||
|
|
-- "customValidation": "function(rows) { return rows.length > 0; }"
|
||
|
|
-- }
|
||
|
|
-- }
|
||
|
|
|
||
|
|
-- 멀티테넌시
|
||
|
|
company_code VARCHAR(20) NOT NULL,
|
||
|
|
|
||
|
|
-- 메타데이터
|
||
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
|
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||
|
|
created_by VARCHAR(50),
|
||
|
|
|
||
|
|
CONSTRAINT fk_source_screen FOREIGN KEY (source_screen_id)
|
||
|
|
REFERENCES screen_info(screen_id) ON DELETE CASCADE,
|
||
|
|
CONSTRAINT fk_target_screen FOREIGN KEY (target_screen_id)
|
||
|
|
REFERENCES screen_info(screen_id) ON DELETE CASCADE
|
||
|
|
);
|
||
|
|
|
||
|
|
-- 인덱스
|
||
|
|
CREATE INDEX idx_screen_data_transfer_source ON screen_data_transfer(source_screen_id, company_code);
|
||
|
|
CREATE INDEX idx_screen_data_transfer_target ON screen_data_transfer(target_screen_id, company_code);
|
||
|
|
```
|
||
|
|
|
||
|
|
### 3. screen_split_panel (분할 패널 설정)
|
||
|
|
|
||
|
|
```sql
|
||
|
|
CREATE TABLE screen_split_panel (
|
||
|
|
id SERIAL PRIMARY KEY,
|
||
|
|
|
||
|
|
-- 부모 화면 (분할 패널 컨테이너)
|
||
|
|
screen_id INTEGER NOT NULL REFERENCES screen_info(screen_id),
|
||
|
|
|
||
|
|
-- 좌측 화면 임베딩
|
||
|
|
left_embedding_id INTEGER REFERENCES screen_embedding(id),
|
||
|
|
|
||
|
|
-- 우측 화면 임베딩
|
||
|
|
right_embedding_id INTEGER REFERENCES screen_embedding(id),
|
||
|
|
|
||
|
|
-- 데이터 전달 설정
|
||
|
|
data_transfer_id INTEGER REFERENCES screen_data_transfer(id),
|
||
|
|
|
||
|
|
-- 레이아웃 설정
|
||
|
|
layout_config JSONB,
|
||
|
|
-- {
|
||
|
|
-- "splitRatio": 50, // 좌:우 비율 (0-100)
|
||
|
|
-- "resizable": true,
|
||
|
|
-- "minLeftWidth": 300,
|
||
|
|
-- "minRightWidth": 400,
|
||
|
|
-- "orientation": "horizontal" // 'horizontal' | 'vertical'
|
||
|
|
-- }
|
||
|
|
|
||
|
|
-- 멀티테넌시
|
||
|
|
company_code VARCHAR(20) NOT NULL,
|
||
|
|
|
||
|
|
-- 메타데이터
|
||
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
|
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||
|
|
|
||
|
|
CONSTRAINT fk_screen FOREIGN KEY (screen_id)
|
||
|
|
REFERENCES screen_info(screen_id) ON DELETE CASCADE,
|
||
|
|
CONSTRAINT fk_left_embedding FOREIGN KEY (left_embedding_id)
|
||
|
|
REFERENCES screen_embedding(id) ON DELETE SET NULL,
|
||
|
|
CONSTRAINT fk_right_embedding FOREIGN KEY (right_embedding_id)
|
||
|
|
REFERENCES screen_embedding(id) ON DELETE SET NULL,
|
||
|
|
CONSTRAINT fk_data_transfer FOREIGN KEY (data_transfer_id)
|
||
|
|
REFERENCES screen_data_transfer(id) ON DELETE SET NULL
|
||
|
|
);
|
||
|
|
|
||
|
|
-- 인덱스
|
||
|
|
CREATE INDEX idx_screen_split_panel_screen ON screen_split_panel(screen_id, company_code);
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 타입 정의
|
||
|
|
|
||
|
|
### 1. 화면 임베딩 타입
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 임베딩 모드
|
||
|
|
type EmbeddingMode =
|
||
|
|
| "view" // 읽기 전용
|
||
|
|
| "select" // 선택 모드 (체크박스)
|
||
|
|
| "form" // 폼 입력 모드
|
||
|
|
| "edit"; // 편집 모드
|
||
|
|
|
||
|
|
// 임베딩 위치
|
||
|
|
type EmbeddingPosition =
|
||
|
|
| "left"
|
||
|
|
| "right"
|
||
|
|
| "top"
|
||
|
|
| "bottom"
|
||
|
|
| "center";
|
||
|
|
|
||
|
|
// 화면 임베딩 설정
|
||
|
|
interface ScreenEmbedding {
|
||
|
|
id: number;
|
||
|
|
parentScreenId: number;
|
||
|
|
childScreenId: number;
|
||
|
|
position: EmbeddingPosition;
|
||
|
|
mode: EmbeddingMode;
|
||
|
|
config: {
|
||
|
|
width?: string; // "50%", "400px"
|
||
|
|
height?: string; // "100%", "600px"
|
||
|
|
resizable?: boolean;
|
||
|
|
multiSelect?: boolean;
|
||
|
|
showToolbar?: boolean;
|
||
|
|
showSearch?: boolean;
|
||
|
|
showPagination?: boolean;
|
||
|
|
};
|
||
|
|
companyCode: string;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 2. 데이터 전달 타입
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 컴포넌트 타입
|
||
|
|
type ComponentType =
|
||
|
|
| "table" // 테이블
|
||
|
|
| "input" // 입력 필드
|
||
|
|
| "select" // 셀렉트 박스
|
||
|
|
| "textarea" // 텍스트 영역
|
||
|
|
| "checkbox" // 체크박스
|
||
|
|
| "radio" // 라디오 버튼
|
||
|
|
| "date" // 날짜 선택
|
||
|
|
| "repeater" // 리피터 (반복 그룹)
|
||
|
|
| "form-group" // 폼 그룹
|
||
|
|
| "hidden"; // 히든 필드
|
||
|
|
|
||
|
|
// 데이터 수신 모드
|
||
|
|
type DataReceiveMode =
|
||
|
|
| "append" // 기존 데이터에 추가
|
||
|
|
| "replace" // 기존 데이터 덮어쓰기
|
||
|
|
| "merge"; // 기존 데이터와 병합 (키 기준)
|
||
|
|
|
||
|
|
// 변환 함수
|
||
|
|
type TransformFunction =
|
||
|
|
| "none" // 변환 없음
|
||
|
|
| "sum" // 합계
|
||
|
|
| "average" // 평균
|
||
|
|
| "count" // 개수
|
||
|
|
| "min" // 최소값
|
||
|
|
| "max" // 최대값
|
||
|
|
| "first" // 첫 번째 값
|
||
|
|
| "last" // 마지막 값
|
||
|
|
| "concat" // 문자열 결합
|
||
|
|
| "join" // 배열 결합
|
||
|
|
| "custom"; // 커스텀 함수
|
||
|
|
|
||
|
|
// 조건 연산자
|
||
|
|
type ConditionOperator =
|
||
|
|
| "equals"
|
||
|
|
| "notEquals"
|
||
|
|
| "contains"
|
||
|
|
| "notContains"
|
||
|
|
| "greaterThan"
|
||
|
|
| "lessThan"
|
||
|
|
| "greaterThanOrEqual"
|
||
|
|
| "lessThanOrEqual"
|
||
|
|
| "in"
|
||
|
|
| "notIn";
|
||
|
|
|
||
|
|
// 매핑 규칙
|
||
|
|
interface MappingRule {
|
||
|
|
sourceField: string; // 소스 필드명
|
||
|
|
targetField: string; // 타겟 필드명
|
||
|
|
transform?: TransformFunction; // 변환 함수
|
||
|
|
transformConfig?: any; // 변환 함수 설정
|
||
|
|
defaultValue?: any; // 기본값
|
||
|
|
required?: boolean; // 필수 여부
|
||
|
|
}
|
||
|
|
|
||
|
|
// 조건
|
||
|
|
interface Condition {
|
||
|
|
field: string;
|
||
|
|
operator: ConditionOperator;
|
||
|
|
value: any;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 데이터 수신자
|
||
|
|
interface DataReceiver {
|
||
|
|
targetComponentId: string; // 타겟 컴포넌트 ID
|
||
|
|
targetComponentType: ComponentType;
|
||
|
|
mode: DataReceiveMode;
|
||
|
|
mappingRules: MappingRule[];
|
||
|
|
condition?: Condition; // 조건부 전달
|
||
|
|
validation?: {
|
||
|
|
required?: boolean;
|
||
|
|
minRows?: number;
|
||
|
|
maxRows?: number;
|
||
|
|
customValidation?: string; // JavaScript 함수 문자열
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// 버튼 설정
|
||
|
|
interface TransferButtonConfig {
|
||
|
|
label: string;
|
||
|
|
position: "left" | "right" | "center";
|
||
|
|
icon?: string;
|
||
|
|
variant?: "default" | "outline" | "ghost";
|
||
|
|
size?: "sm" | "default" | "lg";
|
||
|
|
validation?: {
|
||
|
|
requireSelection: boolean;
|
||
|
|
minSelection?: number;
|
||
|
|
maxSelection?: number;
|
||
|
|
confirmMessage?: string;
|
||
|
|
customValidation?: string;
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// 데이터 전달 설정
|
||
|
|
interface ScreenDataTransfer {
|
||
|
|
id: number;
|
||
|
|
sourceScreenId: number;
|
||
|
|
targetScreenId: number;
|
||
|
|
sourceComponentId?: string;
|
||
|
|
sourceComponentType?: string;
|
||
|
|
dataReceivers: DataReceiver[];
|
||
|
|
buttonConfig: TransferButtonConfig;
|
||
|
|
companyCode: string;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 3. 분할 패널 타입
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 레이아웃 설정
|
||
|
|
interface LayoutConfig {
|
||
|
|
splitRatio: number; // 0-100 (좌측 비율)
|
||
|
|
resizable: boolean;
|
||
|
|
minLeftWidth?: number; // 최소 좌측 너비 (px)
|
||
|
|
minRightWidth?: number; // 최소 우측 너비 (px)
|
||
|
|
orientation: "horizontal" | "vertical";
|
||
|
|
}
|
||
|
|
|
||
|
|
// 분할 패널 설정
|
||
|
|
interface ScreenSplitPanel {
|
||
|
|
id: number;
|
||
|
|
screenId: number;
|
||
|
|
leftEmbedding: ScreenEmbedding;
|
||
|
|
rightEmbedding: ScreenEmbedding;
|
||
|
|
dataTransfer: ScreenDataTransfer;
|
||
|
|
layoutConfig: LayoutConfig;
|
||
|
|
companyCode: string;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 4. 컴포넌트 인터페이스
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 모든 데이터 수신 가능 컴포넌트가 구현해야 하는 인터페이스
|
||
|
|
interface DataReceivable {
|
||
|
|
// 컴포넌트 ID
|
||
|
|
componentId: string;
|
||
|
|
|
||
|
|
// 컴포넌트 타입
|
||
|
|
componentType: ComponentType;
|
||
|
|
|
||
|
|
// 데이터 수신
|
||
|
|
receiveData(data: any[], mode: DataReceiveMode): Promise<void>;
|
||
|
|
|
||
|
|
// 현재 데이터 가져오기
|
||
|
|
getData(): any;
|
||
|
|
|
||
|
|
// 데이터 초기화
|
||
|
|
clearData(): void;
|
||
|
|
|
||
|
|
// 검증
|
||
|
|
validate(): boolean;
|
||
|
|
|
||
|
|
// 이벤트 리스너
|
||
|
|
onDataReceived?: (data: any[]) => void;
|
||
|
|
onDataCleared?: () => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 선택 가능 컴포넌트 인터페이스
|
||
|
|
interface Selectable {
|
||
|
|
// 선택된 행/항목 가져오기
|
||
|
|
getSelectedRows(): any[];
|
||
|
|
|
||
|
|
// 선택 초기화
|
||
|
|
clearSelection(): void;
|
||
|
|
|
||
|
|
// 전체 선택
|
||
|
|
selectAll(): void;
|
||
|
|
|
||
|
|
// 선택 이벤트
|
||
|
|
onSelectionChanged?: (selectedRows: any[]) => void;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 컴포넌트 구조
|
||
|
|
|
||
|
|
### 1. ScreenSplitPanel (최상위 컨테이너)
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
interface ScreenSplitPanelProps {
|
||
|
|
config: ScreenSplitPanel;
|
||
|
|
onDataTransferred?: (data: any[]) => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function ScreenSplitPanel({ config, onDataTransferred }: ScreenSplitPanelProps) {
|
||
|
|
const leftScreenRef = useRef<EmbeddedScreenHandle>(null);
|
||
|
|
const rightScreenRef = useRef<EmbeddedScreenHandle>(null);
|
||
|
|
const [splitRatio, setSplitRatio] = useState(config.layoutConfig.splitRatio);
|
||
|
|
|
||
|
|
// 데이터 전달 핸들러
|
||
|
|
const handleTransferData = async () => {
|
||
|
|
// 1. 좌측 화면에서 선택된 데이터 가져오기
|
||
|
|
const selectedRows = leftScreenRef.current?.getSelectedRows() || [];
|
||
|
|
|
||
|
|
if (selectedRows.length === 0) {
|
||
|
|
toast.error("선택된 항목이 없습니다.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 2. 검증
|
||
|
|
if (config.dataTransfer.buttonConfig.validation) {
|
||
|
|
const validation = config.dataTransfer.buttonConfig.validation;
|
||
|
|
|
||
|
|
if (validation.minSelection && selectedRows.length < validation.minSelection) {
|
||
|
|
toast.error(`최소 ${validation.minSelection}개 이상 선택해야 합니다.`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (validation.maxSelection && selectedRows.length > validation.maxSelection) {
|
||
|
|
toast.error(`최대 ${validation.maxSelection}개까지만 선택할 수 있습니다.`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (validation.confirmMessage) {
|
||
|
|
const confirmed = await confirm(validation.confirmMessage);
|
||
|
|
if (!confirmed) return;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 3. 데이터 전달
|
||
|
|
try {
|
||
|
|
await rightScreenRef.current?.receiveData(
|
||
|
|
selectedRows,
|
||
|
|
config.dataTransfer.dataReceivers
|
||
|
|
);
|
||
|
|
|
||
|
|
toast.success("데이터가 전달되었습니다.");
|
||
|
|
onDataTransferred?.(selectedRows);
|
||
|
|
|
||
|
|
// 4. 좌측 선택 초기화 (옵션)
|
||
|
|
if (config.dataTransfer.buttonConfig.clearAfterTransfer) {
|
||
|
|
leftScreenRef.current?.clearSelection();
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
toast.error("데이터 전달 중 오류가 발생했습니다.");
|
||
|
|
console.error(error);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="flex h-full">
|
||
|
|
{/* 좌측 패널 */}
|
||
|
|
<div style={{ width: `${splitRatio}%` }}>
|
||
|
|
<EmbeddedScreen
|
||
|
|
ref={leftScreenRef}
|
||
|
|
embedding={config.leftEmbedding}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 리사이저 */}
|
||
|
|
{config.layoutConfig.resizable && (
|
||
|
|
<Resizer
|
||
|
|
onResize={(newRatio) => setSplitRatio(newRatio)}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* 전달 버튼 */}
|
||
|
|
<div className="flex items-center justify-center px-4">
|
||
|
|
<Button
|
||
|
|
onClick={handleTransferData}
|
||
|
|
variant={config.dataTransfer.buttonConfig.variant || "default"}
|
||
|
|
size={config.dataTransfer.buttonConfig.size || "default"}
|
||
|
|
>
|
||
|
|
{config.dataTransfer.buttonConfig.icon && (
|
||
|
|
<Icon name={config.dataTransfer.buttonConfig.icon} className="mr-2" />
|
||
|
|
)}
|
||
|
|
{config.dataTransfer.buttonConfig.label}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 우측 패널 */}
|
||
|
|
<div style={{ width: `${100 - splitRatio}%` }}>
|
||
|
|
<EmbeddedScreen
|
||
|
|
ref={rightScreenRef}
|
||
|
|
embedding={config.rightEmbedding}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 2. EmbeddedScreen (임베드된 화면)
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
interface EmbeddedScreenProps {
|
||
|
|
embedding: ScreenEmbedding;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface EmbeddedScreenHandle {
|
||
|
|
getSelectedRows(): any[];
|
||
|
|
clearSelection(): void;
|
||
|
|
receiveData(data: any[], receivers: DataReceiver[]): Promise<void>;
|
||
|
|
getData(): any;
|
||
|
|
}
|
||
|
|
|
||
|
|
export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenProps>(
|
||
|
|
({ embedding }, ref) => {
|
||
|
|
const [screenData, setScreenData] = useState<any>(null);
|
||
|
|
const [selectedRows, setSelectedRows] = useState<any[]>([]);
|
||
|
|
const componentRefs = useRef<Map<string, DataReceivable>>(new Map());
|
||
|
|
|
||
|
|
// 화면 데이터 로드
|
||
|
|
useEffect(() => {
|
||
|
|
loadScreenData(embedding.childScreenId);
|
||
|
|
}, [embedding.childScreenId]);
|
||
|
|
|
||
|
|
// 외부에서 호출 가능한 메서드
|
||
|
|
useImperativeHandle(ref, () => ({
|
||
|
|
getSelectedRows: () => selectedRows,
|
||
|
|
|
||
|
|
clearSelection: () => {
|
||
|
|
setSelectedRows([]);
|
||
|
|
},
|
||
|
|
|
||
|
|
receiveData: async (data: any[], receivers: DataReceiver[]) => {
|
||
|
|
// 각 데이터 수신자에게 데이터 전달
|
||
|
|
for (const receiver of receivers) {
|
||
|
|
const component = componentRefs.current.get(receiver.targetComponentId);
|
||
|
|
|
||
|
|
if (!component) {
|
||
|
|
console.warn(`컴포넌트를 찾을 수 없습니다: ${receiver.targetComponentId}`);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 조건 확인
|
||
|
|
let filteredData = data;
|
||
|
|
if (receiver.condition) {
|
||
|
|
filteredData = filterData(data, receiver.condition);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 매핑 적용
|
||
|
|
const mappedData = applyMappingRules(filteredData, receiver.mappingRules);
|
||
|
|
|
||
|
|
// 데이터 전달
|
||
|
|
await component.receiveData(mappedData, receiver.mode);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
|
||
|
|
getData: () => {
|
||
|
|
const allData: Record<string, any> = {};
|
||
|
|
componentRefs.current.forEach((component, id) => {
|
||
|
|
allData[id] = component.getData();
|
||
|
|
});
|
||
|
|
return allData;
|
||
|
|
}
|
||
|
|
}));
|
||
|
|
|
||
|
|
// 컴포넌트 등록
|
||
|
|
const registerComponent = (id: string, component: DataReceivable) => {
|
||
|
|
componentRefs.current.set(id, component);
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="h-full overflow-auto">
|
||
|
|
{screenData && (
|
||
|
|
<InteractiveScreenViewer
|
||
|
|
screenData={screenData}
|
||
|
|
mode={embedding.mode}
|
||
|
|
onSelectionChanged={setSelectedRows}
|
||
|
|
onComponentMount={registerComponent}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
);
|
||
|
|
```
|
||
|
|
|
||
|
|
### 3. DataReceivable 구현 예시
|
||
|
|
|
||
|
|
#### TableComponent
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
class TableComponent implements DataReceivable {
|
||
|
|
componentId: string;
|
||
|
|
componentType: ComponentType = "table";
|
||
|
|
private rows: any[] = [];
|
||
|
|
|
||
|
|
async receiveData(data: any[], mode: DataReceiveMode): Promise<void> {
|
||
|
|
switch (mode) {
|
||
|
|
case "append":
|
||
|
|
this.rows = [...this.rows, ...data];
|
||
|
|
break;
|
||
|
|
case "replace":
|
||
|
|
this.rows = data;
|
||
|
|
break;
|
||
|
|
case "merge":
|
||
|
|
// 키 기반 병합 (예: id 필드)
|
||
|
|
const existingIds = new Set(this.rows.map(r => r.id));
|
||
|
|
const newRows = data.filter(r => !existingIds.has(r.id));
|
||
|
|
this.rows = [...this.rows, ...newRows];
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
this.render();
|
||
|
|
this.onDataReceived?.(data);
|
||
|
|
}
|
||
|
|
|
||
|
|
getData(): any {
|
||
|
|
return this.rows;
|
||
|
|
}
|
||
|
|
|
||
|
|
clearData(): void {
|
||
|
|
this.rows = [];
|
||
|
|
this.render();
|
||
|
|
this.onDataCleared?.();
|
||
|
|
}
|
||
|
|
|
||
|
|
validate(): boolean {
|
||
|
|
return this.rows.length > 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
private render() {
|
||
|
|
// 테이블 리렌더링
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### InputComponent
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
class InputComponent implements DataReceivable {
|
||
|
|
componentId: string;
|
||
|
|
componentType: ComponentType = "input";
|
||
|
|
private value: any = "";
|
||
|
|
|
||
|
|
async receiveData(data: any[], mode: DataReceiveMode): Promise<void> {
|
||
|
|
// 입력 필드는 단일 값이므로 첫 번째 항목만 사용
|
||
|
|
if (data.length > 0) {
|
||
|
|
this.value = data[0];
|
||
|
|
this.render();
|
||
|
|
this.onDataReceived?.(data);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
getData(): any {
|
||
|
|
return this.value;
|
||
|
|
}
|
||
|
|
|
||
|
|
clearData(): void {
|
||
|
|
this.value = "";
|
||
|
|
this.render();
|
||
|
|
this.onDataCleared?.();
|
||
|
|
}
|
||
|
|
|
||
|
|
validate(): boolean {
|
||
|
|
return this.value !== null && this.value !== undefined && this.value !== "";
|
||
|
|
}
|
||
|
|
|
||
|
|
private render() {
|
||
|
|
// 입력 필드 리렌더링
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## API 설계
|
||
|
|
|
||
|
|
### 1. 화면 임베딩 API
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// GET /api/screen-embedding/:parentScreenId
|
||
|
|
export async function getScreenEmbeddings(
|
||
|
|
parentScreenId: number,
|
||
|
|
companyCode: string
|
||
|
|
): Promise<ApiResponse<ScreenEmbedding[]>> {
|
||
|
|
const query = `
|
||
|
|
SELECT * FROM screen_embedding
|
||
|
|
WHERE parent_screen_id = $1
|
||
|
|
AND company_code = $2
|
||
|
|
ORDER BY position
|
||
|
|
`;
|
||
|
|
|
||
|
|
const result = await pool.query(query, [parentScreenId, companyCode]);
|
||
|
|
return { success: true, data: result.rows };
|
||
|
|
}
|
||
|
|
|
||
|
|
// POST /api/screen-embedding
|
||
|
|
export async function createScreenEmbedding(
|
||
|
|
embedding: Omit<ScreenEmbedding, "id">,
|
||
|
|
companyCode: string
|
||
|
|
): Promise<ApiResponse<ScreenEmbedding>> {
|
||
|
|
const query = `
|
||
|
|
INSERT INTO screen_embedding (
|
||
|
|
parent_screen_id, child_screen_id, position, mode, config, company_code
|
||
|
|
) VALUES ($1, $2, $3, $4, $5, $6)
|
||
|
|
RETURNING *
|
||
|
|
`;
|
||
|
|
|
||
|
|
const result = await pool.query(query, [
|
||
|
|
embedding.parentScreenId,
|
||
|
|
embedding.childScreenId,
|
||
|
|
embedding.position,
|
||
|
|
embedding.mode,
|
||
|
|
JSON.stringify(embedding.config),
|
||
|
|
companyCode
|
||
|
|
]);
|
||
|
|
|
||
|
|
return { success: true, data: result.rows[0] };
|
||
|
|
}
|
||
|
|
|
||
|
|
// PUT /api/screen-embedding/:id
|
||
|
|
export async function updateScreenEmbedding(
|
||
|
|
id: number,
|
||
|
|
embedding: Partial<ScreenEmbedding>,
|
||
|
|
companyCode: string
|
||
|
|
): Promise<ApiResponse<ScreenEmbedding>> {
|
||
|
|
const updates: string[] = [];
|
||
|
|
const values: any[] = [];
|
||
|
|
let paramIndex = 1;
|
||
|
|
|
||
|
|
if (embedding.position) {
|
||
|
|
updates.push(`position = $${paramIndex++}`);
|
||
|
|
values.push(embedding.position);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (embedding.mode) {
|
||
|
|
updates.push(`mode = $${paramIndex++}`);
|
||
|
|
values.push(embedding.mode);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (embedding.config) {
|
||
|
|
updates.push(`config = $${paramIndex++}`);
|
||
|
|
values.push(JSON.stringify(embedding.config));
|
||
|
|
}
|
||
|
|
|
||
|
|
updates.push(`updated_at = NOW()`);
|
||
|
|
|
||
|
|
values.push(id, companyCode);
|
||
|
|
|
||
|
|
const query = `
|
||
|
|
UPDATE screen_embedding
|
||
|
|
SET ${updates.join(", ")}
|
||
|
|
WHERE id = $${paramIndex++}
|
||
|
|
AND company_code = $${paramIndex++}
|
||
|
|
RETURNING *
|
||
|
|
`;
|
||
|
|
|
||
|
|
const result = await pool.query(query, values);
|
||
|
|
|
||
|
|
if (result.rowCount === 0) {
|
||
|
|
return { success: false, message: "임베딩 설정을 찾을 수 없습니다." };
|
||
|
|
}
|
||
|
|
|
||
|
|
return { success: true, data: result.rows[0] };
|
||
|
|
}
|
||
|
|
|
||
|
|
// DELETE /api/screen-embedding/:id
|
||
|
|
export async function deleteScreenEmbedding(
|
||
|
|
id: number,
|
||
|
|
companyCode: string
|
||
|
|
): Promise<ApiResponse<void>> {
|
||
|
|
const query = `
|
||
|
|
DELETE FROM screen_embedding
|
||
|
|
WHERE id = $1 AND company_code = $2
|
||
|
|
`;
|
||
|
|
|
||
|
|
const result = await pool.query(query, [id, companyCode]);
|
||
|
|
|
||
|
|
if (result.rowCount === 0) {
|
||
|
|
return { success: false, message: "임베딩 설정을 찾을 수 없습니다." };
|
||
|
|
}
|
||
|
|
|
||
|
|
return { success: true };
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 2. 데이터 전달 API
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// GET /api/screen-data-transfer?sourceScreenId=1&targetScreenId=2
|
||
|
|
export async function getScreenDataTransfer(
|
||
|
|
sourceScreenId: number,
|
||
|
|
targetScreenId: number,
|
||
|
|
companyCode: string
|
||
|
|
): Promise<ApiResponse<ScreenDataTransfer>> {
|
||
|
|
const query = `
|
||
|
|
SELECT * FROM screen_data_transfer
|
||
|
|
WHERE source_screen_id = $1
|
||
|
|
AND target_screen_id = $2
|
||
|
|
AND company_code = $3
|
||
|
|
`;
|
||
|
|
|
||
|
|
const result = await pool.query(query, [sourceScreenId, targetScreenId, companyCode]);
|
||
|
|
|
||
|
|
if (result.rowCount === 0) {
|
||
|
|
return { success: false, message: "데이터 전달 설정을 찾을 수 없습니다." };
|
||
|
|
}
|
||
|
|
|
||
|
|
return { success: true, data: result.rows[0] };
|
||
|
|
}
|
||
|
|
|
||
|
|
// POST /api/screen-data-transfer
|
||
|
|
export async function createScreenDataTransfer(
|
||
|
|
transfer: Omit<ScreenDataTransfer, "id">,
|
||
|
|
companyCode: string
|
||
|
|
): Promise<ApiResponse<ScreenDataTransfer>> {
|
||
|
|
const query = `
|
||
|
|
INSERT INTO screen_data_transfer (
|
||
|
|
source_screen_id, target_screen_id, source_component_id, source_component_type,
|
||
|
|
data_receivers, button_config, company_code
|
||
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||
|
|
RETURNING *
|
||
|
|
`;
|
||
|
|
|
||
|
|
const result = await pool.query(query, [
|
||
|
|
transfer.sourceScreenId,
|
||
|
|
transfer.targetScreenId,
|
||
|
|
transfer.sourceComponentId,
|
||
|
|
transfer.sourceComponentType,
|
||
|
|
JSON.stringify(transfer.dataReceivers),
|
||
|
|
JSON.stringify(transfer.buttonConfig),
|
||
|
|
companyCode
|
||
|
|
]);
|
||
|
|
|
||
|
|
return { success: true, data: result.rows[0] };
|
||
|
|
}
|
||
|
|
|
||
|
|
// PUT /api/screen-data-transfer/:id
|
||
|
|
export async function updateScreenDataTransfer(
|
||
|
|
id: number,
|
||
|
|
transfer: Partial<ScreenDataTransfer>,
|
||
|
|
companyCode: string
|
||
|
|
): Promise<ApiResponse<ScreenDataTransfer>> {
|
||
|
|
const updates: string[] = [];
|
||
|
|
const values: any[] = [];
|
||
|
|
let paramIndex = 1;
|
||
|
|
|
||
|
|
if (transfer.dataReceivers) {
|
||
|
|
updates.push(`data_receivers = $${paramIndex++}`);
|
||
|
|
values.push(JSON.stringify(transfer.dataReceivers));
|
||
|
|
}
|
||
|
|
|
||
|
|
if (transfer.buttonConfig) {
|
||
|
|
updates.push(`button_config = $${paramIndex++}`);
|
||
|
|
values.push(JSON.stringify(transfer.buttonConfig));
|
||
|
|
}
|
||
|
|
|
||
|
|
updates.push(`updated_at = NOW()`);
|
||
|
|
|
||
|
|
values.push(id, companyCode);
|
||
|
|
|
||
|
|
const query = `
|
||
|
|
UPDATE screen_data_transfer
|
||
|
|
SET ${updates.join(", ")}
|
||
|
|
WHERE id = $${paramIndex++}
|
||
|
|
AND company_code = $${paramIndex++}
|
||
|
|
RETURNING *
|
||
|
|
`;
|
||
|
|
|
||
|
|
const result = await pool.query(query, values);
|
||
|
|
|
||
|
|
if (result.rowCount === 0) {
|
||
|
|
return { success: false, message: "데이터 전달 설정을 찾을 수 없습니다." };
|
||
|
|
}
|
||
|
|
|
||
|
|
return { success: true, data: result.rows[0] };
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 3. 분할 패널 API
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// GET /api/screen-split-panel/:screenId
|
||
|
|
export async function getScreenSplitPanel(
|
||
|
|
screenId: number,
|
||
|
|
companyCode: string
|
||
|
|
): Promise<ApiResponse<ScreenSplitPanel>> {
|
||
|
|
const query = `
|
||
|
|
SELECT
|
||
|
|
ssp.*,
|
||
|
|
le.* as left_embedding,
|
||
|
|
re.* as right_embedding,
|
||
|
|
sdt.* as data_transfer
|
||
|
|
FROM screen_split_panel ssp
|
||
|
|
LEFT JOIN screen_embedding le ON ssp.left_embedding_id = le.id
|
||
|
|
LEFT JOIN screen_embedding re ON ssp.right_embedding_id = re.id
|
||
|
|
LEFT JOIN screen_data_transfer sdt ON ssp.data_transfer_id = sdt.id
|
||
|
|
WHERE ssp.screen_id = $1
|
||
|
|
AND ssp.company_code = $2
|
||
|
|
`;
|
||
|
|
|
||
|
|
const result = await pool.query(query, [screenId, companyCode]);
|
||
|
|
|
||
|
|
if (result.rowCount === 0) {
|
||
|
|
return { success: false, message: "분할 패널 설정을 찾을 수 없습니다." };
|
||
|
|
}
|
||
|
|
|
||
|
|
return { success: true, data: result.rows[0] };
|
||
|
|
}
|
||
|
|
|
||
|
|
// POST /api/screen-split-panel
|
||
|
|
export async function createScreenSplitPanel(
|
||
|
|
panel: Omit<ScreenSplitPanel, "id">,
|
||
|
|
companyCode: string
|
||
|
|
): Promise<ApiResponse<ScreenSplitPanel>> {
|
||
|
|
const client = await pool.connect();
|
||
|
|
|
||
|
|
try {
|
||
|
|
await client.query("BEGIN");
|
||
|
|
|
||
|
|
// 1. 좌측 임베딩 생성
|
||
|
|
const leftEmbedding = await createScreenEmbedding(panel.leftEmbedding, companyCode);
|
||
|
|
|
||
|
|
// 2. 우측 임베딩 생성
|
||
|
|
const rightEmbedding = await createScreenEmbedding(panel.rightEmbedding, companyCode);
|
||
|
|
|
||
|
|
// 3. 데이터 전달 설정 생성
|
||
|
|
const dataTransfer = await createScreenDataTransfer(panel.dataTransfer, companyCode);
|
||
|
|
|
||
|
|
// 4. 분할 패널 생성
|
||
|
|
const query = `
|
||
|
|
INSERT INTO screen_split_panel (
|
||
|
|
screen_id, left_embedding_id, right_embedding_id, data_transfer_id,
|
||
|
|
layout_config, company_code
|
||
|
|
) VALUES ($1, $2, $3, $4, $5, $6)
|
||
|
|
RETURNING *
|
||
|
|
`;
|
||
|
|
|
||
|
|
const result = await client.query(query, [
|
||
|
|
panel.screenId,
|
||
|
|
leftEmbedding.data!.id,
|
||
|
|
rightEmbedding.data!.id,
|
||
|
|
dataTransfer.data!.id,
|
||
|
|
JSON.stringify(panel.layoutConfig),
|
||
|
|
companyCode
|
||
|
|
]);
|
||
|
|
|
||
|
|
await client.query("COMMIT");
|
||
|
|
|
||
|
|
return { success: true, data: result.rows[0] };
|
||
|
|
} catch (error) {
|
||
|
|
await client.query("ROLLBACK");
|
||
|
|
throw error;
|
||
|
|
} finally {
|
||
|
|
client.release();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 구현 단계
|
||
|
|
|
||
|
|
### Phase 1: 기본 인프라 구축 (1-2주)
|
||
|
|
|
||
|
|
#### 1.1 데이터베이스 마이그레이션
|
||
|
|
- [ ] `screen_embedding` 테이블 생성
|
||
|
|
- [ ] `screen_data_transfer` 테이블 생성
|
||
|
|
- [ ] `screen_split_panel` 테이블 생성
|
||
|
|
- [ ] 인덱스 및 외래키 설정
|
||
|
|
- [ ] 샘플 데이터 삽입
|
||
|
|
|
||
|
|
#### 1.2 타입 정의
|
||
|
|
- [ ] TypeScript 인터페이스 작성
|
||
|
|
- [ ] `types/screen-embedding.ts`
|
||
|
|
- [ ] `types/data-transfer.ts`
|
||
|
|
- [ ] `types/split-panel.ts`
|
||
|
|
|
||
|
|
#### 1.3 백엔드 API
|
||
|
|
- [ ] 화면 임베딩 CRUD API
|
||
|
|
- [ ] 데이터 전달 설정 CRUD API
|
||
|
|
- [ ] 분할 패널 CRUD API
|
||
|
|
- [ ] 컨트롤러 및 서비스 레이어 구현
|
||
|
|
|
||
|
|
### Phase 2: 화면 임베딩 기능 (2-3주)
|
||
|
|
|
||
|
|
#### 2.1 EmbeddedScreen 컴포넌트
|
||
|
|
- [ ] 기본 임베딩 기능
|
||
|
|
- [ ] 모드별 렌더링 (view, select, form, edit)
|
||
|
|
- [ ] 선택 모드 구현 (체크박스)
|
||
|
|
- [ ] 이벤트 핸들링
|
||
|
|
|
||
|
|
#### 2.2 DataReceivable 인터페이스 구현
|
||
|
|
- [ ] TableComponent
|
||
|
|
- [ ] InputComponent
|
||
|
|
- [ ] SelectComponent
|
||
|
|
- [ ] TextareaComponent
|
||
|
|
- [ ] RepeaterComponent
|
||
|
|
- [ ] FormGroupComponent
|
||
|
|
- [ ] HiddenComponent
|
||
|
|
|
||
|
|
#### 2.3 컴포넌트 등록 시스템
|
||
|
|
- [ ] 컴포넌트 마운트 시 자동 등록
|
||
|
|
- [ ] 컴포넌트 ID 관리
|
||
|
|
- [ ] 컴포넌트 참조 관리
|
||
|
|
|
||
|
|
### Phase 3: 데이터 전달 시스템 (2-3주)
|
||
|
|
|
||
|
|
#### 3.1 매핑 엔진
|
||
|
|
- [ ] 매핑 규칙 파싱
|
||
|
|
- [ ] 필드 매핑 적용
|
||
|
|
- [ ] 변환 함수 구현
|
||
|
|
- [ ] sum, average, count
|
||
|
|
- [ ] min, max
|
||
|
|
- [ ] first, last
|
||
|
|
- [ ] concat, join
|
||
|
|
|
||
|
|
#### 3.2 조건부 전달
|
||
|
|
- [ ] 조건 파싱
|
||
|
|
- [ ] 필터링 로직
|
||
|
|
- [ ] 복합 조건 지원
|
||
|
|
|
||
|
|
#### 3.3 검증 시스템
|
||
|
|
- [ ] 필수 필드 검증
|
||
|
|
- [ ] 최소/최대 행 수 검증
|
||
|
|
- [ ] 커스텀 검증 함수 실행
|
||
|
|
|
||
|
|
### Phase 4: 분할 패널 UI (2-3주)
|
||
|
|
|
||
|
|
#### 4.1 ScreenSplitPanel 컴포넌트
|
||
|
|
- [ ] 기본 레이아웃
|
||
|
|
- [ ] 리사이저 구현
|
||
|
|
- [ ] 전달 버튼
|
||
|
|
- [ ] 반응형 디자인
|
||
|
|
|
||
|
|
#### 4.2 설정 UI
|
||
|
|
- [ ] 화면 선택 드롭다운
|
||
|
|
- [ ] 매핑 규칙 설정 UI
|
||
|
|
- [ ] 드래그앤드롭 매핑
|
||
|
|
- [ ] 미리보기 기능
|
||
|
|
|
||
|
|
#### 4.3 시각적 피드백
|
||
|
|
- [ ] 데이터 전달 애니메이션
|
||
|
|
- [ ] 로딩 상태 표시
|
||
|
|
- [ ] 성공/실패 토스트
|
||
|
|
|
||
|
|
### Phase 5: 고급 기능 (2-3주)
|
||
|
|
|
||
|
|
#### 5.1 양방향 동기화
|
||
|
|
- [ ] 우측 → 좌측 데이터 반영
|
||
|
|
- [ ] 실시간 업데이트
|
||
|
|
|
||
|
|
#### 5.2 트랜잭션 지원
|
||
|
|
- [ ] 전체 성공 또는 전체 실패
|
||
|
|
- [ ] 롤백 기능
|
||
|
|
|
||
|
|
#### 5.3 성능 최적화
|
||
|
|
- [ ] 대량 데이터 처리
|
||
|
|
- [ ] 가상 스크롤링
|
||
|
|
- [ ] 메모이제이션
|
||
|
|
|
||
|
|
### Phase 6: 테스트 및 문서화 (1-2주)
|
||
|
|
|
||
|
|
#### 6.1 단위 테스트
|
||
|
|
- [ ] 매핑 엔진 테스트
|
||
|
|
- [ ] 변환 함수 테스트
|
||
|
|
- [ ] 검증 로직 테스트
|
||
|
|
|
||
|
|
#### 6.2 통합 테스트
|
||
|
|
- [ ] 전체 워크플로우 테스트
|
||
|
|
- [ ] 실제 시나리오 테스트
|
||
|
|
|
||
|
|
#### 6.3 문서화
|
||
|
|
- [ ] 사용자 가이드
|
||
|
|
- [ ] 개발자 문서
|
||
|
|
- [ ] API 문서
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 사용 시나리오
|
||
|
|
|
||
|
|
### 시나리오 1: 입고 등록
|
||
|
|
|
||
|
|
#### 요구사항
|
||
|
|
- 발주 목록에서 품목을 선택하여 입고 등록
|
||
|
|
- 선택된 품목의 정보를 입고 처리 품목 테이블에 추가
|
||
|
|
- 공급자 정보를 자동으로 입력 필드에 설정
|
||
|
|
- 총 품목 수를 자동 계산
|
||
|
|
|
||
|
|
#### 설정
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
const 입고등록_설정: ScreenSplitPanel = {
|
||
|
|
screenId: 100,
|
||
|
|
leftEmbedding: {
|
||
|
|
childScreenId: 10, // 발주 목록 조회 화면
|
||
|
|
position: "left",
|
||
|
|
mode: "select",
|
||
|
|
config: {
|
||
|
|
width: "50%",
|
||
|
|
multiSelect: true,
|
||
|
|
showSearch: true,
|
||
|
|
showPagination: true
|
||
|
|
}
|
||
|
|
},
|
||
|
|
rightEmbedding: {
|
||
|
|
childScreenId: 20, // 입고 등록 폼 화면
|
||
|
|
position: "right",
|
||
|
|
mode: "form",
|
||
|
|
config: {
|
||
|
|
width: "50%"
|
||
|
|
}
|
||
|
|
},
|
||
|
|
dataTransfer: {
|
||
|
|
sourceScreenId: 10,
|
||
|
|
targetScreenId: 20,
|
||
|
|
sourceComponentId: "table-발주목록",
|
||
|
|
sourceComponentType: "table",
|
||
|
|
dataReceivers: [
|
||
|
|
{
|
||
|
|
targetComponentId: "table-입고처리품목",
|
||
|
|
targetComponentType: "table",
|
||
|
|
mode: "append",
|
||
|
|
mappingRules: [
|
||
|
|
{ sourceField: "품목코드", targetField: "품목코드" },
|
||
|
|
{ sourceField: "품목명", targetField: "품목명" },
|
||
|
|
{ sourceField: "발주수량", targetField: "발주수량" },
|
||
|
|
{ sourceField: "미입고수량", targetField: "입고수량" }
|
||
|
|
]
|
||
|
|
},
|
||
|
|
{
|
||
|
|
targetComponentId: "input-공급자",
|
||
|
|
targetComponentType: "input",
|
||
|
|
mode: "replace",
|
||
|
|
mappingRules: [
|
||
|
|
{
|
||
|
|
sourceField: "공급자",
|
||
|
|
targetField: "value",
|
||
|
|
transform: "first"
|
||
|
|
}
|
||
|
|
]
|
||
|
|
},
|
||
|
|
{
|
||
|
|
targetComponentId: "input-품목수",
|
||
|
|
targetComponentType: "input",
|
||
|
|
mode: "replace",
|
||
|
|
mappingRules: [
|
||
|
|
{
|
||
|
|
sourceField: "품목코드",
|
||
|
|
targetField: "value",
|
||
|
|
transform: "count"
|
||
|
|
}
|
||
|
|
]
|
||
|
|
}
|
||
|
|
],
|
||
|
|
buttonConfig: {
|
||
|
|
label: "선택 품목 추가",
|
||
|
|
position: "center",
|
||
|
|
icon: "ArrowRight",
|
||
|
|
validation: {
|
||
|
|
requireSelection: true,
|
||
|
|
minSelection: 1
|
||
|
|
}
|
||
|
|
}
|
||
|
|
},
|
||
|
|
layoutConfig: {
|
||
|
|
splitRatio: 50,
|
||
|
|
resizable: true,
|
||
|
|
minLeftWidth: 400,
|
||
|
|
minRightWidth: 600,
|
||
|
|
orientation: "horizontal"
|
||
|
|
}
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
### 시나리오 2: 수주 등록
|
||
|
|
|
||
|
|
#### 요구사항
|
||
|
|
- 견적서 목록에서 품목을 선택하여 수주 등록
|
||
|
|
- 고객 정보를 자동으로 폼에 설정
|
||
|
|
- 품목별 수량 및 금액 자동 계산
|
||
|
|
- 총 금액 합계 표시
|
||
|
|
|
||
|
|
#### 설정
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
const 수주등록_설정: ScreenSplitPanel = {
|
||
|
|
screenId: 101,
|
||
|
|
leftEmbedding: {
|
||
|
|
childScreenId: 30, // 견적서 목록 조회 화면
|
||
|
|
position: "left",
|
||
|
|
mode: "select",
|
||
|
|
config: {
|
||
|
|
width: "40%",
|
||
|
|
multiSelect: true
|
||
|
|
}
|
||
|
|
},
|
||
|
|
rightEmbedding: {
|
||
|
|
childScreenId: 40, // 수주 등록 폼 화면
|
||
|
|
position: "right",
|
||
|
|
mode: "form",
|
||
|
|
config: {
|
||
|
|
width: "60%"
|
||
|
|
}
|
||
|
|
},
|
||
|
|
dataTransfer: {
|
||
|
|
sourceScreenId: 30,
|
||
|
|
targetScreenId: 40,
|
||
|
|
dataReceivers: [
|
||
|
|
{
|
||
|
|
targetComponentId: "table-수주품목",
|
||
|
|
targetComponentType: "table",
|
||
|
|
mode: "append",
|
||
|
|
mappingRules: [
|
||
|
|
{ sourceField: "품목코드", targetField: "품목코드" },
|
||
|
|
{ sourceField: "품목명", targetField: "품목명" },
|
||
|
|
{ sourceField: "수량", targetField: "수량" },
|
||
|
|
{ sourceField: "단가", targetField: "단가" },
|
||
|
|
{
|
||
|
|
sourceField: "수량",
|
||
|
|
targetField: "금액",
|
||
|
|
transform: "custom",
|
||
|
|
transformConfig: {
|
||
|
|
formula: "수량 * 단가"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
]
|
||
|
|
},
|
||
|
|
{
|
||
|
|
targetComponentId: "input-고객명",
|
||
|
|
targetComponentType: "input",
|
||
|
|
mode: "replace",
|
||
|
|
mappingRules: [
|
||
|
|
{ sourceField: "고객명", targetField: "value", transform: "first" }
|
||
|
|
]
|
||
|
|
},
|
||
|
|
{
|
||
|
|
targetComponentId: "input-총금액",
|
||
|
|
targetComponentType: "input",
|
||
|
|
mode: "replace",
|
||
|
|
mappingRules: [
|
||
|
|
{
|
||
|
|
sourceField: "금액",
|
||
|
|
targetField: "value",
|
||
|
|
transform: "sum"
|
||
|
|
}
|
||
|
|
]
|
||
|
|
}
|
||
|
|
],
|
||
|
|
buttonConfig: {
|
||
|
|
label: "견적서 불러오기",
|
||
|
|
position: "center",
|
||
|
|
icon: "Download"
|
||
|
|
}
|
||
|
|
},
|
||
|
|
layoutConfig: {
|
||
|
|
splitRatio: 40,
|
||
|
|
resizable: true,
|
||
|
|
orientation: "horizontal"
|
||
|
|
}
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
### 시나리오 3: 출고 등록
|
||
|
|
|
||
|
|
#### 요구사항
|
||
|
|
- 재고 목록에서 품목을 선택하여 출고 등록
|
||
|
|
- 재고 수량 확인 및 경고
|
||
|
|
- 출고 가능 수량만 필터링
|
||
|
|
- 창고별 재고 정보 표시
|
||
|
|
|
||
|
|
#### 설정
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
const 출고등록_설정: ScreenSplitPanel = {
|
||
|
|
screenId: 102,
|
||
|
|
leftEmbedding: {
|
||
|
|
childScreenId: 50, // 재고 목록 조회 화면
|
||
|
|
position: "left",
|
||
|
|
mode: "select",
|
||
|
|
config: {
|
||
|
|
width: "45%",
|
||
|
|
multiSelect: true
|
||
|
|
}
|
||
|
|
},
|
||
|
|
rightEmbedding: {
|
||
|
|
childScreenId: 60, // 출고 등록 폼 화면
|
||
|
|
position: "right",
|
||
|
|
mode: "form",
|
||
|
|
config: {
|
||
|
|
width: "55%"
|
||
|
|
}
|
||
|
|
},
|
||
|
|
dataTransfer: {
|
||
|
|
sourceScreenId: 50,
|
||
|
|
targetScreenId: 60,
|
||
|
|
dataReceivers: [
|
||
|
|
{
|
||
|
|
targetComponentId: "table-출고품목",
|
||
|
|
targetComponentType: "table",
|
||
|
|
mode: "append",
|
||
|
|
mappingRules: [
|
||
|
|
{ sourceField: "품목코드", targetField: "품목코드" },
|
||
|
|
{ sourceField: "품목명", targetField: "품목명" },
|
||
|
|
{ sourceField: "재고수량", targetField: "가용수량" },
|
||
|
|
{ sourceField: "창고", targetField: "출고창고" }
|
||
|
|
],
|
||
|
|
condition: {
|
||
|
|
field: "재고수량",
|
||
|
|
operator: "greaterThan",
|
||
|
|
value: 0
|
||
|
|
}
|
||
|
|
},
|
||
|
|
{
|
||
|
|
targetComponentId: "input-총출고수량",
|
||
|
|
targetComponentType: "input",
|
||
|
|
mode: "replace",
|
||
|
|
mappingRules: [
|
||
|
|
{
|
||
|
|
sourceField: "재고수량",
|
||
|
|
targetField: "value",
|
||
|
|
transform: "sum"
|
||
|
|
}
|
||
|
|
]
|
||
|
|
}
|
||
|
|
],
|
||
|
|
buttonConfig: {
|
||
|
|
label: "출고 품목 추가",
|
||
|
|
position: "center",
|
||
|
|
icon: "ArrowRight",
|
||
|
|
validation: {
|
||
|
|
requireSelection: true,
|
||
|
|
confirmMessage: "선택한 품목을 출고 처리하시겠습니까?"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
},
|
||
|
|
layoutConfig: {
|
||
|
|
splitRatio: 45,
|
||
|
|
resizable: true,
|
||
|
|
orientation: "horizontal"
|
||
|
|
}
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 기술적 고려사항
|
||
|
|
|
||
|
|
### 1. 성능 최적화
|
||
|
|
|
||
|
|
#### 대량 데이터 처리
|
||
|
|
- 가상 스크롤링 적용
|
||
|
|
- 청크 단위 데이터 전달
|
||
|
|
- 백그라운드 처리
|
||
|
|
|
||
|
|
#### 메모리 관리
|
||
|
|
- 컴포넌트 언마운트 시 참조 해제
|
||
|
|
- 이벤트 리스너 정리
|
||
|
|
- 메모이제이션 활용
|
||
|
|
|
||
|
|
### 2. 보안
|
||
|
|
|
||
|
|
#### 권한 검증
|
||
|
|
- 화면 접근 권한 확인
|
||
|
|
- 데이터 전달 권한 확인
|
||
|
|
- 멀티테넌시 격리
|
||
|
|
|
||
|
|
#### 데이터 검증
|
||
|
|
- 입력값 검증
|
||
|
|
- SQL 인젝션 방지
|
||
|
|
- XSS 방지
|
||
|
|
|
||
|
|
### 3. 에러 처리
|
||
|
|
|
||
|
|
#### 사용자 친화적 메시지
|
||
|
|
- 명확한 오류 메시지
|
||
|
|
- 복구 방법 안내
|
||
|
|
- 로그 기록
|
||
|
|
|
||
|
|
#### 트랜잭션 롤백
|
||
|
|
- 부분 실패 시 전체 롤백
|
||
|
|
- 데이터 일관성 유지
|
||
|
|
|
||
|
|
### 4. 확장성
|
||
|
|
|
||
|
|
#### 플러그인 시스템
|
||
|
|
- 커스텀 변환 함수 등록
|
||
|
|
- 커스텀 검증 함수 등록
|
||
|
|
- 커스텀 컴포넌트 타입 추가
|
||
|
|
|
||
|
|
#### 이벤트 시스템
|
||
|
|
- 데이터 전달 전/후 이벤트
|
||
|
|
- 커스텀 이벤트 핸들러
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 마일스톤
|
||
|
|
|
||
|
|
### M1: 기본 인프라 (2주)
|
||
|
|
- 데이터베이스 스키마 완성
|
||
|
|
- 백엔드 API 완성
|
||
|
|
- 타입 정의 완성
|
||
|
|
|
||
|
|
### M2: 화면 임베딩 (3주)
|
||
|
|
- EmbeddedScreen 컴포넌트 완성
|
||
|
|
- DataReceivable 인터페이스 구현 완료
|
||
|
|
- 선택 모드 동작 확인
|
||
|
|
|
||
|
|
### M3: 데이터 전달 (3주)
|
||
|
|
- 매핑 엔진 완성
|
||
|
|
- 변환 함수 구현 완료
|
||
|
|
- 조건부 전달 동작 확인
|
||
|
|
|
||
|
|
### M4: 분할 패널 UI (3주)
|
||
|
|
- ScreenSplitPanel 컴포넌트 완성
|
||
|
|
- 설정 UI 완성
|
||
|
|
- 입고 등록 시나리오 완성
|
||
|
|
|
||
|
|
### M5: 고급 기능 및 최적화 (3주)
|
||
|
|
- 양방향 동기화 완성
|
||
|
|
- 성능 최적화 완료
|
||
|
|
- 전체 테스트 통과
|
||
|
|
|
||
|
|
### M6: 문서화 및 배포 (1주)
|
||
|
|
- 사용자 가이드 작성
|
||
|
|
- 개발자 문서 작성
|
||
|
|
- 프로덕션 배포
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 예상 일정
|
||
|
|
|
||
|
|
**총 소요 기간**: 약 15주 (3.5개월)
|
||
|
|
|
||
|
|
- Week 1-2: Phase 1 (기본 인프라)
|
||
|
|
- Week 3-5: Phase 2 (화면 임베딩)
|
||
|
|
- Week 6-8: Phase 3 (데이터 전달)
|
||
|
|
- Week 9-11: Phase 4 (분할 패널 UI)
|
||
|
|
- Week 12-14: Phase 5 (고급 기능)
|
||
|
|
- Week 15: Phase 6 (테스트 및 문서화)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 성공 지표
|
||
|
|
|
||
|
|
### 기능적 지표
|
||
|
|
- [ ] 입고 등록 시나리오 완벽 동작
|
||
|
|
- [ ] 수주 등록 시나리오 완벽 동작
|
||
|
|
- [ ] 출고 등록 시나리오 완벽 동작
|
||
|
|
- [ ] 모든 컴포넌트 타입 데이터 수신 가능
|
||
|
|
- [ ] 모든 변환 함수 정상 동작
|
||
|
|
|
||
|
|
### 성능 지표
|
||
|
|
- [ ] 1000개 행 데이터 전달 < 1초
|
||
|
|
- [ ] 화면 로딩 시간 < 2초
|
||
|
|
- [ ] 메모리 사용량 < 100MB
|
||
|
|
|
||
|
|
### 사용성 지표
|
||
|
|
- [ ] 설정 UI 직관적
|
||
|
|
- [ ] 에러 메시지 명확
|
||
|
|
- [ ] 문서 완성도 90% 이상
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 리스크 관리
|
||
|
|
|
||
|
|
### 기술적 리스크
|
||
|
|
- **복잡도 증가**: 단계별 구현으로 관리
|
||
|
|
- **성능 문제**: 초기부터 최적화 고려
|
||
|
|
- **호환성 문제**: 기존 시스템과 충돌 방지
|
||
|
|
|
||
|
|
### 일정 리스크
|
||
|
|
- **예상 기간 초과**: 버퍼 2주 확보
|
||
|
|
- **우선순위 변경**: 핵심 기능 먼저 구현
|
||
|
|
|
||
|
|
### 인력 리스크
|
||
|
|
- **담당자 부재**: 문서화 철저히
|
||
|
|
- **지식 공유**: 주간 리뷰 미팅
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 결론
|
||
|
|
|
||
|
|
화면 임베딩 및 데이터 전달 시스템은 복잡한 업무 워크플로우를 효율적으로 처리할 수 있는 강력한 기능입니다. 단계별로 체계적으로 구현하면 약 3.5개월 내에 완성할 수 있으며, 이를 통해 사용자 경험을 크게 향상시킬 수 있습니다.
|
||
|
|
|