diff --git a/backend-node/src/routes/dataRoutes.ts b/backend-node/src/routes/dataRoutes.ts index 344eb1a9..7d1f0a88 100644 --- a/backend-node/src/routes/dataRoutes.ts +++ b/backend-node/src/routes/dataRoutes.ts @@ -459,6 +459,58 @@ router.put( * 레코드 삭제 API * DELETE /api/data/{tableName}/{id} */ +/** + * 복합키 레코드 삭제 API (POST) + * POST /api/data/:tableName/delete + * Body: { user_id: 'xxx', dept_code: 'yyy' } + */ +router.post( + "/:tableName/delete", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { tableName } = req.params; + const compositeKey = req.body; + + // 입력값 검증 + if (!tableName || typeof tableName !== "string") { + return res.status(400).json({ + success: false, + message: "테이블명이 필요합니다.", + error: "INVALID_TABLE_NAME", + }); + } + + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 테이블명입니다.", + error: "INVALID_TABLE_NAME", + }); + } + + console.log(`🗑️ 복합키 레코드 삭제: ${tableName}`, compositeKey); + + // 레코드 삭제 (복합키 객체 전달) + const result = await dataService.deleteRecord(tableName, compositeKey); + + if (!result.success) { + return res.status(400).json(result); + } + + console.log(`✅ 레코드 삭제 성공: ${tableName}`); + return res.json(result); + } catch (error: any) { + console.error(`레코드 삭제 오류 (${req.params.tableName}):`, error); + return res.status(500).json({ + success: false, + message: "레코드 삭제 중 오류가 발생했습니다.", + error: error.message, + }); + } + } +); + router.delete( "/:tableName/:id", authenticateToken, diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index 08c277fc..0cf7ad6b 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -652,7 +652,7 @@ class DataService { */ async deleteRecord( tableName: string, - id: string | number + id: string | number | Record ): Promise> { try { // 테이블 접근 검증 @@ -661,28 +661,53 @@ class DataService { return validation.error!; } - // Primary Key 컬럼 찾기 + // Primary Key 컬럼 찾기 (복합키 지원) const pkResult = await query<{ attname: string }>( `SELECT a.attname FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) - WHERE i.indrelid = $1::regclass AND i.indisprimary`, + WHERE i.indrelid = $1::regclass AND i.indisprimary + ORDER BY a.attnum`, [tableName] ); - let pkColumn = "id"; - if (pkResult.length > 0) { - pkColumn = pkResult[0].attname; + let whereClauses: string[] = []; + let params: any[] = []; + + if (pkResult.length > 1) { + // 복합키인 경우: id가 객체여야 함 + console.log(`🔑 복합키 테이블: ${tableName}, PK: [${pkResult.map(r => r.attname).join(', ')}]`); + + if (typeof id === 'object' && !Array.isArray(id)) { + // id가 객체인 경우: { user_id: 'xxx', dept_code: 'yyy' } + pkResult.forEach((pk, index) => { + whereClauses.push(`"${pk.attname}" = $${index + 1}`); + params.push(id[pk.attname]); + }); + } else { + // id가 문자열/숫자인 경우: 첫 번째 PK만 사용 (하위 호환성) + whereClauses.push(`"${pkResult[0].attname}" = $1`); + params.push(id); + } + } else { + // 단일키인 경우 + const pkColumn = pkResult.length > 0 ? pkResult[0].attname : "id"; + whereClauses.push(`"${pkColumn}" = $1`); + params.push(typeof id === 'object' ? id[pkColumn] : id); } - const queryText = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`; - await query(queryText, [id]); + const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(' AND ')}`; + console.log(`🗑️ 삭제 쿼리:`, queryText, params); + + const result = await query(queryText, params); + + console.log(`✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}`); return { success: true, }; } catch (error) { - console.error(`레코드 삭제 오류 (${tableName}/${id}):`, error); + console.error(`레코드 삭제 오류 (${tableName}):`, error); return { success: false, message: "레코드 삭제 중 오류가 발생했습니다.", diff --git a/frontend/lib/api/data.ts b/frontend/lib/api/data.ts index c1c996e0..208308ff 100644 --- a/frontend/lib/api/data.ts +++ b/frontend/lib/api/data.ts @@ -100,9 +100,16 @@ export const dataApi = { /** * 레코드 삭제 * @param tableName 테이블명 - * @param id 레코드 ID + * @param id 레코드 ID 또는 복합키 객체 */ - deleteRecord: async (tableName: string, id: string | number): Promise => { + deleteRecord: async (tableName: string, id: string | number | Record): Promise => { + // 복합키 객체인 경우 POST로 전달 + if (typeof id === 'object' && !Array.isArray(id)) { + const response = await apiClient.post(`/data/${tableName}/delete`, id); + return response.data; + } + + // 단일 ID인 경우 기존 방식 const response = await apiClient.delete(`/data/${tableName}/${id}`); return response.data; // success, message 포함된 전체 응답 반환 }, diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index 6d198b0f..dff4ee3a 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -394,12 +394,25 @@ export const SplitPanelLayoutComponent: React.FC // 삭제 확인 const handleDeleteConfirm = useCallback(async () => { - const tableName = deleteModalPanel === "left" + // 우측 패널 삭제 시 중계 테이블 확인 + let tableName = deleteModalPanel === "left" ? componentConfig.leftPanel?.tableName : componentConfig.rightPanel?.tableName; + // 우측 패널 + 중계 테이블 모드인 경우 + if (deleteModalPanel === "right" && componentConfig.rightPanel?.addConfig?.targetTable) { + tableName = componentConfig.rightPanel.addConfig.targetTable; + console.log("🔗 중계 테이블 모드: 삭제 대상 테이블 =", tableName); + } + const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || 'id'; - const primaryKey = deleteModalItem[sourceColumn] || deleteModalItem.id || deleteModalItem.ID; + let primaryKey: any = deleteModalItem[sourceColumn] || deleteModalItem.id || deleteModalItem.ID; + + // 복합키 처리: deleteModalItem 전체를 전달 (백엔드에서 복합키 자동 처리) + if (deleteModalItem && typeof deleteModalItem === 'object') { + primaryKey = deleteModalItem; + console.log("🔑 복합키 가능성: 전체 객체 전달", primaryKey); + } if (!tableName || !primaryKey) { toast({ @@ -507,13 +520,39 @@ export const SplitPanelLayoutComponent: React.FC // 테이블명과 모달 컬럼 결정 let tableName: string | undefined; let modalColumns: Array<{ name: string; label: string; required?: boolean }> | undefined; + let finalData = { ...addModalFormData }; if (addModalPanel === "left") { tableName = componentConfig.leftPanel?.tableName; modalColumns = componentConfig.leftPanel?.addModalColumns; } else if (addModalPanel === "right") { - tableName = componentConfig.rightPanel?.tableName; - modalColumns = componentConfig.rightPanel?.addModalColumns; + // 우측 패널: 중계 테이블 설정이 있는지 확인 + const addConfig = componentConfig.rightPanel?.addConfig; + + if (addConfig?.targetTable) { + // 중계 테이블 모드 + tableName = addConfig.targetTable; + modalColumns = componentConfig.rightPanel?.addModalColumns; + + // 좌측 패널에서 선택된 값 자동 채우기 + if (addConfig.leftPanelColumn && addConfig.targetColumn && selectedLeftItem) { + const leftValue = selectedLeftItem[addConfig.leftPanelColumn]; + finalData[addConfig.targetColumn] = leftValue; + console.log(`🔗 좌측 패널 값 자동 채움: ${addConfig.targetColumn} = ${leftValue}`); + } + + // 자동 채움 컬럼 추가 + if (addConfig.autoFillColumns) { + Object.entries(addConfig.autoFillColumns).forEach(([key, value]) => { + finalData[key] = value; + }); + console.log("🔧 자동 채움 컬럼:", addConfig.autoFillColumns); + } + } else { + // 일반 테이블 모드 + tableName = componentConfig.rightPanel?.tableName; + modalColumns = componentConfig.rightPanel?.addModalColumns; + } } else if (addModalPanel === "left-item") { // 하위 항목 추가 (좌측 테이블에 추가) tableName = componentConfig.leftPanel?.tableName; @@ -543,9 +582,9 @@ export const SplitPanelLayoutComponent: React.FC } try { - console.log("📝 데이터 추가:", { tableName, data: addModalFormData }); + console.log("📝 데이터 추가:", { tableName, data: finalData }); - const result = await dataApi.createRecord(tableName, addModalFormData); + const result = await dataApi.createRecord(tableName, finalData); if (result.success) { toast({ diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx index fe513550..bce30f8f 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx @@ -1258,6 +1258,103 @@ export const SplitPanelLayoutConfigPanel: React.FC + + {/* 중계 테이블 설정 */} +
+ +

+ 중계 테이블을 사용하여 다대다 관계를 구현합니다 +

+ +
+ + { + const addConfig = config.rightPanel?.addConfig || {}; + updateRightPanel({ + addConfig: { + ...addConfig, + targetTable: e.target.value, + }, + }); + }} + placeholder="예: user_dept" + className="mt-1 h-8 text-xs" + /> +

+ 데이터가 실제로 저장될 중계 테이블명 +

+
+ +
+ + { + const addConfig = config.rightPanel?.addConfig || {}; + updateRightPanel({ + addConfig: { + ...addConfig, + leftPanelColumn: e.target.value, + }, + }); + }} + placeholder="예: dept_code" + className="mt-1 h-8 text-xs" + /> +

+ 좌측 패널에서 선택한 항목의 어떤 컬럼값을 가져올지 +

+
+ +
+ + { + const addConfig = config.rightPanel?.addConfig || {}; + updateRightPanel({ + addConfig: { + ...addConfig, + targetColumn: e.target.value, + }, + }); + }} + placeholder="예: dept_code" + className="mt-1 h-8 text-xs" + /> +

+ 중계 테이블의 어떤 컬럼에 좌측값을 저장할지 +

+
+ +
+ +