feat(repeat-screen-modal): 연동 저장, 자동 채번, SUM_EXT 참조 제한 기능 추가
- SyncSaveConfig: 모달 저장 시 다른 테이블에 집계 값 동기화 기능 - RowNumberingConfig: 행 추가 시 채번 규칙 적용하여 자동 번호 생성 - externalTableRefs: SUM_EXT 함수가 참조할 외부 테이블 제한 기능 - triggerRepeatScreenModalSave: 외부에서 저장 트리거 가능한 이벤트 리스너 - TableColumnConfig.hidden: 테이블 컬럼 숨김 기능 (데이터는 유지, 화면만 숨김) - beforeFormSave: FK 자동 채우기 및 _isNew 행 포함 로직 개선
This commit is contained in:
parent
ae6f022f88
commit
512e1e30d1
|
|
@ -2141,3 +2141,4 @@ export async function multiTableSave(
|
||||||
client.release();
|
client.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -99,25 +99,99 @@ export function RepeatScreenModalComponent({
|
||||||
contentRowId: string;
|
contentRowId: string;
|
||||||
} | null>(null);
|
} | 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에 병합
|
// 🆕 v3.9: beforeFormSave 이벤트 핸들러 - ButtonPrimary 저장 시 externalTableData를 formData에 병합
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleBeforeFormSave = (event: Event) => {
|
const handleBeforeFormSave = (event: Event) => {
|
||||||
if (!(event instanceof CustomEvent) || !event.detail?.formData) return;
|
if (!(event instanceof CustomEvent) || !event.detail?.formData) return;
|
||||||
|
|
||||||
console.log("[RepeatScreenModal] beforeFormSave 이벤트 수신");
|
console.log("[RepeatScreenModal] beforeFormSave 이벤트 수신");
|
||||||
|
console.log("[RepeatScreenModal] beforeFormSave - externalTableData:", externalTableData);
|
||||||
|
console.log("[RepeatScreenModal] beforeFormSave - groupedCardsData:", groupedCardsData.length, "개 카드");
|
||||||
|
|
||||||
// 외부 테이블 데이터에서 dirty 행만 추출하여 저장 데이터 준비
|
// 외부 테이블 데이터에서 dirty 행만 추출하여 저장 데이터 준비
|
||||||
const saveDataByTable: Record<string, any[]> = {};
|
const saveDataByTable: Record<string, any[]> = {};
|
||||||
|
|
||||||
for (const [key, rows] of Object.entries(externalTableData)) {
|
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 찾기
|
// contentRow 찾기
|
||||||
const contentRow = contentRows.find((r) => key.includes(r.id));
|
const contentRow = contentRows.find((r) => key.includes(r.id));
|
||||||
if (!contentRow?.tableDataSource?.enabled) continue;
|
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;
|
const targetTable = contentRow.tableCrud?.targetTable || contentRow.tableDataSource.sourceTable;
|
||||||
|
|
||||||
// dirty 행만 필터링 (삭제된 행 제외)
|
// dirty 행 또는 새로운 행 필터링 (삭제된 행 제외)
|
||||||
const dirtyRows = rows.filter((row) => row._isDirty && !row._isDeleted);
|
// 🆕 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;
|
if (dirtyRows.length === 0) continue;
|
||||||
|
|
||||||
|
|
@ -126,8 +200,9 @@ export function RepeatScreenModalComponent({
|
||||||
.filter((col) => col.editable)
|
.filter((col) => col.editable)
|
||||||
.map((col) => col.field);
|
.map((col) => col.field);
|
||||||
|
|
||||||
const joinKeys = (contentRow.tableDataSource.joinConditions || [])
|
// 🆕 v3.13: joinConditions에서 sourceKey (저장 대상 테이블의 FK 컬럼) 추출
|
||||||
.map((cond) => cond.sourceKey);
|
const joinConditions = contentRow.tableDataSource.joinConditions || [];
|
||||||
|
const joinKeys = joinConditions.map((cond) => cond.sourceKey);
|
||||||
|
|
||||||
const allowedFields = [...new Set([...editableFields, ...joinKeys])];
|
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 플래그 유지
|
// _isNew 플래그 유지
|
||||||
saveData._isNew = row._isNew;
|
saveData._isNew = row._isNew;
|
||||||
saveData._targetTable = targetTable;
|
saveData._targetTable = targetTable;
|
||||||
|
|
@ -599,15 +685,17 @@ export function RepeatScreenModalComponent({
|
||||||
|
|
||||||
// 각 카드의 집계 재계산
|
// 각 카드의 집계 재계산
|
||||||
const updatedCards = groupedCardsData.map((card) => {
|
const updatedCards = groupedCardsData.map((card) => {
|
||||||
// 🆕 v3.11: 모든 외부 테이블 행의 데이터를 합침
|
// 🆕 v3.11: 테이블 행 ID별로 외부 데이터를 구분하여 저장
|
||||||
|
const externalRowsByTableId: Record<string, any[]> = {};
|
||||||
const allExternalRows: any[] = [];
|
const allExternalRows: any[] = [];
|
||||||
|
|
||||||
for (const tableRow of tableRowsWithExternalSource) {
|
for (const tableRow of tableRowsWithExternalSource) {
|
||||||
const key = `${card._cardId}-${tableRow.id}`;
|
const key = `${card._cardId}-${tableRow.id}`;
|
||||||
// 🆕 v3.7: 삭제된 행은 집계에서 제외
|
// 🆕 v3.7: 삭제된 행은 집계에서 제외
|
||||||
const rows = (extData[key] || []).filter((row) => !row._isDeleted);
|
const rows = (extData[key] || []).filter((row) => !row._isDeleted);
|
||||||
|
externalRowsByTableId[tableRow.id] = rows;
|
||||||
allExternalRows.push(...rows);
|
allExternalRows.push(...rows);
|
||||||
}
|
}
|
||||||
const externalRows = allExternalRows;
|
|
||||||
|
|
||||||
// 집계 재계산
|
// 집계 재계산
|
||||||
const newAggregations: Record<string, number> = {};
|
const newAggregations: Record<string, number> = {};
|
||||||
|
|
@ -622,7 +710,7 @@ export function RepeatScreenModalComponent({
|
||||||
if (isExternalTable) {
|
if (isExternalTable) {
|
||||||
// 외부 테이블 집계
|
// 외부 테이블 집계
|
||||||
newAggregations[agg.resultField] = calculateColumnAggregation(
|
newAggregations[agg.resultField] = calculateColumnAggregation(
|
||||||
externalRows,
|
allExternalRows,
|
||||||
agg.sourceField || "",
|
agg.sourceField || "",
|
||||||
agg.type || "sum"
|
agg.type || "sum"
|
||||||
);
|
);
|
||||||
|
|
@ -632,12 +720,28 @@ export function RepeatScreenModalComponent({
|
||||||
calculateColumnAggregation(card._rows, agg.sourceField || "", agg.type || "sum");
|
calculateColumnAggregation(card._rows, agg.sourceField || "", agg.type || "sum");
|
||||||
}
|
}
|
||||||
} else if (sourceType === "formula" && agg.formula) {
|
} 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(
|
newAggregations[agg.resultField] = evaluateFormulaWithContext(
|
||||||
agg.formula,
|
agg.formula,
|
||||||
card._representativeData,
|
card._representativeData,
|
||||||
card._rows,
|
card._rows,
|
||||||
externalRows,
|
filteredExternalRows,
|
||||||
newAggregations // 이전 집계 결과 참조
|
newAggregations // 이전 집계 결과 참조
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -660,8 +764,8 @@ export function RepeatScreenModalComponent({
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 🆕 v3.1: 외부 테이블 행 추가
|
// 🆕 v3.1: 외부 테이블 행 추가 (v3.13: 자동 채번 기능 추가)
|
||||||
const handleAddExternalRow = (cardId: string, contentRowId: string, contentRow: CardContentRowConfig) => {
|
const handleAddExternalRow = async (cardId: string, contentRowId: string, contentRow: CardContentRowConfig) => {
|
||||||
const key = `${cardId}-${contentRowId}`;
|
const key = `${cardId}-${contentRowId}`;
|
||||||
const card = groupedCardsData.find((c) => c._cardId === cardId) || cardsData.find((c) => c._cardId === cardId);
|
const card = groupedCardsData.find((c) => c._cardId === cardId) || cardsData.find((c) => c._cardId === cardId);
|
||||||
const representativeData = (card as GroupedCardData)?._representativeData || card || {};
|
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] 새 행 추가:", {
|
console.log("[RepeatScreenModal] 새 행 추가:", {
|
||||||
cardId,
|
cardId,
|
||||||
contentRowId,
|
contentRowId,
|
||||||
|
|
@ -1329,8 +1468,13 @@ export function RepeatScreenModalComponent({
|
||||||
for (const fn of extAggFunctions) {
|
for (const fn of extAggFunctions) {
|
||||||
const regex = new RegExp(`${fn}\\(\\{(\\w+)\\}\\)`, "g");
|
const regex = new RegExp(`${fn}\\(\\{(\\w+)\\}\\)`, "g");
|
||||||
expression = expression.replace(regex, (match, fieldName) => {
|
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 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", "");
|
const baseFn = fn.replace("_EXT", "");
|
||||||
switch (baseFn) {
|
switch (baseFn) {
|
||||||
case "SUM":
|
case "SUM":
|
||||||
|
|
@ -1531,6 +1675,9 @@ export function RepeatScreenModalComponent({
|
||||||
// 🆕 v3.1: 외부 테이블 데이터 저장
|
// 🆕 v3.1: 외부 테이블 데이터 저장
|
||||||
await saveExternalTableData();
|
await saveExternalTableData();
|
||||||
|
|
||||||
|
// 🆕 v3.12: 연동 저장 처리 (syncSaves)
|
||||||
|
await processSyncSaves();
|
||||||
|
|
||||||
alert("저장되었습니다.");
|
alert("저장되었습니다.");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("저장 실패:", error);
|
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 버튼 클릭 핸들러
|
// 🆕 v3.1: Footer 버튼 클릭 핸들러
|
||||||
const handleFooterButtonClick = async (btn: FooterButtonConfig) => {
|
const handleFooterButtonClick = async (btn: FooterButtonConfig) => {
|
||||||
switch (btn.action) {
|
switch (btn.action) {
|
||||||
|
|
@ -1934,27 +2177,10 @@ export function RepeatScreenModalComponent({
|
||||||
// 🆕 v3.1: 외부 테이블 데이터 소스 사용
|
// 🆕 v3.1: 외부 테이블 데이터 소스 사용
|
||||||
<div className="border rounded-lg overflow-hidden">
|
<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">
|
<div className="px-4 py-2 bg-muted/30 border-b text-sm font-medium flex items-center justify-between">
|
||||||
<span>{contentRow.tableTitle || ""}</span>
|
<span>{contentRow.tableTitle || ""}</span>
|
||||||
<div className="flex items-center gap-2">
|
<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 && (
|
{contentRow.tableCrud?.allowCreate && (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -1974,7 +2200,8 @@ export function RepeatScreenModalComponent({
|
||||||
{contentRow.showTableHeader !== false && (
|
{contentRow.showTableHeader !== false && (
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-muted/50">
|
<TableRow className="bg-muted/50">
|
||||||
{(contentRow.tableColumns || []).map((col) => (
|
{/* 🆕 v3.13: hidden 컬럼 필터링 */}
|
||||||
|
{(contentRow.tableColumns || []).filter(col => !col.hidden).map((col) => (
|
||||||
<TableHead
|
<TableHead
|
||||||
key={col.id}
|
key={col.id}
|
||||||
style={{ width: col.width }}
|
style={{ width: col.width }}
|
||||||
|
|
@ -1993,7 +2220,7 @@ export function RepeatScreenModalComponent({
|
||||||
{(externalTableData[`${card._cardId}-${contentRow.id}`] || []).length === 0 ? (
|
{(externalTableData[`${card._cardId}-${contentRow.id}`] || []).length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<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"
|
className="text-center py-8 text-muted-foreground"
|
||||||
>
|
>
|
||||||
데이터가 없습니다.
|
데이터가 없습니다.
|
||||||
|
|
@ -2009,7 +2236,8 @@ export function RepeatScreenModalComponent({
|
||||||
row._isDeleted && "bg-destructive/10 opacity-60"
|
row._isDeleted && "bg-destructive/10 opacity-60"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{(contentRow.tableColumns || []).map((col) => (
|
{/* 🆕 v3.13: hidden 컬럼 필터링 */}
|
||||||
|
{(contentRow.tableColumns || []).filter(col => !col.hidden).map((col) => (
|
||||||
<TableCell
|
<TableCell
|
||||||
key={`${row._rowId}-${col.id}`}
|
key={`${row._rowId}-${col.id}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ import {
|
||||||
TableColumnConfig,
|
TableColumnConfig,
|
||||||
CardContentRowConfig,
|
CardContentRowConfig,
|
||||||
AggregationDisplayConfig,
|
AggregationDisplayConfig,
|
||||||
|
SyncSaveConfig,
|
||||||
|
RowNumberingConfig,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
|
@ -738,6 +740,7 @@ function AggregationSettingsModal({
|
||||||
aggregations,
|
aggregations,
|
||||||
sourceTable,
|
sourceTable,
|
||||||
allTables,
|
allTables,
|
||||||
|
contentRows,
|
||||||
onSave,
|
onSave,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -745,6 +748,7 @@ function AggregationSettingsModal({
|
||||||
aggregations: AggregationConfig[];
|
aggregations: AggregationConfig[];
|
||||||
sourceTable: string;
|
sourceTable: string;
|
||||||
allTables: { tableName: string; displayName?: string }[];
|
allTables: { tableName: string; displayName?: string }[];
|
||||||
|
contentRows: CardContentRowConfig[];
|
||||||
onSave: (aggregations: AggregationConfig[]) => void;
|
onSave: (aggregations: AggregationConfig[]) => void;
|
||||||
}) {
|
}) {
|
||||||
// 로컬 상태로 집계 목록 관리
|
// 로컬 상태로 집계 목록 관리
|
||||||
|
|
@ -852,6 +856,7 @@ function AggregationSettingsModal({
|
||||||
sourceTable={sourceTable}
|
sourceTable={sourceTable}
|
||||||
allTables={allTables}
|
allTables={allTables}
|
||||||
existingAggregations={localAggregations}
|
existingAggregations={localAggregations}
|
||||||
|
contentRows={contentRows}
|
||||||
onUpdate={(updates) => updateAggregation(index, updates)}
|
onUpdate={(updates) => updateAggregation(index, updates)}
|
||||||
onRemove={() => removeAggregation(index)}
|
onRemove={() => removeAggregation(index)}
|
||||||
onMove={(direction) => moveAggregation(index, direction)}
|
onMove={(direction) => moveAggregation(index, direction)}
|
||||||
|
|
@ -884,6 +889,7 @@ function AggregationConfigItemModal({
|
||||||
sourceTable,
|
sourceTable,
|
||||||
allTables,
|
allTables,
|
||||||
existingAggregations,
|
existingAggregations,
|
||||||
|
contentRows,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onRemove,
|
onRemove,
|
||||||
onMove,
|
onMove,
|
||||||
|
|
@ -894,6 +900,7 @@ function AggregationConfigItemModal({
|
||||||
sourceTable: string;
|
sourceTable: string;
|
||||||
allTables: { tableName: string; displayName?: string }[];
|
allTables: { tableName: string; displayName?: string }[];
|
||||||
existingAggregations: AggregationConfig[];
|
existingAggregations: AggregationConfig[];
|
||||||
|
contentRows: CardContentRowConfig[];
|
||||||
onUpdate: (updates: Partial<AggregationConfig>) => void;
|
onUpdate: (updates: Partial<AggregationConfig>) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onMove: (direction: "up" | "down") => void;
|
onMove: (direction: "up" | "down") => void;
|
||||||
|
|
@ -1120,6 +1127,15 @@ function AggregationConfigItemModal({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 🆕 v3.11: SUM_EXT 참조 테이블 선택 */}
|
||||||
|
{localFormula.includes("_EXT") && (
|
||||||
|
<ExternalTableRefSelector
|
||||||
|
contentRows={contentRows}
|
||||||
|
selectedRefs={agg.externalTableRefs || []}
|
||||||
|
onUpdate={(refs) => onUpdate({ externalTableRefs: refs })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -1280,6 +1296,504 @@ function FormulaColumnAggregator({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 v3.11: SUM_EXT 참조 테이블 선택 컴포넌트
|
||||||
|
function ExternalTableRefSelector({
|
||||||
|
contentRows,
|
||||||
|
selectedRefs,
|
||||||
|
onUpdate,
|
||||||
|
}: {
|
||||||
|
contentRows: CardContentRowConfig[];
|
||||||
|
selectedRefs: string[];
|
||||||
|
onUpdate: (refs: string[]) => void;
|
||||||
|
}) {
|
||||||
|
// 외부 데이터 소스가 활성화된 테이블 행만 필터링
|
||||||
|
const tableRowsWithExternalSource = contentRows.filter(
|
||||||
|
(row) => row.type === "table" && row.tableDataSource?.enabled
|
||||||
|
);
|
||||||
|
|
||||||
|
if (tableRowsWithExternalSource.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="p-3 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
레이아웃에 외부 데이터 소스가 설정된 테이블 행이 없습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAllSelected = selectedRefs.length === 0;
|
||||||
|
|
||||||
|
const handleToggleTable = (tableId: string) => {
|
||||||
|
if (selectedRefs.includes(tableId)) {
|
||||||
|
// 이미 선택된 경우 제거
|
||||||
|
const newRefs = selectedRefs.filter((id) => id !== tableId);
|
||||||
|
onUpdate(newRefs);
|
||||||
|
} else {
|
||||||
|
// 선택되지 않은 경우 추가
|
||||||
|
onUpdate([...selectedRefs, tableId]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
onUpdate([]); // 빈 배열 = 모든 테이블 사용
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 bg-amber-50 dark:bg-amber-950 rounded-lg border border-amber-200 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs text-amber-700 dark:text-amber-300 font-medium">
|
||||||
|
SUM_EXT 참조 테이블 (외부 데이터 소스)
|
||||||
|
</Label>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={isAllSelected ? "default" : "outline"}
|
||||||
|
onClick={handleSelectAll}
|
||||||
|
className="h-6 text-[10px] px-2"
|
||||||
|
>
|
||||||
|
전체 선택
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[10px] text-amber-600">
|
||||||
|
SUM_EXT 함수가 참조할 테이블을 선택하세요. 선택하지 않으면 모든 외부 테이블 데이터를 사용합니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
{tableRowsWithExternalSource.map((row) => {
|
||||||
|
const isSelected = selectedRefs.length === 0 || selectedRefs.includes(row.id);
|
||||||
|
const tableTitle = row.title || row.tableDataSource?.sourceTable || row.id;
|
||||||
|
const tableName = row.tableDataSource?.sourceTable || "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={row.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 p-2 rounded border cursor-pointer transition-colors",
|
||||||
|
isSelected
|
||||||
|
? "bg-amber-100 border-amber-300"
|
||||||
|
: "bg-white border-gray-200 hover:bg-gray-50"
|
||||||
|
)}
|
||||||
|
onClick={() => handleToggleTable(row.id)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => {}} // onClick에서 처리
|
||||||
|
className="h-4 w-4 rounded border-gray-300 text-amber-600 focus:ring-amber-500"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs font-medium truncate">{tableTitle}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground truncate">
|
||||||
|
테이블: {tableName} | ID: {row.id.slice(-10)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedRefs.length > 0 && (
|
||||||
|
<p className="text-[10px] text-amber-700 bg-amber-100 px-2 py-1 rounded">
|
||||||
|
선택된 테이블: {selectedRefs.length}개 (특정 테이블만 참조)
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 v3.12: 연동 저장 설정 섹션
|
||||||
|
function SyncSaveConfigSection({
|
||||||
|
row,
|
||||||
|
allTables,
|
||||||
|
onUpdateRow,
|
||||||
|
}: {
|
||||||
|
row: CardContentRowConfig;
|
||||||
|
allTables: { tableName: string; displayName?: string }[];
|
||||||
|
onUpdateRow: (updates: Partial<CardContentRowConfig>) => void;
|
||||||
|
}) {
|
||||||
|
const syncSaves = row.tableCrud?.syncSaves || [];
|
||||||
|
const sourceTable = row.tableDataSource?.sourceTable || "";
|
||||||
|
|
||||||
|
// 연동 저장 추가
|
||||||
|
const addSyncSave = () => {
|
||||||
|
const newSyncSave: SyncSaveConfig = {
|
||||||
|
id: `sync-${Date.now()}`,
|
||||||
|
enabled: true,
|
||||||
|
sourceColumn: "",
|
||||||
|
aggregationType: "sum",
|
||||||
|
targetTable: "",
|
||||||
|
targetColumn: "",
|
||||||
|
joinKey: {
|
||||||
|
sourceField: "",
|
||||||
|
targetField: "id",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
onUpdateRow({
|
||||||
|
tableCrud: {
|
||||||
|
...row.tableCrud,
|
||||||
|
allowCreate: row.tableCrud?.allowCreate || false,
|
||||||
|
allowUpdate: row.tableCrud?.allowUpdate || false,
|
||||||
|
allowDelete: row.tableCrud?.allowDelete || false,
|
||||||
|
syncSaves: [...syncSaves, newSyncSave],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 연동 저장 삭제
|
||||||
|
const removeSyncSave = (index: number) => {
|
||||||
|
const newSyncSaves = [...syncSaves];
|
||||||
|
newSyncSaves.splice(index, 1);
|
||||||
|
|
||||||
|
onUpdateRow({
|
||||||
|
tableCrud: {
|
||||||
|
...row.tableCrud,
|
||||||
|
allowCreate: row.tableCrud?.allowCreate || false,
|
||||||
|
allowUpdate: row.tableCrud?.allowUpdate || false,
|
||||||
|
allowDelete: row.tableCrud?.allowDelete || false,
|
||||||
|
syncSaves: newSyncSaves,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 연동 저장 업데이트
|
||||||
|
const updateSyncSave = (index: number, updates: Partial<SyncSaveConfig>) => {
|
||||||
|
const newSyncSaves = [...syncSaves];
|
||||||
|
newSyncSaves[index] = { ...newSyncSaves[index], ...updates };
|
||||||
|
|
||||||
|
onUpdateRow({
|
||||||
|
tableCrud: {
|
||||||
|
...row.tableCrud,
|
||||||
|
allowCreate: row.tableCrud?.allowCreate || false,
|
||||||
|
allowUpdate: row.tableCrud?.allowUpdate || false,
|
||||||
|
allowDelete: row.tableCrud?.allowDelete || false,
|
||||||
|
syncSaves: newSyncSaves,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 p-2 bg-orange-50 dark:bg-orange-950 rounded-lg border border-orange-200">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-[10px] font-semibold text-orange-700 dark:text-orange-300">
|
||||||
|
연동 저장 설정 (모달 저장 시 다른 테이블에 동기화)
|
||||||
|
</Label>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={addSyncSave}
|
||||||
|
className="h-5 text-[9px] px-1 bg-orange-100 hover:bg-orange-200 border-orange-300"
|
||||||
|
>
|
||||||
|
<Plus className="h-2 w-2 mr-0.5" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{syncSaves.length === 0 ? (
|
||||||
|
<p className="text-[9px] text-muted-foreground text-center py-2">
|
||||||
|
연동 저장 설정이 없습니다. 추가 버튼을 눌러 설정하세요.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{syncSaves.map((sync, index) => (
|
||||||
|
<SyncSaveConfigItem
|
||||||
|
key={sync.id}
|
||||||
|
sync={sync}
|
||||||
|
index={index}
|
||||||
|
sourceTable={sourceTable}
|
||||||
|
allTables={allTables}
|
||||||
|
onUpdate={(updates) => updateSyncSave(index, updates)}
|
||||||
|
onRemove={() => removeSyncSave(index)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 v3.12: 개별 연동 저장 설정 아이템
|
||||||
|
function SyncSaveConfigItem({
|
||||||
|
sync,
|
||||||
|
index,
|
||||||
|
sourceTable,
|
||||||
|
allTables,
|
||||||
|
onUpdate,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
sync: SyncSaveConfig;
|
||||||
|
index: number;
|
||||||
|
sourceTable: string;
|
||||||
|
allTables: { tableName: string; displayName?: string }[];
|
||||||
|
onUpdate: (updates: Partial<SyncSaveConfig>) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn(
|
||||||
|
"p-2 rounded border space-y-2",
|
||||||
|
sync.enabled ? "bg-orange-100 border-orange-300" : "bg-gray-100 border-gray-300 opacity-60"
|
||||||
|
)}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={sync.enabled}
|
||||||
|
onCheckedChange={(checked) => onUpdate({ enabled: checked })}
|
||||||
|
className="scale-[0.6]"
|
||||||
|
/>
|
||||||
|
<Badge className="text-[8px] px-1 py-0 bg-orange-200 text-orange-800">
|
||||||
|
연동 {index + 1}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onRemove}
|
||||||
|
className="h-4 w-4 p-0 hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 소스 설정 */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label className="text-[8px] text-muted-foreground">소스 컬럼 ({sourceTable})</Label>
|
||||||
|
<SourceColumnSelector
|
||||||
|
sourceTable={sourceTable}
|
||||||
|
value={sync.sourceColumn}
|
||||||
|
onChange={(value) => onUpdate({ sourceColumn: value })}
|
||||||
|
placeholder="컬럼 선택"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label className="text-[8px] text-muted-foreground">집계 방식</Label>
|
||||||
|
<Select
|
||||||
|
value={sync.aggregationType}
|
||||||
|
onValueChange={(value) => onUpdate({ aggregationType: value as SyncSaveConfig["aggregationType"] })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 text-[10px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="sum">합계 (SUM)</SelectItem>
|
||||||
|
<SelectItem value="count">개수 (COUNT)</SelectItem>
|
||||||
|
<SelectItem value="avg">평균 (AVG)</SelectItem>
|
||||||
|
<SelectItem value="min">최소 (MIN)</SelectItem>
|
||||||
|
<SelectItem value="max">최대 (MAX)</SelectItem>
|
||||||
|
<SelectItem value="latest">최신값 (LATEST)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 대상 설정 */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label className="text-[8px] text-muted-foreground">대상 테이블</Label>
|
||||||
|
<Select
|
||||||
|
value={sync.targetTable}
|
||||||
|
onValueChange={(value) => onUpdate({ targetTable: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 text-[10px]">
|
||||||
|
<SelectValue placeholder="테이블 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{allTables.map((table) => (
|
||||||
|
<SelectItem key={table.tableName} value={table.tableName}>
|
||||||
|
{table.displayName || table.tableName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label className="text-[8px] text-muted-foreground">대상 컬럼</Label>
|
||||||
|
<SourceColumnSelector
|
||||||
|
sourceTable={sync.targetTable}
|
||||||
|
value={sync.targetColumn}
|
||||||
|
onChange={(value) => onUpdate({ targetColumn: value })}
|
||||||
|
placeholder="컬럼 선택"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 조인 키 설정 */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label className="text-[8px] text-muted-foreground">조인 키 (소스)</Label>
|
||||||
|
<SourceColumnSelector
|
||||||
|
sourceTable={sourceTable}
|
||||||
|
value={sync.joinKey.sourceField}
|
||||||
|
onChange={(value) => onUpdate({ joinKey: { ...sync.joinKey, sourceField: value } })}
|
||||||
|
placeholder="예: sales_order_id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label className="text-[8px] text-muted-foreground">조인 키 (대상)</Label>
|
||||||
|
<SourceColumnSelector
|
||||||
|
sourceTable={sync.targetTable}
|
||||||
|
value={sync.joinKey.targetField}
|
||||||
|
onChange={(value) => onUpdate({ joinKey: { ...sync.joinKey, targetField: value } })}
|
||||||
|
placeholder="예: id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 설정 요약 */}
|
||||||
|
{sync.sourceColumn && sync.targetTable && sync.targetColumn && (
|
||||||
|
<p className="text-[8px] text-orange-700 bg-orange-200 px-2 py-1 rounded">
|
||||||
|
{sourceTable}.{sync.sourceColumn}의 {sync.aggregationType.toUpperCase()} 값을{" "}
|
||||||
|
{sync.targetTable}.{sync.targetColumn}에 저장
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 v3.13: 행 추가 시 자동 채번 설정 섹션
|
||||||
|
function RowNumberingConfigSection({
|
||||||
|
row,
|
||||||
|
onUpdateRow,
|
||||||
|
}: {
|
||||||
|
row: CardContentRowConfig;
|
||||||
|
onUpdateRow: (updates: Partial<CardContentRowConfig>) => void;
|
||||||
|
}) {
|
||||||
|
const [numberingRules, setNumberingRules] = useState<{ id: string; name: string; code: string }[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const rowNumbering = row.tableCrud?.rowNumbering;
|
||||||
|
const tableColumns = row.tableColumns || [];
|
||||||
|
|
||||||
|
// 채번 규칙 목록 로드 (옵션설정 > 코드설정에서 등록된 전체 목록)
|
||||||
|
useEffect(() => {
|
||||||
|
const loadNumberingRules = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const { getNumberingRules } = await import("@/lib/api/numberingRule");
|
||||||
|
const response = await getNumberingRules();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setNumberingRules(response.data.map((rule: any, index: number) => ({
|
||||||
|
id: String(rule.ruleId || rule.id || `rule-${index}`),
|
||||||
|
name: rule.ruleName || rule.name || "이름 없음",
|
||||||
|
code: rule.ruleId || rule.code || "",
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("채번 규칙 로드 실패:", error);
|
||||||
|
setNumberingRules([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadNumberingRules();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 채번 설정 업데이트
|
||||||
|
const updateRowNumbering = (updates: Partial<RowNumberingConfig>) => {
|
||||||
|
const currentNumbering = row.tableCrud?.rowNumbering || {
|
||||||
|
enabled: false,
|
||||||
|
targetColumn: "",
|
||||||
|
numberingRuleId: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
onUpdateRow({
|
||||||
|
tableCrud: {
|
||||||
|
...row.tableCrud,
|
||||||
|
allowCreate: row.tableCrud?.allowCreate || false,
|
||||||
|
allowUpdate: row.tableCrud?.allowUpdate || false,
|
||||||
|
allowDelete: row.tableCrud?.allowDelete || false,
|
||||||
|
rowNumbering: {
|
||||||
|
...currentNumbering,
|
||||||
|
...updates,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 p-3 bg-purple-50 dark:bg-purple-950 rounded-lg border border-purple-200">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={rowNumbering?.enabled || false}
|
||||||
|
onCheckedChange={(checked) => updateRowNumbering({ enabled: checked })}
|
||||||
|
className="scale-90"
|
||||||
|
/>
|
||||||
|
<Label className="text-xs font-semibold text-purple-700 dark:text-purple-300">
|
||||||
|
행 추가 시 자동 채번
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[10px] text-purple-600 leading-tight">
|
||||||
|
"추가" 버튼 클릭 시 지정한 컬럼에 자동으로 번호를 생성합니다.
|
||||||
|
(옵션설정 > 코드설정에서 등록한 채번 규칙 사용)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{rowNumbering?.enabled && (
|
||||||
|
<div className="space-y-3 pt-2 border-t border-purple-200">
|
||||||
|
{/* 대상 컬럼 선택 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px] text-purple-700">채번 대상 컬럼 *</Label>
|
||||||
|
<Select
|
||||||
|
value={rowNumbering.targetColumn || ""}
|
||||||
|
onValueChange={(value) => updateRowNumbering({ targetColumn: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tableColumns.map((col, index) => (
|
||||||
|
<SelectItem key={col.id || `col-${index}`} value={col.field} className="text-xs">
|
||||||
|
{col.label || col.field}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[9px] text-muted-foreground">
|
||||||
|
채번 결과가 저장될 컬럼 (수정 가능 여부는 컬럼 설정에서 조절)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 채번 규칙 선택 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px] text-purple-700">채번 규칙 *</Label>
|
||||||
|
<Select
|
||||||
|
value={rowNumbering.numberingRuleId || ""}
|
||||||
|
onValueChange={(value) => updateRowNumbering({ numberingRuleId: value })}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder={isLoading ? "로딩 중..." : "채번 규칙 선택"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{numberingRules.map((rule) => (
|
||||||
|
<SelectItem key={rule.id} value={rule.id} className="text-xs">
|
||||||
|
{rule.name} ({rule.code})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{numberingRules.length === 0 && !isLoading && (
|
||||||
|
<p className="text-[9px] text-amber-600">
|
||||||
|
등록된 채번 규칙이 없습니다. 옵션설정 > 코드설정에서 추가하세요.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 설정 요약 */}
|
||||||
|
{rowNumbering.targetColumn && rowNumbering.numberingRuleId && (
|
||||||
|
<div className="text-[10px] text-purple-700 bg-purple-100 px-2 py-1.5 rounded">
|
||||||
|
"추가" 클릭 시 <strong>{rowNumbering.targetColumn}</strong> 컬럼에 자동 채번
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 🆕 레이아웃 설정 전용 모달
|
// 🆕 레이아웃 설정 전용 모달
|
||||||
function LayoutSettingsModal({
|
function LayoutSettingsModal({
|
||||||
open,
|
open,
|
||||||
|
|
@ -2040,6 +2554,78 @@ function LayoutRowConfigModal({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* CRUD 설정 */}
|
||||||
|
<div className="space-y-2 p-3 bg-green-100/50 dark:bg-green-900/20 rounded border border-green-200 dark:border-green-800">
|
||||||
|
<Label className="text-xs font-semibold">CRUD 설정</Label>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={row.tableCrud?.allowCreate || false}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onUpdateRow({
|
||||||
|
tableCrud: { ...row.tableCrud, allowCreate: checked, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: row.tableCrud?.allowDelete || false },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="scale-90"
|
||||||
|
/>
|
||||||
|
<Label className="text-xs">추가</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={row.tableCrud?.allowUpdate || false}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onUpdateRow({
|
||||||
|
tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: checked, allowDelete: row.tableCrud?.allowDelete || false },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="scale-90"
|
||||||
|
/>
|
||||||
|
<Label className="text-xs">수정</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={row.tableCrud?.allowDelete || false}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onUpdateRow({
|
||||||
|
tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: checked },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="scale-90"
|
||||||
|
/>
|
||||||
|
<Label className="text-xs">삭제</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{row.tableCrud?.allowDelete && (
|
||||||
|
<div className="flex items-center gap-2 pl-2 pt-1 border-t border-green-200">
|
||||||
|
<Switch
|
||||||
|
checked={row.tableCrud?.deleteConfirm?.enabled !== false}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onUpdateRow({
|
||||||
|
tableCrud: { ...row.tableCrud!, deleteConfirm: { enabled: checked } },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="scale-75"
|
||||||
|
/>
|
||||||
|
<Label className="text-[10px]">삭제 확인 팝업</Label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 🆕 v3.13: 행 추가 시 자동 채번 설정 */}
|
||||||
|
{row.tableCrud?.allowCreate && (
|
||||||
|
<RowNumberingConfigSection
|
||||||
|
row={row}
|
||||||
|
onUpdateRow={onUpdateRow}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 🆕 v3.12: 연동 저장 설정 */}
|
||||||
|
<SyncSaveConfigSection
|
||||||
|
row={row}
|
||||||
|
allTables={allTables}
|
||||||
|
onUpdateRow={onUpdateRow}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 테이블 컬럼 목록 */}
|
{/* 테이블 컬럼 목록 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
@ -2077,7 +2663,7 @@ function LayoutRowConfigModal({
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<div className="grid grid-cols-5 gap-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-[10px]">필드</Label>
|
<Label className="text-[10px]">필드</Label>
|
||||||
<SourceColumnSelector
|
<SourceColumnSelector
|
||||||
|
|
@ -2124,6 +2710,17 @@ function LayoutRowConfigModal({
|
||||||
<span className="ml-1 text-[10px]">{col.editable ? "예" : "아니오"}</span>
|
<span className="ml-1 text-[10px]">{col.editable ? "예" : "아니오"}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">숨김</Label>
|
||||||
|
<div className="flex items-center h-6 px-2 border rounded-md bg-background">
|
||||||
|
<Switch
|
||||||
|
checked={col.hidden || false}
|
||||||
|
onCheckedChange={(checked) => onUpdateTableColumn(colIndex, { hidden: checked })}
|
||||||
|
className="scale-75"
|
||||||
|
/>
|
||||||
|
<span className="ml-1 text-[10px]">{col.hidden ? "예" : "아니오"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -3188,21 +3785,21 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM
|
||||||
<div className="space-y-2 pt-2 border-t overflow-hidden">
|
<div className="space-y-2 pt-2 border-t overflow-hidden">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-[10px] font-semibold">집계 설정</Label>
|
<Label className="text-[10px] font-semibold">집계 설정</Label>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setAggregationModalOpen(true)}
|
onClick={() => setAggregationModalOpen(true)}
|
||||||
className="h-6 text-[9px] px-2"
|
className="h-6 text-[9px] px-2"
|
||||||
>
|
>
|
||||||
<Layers className="h-3 w-3 mr-1" />
|
<Layers className="h-3 w-3 mr-1" />
|
||||||
설정 열기 ({(localConfig.grouping?.aggregations || []).length}개)
|
설정 열기 ({(localConfig.grouping?.aggregations || []).length}개)
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 현재 집계 목록 요약 */}
|
{/* 현재 집계 목록 요약 */}
|
||||||
{(localConfig.grouping?.aggregations || []).length > 0 ? (
|
{(localConfig.grouping?.aggregations || []).length > 0 ? (
|
||||||
<div className="space-y-1 max-h-[120px] overflow-y-auto pr-1">
|
<div className="space-y-1 max-h-[120px] overflow-y-auto pr-1">
|
||||||
{(localConfig.grouping?.aggregations || []).map((agg, index) => (
|
{(localConfig.grouping?.aggregations || []).map((agg, index) => (
|
||||||
<div
|
<div
|
||||||
key={`agg-summary-${index}`}
|
key={`agg-summary-${index}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -3239,15 +3836,15 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM
|
||||||
<div className="space-y-3 border rounded-lg p-3 bg-card">
|
<div className="space-y-3 border rounded-lg p-3 bg-card">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-xs font-semibold">레이아웃 행</h3>
|
<h3 className="text-xs font-semibold">레이아웃 행</h3>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setLayoutModalOpen(true)}
|
onClick={() => setLayoutModalOpen(true)}
|
||||||
className="h-6 text-[9px] px-2"
|
className="h-6 text-[9px] px-2"
|
||||||
>
|
>
|
||||||
<Layers className="h-3 w-3 mr-1" />
|
<Layers className="h-3 w-3 mr-1" />
|
||||||
설정 열기 ({(localConfig.contentRows || []).length}개)
|
설정 열기 ({(localConfig.contentRows || []).length}개)
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 현재 레이아웃 요약 */}
|
{/* 현재 레이아웃 요약 */}
|
||||||
|
|
@ -3324,6 +3921,7 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM
|
||||||
aggregations={localConfig.grouping?.aggregations || []}
|
aggregations={localConfig.grouping?.aggregations || []}
|
||||||
sourceTable={localConfig.dataSource?.sourceTable || ""}
|
sourceTable={localConfig.dataSource?.sourceTable || ""}
|
||||||
allTables={allTables}
|
allTables={allTables}
|
||||||
|
contentRows={localConfig.contentRows || []}
|
||||||
onSave={(newAggregations) => {
|
onSave={(newAggregations) => {
|
||||||
updateGrouping({ aggregations: newAggregations });
|
updateGrouping({ aggregations: newAggregations });
|
||||||
}}
|
}}
|
||||||
|
|
@ -4192,7 +4790,7 @@ function ContentRowConfigSection({
|
||||||
checked={row.tableCrud?.allowCreate || false}
|
checked={row.tableCrud?.allowCreate || false}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
onUpdateRow({
|
onUpdateRow({
|
||||||
tableCrud: { ...row.tableCrud, allowCreate: checked, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: row.tableCrud?.allowDelete || false, allowSave: row.tableCrud?.allowSave || false },
|
tableCrud: { ...row.tableCrud, allowCreate: checked, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: row.tableCrud?.allowDelete || false },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="scale-[0.5]"
|
className="scale-[0.5]"
|
||||||
|
|
@ -4204,7 +4802,7 @@ function ContentRowConfigSection({
|
||||||
checked={row.tableCrud?.allowUpdate || false}
|
checked={row.tableCrud?.allowUpdate || false}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
onUpdateRow({
|
onUpdateRow({
|
||||||
tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: checked, allowDelete: row.tableCrud?.allowDelete || false, allowSave: row.tableCrud?.allowSave || false },
|
tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: checked, allowDelete: row.tableCrud?.allowDelete || false },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="scale-[0.5]"
|
className="scale-[0.5]"
|
||||||
|
|
@ -4216,25 +4814,13 @@ function ContentRowConfigSection({
|
||||||
checked={row.tableCrud?.allowDelete || false}
|
checked={row.tableCrud?.allowDelete || false}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
onUpdateRow({
|
onUpdateRow({
|
||||||
tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: checked, allowSave: row.tableCrud?.allowSave || false },
|
tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: checked },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="scale-[0.5]"
|
className="scale-[0.5]"
|
||||||
/>
|
/>
|
||||||
<Label className="text-[9px]">삭제</Label>
|
<Label className="text-[9px]">삭제</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Switch
|
|
||||||
checked={row.tableCrud?.allowSave || false}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
onUpdateRow({
|
|
||||||
tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: row.tableCrud?.allowDelete || false, allowSave: checked },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="scale-[0.5]"
|
|
||||||
/>
|
|
||||||
<Label className="text-[9px]">저장</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{row.tableCrud?.allowDelete && (
|
{row.tableCrud?.allowDelete && (
|
||||||
<div className="flex items-center gap-1 pl-2">
|
<div className="flex items-center gap-1 pl-2">
|
||||||
|
|
@ -4252,6 +4838,21 @@ function ContentRowConfigSection({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 🆕 v3.12: 연동 저장 설정 */}
|
||||||
|
<SyncSaveConfigSection
|
||||||
|
row={row}
|
||||||
|
allTables={allTables}
|
||||||
|
onUpdateRow={onUpdateRow}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 🆕 v3.13: 행 추가 시 자동 채번 설정 */}
|
||||||
|
{row.tableCrud?.allowCreate && (
|
||||||
|
<RowNumberingConfigSection
|
||||||
|
row={row}
|
||||||
|
onUpdateRow={onUpdateRow}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 테이블 컬럼 목록 */}
|
{/* 테이블 컬럼 목록 */}
|
||||||
<div className="space-y-2 pl-2 border-l-2 border-blue-300">
|
<div className="space-y-2 pl-2 border-l-2 border-blue-300">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
|
||||||
|
|
@ -188,10 +188,6 @@ export interface TableCrudConfig {
|
||||||
allowUpdate: boolean; // 행 수정 허용
|
allowUpdate: boolean; // 행 수정 허용
|
||||||
allowDelete: boolean; // 행 삭제 허용
|
allowDelete: boolean; // 행 삭제 허용
|
||||||
|
|
||||||
// 🆕 v3.5: 테이블 영역 저장 버튼
|
|
||||||
allowSave?: boolean; // 테이블 영역에 저장 버튼 표시
|
|
||||||
saveButtonLabel?: string; // 저장 버튼 라벨 (기본: "저장")
|
|
||||||
|
|
||||||
// 신규 행 기본값
|
// 신규 행 기본값
|
||||||
newRowDefaults?: Record<string, string>; // 기본값 (예: { status: "READY", sales_order_id: "{id}" })
|
newRowDefaults?: Record<string, string>; // 기본값 (예: { status: "READY", sales_order_id: "{id}" })
|
||||||
|
|
||||||
|
|
@ -203,6 +199,54 @@ export interface TableCrudConfig {
|
||||||
|
|
||||||
// 저장 대상 테이블 (외부 데이터 소스 사용 시)
|
// 저장 대상 테이블 (외부 데이터 소스 사용 시)
|
||||||
targetTable?: string; // 저장할 테이블 (기본: tableDataSource.sourceTable)
|
targetTable?: string; // 저장할 테이블 (기본: tableDataSource.sourceTable)
|
||||||
|
|
||||||
|
// 🆕 v3.12: 연동 저장 설정 (모달 전체 저장 시 다른 테이블에도 동기화)
|
||||||
|
syncSaves?: SyncSaveConfig[];
|
||||||
|
|
||||||
|
// 🆕 v3.13: 행 추가 시 자동 채번 설정
|
||||||
|
rowNumbering?: RowNumberingConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v3.13: 테이블 행 채번 설정
|
||||||
|
* "추가" 버튼 클릭 시 특정 컬럼에 자동으로 번호를 생성
|
||||||
|
*
|
||||||
|
* 사용 예시:
|
||||||
|
* - 출하계획번호(shipment_plan_no) 자동 생성
|
||||||
|
* - 송장번호(invoice_no) 자동 생성
|
||||||
|
* - 작업지시번호(work_order_no) 자동 생성
|
||||||
|
*
|
||||||
|
* 참고: 채번 후 읽기 전용 여부는 테이블 컬럼의 "수정 가능" 설정으로 제어
|
||||||
|
*/
|
||||||
|
export interface RowNumberingConfig {
|
||||||
|
enabled: boolean; // 채번 사용 여부
|
||||||
|
targetColumn: string; // 채번 결과를 저장할 컬럼 (예: "shipment_plan_no")
|
||||||
|
|
||||||
|
// 채번 규칙 설정 (옵션설정 > 코드설정에서 등록된 채번 규칙)
|
||||||
|
numberingRuleId: string; // 채번 규칙 ID (numbering_rule 테이블)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v3.12: 연동 저장 설정
|
||||||
|
* 테이블 데이터 저장 시 다른 테이블의 특정 컬럼에 집계 값을 동기화
|
||||||
|
*/
|
||||||
|
export interface SyncSaveConfig {
|
||||||
|
id: string; // 고유 ID
|
||||||
|
enabled: boolean; // 활성화 여부
|
||||||
|
|
||||||
|
// 소스 설정 (이 테이블에서)
|
||||||
|
sourceColumn: string; // 집계할 컬럼 (예: "plan_qty")
|
||||||
|
aggregationType: "sum" | "count" | "avg" | "min" | "max" | "latest"; // 집계 방식
|
||||||
|
|
||||||
|
// 대상 설정 (저장할 테이블)
|
||||||
|
targetTable: string; // 대상 테이블 (예: "sales_order_mng")
|
||||||
|
targetColumn: string; // 대상 컬럼 (예: "plan_ship_qty")
|
||||||
|
|
||||||
|
// 조인 키 (어떤 레코드를 업데이트할지)
|
||||||
|
joinKey: {
|
||||||
|
sourceField: string; // 이 테이블의 조인 키 (예: "sales_order_id")
|
||||||
|
targetField: string; // 대상 테이블의 키 (예: "id")
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -285,6 +329,12 @@ export interface AggregationConfig {
|
||||||
// - 산술 연산: +, -, *, /, ()
|
// - 산술 연산: +, -, *, /, ()
|
||||||
formula?: string; // 연산식 (예: "{total_balance} - SUM_EXT({plan_qty})")
|
formula?: string; // 연산식 (예: "{total_balance} - SUM_EXT({plan_qty})")
|
||||||
|
|
||||||
|
// === 🆕 v3.11: SUM_EXT 참조 테이블 제한 ===
|
||||||
|
// SUM_EXT 함수가 참조할 외부 테이블 행 ID 목록
|
||||||
|
// 비어있거나 undefined면 모든 외부 테이블 데이터 사용 (기존 동작)
|
||||||
|
// 특정 테이블만 참조하려면 contentRow의 id를 배열로 지정
|
||||||
|
externalTableRefs?: string[]; // 참조할 테이블 행 ID 목록 (예: ["crow-1764571929625"])
|
||||||
|
|
||||||
// === 공통 ===
|
// === 공통 ===
|
||||||
resultField: string; // 결과 필드명 (예: "total_balance_qty")
|
resultField: string; // 결과 필드명 (예: "total_balance_qty")
|
||||||
label: string; // 표시 라벨 (예: "총수주잔량")
|
label: string; // 표시 라벨 (예: "총수주잔량")
|
||||||
|
|
@ -340,6 +390,9 @@ export interface TableColumnConfig {
|
||||||
editable: boolean; // 편집 가능 여부
|
editable: boolean; // 편집 가능 여부
|
||||||
required?: boolean; // 필수 입력 여부
|
required?: boolean; // 필수 입력 여부
|
||||||
|
|
||||||
|
// 🆕 v3.13: 숨김 설정 (화면에는 안 보이지만 데이터는 존재)
|
||||||
|
hidden?: boolean; // 숨김 여부
|
||||||
|
|
||||||
// 🆕 v3.3: 컬럼 소스 테이블 지정 (조인 테이블 컬럼 사용 시)
|
// 🆕 v3.3: 컬럼 소스 테이블 지정 (조인 테이블 컬럼 사용 시)
|
||||||
fromTable?: string; // 컬럼이 속한 테이블 (비어있으면 소스 테이블)
|
fromTable?: string; // 컬럼이 속한 테이블 (비어있으면 소스 테이블)
|
||||||
fromJoinId?: string; // additionalJoins의 id 참조 (조인 테이블 컬럼일 때)
|
fromJoinId?: string; // additionalJoins의 id 참조 (조인 테이블 컬럼일 때)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { ComponentRendererProps } from "@/types/component";
|
import { ComponentRendererProps } from "@/types/component";
|
||||||
import { AutoGenerationConfig } from "@/types/screen";
|
import { AutoGenerationConfig, AutoGenerationType } from "@/types/screen";
|
||||||
import { TextInputConfig } from "./types";
|
import { TextInputConfig } from "./types";
|
||||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||||
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
|
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import { TextInputConfig } from "./types";
|
import { TextInputConfig } from "./types";
|
||||||
import { AutoGenerationType, AutoGenerationConfig } from "@/types/screen";
|
import { AutoGenerationType, AutoGenerationConfig } from "@/types/screen";
|
||||||
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
|
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue