From 2f78c83ef632326c3e06a951b25c28b7a5a342ef Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 1 Dec 2025 18:50:26 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(repeat-screen-modal):=20=EC=99=B8?= =?UTF-8?q?=EB=B6=80=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=A1=B0=EC=9D=B8,?= =?UTF-8?q?=20=ED=95=84=ED=84=B0=EB=A7=81,=20CRUD=20=EB=B0=8F=20=EC=8B=A4?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EC=A7=91=EA=B3=84=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 외부 테이블 데이터 소스 설정 (TableDataSourceConfig) 추가 - 다중 테이블 조인 지원 (AdditionalJoinConfig) - 테이블 필터링 (equals/notEquals) 지원 - 테이블 CRUD (행 추가/수정/삭제) 기능 추가 - 데이터 변경 시 집계 실시간 재계산 (recalculateAggregationsWithExternalData) - 시각적 수식 빌더 (FormulaBuilder) 컴포넌트 추가 - 테이블 컬럼 순서 변경 기능 추가 - 백엔드: 배열 파라미터 IN 절 변환 로직 추가 --- .../src/services/tableManagementService.ts | 20 + frontend/components/screen/ScreenDesigner.tsx | 6 +- .../screen/panels/UnifiedPropertiesPanel.tsx | 8 +- frontend/components/ui/select.tsx | 2 +- .../components/repeat-screen-modal/README.md | 293 +++- .../RepeatScreenModalComponent.tsx | 999 +++++++++++- .../RepeatScreenModalConfigPanel.tsx | 1363 ++++++++++++++++- .../components/repeat-screen-modal/types.ts | 194 ++- .../section-paper/SectionPaperRenderer.tsx | 1 + .../simple-repeater-table/useCalculation.ts | 1 + 10 files changed, 2716 insertions(+), 171 deletions(-) diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 173de022..c1748123 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -1502,6 +1502,26 @@ export class TableManagementService { columnName ); + // 🆕 배열 처리: IN 절 사용 + if (Array.isArray(value)) { + if (value.length === 0) { + // 빈 배열이면 항상 false 조건 + return { + whereClause: `1 = 0`, + values: [], + paramCount: 0, + }; + } + + // IN 절로 여러 값 검색 + const placeholders = value.map((_, idx) => `$${paramIndex + idx}`).join(", "); + return { + whereClause: `${columnName} IN (${placeholders})`, + values: value, + paramCount: value.length, + }; + } + if (!entityTypeInfo.isEntityType || !entityTypeInfo.referenceTable) { // 엔티티 타입이 아니면 기본 검색 return { diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 08199609..95679adc 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -4245,8 +4245,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD {/* 통합 패널 */} {panelStates.unified?.isOpen && ( -
-
+
+

패널

-
+
diff --git a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx index e3940073..8bd98304 100644 --- a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx +++ b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx @@ -238,9 +238,9 @@ export const UnifiedPropertiesPanel: React.FC = ({ // 컴포넌트가 선택되지 않았을 때도 해상도 설정과 격자 설정은 표시 if (!selectedComponent) { return ( -
+
{/* 해상도 설정과 격자 설정 표시 */} -
+
{/* 해상도 설정 */} {currentResolution && onResolutionChange && ( @@ -1403,7 +1403,7 @@ export const UnifiedPropertiesPanel: React.FC = ({ }; return ( -
+
{/* 헤더 - 간소화 */}
{selectedComponent.type === "widget" && ( @@ -1414,7 +1414,7 @@ export const UnifiedPropertiesPanel: React.FC = ({
{/* 통합 컨텐츠 (탭 제거) */} -
+
{/* 해상도 설정 - 항상 맨 위에 표시 */} {currentResolution && onResolutionChange && ( diff --git a/frontend/components/ui/select.tsx b/frontend/components/ui/select.tsx index aef7dd9d..81e90fd3 100644 --- a/frontend/components/ui/select.tsx +++ b/frontend/components/ui/select.tsx @@ -31,7 +31,7 @@ function SelectTrigger({ data-slot="select-trigger" data-size={size} className={cn( - "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-48 items-center justify-between gap-2 rounded-md border bg-transparent text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-10 data-[size=default]:px-3 data-[size=default]:py-2 data-[size=sm]:h-9 data-[size=sm]:px-3 data-[size=sm]:py-1 data-[size=xs]:h-6 data-[size=xs]:px-2 data-[size=xs]:py-0 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-full items-center justify-between gap-2 rounded-md border bg-transparent text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-10 data-[size=default]:px-3 data-[size=default]:py-2 data-[size=sm]:h-9 data-[size=sm]:px-3 data-[size=sm]:py-1 data-[size=xs]:h-6 data-[size=xs]:px-2 data-[size=xs]:py-0 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className, )} {...props} diff --git a/frontend/lib/registry/components/repeat-screen-modal/README.md b/frontend/lib/registry/components/repeat-screen-modal/README.md index 6ba2783a..cb22964d 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/README.md +++ b/frontend/lib/registry/components/repeat-screen-modal/README.md @@ -1,10 +1,63 @@ -# RepeatScreenModal 컴포넌트 v3 +# RepeatScreenModal 컴포넌트 v3.1 ## 개요 `RepeatScreenModal`은 선택한 데이터를 기반으로 여러 개의 카드를 생성하고, 각 카드의 내부 레이아웃을 자유롭게 구성할 수 있는 컴포넌트입니다. -## v3 주요 변경사항 +## 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 주요 변경사항 (기존) ### 자유 레이아웃 시스템 @@ -33,29 +86,7 @@ | **집계 (aggregation)** | 그룹 내 데이터 집계값 표시 | 총수량, 합계금액 등 | | **테이블 (table)** | 그룹 내 각 행을 테이블로 표시 | 수주목록, 품목목록 등 | -### 자유로운 조합 - -``` -예시 1: 헤더 + 집계 + 테이블 (출하계획) -├── [행 1] 헤더: 품목코드, 품목명 -├── [행 2] 집계: 총수주잔량, 현재고 -└── [행 3] 테이블: 수주별 출하계획 - -예시 2: 집계만 -└── [행 1] 집계: 총매출, 총비용, 순이익 - -예시 3: 테이블만 -└── [행 1] 테이블: 품목 목록 - -예시 4: 테이블 2개 -├── [행 1] 테이블: 입고 내역 -└── [행 2] 테이블: 출고 내역 - -예시 5: 헤더 + 헤더 + 필드 -├── [행 1] 헤더: 기본 정보 (읽기전용) -├── [행 2] 헤더: 상세 정보 (읽기전용) -└── [행 3] 필드: 입력 필드 (편집가능) -``` +--- ## 설정 방법 @@ -107,13 +138,34 @@ - **집계 필드**: 그룹 탭에서 정의한 집계 결과 선택 - **스타일**: 배경색, 폰트 크기 -#### 테이블 행 설정 +#### 테이블 행 설정 (v3.1 확장) - **테이블 제목**: 선택사항 - **헤더 표시**: 테이블 헤더 표시 여부 +- **외부 테이블 데이터 소스**: (v3.1 신규) + - 소스 테이블: 조회할 외부 테이블 + - 조인 조건: 외부 테이블 키 ↔ 카드 데이터 키 + - 정렬: 정렬 컬럼 및 방향 +- **CRUD 설정**: (v3.1 신규) + - 추가: 새 행 추가 허용 + - 수정: 행 수정 허용 + - 삭제: 행 삭제 허용 (확인 팝업 옵션) - **테이블 컬럼**: 필드명, 라벨, 타입, 너비, 편집 가능 - **저장 설정**: 편집 가능한 컬럼의 저장 위치 +### 5. Footer 탭 (v3.1 신규) + +- **Footer 사용**: Footer 영역 활성화 +- **위치**: 컨텐츠 아래 / 하단 고정 (sticky) +- **정렬**: 왼쪽 / 가운데 / 오른쪽 +- **버튼 설정**: + - 라벨: 버튼 텍스트 + - 액션: 저장 / 취소 / 닫기 / 초기화 / 커스텀 + - 스타일: 기본 / 보조 / 외곽선 / 삭제 / 고스트 + - 아이콘: 저장 / X / 초기화 / 없음 + +--- + ## 데이터 흐름 ``` @@ -125,16 +177,22 @@ ↓ 4. 각 그룹에 대해 집계값 계산 ↓ -5. 카드 렌더링 (contentRows 기반) +5. 외부 테이블 데이터 소스가 설정된 테이블 행의 데이터 로드 (v3.1) ↓ -6. 사용자 편집 +6. 카드 렌더링 (contentRows 기반) ↓ -7. 저장 시 targetConfig에 따라 테이블별로 데이터 분류 후 저장 +7. 사용자 편집 (CRUD 포함) + ↓ +8. Footer 버튼 또는 기본 저장 버튼으로 저장 + ↓ +9. 기본 데이터 + 외부 테이블 데이터 일괄 저장 ``` +--- + ## 사용 예시 -### 출하계획 등록 +### 출하계획 등록 (v3.1 - 외부 테이블 + CRUD) ```typescript { @@ -167,40 +225,185 @@ type: "aggregation", aggregationLayout: "horizontal", aggregationFields: [ - { aggregationResultField: "total_balance", label: "총수주잔량", backgroundColor: "blue" }, - { aggregationResultField: "order_count", label: "수주건수", backgroundColor: "green" } + { sourceType: "aggregation", aggregationResultField: "total_balance", label: "총수주잔량", backgroundColor: "blue" }, + { sourceType: "formula", formula: "{order_qty} - {ship_qty}", label: "미출하 수량", backgroundColor: "orange" } ] }, { id: "row-3", type: "table", - tableTitle: "수주 목록", + 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: "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 } - } + { 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/repeat-screen-modal/RepeatScreenModalComponent.tsx b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx index 997b381c..25807607 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx +++ b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx @@ -9,7 +9,17 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ 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 } from "lucide-react"; +import { Loader2, Save, X, Layers, Table as TableIcon, Plus, Trash2, RotateCcw } from "lucide-react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { RepeatScreenModalProps, CardData, @@ -20,6 +30,10 @@ import { TableColumnConfig, CardContentRowConfig, AggregationDisplayConfig, + FooterConfig, + FooterButtonConfig, + TableDataSourceConfig, + TableCrudConfig, } from "./types"; import { ComponentRendererProps } from "@/types/component"; import { cn } from "@/lib/utils"; @@ -59,6 +73,9 @@ export function RepeatScreenModalComponent({ // 🆕 v3: 자유 레이아웃 const contentRows = componentConfig?.contentRows || []; + // 🆕 v3.1: Footer 설정 + const footerConfig = componentConfig?.footerConfig; + // (레거시 호환) const cardLayout = componentConfig?.cardLayout || []; const cardMode = componentConfig?.cardMode || "simple"; @@ -71,6 +88,16 @@ export function RepeatScreenModalComponent({ 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); // 초기 데이터 로드 useEffect(() => { @@ -208,6 +235,425 @@ export function RepeatScreenModalComponent({ 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) { + const refValue = representativeData[condition.referenceKey]; + if (refValue !== undefined && refValue !== null) { + filters[condition.sourceKey] = refValue; + } + } + + if (Object.keys(filters).length === 0) { + console.warn(`[RepeatScreenModal] 조인 조건이 없습니다: ${contentRow.id}`); + continue; + } + + // 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], + }); + + // 🆕 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, + ...row, + })); + } + } 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 tableRowWithExternalSource = contentRows.find( + (row) => row.type === "table" && row.tableDataSource?.enabled + ); + + if (!tableRowWithExternalSource) return; + + // 각 카드의 집계 재계산 + const updatedCards = groupedCardsData.map((card) => { + const key = `${card._cardId}-${tableRowWithExternalSource.id}`; + const externalRows = extData[key] || []; + + // 집계 재계산 + 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( + externalRows, + 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) { + // 가상 집계 (연산식) - 외부 테이블 데이터 포함하여 재계산 + newAggregations[agg.resultField] = evaluateFormulaWithContext( + agg.formula, + card._representativeData, + card._rows, + externalRows, + 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: 외부 테이블 행 추가 + const handleAddExternalRow = (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; + } + } + + 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.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.1: 외부 테이블 행 삭제 실행 + const handleDeleteExternalRow = (cardId: string, rowId: string, contentRowId: string) => { + const key = `${cardId}-${contentRowId}`; + setExternalTableData((prev) => { + const newData = { + ...prev, + [key]: (prev[key] || []).filter((row) => row._rowId !== rowId), + }; + + // 🆕 v3.5: 행 삭제 시 집계 재계산 + setTimeout(() => { + recalculateAggregationsWithExternalData(newData); + }, 0); + + return newData; + }); + setDeleteConfirmOpen(false); + setPendingDeleteInfo(null); + }; + + // 🆕 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) { @@ -240,14 +686,6 @@ export function RepeatScreenModalComponent({ let cardIndex = 0; groupMap.forEach((rows, groupKey) => { - // 집계 계산 - const aggregations: Record = {}; - if (groupingConfig.aggregations) { - groupingConfig.aggregations.forEach((agg) => { - aggregations[agg.resultField] = calculateAggregation(rows, agg); - }); - } - // 행 데이터 생성 const cardRows: CardRowData[] = rows.map((row, idx) => ({ _rowId: `row-${cardIndex}-${idx}-${Date.now()}`, @@ -256,13 +694,56 @@ export function RepeatScreenModalComponent({ ...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; + } + } + }); + } + result.push({ _cardId: `grouped-card-${cardIndex}-${Date.now()}`, _groupKey: groupKey, _groupField: groupByField || "", _aggregations: aggregations, _rows: cardRows, - _representativeData: rows[0] || {}, + _representativeData: representativeData, }); cardIndex++; @@ -271,11 +752,15 @@ export function RepeatScreenModalComponent({ return result; }; - // 집계 계산 - const calculateAggregation = (rows: any[], agg: AggregationConfig): number => { - const values = rows.map((row) => Number(row[agg.sourceField]) || 0); + // 집계 계산 (컬럼 집계용) + const calculateColumnAggregation = ( + rows: any[], + sourceField: string, + type: "sum" | "count" | "avg" | "min" | "max" + ): number => { + const values = rows.map((row) => Number(row[sourceField]) || 0); - switch (agg.type) { + switch (type) { case "sum": return values.reduce((a, b) => a + b, 0); case "count": @@ -291,6 +776,175 @@ export function RepeatScreenModalComponent({ } }; + // 🆕 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) return "0"; + const values = externalRows.map((row) => Number(row[fieldName]) || 0); + 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 = {}; @@ -401,12 +1055,16 @@ export function RepeatScreenModalComponent({ setIsSaving(true); try { + // 기존 데이터 저장 if (cardMode === "withTable") { await saveGroupedData(); } else { await saveSimpleData(); } + // 🆕 v3.1: 외부 테이블 데이터 저장 + await saveExternalTableData(); + alert("저장되었습니다."); } catch (error: any) { console.error("저장 실패:", error); @@ -416,6 +1074,92 @@ export function RepeatScreenModalComponent({ } }; + // 🆕 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.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); @@ -536,11 +1280,21 @@ export function RepeatScreenModalComponent({ // 수정 여부 확인 const hasDirtyData = useMemo(() => { + // 기존 데이터 수정 여부 + let hasBaseDirty = false; if (cardMode === "withTable") { - return groupedCardsData.some((card) => card._rows.some((row) => row._isDirty)); + hasBaseDirty = groupedCardsData.some((card) => card._rows.some((row) => row._isDirty)); + } else { + hasBaseDirty = cardsData.some((c) => c._isDirty); } - return cardsData.some((c) => c._isDirty); - }, [cardMode, cardsData, groupedCardsData]); + + // 🆕 v3.1: 외부 테이블 데이터 수정 여부 + const hasExternalDirty = Object.values(externalTableData).some((rows) => + rows.some((row) => row._isDirty) + ); + + return hasBaseDirty || hasExternalDirty; + }, [cardMode, cardsData, groupedCardsData, externalTableData]); // 디자인 모드 렌더링 if (isDesignMode) { @@ -710,7 +1464,105 @@ export function RepeatScreenModalComponent({ {useNewLayout ? ( contentRows.map((contentRow, rowIndex) => (
- {renderContentRow(contentRow, card, grouping?.aggregations || [], handleRowDataChange)} + {contentRow.type === "table" && contentRow.tableDataSource?.enabled ? ( + // 🆕 v3.1: 외부 테이블 데이터 소스 사용 +
+ {contentRow.tableTitle && ( +
+ {contentRow.tableTitle} + {contentRow.tableCrud?.allowCreate && ( + + )} +
+ )} + {!contentRow.tableTitle && contentRow.tableCrud?.allowCreate && ( +
+ +
+ )} + + {contentRow.showTableHeader !== false && ( + + + {(contentRow.tableColumns || []).map((col) => ( + + {col.label} + + ))} + {contentRow.tableCrud?.allowDelete && ( + 삭제 + )} + + + )} + + {(externalTableData[`${card._cardId}-${contentRow.id}`] || []).length === 0 ? ( + + + 데이터가 없습니다. + + + ) : ( + (externalTableData[`${card._cardId}-${contentRow.id}`] || []).map((row) => ( + + {(contentRow.tableColumns || []).map((col) => ( + + {renderTableCell(col, row, (value) => + handleExternalRowDataChange(card._cardId, contentRow.id, row._rowId, col.field, value) + )} + + ))} + {contentRow.tableCrud?.allowDelete && ( + + + + )} + + )) + )} + +
+
+ ) : ( + // 기존 renderContentRow 사용 + renderContentRow(contentRow, card, grouping?.aggregations || [], handleRowDataChange) + )}
)) ) : ( @@ -782,20 +1634,74 @@ export function RepeatScreenModalComponent({ ))}
- {/* 저장 버튼 */} - {groupedCardsData.length > 0 && ( -
- + {/* 🆕 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" + > + 삭제 + + + +
); } @@ -852,15 +1758,40 @@ export function RepeatScreenModalComponent({ ))}
- {/* 저장 버튼 */} - {cardsData.length > 0 && ( -
- + {/* 🆕 v3.1: Footer 버튼 영역 */} + {footerConfig?.enabled && footerConfig.buttons && footerConfig.buttons.length > 0 ? ( +
+ {footerConfig.buttons.map((btn) => ( + + ))}
- )} + ) : null} {/* 데이터 없음 */} {cardsData.length === 0 && !isLoading && ( diff --git a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx index ab8c962d..da7088a9 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx +++ b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx @@ -6,17 +6,15 @@ import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; -import { Plus, X, GripVertical, Check, ChevronsUpDown, Table, Layers } from "lucide-react"; +import { Plus, X, GripVertical, Check, ChevronsUpDown, Table, Layers, ChevronUp, ChevronDown } from "lucide-react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Badge } from "@/components/ui/badge"; -import { ScrollArea } from "@/components/ui/scroll-area"; import { RepeatScreenModalProps, CardRowConfig, CardColumnConfig, ColumnSourceConfig, ColumnTargetConfig, - DataSourceConfig, GroupingConfig, AggregationConfig, TableLayoutConfig, @@ -84,14 +82,14 @@ function SourceColumnSelector({ variant="outline" role="combobox" aria-expanded={open} - className="h-6 w-full justify-between text-[10px]" + className="h-6 w-full justify-between text-[10px] min-w-0 shrink" disabled={!sourceTable || isLoading} > {isLoading ? "..." : displayText} - + @@ -315,75 +313,583 @@ function CardTitleEditor({ ); } +// 🆕 v3.2: 시각적 수식 빌더 +interface FormulaToken { + id: string; + type: "aggregation" | "column" | "operator" | "number"; + // aggregation: 이전 집계 결과 참조 + aggregationField?: string; + // column: 테이블 컬럼 집계 + table?: string; + column?: string; + aggFunction?: "SUM" | "COUNT" | "AVG" | "MIN" | "MAX" | "SUM_EXT" | "COUNT_EXT" | "AVG_EXT" | "MIN_EXT" | "MAX_EXT" | "VALUE"; + isExternal?: boolean; + // operator: 연산자 + operator?: "+" | "-" | "*" | "/" | "(" | ")"; + // number: 숫자 + value?: number; +} + +function FormulaBuilder({ + formula, + sourceTable, + allTables, + referenceableAggregations, + onChange, +}: { + formula: string; + sourceTable: string; + allTables: { tableName: string; displayName?: string }[]; + referenceableAggregations: AggregationConfig[]; + onChange: (formula: string) => void; +}) { + // 수식 토큰 상태 + const [tokens, setTokens] = useState([]); + + // 새 토큰 추가용 상태 + const [newTokenType, setNewTokenType] = useState<"aggregation" | "column">("aggregation"); + const [newTokenTable, setNewTokenTable] = useState(sourceTable || ""); + const [newTokenColumn, setNewTokenColumn] = useState(""); + const [newTokenAggFunction, setNewTokenAggFunction] = useState("SUM"); + const [newTokenAggField, setNewTokenAggField] = useState(""); + + // formula 문자열에서 토큰 파싱 (초기화용) + useEffect(() => { + if (!formula) { + setTokens([]); + return; + } + + // 간단한 파싱: 기존 formula가 있으면 토큰으로 변환 시도 + const parsed = parseFormulaToTokens(formula, sourceTable); + if (parsed.length > 0) { + setTokens(parsed); + } + }, []); + + // 토큰을 formula 문자열로 변환 + const tokensToFormula = (tokenList: FormulaToken[]): string => { + return tokenList.map((token) => { + switch (token.type) { + case "aggregation": + return `{${token.aggregationField}}`; + case "column": + if (token.aggFunction === "VALUE") { + return `{${token.column}}`; + } + return `${token.aggFunction}({${token.column}})`; + case "operator": + return ` ${token.operator} `; + case "number": + return String(token.value); + default: + return ""; + } + }).join(""); + }; + + // formula 문자열에서 토큰 파싱 (간단한 버전) + const parseFormulaToTokens = (formulaStr: string, defaultTable: string): FormulaToken[] => { + const result: FormulaToken[] = []; + // 간단한 파싱 - 복잡한 경우는 수동 입력 모드로 전환 + // 이 함수는 기존 formula가 있을 때 최대한 파싱 시도 + const parts = formulaStr.split(/(\s*[+\-*/()]\s*)/); + + for (const part of parts) { + const trimmed = part.trim(); + if (!trimmed) continue; + + // 연산자 + if (["+", "-", "*", "/", "(", ")"].includes(trimmed)) { + result.push({ + id: `op-${Date.now()}-${Math.random()}`, + type: "operator", + operator: trimmed as FormulaToken["operator"], + }); + continue; + } + + // 집계 함수: SUM({column}), SUM_EXT({column}) + const aggMatch = trimmed.match(/^(SUM|COUNT|AVG|MIN|MAX)(_EXT)?\(\{(\w+)\}\)$/); + if (aggMatch) { + result.push({ + id: `col-${Date.now()}-${Math.random()}`, + type: "column", + table: aggMatch[2] ? "" : defaultTable, // _EXT면 외부 테이블 + column: aggMatch[3], + aggFunction: (aggMatch[1] + (aggMatch[2] || "")) as FormulaToken["aggFunction"], + isExternal: !!aggMatch[2], + }); + continue; + } + + // 필드 참조: {fieldName} + const fieldMatch = trimmed.match(/^\{(\w+)\}$/); + if (fieldMatch) { + result.push({ + id: `agg-${Date.now()}-${Math.random()}`, + type: "aggregation", + aggregationField: fieldMatch[1], + }); + continue; + } + + // 숫자 + const num = parseFloat(trimmed); + if (!isNaN(num)) { + result.push({ + id: `num-${Date.now()}-${Math.random()}`, + type: "number", + value: num, + }); + } + } + + return result; + }; + + // 토큰 추가 + const addToken = (token: FormulaToken) => { + const newTokens = [...tokens, token]; + setTokens(newTokens); + onChange(tokensToFormula(newTokens)); + }; + + // 토큰 삭제 + const removeToken = (tokenId: string) => { + const newTokens = tokens.filter((t) => t.id !== tokenId); + setTokens(newTokens); + onChange(tokensToFormula(newTokens)); + }; + + // 연산자 추가 + const addOperator = (op: FormulaToken["operator"]) => { + addToken({ + id: `op-${Date.now()}`, + type: "operator", + operator: op, + }); + }; + + // 집계 참조 추가 + const addAggregationRef = () => { + if (!newTokenAggField) return; + addToken({ + id: `agg-${Date.now()}`, + type: "aggregation", + aggregationField: newTokenAggField, + }); + setNewTokenAggField(""); + }; + + // 컬럼 집계 추가 + const addColumnAgg = () => { + if (!newTokenColumn) return; + const isExternal = newTokenTable !== sourceTable; + let aggFunc = newTokenAggFunction; + + // 외부 테이블이면 _EXT 붙이기 + if (isExternal && aggFunc && !aggFunc.endsWith("_EXT") && aggFunc !== "VALUE") { + aggFunc = (aggFunc + "_EXT") as FormulaToken["aggFunction"]; + } + + addToken({ + id: `col-${Date.now()}`, + type: "column", + table: newTokenTable, + column: newTokenColumn, + aggFunction: aggFunc, + isExternal, + }); + setNewTokenColumn(""); + }; + + // 토큰 표시 텍스트 + const getTokenDisplay = (token: FormulaToken): string => { + switch (token.type) { + case "aggregation": + const refAgg = referenceableAggregations.find((a) => a.resultField === token.aggregationField); + return refAgg?.label || token.aggregationField || ""; + case "column": + if (token.aggFunction === "VALUE") { + return `${token.column}`; + } + return `${token.aggFunction}(${token.column})`; + case "operator": + return token.operator || ""; + case "number": + return String(token.value); + default: + return ""; + } + }; + + // 토큰 배지 색상 + const getTokenBadgeClass = (token: FormulaToken): string => { + switch (token.type) { + case "aggregation": + return "bg-blue-100 text-blue-700 border-blue-200"; + case "column": + return token.isExternal + ? "bg-orange-100 text-orange-700 border-orange-200" + : "bg-green-100 text-green-700 border-green-200"; + case "operator": + return "bg-gray-100 text-gray-700 border-gray-200"; + case "number": + return "bg-purple-100 text-purple-700 border-purple-200"; + default: + return ""; + } + }; + + return ( +
+ {/* 현재 수식 표시 */} +
+ +
+ {tokens.length === 0 ? ( + 아래에서 요소를 추가하세요 + ) : ( + tokens.map((token) => ( + removeToken(token.id)} + title="클릭하여 삭제" + > + {getTokenDisplay(token)} + + + )) + )} +
+ {/* 생성된 수식 미리보기 */} + {tokens.length > 0 && ( +

+ {tokensToFormula(tokens)} +

+ )} +
+ + {/* 연산자 버튼 */} +
+ +
+ {["+", "-", "*", "/", "(", ")"].map((op) => ( + + ))} +
+
+ + {/* 집계 참조 추가 */} + {referenceableAggregations.length > 0 && ( +
+ +
+ 참조할 집계 선택 +
+ + +
+
+
+ )} + + {/* 테이블 컬럼 집계 추가 */} +
+ + + {/* 테이블 선택 */} +
+ 테이블 + +
+ + {/* 컬럼 선택 */} +
+ 컬럼 + +
+ + {/* 집계 함수 및 추가 버튼 */} +
+
+ 집계 함수 + +
+
+ +
+
+ + {newTokenTable !== sourceTable && newTokenTable && ( +

외부 테이블: _EXT 함수 사용

+ )} +
+ + {/* 수동 입력 모드 토글 */} +
+ + 수동 입력 모드 + +
+ { + const parsed = parseFormulaToTokens(e.target.value, sourceTable); + setTokens(parsed); + onChange(e.target.value); + }} + placeholder="{total_balance} - SUM_EXT({plan_qty})" + className="h-6 text-[10px] font-mono" + /> +

+ 직접 수식 입력. 예: {"{"}resultField{"}"}, SUM({"{"}column{"}"}), SUM_EXT({"{"}column{"}"}) +

+
+
+
+ ); +} + // 집계 설정 아이템 (로컬 상태 관리로 입력 시 리렌더링 방지) +// 🆕 v3.2: 다중 테이블 및 가상 집계(formula) 지원 function AggregationConfigItem({ agg, index, sourceTable, + allTables, + existingAggregations, onUpdate, onRemove, }: { agg: AggregationConfig; index: number; sourceTable: string; + allTables: { tableName: string; displayName?: string }[]; + existingAggregations: AggregationConfig[]; // 연산식에서 참조할 수 있는 기존 집계들 onUpdate: (updates: Partial) => void; onRemove: () => void; }) { const [localLabel, setLocalLabel] = useState(agg.label || ""); const [localResultField, setLocalResultField] = useState(agg.resultField || ""); + const [localFormula, setLocalFormula] = useState(agg.formula || ""); // agg 변경 시 로컬 상태 동기화 useEffect(() => { setLocalLabel(agg.label || ""); setLocalResultField(agg.resultField || ""); - }, [agg.label, agg.resultField]); + setLocalFormula(agg.formula || ""); + }, [agg.label, agg.resultField, agg.formula]); + + // 현재 집계보다 앞에 정의된 집계들만 참조 가능 (순환 참조 방지) + const referenceableAggregations = existingAggregations.slice(0, index); + + // sourceType 기본값 처리 + const currentSourceType = agg.sourceType || "column"; return ( -
+
- - 집계 {index + 1} - +
+ + {currentSourceType === "formula" ? "가상" : "집계"} {index + 1} + +
-
- - onUpdate({ sourceField: value })} - placeholder="합계할 필드" - /> + {/* 집계 타입 선택 */} +
+ +
-
-
- - -
+ {/* === 컬럼 집계 설정 === */} + {currentSourceType === "column" && ( + <> + {/* 테이블 선택 */} +
+ + +

+ 기본 테이블 외 다른 테이블도 선택 가능 +

+
+ {/* 컬럼 선택 */} +
+ + onUpdate({ sourceField: value })} + placeholder="합계할 필드" + /> +
+ + {/* 집계 함수 */} +
+ + +
+ + )} + + {/* === 가상 집계 (연산식) 설정 === */} + {currentSourceType === "formula" && ( + { + setLocalFormula(newFormula); + onUpdate({ formula: newFormula }); + }} + /> + )} + + {/* 공통: 라벨 및 결과 필드명 */} +
-
-
- - 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]" - /> +
+ + 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] font-mono" + /> +
); } // 테이블 선택기 (Combobox) - 240px 최적화 -function TableSelector({ value, onChange }: { value: string; onChange: (value: string) => void }) { +function TableSelector({ + value, + onChange, + allTables, + placeholder = "테이블 선택", +}: { + value: string; + onChange: (value: string) => void; + allTables?: { tableName: string; displayName?: string }[]; + placeholder?: string; +}) { const [tables, setTables] = useState<{ tableName: string; displayName?: string }[]>([]); const [isLoading, setIsLoading] = useState(false); const [open, setOpen] = useState(false); useEffect(() => { + // allTables가 전달되면 API 호출 없이 사용 + if (allTables && allTables.length > 0) { + setTables(allTables); + return; + } + const loadTables = async () => { setIsLoading(true); try { const response = await tableManagementApi.getTableList(); if (response.success && response.data) { // API 응답이 배열인 경우와 객체인 경우 모두 처리 - const tableData = Array.isArray(response.data) - ? response.data + const tableData = Array.isArray(response.data) + ? response.data : (response.data as any).tables || response.data || []; setTables(tableData); } @@ -446,10 +968,10 @@ function TableSelector({ value, onChange }: { value: string; onChange: (value: s } }; loadTables(); - }, []); + }, [allTables]); const selectedTable = (tables || []).find((t) => t.tableName === value); - const displayText = selectedTable ? selectedTable.tableName : "테이블 선택"; + const displayText = selectedTable ? selectedTable.tableName : placeholder; return ( @@ -587,10 +1109,19 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM }); }; - const addAggregation = () => { + const addAggregation = (sourceType: "column" | "formula" = "column") => { const newAgg: AggregationConfig = { - sourceField: "", - type: "sum", + sourceType, + // column 타입 기본값 + ...(sourceType === "column" && { + sourceTable: localConfig.dataSource?.sourceTable || "", + sourceField: "", + type: "sum" as const, + }), + // formula 타입 기본값 + ...(sourceType === "formula" && { + formula: "", + }), resultField: `agg_${Date.now()}`, label: "", }; @@ -766,6 +1297,7 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM const addContentRowAggField = (rowIndex: number) => { const newRows = [...(localConfig.contentRows || [])]; const newAggField: AggregationDisplayConfig = { + sourceType: "aggregation", aggregationResultField: "", label: "", }; @@ -790,6 +1322,20 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM updateConfig({ contentRows: newRows }); }; + // 🆕 집계 필드 순서 변경 + const moveContentRowAggField = (rowIndex: number, fieldIndex: number, direction: "up" | "down") => { + const newRows = [...(localConfig.contentRows || [])]; + const fields = newRows[rowIndex].aggregationFields; + if (!fields) return; + + const newIndex = direction === "up" ? fieldIndex - 1 : fieldIndex + 1; + if (newIndex < 0 || newIndex >= fields.length) return; + + // 배열 요소 교환 + [fields[fieldIndex], fields[newIndex]] = [fields[newIndex], fields[fieldIndex]]; + updateConfig({ contentRows: newRows }); + }; + // contentRow 내 테이블 컬럼 관리 (table 타입) const addContentRowTableColumn = (rowIndex: number) => { const newRows = [...(localConfig.contentRows || [])]; @@ -818,6 +1364,23 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM updateConfig({ contentRows: newRows }); }; + // 테이블 컬럼 순서 변경 + const moveContentRowTableColumn = (rowIndex: number, colIndex: number, direction: "up" | "down") => { + const newRows = [...(localConfig.contentRows || [])]; + const columns = newRows[rowIndex].tableColumns; + if (!columns) return; + + const newIndex = direction === "up" ? colIndex - 1 : colIndex + 1; + if (newIndex < 0 || newIndex >= columns.length) return; + + // 컬럼 위치 교환 + const newColumns = [...columns]; + [newColumns[colIndex], newColumns[newIndex]] = [newColumns[newIndex], newColumns[colIndex]]; + newRows[rowIndex].tableColumns = newColumns; + + updateConfig({ contentRows: newRows }); + }; + // === (레거시) Simple 모드 행/컬럼 관련 함수 === const addRow = () => { const newRow: CardRowConfig = { @@ -874,10 +1437,10 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM }; return ( - -
+
+
- + 기본 @@ -1014,8 +1577,8 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM {/* === 그룹핑 설정 탭 === */} - -
+ +

그룹핑

{/* 집계 설정 */} -
+
- +
+ + +
+

+ 컬럼 집계: 테이블 컬럼의 합계/개수 등 | 가상 집계: 연산식으로 계산 +

+ {(localConfig.grouping?.aggregations || []).map((agg, index) => ( updateAggregation(index, updates)} onRemove={() => removeAggregation(index)} /> @@ -1139,9 +1726,11 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM onAddAggField={() => addContentRowAggField(rowIndex)} onRemoveAggField={(fieldIndex) => removeContentRowAggField(rowIndex, fieldIndex)} onUpdateAggField={(fieldIndex, updates) => updateContentRowAggField(rowIndex, fieldIndex, updates)} + onMoveAggField={(fieldIndex, direction) => moveContentRowAggField(rowIndex, fieldIndex, direction)} onAddTableColumn={() => addContentRowTableColumn(rowIndex)} onRemoveTableColumn={(colIndex) => removeContentRowTableColumn(rowIndex, colIndex)} onUpdateTableColumn={(colIndex, updates) => updateContentRowTableColumn(rowIndex, colIndex, updates)} + onMoveTableColumn={(colIndex, direction) => moveContentRowTableColumn(rowIndex, colIndex, direction)} /> ))}
@@ -1155,9 +1744,10 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM )}
+
- +
); } @@ -1177,9 +1767,11 @@ function ContentRowConfigSection({ onAddAggField, onRemoveAggField, onUpdateAggField, + onMoveAggField, onAddTableColumn, onRemoveTableColumn, onUpdateTableColumn, + onMoveTableColumn, }: { row: CardContentRowConfig; rowIndex: number; @@ -1195,9 +1787,11 @@ function ContentRowConfigSection({ onAddAggField: () => void; onRemoveAggField: (fieldIndex: number) => void; onUpdateAggField: (fieldIndex: number, updates: Partial) => void; + onMoveAggField: (fieldIndex: number, direction: "up" | "down") => void; onAddTableColumn: () => void; onRemoveTableColumn: (colIndex: number) => void; onUpdateTableColumn: (colIndex: number, updates: Partial) => void; + onMoveTableColumn?: (colIndex: number, direction: "up" | "down") => void; }) { // 로컬 상태로 Input 필드 관리 (타이핑 시 리렌더링 방지) const [localTableTitle, setLocalTableTitle] = useState(row.tableTitle || ""); @@ -1397,9 +1991,34 @@ function ContentRowConfigSection({ {(row.aggregationFields || []).map((field, fieldIndex) => (
- - 집계 {fieldIndex + 1} - +
+ {/* 순서 변경 버튼 */} +
+ + +
+ + 집계 {fieldIndex + 1} + +
@@ -1507,6 +2126,502 @@ function ContentRowConfigSection({
+ {/* 외부 테이블 데이터 소스 설정 */} +
+
+ + onUpdateRow({ + tableDataSource: checked + ? { enabled: true, sourceTable: "", joinConditions: [] } + : undefined, + }) + } + className="scale-[0.6]" + /> + +
+ + {row.tableDataSource?.enabled && ( +
+
+ + +
+ +
+ +
+
+ 외부 테이블 키 + + onUpdateRow({ + tableDataSource: { + ...row.tableDataSource!, + joinConditions: [ + { + ...row.tableDataSource?.joinConditions?.[0], + sourceKey: value, + referenceKey: row.tableDataSource?.joinConditions?.[0]?.referenceKey || "", + }, + ], + }, + }) + } + placeholder="키 선택" + /> +
+
+ 카드 데이터 키 + + onUpdateRow({ + tableDataSource: { + ...row.tableDataSource!, + joinConditions: [ + { + ...row.tableDataSource?.joinConditions?.[0], + sourceKey: row.tableDataSource?.joinConditions?.[0]?.sourceKey || "", + referenceKey: value, + }, + ], + }, + }) + } + placeholder="키 선택" + /> +
+
+
+ +
+
+ + + onUpdateRow({ + tableDataSource: { + ...row.tableDataSource!, + orderBy: value ? { column: value, direction: row.tableDataSource?.orderBy?.direction || "desc" } : undefined, + }, + }) + } + placeholder="선택" + /> +
+
+ + +
+
+ + {/* 🆕 추가 조인 테이블 설정 */} +
+
+ + +
+

+ 소스 테이블에 없는 컬럼을 다른 테이블에서 조인하여 가져옵니다 +

+ + {(row.tableDataSource?.additionalJoins || []).map((join, joinIndex) => ( +
+
+ + 조인 {joinIndex + 1} + + +
+ + {/* 조인 테이블 선택 */} +
+ + { + const newJoins = [...(row.tableDataSource?.additionalJoins || [])]; + newJoins[joinIndex] = { ...join, joinTable: value }; + onUpdateRow({ + tableDataSource: { + ...row.tableDataSource!, + additionalJoins: newJoins, + }, + }); + }} + placeholder="테이블 선택" + /> +
+ + {/* 조인 조건 */} + {join.joinTable && ( +
+ +
+
+ { + const newJoins = [...(row.tableDataSource?.additionalJoins || [])]; + newJoins[joinIndex] = { ...join, sourceKey: value }; + onUpdateRow({ + tableDataSource: { + ...row.tableDataSource!, + additionalJoins: newJoins, + }, + }); + }} + placeholder="소스 키" + /> +
+ = +
+ { + const newJoins = [...(row.tableDataSource?.additionalJoins || [])]; + newJoins[joinIndex] = { ...join, targetKey: value }; + onUpdateRow({ + tableDataSource: { + ...row.tableDataSource!, + additionalJoins: newJoins, + }, + }); + }} + placeholder="조인 키" + /> +
+
+

+ {row.tableDataSource?.sourceTable}.{join.sourceKey || "?"} = {join.joinTable}.{join.targetKey || "?"} +

+
+ )} +
+ ))} + + {(row.tableDataSource?.additionalJoins || []).length === 0 && ( +
+ 조인 테이블 없음 (소스 테이블 컬럼만 사용) +
+ )} +
+ + {/* 🆕 v3.4: 필터 설정 */} +
+
+ + + onUpdateRow({ + tableDataSource: { + ...row.tableDataSource!, + filterConfig: checked + ? { + enabled: true, + filterField: "", + filterType: "equals", + referenceField: "", + referenceSource: "representativeData", + } + : undefined, + }, + }) + } + className="scale-[0.6]" + /> +
+

+ 그룹 내 데이터를 특정 조건으로 필터링합니다 (같은 값만 / 다른 값만) +

+ + {row.tableDataSource?.filterConfig?.enabled && ( +
+ {/* 필터 타입 */} +
+ + +
+ + {/* 필터 필드 */} +
+ + + onUpdateRow({ + tableDataSource: { + ...row.tableDataSource!, + filterConfig: { + ...row.tableDataSource!.filterConfig!, + filterField: value, + }, + }, + }) + } + placeholder="필터링할 컬럼 선택" + /> +

+ 이 컬럼 값을 기준으로 필터링합니다 +

+
+ + {/* 비교 기준 소스 */} +
+ + +
+ + {/* 비교 기준 필드 */} +
+ + {row.tableDataSource.filterConfig.referenceSource === "representativeData" ? ( + + onUpdateRow({ + tableDataSource: { + ...row.tableDataSource!, + filterConfig: { + ...row.tableDataSource!.filterConfig!, + referenceField: value, + }, + }, + }) + } + placeholder="비교할 필드 선택" + /> + ) : ( + + onUpdateRow({ + tableDataSource: { + ...row.tableDataSource!, + filterConfig: { + ...row.tableDataSource!.filterConfig!, + referenceField: e.target.value, + }, + }, + }) + } + placeholder="formData 필드명 (예: selectedOrderNo)" + className="h-6 text-[10px]" + /> + )} +

