From 36ab4840294aa2a36df3c4757ecef016561f96e5 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 28 Nov 2025 16:02:29 +0900 Subject: [PATCH] =?UTF-8?q?feat(repeat-screen-modal):=20=EC=9E=90=EC=9C=A0?= =?UTF-8?q?=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EB=B0=8F=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=84=EB=8B=AC=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - contentRows 기반 자유 레이아웃 지원 (header/aggregation/table/fields 타입) - aggregationFields, tableColumns 직접 참조하도록 렌더링 로직 수정 - groupByField 없어도 grouping.enabled면 그룹핑 모드로 처리 - buttonActions에서 selectedRowsData를 모달 이벤트로 전달 - ScreenModal에서 selectedData를 groupedData props로 컴포넌트에 전달 - types.ts에 CardContentRowConfig, AggregationDisplayConfig 인터페이스 추가 --- frontend/components/common/ScreenModal.tsx | 23 +- .../screen/InteractiveScreenViewerDynamic.tsx | 2 + .../screen/panels/DetailSettingsPanel.tsx | 34 +- .../screen/panels/UnifiedPropertiesPanel.tsx | 60 +- .../components/repeat-screen-modal/README.md | 366 +++-- .../RepeatScreenModalComponent.tsx | 908 +++++++++--- .../RepeatScreenModalConfigPanel.tsx | 1225 ++++++++++++++--- .../components/repeat-screen-modal/index.ts | 68 +- .../components/repeat-screen-modal/types.ts | 78 +- .../components/simple-repeater-table/types.ts | 131 -- frontend/lib/utils/buttonActions.ts | 8 + 11 files changed, 2035 insertions(+), 868 deletions(-) diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 65dbf84c..74b1b8f6 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -120,10 +120,28 @@ export const ScreenModal: React.FC = ({ className }) => { }; }; + // 🆕 선택된 데이터 상태 추가 (RepeatScreenModal 등에서 사용) + const [selectedData, setSelectedData] = useState[]>([]); + // 전역 모달 이벤트 리스너 useEffect(() => { const handleOpenModal = (event: CustomEvent) => { - const { screenId, title, description, size, urlParams } = event.detail; + const { screenId, title, description, size, urlParams, selectedData: eventSelectedData, selectedIds } = event.detail; + + console.log("📦 [ScreenModal] 모달 열기 이벤트 수신:", { + screenId, + title, + selectedData: eventSelectedData, + selectedIds, + }); + + // 🆕 선택된 데이터 저장 + if (eventSelectedData && Array.isArray(eventSelectedData)) { + setSelectedData(eventSelectedData); + console.log("📦 [ScreenModal] 선택된 데이터 저장:", eventSelectedData.length, "건"); + } else { + setSelectedData([]); + } // 🆕 URL 파라미터가 있으면 현재 URL에 추가 if (urlParams && typeof window !== "undefined") { @@ -164,6 +182,7 @@ export const ScreenModal: React.FC = ({ className }) => { }); setScreenData(null); setFormData({}); + setSelectedData([]); // 🆕 선택된 데이터 초기화 setContinuousMode(false); localStorage.setItem("screenModal_continuousMode", "false"); // localStorage에 저장 console.log("🔄 연속 모드 초기화: false"); @@ -605,6 +624,8 @@ export const ScreenModal: React.FC = ({ className }) => { userId={userId} userName={userName} companyCode={user?.companyCode} + // 🆕 선택된 데이터 전달 (RepeatScreenModal 등에서 사용) + groupedData={selectedData.length > 0 ? selectedData : undefined} /> ); })} diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index 41e321e5..e351b68c 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -408,6 +408,7 @@ export const InteractiveScreenViewerDynamic: React.FC handleFormDataChange(fieldName, value), onFormDataChange: handleFormDataChange, + formData: formData, // 🆕 전체 formData 전달 isInteractive: true, readonly: readonly, required: required, @@ -415,6 +416,7 @@ export const InteractiveScreenViewerDynamic: React.FC { diff --git a/frontend/components/screen/panels/DetailSettingsPanel.tsx b/frontend/components/screen/panels/DetailSettingsPanel.tsx index e3e8cbb3..90187838 100644 --- a/frontend/components/screen/panels/DetailSettingsPanel.tsx +++ b/frontend/components/screen/panels/DetailSettingsPanel.tsx @@ -863,27 +863,23 @@ export const DetailSettingsPanel: React.FC = ({ }); // 래퍼 컴포넌트: 새 ConfigPanel 인터페이스를 기존 패턴에 맞춤 - const ConfigPanelWrapper = () => { - // Section Card, Section Paper 등 신규 컴포넌트는 componentConfig 바로 아래에 설정 저장 - const config = currentConfig || definition.defaultProps?.componentConfig || {}; - - const handleConfigChange = (newConfig: any) => { - // componentConfig 전체를 업데이트 - onUpdateProperty(selectedComponent.id, "componentConfig", newConfig); - }; - - return ( -
-
- -

{definition.name} 설정

-
- -
- ); + // Section Card, Section Paper 등 신규 컴포넌트는 componentConfig 바로 아래에 설정 저장 + const config = currentConfig || definition.defaultProps?.componentConfig || {}; + + const handleConfigChange = (newConfig: any) => { + // componentConfig 전체를 업데이트 + onUpdateProperty(selectedComponent.id, "componentConfig", newConfig); }; - return ; + return ( +
+
+ +

{definition.name} 설정

