우측 패널 항목 삭제 기능 구현

This commit is contained in:
dohyeons 2025-11-07 18:20:24 +09:00
parent 3009d1eecc
commit 68577a09f9
6 changed files with 246 additions and 17 deletions

View File

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

View File

@ -652,7 +652,7 @@ class DataService {
*/
async deleteRecord(
tableName: string,
id: string | number
id: string | number | Record<string, any>
): Promise<ServiceResponse<void>> {
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<any>(queryText, [id]);
const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(' AND ')}`;
console.log(`🗑️ 삭제 쿼리:`, queryText, params);
const result = await query<any>(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: "레코드 삭제 중 오류가 발생했습니다.",

View File

@ -100,9 +100,16 @@ export const dataApi = {
/**
*
* @param tableName
* @param id ID
* @param id ID
*/
deleteRecord: async (tableName: string, id: string | number): Promise<any> => {
deleteRecord: async (tableName: string, id: string | number | Record<string, any>): Promise<any> => {
// 복합키 객체인 경우 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 포함된 전체 응답 반환
},

View File

@ -394,12 +394,25 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 삭제 확인
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<SplitPanelLayoutComponentProps>
// 테이블명과 모달 컬럼 결정
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<SplitPanelLayoutComponentProps>
}
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({

View File

@ -1258,6 +1258,103 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
})
)}
</div>
{/* 중계 테이블 설정 */}
<div className="space-y-3 rounded-lg border border-orange-200 bg-orange-50 p-3 mt-3">
<Label className="text-sm font-semibold"> (N:M )</Label>
<p className="text-xs text-gray-600">
</p>
<div>
<Label className="text-xs text-gray-700"> </Label>
<Input
value={config.rightPanel?.addConfig?.targetTable || ""}
onChange={(e) => {
const addConfig = config.rightPanel?.addConfig || {};
updateRightPanel({
addConfig: {
...addConfig,
targetTable: e.target.value,
},
});
}}
placeholder="예: user_dept"
className="mt-1 h-8 text-xs"
/>
<p className="mt-1 text-[10px] text-gray-500">
</p>
</div>
<div>
<Label className="text-xs text-gray-700"> </Label>
<Input
value={config.rightPanel?.addConfig?.leftPanelColumn || ""}
onChange={(e) => {
const addConfig = config.rightPanel?.addConfig || {};
updateRightPanel({
addConfig: {
...addConfig,
leftPanelColumn: e.target.value,
},
});
}}
placeholder="예: dept_code"
className="mt-1 h-8 text-xs"
/>
<p className="mt-1 text-[10px] text-gray-500">
</p>
</div>
<div>
<Label className="text-xs text-gray-700"> </Label>
<Input
value={config.rightPanel?.addConfig?.targetColumn || ""}
onChange={(e) => {
const addConfig = config.rightPanel?.addConfig || {};
updateRightPanel({
addConfig: {
...addConfig,
targetColumn: e.target.value,
},
});
}}
placeholder="예: dept_code"
className="mt-1 h-8 text-xs"
/>
<p className="mt-1 text-[10px] text-gray-500">
</p>
</div>
<div>
<Label className="text-xs text-gray-700"> (JSON)</Label>
<textarea
value={JSON.stringify(config.rightPanel?.addConfig?.autoFillColumns || {}, null, 2)}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value);
const addConfig = config.rightPanel?.addConfig || {};
updateRightPanel({
addConfig: {
...addConfig,
autoFillColumns: parsed,
},
});
} catch (err) {
// JSON 파싱 오류는 무시 (입력 중)
}
}}
placeholder='{ "is_primary": false }'
className="mt-1 h-20 w-full rounded-md border border-input bg-white px-3 py-2 text-xs font-mono"
/>
<p className="mt-1 text-[10px] text-gray-500">
(: is_primary: false)
</p>
</div>
</div>
</div>
)}
</div>

View File

@ -65,8 +65,17 @@ export interface SplitPanelLayoutConfig {
relation?: {
type: "join" | "detail"; // 관계 타입
leftColumn?: string; // 좌측 테이블의 연결 컬럼
rightColumn?: string; // 우측 테이블의 연결 컬럼 (join용)
foreignKey?: string; // 우측 테이블의 외래키 컬럼명
};
// 우측 패널 추가 시 중계 테이블 설정 (N:M 관계)
addConfig?: {
targetTable?: string; // 실제로 INSERT할 테이블 (중계 테이블)
autoFillColumns?: Record<string, any>; // 자동으로 채워질 컬럼과 기본값
leftPanelColumn?: string; // 좌측 패널의 어떤 컬럼값을 가져올지
targetColumn?: string; // targetTable의 어떤 컬럼에 넣을지
};
};
// 레이아웃 설정