V2 이벤트 시스템 통합 및 데이터 전달 인터페이스 구현: UnifiedRepeater 컴포넌트에 데이터 제공 및 수신 인터페이스를 추가하여 다른 컴포넌트와의 데이터 연동을 개선하였습니다. 또한, AggregationWidgetComponent와 RepeatContainerComponent에서 V2 표준 이벤트를 구독하여 데이터 변경 이벤트를 효율적으로 처리하도록 수정하였습니다. 이를 통해 컴포넌트 간의 데이터 흐름과 사용자 경험을 향상시켰습니다.

This commit is contained in:
juseok2 2026-01-29 23:20:23 +09:00
parent 8cdb8a3047
commit 3803b7dce1
16 changed files with 4654 additions and 85 deletions

View File

@ -5,7 +5,7 @@ services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.dev
dockerfile: ../docker/dev/frontend.Dockerfile
container_name: pms-frontend-win
ports:
- "9771:3000"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,974 @@
# 품목정보 (Item Info)
> Screen ID: /screens/140
> 메뉴 경로: 기준정보 > 품목정보
> 테이블: `item_info`
## 1. 테이블 선택 및 화면 구조
### 1.1 사용 테이블
| 테이블명 | 용도 | 비고 |
|----------|------|------|
| `item_info` | 품목 기본정보 | 주 테이블 |
### 1.2 테이블 컬럼 정의 (실제 DB 기준)
| 컬럼명 | 표시명 | 타입 | 필수 | 설명 |
|--------|--------|------|------|------|
| `id` | ID | varchar(500) | PK | UUID 자동 생성 |
| `item_number` | 품번코드 | varchar(500) | | 품목 고유 코드 |
| `item_name` | 품명 | varchar(500) | | 품목명 |
| `status` | 상태 | varchar(500) | | 정상, 품절, 대기, 단종 |
| `size` | 규격 | varchar(500) | | 규격 정보 |
| `material` | 재질 | varchar(500) | | 재질 정보 |
| `inventory_unit` | 재고단위 | varchar(500) | | EA, kg, L, Sheet, Box |
| `weight` | 중량 | varchar(500) | | 중량 값 |
| `unit` | 단위 | varchar(500) | | g, kg, kg/L, t |
| `image` | 이미지 | varchar(500) | | 품목 이미지 경로 |
| `division` | 구분 | varchar(500) | | 원자재, 중간재, 완제품, 포장재 (카테고리 코드) |
| `type` | 유형 | varchar(500) | | 용도별 유형 |
| `meno` | 메모 | varchar(500) | | 비고 (오타: memo) |
| `selling_price` | 판매가 | varchar(500) | | 기본값 '0' |
| `standard_price` | 기준가 | varchar(500) | | 기본값 '0' |
| `currency_code` | 통화코드 | varchar(500) | | 기본값 'KRW' |
| `writer` | 등록자 | varchar(500) | | 작성자 ID |
| `company_code` | 회사코드 | varchar(500) | | 멀티테넌시 |
| `created_date` | 등록일 | timestamp | | 자동 생성 |
| `updated_date` | 수정일 | timestamp | | 자동 갱신 |
### 1.3 화면 구조 개요
- **화면 유형**: 목록형 (단일 테이블 CRUD)
- **주요 기능**:
- 품목 조회/검색/필터링
- 품목 등록/수정/삭제
- 그룹핑 (Group By)
- 코드 변경/합병
- 엑셀 업로드
- 컬럼 표시/숨기기 설정
---
## 2. 컴포넌트 배치도
### 2.1 전체 레이아웃
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ [검색 영역] │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ v2-table-search-widget │ │
│ │ ┌───────────┐ ┌───────────────┐ ┌───────────────┐ ┌─────────┐ │ │
│ │ │ 상태 │ │ 품번코드 │ │ 품명 │ │ [검색] │ │ │
│ │ │ (select) │ │ (text) │ │ (text) │ │ │ │ │
│ │ └───────────┘ └───────────────┘ └───────────────┘ └─────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────────────────┤
│ [테이블 헤더 + 액션 버튼] │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ [코드변경][업로드][다운로드] [등록][복사][수정][삭제] │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────────────────┤
│ [데이터 테이블] │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ v2-table-list │ │
│ │ ┌──┬────┬────────┬────────┬──────┬──────┬────────┬─────┬─────┬────────┐ │ │
│ │ │☐ │상태│품번코드│품명 │규격 │재질 │재고단위│중량 │단위 │구분 │ │ │
│ │ ├──┼────┼────────┼────────┼──────┼──────┼────────┼─────┼─────┼────────┤ │ │
│ │ │☐ │정상│R_001 │테스트A │100mm │SUS304│EA │1.5 │kg │원자재 │ │ │
│ │ │☐ │대기│R_002 │테스트B │200mm │AL │kg │2.0 │kg │완제품 │ │ │
│ │ └──┴────┴────────┴────────┴──────┴──────┴────────┴─────┴─────┴────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### 2.2 컴포넌트 목록
| 컴포넌트 타입 | 역할 |
|---------------|------|
| v2-table-search-widget | 검색 필터 |
| v2-table-list | 품목 데이터 테이블 |
| v2-button-primary | 코드변경 |
| v2-button-primary | 업로드 (엑셀) |
| v2-button-primary | 다운로드 (엑셀) |
| v2-button-primary | 등록 (모달 열기) |
| v2-button-primary | 복사 (모달 열기) |
| v2-button-primary | 수정 (모달 열기) |
| v2-button-primary | 삭제 |
---
## 3. 화면 디자이너 설정 가이드
### 3.1 v2-table-search-widget (검색 필터) 설정
1. 좌측 컴포넌트 패널에서 `v2-table-search-widget` 드래그하여 화면 상단에 배치
2. 대상 테이블로 아래에 배치할 테이블 리스트 선택
> 💡 **참고**: 검색 필터는 사용자가 런타임에서 원하는 필드를 직접 추가/삭제하여 사용할 수 있습니다. 별도의 필드 설정이 필요 없습니다.
---
### 3.2 v2-table-list (품목 테이블) 설정
#### Step 1: 컴포넌트 추가
1. 좌측 컴포넌트 패널에서 `v2-table-list` 드래그하여 검색 필터 아래에 배치
#### Step 2: 데이터 소스 설정
| 설정 항목 | 설정 값 |
|-----------|---------|
| 테이블 선택 | `item_info` |
| 자동 컬럼 생성 | ✅ 체크 (테이블 컬럼 자동 로드) |
#### Step 3: 컬럼 설정
**[컬럼 설정]** 패널에서 표시할 컬럼 선택 및 순서 조정:
| 순서 | 컬럼 | 표시명 | 너비 | 정렬 | 표시 | 특수 설정 |
|------|------|--------|------|------|------|-----------|
| 1 | `status` | 상태 | 80 | 중앙 | ✅ | 뱃지 스타일 (색상별) |
| 2 | `item_number` | 품번코드 | 140 | 좌측 | ✅ | |
| 3 | `item_name` | 품명 | 200 | 좌측 | ✅ | 굵게 표시 |
| 4 | `size` | 규격 | 150 | 좌측 | ✅ | |
| 5 | `material` | 재질 | 150 | 좌측 | ✅ | |
| 6 | `inventory_unit` | 재고단위 | 100 | 중앙 | ✅ | |
| 7 | `weight` | 중량 | 80 | 우측 | ✅ | |
| 8 | `unit` | 단위 | 80 | 중앙 | ✅ | |
| 9 | `image` | 이미지 | 80 | 중앙 | ✅ | 이미지 미리보기 |
| 10 | `division` | 구분 | 100 | 중앙 | ✅ | 카테고리 표시 |
| 11 | `type` | 유형 | 100 | 중앙 | ✅ | |
| 12 | `selling_price` | 판매가 | 100 | 우측 | ☐ | 숫자 포맷 |
| 13 | `standard_price` | 기준가 | 100 | 우측 | ☐ | 숫자 포맷 |
| 14 | `meno` | 메모 | 180 | 좌측 | ☐ | |
| 15 | `writer` | 등록자 | 100 | 좌측 | ☐ | 읽기 전용 |
| 16 | `created_date` | 등록일 | 120 | 중앙 | ☐ | 읽기 전용 |
| 17 | `updated_date` | 수정일 | 120 | 중앙 | ☐ | 읽기 전용 |
#### Step 4: 기능 설정
| 설정 항목 | 설정 값 | 설명 |
|-----------|---------|------|
| 체크박스 | ✅ 사용 | 다중 선택 활성화 |
| 페이지네이션 | ✅ 사용 | |
| 페이지 크기 | 20 | 기본 표시 행 수 |
| 정렬 | ✅ 사용 | 컬럼 헤더 클릭 정렬 |
| 컬럼 리사이즈 | ✅ 사용 | 컬럼 너비 조정 |
| 그룹핑 | ✅ 사용 | Group By 기능 |
#### Step 5: 그룹핑 옵션 설정
Group By 드롭다운에 표시할 컬럼 선택:
- ✅ `status` (상태)
- ✅ `division` (구분)
- ✅ `type` (유형)
- ✅ `inventory_unit` (재고단위)
- ✅ `writer` (등록자)
---
### 3.3 버튼 설정
#### 좌측 버튼 그룹
##### 코드변경 버튼
| 설정 항목 | 설정 값 |
|-----------|---------|
| 라벨 | `코드변경` |
| 액션 타입 | `code_merge` |
| 스타일 | `secondary` |
| 선택 필수 | ✅ 체크 (복수 선택) |
| 병합 대상 컬럼 | `item_number` |
| 데이터플로우 연결 | 품번코드 통합 (flow_id: 18) |
##### 업로드 버튼
| 설정 항목 | 설정 값 |
|-----------|---------|
| 라벨 | `업로드` |
| 액션 타입 | `excel_upload` |
| 스타일 | `secondary` |
| 대상 테이블 | `item_info` |
##### 다운로드 버튼
| 설정 항목 | 설정 값 |
|-----------|---------|
| 라벨 | `다운로드` |
| 액션 타입 | `excel_download` |
| 스타일 | `secondary` |
| 대상 | 현재 테이블 리스트 |
#### 우측 버튼 그룹
##### 등록 버튼
| 설정 항목 | 설정 값 |
|-----------|---------|
| 라벨 | `등록` |
| 액션 타입 | `modal` |
| 스타일 | `default` |
| 연결 화면 | 품목 등록/수정 화면 (아래 3.4 참조) |
| 모달 제목 | 품목 등록 |
| 모달 사이즈 | `md` |
##### 복사 버튼
| 설정 항목 | 설정 값 |
|-----------|---------|
| 라벨 | `복사` |
| 액션 타입 | `copy` |
| 스타일 | `default` |
| 선택 필수 | ✅ 체크 (1개만) |
| 연결 화면 | 품목 등록/수정 화면 (아래 3.4 참조) |
| 동작 | 선택된 데이터를 복사하여 신규 등록 폼에 채움 |
##### 수정 버튼
| 설정 항목 | 설정 값 |
|-----------|---------|
| 라벨 | `수정` |
| 액션 타입 | `edit` |
| 스타일 | `default` |
| 선택 필수 | ✅ 체크 (1개만) |
| 연결 화면 | 품목 등록/수정 화면 (아래 3.4 참조) |
| 동작 | 선택된 데이터 수정 모드로 폼 열기 |
##### 삭제 버튼
| 설정 항목 | 설정 값 |
|-----------|---------|
| 라벨 | `삭제` |
| 액션 타입 | `delete` |
| 스타일 | `default` |
| 선택 필수 | ✅ 체크 (복수 선택 가능) |
| 확인 메시지 | 선택한 품목을 삭제하시겠습니까? |
| 삭제 후 동작 | 테이블 새로고침 |
---
### 3.4 품목 등록/수정 화면 (모달용 화면)
> 📌 **별도 화면 생성 필요**: 등록/복사/수정 버튼에 연결할 모달 화면을 새로 생성합니다.
>
> 💡 **동일 화면 공유**: 등록, 복사, 수정 버튼 모두 동일한 폼 화면을 사용합니다.
> - **등록**: 빈 폼으로 열림
> - **복사**: 선택된 데이터가 채워진 상태로 열림 (신규 등록)
> - **수정**: 선택된 데이터가 채워진 상태로 열림 (기존 데이터 업데이트)
#### Step 1: 새 화면 생성
1. 화면 관리에서 **[+ 새 화면]** 클릭
2. 화면 정보 입력:
- 화면명: `품목 등록/수정`
- 테이블: `item_info`
- 화면 유형: `모달`
#### Step 2: 폼 필드 배치
**모달 레이아웃 배치도**:
```
┌─────────────────────────────────────────────────────────────┐
│ 품목 등록/수정 [✕] │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────┐ ┌─────────────────────────┐ │
│ │ 품번코드 * │ │ 품명 * │ │
│ │ [____________________] │ │ [____________________] │ │
│ └─────────────────────────┘ └─────────────────────────┘ │
│ │
│ ┌─────────────────────────┐ ┌─────────────────────────┐ │
│ │ 규격 │ │ 재질 │ │
│ │ [____________________] │ │ [____________________] │ │
│ └─────────────────────────┘ └─────────────────────────┘ │
│ │
│ ┌─────────────────────────┐ ┌───────────┐ ┌───────────┐ │
│ │ 재고단위 * │ │ 중량 │ │ 중량단위 │ │
│ │ [EA ▼] │ │ [_______] │ │ [kg ▼] │ │
│ └─────────────────────────┘ └───────────┘ └───────────┘ │
│ │
│ ┌─────────────────────────┐ ┌─────────────────────────┐ │
│ │ 구분 * │ │ 유형 │ │
│ │ [원자재 ▼] │ │ [반도체용 ▼] │ │
│ └─────────────────────────┘ └─────────────────────────┘ │
│ │
│ ┌─────────────────────────┐ ┌─────────────────────────┐ │
│ │ 판매가 │ │ 기준가 │ │
│ │ [____________________] │ │ [____________________] │ │
│ └─────────────────────────┘ └─────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 메모 │ │
│ │ [__________________________________________________]│ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────┐ │
│ │ 상태 * │ │
│ │ [정상 ▼] │ │
│ └─────────────────────────┘ │
│ │
├─────────────────────────────────────────────────────────────┤
│ [취소] [💾 저장] │
└─────────────────────────────────────────────────────────────┘
```
**필드 목록**:
| 순서 | 필드 (컬럼명) | 라벨 | 입력 타입 | 필수 | 비고 |
|------|---------------|------|-----------|------|------|
| 1 | `item_number` | 품번코드 | text | ✅ | |
| 2 | `item_name` | 품명 | text | ✅ | |
| 3 | `size` | 규격 | text | | |
| 4 | `material` | 재질 | text | | |
| 5 | `inventory_unit` | 재고단위 | select | ✅ | 옵션: EA, kg, L, Sheet, Box |
| 6 | `weight` | 중량 | number | | |
| 7 | `unit` | 중량단위 | select | | 옵션: g, kg, kg/L, t |
| 8 | `division` | 구분 | category | ✅ | 품목 구분 카테고리 |
| 9 | `type` | 유형 | select | | 옵션: 반도체용, 태양광용, 산업용, 의료용, 건축용, 사출용, 화장품용 |
| 10 | `selling_price` | 판매가 | number | | |
| 11 | `standard_price` | 기준가 | number | | |
| 12 | `meno` | 메모 | text | | |
| 13 | `status` | 상태 | select | ✅ | 옵션: 정상, 품절, 대기, 단종 |
#### Step 3: 버튼 배치
| 버튼 | 액션 타입 | 스타일 | 설정 |
|------|-----------|--------|------|
| 저장 | `저장` | primary | 저장 후 모달 닫기, 부모 화면 테이블 새로고침 |
| 취소 | `모달 닫기` | secondary | |
#### Step 4: 버튼에 화면 연결
1. 메인 화면(품목정보)으로 돌아가기
2. **등록 버튼** 선택 → 설정 패널에서:
- 액션 타입: `modal`
- 연결 화면: `품목 등록/수정` 선택
- 모달 제목: `품목 등록`
3. **복사 버튼** 선택 → 설정 패널에서:
- 액션 타입: `copy`
- 연결 화면: `품목 등록/수정` 선택
- 선택 필수: ✅ 체크
- 동작: 선택된 데이터를 복사하여 폼에 채움 (신규 등록)
4. **수정 버튼** 선택 → 설정 패널에서:
- 액션 타입: `edit`
- 연결 화면: `품목 등록/수정` 선택
- 선택 필수: ✅ 체크
- 동작: 선택된 데이터를 수정 모드로 폼에 채움
> 💡 **참고**: 컬럼별 스타일(뱃지 색상, 카테고리 표시 등)은 컴포넌트 기본 스타일을 따릅니다. 필요시 테이블 관리에서 컬럼별 상세 설정을 조정할 수 있습니다.
---
## 4. 컴포넌트 연동 설정
### 4.1 이벤트 흐름
```
[검색 입력]
v2-table-search-widget
│ onFilterChange
v2-table-list (자동 재조회)
[데이터 표시]
[등록/복사/수정 버튼 클릭]
[모달 열기] → [폼 입력] → [저장]
│ │
│ ▼
│ refreshTable 이벤트
│ │
└────────────────────────┘
v2-table-list (재조회)
```
### 4.2 연동 설정
| 소스 컴포넌트 | 이벤트/액션 | 대상 컴포넌트 | 동작 |
|---------------|-------------|---------------|------|
| 검색 위젯 | onFilterChange | 테이블 리스트 | 필터 적용, 재조회 |
| 등록 버튼 | click | 모달 | 빈 폼으로 모달 열기 |
| 복사 버튼 | click | 모달 | 선택 데이터가 채워진 폼 열기 (신규) |
| 수정 버튼 | click | 모달 | 선택 데이터가 채워진 폼 열기 (수정) |
| 삭제 버튼 | click | 테이블 리스트 | 선택 항목 삭제 |
| 모달 저장 | afterSave | 테이블 리스트 | refreshTable |
### 4.3 TableOptionsContext 연동
```
v2-table-search-widget ──── TableOptionsContext ──── v2-table-list
│ │ │
│ registeredTables에서 │ │
│ item-table 참조 │ │
│ │ │
└── onFilterChange() ───────┼──────────────────────┘
필터 조건 전달 & 재조회
```
---
## 5. 사용자 사용 예시 시나리오
### 시나리오 1: 품목 조회
| 단계 | 사용자 동작 | 기대 결과 |
|------|-------------|-----------|
| 1 | 화면 진입 | 전체 품목 목록 표시 |
| 2 | 상태 필터를 "정상"으로 선택 | 자동 필터링 |
| 3 | 품명에 "폴리머" 입력 후 검색 | 품명에 "폴리머" 포함된 품목 표시 |
| 4 | Group by에서 "구분" 선택 | division별 그룹핑 |
### 시나리오 2: 품목 등록
| 단계 | 사용자 동작 | 기대 결과 |
|------|-------------|-----------|
| 1 | [등록] 버튼 클릭 | 빈 폼 모달 표시 |
| 2 | 데이터 입력 (품번코드, 품명, 규격 등) | 입력 필드 채움 |
| 3 | [저장] 버튼 클릭 | 저장 완료, 모달 닫힘, 목록 갱신 |
### 시나리오 3: 품목 복사
| 단계 | 사용자 동작 | 기대 결과 |
|------|-------------|-----------|
| 1 | 테이블에서 복사할 행 체크박스 선택 | 행 선택 표시 |
| 2 | [복사] 버튼 클릭 | 선택된 데이터가 채워진 폼 모달 표시 |
| 3 | 필요시 데이터 수정 (품번코드 등) | 필드 값 변경 |
| 4 | [저장] 버튼 클릭 | 신규 등록 완료, 목록 갱신 |
### 시나리오 4: 품목 수정
| 단계 | 사용자 동작 | 기대 결과 |
|------|-------------|-----------|
| 1 | 테이블에서 행 체크박스 선택 | 행 선택 표시 |
| 2 | [수정] 버튼 클릭 | 수정 모달 표시 (기존 데이터 로드) |
| 3 | 데이터 수정 | 필드 값 변경 |
| 4 | [저장] 버튼 클릭 | 저장 완료, 목록 갱신 |
### 시나리오 5: 품목 삭제
| 단계 | 사용자 동작 | 기대 결과 |
|------|-------------|-----------|
| 1 | 삭제할 행 체크박스 선택 (다중 가능) | 행 선택 표시 |
| 2 | [삭제] 버튼 클릭 | 삭제 확인 다이얼로그 표시 |
| 3 | 확인 | 삭제 완료, 목록 갱신 |
---
## 6. 검증 체크리스트
### 기본 기능
- [ ] 데이터 조회가 정상 동작하는가?
- [ ] 검색 필터 (상태, 품번코드, 품명)가 정상 동작하는가?
- [ ] 신규 등록이 정상 동작하는가?
- [ ] 복사 기능이 정상 동작하는가?
- [ ] 수정이 정상 동작하는가?
- [ ] 삭제가 정상 동작하는가?
- [ ] 코드변경이 정상 동작하는가?
- [ ] 엑셀 업로드가 정상 동작하는가?
- [ ] 엑셀 다운로드가 정상 동작하는가?
### 테이블 기능
- [ ] 페이지네이션이 정상 동작하는가?
- [ ] 정렬이 정상 동작하는가?
- [ ] 컬럼 너비 조정이 정상 동작하는가?
- [ ] 체크박스 선택이 정상 동작하는가?
### 검색 위젯 연동
- [ ] v2-table-search-widget과 v2-table-list 연동이 정상 동작하는가?
- [ ] 필터 변경 시 자동 재조회가 동작하는가?
- [ ] 초기화 버튼이 정상 동작하는가?
### 그룹핑 기능
- [ ] Group by 선택 시 그룹핑이 정상 동작하는가?
- [ ] 다중 그룹핑이 정상 동작하는가?
---
## 7. 참고 사항
### 관련 테이블
- `customer_item_mapping` - 거래처별 품목 매핑
- `supplier_item_mapping` - 공급업체별 품목 매핑
- `item_inspection_info` - 품목 검사 정보
- `item_routing_version` - 품목별 공정 버전
- `item_routing_detail` - 품목별 공정 상세
### 특이 사항
- `division` 컬럼은 카테고리 코드 (예: CATEGORY_191259)로 저장됨
- `meno` 컬럼은 오타로 보임 (원래 memo)
- `selling_price`, `standard_price`는 varchar로 저장됨 (숫자 형식 문자열)
- `company_code`는 멀티테넌시용 회사 코드
---
## 8. DB INSERT용 JSON 설정 (screen_layouts_v2 방식)
> 📌 실제 화면 저장은 `screen_definitions` + `screen_layouts_v2` 테이블을 사용합니다.
> `screen_layouts_v2`는 전체 레이아웃을 하나의 JSON (`layout_data`)으로 저장합니다.
### 8.1 테이블 구조
#### screen_definitions
| 컬럼명 | 타입 | 필수 | 기본값 | 설명 |
|--------|------|------|--------|------|
| `screen_id` | integer | PK | 자동 생성 (시퀀스) | 화면 고유 ID |
| `screen_name` | varchar(100) | ✅ | - | 화면명 |
| `screen_code` | varchar(50) | ✅ | **자동 생성** | `{company_code}_{순번}` 형식 |
| `table_name` | varchar(100) | | - | 기본 테이블명 |
| `company_code` | varchar(50) | ✅ | - | 회사 코드 |
| `description` | text | | - | 화면 설명 |
| `is_active` | char(1) | | `'Y'` | Y=활성, N=비활성, D=삭제 |
| `created_date` | timestamp | | `CURRENT_TIMESTAMP` | 생성일시 |
| `db_source_type` | varchar(10) | | `'internal'` | internal/external |
| `data_source_type` | varchar(20) | | `'database'` | database/rest_api |
#### screen_layouts_v2
| 컬럼명 | 타입 | 필수 | 기본값 | 설명 |
|--------|------|------|--------|------|
| `layout_id` | integer | PK | 자동 생성 (시퀀스) | 레이아웃 고유 ID |
| `screen_id` | integer | ✅ | - | 화면 ID (FK) |
| `company_code` | varchar(20) | ✅ | - | 회사 코드 |
| `layout_data` | jsonb | ✅ | `'{}'` | 전체 레이아웃 JSON |
| `created_at` | timestamp | | `now()` | 생성일시 |
| `updated_at` | timestamp | | `now()` | 수정일시 |
### 8.2 화면 정의 (screen_definitions)
> ⚠️ `screen_code`는 API 호출 시 자동 생성됩니다. (`{company_code}_{순번}` 형식)
**필수 입력 필드:**
```json
{
"screenName": "품목정보",
"tableName": "item_info",
"companyCode": "COMPANY_7",
"description": "품목 기본정보 관리 화면"
}
```
**전체 필드 (자동 생성 포함):**
```json
{
"screen_id": 140,
"screen_name": "품목정보",
"screen_code": "COMPANY_7_3",
"table_name": "item_info",
"company_code": "COMPANY_7",
"description": "품목 기본정보 관리 화면",
"is_active": "Y",
"db_source_type": "internal",
"data_source_type": "database",
"created_date": "2025-01-29T00:00:00.000Z"
}
```
### 8.2 레이아웃 데이터 (screen_layouts_v2.layout_data)
> 전체 레이아웃을 하나의 JSON으로 저장
```json
{
"version": "2.0",
"components": [
{
"id": "comp_search",
"url": "@/lib/registry/components/v2-table-search-widget",
"size": { "width": 1920, "height": 80 },
"position": { "x": 0, "y": 20, "z": 1 },
"overrides": {
"type": "v2-table-search-widget",
"label": "검색 필터",
"webTypeConfig": {}
},
"displayOrder": 0
},
{
"id": "comp_table",
"url": "@/lib/registry/components/v2-table-list",
"size": { "width": 1920, "height": 930 },
"position": { "x": 0, "y": 150, "z": 1 },
"overrides": {
"type": "v2-table-list",
"label": "테이블 리스트",
"filter": { "enabled": true, "filters": [] },
"height": "auto",
"actions": { "actions": [], "bulkActions": false, "showActions": false },
"columns": [
{ "align": "left", "order": 0, "format": "text", "visible": true, "sortable": true, "columnName": "status", "searchable": true, "displayName": "status" },
{ "align": "left", "order": 1, "format": "text", "visible": true, "sortable": true, "columnName": "item_number", "searchable": true, "displayName": "item_number" },
{ "align": "left", "order": 2, "format": "text", "visible": true, "sortable": true, "columnName": "item_name", "searchable": true, "displayName": "item_name" },
{ "align": "left", "order": 3, "format": "text", "visible": true, "sortable": true, "columnName": "size", "searchable": true, "displayName": "size" },
{ "align": "left", "order": 4, "format": "text", "visible": true, "sortable": true, "columnName": "material", "searchable": true, "displayName": "material" },
{ "align": "left", "order": 5, "format": "text", "visible": true, "sortable": true, "columnName": "inventory_unit", "searchable": true, "displayName": "inventory_unit" },
{ "align": "left", "order": 6, "format": "text", "visible": true, "sortable": true, "columnName": "weight", "searchable": true, "displayName": "weight" },
{ "align": "left", "order": 7, "format": "text", "visible": true, "sortable": true, "columnName": "unit", "searchable": true, "displayName": "unit" },
{ "align": "left", "order": 8, "format": "text", "visible": true, "sortable": true, "columnName": "division", "searchable": true, "displayName": "division" },
{ "align": "left", "order": 9, "format": "text", "visible": true, "sortable": true, "columnName": "type", "searchable": true, "displayName": "type" },
{ "align": "left", "order": 10, "format": "text", "visible": true, "sortable": true, "columnName": "writer", "searchable": true, "displayName": "writer" }
],
"autoLoad": true,
"checkbox": { "enabled": true, "multiple": true, "position": "left", "selectAll": true },
"pagination": { "enabled": true, "pageSize": 20, "showPageInfo": true, "pageSizeOptions": [10, 20, 50, 100], "showSizeSelector": true },
"showFooter": true,
"showHeader": true,
"tableStyle": { "theme": "default", "rowHeight": "normal", "borderStyle": "light", "headerStyle": "default", "hoverEffect": true, "alternateRows": true },
"displayMode": "table",
"stickyHeader": false,
"selectedTable": "item_info",
"webTypeConfig": {},
"horizontalScroll": { "enabled": true, "maxColumnWidth": 300, "minColumnWidth": 100, "maxVisibleColumns": 8 }
},
"displayOrder": 0
},
{
"id": "comp_btn_code_merge",
"url": "@/lib/registry/components/v2-button-primary",
"size": { "width": 88, "height": 40 },
"position": { "x": 10, "y": 100, "z": 1 },
"overrides": {
"text": "코드변경",
"type": "v2-button-primary",
"label": "기본 버튼",
"action": {
"type": "code_merge",
"errorMessage": "저장 중 오류가 발생했습니다.",
"successMessage": "저장되었습니다.",
"mergeColumnName": "item_number"
},
"variant": "primary",
"actionType": "button",
"webTypeConfig": {
"variant": "default",
"actionType": "custom",
"dataflowConfig": {
"flowConfig": { "flowId": 18, "flowName": "품번코드 통합", "contextData": {}, "executionTiming": "after" },
"selectedDiagramId": 18
}
}
},
"displayOrder": 0
},
{
"id": "comp_btn_upload",
"url": "@/lib/registry/components/v2-button-primary",
"size": { "width": 88, "height": 40 },
"position": { "x": 110, "y": 100, "z": 1 },
"overrides": {
"text": "업로드",
"type": "v2-button-primary",
"label": "기본 버튼",
"action": {
"type": "excel_upload",
"errorMessage": "저장 중 오류가 발생했습니다.",
"successMessage": "저장되었습니다."
},
"variant": "primary",
"actionType": "button",
"webTypeConfig": { "variant": "default", "actionType": "custom" }
},
"displayOrder": 0
},
{
"id": "comp_btn_download",
"url": "@/lib/registry/components/v2-button-primary",
"size": { "width": 88, "height": 40 },
"position": { "x": 210, "y": 100, "z": 1 },
"overrides": {
"text": "다운로드",
"type": "v2-button-primary",
"label": "기본 버튼",
"action": {
"type": "excel_download",
"errorMessage": "저장 중 오류가 발생했습니다.",
"successMessage": "저장되었습니다."
},
"variant": "primary",
"actionType": "button",
"webTypeConfig": { "variant": "default", "actionType": "custom" }
},
"displayOrder": 0
},
{
"id": "comp_btn_register",
"url": "@/lib/registry/components/v2-button-primary",
"size": { "width": 80, "height": 40 },
"position": { "x": 1550, "y": 100, "z": 1 },
"overrides": {
"text": "등록",
"type": "v2-button-primary",
"label": "기본 버튼",
"action": {
"type": "modal",
"modalSize": "md",
"modalTitle": "품목 등록",
"errorMessage": "저장 중 오류가 발생했습니다.",
"successMessage": "저장되었습니다.",
"targetScreenId": "{{modal_screen_id}}"
},
"variant": "primary",
"actionType": "button",
"webTypeConfig": { "variant": "default", "actionType": "custom" }
},
"displayOrder": 0
},
{
"id": "comp_btn_copy",
"url": "@/lib/registry/components/v2-button-primary",
"size": { "width": 80, "height": 40 },
"position": { "x": 1640, "y": 100, "z": 1 },
"overrides": {
"text": "복사",
"type": "v2-button-primary",
"label": "기본 버튼",
"action": {
"type": "copy",
"errorMessage": "저장 중 오류가 발생했습니다.",
"successMessage": "저장되었습니다.",
"targetScreenId": "{{modal_screen_id}}"
},
"variant": "primary",
"actionType": "button",
"webTypeConfig": { "variant": "default", "actionType": "custom" }
},
"displayOrder": 0
},
{
"id": "comp_btn_edit",
"url": "@/lib/registry/components/v2-button-primary",
"size": { "width": 80, "height": 40 },
"position": { "x": 1730, "y": 100, "z": 1 },
"overrides": {
"text": "수정",
"type": "v2-button-primary",
"label": "기본 버튼",
"action": {
"type": "edit",
"errorMessage": "저장 중 오류가 발생했습니다.",
"successMessage": "저장되었습니다.",
"targetScreenId": "{{modal_screen_id}}"
},
"variant": "primary",
"actionType": "button",
"webTypeConfig": { "variant": "default", "actionType": "custom" }
},
"displayOrder": 0
},
{
"id": "comp_btn_delete",
"url": "@/lib/registry/components/v2-button-primary",
"size": { "width": 80, "height": 40 },
"position": { "x": 1820, "y": 100, "z": 1 },
"overrides": {
"text": "삭제",
"type": "v2-button-primary",
"label": "기본 버튼",
"action": {
"type": "delete",
"errorMessage": "저장 중 오류가 발생했습니다.",
"successMessage": "저장되었습니다."
},
"variant": "primary",
"actionType": "button",
"webTypeConfig": { "variant": "default", "actionType": "custom" }
},
"displayOrder": 0
}
]
}
```
### 8.3 모달 화면 (품목 등록/수정)
#### 화면 정의 (필수 입력)
```json
{
"screenName": "품목 등록/수정",
"tableName": "item_info",
"companyCode": "COMPANY_7",
"description": "품목 등록/수정 폼 화면"
}
```
#### 레이아웃 데이터 (screen_layouts_v2.layout_data)
```json
{
"version": "2.0",
"components": [
{
"id": "comp_item_number",
"url": "@/lib/registry/components/v2-text-input",
"size": { "width": 300, "height": 60 },
"position": { "x": 20, "y": 20, "z": 1 },
"overrides": {
"type": "v2-text-input",
"label": "품번코드",
"fieldName": "item_number",
"placeholder": "품번코드를 입력하세요",
"required": true
},
"displayOrder": 0
},
{
"id": "comp_item_name",
"url": "@/lib/registry/components/v2-text-input",
"size": { "width": 300, "height": 60 },
"position": { "x": 340, "y": 20, "z": 1 },
"overrides": {
"type": "v2-text-input",
"label": "품명",
"fieldName": "item_name",
"placeholder": "품명을 입력하세요",
"required": true
},
"displayOrder": 1
},
{
"id": "comp_status",
"url": "@/lib/registry/components/v2-select-basic",
"size": { "width": 300, "height": 60 },
"position": { "x": 20, "y": 100, "z": 1 },
"overrides": {
"type": "v2-select-basic",
"label": "상태",
"fieldName": "status",
"options": ["정상", "품절", "대기", "단종"]
},
"displayOrder": 2
},
{
"id": "comp_btn_save",
"url": "@/lib/registry/components/v2-button-primary",
"size": { "width": 80, "height": 40 },
"position": { "x": 400, "y": 500, "z": 1 },
"overrides": {
"text": "저장",
"type": "v2-button-primary",
"label": "저장 버튼",
"action": {
"type": "save",
"closeModalAfterSave": true,
"refreshParentTable": true,
"successMessage": "저장되었습니다.",
"errorMessage": "저장 중 오류가 발생했습니다."
},
"variant": "primary",
"actionType": "button"
},
"displayOrder": 20
}
]
}
```
### 8.4 API 호출 방식
> 📌 실제 화면 생성은 API를 통해 진행됩니다. `screen_code`는 서버에서 자동 생성됩니다.
#### Step 1: 화면 코드 자동 생성 API
```http
GET /api/screens/generate-code?companyCode=COMPANY_7
```
**응답:**
```json
{
"success": true,
"data": { "screenCode": "COMPANY_7_4" }
}
```
#### Step 2: 화면 생성 API
```http
POST /api/screens
Content-Type: application/json
Authorization: Bearer {{token}}
{
"screenName": "품목정보",
"screenCode": "COMPANY_7_4",
"tableName": "item_info",
"companyCode": "COMPANY_7",
"description": "품목 기본정보 관리 화면"
}
```
**응답:**
```json
{
"success": true,
"data": {
"screenId": 141,
"screenCode": "COMPANY_7_4",
"screenName": "품목정보"
}
}
```
#### Step 3: 레이아웃 저장 API
```http
PUT /api/screens/141/layout-v2
Content-Type: application/json
Authorization: Bearer {{token}}
{
"layoutData": {
"version": "2.0",
"components": [ /* 8.2의 components 배열 */ ]
}
}
```
### 8.5 SQL 직접 INSERT (참고용)
> ⚠️ 일반적으로 API를 사용하지만, 대량 마이그레이션 시 직접 SQL 사용 가능
```sql
-- Step 1: 화면 정의 (screen_code는 수동 지정 필요)
INSERT INTO screen_definitions (
screen_name, screen_code, table_name, company_code, description
) VALUES (
'품목정보', 'COMPANY_7_4', 'item_info', 'COMPANY_7', '품목 기본정보 관리 화면'
) RETURNING screen_id;
-- Step 2: 레이아웃 저장 (screen_id 사용)
INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data)
VALUES (
141, -- 위에서 반환된 screen_id
'COMPANY_7',
'{"version": "2.0", "components": [...]}'::jsonb
);
```
### 8.6 주의사항
| 항목 | 설명 |
|------|------|
| `screen_code` | API 사용 시 `generateScreenCode` 먼저 호출, 형식: `{company_code}_{순번}` |
| `screen_id` | 화면 생성 후 반환되는 값, 레이아웃 저장 시 필요 |
| `component.id` | 고유 ID (UUID 또는 `comp_` prefix), 중복 불가 |
| `component.url` | `@/lib/registry/components/v2-xxx` 형식 |
| `{{modal_screen_id}}` | 모달 화면 먼저 생성 후 실제 ID로 치환 |
| `version` | 반드시 `"2.0"` 사용 |
| UNIQUE 제약 | `screen_layouts_v2``(screen_id, company_code)` 조합이 유니크 |

