Merge branch 'ksh'
This commit is contained in:
commit
3a3ecde358
|
|
@ -60,6 +60,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
// 🆕 원본 데이터 상태 (수정 모드에서 UPDATE 판단용)
|
||||
const [originalData, setOriginalData] = useState<Record<string, any> | null>(null);
|
||||
|
||||
// 🆕 선택된 데이터 상태 (RepeatScreenModal 등에서 사용)
|
||||
const [selectedData, setSelectedData] = useState<Record<string, any>[]>([]);
|
||||
|
||||
// 연속 등록 모드 상태 (state로 변경 - 체크박스 UI 업데이트를 위해)
|
||||
const [continuousMode, setContinuousMode] = useState(false);
|
||||
|
||||
|
|
@ -129,12 +132,27 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
// 전역 모달 이벤트 리스너
|
||||
useEffect(() => {
|
||||
const handleOpenModal = (event: CustomEvent) => {
|
||||
const { screenId, title, description, size, urlParams, editData } = event.detail;
|
||||
const { screenId, title, description, size, urlParams, editData, selectedData: eventSelectedData, selectedIds } = event.detail;
|
||||
|
||||
console.log("📦 [ScreenModal] 모달 열기 이벤트 수신:", {
|
||||
screenId,
|
||||
title,
|
||||
selectedData: eventSelectedData,
|
||||
selectedIds,
|
||||
});
|
||||
|
||||
// 🆕 모달 열린 시간 기록
|
||||
modalOpenedAtRef.current = Date.now();
|
||||
console.log("🕐 [ScreenModal] 모달 열림 시간 기록:", modalOpenedAtRef.current);
|
||||
|
||||
// 🆕 선택된 데이터 저장 (RepeatScreenModal 등에서 사용)
|
||||
if (eventSelectedData && Array.isArray(eventSelectedData)) {
|
||||
setSelectedData(eventSelectedData);
|
||||
console.log("📦 [ScreenModal] 선택된 데이터 저장:", eventSelectedData.length, "건");
|
||||
} else {
|
||||
setSelectedData([]);
|
||||
}
|
||||
|
||||
// 🆕 URL 파라미터가 있으면 현재 URL에 추가
|
||||
if (urlParams && typeof window !== "undefined") {
|
||||
const currentUrl = new URL(window.location.href);
|
||||
|
|
@ -184,6 +202,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
setScreenData(null);
|
||||
setFormData({});
|
||||
setOriginalData(null); // 🆕 원본 데이터 초기화
|
||||
setSelectedData([]); // 🆕 선택된 데이터 초기화
|
||||
setContinuousMode(false);
|
||||
localStorage.setItem("screenModal_continuousMode", "false"); // localStorage에 저장
|
||||
console.log("🔄 연속 모드 초기화: false");
|
||||
|
|
@ -649,6 +668,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
id: modalState.screenId!,
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
}}
|
||||
groupedData={selectedData}
|
||||
userId={userId}
|
||||
userName={userName}
|
||||
companyCode={user?.companyCode}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ 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, Plus, Trash2, RotateCcw } from "lucide-react";
|
||||
import { Loader2, Save, X, Layers, Table as TableIcon, Plus, Trash2, RotateCcw, Pencil } from "lucide-react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
@ -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 () => {
|
||||
|
|
@ -344,6 +461,8 @@ export function RepeatScreenModalComponent({
|
|||
_originalData: { ...row },
|
||||
_isDirty: false,
|
||||
_isNew: false,
|
||||
_isEditing: false, // 🆕 v3.8: 로드된 데이터는 읽기 전용
|
||||
_isDeleted: false,
|
||||
...row,
|
||||
}));
|
||||
|
||||
|
|
@ -481,7 +600,8 @@ export function RepeatScreenModalComponent({
|
|||
// 각 카드의 집계 재계산
|
||||
const updatedCards = groupedCardsData.map((card) => {
|
||||
const key = `${card._cardId}-${tableRowWithExternalSource.id}`;
|
||||
const externalRows = extData[key] || [];
|
||||
// 🆕 v3.7: 삭제된 행은 집계에서 제외
|
||||
const externalRows = (extData[key] || []).filter((row) => !row._isDeleted);
|
||||
|
||||
// 집계 재계산
|
||||
const newAggregations: Record<string, number> = {};
|
||||
|
|
@ -671,8 +791,35 @@ export function RepeatScreenModalComponent({
|
|||
inputType: c.inputType
|
||||
})));
|
||||
|
||||
for (const row of dirtyRows) {
|
||||
const { _rowId, _originalData, _isDirty, _isNew, ...allData } = row;
|
||||
// 삭제할 행 (기존 데이터 중 _isDeleted가 true인 것)
|
||||
const deletedRows = dirtyRows.filter((row) => row._isDeleted && row._originalData?.id);
|
||||
// 저장할 행 (삭제되지 않은 것)
|
||||
const rowsToSave = dirtyRows.filter((row) => !row._isDeleted);
|
||||
|
||||
console.log("[RepeatScreenModal] 삭제 대상:", deletedRows.length, "건");
|
||||
console.log("[RepeatScreenModal] 저장 대상:", rowsToSave.length, "건");
|
||||
|
||||
// 🆕 v3.7: 삭제 처리 (배열 형태로 body에 전달)
|
||||
for (const row of deletedRows) {
|
||||
const deleteId = row._originalData.id;
|
||||
console.log(`[RepeatScreenModal] DELETE 요청: /table-management/tables/${targetTable}/delete`, [{ id: deleteId }]);
|
||||
savePromises.push(
|
||||
apiClient.request({
|
||||
method: "DELETE",
|
||||
url: `/table-management/tables/${targetTable}/delete`,
|
||||
data: [{ id: deleteId }],
|
||||
}).then((res) => {
|
||||
console.log("[RepeatScreenModal] DELETE 응답:", res.data);
|
||||
return { type: "delete", id: deleteId };
|
||||
}).catch((err) => {
|
||||
console.error("[RepeatScreenModal] DELETE 실패:", err.response?.data || err.message);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
for (const row of rowsToSave) {
|
||||
const { _rowId, _originalData, _isDirty, _isNew, _isDeleted, ...allData } = row;
|
||||
|
||||
// 허용된 필드만 필터링
|
||||
const dataToSave: Record<string, any> = {};
|
||||
|
|
@ -705,11 +852,14 @@ export function RepeatScreenModalComponent({
|
|||
})
|
||||
);
|
||||
} else if (_originalData?.id) {
|
||||
// UPDATE - /edit 엔드포인트 사용 (id를 body에 포함)
|
||||
const updateData = { ...dataToSave, id: _originalData.id };
|
||||
console.log(`[RepeatScreenModal] UPDATE 요청: /table-management/tables/${targetTable}/edit`, updateData);
|
||||
// UPDATE - /edit 엔드포인트 사용 (originalData와 updatedData 형식)
|
||||
const updatePayload = {
|
||||
originalData: _originalData,
|
||||
updatedData: { ...dataToSave, id: _originalData.id },
|
||||
};
|
||||
console.log(`[RepeatScreenModal] UPDATE 요청: /table-management/tables/${targetTable}/edit`, updatePayload);
|
||||
savePromises.push(
|
||||
apiClient.put(`/table-management/tables/${targetTable}/edit`, updateData).then((res) => {
|
||||
apiClient.put(`/table-management/tables/${targetTable}/edit`, updatePayload).then((res) => {
|
||||
console.log("[RepeatScreenModal] UPDATE 응답:", res.data);
|
||||
savedIds.push(_originalData.id);
|
||||
return res;
|
||||
|
|
@ -724,21 +874,31 @@ export function RepeatScreenModalComponent({
|
|||
try {
|
||||
await Promise.all(savePromises);
|
||||
|
||||
// 저장 후 해당 키의 dirty 플래그만 초기화
|
||||
// 저장 후: 삭제된 행은 제거, 나머지는 dirty/editing 플래그 초기화
|
||||
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 },
|
||||
}));
|
||||
// 삭제된 행은 완전히 제거
|
||||
updated[key] = updated[key]
|
||||
.filter((row) => !row._isDeleted)
|
||||
.map((row) => ({
|
||||
...row,
|
||||
_isDirty: false,
|
||||
_isNew: false,
|
||||
_isEditing: false, // 🆕 v3.8: 수정 모드 해제
|
||||
_originalData: { ...row, _rowId: undefined, _originalData: undefined, _isDirty: undefined, _isNew: undefined, _isDeleted: undefined, _isEditing: undefined },
|
||||
}));
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
|
||||
return { success: true, message: `${dirtyRows.length}건 저장 완료`, savedCount: dirtyRows.length, savedIds };
|
||||
const savedCount = rowsToSave.length;
|
||||
const deletedCount = deletedRows.length;
|
||||
const message = deletedCount > 0
|
||||
? `${savedCount}건 저장, ${deletedCount}건 삭제 완료`
|
||||
: `${savedCount}건 저장 완료`;
|
||||
|
||||
return { success: true, message, savedCount, deletedCount, savedIds };
|
||||
} catch (error: any) {
|
||||
console.error("[RepeatScreenModal] 테이블 영역 저장 실패:", error);
|
||||
return { success: false, message: error.message || "저장 중 오류가 발생했습니다." };
|
||||
|
|
@ -752,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) {
|
||||
|
|
@ -774,16 +1009,20 @@ export function RepeatScreenModalComponent({
|
|||
}
|
||||
};
|
||||
|
||||
// 🆕 v3.1: 외부 테이블 행 삭제 실행
|
||||
// 🆕 v3.1: 외부 테이블 행 삭제 실행 (소프트 삭제 - _isDeleted 플래그 설정)
|
||||
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),
|
||||
[key]: (prev[key] || []).map((row) =>
|
||||
row._rowId === rowId
|
||||
? { ...row, _isDeleted: true, _isDirty: true }
|
||||
: row
|
||||
),
|
||||
};
|
||||
|
||||
// 🆕 v3.5: 행 삭제 시 집계 재계산
|
||||
// 🆕 v3.5: 행 삭제 시 집계 재계산 (삭제된 행 제외)
|
||||
setTimeout(() => {
|
||||
recalculateAggregationsWithExternalData(newData);
|
||||
}, 0);
|
||||
|
|
@ -794,6 +1033,61 @@ export function RepeatScreenModalComponent({
|
|||
setPendingDeleteInfo(null);
|
||||
};
|
||||
|
||||
// 🆕 v3.7: 삭제 취소 (소프트 삭제 복원)
|
||||
const handleRestoreExternalRow = (cardId: string, rowId: string, contentRowId: string) => {
|
||||
const key = `${cardId}-${contentRowId}`;
|
||||
setExternalTableData((prev) => {
|
||||
const newData = {
|
||||
...prev,
|
||||
[key]: (prev[key] || []).map((row) =>
|
||||
row._rowId === rowId
|
||||
? { ...row, _isDeleted: false, _isDirty: true }
|
||||
: row
|
||||
),
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
recalculateAggregationsWithExternalData(newData);
|
||||
}, 0);
|
||||
|
||||
return newData;
|
||||
});
|
||||
};
|
||||
|
||||
// 🆕 v3.8: 수정 모드 전환
|
||||
const handleEditExternalRow = (cardId: string, rowId: string, contentRowId: string) => {
|
||||
const key = `${cardId}-${contentRowId}`;
|
||||
setExternalTableData((prev) => ({
|
||||
...prev,
|
||||
[key]: (prev[key] || []).map((row) =>
|
||||
row._rowId === rowId
|
||||
? { ...row, _isEditing: true }
|
||||
: row
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
// 🆕 v3.8: 수정 취소
|
||||
const handleCancelEditExternalRow = (cardId: string, rowId: string, contentRowId: string) => {
|
||||
const key = `${cardId}-${contentRowId}`;
|
||||
setExternalTableData((prev) => ({
|
||||
...prev,
|
||||
[key]: (prev[key] || []).map((row) =>
|
||||
row._rowId === rowId
|
||||
? {
|
||||
...row._originalData,
|
||||
_rowId: row._rowId,
|
||||
_originalData: row._originalData,
|
||||
_isEditing: false,
|
||||
_isDirty: false,
|
||||
_isNew: false,
|
||||
_isDeleted: false,
|
||||
}
|
||||
: row
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
// 🆕 v3.1: 외부 테이블 행 데이터 변경
|
||||
const handleExternalRowDataChange = (cardId: string, contentRowId: string, rowId: string, field: string, value: any) => {
|
||||
const key = `${cardId}-${contentRowId}`;
|
||||
|
|
@ -900,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,
|
||||
|
|
@ -1680,8 +1977,8 @@ export function RepeatScreenModalComponent({
|
|||
{col.label}
|
||||
</TableHead>
|
||||
))}
|
||||
{contentRow.tableCrud?.allowDelete && (
|
||||
<TableHead className="w-[60px] text-center text-xs">삭제</TableHead>
|
||||
{(contentRow.tableCrud?.allowUpdate || contentRow.tableCrud?.allowDelete) && (
|
||||
<TableHead className="w-[80px] text-center text-xs">작업</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
|
@ -1700,28 +1997,81 @@ export function RepeatScreenModalComponent({
|
|||
(externalTableData[`${card._cardId}-${contentRow.id}`] || []).map((row) => (
|
||||
<TableRow
|
||||
key={row._rowId}
|
||||
className={cn(row._isDirty && "bg-primary/5", row._isNew && "bg-green-50 dark:bg-green-950")}
|
||||
className={cn(
|
||||
row._isDirty && "bg-primary/5",
|
||||
row._isNew && "bg-green-50 dark:bg-green-950",
|
||||
row._isDeleted && "bg-destructive/10 opacity-60"
|
||||
)}
|
||||
>
|
||||
{(contentRow.tableColumns || []).map((col) => (
|
||||
<TableCell
|
||||
key={`${row._rowId}-${col.id}`}
|
||||
className={cn("text-sm", col.align && `text-${col.align}`)}
|
||||
className={cn(
|
||||
"text-sm",
|
||||
col.align && `text-${col.align}`,
|
||||
row._isDeleted && "line-through text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{renderTableCell(col, row, (value) =>
|
||||
handleExternalRowDataChange(card._cardId, contentRow.id, row._rowId, col.field, value)
|
||||
{renderTableCell(
|
||||
col,
|
||||
row,
|
||||
(value) => handleExternalRowDataChange(card._cardId, contentRow.id, row._rowId, col.field, value),
|
||||
row._isNew || row._isEditing // 신규 행이거나 수정 모드일 때만 편집 가능
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
{contentRow.tableCrud?.allowDelete && (
|
||||
{(contentRow.tableCrud?.allowUpdate || contentRow.tableCrud?.allowDelete) && (
|
||||
<TableCell className="text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteExternalRowRequest(card._cardId, row._rowId, contentRow.id, contentRow)}
|
||||
className="h-7 w-7 p-0 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{/* 수정 버튼: 저장된 행(isNew가 아닌)이고 편집 모드가 아닐 때만 표시 */}
|
||||
{contentRow.tableCrud?.allowUpdate && !row._isNew && !row._isEditing && !row._isDeleted && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditExternalRow(card._cardId, row._rowId, contentRow.id)}
|
||||
className="h-7 w-7 p-0 text-blue-600 hover:text-blue-700 hover:bg-blue-50"
|
||||
title="수정"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{/* 수정 취소 버튼: 편집 모드일 때만 표시 */}
|
||||
{row._isEditing && !row._isNew && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleCancelEditExternalRow(card._cardId, row._rowId, contentRow.id)}
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
title="수정 취소"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{/* 삭제/복원 버튼 */}
|
||||
{contentRow.tableCrud?.allowDelete && (
|
||||
row._isDeleted ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRestoreExternalRow(card._cardId, row._rowId, contentRow.id)}
|
||||
className="h-7 w-7 p-0 text-primary hover:text-primary hover:bg-primary/10"
|
||||
title="삭제 취소"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteExternalRowRequest(card._cardId, row._rowId, contentRow.id, contentRow)}
|
||||
className="h-7 w-7 p-0 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
|
|
@ -1787,8 +2137,11 @@ export function RepeatScreenModalComponent({
|
|||
key={`${row._rowId}-${col.id}`}
|
||||
className={cn("text-sm", col.align && `text-${col.align}`)}
|
||||
>
|
||||
{renderTableCell(col, row, (value) =>
|
||||
handleRowDataChange(card._cardId, row._rowId, col.field, value)
|
||||
{renderTableCell(
|
||||
col,
|
||||
row,
|
||||
(value) => handleRowDataChange(card._cardId, row._rowId, col.field, value),
|
||||
row._isNew || row._isEditing
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
|
|
@ -2102,8 +2455,11 @@ function renderContentRow(
|
|||
key={`${row._rowId}-${col.id}`}
|
||||
className={cn("text-sm", col.align && `text-${col.align}`)}
|
||||
>
|
||||
{renderTableCell(col, row, (value) =>
|
||||
onRowDataChange(card._cardId, row._rowId, col.field, value)
|
||||
{renderTableCell(
|
||||
col,
|
||||
row,
|
||||
(value) => onRowDataChange(card._cardId, row._rowId, col.field, value),
|
||||
row._isNew || row._isEditing
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
|
|
@ -2377,7 +2733,8 @@ function renderHeaderColumn(
|
|||
}
|
||||
|
||||
// 테이블 셀 렌더링
|
||||
function renderTableCell(col: TableColumnConfig, row: CardRowData, onChange: (value: any) => void) {
|
||||
// 🆕 v3.8: isRowEditable 파라미터 추가 - 행이 편집 가능한 상태인지 (신규 행이거나 수정 모드)
|
||||
function renderTableCell(col: TableColumnConfig, row: CardRowData, onChange: (value: any) => void, isRowEditable?: boolean) {
|
||||
const value = row[col.field];
|
||||
|
||||
// Badge 타입
|
||||
|
|
@ -2386,11 +2743,20 @@ function renderTableCell(col: TableColumnConfig, row: CardRowData, onChange: (va
|
|||
return <Badge variant={badgeColor as any}>{value || "-"}</Badge>;
|
||||
}
|
||||
|
||||
// 🆕 v3.8: 행 수준 편집 가능 여부 체크
|
||||
// isRowEditable이 false이면 컬럼 설정과 관계없이 읽기 전용
|
||||
const canEdit = col.editable && (isRowEditable !== false);
|
||||
|
||||
// 읽기 전용
|
||||
if (!col.editable) {
|
||||
if (!canEdit) {
|
||||
if (col.type === "number") {
|
||||
return <span>{typeof value === "number" ? value.toLocaleString() : value || "-"}</span>;
|
||||
}
|
||||
if (col.type === "date") {
|
||||
// ISO 8601 형식을 표시용으로 변환
|
||||
const displayDate = value ? (typeof value === 'string' && value.includes('T') ? value.split('T')[0] : value) : "-";
|
||||
return <span>{displayDate}</span>;
|
||||
}
|
||||
return <span>{value || "-"}</span>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue