From a67b53038fddc2009bf38bea653d5655c405f43c Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 20 Jan 2026 09:42:33 +0900 Subject: [PATCH] =?UTF-8?q?v2-repeat-screen-modal=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C:=20v2-repea?= =?UTF-8?q?t-screen-modal=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=EC=99=80?= =?UTF-8?q?=20=EA=B4=80=EB=A0=A8=EB=90=9C=20=EB=AA=A8=EB=93=A0=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EC=9D=84=20=EC=82=AD=EC=A0=9C=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EB=B2=A0=EC=9D=B4=EC=8A=A4=EB=A5=BC=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=ED=95=98=EC=98=80=EC=8A=B5=EB=8B=88=EB=8B=A4?= =?UTF-8?q?.=20=EC=9D=B4=EB=A1=9C=20=EC=9D=B8=ED=95=B4=20=EB=8D=94=20?= =?UTF-8?q?=EC=9D=B4=EC=83=81=20=EC=82=AC=EC=9A=A9=EB=90=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8?= =?UTF-8?q?=EA=B0=80=20=EC=A0=9C=EA=B1=B0=EB=90=98=EC=96=B4=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=ED=9A=A8=EC=9C=A8=EC=84=B1=EC=9D=B4=20=ED=96=A5?= =?UTF-8?q?=EC=83=81=EB=90=98=EC=97=88=EC=8A=B5=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/package-lock.json | 13 +- .../screen/panels/ComponentsPanel.tsx | 1 + frontend/lib/registry/components/index.ts | 1 - .../v2-repeat-screen-modal/README.md | 409 -- .../RepeatScreenModalComponent.tsx | 3179 --------- .../RepeatScreenModalConfigPanel.tsx | 5745 ----------------- .../RepeatScreenModalRenderer.tsx | 13 - .../v2-repeat-screen-modal/index.ts | 114 - .../v2-repeat-screen-modal/types.ts | 525 -- .../v2-table-list/TableListComponent.tsx | 18 +- .../v2-table-list/TableListConfigPanel.tsx | 14 +- .../components/v2-table-list/index.ts | 18 +- .../lib/utils/getComponentConfigPanel.tsx | 1 - 13 files changed, 34 insertions(+), 10017 deletions(-) delete mode 100644 frontend/lib/registry/components/v2-repeat-screen-modal/README.md delete mode 100644 frontend/lib/registry/components/v2-repeat-screen-modal/RepeatScreenModalComponent.tsx delete mode 100644 frontend/lib/registry/components/v2-repeat-screen-modal/RepeatScreenModalConfigPanel.tsx delete mode 100644 frontend/lib/registry/components/v2-repeat-screen-modal/RepeatScreenModalRenderer.tsx delete mode 100644 frontend/lib/registry/components/v2-repeat-screen-modal/index.ts delete mode 100644 frontend/lib/registry/components/v2-repeat-screen-modal/types.ts diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index 43b698d2..7e1108c3 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -1044,7 +1044,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -2372,7 +2371,6 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "license": "MIT", - "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -3476,7 +3474,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz", "integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3713,7 +3710,6 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -3931,7 +3927,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4458,7 +4453,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -5669,7 +5663,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -7432,7 +7425,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -8402,6 +8394,7 @@ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", + "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -9290,7 +9283,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -10141,6 +10133,7 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -10949,7 +10942,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -11055,7 +11047,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/frontend/components/screen/panels/ComponentsPanel.tsx b/frontend/components/screen/panels/ComponentsPanel.tsx index 2bc4a214..5b74519c 100644 --- a/frontend/components/screen/panels/ComponentsPanel.tsx +++ b/frontend/components/screen/panels/ComponentsPanel.tsx @@ -132,6 +132,7 @@ export function ComponentsPanel({ "pivot-grid", // → v2-pivot-grid "table-search-widget", // → v2-table-search-widget "tabs", // → v2-tabs + "tabs-widget", // → v2-tabs-widget ]; return { diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 718840b0..3201639c 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -97,7 +97,6 @@ import "./v2-numbering-rule/NumberingRuleRenderer"; import "./v2-table-list/TableListRenderer"; import "./v2-text-display/TextDisplayRenderer"; import "./v2-pivot-grid/PivotGridRenderer"; -import "./v2-repeat-screen-modal/RepeatScreenModalRenderer"; import "./v2-divider-line/DividerLineRenderer"; import "./v2-repeat-container/RepeatContainerRenderer"; import "./v2-section-card/SectionCardRenderer"; diff --git a/frontend/lib/registry/components/v2-repeat-screen-modal/README.md b/frontend/lib/registry/components/v2-repeat-screen-modal/README.md deleted file mode 100644 index cb22964d..00000000 --- a/frontend/lib/registry/components/v2-repeat-screen-modal/README.md +++ /dev/null @@ -1,409 +0,0 @@ -# RepeatScreenModal 컴포넌트 v3.1 - -## 개요 - -`RepeatScreenModal`은 선택한 데이터를 기반으로 여러 개의 카드를 생성하고, 각 카드의 내부 레이아웃을 자유롭게 구성할 수 있는 컴포넌트입니다. - -## v3.1 주요 변경사항 (2025-11-28) - -### 1. 외부 테이블 데이터 소스 - -테이블 행에서 **외부 테이블의 데이터를 조회**하여 표시할 수 있습니다. - -``` -예시: 수주 관리에서 출하 계획 이력 조회 -┌─────────────────────────────────────────────────────────────────┐ -│ 카드: 품목 A │ -├─────────────────────────────────────────────────────────────────┤ -│ [행 1] 헤더: 품목코드, 품목명 │ -├─────────────────────────────────────────────────────────────────┤ -│ [행 2] 테이블: shipment_plan 테이블에서 조회 │ -│ → sales_order_id로 조인하여 출하 계획 이력 표시 │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 2. 테이블 행 CRUD - -테이블 행에서 **행 추가/수정/삭제** 기능을 지원합니다. - -- **추가**: 새 행 추가 버튼으로 빈 행 생성 -- **수정**: 편집 가능한 컬럼 직접 수정 -- **삭제**: 행 삭제 (확인 팝업 옵션) - -### 3. Footer 버튼 영역 - -모달 하단에 **커스터마이징 가능한 버튼 영역**을 제공합니다. - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ 카드 내용... │ -├─────────────────────────────────────────────────────────────────┤ -│ [초기화] [취소] [저장] │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 4. 집계 연산식 지원 - -집계 행에서 **컬럼 간 사칙연산**을 지원합니다. - -```typescript -// 예: 미출하 수량 = 수주수량 - 출하수량 -{ - sourceType: "formula", - formula: "{order_qty} - {ship_qty}", - label: "미출하 수량" -} -``` - ---- - -## v3 주요 변경사항 (기존) - -### 자유 레이아웃 시스템 - -기존의 "simple 모드 / withTable 모드" 구분을 없애고, **행(Row)을 추가하고 각 행마다 타입을 선택**하는 방식으로 변경되었습니다. - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ 카드 │ -├─────────────────────────────────────────────────────────────────┤ -│ [행 1] 타입: 헤더 → 품목코드, 품목명, 규격 │ -├─────────────────────────────────────────────────────────────────┤ -│ [행 2] 타입: 집계 → 총수주잔량, 현재고, 가용재고 │ -├─────────────────────────────────────────────────────────────────┤ -│ [행 3] 타입: 테이블 → 수주번호, 거래처, 납기일, 출하계획 │ -├─────────────────────────────────────────────────────────────────┤ -│ [행 4] 타입: 테이블 → 또 다른 테이블도 가능! │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 행 타입 - -| 타입 | 설명 | 사용 시나리오 | -|------|------|---------------| -| **헤더 (header)** | 필드들을 가로/세로로 나열 | 품목정보, 거래처정보 표시 | -| **필드 (fields)** | 헤더와 동일, 편집 가능 | 폼 입력 영역 | -| **집계 (aggregation)** | 그룹 내 데이터 집계값 표시 | 총수량, 합계금액 등 | -| **테이블 (table)** | 그룹 내 각 행을 테이블로 표시 | 수주목록, 품목목록 등 | - ---- - -## 설정 방법 - -### 1. 기본 설정 탭 - -- **카드 제목 표시**: 카드 상단에 제목을 표시할지 여부 -- **카드 제목 템플릿**: `{field_name}` 형식으로 동적 제목 생성 -- **카드 간격**: 카드 사이의 간격 (8px ~ 32px) -- **테두리**: 카드 테두리 표시 여부 -- **저장 모드**: 전체 저장 / 개별 저장 - -### 2. 데이터 소스 탭 - -- **소스 테이블**: 데이터를 조회할 테이블 -- **필터 필드**: formData에서 필터링할 필드 (예: selectedIds) - -### 3. 그룹 탭 - -- **그룹핑 활성화**: 여러 행을 하나의 카드로 묶을지 여부 -- **그룹 기준 필드**: 그룹핑할 필드 (예: part_code) -- **집계 설정**: - - 원본 필드: 합계할 필드 (예: balance_qty) - - 집계 타입: sum, count, avg, min, max - - 결과 필드명: 집계 결과를 저장할 필드명 - - 라벨: 표시될 라벨 - -### 4. 레이아웃 탭 - -#### 행 추가 - -4가지 타입의 행을 추가할 수 있습니다: -- **헤더**: 필드 정보 표시 (읽기전용) -- **집계**: 그룹 집계값 표시 -- **테이블**: 그룹 내 행들을 테이블로 표시 -- **필드**: 입력 필드 (편집가능) - -#### 헤더/필드 행 설정 - -- **방향**: 가로 / 세로 -- **배경색**: 없음, 파랑, 초록, 보라, 주황 -- **컬럼**: 필드명, 라벨, 타입, 너비, 편집 가능, 필수 -- **소스 설정**: 직접 / 조인 / 수동 -- **저장 설정**: 저장할 테이블과 컬럼 - -#### 집계 행 설정 - -- **레이아웃**: 가로 나열 / 그리드 -- **그리드 컬럼 수**: 2, 3, 4개 -- **집계 필드**: 그룹 탭에서 정의한 집계 결과 선택 -- **스타일**: 배경색, 폰트 크기 - -#### 테이블 행 설정 (v3.1 확장) - -- **테이블 제목**: 선택사항 -- **헤더 표시**: 테이블 헤더 표시 여부 -- **외부 테이블 데이터 소스**: (v3.1 신규) - - 소스 테이블: 조회할 외부 테이블 - - 조인 조건: 외부 테이블 키 ↔ 카드 데이터 키 - - 정렬: 정렬 컬럼 및 방향 -- **CRUD 설정**: (v3.1 신규) - - 추가: 새 행 추가 허용 - - 수정: 행 수정 허용 - - 삭제: 행 삭제 허용 (확인 팝업 옵션) -- **테이블 컬럼**: 필드명, 라벨, 타입, 너비, 편집 가능 -- **저장 설정**: 편집 가능한 컬럼의 저장 위치 - -### 5. Footer 탭 (v3.1 신규) - -- **Footer 사용**: Footer 영역 활성화 -- **위치**: 컨텐츠 아래 / 하단 고정 (sticky) -- **정렬**: 왼쪽 / 가운데 / 오른쪽 -- **버튼 설정**: - - 라벨: 버튼 텍스트 - - 액션: 저장 / 취소 / 닫기 / 초기화 / 커스텀 - - 스타일: 기본 / 보조 / 외곽선 / 삭제 / 고스트 - - 아이콘: 저장 / X / 초기화 / 없음 - ---- - -## 데이터 흐름 - -``` -1. formData에서 selectedIds 가져오기 - ↓ -2. 소스 테이블에서 해당 ID들의 데이터 조회 - ↓ -3. 그룹핑 활성화 시 groupByField 기준으로 그룹화 - ↓ -4. 각 그룹에 대해 집계값 계산 - ↓ -5. 외부 테이블 데이터 소스가 설정된 테이블 행의 데이터 로드 (v3.1) - ↓ -6. 카드 렌더링 (contentRows 기반) - ↓ -7. 사용자 편집 (CRUD 포함) - ↓ -8. Footer 버튼 또는 기본 저장 버튼으로 저장 - ↓ -9. 기본 데이터 + 외부 테이블 데이터 일괄 저장 -``` - ---- - -## 사용 예시 - -### 출하계획 등록 (v3.1 - 외부 테이블 + CRUD) - -```typescript -{ - showCardTitle: true, - cardTitle: "{part_code} - {part_name}", - dataSource: { - sourceTable: "sales_order_mng", - filterField: "selectedIds" - }, - grouping: { - enabled: true, - groupByField: "part_code", - aggregations: [ - { sourceField: "balance_qty", type: "sum", resultField: "total_balance", label: "총수주잔량" }, - { sourceField: "id", type: "count", resultField: "order_count", label: "수주건수" } - ] - }, - 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: [ - { sourceType: "aggregation", aggregationResultField: "total_balance", label: "총수주잔량", backgroundColor: "blue" }, - { sourceType: "formula", formula: "{order_qty} - {ship_qty}", label: "미출하 수량", backgroundColor: "orange" } - ] - }, - { - id: "row-3", - type: "table", - tableTitle: "출하 계획 이력", - showTableHeader: true, - // 외부 테이블에서 데이터 조회 - tableDataSource: { - enabled: true, - sourceTable: "shipment_plan", - joinConditions: [ - { sourceKey: "sales_order_id", referenceKey: "id" } - ], - orderBy: { column: "created_date", direction: "desc" } - }, - // CRUD 설정 - tableCrud: { - allowCreate: true, - allowUpdate: true, - allowDelete: true, - newRowDefaults: { - sales_order_id: "{id}", - status: "READY" - }, - deleteConfirm: { enabled: true } - }, - tableColumns: [ - { id: "tc1", field: "plan_date", label: "계획일", type: "date", editable: true }, - { id: "tc2", field: "plan_qty", label: "계획수량", type: "number", editable: true }, - { id: "tc3", field: "status", label: "상태", type: "text", editable: false }, - { id: "tc4", field: "memo", label: "비고", type: "text", editable: true } - ] - } - ], - // Footer 설정 - footerConfig: { - enabled: true, - position: "sticky", - alignment: "right", - buttons: [ - { id: "btn-cancel", label: "취소", action: "cancel", variant: "outline" }, - { id: "btn-save", label: "저장", action: "save", variant: "default", icon: "save" } - ] - } -} -``` - ---- - -## 타입 정의 (v3.1) - -### TableDataSourceConfig - -```typescript -interface TableDataSourceConfig { - enabled: boolean; // 외부 데이터 소스 사용 여부 - sourceTable: string; // 조회할 테이블 - joinConditions: JoinCondition[]; // 조인 조건 - orderBy?: { - column: string; // 정렬 컬럼 - direction: "asc" | "desc"; // 정렬 방향 - }; - limit?: number; // 최대 행 수 -} - -interface JoinCondition { - sourceKey: string; // 외부 테이블의 조인 키 - referenceKey: string; // 카드 데이터의 참조 키 - referenceType?: "card" | "row"; // 참조 소스 -} -``` - -### TableCrudConfig - -```typescript -interface TableCrudConfig { - allowCreate: boolean; // 행 추가 허용 - allowUpdate: boolean; // 행 수정 허용 - allowDelete: boolean; // 행 삭제 허용 - newRowDefaults?: Record; // 신규 행 기본값 ({field} 형식 지원) - deleteConfirm?: { - enabled: boolean; // 삭제 확인 팝업 - message?: string; // 확인 메시지 - }; - targetTable?: string; // 저장 대상 테이블 -} -``` - -### FooterConfig - -```typescript -interface FooterConfig { - enabled: boolean; // Footer 사용 여부 - buttons?: FooterButtonConfig[]; - position?: "sticky" | "static"; - alignment?: "left" | "center" | "right"; -} - -interface FooterButtonConfig { - id: string; - label: string; - action: "save" | "cancel" | "close" | "reset" | "custom"; - variant?: "default" | "secondary" | "outline" | "destructive" | "ghost"; - icon?: string; - disabled?: boolean; - customAction?: { - type: string; - config?: Record; - }; -} -``` - -### AggregationDisplayConfig (v3.1 확장) - -```typescript -interface AggregationDisplayConfig { - // 값 소스 타입 - sourceType: "aggregation" | "formula" | "external" | "externalFormula"; - - // aggregation: 기존 집계 결과 참조 - aggregationResultField?: string; - - // formula: 컬럼 간 연산 - formula?: string; // 예: "{order_qty} - {ship_qty}" - - // external: 외부 테이블 조회 (향후 구현) - externalSource?: ExternalValueSource; - - // externalFormula: 외부 테이블 + 연산 (향후 구현) - externalSources?: ExternalValueSource[]; - externalFormula?: string; - - // 표시 설정 - label: string; - icon?: string; - backgroundColor?: string; - textColor?: string; - fontSize?: "xs" | "sm" | "base" | "lg" | "xl" | "2xl"; - format?: "number" | "currency" | "percent"; - decimalPlaces?: number; -} -``` - ---- - -## 레거시 호환 - -v2에서 사용하던 `cardMode`, `cardLayout`, `tableLayout` 설정도 계속 지원됩니다. -새로운 프로젝트에서는 `contentRows`를 사용하는 것을 권장합니다. - ---- - -## 주의사항 - -1. **집계는 그룹핑 필수**: 집계 행은 그룹핑이 활성화되어 있어야 의미가 있습니다. -2. **테이블은 그룹핑 필수**: 테이블 행도 그룹핑이 활성화되어 있어야 그룹 내 행들을 표시할 수 있습니다. -3. **단순 모드**: 그룹핑 없이 사용하면 1행 = 1카드로 동작합니다. 이 경우 헤더/필드 타입만 사용 가능합니다. -4. **외부 테이블 CRUD**: 외부 테이블 데이터 소스가 설정된 테이블에서만 CRUD가 동작합니다. -5. **연산식**: 사칙연산(+, -, *, /)과 괄호만 지원됩니다. 복잡한 함수는 지원하지 않습니다. - ---- - -## 변경 이력 - -### v3.1 (2025-11-28) -- 외부 테이블 데이터 소스 기능 추가 -- 테이블 행 CRUD (추가/수정/삭제) 기능 추가 -- Footer 버튼 영역 기능 추가 -- 집계 연산식 (formula) 지원 추가 -- 다단계 조인 타입 정의 추가 (향후 구현 예정) - -### v3.0 -- 자유 레이아웃 시스템 도입 -- contentRows 기반 행 타입 선택 방식 -- 헤더/필드/집계/테이블 4가지 행 타입 지원 - -### v2.0 -- simple 모드 / withTable 모드 구분 -- cardLayout / tableLayout 분리 diff --git a/frontend/lib/registry/components/v2-repeat-screen-modal/RepeatScreenModalComponent.tsx b/frontend/lib/registry/components/v2-repeat-screen-modal/RepeatScreenModalComponent.tsx deleted file mode 100644 index 16cf7dfc..00000000 --- a/frontend/lib/registry/components/v2-repeat-screen-modal/RepeatScreenModalComponent.tsx +++ /dev/null @@ -1,3179 +0,0 @@ -"use client"; - -import React, { useState, useEffect, useMemo } from "react"; -import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Label } from "@/components/ui/label"; -import { Badge } from "@/components/ui/badge"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { Loader2, Save, X, Layers, Table as TableIcon, Plus, Trash2, RotateCcw, Pencil } from "lucide-react"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { - RepeatScreenModalProps, - CardData, - CardColumnConfig, - GroupedCardData, - CardRowData, - AggregationConfig, - TableColumnConfig, - CardContentRowConfig, - AggregationDisplayConfig, - FooterConfig, - FooterButtonConfig, - TableDataSourceConfig, - TableCrudConfig, -} from "./types"; -import { ComponentRendererProps } from "@/types/component"; -import { cn } from "@/lib/utils"; -import { apiClient } from "@/lib/api/client"; - -export interface RepeatScreenModalComponentProps extends ComponentRendererProps { - config?: RepeatScreenModalProps; - groupedData?: Record[]; // EditModal에서 전달하는 그룹 데이터 -} - -export function RepeatScreenModalComponent({ - component, - isDesignMode = false, - formData, - onFormDataChange, - config, - className, - groupedData: propsGroupedData, // EditModal에서 전달받는 그룹 데이터 - // DynamicComponentRenderer에서 전달되는 props (DOM 전달 방지) - _initialData, - _originalData: _propsOriginalData, - _groupedData, - ...props -}: RepeatScreenModalComponentProps & { _initialData?: any; _originalData?: any; _groupedData?: any }) { - // props에서도 groupedData를 추출 (DynamicWebTypeRenderer에서 전달될 수 있음) - // DynamicComponentRenderer에서는 _groupedData로 전달됨 - const groupedData = propsGroupedData || (props as any).groupedData || _groupedData; - const componentConfig = { - ...config, - ...component?.config, - }; - - // 설정 값 추출 - 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 grouping = componentConfig?.grouping; - - // 🆕 v3: 자유 레이아웃 - const contentRows = componentConfig?.contentRows || []; - - // 🆕 v3.1: Footer 설정 - const footerConfig = componentConfig?.footerConfig; - - // (레거시 호환) - const cardLayout = componentConfig?.cardLayout || []; - const cardMode = componentConfig?.cardMode || "simple"; - const tableLayout = componentConfig?.tableLayout; - - // 상태 - const [rawData, setRawData] = useState([]); // 원본 데이터 - const [cardsData, setCardsData] = useState([]); // simple 모드용 - const [groupedCardsData, setGroupedCardsData] = useState([]); // withTable 모드용 - const [isLoading, setIsLoading] = useState(false); - const [loadError, setLoadError] = useState(null); - const [isSaving, setIsSaving] = useState(false); - - // 🆕 v3.1: 외부 테이블 데이터 (테이블 행별로 관리) - const [externalTableData, setExternalTableData] = useState>({}); - // 🆕 v3.1: 삭제 확인 다이얼로그 - const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); - const [pendingDeleteInfo, setPendingDeleteInfo] = useState<{ - cardId: string; - rowId: string; - contentRowId: string; - } | null>(null); - - // 🆕 v3.13: 외부에서 저장 트리거 가능하도록 이벤트 리스너 추가 - useEffect(() => { - const handleTriggerSave = async (event: Event) => { - if (!(event instanceof CustomEvent)) return; - - console.log("[RepeatScreenModal] triggerRepeatScreenModalSave 이벤트 수신"); - - try { - setIsSaving(true); - - // 기존 데이터 저장 - if (cardMode === "withTable") { - await saveGroupedData(); - } else { - await saveSimpleData(); - } - - // 외부 테이블 데이터 저장 - await saveExternalTableData(); - - // 연동 저장 처리 (syncSaves) - await processSyncSaves(); - - console.log("[RepeatScreenModal] 외부 트리거 저장 완료"); - - // 저장 완료 이벤트 발생 - window.dispatchEvent(new CustomEvent("repeatScreenModalSaveComplete", { - detail: { success: true } - })); - - // 성공 콜백 실행 - if (event.detail?.onSuccess) { - event.detail.onSuccess(); - } - } catch (error: any) { - console.error("[RepeatScreenModal] 외부 트리거 저장 실패:", error); - - // 저장 실패 이벤트 발생 - window.dispatchEvent(new CustomEvent("repeatScreenModalSaveComplete", { - detail: { success: false, error: error.message } - })); - - // 실패 콜백 실행 - if (event.detail?.onError) { - event.detail.onError(error); - } - } finally { - setIsSaving(false); - } - }; - - window.addEventListener("triggerRepeatScreenModalSave", handleTriggerSave as EventListener); - return () => { - window.removeEventListener("triggerRepeatScreenModalSave", handleTriggerSave as EventListener); - }; - }, [cardMode, groupedCardsData, externalTableData, contentRows]); - - // 🆕 v3.9: beforeFormSave 이벤트 핸들러 - ButtonPrimary 저장 시 externalTableData를 formData에 병합 - useEffect(() => { - const handleBeforeFormSave = (event: Event) => { - if (!(event instanceof CustomEvent) || !event.detail?.formData) return; - - console.log("[RepeatScreenModal] beforeFormSave 이벤트 수신"); - console.log("[RepeatScreenModal] beforeFormSave - externalTableData:", externalTableData); - console.log("[RepeatScreenModal] beforeFormSave - groupedCardsData:", groupedCardsData.length, "개 카드"); - - // 외부 테이블 데이터에서 dirty 행만 추출하여 저장 데이터 준비 - const saveDataByTable: Record = {}; - - for (const [key, rows] of Object.entries(externalTableData)) { - // key 형식: cardId-contentRowId - const keyParts = key.split("-"); - const cardId = keyParts.slice(0, -1).join("-"); // contentRowId를 제외한 나머지가 cardId - - // contentRow 찾기 - const contentRow = contentRows.find((r) => key.includes(r.id)); - if (!contentRow?.tableDataSource?.enabled) continue; - - // 🆕 v3.13: 해당 카드의 대표 데이터 찾기 (joinConditions의 targetKey 값을 가져오기 위해) - const card = groupedCardsData.find((c) => c._cardId === cardId); - const representativeData = card?._representativeData || {}; - - const targetTable = contentRow.tableCrud?.targetTable || contentRow.tableDataSource.sourceTable; - - // dirty 행 또는 새로운 행 필터링 (삭제된 행 제외) - // 🆕 v3.13: _isNew 행도 포함 (새로 추가된 행은 _isDirty가 없을 수 있음) - const dirtyRows = rows.filter((row) => (row._isDirty || row._isNew) && !row._isDeleted); - - console.log(`[RepeatScreenModal] beforeFormSave - ${targetTable} 행 필터링:`, { - totalRows: rows.length, - dirtyRows: dirtyRows.length, - rowDetails: rows.map(r => ({ _isDirty: r._isDirty, _isNew: r._isNew, _isDeleted: r._isDeleted })) - }); - - if (dirtyRows.length === 0) continue; - - // 저장할 필드만 추출 - const editableFields = (contentRow.tableColumns || []) - .filter((col) => col.editable) - .map((col) => col.field); - - // 🆕 v3.13: joinConditions에서 sourceKey (저장 대상 테이블의 FK 컬럼) 추출 - const joinConditions = contentRow.tableDataSource.joinConditions || []; - const joinKeys = joinConditions.map((cond) => cond.sourceKey); - - const allowedFields = [...new Set([...editableFields, ...joinKeys])]; - - if (!saveDataByTable[targetTable]) { - saveDataByTable[targetTable] = []; - } - - for (const row of dirtyRows) { - const saveData: Record = {}; - - // 허용된 필드만 포함 - for (const field of allowedFields) { - if (row[field] !== undefined) { - saveData[field] = row[field]; - } - } - - // 🆕 v3.13: joinConditions를 사용하여 FK 값 자동 채우기 - // 예: sales_order_id (sourceKey) = card의 id (targetKey) - for (const joinCond of joinConditions) { - const { sourceKey, targetKey } = joinCond; - // sourceKey가 저장 데이터에 없거나 null인 경우, 카드의 대표 데이터에서 targetKey 값을 가져옴 - if (!saveData[sourceKey] && representativeData[targetKey] !== undefined) { - saveData[sourceKey] = representativeData[targetKey]; - console.log(`[RepeatScreenModal] beforeFormSave - FK 자동 채우기: ${sourceKey} = ${representativeData[targetKey]} (from card.${targetKey})`); - } - } - - // _isNew 플래그 유지 - saveData._isNew = row._isNew; - saveData._targetTable = targetTable; - - // 기존 레코드의 경우 id 포함 - if (!row._isNew && row._originalData?.id) { - saveData.id = row._originalData.id; - } - - saveDataByTable[targetTable].push(saveData); - } - } - - // formData에 테이블별 저장 데이터 추가 - for (const [tableName, rows] of Object.entries(saveDataByTable)) { - const fieldKey = `_repeatScreenModal_${tableName}`; - event.detail.formData[fieldKey] = rows; - console.log(`[RepeatScreenModal] beforeFormSave - ${tableName} 저장 데이터:`, rows); - } - - // 🆕 v3.9: 집계 저장 설정 정보도 formData에 추가 - if (grouping?.aggregations && groupedCardsData.length > 0) { - const aggregationSaveConfigs: Array<{ - resultField: string; - aggregatedValue: number; - targetTable: string; - targetColumn: string; - joinKey: { sourceField: string; targetField: string }; - sourceValue: any; // 조인 키 값 - }> = []; - - for (const card of groupedCardsData) { - for (const agg of grouping.aggregations) { - if (agg.saveConfig?.enabled) { - const { saveConfig, resultField } = agg; - const { targetTable, targetColumn, joinKey } = saveConfig; - - if (!targetTable || !targetColumn || !joinKey?.sourceField || !joinKey?.targetField) { - continue; - } - - const aggregatedValue = card._aggregations?.[resultField] ?? 0; - const sourceValue = card._representativeData?.[joinKey.sourceField]; - - if (sourceValue !== undefined) { - aggregationSaveConfigs.push({ - resultField, - aggregatedValue, - targetTable, - targetColumn, - joinKey, - sourceValue, - }); - } - } - } - } - - if (aggregationSaveConfigs.length > 0) { - event.detail.formData._repeatScreenModal_aggregations = aggregationSaveConfigs; - console.log("[RepeatScreenModal] beforeFormSave - 집계 저장 설정:", aggregationSaveConfigs); - } - } - }; - - window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener); - return () => { - window.removeEventListener("beforeFormSave", handleBeforeFormSave as EventListener); - }; - }, [externalTableData, contentRows, grouping, groupedCardsData]); - - // 초기 데이터 로드 - useEffect(() => { - const loadInitialData = async () => { - 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 { - let loadedData: any[] = []; - - // 🆕 우선순위 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); - } - } - - console.log("[RepeatScreenModal] 최종 선택된 ID:", selectedIds); - - // 선택된 ID가 있으면 필터 적용 - if (selectedIds.length > 0) { - filters.id = selectedIds; - } else { - 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 { - 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); - setLoadError(error.message || "데이터 로드 중 오류가 발생했습니다."); - } finally { - setIsLoading(false); - } - }; - - loadInitialData(); - }, [dataSource, formData, groupedData, contentRows, grouping?.enabled, grouping?.groupByField]); - - // 🆕 v3.1: 외부 테이블 데이터 로드 - useEffect(() => { - const loadExternalTableData = async () => { - // contentRows에서 외부 테이블 데이터 소스가 있는 table 타입 행 찾기 - const tableRowsWithExternalSource = contentRows.filter( - (row) => row.type === "table" && row.tableDataSource?.enabled - ); - - if (tableRowsWithExternalSource.length === 0) return; - if (groupedCardsData.length === 0 && cardsData.length === 0) return; - - const newExternalData: Record = {}; - - for (const contentRow of tableRowsWithExternalSource) { - const dataSourceConfig = contentRow.tableDataSource!; - const cards = groupedCardsData.length > 0 ? groupedCardsData : cardsData; - - for (const card of cards) { - const cardId = card._cardId; - const representativeData = (card as GroupedCardData)._representativeData || card; - - try { - // 조인 조건 생성 - const filters: Record = {}; - for (const condition of dataSourceConfig.joinConditions) { - let refValue = representativeData[condition.referenceKey]; - if (refValue !== undefined && refValue !== null) { - // 숫자형 ID인 경우 숫자로 변환 (문자열 '189' → 숫자 189) - // 백엔드에서 entity 타입 컬럼 검색 시 문자열이면 ILIKE 검색을 수행하므로 - // 정확한 ID 매칭을 위해 숫자로 변환해야 함 - if (condition.sourceKey.endsWith('_id') || condition.sourceKey === 'id') { - const numValue = Number(refValue); - if (!isNaN(numValue)) { - refValue = numValue; - } - } - filters[condition.sourceKey] = refValue; - } - } - - if (Object.keys(filters).length === 0) { - console.warn(`[RepeatScreenModal] 조인 조건이 없습니다: ${contentRow.id}`); - continue; - } - - console.log(`[RepeatScreenModal] 외부 테이블 API 호출:`, { - sourceTable: dataSourceConfig.sourceTable, - filters, - joinConditions: dataSourceConfig.joinConditions, - representativeDataId: representativeData.id, - representativeDataIdType: typeof representativeData.id, - }); - - // API 호출 - 메인 테이블 데이터 - const response = await apiClient.post( - `/table-management/tables/${dataSourceConfig.sourceTable}/data`, - { - search: filters, - page: 1, - size: dataSourceConfig.limit || 100, - sort: dataSourceConfig.orderBy - ? { - column: dataSourceConfig.orderBy.column, - direction: dataSourceConfig.orderBy.direction, - } - : undefined, - } - ); - - if (response.data.success && response.data.data?.data) { - let tableData = response.data.data.data; - - console.log(`[RepeatScreenModal] 소스 테이블 데이터 로드 완료:`, { - sourceTable: dataSourceConfig.sourceTable, - rowCount: tableData.length, - sampleRow: tableData[0] ? Object.keys(tableData[0]) : [], - firstRowData: tableData[0], - // 디버그: plan_date 필드 확인 - plan_date_value: tableData[0]?.plan_date, - }); - - // 🆕 v3.3: 추가 조인 테이블 데이터 로드 및 병합 - if (dataSourceConfig.additionalJoins && dataSourceConfig.additionalJoins.length > 0) { - console.log(`[RepeatScreenModal] 조인 설정:`, dataSourceConfig.additionalJoins); - tableData = await loadAndMergeJoinData(tableData, dataSourceConfig.additionalJoins); - console.log(`[RepeatScreenModal] 조인 후 데이터:`, { - rowCount: tableData.length, - sampleRow: tableData[0] ? Object.keys(tableData[0]) : [], - firstRowData: tableData[0], - }); - } - - // 🆕 v3.4: 필터 조건 적용 - if (dataSourceConfig.filterConfig?.enabled) { - const { filterField, filterType, referenceField, referenceSource } = dataSourceConfig.filterConfig; - - // 비교 값 가져오기 - let referenceValue: any; - if (referenceSource === "formData") { - referenceValue = formData?.[referenceField]; - } else { - // representativeData - referenceValue = representativeData[referenceField]; - } - - if (referenceValue !== undefined && referenceValue !== null) { - tableData = tableData.filter((row: any) => { - const rowValue = row[filterField]; - if (filterType === "equals") { - return rowValue === referenceValue; - } else { - // notEquals - return rowValue !== referenceValue; - } - }); - - console.log(`[RepeatScreenModal] 필터 적용: ${filterField} ${filterType} ${referenceValue}, 결과: ${tableData.length}건`); - } - } - - const key = `${cardId}-${contentRow.id}`; - newExternalData[key] = tableData.map((row: any, idx: number) => ({ - _rowId: `ext-row-${cardId}-${contentRow.id}-${idx}-${Date.now()}`, - _originalData: { ...row }, - _isDirty: false, - _isNew: false, - _isEditing: false, // 🆕 v3.8: 로드된 데이터는 읽기 전용 - _isDeleted: false, - ...row, - })); - - // 디버그: 저장된 외부 테이블 데이터 확인 - console.log(`[RepeatScreenModal] 외부 테이블 데이터 저장:`, { - key, - rowCount: newExternalData[key].length, - firstRow: newExternalData[key][0], - plan_date_in_firstRow: newExternalData[key][0]?.plan_date, - }); - } - } catch (error) { - console.error(`[RepeatScreenModal] 외부 테이블 데이터 로드 실패:`, error); - } - } - } - - setExternalTableData((prev) => { - // 이전 데이터와 동일하면 업데이트하지 않음 (무한 루프 방지) - const prevKeys = Object.keys(prev).sort().join(","); - const newKeys = Object.keys(newExternalData).sort().join(","); - if (prevKeys === newKeys) { - // 키가 같으면 데이터 내용 비교 - const isSame = Object.keys(newExternalData).every( - (key) => JSON.stringify(prev[key]) === JSON.stringify(newExternalData[key]) - ); - if (isSame) return prev; - } - - // 🆕 v3.2: 외부 테이블 데이터 로드 후 집계 재계산 - // 비동기적으로 처리하여 무한 루프 방지 - setTimeout(() => { - recalculateAggregationsWithExternalData(newExternalData); - }, 0); - - return newExternalData; - }); - }; - - loadExternalTableData(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [contentRows, groupedCardsData.length, cardsData.length]); - - // 🆕 v3.3: 추가 조인 테이블 데이터 로드 및 병합 - const loadAndMergeJoinData = async ( - mainData: any[], - additionalJoins: { id: string; joinTable: string; joinType: string; sourceKey: string; targetKey: string }[] - ): Promise => { - if (mainData.length === 0) return mainData; - - // 각 조인 테이블별로 필요한 키 값들 수집 - for (const joinConfig of additionalJoins) { - if (!joinConfig.joinTable || !joinConfig.sourceKey || !joinConfig.targetKey) continue; - - // 메인 데이터에서 조인 키 값들 추출 - const joinKeyValues = [...new Set(mainData.map((row) => row[joinConfig.sourceKey]).filter(Boolean))]; - - if (joinKeyValues.length === 0) continue; - - try { - // 조인 테이블 데이터 조회 - const joinResponse = await apiClient.post( - `/table-management/tables/${joinConfig.joinTable}/data`, - { - search: { [joinConfig.targetKey]: joinKeyValues }, - page: 1, - size: 1000, // 충분히 큰 값 - } - ); - - if (joinResponse.data.success && joinResponse.data.data?.data) { - const joinData = joinResponse.data.data.data; - - // 조인 데이터를 맵으로 변환 (빠른 조회를 위해) - const joinDataMap = new Map(); - for (const joinRow of joinData) { - joinDataMap.set(joinRow[joinConfig.targetKey], joinRow); - } - - // 메인 데이터에 조인 데이터 병합 - mainData = mainData.map((row) => { - const joinKey = row[joinConfig.sourceKey]; - const joinRow = joinDataMap.get(joinKey); - - if (joinRow) { - // 조인 테이블의 컬럼들을 메인 데이터에 추가 (접두사 없이) - const mergedRow = { ...row }; - for (const [key, value] of Object.entries(joinRow)) { - // 이미 존재하는 키가 아닌 경우에만 추가 (메인 테이블 우선) - if (!(key in mergedRow)) { - mergedRow[key] = value; - } else { - // 충돌하는 경우 조인 테이블명을 접두사로 사용 - mergedRow[`${joinConfig.joinTable}_${key}`] = value; - } - } - return mergedRow; - } - return row; - }); - } - } catch (error) { - console.error(`[RepeatScreenModal] 조인 테이블 데이터 로드 실패 (${joinConfig.joinTable}):`, error); - } - } - - return mainData; - }; - - // 🆕 v3.2: 외부 테이블 데이터가 로드된 후 집계 재계산 - const recalculateAggregationsWithExternalData = (extData: Record) => { - if (!grouping?.aggregations || grouping.aggregations.length === 0) return; - if (groupedCardsData.length === 0) return; - - // 외부 테이블 집계 또는 formula가 있는지 확인 - const hasExternalAggregation = grouping.aggregations.some((agg) => { - const sourceType = agg.sourceType || "column"; - if (sourceType === "formula") return true; // formula는 외부 테이블 참조 가능 - if (sourceType === "column") { - const sourceTable = agg.sourceTable || dataSource?.sourceTable; - return sourceTable && sourceTable !== dataSource?.sourceTable; - } - return false; - }); - - if (!hasExternalAggregation) return; - - // contentRows에서 외부 테이블 데이터 소스가 있는 모든 table 타입 행 찾기 - const tableRowsWithExternalSource = contentRows.filter( - (row) => row.type === "table" && row.tableDataSource?.enabled - ); - - if (tableRowsWithExternalSource.length === 0) return; - - // 각 카드의 집계 재계산 - const updatedCards = groupedCardsData.map((card) => { - // 🆕 v3.11: 테이블 행 ID별로 외부 데이터를 구분하여 저장 - const externalRowsByTableId: Record = {}; - const allExternalRows: any[] = []; - - for (const tableRow of tableRowsWithExternalSource) { - const key = `${card._cardId}-${tableRow.id}`; - // 🆕 v3.7: 삭제된 행은 집계에서 제외 - const rows = (extData[key] || []).filter((row) => !row._isDeleted); - externalRowsByTableId[tableRow.id] = rows; - allExternalRows.push(...rows); - } - - // 집계 재계산 - const newAggregations: Record = {}; - - grouping.aggregations!.forEach((agg) => { - const sourceType = agg.sourceType || "column"; - - if (sourceType === "column") { - const sourceTable = agg.sourceTable || dataSource?.sourceTable; - const isExternalTable = sourceTable && sourceTable !== dataSource?.sourceTable; - - if (isExternalTable) { - // 외부 테이블 집계 - newAggregations[agg.resultField] = calculateColumnAggregation( - allExternalRows, - agg.sourceField || "", - agg.type || "sum" - ); - } else { - // 기본 테이블 집계 (기존 값 유지) - newAggregations[agg.resultField] = card._aggregations[agg.resultField] || - calculateColumnAggregation(card._rows, agg.sourceField || "", agg.type || "sum"); - } - } else if (sourceType === "formula" && agg.formula) { - // 🆕 v3.11: externalTableRefs 기반으로 필터링된 외부 데이터 사용 - let filteredExternalRows: any[]; - - if (agg.externalTableRefs && agg.externalTableRefs.length > 0) { - // 특정 테이블만 참조 - filteredExternalRows = []; - for (const tableId of agg.externalTableRefs) { - if (externalRowsByTableId[tableId]) { - filteredExternalRows.push(...externalRowsByTableId[tableId]); - } - } - } else { - // 모든 외부 테이블 데이터 사용 (기존 동작) - filteredExternalRows = allExternalRows; - } - - // 가상 집계 (연산식) - 외부 테이블 데이터 포함하여 재계산 - newAggregations[agg.resultField] = evaluateFormulaWithContext( - agg.formula, - card._representativeData, - card._rows, - filteredExternalRows, - newAggregations // 이전 집계 결과 참조 - ); - } - }); - - return { - ...card, - _aggregations: newAggregations, - }; - }); - - // 변경된 경우에만 업데이트 (무한 루프 방지) - setGroupedCardsData((prev) => { - const hasChanges = updatedCards.some((card, idx) => { - const prevCard = prev[idx]; - if (!prevCard) return true; - return JSON.stringify(card._aggregations) !== JSON.stringify(prevCard._aggregations); - }); - return hasChanges ? updatedCards : prev; - }); - }; - - // 🆕 v3.1: 외부 테이블 행 추가 (v3.13: 자동 채번 기능 추가) - const handleAddExternalRow = async (cardId: string, contentRowId: string, contentRow: CardContentRowConfig) => { - const key = `${cardId}-${contentRowId}`; - const card = groupedCardsData.find((c) => c._cardId === cardId) || cardsData.find((c) => c._cardId === cardId); - const representativeData = (card as GroupedCardData)?._representativeData || card || {}; - - // 기본값 생성 - const newRowData: Record = { - _rowId: `new-row-${Date.now()}`, - _originalData: {}, - _isDirty: true, - _isNew: true, - }; - - // 🆕 v3.5: 카드 대표 데이터에서 조인 테이블 컬럼 값 자동 채우기 - // tableColumns에서 정의된 필드들 중 representativeData에 있는 값을 자동으로 채움 - if (contentRow.tableColumns) { - for (const col of contentRow.tableColumns) { - // representativeData에 해당 필드가 있으면 자동으로 채움 - if (representativeData[col.field] !== undefined && representativeData[col.field] !== null) { - newRowData[col.field] = representativeData[col.field]; - } - } - } - - // 🆕 v3.5: 조인 조건의 키 값도 자동으로 채움 (예: sales_order_id) - if (contentRow.tableDataSource?.joinConditions) { - for (const condition of contentRow.tableDataSource.joinConditions) { - // sourceKey는 소스 테이블(예: shipment_plan)의 컬럼 - // referenceKey는 카드 대표 데이터의 컬럼 (예: id) - const refValue = representativeData[condition.referenceKey]; - if (refValue !== undefined && refValue !== null) { - newRowData[condition.sourceKey] = refValue; - } - } - } - - // newRowDefaults 적용 (사용자 정의 기본값이 우선) - if (contentRow.tableCrud?.newRowDefaults) { - for (const [field, template] of Object.entries(contentRow.tableCrud.newRowDefaults)) { - // {fieldName} 형식의 템플릿 치환 - let value = template; - const matches = template.match(/\{(\w+)\}/g); - if (matches) { - for (const match of matches) { - const fieldName = match.slice(1, -1); - value = value.replace(match, String(representativeData[fieldName] || "")); - } - } - newRowData[field] = value; - } - } - - // 🆕 v3.13: 자동 채번 처리 - const rowNumbering = contentRow.tableCrud?.rowNumbering; - console.log("[RepeatScreenModal] 채번 설정 확인:", { - tableCrud: contentRow.tableCrud, - rowNumbering, - enabled: rowNumbering?.enabled, - targetColumn: rowNumbering?.targetColumn, - numberingRuleId: rowNumbering?.numberingRuleId, - }); - if (rowNumbering?.enabled && rowNumbering.targetColumn && rowNumbering.numberingRuleId) { - try { - console.log("[RepeatScreenModal] 자동 채번 시작:", { - targetColumn: rowNumbering.targetColumn, - numberingRuleId: rowNumbering.numberingRuleId, - }); - - // 채번 API 호출 (allocate: 실제 시퀀스 증가) - const { allocateNumberingCode } = await import("@/lib/api/numberingRule"); - const response = await allocateNumberingCode(rowNumbering.numberingRuleId); - - if (response.success && response.data) { - newRowData[rowNumbering.targetColumn] = response.data.generatedCode; - - console.log("[RepeatScreenModal] 자동 채번 완료:", { - column: rowNumbering.targetColumn, - generatedCode: response.data.generatedCode, - }); - } else { - console.warn("[RepeatScreenModal] 채번 실패:", response); - } - } catch (error) { - console.error("[RepeatScreenModal] 채번 API 호출 실패:", error); - } - } - - console.log("[RepeatScreenModal] 새 행 추가:", { - cardId, - contentRowId, - representativeData, - newRowData, - }); - - setExternalTableData((prev) => { - const newData = { - ...prev, - [key]: [...(prev[key] || []), newRowData], - }; - - // 🆕 v3.5: 새 행 추가 시 집계 재계산 - setTimeout(() => { - recalculateAggregationsWithExternalData(newData); - }, 0); - - return newData; - }); - }; - - // 🆕 v3.6: 테이블 영역 저장 기능 - const saveTableAreaData = async (cardId: string, contentRowId: string, contentRow: CardContentRowConfig) => { - const key = `${cardId}-${contentRowId}`; - const rows = externalTableData[key] || []; - - console.log("[RepeatScreenModal] saveTableAreaData 시작:", { - key, - rowsCount: rows.length, - contentRowId, - tableDataSource: contentRow?.tableDataSource, - tableCrud: contentRow?.tableCrud, - }); - - if (!contentRow?.tableDataSource?.enabled) { - console.warn("[RepeatScreenModal] 외부 테이블 데이터 소스가 설정되지 않음"); - return { success: false, message: "데이터 소스가 설정되지 않았습니다." }; - } - - const targetTable = contentRow.tableCrud?.targetTable || contentRow.tableDataSource.sourceTable; - const dirtyRows = rows.filter((row) => row._isDirty); - - console.log("[RepeatScreenModal] 저장 대상:", { - targetTable, - dirtyRowsCount: dirtyRows.length, - dirtyRows: dirtyRows.map(r => ({ _isNew: r._isNew, _isDirty: r._isDirty, data: r })), - }); - - if (dirtyRows.length === 0) { - return { success: true, message: "저장할 변경사항이 없습니다.", savedCount: 0 }; - } - - const savePromises: Promise[] = []; - const savedIds: number[] = []; - - // 🆕 v3.6: editable한 컬럼 + 조인 키만 추출 (읽기 전용 컬럼은 제외) - const allowedFields = new Set(); - - // tableColumns에서 editable: true인 필드만 추가 (읽기 전용 컬럼 제외) - if (contentRow.tableColumns) { - contentRow.tableColumns.forEach((col) => { - // editable이 명시적으로 true이거나, editable이 undefined가 아니고 false가 아닌 경우 - // 또는 inputType이 있는 경우 (입력 가능한 컬럼) - if (col.field && (col.editable === true || col.inputType)) { - allowedFields.add(col.field); - } - }); - } - - // 조인 조건의 sourceKey 추가 (예: sales_order_id) - 이건 항상 필요 - if (contentRow.tableDataSource?.joinConditions) { - contentRow.tableDataSource.joinConditions.forEach((cond) => { - if (cond.sourceKey) allowedFields.add(cond.sourceKey); - }); - } - - console.log("[RepeatScreenModal] 저장 허용 필드 (editable + 조인키):", Array.from(allowedFields)); - console.log("[RepeatScreenModal] tableColumns 정보:", contentRow.tableColumns?.map(c => ({ - field: c.field, - editable: c.editable, - inputType: c.inputType - }))); - - // 삭제할 행 (기존 데이터 중 _isDeleted가 true인 것) - const deletedRows = dirtyRows.filter((row) => row._isDeleted && row._originalData?.id); - // 저장할 행 (삭제되지 않은 것) - const rowsToSave = dirtyRows.filter((row) => !row._isDeleted); - - console.log("[RepeatScreenModal] 삭제 대상:", deletedRows.length, "건"); - console.log("[RepeatScreenModal] 저장 대상:", rowsToSave.length, "건"); - - // 🆕 v3.7: 삭제 처리 (배열 형태로 body에 전달) - for (const row of deletedRows) { - const deleteId = row._originalData.id; - console.log(`[RepeatScreenModal] DELETE 요청: /table-management/tables/${targetTable}/delete`, [{ id: deleteId }]); - savePromises.push( - apiClient.request({ - method: "DELETE", - url: `/table-management/tables/${targetTable}/delete`, - data: [{ id: deleteId }], - }).then((res) => { - console.log("[RepeatScreenModal] DELETE 응답:", res.data); - return { type: "delete", id: deleteId }; - }).catch((err) => { - console.error("[RepeatScreenModal] DELETE 실패:", err.response?.data || err.message); - throw err; - }) - ); - } - - for (const row of rowsToSave) { - const { _rowId, _originalData, _isDirty, _isNew, _isDeleted, ...allData } = row; - - // 허용된 필드만 필터링 - const dataToSave: Record = {}; - for (const field of allowedFields) { - if (allData[field] !== undefined) { - dataToSave[field] = allData[field]; - } - } - - console.log("[RepeatScreenModal] 저장할 데이터:", { - _isNew, - _originalData, - allData, - dataToSave, - }); - - if (_isNew) { - // INSERT - /add 엔드포인트 사용 - console.log(`[RepeatScreenModal] INSERT 요청: /table-management/tables/${targetTable}/add`, dataToSave); - savePromises.push( - apiClient.post(`/table-management/tables/${targetTable}/add`, dataToSave).then((res) => { - console.log("[RepeatScreenModal] INSERT 응답:", res.data); - if (res.data?.data?.id) { - savedIds.push(res.data.data.id); - } - return res; - }).catch((err) => { - console.error("[RepeatScreenModal] INSERT 실패:", err.response?.data || err.message); - throw err; - }) - ); - } else if (_originalData?.id) { - // UPDATE - /edit 엔드포인트 사용 (originalData와 updatedData 형식) - const updatePayload = { - originalData: _originalData, - updatedData: { ...dataToSave, id: _originalData.id }, - }; - console.log(`[RepeatScreenModal] UPDATE 요청: /table-management/tables/${targetTable}/edit`, updatePayload); - savePromises.push( - apiClient.put(`/table-management/tables/${targetTable}/edit`, updatePayload).then((res) => { - console.log("[RepeatScreenModal] UPDATE 응답:", res.data); - savedIds.push(_originalData.id); - return res; - }).catch((err) => { - console.error("[RepeatScreenModal] UPDATE 실패:", err.response?.data || err.message); - throw err; - }) - ); - } - } - - try { - await Promise.all(savePromises); - - // 저장 후: 삭제된 행은 제거, 나머지는 dirty/editing 플래그 초기화 - setExternalTableData((prev) => { - const updated = { ...prev }; - if (updated[key]) { - // 삭제된 행은 완전히 제거 - updated[key] = updated[key] - .filter((row) => !row._isDeleted) - .map((row) => ({ - ...row, - _isDirty: false, - _isNew: false, - _isEditing: false, // 🆕 v3.8: 수정 모드 해제 - _originalData: { ...row, _rowId: undefined, _originalData: undefined, _isDirty: undefined, _isNew: undefined, _isDeleted: undefined, _isEditing: undefined }, - })); - } - return updated; - }); - - const savedCount = rowsToSave.length; - const deletedCount = deletedRows.length; - const message = deletedCount > 0 - ? `${savedCount}건 저장, ${deletedCount}건 삭제 완료` - : `${savedCount}건 저장 완료`; - - return { success: true, message, savedCount, deletedCount, savedIds }; - } catch (error: any) { - console.error("[RepeatScreenModal] 테이블 영역 저장 실패:", error); - return { success: false, message: error.message || "저장 중 오류가 발생했습니다." }; - } - }; - - // 🆕 v3.6: 테이블 영역 저장 핸들러 - const handleTableAreaSave = async (cardId: string, contentRowId: string, contentRow: CardContentRowConfig) => { - setIsSaving(true); - try { - const result = await saveTableAreaData(cardId, contentRowId, contentRow); - if (result.success) { - console.log("[RepeatScreenModal] 테이블 영역 저장 성공:", result); - - // 🆕 v3.9: 집계 저장 설정이 있는 경우 연관 테이블 동기화 - const card = groupedCardsData.find((c) => c._cardId === cardId); - if (card && grouping?.aggregations) { - await saveAggregationsToRelatedTables(card, contentRowId); - } - } else { - console.error("[RepeatScreenModal] 테이블 영역 저장 실패:", result.message); - } - } finally { - setIsSaving(false); - } - }; - - // 🆕 v3.9: 집계 결과를 연관 테이블에 저장 - const saveAggregationsToRelatedTables = async (card: GroupedCardData, contentRowId: string) => { - if (!grouping?.aggregations) return; - - const savePromises: Promise[] = []; - - for (const agg of grouping.aggregations) { - const saveConfig = agg.saveConfig; - - // 저장 설정이 없거나 비활성화된 경우 스킵 - if (!saveConfig?.enabled) continue; - - // 자동 저장이 아닌 경우, 레이아웃에 연결되어 있는지 확인 필요 - // (현재는 자동 저장과 동일하게 처리 - 추후 레이아웃 연결 체크 추가 가능) - - // 집계 결과 값 가져오기 - const aggregatedValue = card._aggregations[agg.resultField]; - - if (aggregatedValue === undefined) { - console.warn(`[RepeatScreenModal] 집계 결과 없음: ${agg.resultField}`); - continue; - } - - // 조인 키로 대상 레코드 식별 - const sourceKeyValue = card._representativeData[saveConfig.joinKey.sourceField]; - - if (!sourceKeyValue) { - console.warn(`[RepeatScreenModal] 조인 키 값 없음: ${saveConfig.joinKey.sourceField}`); - continue; - } - - console.log(`[RepeatScreenModal] 집계 저장 시작:`, { - aggregation: agg.resultField, - value: aggregatedValue, - targetTable: saveConfig.targetTable, - targetColumn: saveConfig.targetColumn, - joinKey: `${saveConfig.joinKey.sourceField}=${sourceKeyValue} -> ${saveConfig.joinKey.targetField}`, - }); - - // UPDATE API 호출 - const updatePayload = { - originalData: { [saveConfig.joinKey.targetField]: sourceKeyValue }, - updatedData: { - [saveConfig.targetColumn]: aggregatedValue, - [saveConfig.joinKey.targetField]: sourceKeyValue, - }, - }; - - savePromises.push( - apiClient.put(`/table-management/tables/${saveConfig.targetTable}/edit`, updatePayload) - .then((res) => { - console.log(`[RepeatScreenModal] 집계 저장 성공: ${agg.resultField} -> ${saveConfig.targetTable}.${saveConfig.targetColumn}`); - return res; - }) - .catch((err) => { - console.error(`[RepeatScreenModal] 집계 저장 실패: ${agg.resultField}`, err.response?.data || err.message); - throw err; - }) - ); - } - - if (savePromises.length > 0) { - try { - await Promise.all(savePromises); - console.log(`[RepeatScreenModal] 모든 집계 저장 완료: ${savePromises.length}건`); - } catch (error) { - console.error("[RepeatScreenModal] 일부 집계 저장 실패:", error); - } - } - }; - - // 🆕 v3.1: 외부 테이블 행 삭제 요청 - const handleDeleteExternalRowRequest = (cardId: string, rowId: string, contentRowId: string, contentRow: CardContentRowConfig) => { - if (contentRow.tableCrud?.deleteConfirm?.enabled !== false) { - // 삭제 확인 팝업 표시 - setPendingDeleteInfo({ cardId, rowId, contentRowId }); - setDeleteConfirmOpen(true); - } else { - // 바로 삭제 - handleDeleteExternalRow(cardId, rowId, contentRowId); - } - }; - - // 🆕 v3.14: 외부 테이블 행 삭제 실행 (즉시 DELETE API 호출) - const handleDeleteExternalRow = async (cardId: string, rowId: string, contentRowId: string) => { - const key = `${cardId}-${contentRowId}`; - const rows = externalTableData[key] || []; - const targetRow = rows.find((row) => row._rowId === rowId); - - // 기존 DB 데이터인 경우 (id가 있는 경우) 즉시 삭제 - if (targetRow?._originalData?.id) { - try { - const contentRow = contentRows.find((r) => r.id === contentRowId); - const targetTable = contentRow?.tableCrud?.targetTable || contentRow?.tableDataSource?.sourceTable; - - if (!targetTable) { - console.error("[RepeatScreenModal] 삭제 대상 테이블을 찾을 수 없습니다."); - return; - } - - console.log(`[RepeatScreenModal] DELETE API 호출: ${targetTable}, id=${targetRow._originalData.id}`); - - // 백엔드는 배열 형태의 데이터를 기대함 - await apiClient.request({ - method: "DELETE", - url: `/table-management/tables/${targetTable}/delete`, - data: [{ id: targetRow._originalData.id }], - }); - - console.log(`[RepeatScreenModal] DELETE 성공: ${targetTable}, id=${targetRow._originalData.id}`); - - // 성공 시 UI에서 완전히 제거 - setExternalTableData((prev) => { - const newData = { - ...prev, - [key]: prev[key].filter((row) => row._rowId !== rowId), - }; - - // 행 삭제 시 집계 재계산 - setTimeout(() => { - recalculateAggregationsWithExternalData(newData); - }, 0); - - return newData; - }); - } catch (error: any) { - console.error(`[RepeatScreenModal] DELETE 실패:`, error.response?.data || error.message); - // 에러 시에도 다이얼로그 닫기 - } - } else { - // 새로 추가된 행 (아직 DB에 없음) - UI에서만 제거 - console.log(`[RepeatScreenModal] 새 행 삭제 (DB 없음): rowId=${rowId}`); - setExternalTableData((prev) => { - const newData = { - ...prev, - [key]: prev[key].filter((row) => row._rowId !== rowId), - }; - - // 행 삭제 시 집계 재계산 - setTimeout(() => { - recalculateAggregationsWithExternalData(newData); - }, 0); - - return newData; - }); - } - - setDeleteConfirmOpen(false); - setPendingDeleteInfo(null); - }; - - // 🆕 v3.7: 삭제 취소 (소프트 삭제 복원) - const handleRestoreExternalRow = (cardId: string, rowId: string, contentRowId: string) => { - const key = `${cardId}-${contentRowId}`; - setExternalTableData((prev) => { - const newData = { - ...prev, - [key]: (prev[key] || []).map((row) => - row._rowId === rowId - ? { ...row, _isDeleted: false, _isDirty: true } - : row - ), - }; - - setTimeout(() => { - recalculateAggregationsWithExternalData(newData); - }, 0); - - return newData; - }); - }; - - // 🆕 v3.8: 수정 모드 전환 - const handleEditExternalRow = (cardId: string, rowId: string, contentRowId: string) => { - const key = `${cardId}-${contentRowId}`; - setExternalTableData((prev) => ({ - ...prev, - [key]: (prev[key] || []).map((row) => - row._rowId === rowId - ? { ...row, _isEditing: true } - : row - ), - })); - }; - - // 🆕 v3.8: 수정 취소 - const handleCancelEditExternalRow = (cardId: string, rowId: string, contentRowId: string) => { - const key = `${cardId}-${contentRowId}`; - setExternalTableData((prev) => ({ - ...prev, - [key]: (prev[key] || []).map((row) => - row._rowId === rowId - ? { - ...row._originalData, - _rowId: row._rowId, - _originalData: row._originalData, - _isEditing: false, - _isDirty: false, - _isNew: false, - _isDeleted: false, - } - : row - ), - })); - }; - - // 🆕 v3.1: 외부 테이블 행 데이터 변경 - const handleExternalRowDataChange = (cardId: string, contentRowId: string, rowId: string, field: string, value: any) => { - const key = `${cardId}-${contentRowId}`; - - // 데이터 업데이트 - setExternalTableData((prev) => { - const newData = { - ...prev, - [key]: (prev[key] || []).map((row) => - row._rowId === rowId ? { ...row, [field]: value, _isDirty: true } : row - ), - }; - - // 🆕 v3.5: 데이터 변경 시 집계 실시간 재계산 - // setTimeout으로 비동기 처리하여 상태 업데이트 후 재계산 - setTimeout(() => { - recalculateAggregationsWithExternalData(newData); - }, 0); - - return newData; - }); - }; - - // 그룹화된 데이터 처리 - const processGroupedData = (data: any[], groupingConfig: typeof grouping): GroupedCardData[] => { - if (!groupingConfig?.enabled) { - return []; - } - - const groupByField = groupingConfig.groupByField; - const groupMap = new Map(); - - // 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[] = []; - let cardIndex = 0; - - groupMap.forEach((rows, groupKey) => { - // 행 데이터 생성 - const cardRows: CardRowData[] = rows.map((row, idx) => ({ - _rowId: `row-${cardIndex}-${idx}-${Date.now()}`, - _originalData: { ...row }, - _isDirty: false, - ...row, - })); - - const representativeData = rows[0] || {}; - - // 🆕 v3.2: 집계 계산 (순서대로 - 이전 집계 결과 참조 가능) - // 1단계: 기본 테이블 컬럼 집계만 (외부 테이블 데이터는 아직 없음) - const aggregations: Record = {}; - if (groupingConfig.aggregations) { - groupingConfig.aggregations.forEach((agg) => { - const sourceType = agg.sourceType || "column"; - - if (sourceType === "column") { - // 컬럼 집계 (기본 테이블만 - 외부 테이블은 나중에 처리) - const sourceTable = agg.sourceTable || dataSource?.sourceTable; - const isExternalTable = sourceTable && sourceTable !== dataSource?.sourceTable; - - if (!isExternalTable) { - // 기본 테이블 집계 - aggregations[agg.resultField] = calculateColumnAggregation( - rows, - agg.sourceField || "", - agg.type || "sum" - ); - } else { - // 외부 테이블 집계는 나중에 계산 (placeholder) - aggregations[agg.resultField] = 0; - } - } else if (sourceType === "formula") { - // 가상 집계 (연산식) - 외부 테이블 없이 먼저 계산 시도 - // 외부 테이블 데이터가 필요한 경우 나중에 재계산됨 - if (agg.formula) { - aggregations[agg.resultField] = evaluateFormulaWithContext( - agg.formula, - representativeData, - rows, - [], // 외부 테이블 데이터 없음 - aggregations // 이전 집계 결과 참조 - ); - } else { - aggregations[agg.resultField] = 0; - } - } - }); - } - - // 안정적인 _cardId 생성 (Date.now() 대신 groupKey 사용) - // groupKey가 없으면 대표 데이터의 id 사용 - const stableId = groupKey || representativeData.id || cardIndex; - result.push({ - _cardId: `grouped-card-${cardIndex}-${stableId}`, - _groupKey: groupKey, - _groupField: groupByField || "", - _aggregations: aggregations, - _rows: cardRows, - _representativeData: representativeData, - }); - - cardIndex++; - }); - - return result; - }; - - // 집계 계산 (컬럼 집계용) - const calculateColumnAggregation = ( - rows: any[], - sourceField: string, - type: "sum" | "count" | "avg" | "min" | "max" - ): number => { - const values = rows.map((row) => Number(row[sourceField]) || 0); - - switch (type) { - case "sum": - return values.reduce((a, b) => a + b, 0); - case "count": - return values.length; - case "avg": - return values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0; - case "min": - return values.length > 0 ? Math.min(...values) : 0; - case "max": - return values.length > 0 ? Math.max(...values) : 0; - default: - return 0; - } - }; - - // 🆕 v3.2: 집계 계산 (다중 테이블 및 formula 지원) - const calculateAggregation = ( - agg: AggregationConfig, - cardRows: any[], // 기본 테이블 행들 - externalRows: any[], // 외부 테이블 행들 - previousAggregations: Record, // 이전 집계 결과들 - representativeData: Record // 카드 대표 데이터 - ): number => { - const sourceType = agg.sourceType || "column"; - - if (sourceType === "column") { - // 컬럼 집계 - const sourceTable = agg.sourceTable || dataSource?.sourceTable; - const isExternalTable = sourceTable && sourceTable !== dataSource?.sourceTable; - - // 외부 테이블인 경우 externalRows 사용, 아니면 cardRows 사용 - const targetRows = isExternalTable ? externalRows : cardRows; - - return calculateColumnAggregation( - targetRows, - agg.sourceField || "", - agg.type || "sum" - ); - } else if (sourceType === "formula") { - // 가상 집계 (연산식) - if (!agg.formula) return 0; - - return evaluateFormulaWithContext( - agg.formula, - representativeData, - cardRows, - externalRows, - previousAggregations - ); - } - - return 0; - }; - - // 🆕 v3.1: 집계 표시값 계산 (formula, external 등 지원) - const calculateAggregationDisplayValue = ( - aggField: AggregationDisplayConfig, - card: GroupedCardData - ): number | string => { - const sourceType = aggField.sourceType || "aggregation"; - - switch (sourceType) { - case "aggregation": - // 기존 집계 결과 참조 - return card._aggregations?.[aggField.aggregationResultField || ""] || 0; - - case "formula": - // 컬럼 간 연산 - if (!aggField.formula) return 0; - return evaluateFormula(aggField.formula, card._representativeData, card._rows); - - case "external": - // 외부 테이블 값 (별도 로드 필요 - 현재는 placeholder) - // TODO: 외부 테이블 값 로드 구현 - return 0; - - case "externalFormula": - // 외부 테이블 + 연산 (별도 로드 필요 - 현재는 placeholder) - // TODO: 외부 테이블 값 로드 후 연산 구현 - return 0; - - default: - return 0; - } - }; - - // 🆕 v3.2: 연산식 평가 (다중 테이블, 이전 집계 결과 참조 지원) - const evaluateFormulaWithContext = ( - formula: string, - representativeData: Record, - cardRows: any[], // 기본 테이블 행들 - externalRows: any[], // 외부 테이블 행들 - previousAggregations: Record // 이전 집계 결과들 - ): number => { - try { - let expression = formula; - - // 1. 외부 테이블 집계 함수 처리: SUM_EXT({field}), COUNT_EXT({field}) 등 - const extAggFunctions = ["SUM_EXT", "COUNT_EXT", "AVG_EXT", "MIN_EXT", "MAX_EXT"]; - for (const fn of extAggFunctions) { - const regex = new RegExp(`${fn}\\(\\{(\\w+)\\}\\)`, "g"); - expression = expression.replace(regex, (match, fieldName) => { - if (!externalRows || externalRows.length === 0) { - console.log(`[SUM_EXT] ${fieldName}: 외부 데이터 없음`); - return "0"; - } - const values = externalRows.map((row) => Number(row[fieldName]) || 0); - const sum = values.reduce((a, b) => a + b, 0); - console.log(`[SUM_EXT] ${fieldName}: ${externalRows.length}개 행, 값들:`, values, `합계: ${sum}`); - const baseFn = fn.replace("_EXT", ""); - switch (baseFn) { - case "SUM": - return String(values.reduce((a, b) => a + b, 0)); - case "COUNT": - return String(values.length); - case "AVG": - return String(values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0); - case "MIN": - return String(values.length > 0 ? Math.min(...values) : 0); - case "MAX": - return String(values.length > 0 ? Math.max(...values) : 0); - default: - return "0"; - } - }); - } - - // 2. 기본 테이블 집계 함수 처리: SUM({field}), COUNT({field}) 등 - const aggFunctions = ["SUM", "COUNT", "AVG", "MIN", "MAX"]; - for (const fn of aggFunctions) { - // SUM_EXT는 이미 처리했으므로 제외 - const regex = new RegExp(`(? { - if (!cardRows || cardRows.length === 0) return "0"; - const values = cardRows.map((row) => Number(row[fieldName]) || 0); - switch (fn) { - case "SUM": - return String(values.reduce((a, b) => a + b, 0)); - case "COUNT": - return String(values.length); - case "AVG": - return String(values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0); - case "MIN": - return String(values.length > 0 ? Math.min(...values) : 0); - case "MAX": - return String(values.length > 0 ? Math.max(...values) : 0); - default: - return "0"; - } - }); - } - - // 3. 단순 필드 참조 치환 (이전 집계 결과 또는 대표 데이터) - const fieldRegex = /\{(\w+)\}/g; - expression = expression.replace(fieldRegex, (match, fieldName) => { - // 먼저 이전 집계 결과에서 찾기 - if (previousAggregations && fieldName in previousAggregations) { - return String(previousAggregations[fieldName]); - } - // 대표 데이터에서 값 가져오기 - const value = representativeData[fieldName]; - return String(Number(value) || 0); - }); - - // 4. 안전한 수식 평가 (사칙연산만 허용) - // 허용 문자: 숫자, 소수점, 사칙연산, 괄호, 공백 - if (!/^[\d\s+\-*/().]+$/.test(expression)) { - console.warn("[RepeatScreenModal] 허용되지 않는 연산식:", expression); - return 0; - } - - // eval 대신 Function 사용 (더 안전) - const result = new Function(`return ${expression}`)(); - return Number(result) || 0; - } catch (error) { - console.error("[RepeatScreenModal] 연산식 평가 실패:", formula, error); - return 0; - } - }; - - // 레거시 호환: 기존 evaluateFormula 유지 - const evaluateFormula = ( - formula: string, - representativeData: Record, - rows?: any[] - ): number => { - return evaluateFormulaWithContext(formula, representativeData, rows || [], [], {}); - }; - - // 카드 데이터 로드 (소스 설정에 따라) - const loadCardData = async (originalData: any): Promise> => { - const cardData: Record = {}; - - // 🆕 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]; - } - } - } - } - - return cardData; - }; - - // Simple 모드: 카드 데이터 변경 - const handleCardDataChange = (cardId: string, field: string, value: any) => { - setCardsData((prev) => - prev.map((card) => (card._cardId === cardId ? { ...card, [field]: value, _isDirty: true } : card)) - ); - }; - - // WithTable 모드: 행 데이터 변경 - const handleRowDataChange = (cardId: string, rowId: string, field: string, value: any) => { - setGroupedCardsData((prev) => - prev.map((card) => { - if (card._cardId !== cardId) return card; - - const updatedRows = card._rows.map((row) => - row._rowId === rowId ? { ...row, [field]: value, _isDirty: true } : row - ); - - // 집계값 재계산 - const newAggregations: Record = {}; - if (grouping?.aggregations) { - grouping.aggregations.forEach((agg) => { - newAggregations[agg.resultField] = calculateAggregation(updatedRows, agg); - }); - } - - return { - ...card, - _rows: updatedRows, - _aggregations: newAggregations, - }; - }) - ); - }; - - // 카드 제목 생성 - const getCardTitle = (data: Record, index: number): string => { - let title = cardTitle; - title = title.replace("{index}", String(index + 1)); - - const matches = title.match(/\{(\w+)\}/g); - if (matches) { - matches.forEach((match) => { - const field = match.slice(1, -1); - const value = data[field] || ""; - title = title.replace(match, String(value)); - }); - } - - return title; - }; - - // 전체 저장 - const handleSaveAll = async () => { - setIsSaving(true); - - try { - // 기존 데이터 저장 - if (cardMode === "withTable") { - await saveGroupedData(); - } else { - await saveSimpleData(); - } - - // 🆕 v3.1: 외부 테이블 데이터 저장 - await saveExternalTableData(); - - // 🆕 v3.12: 연동 저장 처리 (syncSaves) - await processSyncSaves(); - - alert("저장되었습니다."); - } catch (error: any) { - console.error("저장 실패:", error); - alert(`저장 중 오류가 발생했습니다: ${error.message}`); - } finally { - setIsSaving(false); - } - }; - - // 🆕 v3.1: 외부 테이블 데이터 저장 - const saveExternalTableData = async () => { - const savePromises: Promise[] = []; - - for (const [key, rows] of Object.entries(externalTableData)) { - // key 형식: cardId-contentRowId - const [cardId, contentRowId] = key.split("-").slice(0, 2); - const contentRow = contentRows.find((r) => r.id === contentRowId || key.includes(r.id)); - - if (!contentRow?.tableDataSource?.enabled) continue; - - const targetTable = contentRow.tableCrud?.targetTable || contentRow.tableDataSource.sourceTable; - const dirtyRows = rows.filter((row) => row._isDirty); - - for (const row of dirtyRows) { - const { _rowId, _originalData, _isDirty, _isNew, ...dataToSave } = row; - - if (_isNew) { - // INSERT - savePromises.push( - apiClient.post(`/table-management/tables/${targetTable}/data`, dataToSave).then(() => {}) - ); - } else if (_originalData?.id) { - // UPDATE - savePromises.push( - apiClient.put(`/table-management/tables/${targetTable}/data/${_originalData.id}`, dataToSave).then(() => {}) - ); - } - } - } - - await Promise.all(savePromises); - - // 저장 후 dirty 플래그 초기화 - setExternalTableData((prev) => { - const updated: Record = {}; - for (const [key, rows] of Object.entries(prev)) { - updated[key] = rows.map((row) => ({ - ...row, - _isDirty: false, - _isNew: false, - _originalData: { ...row, _rowId: undefined, _originalData: undefined, _isDirty: undefined, _isNew: undefined }, - })); - } - return updated; - }); - }; - - // 🆕 v3.12: 연동 저장 처리 (syncSaves) - const processSyncSaves = async () => { - const syncPromises: Promise[] = []; - - // contentRows에서 syncSaves가 설정된 테이블 행 찾기 - for (const contentRow of contentRows) { - if (contentRow.type !== "table") continue; - if (!contentRow.tableCrud?.syncSaves?.length) continue; - - const sourceTable = contentRow.tableDataSource?.sourceTable; - if (!sourceTable) continue; - - // 이 테이블 행의 모든 카드 데이터 수집 - for (const card of groupedCardsData) { - const key = `${card._cardId}-${contentRow.id}`; - const rows = (externalTableData[key] || []).filter((row) => !row._isDeleted); - - // 각 syncSave 설정 처리 - for (const syncSave of contentRow.tableCrud.syncSaves) { - if (!syncSave.enabled) continue; - if (!syncSave.sourceColumn || !syncSave.targetTable || !syncSave.targetColumn) continue; - - // 조인 키 값 수집 (중복 제거) - const joinKeyValues = new Set(); - for (const row of rows) { - const keyValue = row[syncSave.joinKey.sourceField]; - if (keyValue !== undefined && keyValue !== null) { - joinKeyValues.add(keyValue); - } - } - - // 각 조인 키별로 집계 계산 및 업데이트 - for (const keyValue of joinKeyValues) { - // 해당 조인 키에 해당하는 행들만 필터링 - const filteredRows = rows.filter( - (row) => row[syncSave.joinKey.sourceField] === keyValue - ); - - // 집계 계산 - let aggregatedValue: number = 0; - const values = filteredRows.map((row) => Number(row[syncSave.sourceColumn]) || 0); - - switch (syncSave.aggregationType) { - case "sum": - aggregatedValue = values.reduce((a, b) => a + b, 0); - break; - case "count": - aggregatedValue = values.length; - break; - case "avg": - aggregatedValue = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0; - break; - case "min": - aggregatedValue = values.length > 0 ? Math.min(...values) : 0; - break; - case "max": - aggregatedValue = values.length > 0 ? Math.max(...values) : 0; - break; - case "latest": - aggregatedValue = values.length > 0 ? values[values.length - 1] : 0; - break; - } - - console.log(`[SyncSave] ${sourceTable}.${syncSave.sourceColumn} → ${syncSave.targetTable}.${syncSave.targetColumn}`, { - joinKey: keyValue, - aggregationType: syncSave.aggregationType, - values, - aggregatedValue, - }); - - // 대상 테이블 업데이트 - syncPromises.push( - apiClient - .put(`/table-management/tables/${syncSave.targetTable}/data/${keyValue}`, { - [syncSave.targetColumn]: aggregatedValue, - }) - .then(() => { - console.log(`[SyncSave] 업데이트 완료: ${syncSave.targetTable}.${syncSave.targetColumn} = ${aggregatedValue} (id=${keyValue})`); - }) - .catch((err) => { - console.error(`[SyncSave] 업데이트 실패:`, err); - throw err; - }) - ); - } - } - } - } - - if (syncPromises.length > 0) { - console.log(`[SyncSave] ${syncPromises.length}개 연동 저장 처리 중...`); - await Promise.all(syncPromises); - console.log(`[SyncSave] 연동 저장 완료`); - } - }; - - // 🆕 v3.1: Footer 버튼 클릭 핸들러 - const handleFooterButtonClick = async (btn: FooterButtonConfig) => { - switch (btn.action) { - case "save": - await handleSaveAll(); - break; - case "cancel": - case "close": - // 모달 닫기 이벤트 발생 - window.dispatchEvent(new CustomEvent("closeScreenModal")); - break; - case "reset": - // 데이터 초기화 - if (confirm("변경 사항을 모두 취소하시겠습니까?")) { - // 외부 테이블 데이터 초기화 - setExternalTableData({}); - // 기존 데이터 재로드 - setCardsData([]); - setGroupedCardsData([]); - } - break; - case "custom": - // 커스텀 액션 이벤트 발생 - if (btn.customAction) { - window.dispatchEvent( - new CustomEvent("repeatScreenModalCustomAction", { - detail: { - actionType: btn.customAction.type, - config: btn.customAction.config, - componentId: component?.id, - }, - }) - ); - } - break; - } - }; - - // Simple 모드 저장 - const saveSimpleData = async () => { - const dirtyCards = cardsData.filter((card) => card._isDirty); - - if (dirtyCards.length === 0) { - alert("변경된 데이터가 없습니다."); - return; - } - - const groupedData: Record = {}; - - for (const card of dirtyCards) { - for (const row of cardLayout) { - for (const col of row.columns) { - if (col.targetConfig && col.targetConfig.saveEnabled !== false) { - const targetTable = col.targetConfig.targetTable; - const targetColumn = col.targetConfig.targetColumn; - const value = card[col.field]; - - if (!groupedData[targetTable]) { - groupedData[targetTable] = []; - } - - let existingRow = groupedData[targetTable].find((r) => r._cardId === card._cardId); - - if (!existingRow) { - existingRow = { - _cardId: card._cardId, - _originalData: card._originalData, - }; - groupedData[targetTable].push(existingRow); - } - - existingRow[targetColumn] = value; - } - } - } - } - - await saveToTables(groupedData); - - setCardsData((prev) => prev.map((card) => ({ ...card, _isDirty: false }))); - }; - - // WithTable 모드 저장 - const saveGroupedData = async () => { - const dirtyCards = groupedCardsData.filter((card) => card._rows.some((row) => row._isDirty)); - - if (dirtyCards.length === 0) { - alert("변경된 데이터가 없습니다."); - return; - } - - const groupedData: Record = {}; - - for (const card of dirtyCards) { - const dirtyRows = card._rows.filter((row) => row._isDirty); - - for (const row of dirtyRows) { - // 테이블 컬럼에서 저장 대상 추출 - if (tableLayout?.tableColumns) { - for (const col of tableLayout.tableColumns) { - if (col.editable && col.targetConfig && col.targetConfig.saveEnabled !== false) { - const targetTable = col.targetConfig.targetTable; - const targetColumn = col.targetConfig.targetColumn; - const value = row[col.field]; - - if (!groupedData[targetTable]) { - groupedData[targetTable] = []; - } - - let existingRow = groupedData[targetTable].find((r) => r._rowId === row._rowId); - - if (!existingRow) { - existingRow = { - _rowId: row._rowId, - _originalData: row._originalData, - }; - groupedData[targetTable].push(existingRow); - } - - existingRow[targetColumn] = value; - } - } - } - } - } - - await saveToTables(groupedData); - - setGroupedCardsData((prev) => - prev.map((card) => ({ - ...card, - _rows: card._rows.map((row) => ({ ...row, _isDirty: false })), - })) - ); - }; - - // 테이블별 저장 - const saveToTables = async (groupedData: Record) => { - const savePromises = Object.entries(groupedData).map(async ([tableName, rows]) => { - return Promise.all( - rows.map(async (row) => { - const { _cardId, _rowId, _originalData, ...dataToSave } = row; - const id = _originalData?.id; - - if (id) { - await apiClient.put(`/table-management/tables/${tableName}/data/${id}`, dataToSave); - } else { - await apiClient.post(`/table-management/tables/${tableName}/data`, dataToSave); - } - }) - ); - }); - - await Promise.all(savePromises); - }; - - // 수정 여부 확인 - const hasDirtyData = useMemo(() => { - // 기존 데이터 수정 여부 - let hasBaseDirty = false; - if (cardMode === "withTable") { - hasBaseDirty = groupedCardsData.some((card) => card._rows.some((row) => row._isDirty)); - } else { - hasBaseDirty = cardsData.some((c) => c._isDirty); - } - - // 🆕 v3.1: 외부 테이블 데이터 수정 여부 - const hasExternalDirty = Object.values(externalTableData).some((rows) => - rows.some((row) => row._isDirty) - ); - - return hasBaseDirty || hasExternalDirty; - }, [cardMode, cardsData, groupedCardsData, externalTableData]); - - // 디자인 모드 렌더링 - 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 ( -
-
- {/* 아이콘 */} -
- -
- - {/* 제목 */} -
-
Repeat Screen Modal
-
반복 화면 모달
- v3 자유 레이아웃 -
- - {/* 행 구성 정보 */} -
- {contentRows.length > 0 ? ( - <> - {rowTypeCounts.header > 0 && ( - - 헤더 {rowTypeCounts.header}개 - - )} - {rowTypeCounts.aggregation > 0 && ( - - 집계 {rowTypeCounts.aggregation}개 - - )} - {rowTypeCounts.table > 0 && ( - - 테이블 {rowTypeCounts.table}개 - - )} - {rowTypeCounts.fields > 0 && ( - - 필드 {rowTypeCounts.fields}개 - - )} - - ) : ( - 행 없음 - )} -
- - {/* 통계 정보 */} -
-
-
{contentRows.length}
-
행 (Rows)
-
-
-
-
{grouping?.aggregations?.length || 0}
-
집계 설정
-
-
-
-
{dataSource?.sourceTable ? 1 : 0}
-
데이터 소스
-
-
- - {/* 데이터 소스 정보 */} - {dataSource?.sourceTable && ( -
- 소스 테이블: {dataSource.sourceTable} - {dataSource.filterField && (필터: {dataSource.filterField})} -
- )} - - {/* 그룹핑 정보 */} - {grouping?.enabled && ( -
- 그룹핑: {grouping.groupByField} -
- )} - - {/* 카드 제목 정보 */} - {showCardTitle && cardTitle && ( -
- 카드 제목: {cardTitle} -
- )} - - {/* 설정 안내 */} -
- 오른쪽 패널에서 행을 추가하고 타입(헤더/집계/테이블/필드)을 선택하세요 -
-
-
- ); - } - - // 로딩 상태 - if (isLoading) { - return ( -
- - 데이터를 불러오는 중... -
- ); - } - - // 오류 상태 - if (loadError) { - return ( -
-
- - 데이터 로드 실패 -
-

