feat(repeat-screen-modal): 연동 저장, 자동 채번, SUM_EXT 참조 제한 기능 추가

- SyncSaveConfig: 모달 저장 시 다른 테이블에 집계 값 동기화 기능
- RowNumberingConfig: 행 추가 시 채번 규칙 적용하여 자동 번호 생성
- externalTableRefs: SUM_EXT 함수가 참조할 외부 테이블 제한 기능
- triggerRepeatScreenModalSave: 외부에서 저장 트리거 가능한 이벤트 리스너
- TableColumnConfig.hidden: 테이블 컬럼 숨김 기능 (데이터는 유지, 화면만 숨김)
- beforeFormSave: FK 자동 채우기 및 _isNew 행 포함 로직 개선
This commit is contained in:
SeongHyun Kim
2025-12-10 17:13:39 +09:00
parent ae6f022f88
commit 512e1e30d1
6 changed files with 950 additions and 65 deletions

View File

@@ -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(