From 512e1e30d1bb8bdd70ade35a8a0fd9a74a923148 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 10 Dec 2025 17:13:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(repeat-screen-modal):=20=EC=97=B0=EB=8F=99?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5,=20=EC=9E=90=EB=8F=99=20=EC=B1=84?= =?UTF-8?q?=EB=B2=88,=20SUM=5FEXT=20=EC=B0=B8=EC=A1=B0=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SyncSaveConfig: 모달 저장 시 다른 테이블에 집계 값 동기화 기능 - RowNumberingConfig: 행 추가 시 채번 규칙 적용하여 자동 번호 생성 - externalTableRefs: SUM_EXT 함수가 참조할 외부 테이블 제한 기능 - triggerRepeatScreenModalSave: 외부에서 저장 트리거 가능한 이벤트 리스너 - TableColumnConfig.hidden: 테이블 컬럼 숨김 기능 (데이터는 유지, 화면만 숨김) - beforeFormSave: FK 자동 채우기 및 _isNew 행 포함 로직 개선 --- .../controllers/tableManagementController.ts | 1 + .../RepeatScreenModalComponent.tsx | 292 +++++++- .../RepeatScreenModalConfigPanel.tsx | 657 +++++++++++++++++- .../components/repeat-screen-modal/types.ts | 61 +- .../text-input/TextInputComponent.tsx | 2 +- .../text-input/TextInputConfigPanel.tsx | 2 + 6 files changed, 950 insertions(+), 65 deletions(-) diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 2dfe0770..66c70a77 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -2141,3 +2141,4 @@ export async function multiTableSave( client.release(); } } + diff --git a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx index 1a8012de..360a1585 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx +++ b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx @@ -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 = {}; 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 = {}; 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 = {}; @@ -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[] = []; + + // 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(); + 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: 외부 테이블 데이터 소스 사용
{/* 테이블 헤더 영역: 제목 + 버튼들 */} - {(contentRow.tableTitle || contentRow.tableCrud?.allowSave || contentRow.tableCrud?.allowCreate) && ( + {(contentRow.tableTitle || contentRow.tableCrud?.allowCreate) && (
{contentRow.tableTitle || ""}
- {/* 저장 버튼 - allowSave가 true일 때만 표시 */} - {contentRow.tableCrud?.allowSave && ( - - )} {/* 추가 버튼 */} {contentRow.tableCrud?.allowCreate && (
+ + {/* 🆕 v3.11: SUM_EXT 참조 테이블 선택 */} + {localFormula.includes("_EXT") && ( + onUpdate({ externalTableRefs: refs })} + /> + )}
)} @@ -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 ( +
+

+ 레이아웃에 외부 데이터 소스가 설정된 테이블 행이 없습니다. +

+
+ ); + } + + 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 ( +
+
+ + +
+ +

+ SUM_EXT 함수가 참조할 테이블을 선택하세요. 선택하지 않으면 모든 외부 테이블 데이터를 사용합니다. +

+ +
+ {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 ( +
handleToggleTable(row.id)} + > + {}} // onClick에서 처리 + className="h-4 w-4 rounded border-gray-300 text-amber-600 focus:ring-amber-500" + /> +
+

{tableTitle}

+

+ 테이블: {tableName} | ID: {row.id.slice(-10)} +

+
+
+ ); + })} +
+ + {selectedRefs.length > 0 && ( +

+ 선택된 테이블: {selectedRefs.length}개 (특정 테이블만 참조) +

+ )} +
+ ); +} + +// 🆕 v3.12: 연동 저장 설정 섹션 +function SyncSaveConfigSection({ + row, + allTables, + onUpdateRow, +}: { + row: CardContentRowConfig; + allTables: { tableName: string; displayName?: string }[]; + onUpdateRow: (updates: Partial) => 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) => { + 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 ( +
+
+ + +
+ + {syncSaves.length === 0 ? ( +

+ 연동 저장 설정이 없습니다. 추가 버튼을 눌러 설정하세요. +

+ ) : ( +
+ {syncSaves.map((sync, index) => ( + updateSyncSave(index, updates)} + onRemove={() => removeSyncSave(index)} + /> + ))} +
+ )} +
+ ); +} + +// 🆕 v3.12: 개별 연동 저장 설정 아이템 +function SyncSaveConfigItem({ + sync, + index, + sourceTable, + allTables, + onUpdate, + onRemove, +}: { + sync: SyncSaveConfig; + index: number; + sourceTable: string; + allTables: { tableName: string; displayName?: string }[]; + onUpdate: (updates: Partial) => void; + onRemove: () => void; +}) { + return ( +
+ {/* 헤더 */} +
+
+ onUpdate({ enabled: checked })} + className="scale-[0.6]" + /> + + 연동 {index + 1} + +
+ +
+ + {/* 소스 설정 */} +
+
+ + onUpdate({ sourceColumn: value })} + placeholder="컬럼 선택" + /> +
+
+ + +
+
+ + {/* 대상 설정 */} +
+
+ + +
+
+ + onUpdate({ targetColumn: value })} + placeholder="컬럼 선택" + /> +
+
+ + {/* 조인 키 설정 */} +
+
+ + onUpdate({ joinKey: { ...sync.joinKey, sourceField: value } })} + placeholder="예: sales_order_id" + /> +
+
+ + onUpdate({ joinKey: { ...sync.joinKey, targetField: value } })} + placeholder="예: id" + /> +
+
+ + {/* 설정 요약 */} + {sync.sourceColumn && sync.targetTable && sync.targetColumn && ( +

+ {sourceTable}.{sync.sourceColumn}의 {sync.aggregationType.toUpperCase()} 값을{" "} + {sync.targetTable}.{sync.targetColumn}에 저장 +

+ )} +
+ ); +} + +// 🆕 v3.13: 행 추가 시 자동 채번 설정 섹션 +function RowNumberingConfigSection({ + row, + onUpdateRow, +}: { + row: CardContentRowConfig; + onUpdateRow: (updates: Partial) => 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) => { + 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 ( +
+
+
+ updateRowNumbering({ enabled: checked })} + className="scale-90" + /> + +
+
+ +

