feat(repeat-screen-modal): 집계 저장 및 채번 규칙 값 저장 기능 추가

- RepeatScreenModal 집계 결과를 연관 테이블에 저장하는 기능 추가
- ButtonPrimary 저장 시 채번 규칙 값(shipment_plan_no) 함께 저장
- _repeatScreenModal_* 데이터 감지 시 메인 테이블 중복 저장 방지
- 기존 행 수정 모드(_isEditing) 지원
- AggregationSaveConfig 타입 및 ConfigPanel UI 추가
This commit is contained in:
SeongHyun Kim
2025-12-02 17:44:24 +09:00
parent 10d81cb9bc
commit ae7b21147b
4 changed files with 494 additions and 9 deletions

View File

@@ -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<string, any[]> = {};
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<string, any> = {};
// 허용된 필드만 포함
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<any>[] = [];
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,

View File

@@ -766,7 +766,7 @@ function AggregationConfigItem({
const currentSourceType = agg.sourceType || "column";
return (
<div className="border rounded p-2 space-y-1.5 bg-background overflow-hidden min-w-0">
<div className="border rounded p-2 space-y-1.5 bg-background min-w-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 min-w-0">
<Badge
@@ -922,6 +922,161 @@ function AggregationConfigItem({
/>
</div>
</div>
{/* 🆕 v3.9: 저장 설정 */}
<AggregationSaveConfigSection
agg={agg}
sourceTable={sourceTable}
allTables={allTables}
onUpdate={onUpdate}
/>
</div>
);
}
// 🆕 v3.9: 집계 저장 설정 섹션
function AggregationSaveConfigSection({
agg,
sourceTable,
allTables,
onUpdate,
}: {
agg: AggregationConfig;
sourceTable: string;
allTables: { tableName: string; displayName?: string }[];
onUpdate: (updates: Partial<AggregationConfig>) => void;
}) {
const saveConfig = agg.saveConfig || { enabled: false, autoSave: false, targetTable: "", targetColumn: "", joinKey: { sourceField: "", targetField: "" } };
const updateSaveConfig = (updates: Partial<typeof saveConfig>) => {
onUpdate({
saveConfig: {
...saveConfig,
...updates,
},
});
};
return (
<div className="space-y-2 pt-2 border-t border-dashed">
<div className="flex items-center justify-between">
<Label className="text-[9px] font-semibold"> </Label>
<Switch
checked={saveConfig.enabled}
onCheckedChange={(checked) => updateSaveConfig({ enabled: checked })}
className="scale-[0.6]"
/>
</div>
{saveConfig.enabled && (
<div className="space-y-2 p-2 bg-blue-50/50 dark:bg-blue-900/20 rounded border border-blue-200 dark:border-blue-800">
{/* 자동 저장 옵션 */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-[9px]"> </Label>
<p className="text-[8px] text-muted-foreground">
</p>
</div>
<Switch
checked={saveConfig.autoSave}
onCheckedChange={(checked) => updateSaveConfig({ autoSave: checked })}
className="scale-[0.6]"
/>
</div>
{/* 대상 테이블 */}
<div className="space-y-1">
<Label className="text-[9px]"> </Label>
<Select
value={saveConfig.targetTable}
onValueChange={(value) => {
updateSaveConfig({ targetTable: value, targetColumn: "" });
}}
>
<SelectTrigger className="h-6 text-[10px]">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{allTables.map((t) => (
<SelectItem key={t.tableName} value={t.tableName} className="text-[10px]">
{t.displayName || t.tableName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 대상 컬럼 */}
<div className="space-y-1">
<Label className="text-[9px]"> </Label>
<SourceColumnSelector
sourceTable={saveConfig.targetTable}
value={saveConfig.targetColumn}
onChange={(value) => updateSaveConfig({ targetColumn: value })}
placeholder="컬럼 선택"
/>
</div>
{/* 조인 키 설정 */}
<div className="space-y-2">
<Label className="text-[9px]"> </Label>
<div className="space-y-1.5">
<div className="space-y-0.5">
<span className="text-[8px] text-muted-foreground"> ( )</span>
<SourceColumnSelector
sourceTable={sourceTable}
value={saveConfig.joinKey?.sourceField || ""}
onChange={(value) =>
updateSaveConfig({
joinKey: { ...saveConfig.joinKey, sourceField: value },
})
}
placeholder="카드 키 선택"
/>
</div>
<div className="flex justify-center">
<span className="text-[10px] text-muted-foreground"></span>
</div>
<div className="space-y-0.5">
<span className="text-[8px] text-muted-foreground"> ( )</span>
<SourceColumnSelector
sourceTable={saveConfig.targetTable}
value={saveConfig.joinKey?.targetField || ""}
onChange={(value) =>
updateSaveConfig({
joinKey: { ...saveConfig.joinKey, targetField: value },
})
}
placeholder="대상 키 선택"
/>
</div>
</div>
</div>
{/* 설정 요약 */}
{saveConfig.targetTable && saveConfig.targetColumn && (
<div className="p-1.5 bg-white/50 dark:bg-black/20 rounded text-[9px] space-y-1">
<div>
<span className="font-medium"> :</span>
{saveConfig.autoSave && (
<Badge variant="secondary" className="ml-1 text-[8px] px-1 py-0">
</Badge>
)}
</div>
<div className="font-mono text-blue-600 dark:text-blue-400 break-all">
{saveConfig.targetTable}.{saveConfig.targetColumn}
</div>
{saveConfig.joinKey?.sourceField && saveConfig.joinKey?.targetField && (
<div className="text-[8px] text-muted-foreground">
: {saveConfig.joinKey.sourceField} {saveConfig.joinKey.targetField}
</div>
)}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -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")
};
}
/**

View File

@@ -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<string, any> = {};
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) {