+
+ +
+ ); } else { console.warn("⚠️ ConfigPanel 없음:", { componentId, diff --git a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx index 69b7d092..e3940073 100644 --- a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx +++ b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx @@ -326,40 +326,36 @@ export const UnifiedPropertiesPanel: React.FC = ({ }); // 래퍼 컴포넌트: 새 ConfigPanel 인터페이스를 기존 패턴에 맞춤 - const ConfigPanelWrapper = () => { - // Section Card, Section Paper 등 신규 컴포넌트는 componentConfig 바로 아래에 설정 저장 - const config = currentConfig || definition.defaultProps?.componentConfig || {}; - - const handleConfigChange = (newConfig: any) => { - // componentConfig 전체를 업데이트 - onUpdateProperty(selectedComponent.id, "componentConfig", newConfig); - }; - - return ( -
-
- -

{definition.name} 설정

-
- -
설정 패널 로딩 중...
-
- }> - - - - ); + // Section Card, Section Paper 등 신규 컴포넌트는 componentConfig 바로 아래에 설정 저장 + const config = currentConfig || definition.defaultProps?.componentConfig || {}; + + const handleConfigChange = (newConfig: any) => { + // componentConfig 전체를 업데이트 + onUpdateProperty(selectedComponent.id, "componentConfig", newConfig); }; - return ; + return ( +
+
+ +

{definition.name} 설정

+
+ +
설정 패널 로딩 중...
+
+ }> + + + + ); } else { console.warn("⚠️ ComponentRegistry에서 ConfigPanel을 찾을 수 없음 - switch case로 이동:", { componentId, diff --git a/frontend/lib/registry/components/repeat-screen-modal/README.md b/frontend/lib/registry/components/repeat-screen-modal/README.md index 87e8114f..6ba2783a 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/README.md +++ b/frontend/lib/registry/components/repeat-screen-modal/README.md @@ -1,71 +1,145 @@ -# RepeatScreenModal 컴포넌트 v2 +# RepeatScreenModal 컴포넌트 v3 ## 개요 -`RepeatScreenModal`은 선택한 데이터를 그룹핑하여 카드 형태로 표시하고, 각 카드 내에서 데이터를 편집할 수 있는 **만능 폼 컴포넌트**입니다. +`RepeatScreenModal`은 선택한 데이터를 기반으로 여러 개의 카드를 생성하고, 각 카드의 내부 레이아웃을 자유롭게 구성할 수 있는 컴포넌트입니다. -**이 컴포넌트 하나로 대부분의 ERP 화면을 설정만으로 구현할 수 있습니다.** +## v3 주요 변경사항 -## 핵심 철학 +### 자유 레이아웃 시스템 + +기존의 "simple 모드 / withTable 모드" 구분을 없애고, **행(Row)을 추가하고 각 행마다 타입을 선택**하는 방식으로 변경되었습니다. ``` ┌─────────────────────────────────────────────────────────────────┐ -│ ERP 화면 구성의 핵심 │ +│ 카드 │ ├─────────────────────────────────────────────────────────────────┤ -│ │ -│ 1. 어떤 테이블에서 → 어떤 컬럼을 → 어떻게 보여줄 것인가? │ -│ │ -│ 2. 보기만 할 것인가? vs 수정 가능하게 할 것인가? │ -│ │ -│ 3. 수정한다면 → 어떤 테이블의 → 어떤 컬럼에 저장할 것인가? │ -│ │ -│ 4. 데이터를 어떻게 그룹화해서 보여줄 것인가? │ -│ │ +│ [행 1] 타입: 헤더 → 품목코드, 품목명, 규격 │ +├─────────────────────────────────────────────────────────────────┤ +│ [행 2] 타입: 집계 → 총수주잔량, 현재고, 가용재고 │ +├─────────────────────────────────────────────────────────────────┤ +│ [행 3] 타입: 테이블 → 수주번호, 거래처, 납기일, 출하계획 │ +├─────────────────────────────────────────────────────────────────┤ +│ [행 4] 타입: 테이블 → 또 다른 테이블도 가능! │ └─────────────────────────────────────────────────────────────────┘ ``` -## 카드 모드 +### 행 타입 -### 1. Simple 모드 (단순) +| 타입 | 설명 | 사용 시나리오 | +|------|------|---------------| +| **헤더 (header)** | 필드들을 가로/세로로 나열 | 품목정보, 거래처정보 표시 | +| **필드 (fields)** | 헤더와 동일, 편집 가능 | 폼 입력 영역 | +| **집계 (aggregation)** | 그룹 내 데이터 집계값 표시 | 총수량, 합계금액 등 | +| **테이블 (table)** | 그룹 내 각 행을 테이블로 표시 | 수주목록, 품목목록 등 | -- **1행 = 1카드**: 선택한 각 행이 독립적인 카드로 표시 -- 자유로운 레이아웃 구성 (행/컬럼 기반) -- 적합한 상황: 단순 데이터 편집, 개별 레코드 수정 - -### 2. WithTable 모드 (테이블 포함) - -- **N행 = 1카드**: 그룹핑된 여러 행이 하나의 카드로 표시 -- 카드 = 헤더 영역 + 테이블 영역 -- 헤더: 그룹 대표값, 집계값 표시 -- 테이블: 그룹 내 각 행을 테이블로 표시 -- 적합한 상황: 출하계획, 구매발주, 생산계획 등 일괄 등록 - -## 주요 기능 - -| 기능 | 설명 | -|------|------| -| 그룹핑 | 특정 필드 기준으로 여러 행을 하나의 카드로 묶음 | -| 집계 | 그룹 내 데이터의 합계/개수/평균/최소/최대 자동 계산 | -| 카드 내 테이블 | 그룹 내 각 행을 테이블 형태로 표시 | -| 테이블 내 편집 | 테이블의 특정 컬럼을 편집 가능하게 설정 | -| 다중 테이블 저장 | 하나의 카드에서 여러 테이블 동시 저장 | -| 컬럼별 소스 설정 | 직접 조회/조인 조회/수동 입력 선택 | -| 컬럼별 타겟 설정 | 저장할 테이블과 컬럼 지정 | - -## 사용 시나리오 - -### 시나리오 1: 출하계획 동시 등록 +### 자유로운 조합 ``` -그룹핑: part_code (품목코드) -헤더: 품목정보 + 총수주잔량 + 현재고 -테이블: 수주별 출하계획 입력 +예시 1: 헤더 + 집계 + 테이블 (출하계획) +├── [행 1] 헤더: 품목코드, 품목명 +├── [행 2] 집계: 총수주잔량, 현재고 +└── [행 3] 테이블: 수주별 출하계획 + +예시 2: 집계만 +└── [행 1] 집계: 총매출, 총비용, 순이익 + +예시 3: 테이블만 +└── [행 1] 테이블: 품목 목록 + +예시 4: 테이블 2개 +├── [행 1] 테이블: 입고 내역 +└── [행 2] 테이블: 출고 내역 + +예시 5: 헤더 + 헤더 + 필드 +├── [행 1] 헤더: 기본 정보 (읽기전용) +├── [행 2] 헤더: 상세 정보 (읽기전용) +└── [행 3] 필드: 입력 필드 (편집가능) ``` -**설정 예시:** +## 설정 방법 + +### 1. 기본 설정 탭 + +- **카드 제목 표시**: 카드 상단에 제목을 표시할지 여부 +- **카드 제목 템플릿**: `{field_name}` 형식으로 동적 제목 생성 +- **카드 간격**: 카드 사이의 간격 (8px ~ 32px) +- **테두리**: 카드 테두리 표시 여부 +- **저장 모드**: 전체 저장 / 개별 저장 + +### 2. 데이터 소스 탭 + +- **소스 테이블**: 데이터를 조회할 테이블 +- **필터 필드**: formData에서 필터링할 필드 (예: selectedIds) + +### 3. 그룹 탭 + +- **그룹핑 활성화**: 여러 행을 하나의 카드로 묶을지 여부 +- **그룹 기준 필드**: 그룹핑할 필드 (예: part_code) +- **집계 설정**: + - 원본 필드: 합계할 필드 (예: balance_qty) + - 집계 타입: sum, count, avg, min, max + - 결과 필드명: 집계 결과를 저장할 필드명 + - 라벨: 표시될 라벨 + +### 4. 레이아웃 탭 + +#### 행 추가 + +4가지 타입의 행을 추가할 수 있습니다: +- **헤더**: 필드 정보 표시 (읽기전용) +- **집계**: 그룹 집계값 표시 +- **테이블**: 그룹 내 행들을 테이블로 표시 +- **필드**: 입력 필드 (편집가능) + +#### 헤더/필드 행 설정 + +- **방향**: 가로 / 세로 +- **배경색**: 없음, 파랑, 초록, 보라, 주황 +- **컬럼**: 필드명, 라벨, 타입, 너비, 편집 가능, 필수 +- **소스 설정**: 직접 / 조인 / 수동 +- **저장 설정**: 저장할 테이블과 컬럼 + +#### 집계 행 설정 + +- **레이아웃**: 가로 나열 / 그리드 +- **그리드 컬럼 수**: 2, 3, 4개 +- **집계 필드**: 그룹 탭에서 정의한 집계 결과 선택 +- **스타일**: 배경색, 폰트 크기 + +#### 테이블 행 설정 + +- **테이블 제목**: 선택사항 +- **헤더 표시**: 테이블 헤더 표시 여부 +- **테이블 컬럼**: 필드명, 라벨, 타입, 너비, 편집 가능 +- **저장 설정**: 편집 가능한 컬럼의 저장 위치 + +## 데이터 흐름 + +``` +1. formData에서 selectedIds 가져오기 + ↓ +2. 소스 테이블에서 해당 ID들의 데이터 조회 + ↓ +3. 그룹핑 활성화 시 groupByField 기준으로 그룹화 + ↓ +4. 각 그룹에 대해 집계값 계산 + ↓ +5. 카드 렌더링 (contentRows 기반) + ↓ +6. 사용자 편집 + ↓ +7. 저장 시 targetConfig에 따라 테이블별로 데이터 분류 후 저장 +``` + +## 사용 예시 + +### 출하계획 등록 + ```typescript { - cardMode: "withTable", + showCardTitle: true, + cardTitle: "{part_code} - {part_name}", dataSource: { sourceTable: "sales_order_mng", filterField: "selectedIds" @@ -78,159 +152,55 @@ { sourceField: "id", type: "count", resultField: "order_count", label: "수주건수" } ] }, - tableLayout: { - headerRows: [ - { - columns: [ - { field: "part_code", label: "품목코드", type: "text", editable: false }, - { field: "part_name", label: "품목명", type: "text", editable: false }, - { field: "total_balance", label: "총수주잔량", type: "aggregation", aggregationField: "total_balance" } - ] - } - ], - tableColumns: [ - { field: "order_no", label: "수주번호", type: "text", editable: false }, - { field: "partner_id", label: "거래처", type: "text", editable: false }, - { field: "due_date", label: "납기일", type: "date", editable: false }, - { field: "balance_qty", label: "미출하", type: "number", editable: false }, - { - field: "plan_qty", - label: "출하계획", - type: "number", - editable: true, - targetConfig: { targetTable: "shipment_plan", targetColumn: "plan_qty", saveEnabled: true } - } - ] - } + contentRows: [ + { + id: "row-1", + type: "header", + columns: [ + { id: "c1", field: "part_code", label: "품목코드", type: "text", editable: false }, + { id: "c2", field: "part_name", label: "품목명", type: "text", editable: false } + ], + layout: "horizontal" + }, + { + id: "row-2", + type: "aggregation", + aggregationLayout: "horizontal", + aggregationFields: [ + { aggregationResultField: "total_balance", label: "총수주잔량", backgroundColor: "blue" }, + { aggregationResultField: "order_count", label: "수주건수", backgroundColor: "green" } + ] + }, + { + id: "row-3", + type: "table", + tableTitle: "수주 목록", + showTableHeader: true, + tableColumns: [ + { id: "tc1", field: "order_no", label: "수주번호", type: "text", editable: false }, + { id: "tc2", field: "partner_name", label: "거래처", type: "text", editable: false }, + { id: "tc3", field: "balance_qty", label: "미출하", type: "number", editable: false }, + { + id: "tc4", + field: "plan_qty", + label: "출하계획", + type: "number", + editable: true, + targetConfig: { targetTable: "shipment_plan", targetColumn: "plan_qty", saveEnabled: true } + } + ] + } + ] } ``` -### 시나리오 2: 구매발주 일괄 등록 +## 레거시 호환 -``` -그룹핑: supplier_id (공급업체) -헤더: 공급업체정보 + 총발주금액 -테이블: 품목별 발주수량 입력 -``` +v2에서 사용하던 `cardMode`, `cardLayout`, `tableLayout` 설정도 계속 지원됩니다. +새로운 프로젝트에서는 `contentRows`를 사용하는 것을 권장합니다. -### 시나리오 3: 생산계획 일괄 등록 +## 주의사항 -``` -그룹핑: product_code (제품코드) -헤더: 제품정보 + 현재고 + 필요수량 -테이블: 작업지시별 생산수량 입력 -``` - -### 시나리오 4: 입고검사 일괄 처리 - -``` -그룹핑: po_no (발주번호) -헤더: 발주정보 + 공급업체 -테이블: 품목별 검사결과 입력 -``` - -## ConfigPanel 사용법 - -### 1. 기본 설정 탭 - -- **카드 제목**: `{field}` 형식으로 동적 제목 설정 -- **카드 간격**: 카드 사이 간격 (8px ~ 32px) -- **테두리**: 카드 테두리 표시 여부 -- **저장 모드**: 전체 저장 / 개별 저장 -- **카드 모드**: 단순 / 테이블 - -### 2. 데이터 소스 탭 - -- **소스 테이블**: 데이터를 조회할 테이블 -- **필터 필드**: formData에서 가져올 필터 필드명 (예: `selectedIds`) - -### 3. 그룹핑 탭 (테이블 모드에서 활성화) - -- **그룹핑 활성화**: ON/OFF -- **그룹 기준 필드**: 그룹핑할 필드 선택 -- **집계 설정**: 합계/개수/평균 등 집계 추가 - -### 4. 레이아웃 탭 - -**Simple 모드:** -- 행 추가/삭제 -- 각 행에 컬럼 추가/삭제 -- 컬럼별 필드명, 라벨, 타입, 너비, 편집 가능 여부 설정 - -**WithTable 모드:** -- 헤더 영역: 그룹 대표값, 집계값 표시용 행/컬럼 설정 -- 테이블 영역: 그룹 내 각 행을 표시할 테이블 컬럼 설정 - -## 컬럼 설정 상세 - -### 소스 설정 (데이터 조회) - -| 타입 | 설명 | -|------|------| -| direct | 소스 테이블에서 직접 조회 | -| join | 다른 테이블과 조인하여 조회 | -| manual | 사용자 직접 입력 | - -### 타겟 설정 (데이터 저장) - -- **저장 테이블**: 데이터를 저장할 테이블 -- **저장 컬럼**: 데이터를 저장할 컬럼 -- **저장 활성화**: 저장 여부 - -## 타입 정의 - -```typescript -interface RepeatScreenModalProps { - // 기본 설정 - cardTitle?: string; - cardSpacing?: string; - showCardBorder?: boolean; - saveMode?: "all" | "individual"; - cardMode?: "simple" | "withTable"; - - // 데이터 소스 - dataSource?: DataSourceConfig; - - // 그룹핑 설정 - grouping?: GroupingConfig; - - // 레이아웃 - cardLayout?: CardRowConfig[]; // simple 모드 - tableLayout?: TableLayoutConfig; // withTable 모드 -} - -interface GroupingConfig { - enabled: boolean; - groupByField: string; - aggregations?: AggregationConfig[]; -} - -interface AggregationConfig { - sourceField: string; - type: "sum" | "count" | "avg" | "min" | "max"; - resultField: string; - label: string; -} - -interface TableLayoutConfig { - headerRows: CardRowConfig[]; - tableColumns: TableColumnConfig[]; -} -``` - -## 파일 구조 - -``` -repeat-screen-modal/ -├── index.ts # 컴포넌트 정의 및 export -├── types.ts # 타입 정의 -├── RepeatScreenModalComponent.tsx # 메인 컴포넌트 -├── RepeatScreenModalConfigPanel.tsx # 설정 패널 -├── RepeatScreenModalRenderer.tsx # 자동 등록 -└── README.md # 문서 -``` - -## 버전 히스토리 - -- **v2.0.0**: 그룹핑, 집계, 테이블 모드 추가 -- **v1.0.0**: 초기 버전 (Simple 모드) +1. **집계는 그룹핑 필수**: 집계 행은 그룹핑이 활성화되어 있어야 의미가 있습니다. +2. **테이블은 그룹핑 필수**: 테이블 행도 그룹핑이 활성화되어 있어야 그룹 내 행들을 표시할 수 있습니다. +3. **단순 모드**: 그룹핑 없이 사용하면 1행 = 1카드로 동작합니다. 이 경우 헤더/필드 타입만 사용 가능합니다. diff --git a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx index 5f2e1690..997b381c 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx +++ b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx @@ -18,6 +18,8 @@ import { CardRowData, AggregationConfig, TableColumnConfig, + CardContentRowConfig, + AggregationDisplayConfig, } from "./types"; import { ComponentRendererProps } from "@/types/component"; import { cn } from "@/lib/utils"; @@ -25,6 +27,7 @@ import { apiClient } from "@/lib/api/client"; export interface RepeatScreenModalComponentProps extends ComponentRendererProps { config?: RepeatScreenModalProps; + groupedData?: Record[]; // EditModal에서 전달하는 그룹 데이터 } export function RepeatScreenModalComponent({ @@ -34,22 +37,31 @@ export function RepeatScreenModalComponent({ onFormDataChange, config, className, + groupedData: propsGroupedData, // EditModal에서 전달받는 그룹 데이터 ...props }: RepeatScreenModalComponentProps) { + // props에서도 groupedData를 추출 (DynamicWebTypeRenderer에서 전달될 수 있음) + const groupedData = propsGroupedData || (props as any).groupedData; const componentConfig = { ...config, ...component?.config, }; // 설정 값 추출 - const cardLayout = componentConfig?.cardLayout || []; const dataSource = componentConfig?.dataSource; const saveMode = componentConfig?.saveMode || "all"; const cardSpacing = componentConfig?.cardSpacing || "24px"; const showCardBorder = componentConfig?.showCardBorder ?? true; + const showCardTitle = componentConfig?.showCardTitle ?? true; const cardTitle = componentConfig?.cardTitle || "카드 {index}"; - const cardMode = componentConfig?.cardMode || "simple"; const grouping = componentConfig?.grouping; + + // 🆕 v3: 자유 레이아웃 + const contentRows = componentConfig?.contentRows || []; + + // (레거시 호환) + const cardLayout = componentConfig?.cardLayout || []; + const cardMode = componentConfig?.cardMode || "simple"; const tableLayout = componentConfig?.tableLayout; // 상태 @@ -63,59 +75,127 @@ export function RepeatScreenModalComponent({ // 초기 데이터 로드 useEffect(() => { const loadInitialData = async () => { - if (!dataSource || !dataSource.sourceTable) { - return; - } + console.log("[RepeatScreenModal] 데이터 로드 시작"); + console.log("[RepeatScreenModal] groupedData (from EditModal):", groupedData); + console.log("[RepeatScreenModal] formData:", formData); + console.log("[RepeatScreenModal] dataSource:", dataSource); setIsLoading(true); setLoadError(null); try { - // 필터 조건 생성 - const filters: Record = {}; + let loadedData: any[] = []; - if (dataSource.filterField && formData) { - const filterValue = formData[dataSource.filterField]; - if (filterValue) { - // 배열이면 IN 조건, 아니면 단일 조건 - if (Array.isArray(filterValue)) { - filters.id = filterValue; - } else { - filters.id = filterValue; + // 🆕 우선순위 1: EditModal에서 전달받은 groupedData 사용 + if (groupedData && groupedData.length > 0) { + console.log("[RepeatScreenModal] groupedData 사용:", groupedData.length, "건"); + loadedData = groupedData; + } + // 우선순위 2: API 호출 + else if (dataSource && dataSource.sourceTable) { + // 필터 조건 생성 + const filters: Record = {}; + + // formData에서 선택된 행 ID 가져오기 + let selectedIds: any[] = []; + + if (formData) { + // 1. 명시적으로 설정된 filterField 확인 + if (dataSource.filterField) { + const filterValue = formData[dataSource.filterField]; + if (filterValue) { + selectedIds = Array.isArray(filterValue) ? filterValue : [filterValue]; + } + } + + // 2. 일반적인 선택 필드 확인 (fallback) + if (selectedIds.length === 0) { + const commonFields = ['selectedRows', 'selectedIds', 'checkedRows', 'checkedIds', 'ids']; + for (const field of commonFields) { + if (formData[field]) { + const value = formData[field]; + selectedIds = Array.isArray(value) ? value : [value]; + console.log(`[RepeatScreenModal] ${field}에서 선택된 ID 발견:`, selectedIds); + break; + } + } + } + + // 3. formData에 id가 있으면 단일 행 + if (selectedIds.length === 0 && formData.id) { + selectedIds = [formData.id]; + console.log("[RepeatScreenModal] formData.id 사용:", selectedIds); } } - } - // API 호출 - const response = await apiClient.post(`/table-management/tables/${dataSource.sourceTable}/data`, { - search: filters, - page: 1, - size: 1000, - }); + console.log("[RepeatScreenModal] 최종 선택된 ID:", selectedIds); - if (response.data.success && response.data.data?.data) { - const loadedData = response.data.data.data; - setRawData(loadedData); - - // 모드에 따라 데이터 처리 - if (cardMode === "withTable" && grouping?.enabled && grouping.groupByField) { - // 그룹핑 모드 - const grouped = processGroupedData(loadedData, grouping); - setGroupedCardsData(grouped); + // 선택된 ID가 있으면 필터 적용 + if (selectedIds.length > 0) { + filters.id = selectedIds; } else { - // 단순 모드 - const initialCards: CardData[] = await Promise.all( - loadedData.map(async (row: any, index: number) => ({ - _cardId: `card-${index}-${Date.now()}`, - _originalData: { ...row }, - _isDirty: false, - ...(await loadCardData(row)), - })) - ); - setCardsData(initialCards); + console.warn("[RepeatScreenModal] 선택된 데이터가 없습니다."); + setRawData([]); + setCardsData([]); + setGroupedCardsData([]); + setIsLoading(false); + return; + } + + console.log("[RepeatScreenModal] API 필터:", filters); + + // API 호출 + const response = await apiClient.post(`/table-management/tables/${dataSource.sourceTable}/data`, { + search: filters, + page: 1, + size: 1000, + }); + + if (response.data.success && response.data.data?.data) { + loadedData = response.data.data.data; } } else { - setLoadError("데이터를 불러오는데 실패했습니다."); + console.log("[RepeatScreenModal] 데이터 소스 없음"); + setRawData([]); + setCardsData([]); + setGroupedCardsData([]); + setIsLoading(false); + return; + } + + console.log("[RepeatScreenModal] 로드된 데이터:", loadedData.length, "건"); + + if (loadedData.length === 0) { + setRawData([]); + setCardsData([]); + setGroupedCardsData([]); + setIsLoading(false); + return; + } + + setRawData(loadedData); + + // 🆕 v3: contentRows가 있으면 새로운 방식 사용 + const useNewLayout = contentRows && contentRows.length > 0; + + // 그룹핑 모드 확인 (groupByField가 없어도 enabled면 그룹핑 모드로 처리) + const useGrouping = grouping?.enabled; + + if (useGrouping) { + // 그룹핑 모드 + const grouped = processGroupedData(loadedData, grouping); + setGroupedCardsData(grouped); + } else { + // 단순 모드: 각 행이 하나의 카드 + const initialCards: CardData[] = await Promise.all( + loadedData.map(async (row: any, index: number) => ({ + _cardId: `card-${index}-${Date.now()}`, + _originalData: { ...row }, + _isDirty: false, + ...(await loadCardData(row)), + })) + ); + setCardsData(initialCards); } } catch (error: any) { console.error("데이터 로드 실패:", error); @@ -126,25 +206,34 @@ export function RepeatScreenModalComponent({ }; loadInitialData(); - }, [dataSource, formData, cardMode, grouping?.enabled, grouping?.groupByField]); + }, [dataSource, formData, groupedData, contentRows, grouping?.enabled, grouping?.groupByField]); // 그룹화된 데이터 처리 const processGroupedData = (data: any[], groupingConfig: typeof grouping): GroupedCardData[] => { - if (!groupingConfig?.enabled || !groupingConfig.groupByField) { + if (!groupingConfig?.enabled) { return []; } const groupByField = groupingConfig.groupByField; const groupMap = new Map(); - // 그룹별로 데이터 분류 - data.forEach((row) => { - const groupKey = String(row[groupByField] || ""); - if (!groupMap.has(groupKey)) { - groupMap.set(groupKey, []); - } - groupMap.get(groupKey)!.push(row); - }); + // groupByField가 없으면 각 행을 개별 그룹으로 처리 + if (!groupByField) { + // 각 행이 하나의 카드 (그룹) + data.forEach((row, index) => { + const groupKey = `row-${index}`; + groupMap.set(groupKey, [row]); + }); + } else { + // 그룹별로 데이터 분류 + data.forEach((row) => { + const groupKey = String(row[groupByField] || ""); + if (!groupMap.has(groupKey)) { + groupMap.set(groupKey, []); + } + groupMap.get(groupKey)!.push(row); + }); + } // GroupedCardData 생성 const result: GroupedCardData[] = []; @@ -170,7 +259,7 @@ export function RepeatScreenModalComponent({ result.push({ _cardId: `grouped-card-${cardIndex}-${Date.now()}`, _groupKey: groupKey, - _groupField: groupByField, + _groupField: groupByField || "", _aggregations: aggregations, _rows: cardRows, _representativeData: rows[0] || {}, @@ -206,18 +295,49 @@ export function RepeatScreenModalComponent({ const loadCardData = async (originalData: any): Promise> => { const cardData: Record = {}; - for (const row of cardLayout) { - for (const col of row.columns) { - if (col.sourceConfig) { - if (col.sourceConfig.type === "direct") { - cardData[col.field] = originalData[col.sourceConfig.sourceColumn || col.field]; - } else if (col.sourceConfig.type === "join" && col.sourceConfig.joinTable) { - cardData[col.field] = null; // 조인은 나중에 일괄 처리 - } else if (col.sourceConfig.type === "manual") { - cardData[col.field] = null; + // 🆕 v3: contentRows 사용 + if (contentRows && contentRows.length > 0) { + for (const contentRow of contentRows) { + // 헤더/필드 타입의 컬럼 처리 + if ((contentRow.type === "header" || contentRow.type === "fields") && contentRow.columns) { + for (const col of contentRow.columns) { + if (col.sourceConfig) { + if (col.sourceConfig.type === "direct") { + cardData[col.field] = originalData[col.sourceConfig.sourceColumn || col.field]; + } else if (col.sourceConfig.type === "join" && col.sourceConfig.joinTable) { + cardData[col.field] = null; // 조인은 나중에 일괄 처리 + } else if (col.sourceConfig.type === "manual") { + cardData[col.field] = null; + } + } else { + // sourceConfig가 없으면 원본 데이터에서 직접 가져옴 + cardData[col.field] = originalData[col.field]; + } + } + } + + // 테이블 타입의 컬럼 처리 + if (contentRow.type === "table" && contentRow.tableColumns) { + for (const col of contentRow.tableColumns) { + cardData[col.field] = originalData[col.field]; + } + } + } + } else { + // 레거시: cardLayout 사용 + for (const row of cardLayout) { + for (const col of row.columns) { + if (col.sourceConfig) { + if (col.sourceConfig.type === "direct") { + cardData[col.field] = originalData[col.sourceConfig.sourceColumn || col.field]; + } else if (col.sourceConfig.type === "join" && col.sourceConfig.joinTable) { + cardData[col.field] = null; // 조인은 나중에 일괄 처리 + } else if (col.sourceConfig.type === "manual") { + cardData[col.field] = null; + } + } else { + cardData[col.field] = originalData[col.field]; } - } else { - cardData[col.field] = originalData[col.field]; } } } @@ -424,6 +544,14 @@ export function RepeatScreenModalComponent({ // 디자인 모드 렌더링 if (isDesignMode) { + // 행 타입별 개수 계산 + const rowTypeCounts = { + header: contentRows.filter((r) => r.type === "header").length, + aggregation: contentRows.filter((r) => r.type === "aggregation").length, + table: contentRows.filter((r) => r.type === "table").length, + fields: contentRows.filter((r) => r.type === "fields").length, + }; + return (
{/* 아이콘 */}
- {cardMode === "withTable" ? : } +
{/* 제목 */}
Repeat Screen Modal
반복 화면 모달
- - {cardMode === "withTable" ? "테이블 모드" : "단순 모드"} - + v3 자유 레이아웃 +
+ + {/* 행 구성 정보 */} +
+ {contentRows.length > 0 ? ( + <> + {rowTypeCounts.header > 0 && ( + + 헤더 {rowTypeCounts.header}개 + + )} + {rowTypeCounts.aggregation > 0 && ( + + 집계 {rowTypeCounts.aggregation}개 + + )} + {rowTypeCounts.table > 0 && ( + + 테이블 {rowTypeCounts.table}개 + + )} + {rowTypeCounts.fields > 0 && ( + + 필드 {rowTypeCounts.fields}개 + + )} + + ) : ( + 행 없음 + )}
{/* 통계 정보 */}
- {cardMode === "simple" ? ( - <> -
-
{cardLayout.length}
-
행 (Rows)
-
-
-
-
- {cardLayout.reduce((sum, row) => sum + row.columns.length, 0)} -
-
컬럼 (Columns)
-
- - ) : ( - <> -
-
{tableLayout?.headerRows?.length || 0}
-
헤더 행
-
-
-
-
{tableLayout?.tableColumns?.length || 0}
-
테이블 컬럼
-
-
-
-
{grouping?.aggregations?.length || 0}
-
집계
-
- - )} +
+
{contentRows.length}
+
행 (Rows)
+
-
{dataSource?.sourceTable ? "✓" : "○"}
+
{grouping?.aggregations?.length || 0}
+
집계 설정
+
+
+
+
{dataSource?.sourceTable ? 1 : 0}
데이터 소스
+ {/* 데이터 소스 정보 */} + {dataSource?.sourceTable && ( +
+ 소스 테이블: {dataSource.sourceTable} + {dataSource.filterField && (필터: {dataSource.filterField})} +
+ )} + {/* 그룹핑 정보 */} {grouping?.enabled && (
@@ -494,9 +635,16 @@ export function RepeatScreenModalComponent({
)} + {/* 카드 제목 정보 */} + {showCardTitle && cardTitle && ( +
+ 카드 제목: {cardTitle} +
+ )} + {/* 설정 안내 */}
- 오른쪽 패널에서 카드 레이아웃과 데이터 소스를 설정하세요 + 오른쪽 패널에서 행을 추가하고 타입(헤더/집계/테이블/필드)을 선택하세요
@@ -526,8 +674,12 @@ export function RepeatScreenModalComponent({ ); } - // WithTable 모드 렌더링 - if (cardMode === "withTable" && grouping?.enabled) { + // 🆕 v3: 자유 레이아웃 렌더링 (contentRows 사용) + const useNewLayout = contentRows && contentRows.length > 0; + const useGrouping = grouping?.enabled; + + // 그룹핑 모드 렌더링 + if (useGrouping) { return (
@@ -540,77 +692,90 @@ export function RepeatScreenModalComponent({ card._rows.some((r) => r._isDirty) && "border-primary shadow-lg" )} > - - - {getCardTitle(card._representativeData, cardIndex)} - {card._rows.some((r) => r._isDirty) && ( - - 수정됨 - - )} - - + {/* 카드 제목 (선택사항) */} + {showCardTitle && ( + + + {getCardTitle(card._representativeData, cardIndex)} + {card._rows.some((r) => r._isDirty) && ( + + 수정됨 + + )} + + + )} - {/* 헤더 영역 (그룹 대표값, 집계값) */} - {tableLayout?.headerRows && tableLayout.headerRows.length > 0 && ( -
- {tableLayout.headerRows.map((row, rowIndex) => ( -
- {row.columns.map((col, colIndex) => ( -
- {renderHeaderColumn(col, card, grouping?.aggregations || [])} + {/* 🆕 v3: contentRows 기반 렌더링 */} + {useNewLayout ? ( + contentRows.map((contentRow, rowIndex) => ( +
+ {renderContentRow(contentRow, card, grouping?.aggregations || [], handleRowDataChange)} +
+ )) + ) : ( + // 레거시: tableLayout 사용 + <> + {tableLayout?.headerRows && tableLayout.headerRows.length > 0 && ( +
+ {tableLayout.headerRows.map((row, rowIndex) => ( +
+ {row.columns.map((col, colIndex) => ( +
+ {renderHeaderColumn(col, card, grouping?.aggregations || [])} +
+ ))}
))}
- ))} -
- )} + )} - {/* 테이블 영역 */} - {tableLayout?.tableColumns && tableLayout.tableColumns.length > 0 && ( -
- - - - {tableLayout.tableColumns.map((col) => ( - - {col.label} - - ))} - - - - {card._rows.map((row) => ( - - {tableLayout.tableColumns.map((col) => ( - - {renderTableCell(col, row, (value) => - handleRowDataChange(card._cardId, row._rowId, col.field, value) - )} - + {tableLayout?.tableColumns && tableLayout.tableColumns.length > 0 && ( +
+
+ + + {tableLayout.tableColumns.map((col) => ( + + {col.label} + + ))} + + + + {card._rows.map((row) => ( + + {tableLayout.tableColumns.map((col) => ( + + {renderTableCell(col, row, (value) => + handleRowDataChange(card._cardId, row._rowId, col.field, value) + )} + + ))} + ))} - - ))} - -
-
+ + +
+ )} + )} @@ -635,7 +800,7 @@ export function RepeatScreenModalComponent({ ); } - // Simple 모드 렌더링 + // 단순 모드 렌더링 (그룹핑 없음) return (
@@ -644,29 +809,44 @@ export function RepeatScreenModalComponent({ key={card._cardId} className={cn("transition-shadow", showCardBorder && "border-2", card._isDirty && "border-primary shadow-lg")} > - - - {getCardTitle(card, cardIndex)} - {card._isDirty && (수정됨)} - - + {/* 카드 제목 (선택사항) */} + {showCardTitle && ( + + + {getCardTitle(card, cardIndex)} + {card._isDirty && (수정됨)} + + + )} - {cardLayout.map((row, rowIndex) => ( -
- {row.columns.map((col, colIndex) => ( -
- {renderColumn(col, card, (value) => handleCardDataChange(card._cardId, col.field, value))} -
- ))} -
- ))} + {/* 🆕 v3: contentRows 기반 렌더링 */} + {useNewLayout ? ( + contentRows.map((contentRow, rowIndex) => ( +
+ {renderSimpleContentRow(contentRow, card, (value, field) => + handleCardDataChange(card._cardId, field, value) + )} +
+ )) + ) : ( + // 레거시: cardLayout 사용 + cardLayout.map((row, rowIndex) => ( +
+ {row.columns.map((col, colIndex) => ( +
+ {renderColumn(col, card, (value) => handleCardDataChange(card._cardId, col.field, value))} +
+ ))} +
+ )) + )}
))} @@ -690,6 +870,350 @@ export function RepeatScreenModalComponent({ ); } +// 🆕 v3: contentRow 렌더링 (그룹핑 모드) +function renderContentRow( + contentRow: CardContentRowConfig, + card: GroupedCardData, + aggregations: AggregationConfig[], + onRowDataChange: (cardId: string, rowId: string, field: string, value: any) => void +) { + switch (contentRow.type) { + case "header": + case "fields": + // contentRow에서 직접 columns 가져오기 (v3 구조) + const headerColumns = contentRow.columns || []; + + if (headerColumns.length === 0) { + return ( +
+ 헤더 컬럼이 설정되지 않았습니다. +
+ ); + } + + return ( +
+ {headerColumns.map((col, colIndex) => ( +
+ {renderHeaderColumn(col, card, aggregations)} +
+ ))} +
+ ); + + case "aggregation": + // contentRow에서 직접 aggregationFields 가져오기 (v3 구조) + const aggFields = contentRow.aggregationFields || []; + + if (aggFields.length === 0) { + return ( +
+ 집계 필드가 설정되지 않았습니다. (레이아웃 탭에서 집계 필드를 추가하세요) +
+ ); + } + + return ( +
+ {aggFields.map((aggField, fieldIndex) => { + // 집계 결과에서 값 가져오기 (aggregationResultField 사용) + const value = card._aggregations?.[aggField.aggregationResultField] || 0; + return ( +
+
{aggField.label || aggField.aggregationResultField}
+
+ {typeof value === "number" ? value.toLocaleString() : value || "-"} +
+
+ ); + })} +
+ ); + + case "table": + // contentRow에서 직접 tableColumns 가져오기 (v3 구조) + const tableColumns = contentRow.tableColumns || []; + + if (tableColumns.length === 0) { + return ( +
+ 테이블 컬럼이 설정되지 않았습니다. (레이아웃 탭에서 테이블 컬럼을 추가하세요) +
+ ); + } + + return ( +
+ {contentRow.tableTitle && ( +
+ {contentRow.tableTitle} +
+ )} + + {contentRow.showTableHeader !== false && ( + + + {tableColumns.map((col) => ( + + {col.label} + + ))} + + + )} + + {card._rows.map((row) => ( + + {tableColumns.map((col) => ( + + {renderTableCell(col, row, (value) => + onRowDataChange(card._cardId, row._rowId, col.field, value) + )} + + ))} + + ))} + +
+
+ ); + + default: + return null; + } +} + +// 🆕 v3: contentRow 렌더링 (단순 모드) +function renderSimpleContentRow( + contentRow: CardContentRowConfig, + card: CardData, + onChange: (value: any, field: string) => void +) { + switch (contentRow.type) { + case "header": + case "fields": + return ( +
+ {(contentRow.columns || []).map((col, colIndex) => ( +
+ {renderColumn(col, card, (value) => onChange(value, col.field))} +
+ ))} +
+ ); + + case "aggregation": + // 단순 모드에서도 집계 표시 (단일 카드 기준) + // contentRow에서 직접 aggregationFields 가져오기 (v3 구조) + const aggFields = contentRow.aggregationFields || []; + + if (aggFields.length === 0) { + return ( +
+ 집계 필드가 설정되지 않았습니다. +
+ ); + } + + return ( +
+ {aggFields.map((aggField, fieldIndex) => { + // 단순 모드에서는 카드 데이터에서 직접 값을 가져옴 (aggregationResultField 사용) + const value = card[aggField.aggregationResultField] || card._originalData?.[aggField.aggregationResultField]; + return ( +
+
{aggField.label || aggField.aggregationResultField}
+
+ {typeof value === "number" ? value.toLocaleString() : value || "-"} +
+
+ ); + })} +
+ ); + + case "table": + // 단순 모드에서도 테이블 표시 (단일 행) + // contentRow에서 직접 tableColumns 가져오기 (v3 구조) + const tableColumns = contentRow.tableColumns || []; + + if (tableColumns.length === 0) { + return ( +
+ 테이블 컬럼이 설정되지 않았습니다. +
+ ); + } + + return ( +
+ {contentRow.tableTitle && ( +
+ {contentRow.tableTitle} +
+ )} + + {contentRow.showTableHeader !== false && ( + + + {tableColumns.map((col) => ( + + {col.label} + + ))} + + + )} + + {/* 단순 모드: 카드 자체가 하나의 행 */} + + {tableColumns.map((col) => ( + + {renderSimpleTableCell(col, card, (value) => onChange(value, col.field))} + + ))} + + +
+
+ ); + + default: + return null; + } +} + +// 단순 모드 테이블 셀 렌더링 +function renderSimpleTableCell( + col: TableColumnConfig, + card: CardData, + onChange: (value: any) => void +) { + const value = card[col.field] || card._originalData?.[col.field]; + + if (!col.editable) { + // 읽기 전용 + if (col.type === "number") { + return typeof value === "number" ? value.toLocaleString() : value || "-"; + } + return value || "-"; + } + + // 편집 가능 + switch (col.type) { + case "number": + return ( + onChange(parseFloat(e.target.value) || 0)} + className="h-8 text-sm" + /> + ); + case "date": + return ( + onChange(e.target.value)} + className="h-8 text-sm" + /> + ); + case "select": + return ( + + ); + default: + return ( + onChange(e.target.value)} + className="h-8 text-sm" + /> + ); + } +} + // 배경색 클래스 변환 function getBackgroundClass(color: string): string { const colorMap: Record = { diff --git a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx index d55e557c..ab8c962d 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx +++ b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -21,6 +21,8 @@ import { AggregationConfig, TableLayoutConfig, TableColumnConfig, + CardContentRowConfig, + AggregationDisplayConfig, } from "./types"; import { tableManagementApi } from "@/lib/api/tableManagement"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; @@ -119,6 +121,305 @@ function SourceColumnSelector({ ); } +// 카드 제목 편집기 - 직접 입력 + 필드 삽입 방식 +function CardTitleEditor({ + sourceTable, + currentValue, + onChange, +}: { + sourceTable: string; + currentValue: string; + onChange: (value: string) => void; +}) { + const [columns, setColumns] = useState<{ columnName: string; displayName?: string }[]>([]); + const [isLoading, setIsLoading] = useState(false); + const [open, setOpen] = useState(false); + const [localValue, setLocalValue] = useState(currentValue || ""); + const inputRef = React.useRef(null); + + useEffect(() => { + setLocalValue(currentValue || ""); + }, [currentValue]); + + useEffect(() => { + const loadColumns = async () => { + if (!sourceTable) { + setColumns([]); + return; + } + + setIsLoading(true); + try { + const response = await tableManagementApi.getColumnList(sourceTable); + if (response.success && response.data) { + setColumns(response.data.columns || []); + } + } catch (error) { + console.error("컬럼 로드 실패:", error); + setColumns([]); + } finally { + setIsLoading(false); + } + }; + loadColumns(); + }, [sourceTable]); + + // 필드 삽입 (현재 커서 위치 또는 끝에) + const insertField = (fieldName: string) => { + const newValue = localValue ? `${localValue} - {${fieldName}}` : `{${fieldName}}`; + setLocalValue(newValue); + onChange(newValue); + setOpen(false); + }; + + // 추천 템플릿 + const templateOptions = useMemo(() => { + const options = [ + { value: "카드 {index}", label: "카드 {index} - 순번만" }, + ]; + + if (columns.length > 0) { + // part_code - part_name 패턴 찾기 + const codeCol = columns.find((c) => + c.columnName.toLowerCase().includes("code") || c.columnName.toLowerCase().includes("no") + ); + const nameCol = columns.find((c) => + c.columnName.toLowerCase().includes("name") && !c.columnName.toLowerCase().includes("code") + ); + + if (codeCol && nameCol) { + options.push({ + value: `{${codeCol.columnName}} - {${nameCol.columnName}}`, + label: `{${codeCol.columnName}} - {${nameCol.columnName}} (추천)`, + }); + } + + // 첫 번째 컬럼 단일 + const firstCol = columns[0]; + options.push({ + value: `{${firstCol.columnName}}`, + label: `{${firstCol.columnName}}${firstCol.displayName ? ` - ${firstCol.displayName}` : ""}`, + }); + } + + return options; + }, [columns]); + + // 입력값 변경 핸들러 + const handleInputChange = (e: React.ChangeEvent) => { + setLocalValue(e.target.value); + }; + + // 입력 완료 (blur 또는 Enter) + const handleInputBlur = () => { + if (localValue !== currentValue) { + onChange(localValue); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleInputBlur(); + } + }; + + return ( +
+ {/* 직접 입력 필드 */} +
+ + + + + + + + + + 없음 + + {/* 추천 템플릿 */} + + {templateOptions.map((opt) => ( + { + setLocalValue(opt.value); + onChange(opt.value); + setOpen(false); + }} + className="text-[10px] py-1" + > + {opt.label} + + ))} + + + {/* 필드 삽입 */} + {columns.length > 0 && ( + + insertField("index")} + className="text-[10px] py-1" + > + + index - 순번 + + {columns.map((col) => ( + insertField(col.columnName)} + className="text-[10px] py-1" + > + + + {col.columnName} + {col.displayName && ( + - {col.displayName} + )} + + + ))} + + )} + + + + +
+ + {/* 안내 텍스트 */} +

