Compare commits

..

7 Commits

Author SHA1 Message Date
juseok2 7a9ec8d02c docs: 품목정보 화면 구현 가이드 업데이트
- 품목정보 화면의 구현 예시를 추가하여 JSON 데이터 사용에 대한 주의사항을 명시하였습니다.
- V2 컴포넌트 목록을 업데이트하고, 사용 가능한 컴포넌트에 대한 설명을 추가하였습니다.
- 화면 구현 시 테이블 구조 분석 및 JSON 구조 작성 방법에 대한 지침을 포함하였습니다.
- 각 컴포넌트의 역할과 사용법을 명확히 하여 개발자들이 쉽게 참고할 수 있도록 하였습니다.
2026-01-30 00:05:21 +09:00
juseok2 6cd416fdaa Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into feature/v2-unified-renewal 2026-01-29 23:38:37 +09:00
juseok2 3803b7dce1 V2 이벤트 시스템 통합 및 데이터 전달 인터페이스 구현: UnifiedRepeater 컴포넌트에 데이터 제공 및 수신 인터페이스를 추가하여 다른 컴포넌트와의 데이터 연동을 개선하였습니다. 또한, AggregationWidgetComponent와 RepeatContainerComponent에서 V2 표준 이벤트를 구독하여 데이터 변경 이벤트를 효율적으로 처리하도록 수정하였습니다. 이를 통해 컴포넌트 간의 데이터 흐름과 사용자 경험을 향상시켰습니다. 2026-01-29 23:20:23 +09:00
DDD1542 3fca677f3d feat: V2Media 컴포넌트 추가 및 통합 미디어 기능 정의
- 새로운 V2Media 컴포넌트를 추가하여 파일, 이미지, 비디오, 오디오 등 다양한 미디어 타입을 지원합니다.
- V2Media의 설정 스키마와 기본 속성을 정의하고, 관련 설정 패널을 통합하였습니다.
- 기존 컴포넌트 목록에 V2Media를 포함시켜 통합 미디어 기능을 강화하였습니다.
- componentConfig 스키마에서 v2-repeater를 제거하여 불필요한 항목을 정리하였습니다.
2026-01-29 14:47:59 +09:00
DDD1542 314d80ccf0 feat: V2Media 컴포넌트 추가 및 통합 미디어 기능 정의
- 새로운 V2Media 컴포넌트를 추가하여 파일, 이미지, 비디오, 오디오 등 다양한 미디어 타입을 지원합니다.
- V2Media의 설정 스키마와 기본 속성을 정의하고, 관련 설정 패널을 통합하였습니다.
- 기존 컴포넌트 목록에 V2Media를 포함시켜 통합 미디어 기능을 강화하였습니다.
- componentConfig 스키마에서 v2-repeater를 제거하여 불필요한 항목을 정리하였습니다.
2026-01-29 14:46:55 +09:00
DDD1542 42ad8cddb3 Merge branch 'feature/v2-unified-renewal' of http://39.117.244.52:3000/kjs/ERP-node into feature/v2-renewal
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-29 14:46:37 +09:00
DDD1542 12128f278c refactor: 화면 저장 로직에서 updatedAt 필드 제거 및 버전 정보 주석 수정
- 화면 저장 시 updatedAt 필드를 제거하고, DB에서 updated_at 컬럼으로 관리하도록 변경하였습니다.
- 관련된 주석을 업데이트하여 변경 사항을 명확히 하였습니다.
2026-01-28 17:00:26 +09:00
20 changed files with 5018 additions and 29 deletions

View File

@ -4690,11 +4690,10 @@ export class ScreenManagementService {
throw new Error("이 화면의 레이아웃을 저장할 권한이 없습니다.");
}
// 버전 정보 추가
// 버전 정보 추가 (updatedAt은 DB 컬럼 updated_at으로 관리)
const dataToSave = {
version: "2.0",
...layoutData,
updatedAt: new Date().toISOString(),
...layoutData
};
// UPSERT (있으면 업데이트, 없으면 삽입)

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

File diff suppressed because it is too large Load Diff

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";
@ -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 { 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 {
@ -56,6 +68,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());
@ -105,6 +120,123 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
};
}, [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(() => {
const handleSaveEvent = async (event: CustomEvent) => {

View File

@ -105,6 +105,7 @@ import "./v2-location-swap-selector/LocationSwapSelectorRenderer";
import "./v2-table-search-widget";
import "./v2-tabs-widget/tabs-component";
import "./v2-category-manager/V2CategoryManagerRenderer";
import "./v2-media"; // 통합 미디어 컴포넌트
/**
*

View File

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

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;
// 외부에서 데이터를 직접 전달받을 수 있음
@ -254,7 +257,10 @@ export function RepeatContainerComponent({
};
}, [isDesignMode, component?.id, effectiveTableName, data]);
// 컴포넌트 데이터 변경 이벤트 리스닝 (componentId 또는 tableName으로 매칭)
// ============================================================
// 컴포넌트 데이터 변경 이벤트 리스닝 (V2 표준 이벤트)
// componentId 또는 tableName으로 매칭
// ============================================================
useEffect(() => {
if (isDesignMode) return;
@ -265,19 +271,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)) {
@ -287,8 +286,6 @@ export function RepeatContainerComponent({
setSelectedIndices([]);
// 데이터 변경 시 섹션별 폼 데이터 초기화
sectionFormDataRef.current.clear();
} else {
console.log("⚠️ 리피터: 컴포넌트 ID 불일치로 무시", { expected: dataSourceComponentId, received: componentId });
}
return;
}
@ -301,17 +298,28 @@ export function RepeatContainerComponent({
setSelectedIndices([]);
// 데이터 변경 시 섹션별 폼 데이터 초기화
sectionFormDataRef.current.clear();
} 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

@ -643,7 +643,6 @@ const componentOverridesSchemaRegistry: Record<string, z.ZodType<Record<string,
"v2-media": v2MediaOverridesSchema,
"v2-biz": v2BizOverridesSchema,
"v2-hierarchy": v2HierarchyOverridesSchema,
"v2-repeater": v2RepeaterOverridesSchema,
};
// ============================================
@ -971,6 +970,5 @@ export function saveLayoutV2(components: Array<ComponentV2 & { config?: Record<s
return {
version: "2.0",
components: components.map(saveComponentV2),
updatedAt: new Date().toISOString(),
};
}

View File

@ -136,7 +136,6 @@ export function convertLegacyToV2(legacyLayout: LegacyLayoutData): LayoutV2 {
return {
version: "2.0",
components,
updatedAt: new Date().toISOString(),
};
}

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.