{loadError}

-
- ); - } - - // 🆕 v3: 자유 레이아웃 렌더링 (contentRows 사용) - const useNewLayout = contentRows && contentRows.length > 0; - const useGrouping = grouping?.enabled; - - // 그룹핑 모드 렌더링 - if (useGrouping) { - return ( -
-
- {groupedCardsData.map((card, cardIndex) => ( - r._isDirty) && "border-primary shadow-lg" - )} - > - {/* 카드 제목 (선택사항) */} - {showCardTitle && ( - - - {getCardTitle(card._representativeData, cardIndex)} - {card._rows.some((r) => r._isDirty) && ( - - 수정됨 - - )} - - - )} - - {/* 🆕 v3: contentRows 기반 렌더링 */} - {useNewLayout ? ( - contentRows.map((contentRow, rowIndex) => ( -
- {contentRow.type === "table" && contentRow.tableDataSource?.enabled ? ( - // 🆕 v3.1: 외부 테이블 데이터 소스 사용 -
- {/* 테이블 헤더 영역: 제목 + 버튼들 */} - {(contentRow.tableTitle || contentRow.tableCrud?.allowCreate) && ( -
- {contentRow.tableTitle || ""} -
- {/* 추가 버튼 */} - {contentRow.tableCrud?.allowCreate && ( - - )} -
-
- )} - - {contentRow.showTableHeader !== false && ( - - - {/* 🆕 v3.13: hidden 컬럼 필터링 */} - {(contentRow.tableColumns || []).filter(col => !col.hidden).map((col) => ( - - {col.label} - - ))} - {(contentRow.tableCrud?.allowUpdate || contentRow.tableCrud?.allowDelete) && ( - 작업 - )} - - - )} - - {(externalTableData[`${card._cardId}-${contentRow.id}`] || []).length === 0 ? ( - - !col.hidden)?.length || 0) + (contentRow.tableCrud?.allowDelete ? 1 : 0)} - className="text-center py-8 text-muted-foreground" - > - 데이터가 없습니다. - - - ) : ( - (externalTableData[`${card._cardId}-${contentRow.id}`] || []).map((row) => ( - - {/* 🆕 v3.13: hidden 컬럼 필터링 */} - {(contentRow.tableColumns || []).filter(col => !col.hidden).map((col) => ( - - {renderTableCell( - col, - row, - (value) => handleExternalRowDataChange(card._cardId, contentRow.id, row._rowId, col.field, value), - row._isNew || row._isEditing // 신규 행이거나 수정 모드일 때만 편집 가능 - )} - - ))} - {(contentRow.tableCrud?.allowUpdate || contentRow.tableCrud?.allowDelete) && ( - -
- {/* 수정 버튼: 저장된 행(isNew가 아닌)이고 편집 모드가 아닐 때만 표시 */} - {contentRow.tableCrud?.allowUpdate && !row._isNew && !row._isEditing && !row._isDeleted && ( - - )} - {/* 수정 취소 버튼: 편집 모드일 때만 표시 */} - {row._isEditing && !row._isNew && ( - - )} - {/* 삭제/복원 버튼 */} - {contentRow.tableCrud?.allowDelete && ( - row._isDeleted ? ( - - ) : ( - - ) - )} -
-
- )} -
- )) - )} -
-
-
- ) : ( - // 기존 renderContentRow 사용 - 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), - row._isNew || row._isEditing - )} - - ))} - - ))} - -
-
- )} - - )} -
-
- ))} -
- - {/* 🆕 v3.1: Footer 버튼 영역 */} - {footerConfig?.enabled && footerConfig.buttons && footerConfig.buttons.length > 0 ? ( -
- {footerConfig.buttons.map((btn) => ( - - ))} -
- ) : null} - - {/* 데이터 없음 */} - {groupedCardsData.length === 0 && !isLoading && ( -
표시할 데이터가 없습니다.
- )} - - {/* 🆕 v3.1: 삭제 확인 다이얼로그 */} - - - - 삭제 확인 - - 이 행을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다. - - - - 취소 - { - if (pendingDeleteInfo) { - handleDeleteExternalRow( - pendingDeleteInfo.cardId, - pendingDeleteInfo.rowId, - pendingDeleteInfo.contentRowId - ); - } - }} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - 삭제 - - - - -
- ); - } - - // 단순 모드 렌더링 (그룹핑 없음) - return ( -
-
- {cardsData.map((card, cardIndex) => ( - - {/* 카드 제목 (선택사항) */} - {showCardTitle && ( - - - {getCardTitle(card, cardIndex)} - {card._isDirty && (수정됨)} - - - )} - - {/* 🆕 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))} -
- ))} -
- )) - )} -
-
- ))} -
- - {/* 🆕 v3.1: Footer 버튼 영역 */} - {footerConfig?.enabled && footerConfig.buttons && footerConfig.buttons.length > 0 ? ( -
- {footerConfig.buttons.map((btn) => ( - - ))} -
- ) : null} - - {/* 데이터 없음 */} - {cardsData.length === 0 && !isLoading && ( -
표시할 데이터가 없습니다.
- )} -
- ); -} - -// 🆕 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), - row._isNew || row._isEditing - )} - - ))} - - ))} - -
-
- ); - - 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 = { - blue: "bg-blue-50 dark:bg-blue-950", - green: "bg-green-50 dark:bg-green-950", - purple: "bg-purple-50 dark:bg-purple-950", - orange: "bg-orange-50 dark:bg-orange-950", - }; - return colorMap[color] || ""; -} - -// 헤더 컬럼 렌더링 (집계값 포함) -function renderHeaderColumn( - col: CardColumnConfig, - card: GroupedCardData, - aggregations: AggregationConfig[] -) { - let value: any; - - // 집계값 타입이면 집계 결과에서 가져옴 - if (col.type === "aggregation" && col.aggregationField) { - value = card._aggregations[col.aggregationField]; - const aggConfig = aggregations.find((a) => a.resultField === col.aggregationField); - - return ( -
- -
- {typeof value === "number" ? value.toLocaleString() : value || "-"} - {aggConfig && ({aggConfig.type})} -
-
- ); - } - - // 일반 필드는 대표 데이터에서 가져옴 - value = card._representativeData[col.field]; - - return ( -
- -
- {value || "-"} -
-
- ); -} - -// 테이블 셀 렌더링 -// 🆕 v3.8: isRowEditable 파라미터 추가 - 행이 편집 가능한 상태인지 (신규 행이거나 수정 모드) -function renderTableCell(col: TableColumnConfig, row: CardRowData, onChange: (value: any) => void, isRowEditable?: boolean) { - const value = row[col.field]; - - // Badge 타입 - if (col.type === "badge") { - const badgeColor = col.badgeColorMap?.[value] || col.badgeVariant || "default"; - return {value || "-"}; - } - - // 🆕 v3.8: 행 수준 편집 가능 여부 체크 - // isRowEditable이 false이면 컬럼 설정과 관계없이 읽기 전용 - const canEdit = col.editable && (isRowEditable !== false); - - // 읽기 전용 - if (!canEdit) { - if (col.type === "number") { - return {typeof value === "number" ? value.toLocaleString() : value || "-"}; - } - if (col.type === "date") { - // ISO 8601 형식을 표시용으로 변환 - const displayDate = value ? (typeof value === 'string' && value.includes('T') ? value.split('T')[0] : value) : "-"; - return {displayDate}; - } - return {value || "-"}; - } - - // 편집 가능 - switch (col.type) { - case "text": - return ( - onChange(e.target.value)} - className="h-8 text-sm" - /> - ); - case "number": - return ( - onChange(Number(e.target.value) || 0)} - className="h-8 text-sm text-right" - /> - ); - case "date": - // ISO 8601 형식('2025-12-02T00:00:00.000Z')을 'YYYY-MM-DD' 형식으로 변환 - const dateValue = value ? (typeof value === 'string' && value.includes('T') ? value.split('T')[0] : value) : ""; - return ( - onChange(e.target.value)} - className="h-8 text-sm" - /> - ); - default: - return {value || "-"}; - } -} - -// 컬럼 렌더링 함수 (Simple 모드) -function renderColumn(col: CardColumnConfig, card: CardData, onChange: (value: any) => void) { - const value = card[col.field]; - const isReadOnly = !col.editable; - - return ( -
- - - {isReadOnly && ( -
- {value || "-"} -
- )} - - {!isReadOnly && ( - <> - {col.type === "text" && ( - onChange(e.target.value)} - placeholder={col.placeholder} - className="h-10 text-sm" - /> - )} - - {col.type === "number" && ( - onChange(e.target.value)} - placeholder={col.placeholder} - className="h-10 text-sm" - /> - )} - - {col.type === "date" && ( - onChange(e.target.value)} - className="h-10 text-sm" - /> - )} - - {col.type === "select" && ( - - )} - - {col.type === "textarea" && ( -