View File

@ -0,0 +1,126 @@
# 화면 구현 가이드
V2 컴포넌트를 활용한 ERP 화면 구현 가이드입니다.
## 폴더 구조
```
screen-implementation-guide/
├── 01_master-data/ # 기준정보
│ ├── company-info.md # 회사정보
│ ├── department.md # 부서관리
│ ├── item-info.md # 품목정보
│ └── options.md # 옵션설정
├── 02_sales/ # 영업관리
│ ├── quotation.md # 견적관리
│ ├── order.md # 수주관리
│ ├── customer.md # 거래처관리
│ ├── sales-item.md # 판매품목정보
│ └── options.md # 영업옵션설정
├── 03_production/ # 생산관리
│ ├── production-plan.md # 생산계획
│ ├── work-order.md # 작업지시
│ ├── production-result.md # 생산실적
│ ├── process-info.md # 공정정보관리
│ ├── bom.md # BOM관리
│ └── options.md # 생산옵션설정
├── 04_purchase/ # 구매관리
│ ├── purchase-order.md # 발주관리
│ ├── purchase-item.md # 구매품목정보
│ ├── supplier.md # 공급업체관리
│ ├── receiving.md # 입고관리
│ └── options.md # 구매옵션설정
├── 05_equipment/ # 설비관리
│ ├── equipment-info.md # 설비정보
│ └── options.md # 설비옵션설정
├── 06_logistics/ # 물류관리
│ ├── logistics-info.md # 물류정보관리
│ ├── inout.md # 입출고관리
│ ├── inventory.md # 재고현황
│ ├── warehouse.md # 창고정보관리
│ ├── shipping.md # 출고관리
│ └── options.md # 물류옵션설정
├── 07_quality/ # 품질관리
│ ├── inspection-info.md # 검사정보관리
│ ├── item-inspection.md # 품목검사정보
│ └── options.md # 품질옵션설정
└── README.md
```
## 문서 작성 형식
각 화면별 문서는 다음 구조로 작성됩니다:
### 1. 테이블 선택 및 화면 구조
- 사용할 데이터베이스 테이블
- 테이블 간 관계 (FK, 조인)
- 화면 전체 레이아웃
### 2. 컴포넌트 배치도
- ASCII 다이어그램으로 컴포넌트 배치
- 각 영역별 사용 컴포넌트 명시
### 3. 각 컴포넌트별 설정
- 컴포넌트 타입
- 상세 설정 (config)
- 연동 설정
### 4. 사용자 사용 예시 시나리오
- 테스트 시나리오
- 기대 동작
- 검증 포인트
## 메뉴별 Screen ID 매핑
| 메뉴 | Screen ID | 상태 |
|------|-----------|------|
| **기준정보** | | |
| 회사정보 | /screens/138 | 활성화 |
| 부서관리 | /screens/1487 | 활성화 |
| 품목정보 | /screens/140 | 활성화 |
| 옵션설정 | /screens/1421 | 활성화 |
| **영업관리** | | |
| 견적관리 | - | 활성화 |
| 수주관리 | /screens/156 | 활성화 |
| 거래처관리 | - | 활성화 |
| 판매품목정보 | - | 활성화 |
| 영업옵션설정 | /screens/1552 | 활성화 |
| **생산관리** | | |
| 생산계획 | - | 활성화 |
| 작업지시 | - | 활성화 |
| 생산실적 | - | 활성화 |
| 공정정보관리 | /screens/1599 | 활성화 |
| BOM관리 | - | 활성화 |
| 생산옵션설정 | /screens/1606 | 활성화 |
| **구매관리** | | |
| 발주관리 | /screens/1244 | 활성화 |
| 구매품목정보 | /screens/1061 | 활성화 |
| 공급업체관리 | /screens/1053 | 활성화 |
| 입고관리 | /screens/1064 | 활성화 |
| 구매옵션설정 | /screens/1057 | 활성화 |
| **설비관리** | | |
| 설비정보 | /screens/1253 | 활성화 |
| 설비옵션설정 | /screens/1264 | 활성화 |
| **물류관리** | | |
| 물류정보관리 | /screens/1556 | 활성화 |
| 입출고관리 | - | 활성화 |
| 재고현황 | /screens/1587 | 활성화 |
| 창고정보관리 | /screens/1562 | 활성화 |
| 출고관리 | /screens/2296 | 활성화 |
| 물류옵션설정 | /screens/1559 | 활성화 |
| **품질관리** | | |
| 검사정보관리 | /screens/1616 | 활성화 |
| 품목검사정보 | /screens/2089 | 활성화 |
| 품질옵션설정 | /screens/1622 | 활성화 |
## 참고 문서
- [V2 컴포넌트 분석 가이드](../V2_컴포넌트_분석_가이드.md)
- [V2 컴포넌트 연동 가이드](../V2_컴포넌트_연동_가이드.md)

