feat(repeat-screen-modal): 연동 저장, 자동 채번, SUM_EXT 참조 제한 기능 추가
- SyncSaveConfig: 모달 저장 시 다른 테이블에 집계 값 동기화 기능 - RowNumberingConfig: 행 추가 시 채번 규칙 적용하여 자동 번호 생성 - externalTableRefs: SUM_EXT 함수가 참조할 외부 테이블 제한 기능 - triggerRepeatScreenModalSave: 외부에서 저장 트리거 가능한 이벤트 리스너 - TableColumnConfig.hidden: 테이블 컬럼 숨김 기능 (데이터는 유지, 화면만 숨김) - beforeFormSave: FK 자동 채우기 및 _isNew 행 포함 로직 개선
This commit is contained in:
@@ -99,25 +99,99 @@ export function RepeatScreenModalComponent({
|
||||
contentRowId: string;
|
||||
} | null>(null);
|
||||
|
||||
// 🆕 v3.13: 외부에서 저장 트리거 가능하도록 이벤트 리스너 추가
|
||||
useEffect(() => {
|
||||
const handleTriggerSave = async (event: Event) => {
|
||||
if (!(event instanceof CustomEvent)) return;
|
||||
|
||||
console.log("[RepeatScreenModal] triggerRepeatScreenModalSave 이벤트 수신");
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
|
||||
// 기존 데이터 저장
|
||||
if (cardMode === "withTable") {
|
||||
await saveGroupedData();
|
||||
} else {
|
||||
await saveSimpleData();
|
||||
}
|
||||
|
||||
// 외부 테이블 데이터 저장
|
||||
await saveExternalTableData();
|
||||
|
||||
// 연동 저장 처리 (syncSaves)
|
||||
await processSyncSaves();
|
||||
|
||||
console.log("[RepeatScreenModal] 외부 트리거 저장 완료");
|
||||
|
||||
// 저장 완료 이벤트 발생
|
||||
window.dispatchEvent(new CustomEvent("repeatScreenModalSaveComplete", {
|
||||
detail: { success: true }
|
||||
}));
|
||||
|
||||
// 성공 콜백 실행
|
||||
if (event.detail?.onSuccess) {
|
||||
event.detail.onSuccess();
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("[RepeatScreenModal] 외부 트리거 저장 실패:", error);
|
||||
|
||||
// 저장 실패 이벤트 발생
|
||||
window.dispatchEvent(new CustomEvent("repeatScreenModalSaveComplete", {
|
||||
detail: { success: false, error: error.message }
|
||||
}));
|
||||
|
||||
// 실패 콜백 실행
|
||||
if (event.detail?.onError) {
|
||||
event.detail.onError(error);
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("triggerRepeatScreenModalSave", handleTriggerSave as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener("triggerRepeatScreenModalSave", handleTriggerSave as EventListener);
|
||||
};
|
||||
}, [cardMode, groupedCardsData, externalTableData, contentRows]);
|
||||
|
||||
// 🆕 v3.9: beforeFormSave 이벤트 핸들러 - ButtonPrimary 저장 시 externalTableData를 formData에 병합
|
||||
useEffect(() => {
|
||||
const handleBeforeFormSave = (event: Event) => {
|
||||
if (!(event instanceof CustomEvent) || !event.detail?.formData) return;
|
||||
|
||||
console.log("[RepeatScreenModal] beforeFormSave 이벤트 수신");
|
||||
console.log("[RepeatScreenModal] beforeFormSave - externalTableData:", externalTableData);
|
||||
console.log("[RepeatScreenModal] beforeFormSave - groupedCardsData:", groupedCardsData.length, "개 카드");
|
||||
|
||||
// 외부 테이블 데이터에서 dirty 행만 추출하여 저장 데이터 준비
|
||||
const saveDataByTable: Record<string, any[]> = {};
|
||||
|
||||
for (const [key, rows] of Object.entries(externalTableData)) {
|
||||
// key 형식: cardId-contentRowId
|
||||
const keyParts = key.split("-");
|
||||
const cardId = keyParts.slice(0, -1).join("-"); // contentRowId를 제외한 나머지가 cardId
|
||||
|
||||
// contentRow 찾기
|
||||
const contentRow = contentRows.find((r) => key.includes(r.id));
|
||||
if (!contentRow?.tableDataSource?.enabled) continue;
|
||||
|
||||
// 🆕 v3.13: 해당 카드의 대표 데이터 찾기 (joinConditions의 targetKey 값을 가져오기 위해)
|
||||
const card = groupedCardsData.find((c) => c._cardId === cardId);
|
||||
const representativeData = card?._representativeData || {};
|
||||
|
||||
const targetTable = contentRow.tableCrud?.targetTable || contentRow.tableDataSource.sourceTable;
|
||||
|
||||
// dirty 행만 필터링 (삭제된 행 제외)
|
||||
const dirtyRows = rows.filter((row) => row._isDirty && !row._isDeleted);
|
||||
// dirty 행 또는 새로운 행 필터링 (삭제된 행 제외)
|
||||
// 🆕 v3.13: _isNew 행도 포함 (새로 추가된 행은 _isDirty가 없을 수 있음)
|
||||
const dirtyRows = rows.filter((row) => (row._isDirty || row._isNew) && !row._isDeleted);
|
||||
|
||||
console.log(`[RepeatScreenModal] beforeFormSave - ${targetTable} 행 필터링:`, {
|
||||
totalRows: rows.length,
|
||||
dirtyRows: dirtyRows.length,
|
||||
rowDetails: rows.map(r => ({ _isDirty: r._isDirty, _isNew: r._isNew, _isDeleted: r._isDeleted }))
|
||||
});
|
||||
|
||||
if (dirtyRows.length === 0) continue;
|
||||
|
||||
@@ -126,8 +200,9 @@ export function RepeatScreenModalComponent({
|
||||
.filter((col) => col.editable)
|
||||
.map((col) => col.field);
|
||||
|
||||
const joinKeys = (contentRow.tableDataSource.joinConditions || [])
|
||||
.map((cond) => cond.sourceKey);
|
||||
// 🆕 v3.13: joinConditions에서 sourceKey (저장 대상 테이블의 FK 컬럼) 추출
|
||||
const joinConditions = contentRow.tableDataSource.joinConditions || [];
|
||||
const joinKeys = joinConditions.map((cond) => cond.sourceKey);
|
||||
|
||||
const allowedFields = [...new Set([...editableFields, ...joinKeys])];
|
||||
|
||||
@@ -145,6 +220,17 @@ export function RepeatScreenModalComponent({
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 v3.13: joinConditions를 사용하여 FK 값 자동 채우기
|
||||
// 예: sales_order_id (sourceKey) = card의 id (targetKey)
|
||||
for (const joinCond of joinConditions) {
|
||||
const { sourceKey, targetKey } = joinCond;
|
||||
// sourceKey가 저장 데이터에 없거나 null인 경우, 카드의 대표 데이터에서 targetKey 값을 가져옴
|
||||
if (!saveData[sourceKey] && representativeData[targetKey] !== undefined) {
|
||||
saveData[sourceKey] = representativeData[targetKey];
|
||||
console.log(`[RepeatScreenModal] beforeFormSave - FK 자동 채우기: ${sourceKey} = ${representativeData[targetKey]} (from card.${targetKey})`);
|
||||
}
|
||||
}
|
||||
|
||||
// _isNew 플래그 유지
|
||||
saveData._isNew = row._isNew;
|
||||
saveData._targetTable = targetTable;
|
||||
@@ -599,15 +685,17 @@ export function RepeatScreenModalComponent({
|
||||
|
||||
// 각 카드의 집계 재계산
|
||||
const updatedCards = groupedCardsData.map((card) => {
|
||||
// 🆕 v3.11: 모든 외부 테이블 행의 데이터를 합침
|
||||
// 🆕 v3.11: 테이블 행 ID별로 외부 데이터를 구분하여 저장
|
||||
const externalRowsByTableId: Record<string, any[]> = {};
|
||||
const allExternalRows: any[] = [];
|
||||
|
||||
for (const tableRow of tableRowsWithExternalSource) {
|
||||
const key = `${card._cardId}-${tableRow.id}`;
|
||||
// 🆕 v3.7: 삭제된 행은 집계에서 제외
|
||||
const rows = (extData[key] || []).filter((row) => !row._isDeleted);
|
||||
externalRowsByTableId[tableRow.id] = rows;
|
||||
allExternalRows.push(...rows);
|
||||
}
|
||||
const externalRows = allExternalRows;
|
||||
|
||||
// 집계 재계산
|
||||
const newAggregations: Record<string, number> = {};
|
||||
@@ -622,7 +710,7 @@ export function RepeatScreenModalComponent({
|
||||
if (isExternalTable) {
|
||||
// 외부 테이블 집계
|
||||
newAggregations[agg.resultField] = calculateColumnAggregation(
|
||||
externalRows,
|
||||
allExternalRows,
|
||||
agg.sourceField || "",
|
||||
agg.type || "sum"
|
||||
);
|
||||
@@ -632,12 +720,28 @@ export function RepeatScreenModalComponent({
|
||||
calculateColumnAggregation(card._rows, agg.sourceField || "", agg.type || "sum");
|
||||
}
|
||||
} else if (sourceType === "formula" && agg.formula) {
|
||||
// 🆕 v3.11: externalTableRefs 기반으로 필터링된 외부 데이터 사용
|
||||
let filteredExternalRows: any[];
|
||||
|
||||
if (agg.externalTableRefs && agg.externalTableRefs.length > 0) {
|
||||
// 특정 테이블만 참조
|
||||
filteredExternalRows = [];
|
||||
for (const tableId of agg.externalTableRefs) {
|
||||
if (externalRowsByTableId[tableId]) {
|
||||
filteredExternalRows.push(...externalRowsByTableId[tableId]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 모든 외부 테이블 데이터 사용 (기존 동작)
|
||||
filteredExternalRows = allExternalRows;
|
||||
}
|
||||
|
||||
// 가상 집계 (연산식) - 외부 테이블 데이터 포함하여 재계산
|
||||
newAggregations[agg.resultField] = evaluateFormulaWithContext(
|
||||
agg.formula,
|
||||
card._representativeData,
|
||||
card._rows,
|
||||
externalRows,
|
||||
filteredExternalRows,
|
||||
newAggregations // 이전 집계 결과 참조
|
||||
);
|
||||
}
|
||||
@@ -660,8 +764,8 @@ export function RepeatScreenModalComponent({
|
||||
});
|
||||
};
|
||||
|
||||
// 🆕 v3.1: 외부 테이블 행 추가
|
||||
const handleAddExternalRow = (cardId: string, contentRowId: string, contentRow: CardContentRowConfig) => {
|
||||
// 🆕 v3.1: 외부 테이블 행 추가 (v3.13: 자동 채번 기능 추가)
|
||||
const handleAddExternalRow = async (cardId: string, contentRowId: string, contentRow: CardContentRowConfig) => {
|
||||
const key = `${cardId}-${contentRowId}`;
|
||||
const card = groupedCardsData.find((c) => c._cardId === cardId) || cardsData.find((c) => c._cardId === cardId);
|
||||
const representativeData = (card as GroupedCardData)?._representativeData || card || {};
|
||||
@@ -713,6 +817,41 @@ export function RepeatScreenModalComponent({
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 v3.13: 자동 채번 처리
|
||||
const rowNumbering = contentRow.tableCrud?.rowNumbering;
|
||||
console.log("[RepeatScreenModal] 채번 설정 확인:", {
|
||||
tableCrud: contentRow.tableCrud,
|
||||
rowNumbering,
|
||||
enabled: rowNumbering?.enabled,
|
||||
targetColumn: rowNumbering?.targetColumn,
|
||||
numberingRuleId: rowNumbering?.numberingRuleId,
|
||||
});
|
||||
if (rowNumbering?.enabled && rowNumbering.targetColumn && rowNumbering.numberingRuleId) {
|
||||
try {
|
||||
console.log("[RepeatScreenModal] 자동 채번 시작:", {
|
||||
targetColumn: rowNumbering.targetColumn,
|
||||
numberingRuleId: rowNumbering.numberingRuleId,
|
||||
});
|
||||
|
||||
// 채번 API 호출 (allocate: 실제 시퀀스 증가)
|
||||
const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
|
||||
const response = await allocateNumberingCode(rowNumbering.numberingRuleId);
|
||||
|
||||
if (response.success && response.data) {
|
||||
newRowData[rowNumbering.targetColumn] = response.data.generatedCode;
|
||||
|
||||
console.log("[RepeatScreenModal] 자동 채번 완료:", {
|
||||
column: rowNumbering.targetColumn,
|
||||
generatedCode: response.data.generatedCode,
|
||||
});
|
||||
} else {
|
||||
console.warn("[RepeatScreenModal] 채번 실패:", response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[RepeatScreenModal] 채번 API 호출 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[RepeatScreenModal] 새 행 추가:", {
|
||||
cardId,
|
||||
contentRowId,
|
||||
@@ -1329,8 +1468,13 @@ export function RepeatScreenModalComponent({
|
||||
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";
|
||||
if (!externalRows || externalRows.length === 0) {
|
||||
console.log(`[SUM_EXT] ${fieldName}: 외부 데이터 없음`);
|
||||
return "0";
|
||||
}
|
||||
const values = externalRows.map((row) => Number(row[fieldName]) || 0);
|
||||
const sum = values.reduce((a, b) => a + b, 0);
|
||||
console.log(`[SUM_EXT] ${fieldName}: ${externalRows.length}개 행, 값들:`, values, `합계: ${sum}`);
|
||||
const baseFn = fn.replace("_EXT", "");
|
||||
switch (baseFn) {
|
||||
case "SUM":
|
||||
@@ -1531,6 +1675,9 @@ export function RepeatScreenModalComponent({
|
||||
// 🆕 v3.1: 외부 테이블 데이터 저장
|
||||
await saveExternalTableData();
|
||||
|
||||
// 🆕 v3.12: 연동 저장 처리 (syncSaves)
|
||||
await processSyncSaves();
|
||||
|
||||
alert("저장되었습니다.");
|
||||
} catch (error: any) {
|
||||
console.error("저장 실패:", error);
|
||||
@@ -1588,6 +1735,102 @@ export function RepeatScreenModalComponent({
|
||||
});
|
||||
};
|
||||
|
||||
// 🆕 v3.12: 연동 저장 처리 (syncSaves)
|
||||
const processSyncSaves = async () => {
|
||||
const syncPromises: Promise<void>[] = [];
|
||||
|
||||
// contentRows에서 syncSaves가 설정된 테이블 행 찾기
|
||||
for (const contentRow of contentRows) {
|
||||
if (contentRow.type !== "table") continue;
|
||||
if (!contentRow.tableCrud?.syncSaves?.length) continue;
|
||||
|
||||
const sourceTable = contentRow.tableDataSource?.sourceTable;
|
||||
if (!sourceTable) continue;
|
||||
|
||||
// 이 테이블 행의 모든 카드 데이터 수집
|
||||
for (const card of groupedCardsData) {
|
||||
const key = `${card._cardId}-${contentRow.id}`;
|
||||
const rows = (externalTableData[key] || []).filter((row) => !row._isDeleted);
|
||||
|
||||
// 각 syncSave 설정 처리
|
||||
for (const syncSave of contentRow.tableCrud.syncSaves) {
|
||||
if (!syncSave.enabled) continue;
|
||||
if (!syncSave.sourceColumn || !syncSave.targetTable || !syncSave.targetColumn) continue;
|
||||
|
||||
// 조인 키 값 수집 (중복 제거)
|
||||
const joinKeyValues = new Set<string | number>();
|
||||
for (const row of rows) {
|
||||
const keyValue = row[syncSave.joinKey.sourceField];
|
||||
if (keyValue !== undefined && keyValue !== null) {
|
||||
joinKeyValues.add(keyValue);
|
||||
}
|
||||
}
|
||||
|
||||
// 각 조인 키별로 집계 계산 및 업데이트
|
||||
for (const keyValue of joinKeyValues) {
|
||||
// 해당 조인 키에 해당하는 행들만 필터링
|
||||
const filteredRows = rows.filter(
|
||||
(row) => row[syncSave.joinKey.sourceField] === keyValue
|
||||
);
|
||||
|
||||
// 집계 계산
|
||||
let aggregatedValue: number = 0;
|
||||
const values = filteredRows.map((row) => Number(row[syncSave.sourceColumn]) || 0);
|
||||
|
||||
switch (syncSave.aggregationType) {
|
||||
case "sum":
|
||||
aggregatedValue = values.reduce((a, b) => a + b, 0);
|
||||
break;
|
||||
case "count":
|
||||
aggregatedValue = values.length;
|
||||
break;
|
||||
case "avg":
|
||||
aggregatedValue = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0;
|
||||
break;
|
||||
case "min":
|
||||
aggregatedValue = values.length > 0 ? Math.min(...values) : 0;
|
||||
break;
|
||||
case "max":
|
||||
aggregatedValue = values.length > 0 ? Math.max(...values) : 0;
|
||||
break;
|
||||
case "latest":
|
||||
aggregatedValue = values.length > 0 ? values[values.length - 1] : 0;
|
||||
break;
|
||||
}
|
||||
|
||||
console.log(`[SyncSave] ${sourceTable}.${syncSave.sourceColumn} → ${syncSave.targetTable}.${syncSave.targetColumn}`, {
|
||||
joinKey: keyValue,
|
||||
aggregationType: syncSave.aggregationType,
|
||||
values,
|
||||
aggregatedValue,
|
||||
});
|
||||
|
||||
// 대상 테이블 업데이트
|
||||
syncPromises.push(
|
||||
apiClient
|
||||
.put(`/table-management/tables/${syncSave.targetTable}/data/${keyValue}`, {
|
||||
[syncSave.targetColumn]: aggregatedValue,
|
||||
})
|
||||
.then(() => {
|
||||
console.log(`[SyncSave] 업데이트 완료: ${syncSave.targetTable}.${syncSave.targetColumn} = ${aggregatedValue} (id=${keyValue})`);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`[SyncSave] 업데이트 실패:`, err);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (syncPromises.length > 0) {
|
||||
console.log(`[SyncSave] ${syncPromises.length}개 연동 저장 처리 중...`);
|
||||
await Promise.all(syncPromises);
|
||||
console.log(`[SyncSave] 연동 저장 완료`);
|
||||
}
|
||||
};
|
||||
|
||||
// 🆕 v3.1: Footer 버튼 클릭 핸들러
|
||||
const handleFooterButtonClick = async (btn: FooterButtonConfig) => {
|
||||
switch (btn.action) {
|
||||
@@ -1934,27 +2177,10 @@ export function RepeatScreenModalComponent({
|
||||
// 🆕 v3.1: 외부 테이블 데이터 소스 사용
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
{/* 테이블 헤더 영역: 제목 + 버튼들 */}
|
||||
{(contentRow.tableTitle || contentRow.tableCrud?.allowSave || contentRow.tableCrud?.allowCreate) && (
|
||||
{(contentRow.tableTitle || contentRow.tableCrud?.allowCreate) && (
|
||||
<div className="px-4 py-2 bg-muted/30 border-b text-sm font-medium flex items-center justify-between">
|
||||
<span>{contentRow.tableTitle || ""}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 저장 버튼 - allowSave가 true일 때만 표시 */}
|
||||
{contentRow.tableCrud?.allowSave && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => handleTableAreaSave(card._cardId, contentRow.id, contentRow)}
|
||||
disabled={isSaving || !(externalTableData[`${card._cardId}-${contentRow.id}`] || []).some((r: any) => r._isDirty)}
|
||||
className="h-7 text-xs gap-1"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-3 w-3" />
|
||||
)}
|
||||
{contentRow.tableCrud?.saveButtonLabel || "저장"}
|
||||
</Button>
|
||||
)}
|
||||
{/* 추가 버튼 */}
|
||||
{contentRow.tableCrud?.allowCreate && (
|
||||
<Button
|
||||
@@ -1974,7 +2200,8 @@ export function RepeatScreenModalComponent({
|
||||
{contentRow.showTableHeader !== false && (
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
{(contentRow.tableColumns || []).map((col) => (
|
||||
{/* 🆕 v3.13: hidden 컬럼 필터링 */}
|
||||
{(contentRow.tableColumns || []).filter(col => !col.hidden).map((col) => (
|
||||
<TableHead
|
||||
key={col.id}
|
||||
style={{ width: col.width }}
|
||||
@@ -1993,7 +2220,7 @@ export function RepeatScreenModalComponent({
|
||||
{(externalTableData[`${card._cardId}-${contentRow.id}`] || []).length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={(contentRow.tableColumns?.length || 0) + (contentRow.tableCrud?.allowDelete ? 1 : 0)}
|
||||
colSpan={(contentRow.tableColumns?.filter(col => !col.hidden)?.length || 0) + (contentRow.tableCrud?.allowDelete ? 1 : 0)}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
데이터가 없습니다.
|
||||
@@ -2009,7 +2236,8 @@ export function RepeatScreenModalComponent({
|
||||
row._isDeleted && "bg-destructive/10 opacity-60"
|
||||
)}
|
||||
>
|
||||
{(contentRow.tableColumns || []).map((col) => (
|
||||
{/* 🆕 v3.13: hidden 컬럼 필터링 */}
|
||||
{(contentRow.tableColumns || []).filter(col => !col.hidden).map((col) => (
|
||||
<TableCell
|
||||
key={`${row._rowId}-${col.id}`}
|
||||
className={cn(
|
||||
|
||||
Reference in New Issue
Block a user