From ae7b21147be77e2a15aa7e25353b0ee44c8d435d Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 2 Dec 2025 17:44:24 +0900 Subject: [PATCH] =?UTF-8?q?feat(repeat-screen-modal):=20=EC=A7=91=EA=B3=84?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=20=EB=B0=8F=20=EC=B1=84=EB=B2=88=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=20=EA=B0=92=20=EC=A0=80=EC=9E=A5=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RepeatScreenModal 집계 결과를 연관 테이블에 저장하는 기능 추가 - ButtonPrimary 저장 시 채번 규칙 값(shipment_plan_no) 함께 저장 - _repeatScreenModal_* 데이터 감지 시 메인 테이블 중복 저장 방지 - 기존 행 수정 모드(_isEditing) 지원 - AggregationSaveConfig 타입 및 ConfigPanel UI 추가 --- .../RepeatScreenModalComponent.tsx | 201 +++++++++++++++++- .../RepeatScreenModalConfigPanel.tsx | 157 +++++++++++++- .../components/repeat-screen-modal/types.ts | 23 ++ frontend/lib/utils/buttonActions.ts | 122 ++++++++++- 4 files changed, 494 insertions(+), 9 deletions(-) diff --git a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx index 48c58392..85e43ce9 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx +++ b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx @@ -99,6 +99,123 @@ export function RepeatScreenModalComponent({ contentRowId: string; } | null>(null); + // 🆕 v3.9: beforeFormSave 이벤트 핸들러 - ButtonPrimary 저장 시 externalTableData를 formData에 병합 + useEffect(() => { + const handleBeforeFormSave = (event: Event) => { + if (!(event instanceof CustomEvent) || !event.detail?.formData) return; + + console.log("[RepeatScreenModal] beforeFormSave 이벤트 수신"); + + // 외부 테이블 데이터에서 dirty 행만 추출하여 저장 데이터 준비 + const saveDataByTable: Record = {}; + + for (const [key, rows] of Object.entries(externalTableData)) { + // contentRow 찾기 + const contentRow = contentRows.find((r) => key.includes(r.id)); + if (!contentRow?.tableDataSource?.enabled) continue; + + const targetTable = contentRow.tableCrud?.targetTable || contentRow.tableDataSource.sourceTable; + + // dirty 행만 필터링 (삭제된 행 제외) + const dirtyRows = rows.filter((row) => row._isDirty && !row._isDeleted); + + if (dirtyRows.length === 0) continue; + + // 저장할 필드만 추출 + const editableFields = (contentRow.tableColumns || []) + .filter((col) => col.editable) + .map((col) => col.field); + + const joinKeys = (contentRow.tableDataSource.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]; + } + } + + // _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 () => { @@ -795,16 +912,91 @@ export function RepeatScreenModalComponent({ const result = await saveTableAreaData(cardId, contentRowId, contentRow); if (result.success) { console.log("[RepeatScreenModal] 테이블 영역 저장 성공:", result); - // 성공 알림 (필요 시 toast 추가) + + // 🆕 v3.9: 집계 저장 설정이 있는 경우 연관 테이블 동기화 + const card = groupedCardsData.find((c) => c._cardId === cardId); + if (card && grouping?.aggregations) { + await saveAggregationsToRelatedTables(card, contentRowId); + } } else { console.error("[RepeatScreenModal] 테이블 영역 저장 실패:", result.message); - // 실패 알림 (필요 시 toast 추가) } } 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) { @@ -1002,8 +1194,11 @@ export function RepeatScreenModalComponent({ }); } + // 안정적인 _cardId 생성 (Date.now() 대신 groupKey 사용) + // groupKey가 없으면 대표 데이터의 id 사용 + const stableId = groupKey || representativeData.id || cardIndex; result.push({ - _cardId: `grouped-card-${cardIndex}-${Date.now()}`, + _cardId: `grouped-card-${cardIndex}-${stableId}`, _groupKey: groupKey, _groupField: groupByField || "", _aggregations: aggregations, diff --git a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx index 54949627..44ee5ce6 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx +++ b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx @@ -766,7 +766,7 @@ function AggregationConfigItem({ const currentSourceType = agg.sourceType || "column"; return ( -
+
+ + {/* 🆕 v3.9: 저장 설정 */} + +
+ ); +} + +// 🆕 v3.9: 집계 저장 설정 섹션 +function AggregationSaveConfigSection({ + agg, + sourceTable, + allTables, + onUpdate, +}: { + agg: AggregationConfig; + sourceTable: string; + allTables: { tableName: string; displayName?: string }[]; + onUpdate: (updates: Partial) => void; +}) { + const saveConfig = agg.saveConfig || { enabled: false, autoSave: false, targetTable: "", targetColumn: "", joinKey: { sourceField: "", targetField: "" } }; + + const updateSaveConfig = (updates: Partial) => { + onUpdate({ + saveConfig: { + ...saveConfig, + ...updates, + }, + }); + }; + + return ( +
+
+ + updateSaveConfig({ enabled: checked })} + className="scale-[0.6]" + /> +
+ + {saveConfig.enabled && ( +
+ {/* 자동 저장 옵션 */} +
+
+ +

+ 레이아웃에 없어도 저장 +

+
+ updateSaveConfig({ autoSave: checked })} + className="scale-[0.6]" + /> +
+ + {/* 대상 테이블 */} +
+ + +
+ + {/* 대상 컬럼 */} +
+ + updateSaveConfig({ targetColumn: value })} + placeholder="컬럼 선택" + /> +
+ + {/* 조인 키 설정 */} +
+ +
+
+ 카드 키 (현재 카드 데이터) + + updateSaveConfig({ + joinKey: { ...saveConfig.joinKey, sourceField: value }, + }) + } + placeholder="카드 키 선택" + /> +
+
+ +
+
+ 대상 키 (업데이트할 레코드 식별) + + updateSaveConfig({ + joinKey: { ...saveConfig.joinKey, targetField: value }, + }) + } + placeholder="대상 키 선택" + /> +
+
+
+ + {/* 설정 요약 */} + {saveConfig.targetTable && saveConfig.targetColumn && ( +
+
+ 저장 경로: + {saveConfig.autoSave && ( + + 자동 + + )} +
+
+ {saveConfig.targetTable}.{saveConfig.targetColumn} +
+ {saveConfig.joinKey?.sourceField && saveConfig.joinKey?.targetField && ( +
+ 조인: {saveConfig.joinKey.sourceField} → {saveConfig.joinKey.targetField} +
+ )} +
+ )} +
+ )}
); } diff --git a/frontend/lib/registry/components/repeat-screen-modal/types.ts b/frontend/lib/registry/components/repeat-screen-modal/types.ts index 7226503e..2191818f 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/types.ts +++ b/frontend/lib/registry/components/repeat-screen-modal/types.ts @@ -265,6 +265,7 @@ export interface ChainedJoinConfig { /** * 집계 설정 * 🆕 v3.2: 다중 테이블 및 가상 집계(formula) 지원 + * 🆕 v3.9: 연관 테이블 저장 기능 추가 */ export interface AggregationConfig { // === 집계 소스 타입 === @@ -287,6 +288,28 @@ export interface AggregationConfig { // === 공통 === resultField: string; // 결과 필드명 (예: "total_balance_qty") label: string; // 표시 라벨 (예: "총수주잔량") + + // === 🆕 v3.9: 저장 설정 === + saveConfig?: AggregationSaveConfig; // 연관 테이블 저장 설정 +} + +/** + * 🆕 v3.9: 집계 결과 저장 설정 + * 집계된 값을 다른 테이블에 동기화 + */ +export interface AggregationSaveConfig { + enabled: boolean; // 저장 활성화 여부 + autoSave: boolean; // 자동 저장 (레이아웃에 없어도 저장) + + // 저장 대상 + targetTable: string; // 저장할 테이블 (예: "sales_order_mng") + targetColumn: string; // 저장할 컬럼 (예: "plan_qty_total") + + // 조인 키 (어떤 레코드를 업데이트할지) + joinKey: { + sourceField: string; // 현재 카드의 조인 키 (예: "id" 또는 "sales_order_id") + targetField: string; // 대상 테이블의 키 (예: "id") + }; } /** diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 5be55b65..235946ce 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -5,6 +5,7 @@ import { toast } from "sonner"; import { screenApi } from "@/lib/api/screen"; import { DynamicFormApi } from "@/lib/api/dynamicForm"; import { ImprovedButtonActionExecutor } from "@/lib/utils/improvedButtonActionExecutor"; +import { apiClient } from "@/lib/api/client"; import type { ExtendedControlContext } from "@/types/control-management"; /** @@ -663,11 +664,122 @@ export class ButtonActionExecutor { } } - saveResult = await DynamicFormApi.saveFormData({ - screenId, - tableName, - data: dataWithUserInfo, - }); + // 🆕 v3.9: RepeatScreenModal의 외부 테이블 데이터 저장 처리 + const repeatScreenModalKeys = Object.keys(context.formData).filter((key) => + key.startsWith("_repeatScreenModal_") && key !== "_repeatScreenModal_aggregations" + ); + + // RepeatScreenModal 데이터가 있으면 해당 테이블에 대한 메인 저장은 건너뜀 + const repeatScreenModalTables = repeatScreenModalKeys.map((key) => key.replace("_repeatScreenModal_", "")); + const shouldSkipMainSave = repeatScreenModalTables.includes(tableName); + + if (shouldSkipMainSave) { + console.log(`⏭️ [handleSave] ${tableName} 메인 저장 건너뜀 (RepeatScreenModal에서 처리)`); + saveResult = { success: true, message: "RepeatScreenModal에서 처리" }; + } else { + saveResult = await DynamicFormApi.saveFormData({ + screenId, + tableName, + data: dataWithUserInfo, + }); + } + + if (repeatScreenModalKeys.length > 0) { + console.log("📦 [handleSave] RepeatScreenModal 데이터 저장 시작:", repeatScreenModalKeys); + + // 🆕 formData에서 채번 규칙으로 생성된 값들 추출 (예: shipment_plan_no) + const numberingFields: Record = {}; + for (const [fieldKey, value] of Object.entries(context.formData)) { + // _numberingRuleId로 끝나는 키가 있으면 해당 필드는 채번 규칙 값 + if (context.formData[`${fieldKey}_numberingRuleId`]) { + numberingFields[fieldKey] = value; + } + } + console.log("📦 [handleSave] 채번 규칙 필드:", numberingFields); + + for (const key of repeatScreenModalKeys) { + const targetTable = key.replace("_repeatScreenModal_", ""); + const rows = context.formData[key] as any[]; + + if (!Array.isArray(rows) || rows.length === 0) continue; + + console.log(`📦 [handleSave] ${targetTable} 테이블 저장:`, rows); + + for (const row of rows) { + const { _isNew, _targetTable, id, ...dataToSave } = row; + + // 사용자 정보 추가 + 채번 규칙 값 병합 + const dataWithMeta = { + ...dataToSave, + ...numberingFields, // 채번 규칙 값 (shipment_plan_no 등) + created_by: context.userId, + updated_by: context.userId, + company_code: context.companyCode, + }; + + try { + if (_isNew) { + // INSERT + console.log(`📝 [handleSave] ${targetTable} INSERT:`, dataWithMeta); + const insertResult = await apiClient.post( + `/table-management/tables/${targetTable}/add`, + dataWithMeta + ); + console.log(`✅ [handleSave] ${targetTable} INSERT 완료:`, insertResult.data); + } else if (id) { + // UPDATE + const originalData = { id }; + const updatedData = { ...dataWithMeta, id }; + console.log(`📝 [handleSave] ${targetTable} UPDATE:`, { originalData, updatedData }); + const updateResult = await apiClient.put( + `/table-management/tables/${targetTable}/edit`, + { originalData, updatedData } + ); + console.log(`✅ [handleSave] ${targetTable} UPDATE 완료:`, updateResult.data); + } + } catch (error: any) { + console.error(`❌ [handleSave] ${targetTable} 저장 실패:`, error.response?.data || error.message); + // 개별 실패는 전체 저장을 중단하지 않음 + } + } + } + } + + // 🆕 v3.9: RepeatScreenModal 집계 저장 처리 + const aggregationConfigs = context.formData._repeatScreenModal_aggregations as Array<{ + resultField: string; + aggregatedValue: number; + targetTable: string; + targetColumn: string; + joinKey: { sourceField: string; targetField: string }; + sourceValue: any; + }>; + + if (aggregationConfigs && aggregationConfigs.length > 0) { + console.log("📊 [handleSave] 집계 저장 시작:", aggregationConfigs); + + for (const config of aggregationConfigs) { + const { targetTable, targetColumn, joinKey, aggregatedValue, sourceValue } = config; + + try { + const originalData = { [joinKey.targetField]: sourceValue }; + const updatedData = { + [targetColumn]: aggregatedValue, + [joinKey.targetField]: sourceValue, + }; + + console.log(`📊 [handleSave] ${targetTable}.${targetColumn} = ${aggregatedValue} (조인: ${joinKey.sourceField} = ${sourceValue})`); + + const updateResult = await apiClient.put( + `/table-management/tables/${targetTable}/edit`, + { originalData, updatedData } + ); + console.log(`✅ [handleSave] ${targetTable} 집계 저장 완료:`, updateResult.data); + } catch (error: any) { + console.error(`❌ [handleSave] ${targetTable} 집계 저장 실패:`, error.response?.data || error.message); + } + } + } } if (!saveResult.success) {