+ 직접 입력하거나 + 버튼으로 필드 추가. 예: {"{part_code} - {part_name}"} +

+
+ ); +} + +// 집계 설정 아이템 (로컬 상태 관리로 입력 시 리렌더링 방지) +function AggregationConfigItem({ + agg, + index, + sourceTable, + onUpdate, + onRemove, +}: { + agg: AggregationConfig; + index: number; + sourceTable: string; + onUpdate: (updates: Partial) => void; + onRemove: () => void; +}) { + const [localLabel, setLocalLabel] = useState(agg.label || ""); + const [localResultField, setLocalResultField] = useState(agg.resultField || ""); + + // agg 변경 시 로컬 상태 동기화 + useEffect(() => { + setLocalLabel(agg.label || ""); + setLocalResultField(agg.resultField || ""); + }, [agg.label, agg.resultField]); + + return ( +
+
+ + 집계 {index + 1} + + +
+ +
+ + onUpdate({ sourceField: value })} + placeholder="합계할 필드" + /> +
+ +
+
+ + +
+ +
+ + setLocalLabel(e.target.value)} + onBlur={() => onUpdate({ label: localLabel })} + onKeyDown={(e) => { + if (e.key === "Enter") { + onUpdate({ label: localLabel }); + } + }} + placeholder="총수주잔량" + className="h-6 text-[10px]" + /> +
+
+ +
+ + setLocalResultField(e.target.value)} + onBlur={() => onUpdate({ resultField: localResultField })} + onKeyDown={(e) => { + if (e.key === "Enter") { + onUpdate({ resultField: localResultField }); + } + }} + placeholder="total_balance_qty" + className="h-6 text-[10px]" + /> +
+
+ ); +} + // 테이블 선택기 (Combobox) - 240px 최적화 function TableSelector({ value, onChange }: { value: string; onChange: (value: string) => void }) { const [tables, setTables] = useState<{ tableName: string; displayName?: string }[]>([]); @@ -131,7 +432,11 @@ function TableSelector({ value, onChange }: { value: string; onChange: (value: s try { const response = await tableManagementApi.getTableList(); if (response.success && response.data) { - setTables(response.data.tables || []); + // API 응답이 배열인 경우와 객체인 경우 모두 처리 + const tableData = Array.isArray(response.data) + ? response.data + : (response.data as any).tables || response.data || []; + setTables(tableData); } } catch (error) { console.error("테이블 로드 실패:", error); @@ -190,21 +495,36 @@ function TableSelector({ value, onChange }: { value: string; onChange: (value: s ); } +// 모듈 레벨에서 탭 상태 유지 (컴포넌트 리마운트 시에도 유지) +let persistedActiveTab = "basic"; + export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenModalConfigPanelProps) { - const [localConfig, setLocalConfig] = useState>({ - cardLayout: [], + const [localConfig, setLocalConfig] = useState>(() => ({ dataSource: { sourceTable: "" }, saveMode: "all", cardSpacing: "24px", showCardBorder: true, + showCardTitle: true, cardTitle: "카드 {index}", - cardMode: "simple", grouping: { enabled: false, groupByField: "", aggregations: [] }, + contentRows: [], // 🆕 v3: 자유 레이아웃 + // 레거시 호환 + cardMode: "simple", + cardLayout: [], tableLayout: { headerRows: [], tableColumns: [] }, ...config, - }); + })); const [allTables, setAllTables] = useState<{ tableName: string; displayName?: string }[]>([]); + + // 탭 상태 유지 (모듈 레벨 변수와 동기화) + const [activeTab, setActiveTab] = useState(persistedActiveTab); + + // 탭 변경 시 모듈 레벨 변수도 업데이트 + const handleTabChange = (tab: string) => { + persistedActiveTab = tab; + setActiveTab(tab); + }; // 테이블 목록 로드 useEffect(() => { @@ -212,7 +532,11 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM try { const response = await tableManagementApi.getTableList(); if (response.success && response.data) { - setAllTables(response.data.tables || []); + // API 응답이 배열인 경우와 객체인 경우 모두 처리 + const tableData = Array.isArray(response.data) + ? response.data + : (response.data as any).tables || response.data || []; + setAllTables(tableData); } } catch (error) { console.error("테이블 로드 실패:", error); @@ -238,13 +562,17 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM ); // Immediate update for select/switch fields - const updateConfig = (updates: Partial) => { + // requestAnimationFrame을 사용하여 React 렌더링 사이클 이후에 onChange 호출 + const updateConfig = useCallback((updates: Partial) => { setLocalConfig((prev) => { const newConfig = { ...prev, ...updates }; - onChange(newConfig); + // 비동기로 onChange 호출하여 현재 렌더링 사이클 완료 후 실행 + requestAnimationFrame(() => { + onChange(newConfig); + }); return newConfig; }); - }; + }, [onChange]); // === 그룹핑 관련 함수 === const updateGrouping = (updates: Partial) => { @@ -372,7 +700,125 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM updateTableLayout({ headerRows: newRows }); }; - // === Simple 모드 행/컬럼 관련 함수 === + // === 🆕 v3: contentRows 관련 함수 === + const addContentRow = (type: CardContentRowConfig["type"]) => { + const newRow: CardContentRowConfig = { + id: `crow-${Date.now()}`, + type, + // 타입별 기본값 + ...(type === "header" || type === "fields" + ? { columns: [], layout: "horizontal", gap: "16px" } + : {}), + ...(type === "aggregation" + ? { aggregationFields: [], aggregationLayout: "horizontal" } + : {}), + ...(type === "table" + ? { tableColumns: [], showTableHeader: true } + : {}), + }; + updateConfig({ + contentRows: [...(localConfig.contentRows || []), newRow], + }); + }; + + const removeContentRow = (rowIndex: number) => { + const newRows = [...(localConfig.contentRows || [])]; + newRows.splice(rowIndex, 1); + updateConfig({ contentRows: newRows }); + }; + + const updateContentRow = (rowIndex: number, updates: Partial) => { + const newRows = [...(localConfig.contentRows || [])]; + newRows[rowIndex] = { ...newRows[rowIndex], ...updates }; + updateConfig({ contentRows: newRows }); + }; + + // contentRow 내 컬럼 관리 (header/fields 타입) + const addContentRowColumn = (rowIndex: number) => { + const newRows = [...(localConfig.contentRows || [])]; + const newColumn: CardColumnConfig = { + id: `col-${Date.now()}`, + field: "", + label: "", + type: "text", + width: "auto", + editable: false, + }; + newRows[rowIndex].columns = [...(newRows[rowIndex].columns || []), newColumn]; + updateConfig({ contentRows: newRows }); + }; + + const removeContentRowColumn = (rowIndex: number, colIndex: number) => { + const newRows = [...(localConfig.contentRows || [])]; + newRows[rowIndex].columns?.splice(colIndex, 1); + updateConfig({ contentRows: newRows }); + }; + + const updateContentRowColumn = (rowIndex: number, colIndex: number, updates: Partial) => { + const newRows = [...(localConfig.contentRows || [])]; + if (newRows[rowIndex].columns) { + newRows[rowIndex].columns![colIndex] = { ...newRows[rowIndex].columns![colIndex], ...updates }; + } + updateConfig({ contentRows: newRows }); + }; + + // contentRow 내 집계 필드 관리 (aggregation 타입) + const addContentRowAggField = (rowIndex: number) => { + const newRows = [...(localConfig.contentRows || [])]; + const newAggField: AggregationDisplayConfig = { + aggregationResultField: "", + label: "", + }; + newRows[rowIndex].aggregationFields = [...(newRows[rowIndex].aggregationFields || []), newAggField]; + updateConfig({ contentRows: newRows }); + }; + + const removeContentRowAggField = (rowIndex: number, fieldIndex: number) => { + const newRows = [...(localConfig.contentRows || [])]; + newRows[rowIndex].aggregationFields?.splice(fieldIndex, 1); + updateConfig({ contentRows: newRows }); + }; + + const updateContentRowAggField = (rowIndex: number, fieldIndex: number, updates: Partial) => { + const newRows = [...(localConfig.contentRows || [])]; + if (newRows[rowIndex].aggregationFields) { + newRows[rowIndex].aggregationFields![fieldIndex] = { + ...newRows[rowIndex].aggregationFields![fieldIndex], + ...updates, + }; + } + updateConfig({ contentRows: newRows }); + }; + + // contentRow 내 테이블 컬럼 관리 (table 타입) + const addContentRowTableColumn = (rowIndex: number) => { + const newRows = [...(localConfig.contentRows || [])]; + const newCol: TableColumnConfig = { + id: `tcol-${Date.now()}`, + field: "", + label: "", + type: "text", + editable: false, + }; + newRows[rowIndex].tableColumns = [...(newRows[rowIndex].tableColumns || []), newCol]; + updateConfig({ contentRows: newRows }); + }; + + const removeContentRowTableColumn = (rowIndex: number, colIndex: number) => { + const newRows = [...(localConfig.contentRows || [])]; + newRows[rowIndex].tableColumns?.splice(colIndex, 1); + updateConfig({ contentRows: newRows }); + }; + + const updateContentRowTableColumn = (rowIndex: number, colIndex: number, updates: Partial) => { + const newRows = [...(localConfig.contentRows || [])]; + if (newRows[rowIndex].tableColumns) { + newRows[rowIndex].tableColumns![colIndex] = { ...newRows[rowIndex].tableColumns![colIndex], ...updates }; + } + updateConfig({ contentRows: newRows }); + }; + + // === (레거시) Simple 모드 행/컬럼 관련 함수 === const addRow = () => { const newRow: CardRowConfig = { id: `row-${Date.now()}`, @@ -430,7 +876,7 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM return (
- + 기본 @@ -451,20 +897,31 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM

카드 설정

-
- - { - setLocalConfig((prev) => ({ ...prev, cardTitle: e.target.value })); - updateConfigDebounced({ cardTitle: e.target.value }); - }} - placeholder="{part_code} - {part_name}" - className="h-7 text-[10px]" + {/* 카드 제목 표시 여부 */} +
+ + updateConfig({ showCardTitle: checked })} + className="scale-75" /> -

{"{field}"}: 필드값 사용

+ {/* 카드 제목 설정 (표시할 때만) */} + {localConfig.showCardTitle && ( +
+ + { + setLocalConfig((prev) => ({ ...prev, cardTitle: value })); + updateConfig({ cardTitle: value }); + }} + /> +
+ )} +
- - {/* 카드 모드 선택 */} -
- -
- -