+ 이 값과 비교하여 필터링합니다 +

+
+ + {/* 필터 조건 미리보기 */} + {row.tableDataSource.filterConfig.filterField && row.tableDataSource.filterConfig.referenceField && ( +
+ 조건: + {row.tableDataSource.sourceTable}.{row.tableDataSource.filterConfig.filterField} + {row.tableDataSource.filterConfig.filterType === "equals" ? " = " : " != "} + {row.tableDataSource.filterConfig.referenceSource === "representativeData" + ? `카드.${row.tableDataSource.filterConfig.referenceField}` + : `formData.${row.tableDataSource.filterConfig.referenceField}` + } +
+ )} +
+ )} +
+
+ )} +
+ + {/* CRUD 설정 */} +
+ +
+
+ + onUpdateRow({ + tableCrud: { ...row.tableCrud, allowCreate: checked, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: row.tableCrud?.allowDelete || false }, + }) + } + className="scale-[0.5]" + /> + +
+
+ + onUpdateRow({ + tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: checked, allowDelete: row.tableCrud?.allowDelete || false }, + }) + } + className="scale-[0.5]" + /> + +
+
+ + onUpdateRow({ + tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: checked }, + }) + } + className="scale-[0.5]" + /> + +
+
+ {row.tableCrud?.allowDelete && ( +
+ + onUpdateRow({ + tableCrud: { ...row.tableCrud!, deleteConfirm: { enabled: checked } }, + }) + } + className="scale-[0.5]" + /> + +
+ )} +
+ {/* 테이블 컬럼 목록 */}
@@ -1523,9 +2638,14 @@ function ContentRowConfigSection({ col={col} colIndex={colIndex} allTables={allTables} - dataSourceTable={dataSourceTable} + dataSourceTable={row.tableDataSource?.enabled ? row.tableDataSource.sourceTable : dataSourceTable} + additionalJoins={row.tableDataSource?.additionalJoins || []} onUpdate={(updates) => onUpdateTableColumn(colIndex, updates)} onRemove={() => onRemoveTableColumn(colIndex)} + onMoveUp={() => onMoveTableColumn?.(colIndex, "up")} + onMoveDown={() => onMoveTableColumn?.(colIndex, "down")} + isFirst={colIndex === 0} + isLast={colIndex === (row.tableColumns || []).length - 1} /> ))} @@ -1945,20 +3065,42 @@ function TableColumnConfigSection({ colIndex, allTables, dataSourceTable, + additionalJoins, onUpdate, onRemove, + onMoveUp, + onMoveDown, + isFirst, + isLast, }: { col: TableColumnConfig; colIndex: number; allTables: { tableName: string; displayName?: string }[]; dataSourceTable: string; + additionalJoins: { id: string; joinTable: string; joinType: string; sourceKey: string; targetKey: string }[]; onUpdate: (updates: Partial) => void; onRemove: () => void; + onMoveUp?: () => void; + onMoveDown?: () => void; + isFirst?: boolean; + isLast?: boolean; }) { // 로컬 상태로 Input 필드 관리 (타이핑 시 리렌더링 방지) const [localLabel, setLocalLabel] = useState(col.label || ""); const [localWidth, setLocalWidth] = useState(col.width || ""); + // 선택된 테이블 (소스 테이블 또는 조인 테이블) + const selectedTable = col.fromTable || dataSourceTable; + const selectedJoinId = col.fromJoinId || ""; + + // 사용 가능한 테이블 목록 (소스 테이블 + 조인 테이블들) + const availableTables = [ + { id: "", table: dataSourceTable, label: `${dataSourceTable} (소스)` }, + ...additionalJoins + .filter(j => j.joinTable) + .map(j => ({ id: j.id, table: j.joinTable, label: `${j.joinTable} (조인)` })), + ]; + useEffect(() => { setLocalLabel(col.label || ""); setLocalWidth(col.width || ""); @@ -1979,19 +3121,80 @@ function TableColumnConfigSection({ return (
- - 컬럼 {colIndex + 1} - +
+ {/* 순서 변경 버튼 */} +
+ + +
+ + 컬럼 {colIndex + 1} + + {col.fromJoinId && ( + + 조인 + + )} +
+ {/* 테이블 선택 (조인 테이블이 있을 때만 표시) */} + {additionalJoins.length > 0 && ( +
+ + +
+ )} +
onUpdate({ field: value })} placeholder="필드 선택" diff --git a/frontend/lib/registry/components/repeat-screen-modal/types.ts b/frontend/lib/registry/components/repeat-screen-modal/types.ts index b2c8d861..81a36366 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/types.ts +++ b/frontend/lib/registry/components/repeat-screen-modal/types.ts @@ -23,6 +23,9 @@ export interface RepeatScreenModalProps { // === 🆕 v3: 자유 레이아웃 === contentRows?: CardContentRowConfig[]; // 카드 내부 행들 (각 행마다 타입 선택) + // === 🆕 v3.1: Footer 버튼 설정 === + footerConfig?: FooterConfig; // Footer 영역 설정 + // === (레거시 호환) === cardMode?: "simple" | "withTable"; // @deprecated - contentRows 사용 권장 cardLayout?: CardRowConfig[]; // @deprecated - contentRows 사용 권장 @@ -33,6 +36,34 @@ export interface RepeatScreenModalProps { onChange?: (newData: any[]) => void; } +/** + * 🆕 v3.1: Footer 설정 + */ +export interface FooterConfig { + enabled: boolean; // Footer 사용 여부 + buttons?: FooterButtonConfig[]; // Footer 버튼들 + position?: "sticky" | "static"; // sticky: 하단 고정, static: 컨텐츠 아래 + alignment?: "left" | "center" | "right"; // 버튼 정렬 +} + +/** + * 🆕 v3.1: Footer 버튼 설정 + */ +export interface FooterButtonConfig { + id: string; // 버튼 고유 ID + label: string; // 버튼 라벨 + action: "save" | "cancel" | "close" | "reset" | "custom"; // 액션 타입 + variant?: "default" | "secondary" | "outline" | "destructive" | "ghost"; // 버튼 스타일 + icon?: string; // 아이콘 (lucide 아이콘명) + disabled?: boolean; // 비활성화 여부 + + // custom 액션일 때 + customAction?: { + type: string; // 커스텀 액션 타입 + config?: Record; // 커스텀 설정 + }; +} + /** * 데이터 소스 설정 */ @@ -79,26 +110,177 @@ export interface CardContentRowConfig { tableTitle?: string; // 테이블 제목 showTableHeader?: boolean; // 테이블 헤더 표시 여부 tableMaxHeight?: string; // 테이블 최대 높이 + + // 🆕 v3.1: 테이블 외부 데이터 소스 + tableDataSource?: TableDataSourceConfig; // 외부 테이블에서 데이터 조회 + + // 🆕 v3.1: 테이블 CRUD 설정 + tableCrud?: TableCrudConfig; // 행 추가/수정/삭제 설정 +} + +/** + * 🆕 v3.1: 테이블 데이터 소스 설정 + * 외부 테이블에서 연관 데이터를 조회 + */ +export interface TableDataSourceConfig { + enabled: boolean; // 외부 데이터 소스 사용 여부 + sourceTable: string; // 조회할 테이블 (예: "shipment_plan") + + // 조인 설정 + joinConditions: JoinCondition[]; // 조인 조건 (복합 키 지원) + + // 🆕 v3.3: 추가 조인 테이블 설정 (소스 테이블에 없는 컬럼 조회) + additionalJoins?: AdditionalJoinConfig[]; + + // 🆕 v3.4: 필터 조건 설정 (그룹 내 특정 조건으로 필터링) + filterConfig?: TableFilterConfig; + + // 정렬 설정 + orderBy?: { + column: string; // 정렬 컬럼 + direction: "asc" | "desc"; // 정렬 방향 + }; + + // 제한 + limit?: number; // 최대 행 수 +} + +/** + * 🆕 v3.4: 테이블 필터 설정 + * 그룹 내 데이터를 특정 조건으로 필터링 + */ +export interface TableFilterConfig { + enabled: boolean; // 필터 사용 여부 + filterField: string; // 필터링할 필드 (예: "order_no") + filterType: "equals" | "notEquals"; // equals: 같은 값만, notEquals: 다른 값만 + referenceField: string; // 비교 기준 필드 (formData 또는 카드 대표 데이터에서) + referenceSource: "formData" | "representativeData"; // 비교 값 소스 +} + +/** + * 🆕 v3.3: 추가 조인 테이블 설정 + * 소스 테이블에서 다른 테이블을 조인하여 컬럼 가져오기 + */ +export interface AdditionalJoinConfig { + id: string; // 조인 설정 고유 ID + joinTable: string; // 조인할 테이블 (예: "sales_order_mng") + joinType: "left" | "inner"; // 조인 타입 + sourceKey: string; // 소스 테이블의 조인 키 (예: "sales_order_id") + targetKey: string; // 조인 테이블의 키 (예: "id") + alias?: string; // 테이블 별칭 (예: "so") + selectColumns?: string[]; // 가져올 컬럼 목록 (비어있으면 전체) +} + +/** + * 🆕 v3.1: 조인 조건 + */ +export interface JoinCondition { + sourceKey: string; // 외부 테이블의 조인 키 (예: "sales_order_id") + referenceKey: string; // 현재 카드 데이터의 참조 키 (예: "id") + referenceType?: "card" | "row"; // card: 카드 대표 데이터, row: 각 행 데이터 (기본: card) +} + +/** + * 🆕 v3.1: 테이블 CRUD 설정 + */ +export interface TableCrudConfig { + allowCreate: boolean; // 행 추가 허용 + allowUpdate: boolean; // 행 수정 허용 + allowDelete: boolean; // 행 삭제 허용 + + // 신규 행 기본값 + newRowDefaults?: Record; // 기본값 (예: { status: "READY", sales_order_id: "{id}" }) + + // 삭제 확인 + deleteConfirm?: { + enabled: boolean; // 삭제 확인 팝업 표시 여부 + message?: string; // 확인 메시지 + }; + + // 저장 대상 테이블 (외부 데이터 소스 사용 시) + targetTable?: string; // 저장할 테이블 (기본: tableDataSource.sourceTable) } /** * 🆕 v3: 집계 표시 설정 */ export interface AggregationDisplayConfig { - aggregationResultField: string; // 그룹핑 설정의 resultField 참조 + // 값 소스 타입 + sourceType: "aggregation" | "formula" | "external" | "externalFormula"; + + // === sourceType: "aggregation" (기존 그룹핑 집계 결과 참조) === + aggregationResultField?: string; // 그룹핑 설정의 resultField 참조 + + // === sourceType: "formula" (컬럼 간 연산) === + formula?: string; // 연산식 (예: "{order_qty} - {ship_qty}") + + // === sourceType: "external" (외부 테이블 조회) === + externalSource?: ExternalValueSource; + + // === sourceType: "externalFormula" (외부 테이블 + 연산) === + externalSources?: ExternalValueSource[]; // 여러 외부 소스 + externalFormula?: string; // 외부 값들을 조합한 연산식 (예: "{inv_qty} + {prod_qty}") + + // 표시 설정 label: string; // 표시 라벨 icon?: string; // 아이콘 (lucide 아이콘명) backgroundColor?: string; // 배경색 textColor?: string; // 텍스트 색상 fontSize?: "xs" | "sm" | "base" | "lg" | "xl" | "2xl"; // 폰트 크기 + format?: "number" | "currency" | "percent"; // 숫자 포맷 + decimalPlaces?: number; // 소수점 자릿수 +} + +/** + * 🆕 v3.1: 외부 값 소스 설정 + */ +export interface ExternalValueSource { + alias: string; // 연산식에서 사용할 별칭 (예: "inv_qty") + sourceTable: string; // 조회할 테이블 + sourceColumn: string; // 조회할 컬럼 + aggregationType?: "sum" | "count" | "avg" | "min" | "max" | "first"; // 집계 타입 (기본: first) + + // 조인 설정 (다단계 조인 지원) + joins: ChainedJoinConfig[]; +} + +/** + * 🆕 v3.1: 다단계 조인 설정 + */ +export interface ChainedJoinConfig { + step: number; // 조인 순서 (1, 2, 3...) + sourceTable: string; // 조인할 테이블 + joinConditions: { + sourceKey: string; // 조인 테이블의 키 + referenceKey: string; // 참조 키 (이전 단계 결과 또는 카드 데이터) + referenceFrom?: "card" | "previousStep"; // 참조 소스 (기본: card, step > 1이면 previousStep) + }[]; + selectColumns?: string[]; // 이 단계에서 선택할 컬럼 } /** * 집계 설정 + * 🆕 v3.2: 다중 테이블 및 가상 집계(formula) 지원 */ export interface AggregationConfig { - sourceField: string; // 원본 필드 (예: "balance_qty") - type: "sum" | "count" | "avg" | "min" | "max"; // 집계 타입 + // === 집계 소스 타입 === + sourceType: "column" | "formula"; // column: 테이블 컬럼 집계, formula: 연산식 (가상 집계) + + // === sourceType: "column" (테이블 컬럼 집계) === + sourceTable?: string; // 집계할 테이블 (기본: dataSource.sourceTable, 외부 테이블도 가능) + sourceField?: string; // 원본 필드 (예: "balance_qty") + type?: "sum" | "count" | "avg" | "min" | "max"; // 집계 타입 + + // === sourceType: "formula" (가상 집계 - 연산식) === + // 연산식 문법: + // - {resultField}: 다른 집계 결과 참조 (예: {total_balance}) + // - {테이블.컬럼}: 테이블의 컬럼 직접 참조 (예: {sales_order_mng.order_qty}) + // - SUM({컬럼}): 기본 테이블 행들의 합계 + // - SUM_EXT({컬럼}): 외부 테이블 행들의 합계 (externalTableData) + // - 산술 연산: +, -, *, /, () + formula?: string; // 연산식 (예: "{total_balance} - SUM_EXT({plan_qty})") + + // === 공통 === resultField: string; // 결과 필드명 (예: "total_balance_qty") label: string; // 표시 라벨 (예: "총수주잔량") } @@ -120,7 +302,7 @@ export interface TableLayoutConfig { */ export interface TableColumnConfig { id: string; // 컬럼 고유 ID - field: string; // 필드명 + field: string; // 필드명 (소스 테이블 컬럼 또는 조인 테이블 컬럼) label: string; // 헤더 라벨 type: "text" | "number" | "date" | "select" | "badge"; // 타입 width?: string; // 너비 (예: "100px", "20%") @@ -128,6 +310,10 @@ export interface TableColumnConfig { editable: boolean; // 편집 가능 여부 required?: boolean; // 필수 입력 여부 + // 🆕 v3.3: 컬럼 소스 테이블 지정 (조인 테이블 컬럼 사용 시) + fromTable?: string; // 컬럼이 속한 테이블 (비어있으면 소스 테이블) + fromJoinId?: string; // additionalJoins의 id 참조 (조인 테이블 컬럼일 때) + // Select 타입 옵션 selectOptions?: { value: string; label: string }[]; diff --git a/frontend/lib/registry/components/section-paper/SectionPaperRenderer.tsx b/frontend/lib/registry/components/section-paper/SectionPaperRenderer.tsx index c4e653e1..fb9dc6fe 100644 --- a/frontend/lib/registry/components/section-paper/SectionPaperRenderer.tsx +++ b/frontend/lib/registry/components/section-paper/SectionPaperRenderer.tsx @@ -25,3 +25,4 @@ if (process.env.NODE_ENV === "development") { SectionPaperRenderer.enableHotReload(); } + diff --git a/frontend/lib/registry/components/simple-repeater-table/useCalculation.ts b/frontend/lib/registry/components/simple-repeater-table/useCalculation.ts index 1f6f69f2..8a0fdba5 100644 --- a/frontend/lib/registry/components/simple-repeater-table/useCalculation.ts +++ b/frontend/lib/registry/components/simple-repeater-table/useCalculation.ts @@ -65,3 +65,4 @@ export function useCalculation(calculationRules: CalculationRule[] = []) { }; } + From 4787a8b177c8e8d173e9ac021aac401e7931e77b Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 2 Dec 2025 14:02:47 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(repeat-screen-modal):=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EC=98=81=EC=97=AD=20=EB=8F=85=EB=A6=BD=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TableCrudConfig에 allowSave, saveButtonLabel 속성 추가 - CRUD 설정 패널에 저장 스위치 추가 - saveTableAreaData 함수: editable 컬럼 + 조인키만 필터링하여 저장 - 날짜 필드 ISO 8601 -> YYYY-MM-DD 형식 변환 - 백엔드: company_code 자동 주입 로직 추가 - tableManagementService에 hasColumn 메서드 추가 --- .../controllers/tableManagementController.ts | 11 + .../src/services/tableManagementService.ts | 18 ++ .../RepeatScreenModalComponent.tsx | 229 +++++++++++++++--- .../RepeatScreenModalConfigPanel.tsx | 20 +- .../components/repeat-screen-modal/types.ts | 4 + 5 files changed, 250 insertions(+), 32 deletions(-) diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index f552124f..4a80b007 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -870,6 +870,17 @@ export async function addTableData( const tableManagementService = new TableManagementService(); + // 🆕 멀티테넌시: company_code 자동 추가 (테이블에 company_code 컬럼이 있는 경우) + const companyCode = req.user?.companyCode; + if (companyCode && !data.company_code) { + // 테이블에 company_code 컬럼이 있는지 확인 + const hasCompanyCodeColumn = await tableManagementService.hasColumn(tableName, "company_code"); + if (hasCompanyCodeColumn) { + data.company_code = companyCode; + logger.info(`🔒 멀티테넌시: company_code 자동 추가 - ${companyCode}`); + } + } + // 데이터 추가 await tableManagementService.addTableData(tableName, data); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index c1748123..dabe41da 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -4076,4 +4076,22 @@ export class TableManagementService { throw error; } } + + /** + * 테이블에 특정 컬럼이 존재하는지 확인 + */ + async hasColumn(tableName: string, columnName: string): Promise { + try { + const result = await query( + `SELECT column_name + FROM information_schema.columns + WHERE table_name = $1 AND column_name = $2`, + [tableName, columnName] + ); + return result.length > 0; + } catch (error) { + logger.error(`컬럼 존재 여부 확인 실패: ${tableName}.${columnName}`, error); + return false; + } + } } diff --git a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx index 25807607..2484f1d7 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx +++ b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx @@ -295,6 +295,8 @@ export function RepeatScreenModalComponent({ rowCount: tableData.length, sampleRow: tableData[0] ? Object.keys(tableData[0]) : [], firstRowData: tableData[0], + // 디버그: plan_date 필드 확인 + plan_date_value: tableData[0]?.plan_date, }); // 🆕 v3.3: 추가 조인 테이블 데이터 로드 및 병합 @@ -344,6 +346,14 @@ export function RepeatScreenModalComponent({ _isNew: 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); @@ -599,6 +609,159 @@ export function RepeatScreenModalComponent({ }); }; + // 🆕 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 + }))); + + for (const row of dirtyRows) { + const { _rowId, _originalData, _isDirty, _isNew, ...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 엔드포인트 사용 (id를 body에 포함) + const updateData = { ...dataToSave, id: _originalData.id }; + console.log(`[RepeatScreenModal] UPDATE 요청: /table-management/tables/${targetTable}/edit`, updateData); + savePromises.push( + apiClient.put(`/table-management/tables/${targetTable}/edit`, updateData).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 플래그만 초기화 + setExternalTableData((prev) => { + const updated = { ...prev }; + if (updated[key]) { + updated[key] = updated[key].map((row) => ({ + ...row, + _isDirty: false, + _isNew: false, + _originalData: { ...row, _rowId: undefined, _originalData: undefined, _isDirty: undefined, _isNew: undefined }, + })); + } + return updated; + }); + + return { success: true, message: `${dirtyRows.length}건 저장 완료`, savedCount: dirtyRows.length, 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); + // 성공 알림 (필요 시 toast 추가) + } else { + console.error("[RepeatScreenModal] 테이블 영역 저장 실패:", result.message); + // 실패 알림 (필요 시 toast 추가) + } + } finally { + setIsSaving(false); + } + }; + // 🆕 v3.1: 외부 테이블 행 삭제 요청 const handleDeleteExternalRowRequest = (cardId: string, rowId: string, contentRowId: string, contentRow: CardContentRowConfig) => { if (contentRow.tableCrud?.deleteConfirm?.enabled !== false) { @@ -1467,33 +1630,41 @@ export function RepeatScreenModalComponent({ {contentRow.type === "table" && contentRow.tableDataSource?.enabled ? ( // 🆕 v3.1: 외부 테이블 데이터 소스 사용
- {contentRow.tableTitle && ( + {/* 테이블 헤더 영역: 제목 + 버튼들 */} + {(contentRow.tableTitle || contentRow.tableCrud?.allowSave || contentRow.tableCrud?.allowCreate) && (
- {contentRow.tableTitle} - {contentRow.tableCrud?.allowCreate && ( - - )} -
- )} - {!contentRow.tableTitle && contentRow.tableCrud?.allowCreate && ( -
- + {contentRow.tableTitle || ""} +
+ {/* 저장 버튼 - allowSave가 true일 때만 표시 */} + {contentRow.tableCrud?.allowSave && ( + + )} + {/* 추가 버튼 */} + {contentRow.tableCrud?.allowCreate && ( + + )} +
)} @@ -2243,10 +2414,12 @@ function renderTableCell(col: TableColumnConfig, row: CardRowData, onChange: (va /> ); 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" /> @@ -2298,7 +2471,7 @@ function renderColumn(col: CardColumnConfig, card: CardData, onChange: (value: a {col.type === "date" && ( onChange(e.target.value)} className="h-10 text-sm" /> diff --git a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx index da7088a9..54949627 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx +++ b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx @@ -2568,13 +2568,13 @@ function ContentRowConfigSection({ {/* CRUD 설정 */}
-
+
onUpdateRow({ - tableCrud: { ...row.tableCrud, allowCreate: checked, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: row.tableCrud?.allowDelete || false }, + tableCrud: { ...row.tableCrud, allowCreate: checked, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: row.tableCrud?.allowDelete || false, allowSave: row.tableCrud?.allowSave || false }, }) } className="scale-[0.5]" @@ -2586,7 +2586,7 @@ function ContentRowConfigSection({ checked={row.tableCrud?.allowUpdate || false} onCheckedChange={(checked) => onUpdateRow({ - tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: checked, allowDelete: row.tableCrud?.allowDelete || false }, + tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: checked, allowDelete: row.tableCrud?.allowDelete || false, allowSave: row.tableCrud?.allowSave || false }, }) } className="scale-[0.5]" @@ -2598,13 +2598,25 @@ function ContentRowConfigSection({ checked={row.tableCrud?.allowDelete || false} onCheckedChange={(checked) => onUpdateRow({ - tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: checked }, + tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: checked, allowSave: row.tableCrud?.allowSave || false }, }) } className="scale-[0.5]" />
+
+ + onUpdateRow({ + tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: row.tableCrud?.allowDelete || false, allowSave: checked }, + }) + } + className="scale-[0.5]" + /> + +
{row.tableCrud?.allowDelete && (
diff --git a/frontend/lib/registry/components/repeat-screen-modal/types.ts b/frontend/lib/registry/components/repeat-screen-modal/types.ts index 81a36366..7226503e 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/types.ts +++ b/frontend/lib/registry/components/repeat-screen-modal/types.ts @@ -188,6 +188,10 @@ export interface TableCrudConfig { allowUpdate: boolean; // 행 수정 허용 allowDelete: boolean; // 행 삭제 허용 + // 🆕 v3.5: 테이블 영역 저장 버튼 + allowSave?: boolean; // 테이블 영역에 저장 버튼 표시 + saveButtonLabel?: string; // 저장 버튼 라벨 (기본: "저장") + // 신규 행 기본값 newRowDefaults?: Record; // 기본값 (예: { status: "READY", sales_order_id: "{id}" })