View File

@ -0,0 +1,212 @@
# [화면명]
> Screen ID: /screens/XXX
> 메뉴 경로: [L2 메뉴] > [L3 메뉴]
## 1. 테이블 선택 및 화면 구조
### 1.1 사용 테이블
| 테이블명 | 용도 | 비고 |
|----------|------|------|
| `table_name` | 마스터 데이터 | 주 테이블 |
| `detail_table` | 디테일 데이터 | FK: master_id |
### 1.2 테이블 관계
```
┌─────────────────┐ ┌─────────────────┐
│ master_table │ │ detail_table │
├─────────────────┤ ├─────────────────┤
│ id (PK) │──1:N──│ master_id (FK) │
│ name │ │ id (PK) │
│ ... │ │ ... │
└─────────────────┘ └─────────────────┘
```
### 1.3 화면 구조 개요
- **화면 유형**: [목록형 / 마스터-디테일 / 단일 폼 / 복합]
- **주요 기능**: [CRUD / 조회 / 집계 등]
---
## 2. 컴포넌트 배치도
### 2.1 전체 레이아웃
```
┌─────────────────────────────────────────────────────────────┐
│ [검색 영역] v2-table-search-widget │
├─────────────────────────────────────────────────────────────┤
│ │
│ [메인 테이블] v2-table-list │
│ │
├─────────────────────────────────────────────────────────────┤
│ [버튼 영역] v2-button-primary (신규, 저장, 삭제) │
└─────────────────────────────────────────────────────────────┘
```
### 2.2 컴포넌트 목록
| 컴포넌트 ID | 컴포넌트 타입 | 역할 |
|-------------|---------------|------|
| `search-widget` | v2-table-search-widget | 검색 필터 |
| `main-table` | v2-table-list | 데이터 목록 |
| `btn-new` | v2-button-primary | 신규 등록 |
| `btn-save` | v2-button-primary | 저장 |
| `btn-delete` | v2-button-primary | 삭제 |
---
## 3. 각 컴포넌트별 설정
### 3.1 v2-table-search-widget
```json
{
"targetTableId": "main-table",
"searchFields": [
{
"field": "name",
"label": "이름",
"type": "text"
},
{
"field": "status",
"label": "상태",
"type": "select",
"options": [
{ "value": "active", "label": "활성" },
{ "value": "inactive", "label": "비활성" }
]
}
]
}
```
### 3.2 v2-table-list
```json
{
"tableName": "master_table",
"columns": [
{
"field": "id",
"headerName": "ID",
"width": 80,
"visible": false
},
{
"field": "name",
"headerName": "이름",
"width": 150
},
{
"field": "status",
"headerName": "상태",
"width": 100
}
],
"features": {
"checkbox": true,
"pagination": true,
"sorting": true
},
"pagination": {
"pageSize": 20
}
}
```
### 3.3 v2-button-primary (저장)
```json
{
"label": "저장",
"actionType": "save",
"variant": "default",
"afterSaveActions": ["refreshTable"]
}
```
---
## 4. 컴포넌트 연동 설정
### 4.1 이벤트 흐름
```
[검색 입력]
v2-table-search-widget
│ onFilterChange
v2-table-list (자동 재조회)
[데이터 표시]
```
### 4.2 연동 설정
| 소스 컴포넌트 | 이벤트/액션 | 대상 컴포넌트 | 동작 |
|---------------|-------------|---------------|------|
| search-widget | onFilterChange | main-table | 필터 적용 |
| btn-save | click | main-table | refreshTable |
---
## 5. 사용자 사용 예시 시나리오
### 시나리오 1: 데이터 조회
| 단계 | 사용자 동작 | 기대 결과 |
|------|-------------|-----------|
| 1 | 화면 진입 | 전체 목록 표시 |
| 2 | 검색어 입력 | 필터링된 결과 표시 |
| 3 | 정렬 클릭 | 정렬 순서 변경 |
### 시나리오 2: 데이터 등록
| 단계 | 사용자 동작 | 기대 결과 |
|------|-------------|-----------|
| 1 | [신규] 버튼 클릭 | 등록 모달/폼 표시 |
| 2 | 데이터 입력 | 입력 필드 채움 |
| 3 | [저장] 버튼 클릭 | 저장 완료, 목록 갱신 |
### 시나리오 3: 데이터 수정
| 단계 | 사용자 동작 | 기대 결과 |
|------|-------------|-----------|
| 1 | 행 더블클릭 | 수정 모달/폼 표시 |
| 2 | 데이터 수정 | 필드 값 변경 |
| 3 | [저장] 버튼 클릭 | 저장 완료, 목록 갱신 |
### 시나리오 4: 데이터 삭제
| 단계 | 사용자 동작 | 기대 결과 |
|------|-------------|-----------|
| 1 | 행 체크박스 선택 | 선택 표시 |
| 2 | [삭제] 버튼 클릭 | 삭제 확인 다이얼로그 |
| 3 | 확인 | 삭제 완료, 목록 갱신 |
---
## 6. 검증 체크리스트
- [ ] 데이터 조회가 정상 동작하는가?
- [ ] 검색 필터가 정상 동작하는가?
- [ ] 신규 등록이 정상 동작하는가?
- [ ] 수정이 정상 동작하는가?
- [ ] 삭제가 정상 동작하는가?
- [ ] 페이지네이션이 정상 동작하는가?
- [ ] 정렬이 정상 동작하는가?
---
## 7. 참고 사항
- 관련 화면: [관련 화면명](./related-screen.md)
- 특이 사항: 없음

View File

@ -8,6 +8,11 @@
* - modal: 엔티티 (FK ) +
*
* RepeaterTable ItemSelectionModal
*
* :
* - DataProvidable: 선택된
* - DataReceivable: 외부에서
* - repeaterDataChange
*/
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
@ -28,6 +33,13 @@ import { RepeaterTable } from "@/lib/registry/components/modal-repeater-table/Re
import { ItemSelectionModal } from "@/lib/registry/components/modal-repeater-table/ItemSelectionModal";
import { RepeaterColumnConfig } from "@/lib/registry/components/modal-repeater-table/types";
// 데이터 전달 인터페이스
import type { DataProvidable, DataReceivable, DataReceiverConfig } from "@/types/data-transfer";
import { useScreenContextOptional } from "@/contexts/ScreenContext";
// V2 이벤트 시스템
import { V2_EVENTS, dispatchV2Event } from "@/types/component-events";
// 전역 UnifiedRepeater 등록 (buttonActions에서 사용)
declare global {
interface Window {
@ -55,6 +67,9 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
[propConfig],
);
// ScreenContext (데이터 전달 인터페이스 등록용)
const screenContext = useScreenContextOptional();
// 상태
const [data, setData] = useState<any[]>(initialData || []);
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
@ -101,6 +116,123 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
};
}, [config.dataSource?.tableName]);
// ============================================================
// DataProvidable 인터페이스 구현
// 다른 컴포넌트에서 이 리피터의 데이터를 가져갈 수 있게 함
// ============================================================
const dataProvider: DataProvidable = useMemo(() => ({
componentId: parentId || config.fieldName || "unified-repeater",
componentType: "unified-repeater",
// 선택된 행 데이터 반환
getSelectedData: () => {
return Array.from(selectedRows).map((idx) => data[idx]).filter(Boolean);
},
// 전체 데이터 반환
getAllData: () => {
return [...data];
},
// 선택 초기화
clearSelection: () => {
setSelectedRows(new Set());
},
}), [parentId, config.fieldName, data, selectedRows]);
// ============================================================
// DataReceivable 인터페이스 구현
// 외부에서 이 리피터로 데이터를 전달받을 수 있게 함
// ============================================================
const dataReceiver: DataReceivable = useMemo(() => ({
componentId: parentId || config.fieldName || "unified-repeater",
componentType: "repeater",
// 데이터 수신 (append, replace, merge 모드 지원)
receiveData: async (incomingData: any[], receiverConfig: DataReceiverConfig) => {
if (!incomingData || incomingData.length === 0) return;
// 매핑 규칙 적용
const mappedData = incomingData.map((item, index) => {
const newRow: any = { _id: `received_${Date.now()}_${index}` };
if (receiverConfig.mappingRules && receiverConfig.mappingRules.length > 0) {
receiverConfig.mappingRules.forEach((rule) => {
const sourceValue = item[rule.sourceField];
newRow[rule.targetField] = sourceValue !== undefined ? sourceValue : rule.defaultValue;
});
} else {
// 매핑 규칙 없으면 그대로 복사
Object.assign(newRow, item);
}
return newRow;
});
// 모드에 따라 데이터 처리
switch (receiverConfig.mode) {
case "replace":
setData(mappedData);
onDataChange?.(mappedData);
break;
case "merge":
// 중복 제거 후 병합 (id 또는 _id 기준)
const existingIds = new Set(data.map((row) => row.id || row._id));
const newItems = mappedData.filter((row) => !existingIds.has(row.id || row._id));
const mergedData = [...data, ...newItems];
setData(mergedData);
onDataChange?.(mergedData);
break;
case "append":
default:
const appendedData = [...data, ...mappedData];
setData(appendedData);
onDataChange?.(appendedData);
break;
}
},
// 현재 데이터 반환
getData: () => {
return [...data];
},
}), [parentId, config.fieldName, data, onDataChange]);
// ============================================================
// ScreenContext에 DataProvider/DataReceiver 등록
// ============================================================
useEffect(() => {
if (screenContext && (parentId || config.fieldName)) {
const componentId = parentId || config.fieldName || "unified-repeater";
screenContext.registerDataProvider(componentId, dataProvider);
screenContext.registerDataReceiver(componentId, dataReceiver);
return () => {
screenContext.unregisterDataProvider(componentId);
screenContext.unregisterDataReceiver(componentId);
};
}
}, [screenContext, parentId, config.fieldName, dataProvider, dataReceiver]);
// ============================================================
// repeaterDataChange 이벤트 발행
// 데이터 변경 시 다른 컴포넌트(aggregation-widget 등)에 알림
// ============================================================
const prevDataLengthRef = useRef(data.length);
useEffect(() => {
// 데이터가 변경되었을 때만 이벤트 발행
if (typeof window !== "undefined" && data.length !== prevDataLengthRef.current) {
dispatchV2Event(V2_EVENTS.REPEATER_DATA_CHANGE, {
componentId: parentId || config.fieldName || "unified-repeater",
tableName: config.dataSource?.tableName || "",
data: data,
selectedData: Array.from(selectedRows).map((idx) => data[idx]).filter(Boolean),
});
prevDataLengthRef.current = data.length;
}
}, [data, selectedRows, parentId, config.fieldName, config.dataSource?.tableName]);
// 저장 이벤트 리스너
useEffect(() => {
const handleSaveEvent = async (event: CustomEvent) => {

View File

@ -8,6 +8,9 @@ import { cn } from "@/lib/utils";
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
import { apiClient } from "@/lib/api/client";
// V2 이벤트 시스템
import { V2_EVENTS, subscribeV2Event, type TableListDataChangeDetail, type RepeaterDataChangeDetail } from "@/types/component-events";
interface AggregationWidgetComponentProps extends ComponentRendererProps {
config?: AggregationWidgetConfig;
// 외부에서 데이터를 직접 전달받을 수 있음
@ -269,19 +272,19 @@ export function AggregationWidgetComponent({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataSourceType, selectedRowsKey]);
// ============================================================
// 전역 선택 이벤트 수신 (dataSourceType === "selection"일 때)
// V2 표준 이벤트만 사용 (중복 이벤트 제거됨)
// ============================================================
useEffect(() => {
if (dataSourceType !== "selection" || isDesignMode) return;
// 테이블리스트에서 발생하는 선택 이벤트 수신
// tableListDataChange 이벤트의 data가 선택된 행들임
const handleTableListDataChange = (event: CustomEvent) => {
const { data: eventData, selectedRows: eventSelectedRows } = event.detail || {};
// data가 선택된 행 데이터 배열
// 테이블리스트 데이터 변경 이벤트 (V2 표준)
const handleTableListDataChange = (event: CustomEvent<TableListDataChangeDetail>) => {
const { data: eventData } = event.detail || {};
const rows = eventData || [];
if (Array.isArray(rows)) {
// 필터 적용
const filteredData = applyFilters(
rows,
filtersRef.current || [],
@ -293,8 +296,8 @@ export function AggregationWidgetComponent({
}
};
// 리피터에서 발생하는 이벤트
const handleRepeaterDataChange = (event: CustomEvent) => {
// 리피터 데이터 변경 이벤트 (V2 표준)
const handleRepeaterDataChange = (event: CustomEvent<RepeaterDataChangeDetail>) => {
const { data: eventData, selectedData } = event.detail || {};
const rows = selectedData || eventData || [];
@ -310,38 +313,13 @@ export function AggregationWidgetComponent({
}
};
// 일반 선택 이벤트
const handleSelectionChange = (event: CustomEvent) => {
const { selectedRows: eventSelectedRows, selectedData, checkedRows, selectedItems } = event.detail || {};
const rows = selectedData || eventSelectedRows || checkedRows || selectedItems || [];
if (Array.isArray(rows)) {
const filteredData = applyFilters(
rows,
filtersRef.current || [],
filterLogic,
formDataRef.current,
selectedRowsRef.current
);
setData(filteredData);
}
};
// 다양한 선택 이벤트 수신
window.addEventListener("tableListDataChange" as any, handleTableListDataChange);
window.addEventListener("repeaterDataChange" as any, handleRepeaterDataChange);
window.addEventListener("selectionChange" as any, handleSelectionChange);
window.addEventListener("tableSelectionChange" as any, handleSelectionChange);
window.addEventListener("rowSelectionChange" as any, handleSelectionChange);
window.addEventListener("checkboxSelectionChange" as any, handleSelectionChange);
// V2 표준 이벤트만 구독 (중복 이벤트 제거)
const unsubscribeTableList = subscribeV2Event(V2_EVENTS.TABLE_LIST_DATA_CHANGE, handleTableListDataChange);
const unsubscribeRepeater = subscribeV2Event(V2_EVENTS.REPEATER_DATA_CHANGE, handleRepeaterDataChange);
return () => {
window.removeEventListener("tableListDataChange" as any, handleTableListDataChange);
window.removeEventListener("repeaterDataChange" as any, handleRepeaterDataChange);
window.removeEventListener("selectionChange" as any, handleSelectionChange);
window.removeEventListener("tableSelectionChange" as any, handleSelectionChange);
window.removeEventListener("rowSelectionChange" as any, handleSelectionChange);
window.removeEventListener("checkboxSelectionChange" as any, handleSelectionChange);
unsubscribeTableList();
unsubscribeRepeater();
};
}, [dataSourceType, isDesignMode, filterLogic]);
@ -362,14 +340,17 @@ export function AggregationWidgetComponent({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [externalDataKey, filterLogic]);
// ============================================================
// 컴포넌트 데이터 변경 이벤트 리스닝 (dataSourceType === "component"일 때)
// V2 표준 이벤트만 사용
// ============================================================
useEffect(() => {
if (dataSourceType !== "component" || !dataSourceComponentId || isDesignMode) return;
const handleDataChange = (event: CustomEvent) => {
// 테이블 리스트 데이터 변경
const handleTableListDataChange = (event: CustomEvent<TableListDataChangeDetail>) => {
const { componentId, data: eventData } = event.detail || {};
if (componentId === dataSourceComponentId && Array.isArray(eventData)) {
// 필터 적용
const filteredData = applyFilters(
eventData,
filtersRef.current || [],
@ -381,33 +362,31 @@ export function AggregationWidgetComponent({
}
};
// 선택 변경 이벤트 (체크박스 선택 등)
const handleSelectionChange = (event: CustomEvent) => {
const { componentId, selectedData } = event.detail || {};
if (componentId === dataSourceComponentId && Array.isArray(selectedData)) {
// 선택된 데이터만 집계
const filteredData = applyFilters(
selectedData,
filtersRef.current || [],
filterLogic,
formDataRef.current,
selectedRowsRef.current
);
setData(filteredData);
// 리피터 데이터 변경
const handleRepeaterDataChange = (event: CustomEvent<RepeaterDataChangeDetail>) => {
const { componentId, data: eventData, selectedData } = event.detail || {};
if (componentId === dataSourceComponentId) {
const rows = selectedData || eventData || [];
if (Array.isArray(rows)) {
const filteredData = applyFilters(
rows,
filtersRef.current || [],
filterLogic,
formDataRef.current,
selectedRowsRef.current
);
setData(filteredData);
}
}
};
// 리피터 데이터 변경 이벤트
window.addEventListener("repeaterDataChange" as any, handleDataChange);
// 테이블 리스트 데이터 변경 이벤트
window.addEventListener("tableListDataChange" as any, handleDataChange);
// 선택 변경 이벤트
window.addEventListener("selectionChange" as any, handleSelectionChange);
// V2 표준 이벤트만 구독
const unsubscribeTableList = subscribeV2Event(V2_EVENTS.TABLE_LIST_DATA_CHANGE, handleTableListDataChange);
const unsubscribeRepeater = subscribeV2Event(V2_EVENTS.REPEATER_DATA_CHANGE, handleRepeaterDataChange);
return () => {
window.removeEventListener("repeaterDataChange" as any, handleDataChange);
window.removeEventListener("tableListDataChange" as any, handleDataChange);
window.removeEventListener("selectionChange" as any, handleSelectionChange);
unsubscribeTableList();
unsubscribeRepeater();
};
}, [dataSourceType, dataSourceComponentId, isDesignMode, filterLogic]);

View File

@ -8,6 +8,9 @@ import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import DynamicComponentRenderer from "@/lib/registry/DynamicComponentRenderer";
// V2 이벤트 시스템
import { V2_EVENTS, subscribeV2Event, type TableListDataChangeDetail, type RepeaterDataChangeDetail } from "@/types/component-events";
interface RepeatContainerComponentProps extends ComponentRendererProps {
config?: RepeatContainerConfig;
// 외부에서 데이터를 직접 전달받을 수 있음
@ -136,7 +139,10 @@ export function RepeatContainerComponent({
}
}, [externalData]);
// 컴포넌트 데이터 변경 이벤트 리스닝 (componentId 또는 tableName으로 매칭)
// ============================================================
// 컴포넌트 데이터 변경 이벤트 리스닝 (V2 표준 이벤트)
// componentId 또는 tableName으로 매칭
// ============================================================
useEffect(() => {
if (isDesignMode) return;
@ -147,19 +153,12 @@ export function RepeatContainerComponent({
effectiveTableName,
});
// dataSourceComponentId가 없어도 테이블명으로 매칭 가능
const handleDataChange = (event: CustomEvent) => {
const { componentId, tableName: eventTableName, data: eventData } = event.detail || {};
console.log("📩 리피터 컨테이너 이벤트 수신:", {
eventType: event.type,
fromComponentId: componentId,
fromTableName: eventTableName,
dataCount: Array.isArray(eventData) ? eventData.length : 0,
myDataSourceComponentId: dataSourceComponentId,
myEffectiveTableName: effectiveTableName,
});
// 공통 데이터 처리 함수
const processIncomingData = (
componentId: string | undefined,
eventTableName: string | undefined,
eventData: any[]
) => {
// 1. 명시적으로 dataSourceComponentId가 설정된 경우 해당 컴포넌트만 매칭
if (dataSourceComponentId) {
if (componentId === dataSourceComponentId && Array.isArray(eventData)) {
@ -167,8 +166,6 @@ export function RepeatContainerComponent({
setData(eventData);
setCurrentPage(1);
setSelectedIndices([]);
} else {
console.log("⚠️ 리피터: 컴포넌트 ID 불일치로 무시", { expected: dataSourceComponentId, received: componentId });
}
return;
}
@ -179,17 +176,28 @@ export function RepeatContainerComponent({
setData(eventData);
setCurrentPage(1);
setSelectedIndices([]);
} else if (effectiveTableName) {
console.log("⚠️ 리피터: 테이블명 불일치로 무시", { expected: effectiveTableName, received: eventTableName });
}
};
window.addEventListener("repeaterDataChange" as any, handleDataChange);
window.addEventListener("tableListDataChange" as any, handleDataChange);
// 테이블 리스트 데이터 변경 이벤트 (V2 표준)
const handleTableListDataChange = (event: CustomEvent<TableListDataChangeDetail>) => {
const { componentId, tableName: eventTableName, data: eventData } = event.detail || {};
processIncomingData(componentId, eventTableName, eventData);
};
// 리피터 데이터 변경 이벤트 (V2 표준)
const handleRepeaterDataChange = (event: CustomEvent<RepeaterDataChangeDetail>) => {
const { componentId, tableName: eventTableName, data: eventData } = event.detail || {};
processIncomingData(componentId, eventTableName, eventData);
};
// V2 표준 이벤트 구독
const unsubscribeTableList = subscribeV2Event(V2_EVENTS.TABLE_LIST_DATA_CHANGE, handleTableListDataChange);
const unsubscribeRepeater = subscribeV2Event(V2_EVENTS.REPEATER_DATA_CHANGE, handleRepeaterDataChange);
return () => {
window.removeEventListener("repeaterDataChange" as any, handleDataChange);
window.removeEventListener("tableListDataChange" as any, handleDataChange);
unsubscribeTableList();
unsubscribeRepeater();
};
}, [component?.id, dataSourceType, dataSourceComponentId, effectiveTableName, isDesignMode]);

View File

@ -0,0 +1,241 @@
/**
* V2
*
* V2 .
* / .
*/
// ============================================================
// 이벤트 상세 데이터 타입 (event.detail)
// ============================================================
/**
*
* 발행: v2-table-list
* 구독: v2-aggregation-widget, v2-repeat-container
*/
export interface TableListDataChangeDetail {
componentId: string;
tableName: string;
data: any[];
selectedRows: string[] | number[];
}
/**
*
* 발행: v2-unified-repeater
* 구독: v2-aggregation-widget, v2-repeat-container
*/
export interface RepeaterDataChangeDetail {
componentId: string;
tableName: string;
data: any[];
selectedData?: any[];
}
/**
*
* 발행: buttonActions, UnifiedFormContext
* 구독: v2-unified-repeater, simple-repeater-table, modal-repeater-table
*/
export interface BeforeFormSaveDetail {
formData: Record<string, any>;
skipDefaultSave?: boolean;
}
/**
*
* 발행: UnifiedFormContext
* 구독: 저장
*/
export interface AfterFormSaveDetail {
success: boolean;
data?: any;
error?: string;
}
/**
* (- FK )
* 발행: InteractiveScreenViewerDynamic
* 구독: v2-unified-repeater
*/
export interface RepeaterSaveDetail {
parentId?: string | number;
masterRecordId: string | number;
mainFormData: Record<string, any>;
tableName: string;
}
/**
*
* 발행: v2-button-primary, buttonActions
* 구독: v2-table-list, v2-split-panel-layout
*/
export interface RefreshTableDetail {
tableName?: string;
componentId?: string;
}
/**
*
* 발행: buttonActions, InteractiveScreenViewerDynamic
* 구독: v2-card-display
*/
export interface RefreshCardDisplayDetail {
componentId?: string;
}
/**
*
* 발행: buttonActions
* 구독: v2-unified-repeater
*/
export interface ComponentDataTransferDetail {
sourceComponentId: string;
targetComponentId: string;
data: any[];
mode: "append" | "replace" | "merge";
mappingRules?: Array<{
sourceField: string;
targetField: string;
defaultValue?: any;
}>;
}
/**
*
* 발행: buttonActions
* 구독: v2-unified-repeater, repeater-field-group
*/
export interface SplitPanelDataTransferDetail {
sourcePosition: "left" | "right";
targetPosition: "left" | "right";
data: any[];
mode: "append" | "replace" | "merge";
mappingRules?: Array<{
sourceField: string;
targetField: string;
defaultValue?: any;
}>;
}
/**
*
* 발행: related-data-buttons
* 구독: v2-table-list
*/
export interface RelatedButtonSelectDetail {
targetTable: string;
filterColumn: string;
filterValue: any;
selectedData?: any;
}
/**
*
*/
export interface EditModalDetail {
screenId?: number;
recordId?: string | number;
data?: any;
}
// ============================================================
// 이벤트 이름 상수
// ============================================================
export const V2_EVENTS = {
// 데이터 변경 이벤트
TABLE_LIST_DATA_CHANGE: "tableListDataChange",
REPEATER_DATA_CHANGE: "repeaterDataChange",
// 폼 저장 이벤트
BEFORE_FORM_SAVE: "beforeFormSave",
AFTER_FORM_SAVE: "afterFormSave",
REPEATER_SAVE: "repeaterSave",
// UI 갱신 이벤트
REFRESH_TABLE: "refreshTable",
REFRESH_CARD_DISPLAY: "refreshCardDisplay",
// 데이터 전달 이벤트
COMPONENT_DATA_TRANSFER: "componentDataTransfer",
SPLIT_PANEL_DATA_TRANSFER: "splitPanelDataTransfer",
// 모달 제어 이벤트
OPEN_EDIT_MODAL: "openEditModal",
CLOSE_EDIT_MODAL: "closeEditModal",
SAVE_SUCCESS_IN_MODAL: "saveSuccessInModal",
// 연관 데이터 버튼 이벤트
RELATED_BUTTON_SELECT: "related-button-select",
RELATED_BUTTON_REGISTER: "related-button-register",
RELATED_BUTTON_UNREGISTER: "related-button-unregister",
} as const;
// ============================================================
// Window EventMap 확장 (타입 안전한 이벤트 리스너)
// ============================================================
declare global {
interface WindowEventMap {
// 데이터 변경 이벤트
[V2_EVENTS.TABLE_LIST_DATA_CHANGE]: CustomEvent<TableListDataChangeDetail>;
[V2_EVENTS.REPEATER_DATA_CHANGE]: CustomEvent<RepeaterDataChangeDetail>;
// 폼 저장 이벤트
[V2_EVENTS.BEFORE_FORM_SAVE]: CustomEvent<BeforeFormSaveDetail>;
[V2_EVENTS.AFTER_FORM_SAVE]: CustomEvent<AfterFormSaveDetail>;
[V2_EVENTS.REPEATER_SAVE]: CustomEvent<RepeaterSaveDetail>;
// UI 갱신 이벤트
[V2_EVENTS.REFRESH_TABLE]: CustomEvent<RefreshTableDetail>;
[V2_EVENTS.REFRESH_CARD_DISPLAY]: CustomEvent<RefreshCardDisplayDetail>;
// 데이터 전달 이벤트
[V2_EVENTS.COMPONENT_DATA_TRANSFER]: CustomEvent<ComponentDataTransferDetail>;
[V2_EVENTS.SPLIT_PANEL_DATA_TRANSFER]: CustomEvent<SplitPanelDataTransferDetail>;
// 연관 데이터 버튼 이벤트
[V2_EVENTS.RELATED_BUTTON_SELECT]: CustomEvent<RelatedButtonSelectDetail>;
}
}
// ============================================================
// 유틸리티 함수
// ============================================================
/**
*
*/
export function dispatchV2Event<K extends keyof WindowEventMap>(
eventName: K,
detail: WindowEventMap[K] extends CustomEvent<infer D> ? D : never
): void {
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent(eventName, { detail }));
}
}
/**
*
*/
export function subscribeV2Event<K extends keyof WindowEventMap>(
eventName: K,
handler: (event: WindowEventMap[K]) => void
): () => void {
if (typeof window === "undefined") {
return () => {};
}
window.addEventListener(eventName, handler as EventListener);
return () => {
window.removeEventListener(eventName, handler as EventListener);
};
}
// ============================================================
// 내보내기
// ============================================================
export type V2EventName = typeof V2_EVENTS[keyof typeof V2_EVENTS];

BIN
my_layout.json Normal file

Binary file not shown.

View File

@ -0,0 +1,116 @@
@echo off
chcp 65001 >nul
setlocal EnableDelayedExpansion
REM 스크립트가 있는 디렉토리에서 루트로 이동
cd /d "%~dp0\..\.."
REM 시작 시간 기록
set START_TIME=%DATE% %TIME%
echo ============================================
echo WACE 솔루션 - 전체 서비스 시작 (병렬 최적화)
echo ============================================
echo [시작 시간] %START_TIME%
echo.
REM Docker Desktop 실행 확인
echo [1/5] Docker Desktop 상태 확인 중...
docker --version >nul 2>&1
if %errorlevel% neq 0 (
echo [ERROR] Docker Desktop이 실행되지 않았습니다!
echo Docker Desktop을 먼저 실행해주세요.
pause
exit /b 1
)
echo [OK] Docker Desktop이 실행 중입니다.
echo.
REM 기존 컨테이너 정리
echo [2/5] 기존 컨테이너 정리 중...
docker rm -f pms-backend-win pms-frontend-win 2>nul
docker network rm pms-network 2>nul
docker network create pms-network 2>nul
echo [OK] 컨테이너 정리 완료
echo.
REM 병렬 빌드 (docker-compose 자체가 병렬 처리)
echo [3/5] 이미지 빌드 중... (백엔드 + 프론트엔드 병렬)
echo 이 작업은 시간이 걸릴 수 있습니다...
echo.
REM 백엔드 빌드
docker-compose -f docker-compose.backend.win.yml build
if %errorlevel% neq 0 (
echo [ERROR] 백엔드 빌드 실패!
pause
exit /b 1
)
echo [OK] 백엔드 빌드 완료
echo.
REM 프론트엔드 빌드
docker-compose -f docker-compose.frontend.win.yml build
if %errorlevel% neq 0 (
echo [ERROR] 프론트엔드 빌드 실패!
pause
exit /b 1
)
echo [OK] 프론트엔드 빌드 완료
echo.
REM 기존 컨테이너 정리 후 서비스 시작
echo [4/5] 서비스 시작 중...
docker-compose -f docker-compose.backend.win.yml down -v 2>nul
docker-compose -f docker-compose.frontend.win.yml down -v 2>nul
REM 백엔드 시작
echo 백엔드 서비스 시작...
docker-compose -f docker-compose.backend.win.yml up -d
if %errorlevel% neq 0 (
echo [ERROR] 백엔드 시작 실패!
pause
exit /b 1
)
REM 프론트엔드 시작
echo 프론트엔드 서비스 시작...
docker-compose -f docker-compose.frontend.win.yml up -d
if %errorlevel% neq 0 (
echo [ERROR] 프론트엔드 시작 실패!
pause
exit /b 1
)
echo [OK] 서비스 시작 완료
echo.
REM 안정화 대기
echo [5/5] 서비스 안정화 대기 중... (10초)
timeout /t 10 /nobreak >nul
echo.
echo ============================================
echo [완료] 모든 서비스가 시작되었습니다!
echo ============================================
echo.
echo [DATABASE] PostgreSQL: http://39.117.244.52:11132
echo [BACKEND] Node.js API: http://localhost:8080/api
echo [FRONTEND] Next.js: http://localhost:9771
echo.
echo [서비스 상태 확인]
echo docker-compose -f docker-compose.backend.win.yml ps
echo docker-compose -f docker-compose.frontend.win.yml ps
echo.
echo [로그 확인]
echo 백엔드: docker-compose -f docker-compose.backend.win.yml logs -f
echo 프론트엔드: docker-compose -f docker-compose.frontend.win.yml logs -f
echo.
echo [서비스 중지]
echo scripts\dev\stop-all.bat
echo.
set END_TIME=%DATE% %TIME%
echo [종료 시간] %END_TIME%
echo ============================================
pause

View File

@ -0,0 +1,183 @@
# WACE 솔루션 - 전체 서비스 시작 (병렬 최적화) - PowerShell 버전
# 실행 방법: powershell -ExecutionPolicy Bypass -File .\scripts\dev\start-all-parallel.ps1
# UTF-8 출력 설정
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$OutputEncoding = [System.Text.Encoding]::UTF8
# 스크립트 위치에서 루트 디렉토리로 이동
$scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
Set-Location (Join-Path $scriptPath "..\..")
# 시작 시간 기록
$startTime = Get-Date
$startTimeFormatted = $startTime.ToString("yyyy-MM-dd HH:mm:ss")
Write-Host ""
Write-Host "============================================" -ForegroundColor Cyan
Write-Host "WACE 솔루션 - 전체 서비스 시작 (병렬 최적화)" -ForegroundColor Cyan
Write-Host "============================================" -ForegroundColor Cyan
Write-Host "[시작 시간] $startTimeFormatted" -ForegroundColor Yellow
Write-Host ""
# Docker Desktop 실행 확인
Write-Host "[1/5] Docker Desktop 상태 확인 중..." -ForegroundColor White
$dockerCheck = docker --version 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Host "[ERROR] Docker Desktop이 실행되지 않았습니다!" -ForegroundColor Red
Write-Host " Docker Desktop을 먼저 실행해주세요." -ForegroundColor Red
Read-Host "계속하려면 Enter를 누르세요"
exit 1
}
Write-Host "[OK] Docker Desktop이 실행 중입니다." -ForegroundColor Green
Write-Host ""
# 기존 컨테이너 정리
Write-Host "[2/5] 기존 컨테이너 정리 중..." -ForegroundColor White
docker rm -f pms-backend-win pms-frontend-win 2>$null | Out-Null
docker network rm pms-network 2>$null | Out-Null
docker network create pms-network 2>$null | Out-Null
Write-Host "[OK] 컨테이너 정리 완료" -ForegroundColor Green
Write-Host ""
# 병렬 빌드 시작
$parallelStart = Get-Date
Write-Host "[3/5] 이미지 빌드 중... (백엔드 + 프론트엔드 병렬)" -ForegroundColor White
Write-Host " 이 작업은 시간이 걸릴 수 있습니다..." -ForegroundColor Gray
Write-Host ""
# 병렬 빌드 실행
$backendBuildJob = Start-Job -ScriptBlock {
param($workDir)
Set-Location $workDir
$output = docker-compose -f docker-compose.backend.win.yml build 2>&1
return @{
Success = $LASTEXITCODE -eq 0
Output = $output
}
} -ArgumentList $PWD.Path
$frontendBuildJob = Start-Job -ScriptBlock {
param($workDir)
Set-Location $workDir
$output = docker-compose -f docker-compose.frontend.win.yml build 2>&1
return @{
Success = $LASTEXITCODE -eq 0
Output = $output
}
} -ArgumentList $PWD.Path
Write-Host " 백엔드 빌드 진행 중..." -ForegroundColor Gray
Write-Host " 프론트엔드 빌드 진행 중..." -ForegroundColor Gray
Write-Host ""
# 빌드 완료 대기
$null = Wait-Job -Job $backendBuildJob, $frontendBuildJob
$backendResult = Receive-Job -Job $backendBuildJob
$frontendResult = Receive-Job -Job $frontendBuildJob
Remove-Job -Job $backendBuildJob, $frontendBuildJob -Force
# 빌드 결과 확인
$buildFailed = $false
if ($backendResult.Success) {
Write-Host "[OK] 백엔드 빌드 완료" -ForegroundColor Green
} else {
Write-Host "[ERROR] 백엔드 빌드 실패!" -ForegroundColor Red
Write-Host $backendResult.Output -ForegroundColor Red
$buildFailed = $true
}
if ($frontendResult.Success) {
Write-Host "[OK] 프론트엔드 빌드 완료" -ForegroundColor Green
} else {
Write-Host "[ERROR] 프론트엔드 빌드 실패!" -ForegroundColor Red
Write-Host $frontendResult.Output -ForegroundColor Red
$buildFailed = $true
}
if ($buildFailed) {
Read-Host "빌드 실패. Enter를 누르면 종료됩니다"
exit 1
}
$parallelEnd = Get-Date
$parallelDuration = ($parallelEnd - $parallelStart).TotalSeconds
Write-Host "[INFO] 빌드 소요 시간: $([math]::Round($parallelDuration))" -ForegroundColor Yellow
Write-Host ""
# 서비스 시작
$serviceStart = Get-Date
Write-Host "[4/5] 서비스 시작 중..." -ForegroundColor White
# 기존 컨테이너 정리
docker-compose -f docker-compose.backend.win.yml down -v 2>$null | Out-Null
docker-compose -f docker-compose.frontend.win.yml down -v 2>$null | Out-Null
# 백엔드 시작
Write-Host " 백엔드 서비스 시작..." -ForegroundColor Gray
docker-compose -f docker-compose.backend.win.yml up -d 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Host "[ERROR] 백엔드 시작 실패!" -ForegroundColor Red
Read-Host "계속하려면 Enter를 누르세요"
exit 1
}
# 프론트엔드 시작
Write-Host " 프론트엔드 서비스 시작..." -ForegroundColor Gray
docker-compose -f docker-compose.frontend.win.yml up -d 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Host "[ERROR] 프론트엔드 시작 실패!" -ForegroundColor Red
Read-Host "계속하려면 Enter를 누르세요"
exit 1
}
Write-Host "[OK] 서비스 시작 완료" -ForegroundColor Green
$serviceEnd = Get-Date
$serviceDuration = ($serviceEnd - $serviceStart).TotalSeconds
Write-Host "[INFO] 서비스 시작 소요 시간: $([math]::Round($serviceDuration))" -ForegroundColor Yellow
Write-Host ""
# 안정화 대기
Write-Host "[5/5] 서비스 안정화 대기 중... (10초)" -ForegroundColor White
Start-Sleep -Seconds 10
Write-Host ""
Write-Host "============================================" -ForegroundColor Cyan
Write-Host "[완료] 모든 서비스가 시작되었습니다!" -ForegroundColor Green
Write-Host "============================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "[DATABASE] PostgreSQL: http://39.117.244.52:11132" -ForegroundColor White
Write-Host "[BACKEND] Node.js API: http://localhost:8080/api" -ForegroundColor White
Write-Host "[FRONTEND] Next.js: http://localhost:9771" -ForegroundColor White
Write-Host ""
Write-Host "[서비스 상태 확인]" -ForegroundColor Yellow
Write-Host " docker-compose -f docker-compose.backend.win.yml ps" -ForegroundColor Gray
Write-Host " docker-compose -f docker-compose.frontend.win.yml ps" -ForegroundColor Gray
Write-Host ""
Write-Host "[로그 확인]" -ForegroundColor Yellow
Write-Host " 백엔드: docker-compose -f docker-compose.backend.win.yml logs -f" -ForegroundColor Gray
Write-Host " 프론트엔드: docker-compose -f docker-compose.frontend.win.yml logs -f" -ForegroundColor Gray
Write-Host ""
Write-Host "[서비스 중지]" -ForegroundColor Yellow
Write-Host " .\scripts\dev\stop-all.ps1" -ForegroundColor Gray
Write-Host ""
# 종료 시간 계산
$endTime = Get-Date
$endTimeFormatted = $endTime.ToString("yyyy-MM-dd HH:mm:ss")
$totalDuration = ($endTime - $startTime).TotalSeconds
$minutes = [math]::Floor($totalDuration / 60)
$seconds = [math]::Round($totalDuration % 60)
Write-Host "============================================" -ForegroundColor Cyan
Write-Host "[종료 시간] $endTimeFormatted" -ForegroundColor Yellow
Write-Host "[총 소요 시간] ${minutes}${seconds}" -ForegroundColor Yellow
Write-Host " - 빌드: $([math]::Round($parallelDuration))" -ForegroundColor Gray
Write-Host " - 서비스 시작: $([math]::Round($serviceDuration))" -ForegroundColor Gray
Write-Host "============================================" -ForegroundColor Cyan
Read-Host "계속하려면 Enter를 누르세요"

30
scripts/dev/stop-all.bat Normal file
View File

@ -0,0 +1,30 @@
@echo off
chcp 65001 >nul
REM 스크립트가 있는 디렉토리에서 루트로 이동
cd /d "%~dp0\..\.."
echo ============================================
echo WACE 솔루션 - 전체 서비스 중지
echo ============================================
echo.
echo 🛑 백엔드 서비스 중지 중...
docker-compose -f docker-compose.backend.win.yml down -v 2>nul
echo ✅ 백엔드 서비스 중지 완료
echo.
echo 🛑 프론트엔드 서비스 중지 중...
docker-compose -f docker-compose.frontend.win.yml down -v 2>nul
echo ✅ 프론트엔드 서비스 중지 완료
echo.
echo 🧹 네트워크 정리 중...
docker network rm pms-network 2>nul
echo.
echo ============================================
echo 🎉 모든 서비스가 중지되었습니다!
echo ============================================
pause

33
scripts/dev/stop-all.ps1 Normal file
View File

@ -0,0 +1,33 @@
# WACE 솔루션 - 전체 서비스 중지 - PowerShell 버전
# 실행 방법: powershell -ExecutionPolicy Bypass -File .\scripts\dev\stop-all.ps1
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
# 스크립트 위치에서 루트 디렉토리로 이동
$scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
Set-Location (Join-Path $scriptPath "..\..")
Write-Host "============================================" -ForegroundColor Cyan
Write-Host "WACE 솔루션 - 전체 서비스 중지" -ForegroundColor Cyan
Write-Host "============================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "🛑 백엔드 서비스 중지 중..." -ForegroundColor Yellow
docker-compose -f docker-compose.backend.win.yml down -v 2>$null
Write-Host "✅ 백엔드 서비스 중지 완료" -ForegroundColor Green
Write-Host ""
Write-Host "🛑 프론트엔드 서비스 중지 중..." -ForegroundColor Yellow
docker-compose -f docker-compose.frontend.win.yml down -v 2>$null
Write-Host "✅ 프론트엔드 서비스 중지 완료" -ForegroundColor Green
Write-Host ""
Write-Host "🧹 네트워크 정리 중..." -ForegroundColor Yellow
docker network rm pms-network 2>$null
Write-Host ""
Write-Host "============================================" -ForegroundColor Cyan
Write-Host "🎉 모든 서비스가 중지되었습니다!" -ForegroundColor Green
Write-Host "============================================" -ForegroundColor Cyan
Read-Host "계속하려면 Enter를 누르세요"

BIN
working_layout.json Normal file

Binary file not shown.