+ "추가" 버튼 클릭 시 지정한 컬럼에 자동으로 번호를 생성합니다. + (옵션설정 > 코드설정에서 등록한 채번 규칙 사용) +

+ + {rowNumbering?.enabled && ( +
+ {/* 대상 컬럼 선택 */} +
+ + +

+ 채번 결과가 저장될 컬럼 (수정 가능 여부는 컬럼 설정에서 조절) +

+
+ + {/* 채번 규칙 선택 */} +
+ + + {numberingRules.length === 0 && !isLoading && ( +

+ 등록된 채번 규칙이 없습니다. 옵션설정 > 코드설정에서 추가하세요. +

+ )} +
+ + {/* 설정 요약 */} + {rowNumbering.targetColumn && rowNumbering.numberingRuleId && ( +
+ "추가" 클릭 시 {rowNumbering.targetColumn} 컬럼에 자동 채번 +
+ )} +
+ )} +
+ ); +} + // 🆕 레이아웃 설정 전용 모달 function LayoutSettingsModal({ open, @@ -2040,6 +2554,78 @@ function LayoutRowConfigModal({ )}
+ {/* CRUD 설정 */} +
+ +
+
+ + onUpdateRow({ + tableCrud: { ...row.tableCrud, allowCreate: checked, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: row.tableCrud?.allowDelete || false }, + }) + } + className="scale-90" + /> + +
+
+ + onUpdateRow({ + tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: checked, allowDelete: row.tableCrud?.allowDelete || false }, + }) + } + className="scale-90" + /> + +
+
+ + onUpdateRow({ + tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: checked }, + }) + } + className="scale-90" + /> + +
+
+ {row.tableCrud?.allowDelete && ( +
+ + onUpdateRow({ + tableCrud: { ...row.tableCrud!, deleteConfirm: { enabled: checked } }, + }) + } + className="scale-75" + /> + +
+ )} +
+ + {/* 🆕 v3.13: 행 추가 시 자동 채번 설정 */} + {row.tableCrud?.allowCreate && ( + + )} + + {/* 🆕 v3.12: 연동 저장 설정 */} + + {/* 테이블 컬럼 목록 */}
@@ -2077,7 +2663,7 @@ function LayoutRowConfigModal({
-
+
{col.editable ? "예" : "아니오"}
+
+ +
+ onUpdateTableColumn(colIndex, { hidden: checked })} + className="scale-75" + /> + {col.hidden ? "예" : "아니오"} +
+
))} @@ -3188,21 +3785,21 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM
- +
{/* 현재 집계 목록 요약 */} {(localConfig.grouping?.aggregations || []).length > 0 ? (
- {(localConfig.grouping?.aggregations || []).map((agg, index) => ( + {(localConfig.grouping?.aggregations || []).map((agg, index) => (

레이아웃 행

- +
{/* 현재 레이아웃 요약 */} @@ -3324,6 +3921,7 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM aggregations={localConfig.grouping?.aggregations || []} sourceTable={localConfig.dataSource?.sourceTable || ""} allTables={allTables} + contentRows={localConfig.contentRows || []} onSave={(newAggregations) => { updateGrouping({ aggregations: newAggregations }); }} @@ -4192,7 +4790,7 @@ function ContentRowConfigSection({ checked={row.tableCrud?.allowCreate || false} onCheckedChange={(checked) => 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]" @@ -4204,7 +4802,7 @@ function ContentRowConfigSection({ checked={row.tableCrud?.allowUpdate || false} onCheckedChange={(checked) => 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]" @@ -4216,25 +4814,13 @@ function ContentRowConfigSection({ checked={row.tableCrud?.allowDelete || false} onCheckedChange={(checked) => 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]" />
-
- - 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]" - /> - -
{row.tableCrud?.allowDelete && (
@@ -4252,6 +4838,21 @@ function ContentRowConfigSection({ )}
+ {/* 🆕 v3.12: 연동 저장 설정 */} + + + {/* 🆕 v3.13: 행 추가 시 자동 채번 설정 */} + {row.tableCrud?.allowCreate && ( + + )} + {/* 테이블 컬럼 목록 */}
diff --git a/frontend/lib/registry/components/repeat-screen-modal/types.ts b/frontend/lib/registry/components/repeat-screen-modal/types.ts index 47242d5a..92def760 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/types.ts +++ b/frontend/lib/registry/components/repeat-screen-modal/types.ts @@ -188,10 +188,6 @@ export interface TableCrudConfig { allowUpdate: boolean; // 행 수정 허용 allowDelete: boolean; // 행 삭제 허용 - // 🆕 v3.5: 테이블 영역 저장 버튼 - allowSave?: boolean; // 테이블 영역에 저장 버튼 표시 - saveButtonLabel?: string; // 저장 버튼 라벨 (기본: "저장") - // 신규 행 기본값 newRowDefaults?: Record; // 기본값 (예: { status: "READY", sales_order_id: "{id}" }) @@ -203,6 +199,54 @@ export interface TableCrudConfig { // 저장 대상 테이블 (외부 데이터 소스 사용 시) 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})") + // === 🆕 v3.11: SUM_EXT 참조 테이블 제한 === + // SUM_EXT 함수가 참조할 외부 테이블 행 ID 목록 + // 비어있거나 undefined면 모든 외부 테이블 데이터 사용 (기존 동작) + // 특정 테이블만 참조하려면 contentRow의 id를 배열로 지정 + externalTableRefs?: string[]; // 참조할 테이블 행 ID 목록 (예: ["crow-1764571929625"]) + // === 공통 === resultField: string; // 결과 필드명 (예: "total_balance_qty") label: string; // 표시 라벨 (예: "총수주잔량") @@ -340,6 +390,9 @@ export interface TableColumnConfig { editable: boolean; // 편집 가능 여부 required?: boolean; // 필수 입력 여부 + // 🆕 v3.13: 숨김 설정 (화면에는 안 보이지만 데이터는 존재) + hidden?: boolean; // 숨김 여부 + // 🆕 v3.3: 컬럼 소스 테이블 지정 (조인 테이블 컬럼 사용 시) fromTable?: string; // 컬럼이 속한 테이블 (비어있으면 소스 테이블) fromJoinId?: string; // additionalJoins의 id 참조 (조인 테이블 컬럼일 때) diff --git a/frontend/lib/registry/components/text-input/TextInputComponent.tsx b/frontend/lib/registry/components/text-input/TextInputComponent.tsx index f97609a6..72dabb61 100644 --- a/frontend/lib/registry/components/text-input/TextInputComponent.tsx +++ b/frontend/lib/registry/components/text-input/TextInputComponent.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react"; import { ComponentRendererProps } from "@/types/component"; -import { AutoGenerationConfig } from "@/types/screen"; +import { AutoGenerationConfig, AutoGenerationType } from "@/types/screen"; import { TextInputConfig } from "./types"; import { filterDOMProps } from "@/lib/utils/domPropsFilter"; import { AutoGenerationUtils } from "@/lib/utils/autoGeneration"; diff --git a/frontend/lib/registry/components/text-input/TextInputConfigPanel.tsx b/frontend/lib/registry/components/text-input/TextInputConfigPanel.tsx index 69088e96..5dd67812 100644 --- a/frontend/lib/registry/components/text-input/TextInputConfigPanel.tsx +++ b/frontend/lib/registry/components/text-input/TextInputConfigPanel.tsx @@ -5,6 +5,8 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Checkbox } from "@/components/ui/checkbox"; 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 { AutoGenerationType, AutoGenerationConfig } from "@/types/screen"; import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";