Compare commits
7 Commits
924c95ab89
...
7a9ec8d02c
| Author | SHA1 | Date |
|---|---|---|
|
|
7a9ec8d02c | |
|
|
6cd416fdaa | |
|
|
3803b7dce1 | |
|
|
3fca677f3d | |
|
|
314d80ccf0 | |
|
|
42ad8cddb3 | |
|
|
12128f278c |
|
|
@ -4690,11 +4690,10 @@ export class ScreenManagementService {
|
||||||
throw new Error("이 화면의 레이아웃을 저장할 권한이 없습니다.");
|
throw new Error("이 화면의 레이아웃을 저장할 권한이 없습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 버전 정보 추가
|
// 버전 정보 추가 (updatedAt은 DB 컬럼 updated_at으로 관리)
|
||||||
const dataToSave = {
|
const dataToSave = {
|
||||||
version: "2.0",
|
version: "2.0",
|
||||||
...layoutData,
|
...layoutData
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// UPSERT (있으면 업데이트, 없으면 삽입)
|
// UPSERT (있으면 업데이트, 없으면 삽입)
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ services:
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile.dev
|
dockerfile: ../docker/dev/frontend.Dockerfile
|
||||||
container_name: pms-frontend-win
|
container_name: pms-frontend-win
|
||||||
ports:
|
ports:
|
||||||
- "9771:3000"
|
- "9771:3000"
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
- 특이 사항: 없음
|
||||||
|
|
@ -8,6 +8,11 @@
|
||||||
* - modal: 엔티티 선택 (FK 저장) + 추가 입력 컬럼
|
* - modal: 엔티티 선택 (FK 저장) + 추가 입력 컬럼
|
||||||
*
|
*
|
||||||
* RepeaterTable 및 ItemSelectionModal 재사용
|
* RepeaterTable 및 ItemSelectionModal 재사용
|
||||||
|
*
|
||||||
|
* 데이터 전달 인터페이스:
|
||||||
|
* - DataProvidable: 선택된 데이터 제공
|
||||||
|
* - DataReceivable: 외부에서 데이터 수신
|
||||||
|
* - repeaterDataChange 이벤트 발행
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||||
|
|
@ -29,6 +34,13 @@ import { RepeaterTable } from "@/lib/registry/components/modal-repeater-table/Re
|
||||||
import { ItemSelectionModal } from "@/lib/registry/components/modal-repeater-table/ItemSelectionModal";
|
import { ItemSelectionModal } from "@/lib/registry/components/modal-repeater-table/ItemSelectionModal";
|
||||||
import { RepeaterColumnConfig } from "@/lib/registry/components/modal-repeater-table/types";
|
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에서 사용)
|
// 전역 UnifiedRepeater 등록 (buttonActions에서 사용)
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
|
|
@ -56,6 +68,9 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
||||||
[propConfig],
|
[propConfig],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ScreenContext (데이터 전달 인터페이스 등록용)
|
||||||
|
const screenContext = useScreenContextOptional();
|
||||||
|
|
||||||
// 상태
|
// 상태
|
||||||
const [data, setData] = useState<any[]>(initialData || []);
|
const [data, setData] = useState<any[]>(initialData || []);
|
||||||
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||||||
|
|
@ -105,6 +120,123 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
||||||
};
|
};
|
||||||
}, [config.useCustomTable, config.mainTableName, config.dataSource?.tableName]);
|
}, [config.useCustomTable, config.mainTableName, 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(() => {
|
useEffect(() => {
|
||||||
const handleSaveEvent = async (event: CustomEvent) => {
|
const handleSaveEvent = async (event: CustomEvent) => {
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,7 @@ import "./v2-location-swap-selector/LocationSwapSelectorRenderer";
|
||||||
import "./v2-table-search-widget";
|
import "./v2-table-search-widget";
|
||||||
import "./v2-tabs-widget/tabs-component";
|
import "./v2-tabs-widget/tabs-component";
|
||||||
import "./v2-category-manager/V2CategoryManagerRenderer";
|
import "./v2-category-manager/V2CategoryManagerRenderer";
|
||||||
|
import "./v2-media"; // 통합 미디어 컴포넌트
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 컴포넌트 초기화 함수
|
* 컴포넌트 초기화 함수
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
/**
|
||||||
|
* V2Media 컴포넌트 정의
|
||||||
|
*
|
||||||
|
* 파일, 이미지, 비디오, 오디오 등 다양한 미디어 타입을 지원하는 통합 미디어 컴포넌트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ComponentCategory } from "@/types/component";
|
||||||
|
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||||
|
import { V2MediaConfigPanel } from "@/components/v2/config-panels/V2MediaConfigPanel";
|
||||||
|
import { V2Media } from "@/components/v2/V2Media";
|
||||||
|
|
||||||
|
export const V2MediaDefinition = createComponentDefinition({
|
||||||
|
id: "v2-media",
|
||||||
|
name: "V2 미디어",
|
||||||
|
description: "파일, 이미지, 비디오, 오디오 등 다양한 미디어 타입 지원",
|
||||||
|
category: ComponentCategory.INPUT,
|
||||||
|
webType: "file",
|
||||||
|
version: "2.0.0",
|
||||||
|
component: V2Media,
|
||||||
|
|
||||||
|
// 기본 속성
|
||||||
|
defaultProps: {
|
||||||
|
config: {
|
||||||
|
mediaType: "file",
|
||||||
|
multiple: false,
|
||||||
|
preview: true,
|
||||||
|
maxSize: 10, // MB
|
||||||
|
accept: "*/*",
|
||||||
|
showFileList: true,
|
||||||
|
dragDrop: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 설정 스키마
|
||||||
|
configSchema: {
|
||||||
|
mediaType: {
|
||||||
|
type: "select",
|
||||||
|
label: "미디어 타입",
|
||||||
|
options: [
|
||||||
|
{ value: "file", label: "파일" },
|
||||||
|
{ value: "image", label: "이미지" },
|
||||||
|
{ value: "video", label: "비디오" },
|
||||||
|
{ value: "audio", label: "오디오" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
multiple: {
|
||||||
|
type: "boolean",
|
||||||
|
label: "다중 업로드",
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
type: "boolean",
|
||||||
|
label: "미리보기",
|
||||||
|
},
|
||||||
|
maxSize: {
|
||||||
|
type: "number",
|
||||||
|
label: "최대 크기 (MB)",
|
||||||
|
},
|
||||||
|
accept: {
|
||||||
|
type: "text",
|
||||||
|
label: "허용 파일 형식",
|
||||||
|
placeholder: "*/* 또는 image/*",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 이벤트
|
||||||
|
events: ["onChange", "onUpload", "onDelete"],
|
||||||
|
|
||||||
|
// 아이콘
|
||||||
|
icon: "Upload",
|
||||||
|
|
||||||
|
// 태그
|
||||||
|
tags: ["media", "file", "image", "upload", "v2"],
|
||||||
|
|
||||||
|
// 설정 패널
|
||||||
|
configPanel: V2MediaConfigPanel,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default V2MediaDefinition;
|
||||||
|
|
@ -8,6 +8,9 @@ import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import DynamicComponentRenderer from "@/lib/registry/DynamicComponentRenderer";
|
import DynamicComponentRenderer from "@/lib/registry/DynamicComponentRenderer";
|
||||||
|
|
||||||
|
// V2 이벤트 시스템
|
||||||
|
import { V2_EVENTS, subscribeV2Event, type TableListDataChangeDetail, type RepeaterDataChangeDetail } from "@/types/component-events";
|
||||||
|
|
||||||
interface RepeatContainerComponentProps extends ComponentRendererProps {
|
interface RepeatContainerComponentProps extends ComponentRendererProps {
|
||||||
config?: RepeatContainerConfig;
|
config?: RepeatContainerConfig;
|
||||||
// 외부에서 데이터를 직접 전달받을 수 있음
|
// 외부에서 데이터를 직접 전달받을 수 있음
|
||||||
|
|
@ -254,7 +257,10 @@ export function RepeatContainerComponent({
|
||||||
};
|
};
|
||||||
}, [isDesignMode, component?.id, effectiveTableName, data]);
|
}, [isDesignMode, component?.id, effectiveTableName, data]);
|
||||||
|
|
||||||
// 컴포넌트 데이터 변경 이벤트 리스닝 (componentId 또는 tableName으로 매칭)
|
// ============================================================
|
||||||
|
// 컴포넌트 데이터 변경 이벤트 리스닝 (V2 표준 이벤트)
|
||||||
|
// componentId 또는 tableName으로 매칭
|
||||||
|
// ============================================================
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isDesignMode) return;
|
if (isDesignMode) return;
|
||||||
|
|
||||||
|
|
@ -265,19 +271,12 @@ export function RepeatContainerComponent({
|
||||||
effectiveTableName,
|
effectiveTableName,
|
||||||
});
|
});
|
||||||
|
|
||||||
// dataSourceComponentId가 없어도 테이블명으로 매칭 가능
|
// 공통 데이터 처리 함수
|
||||||
const handleDataChange = (event: CustomEvent) => {
|
const processIncomingData = (
|
||||||
const { componentId, tableName: eventTableName, data: eventData } = event.detail || {};
|
componentId: string | undefined,
|
||||||
|
eventTableName: string | undefined,
|
||||||
console.log("📩 리피터 컨테이너 이벤트 수신:", {
|
eventData: any[]
|
||||||
eventType: event.type,
|
) => {
|
||||||
fromComponentId: componentId,
|
|
||||||
fromTableName: eventTableName,
|
|
||||||
dataCount: Array.isArray(eventData) ? eventData.length : 0,
|
|
||||||
myDataSourceComponentId: dataSourceComponentId,
|
|
||||||
myEffectiveTableName: effectiveTableName,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 1. 명시적으로 dataSourceComponentId가 설정된 경우 해당 컴포넌트만 매칭
|
// 1. 명시적으로 dataSourceComponentId가 설정된 경우 해당 컴포넌트만 매칭
|
||||||
if (dataSourceComponentId) {
|
if (dataSourceComponentId) {
|
||||||
if (componentId === dataSourceComponentId && Array.isArray(eventData)) {
|
if (componentId === dataSourceComponentId && Array.isArray(eventData)) {
|
||||||
|
|
@ -287,8 +286,6 @@ export function RepeatContainerComponent({
|
||||||
setSelectedIndices([]);
|
setSelectedIndices([]);
|
||||||
// 데이터 변경 시 섹션별 폼 데이터 초기화
|
// 데이터 변경 시 섹션별 폼 데이터 초기화
|
||||||
sectionFormDataRef.current.clear();
|
sectionFormDataRef.current.clear();
|
||||||
} else {
|
|
||||||
console.log("⚠️ 리피터: 컴포넌트 ID 불일치로 무시", { expected: dataSourceComponentId, received: componentId });
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -301,17 +298,28 @@ export function RepeatContainerComponent({
|
||||||
setSelectedIndices([]);
|
setSelectedIndices([]);
|
||||||
// 데이터 변경 시 섹션별 폼 데이터 초기화
|
// 데이터 변경 시 섹션별 폼 데이터 초기화
|
||||||
sectionFormDataRef.current.clear();
|
sectionFormDataRef.current.clear();
|
||||||
} else if (effectiveTableName) {
|
|
||||||
console.log("⚠️ 리피터: 테이블명 불일치로 무시", { expected: effectiveTableName, received: eventTableName });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("repeaterDataChange" as any, handleDataChange);
|
// 테이블 리스트 데이터 변경 이벤트 (V2 표준)
|
||||||
window.addEventListener("tableListDataChange" as any, handleDataChange);
|
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 () => {
|
return () => {
|
||||||
window.removeEventListener("repeaterDataChange" as any, handleDataChange);
|
unsubscribeTableList();
|
||||||
window.removeEventListener("tableListDataChange" as any, handleDataChange);
|
unsubscribeRepeater();
|
||||||
};
|
};
|
||||||
}, [component?.id, dataSourceType, dataSourceComponentId, effectiveTableName, isDesignMode]);
|
}, [component?.id, dataSourceType, dataSourceComponentId, effectiveTableName, isDesignMode]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -643,7 +643,6 @@ const componentOverridesSchemaRegistry: Record<string, z.ZodType<Record<string,
|
||||||
"v2-media": v2MediaOverridesSchema,
|
"v2-media": v2MediaOverridesSchema,
|
||||||
"v2-biz": v2BizOverridesSchema,
|
"v2-biz": v2BizOverridesSchema,
|
||||||
"v2-hierarchy": v2HierarchyOverridesSchema,
|
"v2-hierarchy": v2HierarchyOverridesSchema,
|
||||||
"v2-repeater": v2RepeaterOverridesSchema,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
@ -971,6 +970,5 @@ export function saveLayoutV2(components: Array<ComponentV2 & { config?: Record<s
|
||||||
return {
|
return {
|
||||||
version: "2.0",
|
version: "2.0",
|
||||||
components: components.map(saveComponentV2),
|
components: components.map(saveComponentV2),
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,6 @@ export function convertLegacyToV2(legacyLayout: LegacyLayoutData): LayoutV2 {
|
||||||
return {
|
return {
|
||||||
version: "2.0",
|
version: "2.0",
|
||||||
components,
|
components,
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
Binary file not shown.
|
|
@ -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
|
||||||
|
|
@ -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를 누르세요"
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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를 누르세요"
|
||||||
Binary file not shown.
Loading…
Reference in New Issue