diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index c970878b..08c277fc 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -561,6 +561,11 @@ class DataService { return validation.error!; } + // _relationInfo 추출 (조인 관계 업데이트용) + const relationInfo = data._relationInfo; + const cleanData = { ...data }; + delete cleanData._relationInfo; + // Primary Key 컬럼 찾기 const pkResult = await query<{ attname: string }>( `SELECT a.attname @@ -575,8 +580,8 @@ class DataService { pkColumn = pkResult[0].attname; } - const columns = Object.keys(data); - const values = Object.values(data); + const columns = Object.keys(cleanData); + const values = Object.values(cleanData); const setClause = columns .map((col, index) => `"${col}" = $${index + 1}`) .join(", "); @@ -599,6 +604,35 @@ class DataService { }; } + // 🔗 조인 관계가 있는 경우, 연결된 테이블의 FK도 업데이트 + if (relationInfo && relationInfo.rightTable && relationInfo.leftColumn && relationInfo.rightColumn) { + const { rightTable, leftColumn, rightColumn, oldLeftValue } = relationInfo; + const newLeftValue = cleanData[leftColumn]; + + // leftColumn 값이 변경된 경우에만 우측 테이블 업데이트 + if (newLeftValue !== undefined && newLeftValue !== oldLeftValue) { + console.log("🔗 조인 관계 FK 업데이트:", { + rightTable, + rightColumn, + oldValue: oldLeftValue, + newValue: newLeftValue, + }); + + try { + const updateRelatedQuery = ` + UPDATE "${rightTable}" + SET "${rightColumn}" = $1 + WHERE "${rightColumn}" = $2 + `; + const updateResult = await query(updateRelatedQuery, [newLeftValue, oldLeftValue]); + console.log(`✅ 연결된 ${rightTable} 테이블의 ${updateResult.length}개 레코드 업데이트 완료`); + } catch (relError) { + console.error("❌ 연결된 테이블 업데이트 실패:", relError); + // 연결된 테이블 업데이트 실패 시 롤백은 하지 않고 경고만 로그 + } + } + } + return { success: true, data: result[0], diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 4401bb12..6823e2d5 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -183,6 +183,15 @@ body { background: hsl(var(--background)); } +/* Button 기본 커서 스타일 */ +button { + cursor: pointer; +} + +button:disabled { + cursor: not-allowed; +} + /* ===== Dialog/Modal Overlay ===== */ /* Radix UI Dialog Overlay - 60% 불투명도 배경 */ [data-radix-dialog-overlay], diff --git a/frontend/lib/api/data.ts b/frontend/lib/api/data.ts index 3f621a3c..c1c996e0 100644 --- a/frontend/lib/api/data.ts +++ b/frontend/lib/api/data.ts @@ -94,7 +94,7 @@ export const dataApi = { */ updateRecord: async (tableName: string, id: string | number, data: Record): Promise => { const response = await apiClient.put(`/data/${tableName}/${id}`, data); - return response.data?.data || response.data; + return response.data; // success, data, message 포함된 전체 응답 반환 }, /** @@ -102,7 +102,8 @@ export const dataApi = { * @param tableName 테이블명 * @param id 레코드 ID */ - deleteRecord: async (tableName: string, id: string | number): Promise => { - await apiClient.delete(`/data/${tableName}/${id}`); + deleteRecord: async (tableName: string, id: string | number): Promise => { + const response = await apiClient.delete(`/data/${tableName}/${id}`); + return response.data; // success, message 포함된 전체 응답 반환 }, }; diff --git a/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx b/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx index f5eefe3c..8dda7864 100644 --- a/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx +++ b/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx @@ -771,7 +771,17 @@ const FileUploadComponent: React.FC = ({ return; } - console.log("🖼️ 대표 이미지 로드 시작:", file.realFileName); + // objid가 없거나 유효하지 않으면 로드 중단 + if (!file.objid || file.objid === "0" || file.objid === "") { + console.warn("⚠️ 대표 이미지 로드 실패: objid가 없음", file); + setRepresentativeImageUrl(null); + return; + } + + console.log("🖼️ 대표 이미지 로드 시작:", { + objid: file.objid, + fileName: file.realFileName, + }); // API 클라이언트를 통해 Blob으로 다운로드 (인증 토큰 포함) const response = await apiClient.get(`/files/download/${file.objid}`, { @@ -792,8 +802,12 @@ const FileUploadComponent: React.FC = ({ setRepresentativeImageUrl(url); console.log("✅ 대표 이미지 로드 성공:", url); - } catch (error) { - console.error("❌ 대표 이미지 로드 실패:", error); + } catch (error: any) { + console.error("❌ 대표 이미지 로드 실패:", { + file: file.realFileName, + objid: file.objid, + error: error?.response?.status || error?.message, + }); setRepresentativeImageUrl(null); } }, diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index c945b04f..6d198b0f 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -6,7 +6,7 @@ import { SplitPanelLayoutConfig } from "./types"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Plus, Search, GripVertical, Loader2, ChevronDown, ChevronUp, Save, ChevronRight } from "lucide-react"; +import { Plus, Search, GripVertical, Loader2, ChevronDown, ChevronUp, Save, ChevronRight, Pencil, Trash2 } from "lucide-react"; import { dataApi } from "@/lib/api/data"; import { useToast } from "@/hooks/use-toast"; import { tableTypeApi } from "@/lib/api/screen"; @@ -54,6 +54,17 @@ export const SplitPanelLayoutComponent: React.FC const [showAddModal, setShowAddModal] = useState(false); const [addModalPanel, setAddModalPanel] = useState<"left" | "right" | "left-item" | null>(null); const [addModalFormData, setAddModalFormData] = useState>({}); + + // 수정 모달 상태 + const [showEditModal, setShowEditModal] = useState(false); + const [editModalPanel, setEditModalPanel] = useState<"left" | "right" | null>(null); + const [editModalItem, setEditModalItem] = useState(null); + const [editModalFormData, setEditModalFormData] = useState>({}); + + // 삭제 확인 모달 상태 + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [deleteModalPanel, setDeleteModalPanel] = useState<"left" | "right" | null>(null); + const [deleteModalItem, setDeleteModalItem] = useState(null); // 리사이저 드래그 상태 const [isDragging, setIsDragging] = useState(false); @@ -286,6 +297,169 @@ export const SplitPanelLayoutComponent: React.FC setShowAddModal(true); }, []); + // 수정 버튼 핸들러 + const handleEditClick = useCallback((panel: "left" | "right", item: any) => { + setEditModalPanel(panel); + setEditModalItem(item); + setEditModalFormData({ ...item }); + setShowEditModal(true); + }, []); + + // 수정 모달 저장 + const handleEditModalSave = useCallback(async () => { + const tableName = editModalPanel === "left" + ? componentConfig.leftPanel?.tableName + : componentConfig.rightPanel?.tableName; + + const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || 'id'; + const primaryKey = editModalItem[sourceColumn] || editModalItem.id || editModalItem.ID; + + if (!tableName || !primaryKey) { + toast({ + title: "수정 오류", + description: "테이블명 또는 Primary Key가 없습니다.", + variant: "destructive", + }); + return; + } + + try { + console.log("📝 데이터 수정:", { tableName, primaryKey, data: editModalFormData }); + + // 프론트엔드 전용 필드 제거 (children, level 등) + const cleanData = { ...editModalFormData }; + delete cleanData.children; + delete cleanData.level; + + // 좌측 패널 수정 시, 조인 관계 정보 포함 + let updatePayload: any = cleanData; + + if (editModalPanel === "left" && componentConfig.rightPanel?.relation?.type === "join") { + // 조인 관계가 있는 경우, 관계 정보를 페이로드에 추가 + updatePayload._relationInfo = { + rightTable: componentConfig.rightPanel.tableName, + leftColumn: componentConfig.rightPanel.relation.leftColumn, + rightColumn: componentConfig.rightPanel.relation.rightColumn, + oldLeftValue: editModalItem[componentConfig.rightPanel.relation.leftColumn], + }; + console.log("🔗 조인 관계 정보 추가:", updatePayload._relationInfo); + } + + const result = await dataApi.updateRecord(tableName, primaryKey, updatePayload); + + if (result.success) { + toast({ + title: "성공", + description: "데이터가 성공적으로 수정되었습니다.", + }); + + // 모달 닫기 + setShowEditModal(false); + setEditModalFormData({}); + setEditModalItem(null); + + // 데이터 새로고침 + if (editModalPanel === "left") { + loadLeftData(); + // 우측 패널도 새로고침 (FK가 변경되었을 수 있음) + if (selectedLeftItem) { + loadRightData(selectedLeftItem); + } + } else if (editModalPanel === "right" && selectedLeftItem) { + loadRightData(selectedLeftItem); + } + } else { + toast({ + title: "수정 실패", + description: result.message || "데이터 수정에 실패했습니다.", + variant: "destructive", + }); + } + } catch (error: any) { + console.error("데이터 수정 오류:", error); + toast({ + title: "오류", + description: error?.response?.data?.message || "데이터 수정 중 오류가 발생했습니다.", + variant: "destructive", + }); + } + }, [editModalPanel, componentConfig, editModalItem, editModalFormData, toast, selectedLeftItem, loadLeftData, loadRightData]); + + // 삭제 버튼 핸들러 + const handleDeleteClick = useCallback((panel: "left" | "right", item: any) => { + setDeleteModalPanel(panel); + setDeleteModalItem(item); + setShowDeleteModal(true); + }, []); + + // 삭제 확인 + const handleDeleteConfirm = useCallback(async () => { + const tableName = deleteModalPanel === "left" + ? componentConfig.leftPanel?.tableName + : componentConfig.rightPanel?.tableName; + + const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || 'id'; + const primaryKey = deleteModalItem[sourceColumn] || deleteModalItem.id || deleteModalItem.ID; + + if (!tableName || !primaryKey) { + toast({ + title: "삭제 오류", + description: "테이블명 또는 Primary Key가 없습니다.", + variant: "destructive", + }); + return; + } + + try { + console.log("🗑️ 데이터 삭제:", { tableName, primaryKey }); + + const result = await dataApi.deleteRecord(tableName, primaryKey); + + if (result.success) { + toast({ + title: "성공", + description: "데이터가 성공적으로 삭제되었습니다.", + }); + + // 모달 닫기 + setShowDeleteModal(false); + setDeleteModalItem(null); + + // 데이터 새로고침 + if (deleteModalPanel === "left") { + loadLeftData(); + // 삭제된 항목이 선택되어 있었으면 선택 해제 + if (selectedLeftItem && selectedLeftItem[sourceColumn] === primaryKey) { + setSelectedLeftItem(null); + setRightData(null); + } + } else if (deleteModalPanel === "right" && selectedLeftItem) { + loadRightData(selectedLeftItem); + } + } else { + toast({ + title: "삭제 실패", + description: result.message || "데이터 삭제에 실패했습니다.", + variant: "destructive", + }); + } + } catch (error: any) { + console.error("데이터 삭제 오류:", error); + + // 외래키 제약조건 에러 처리 + let errorMessage = "데이터 삭제 중 오류가 발생했습니다."; + if (error?.response?.data?.error?.includes("foreign key")) { + errorMessage = "이 데이터를 참조하는 다른 데이터가 있어 삭제할 수 없습니다."; + } + + toast({ + title: "오류", + description: errorMessage, + variant: "destructive", + }); + } + }, [deleteModalPanel, componentConfig, deleteModalItem, toast, selectedLeftItem, loadLeftData, loadRightData]); + // 항목별 추가 버튼 핸들러 (좌측 항목의 + 버튼 - 하위 항목 추가) const handleItemAddClick = useCallback((item: any) => { const itemAddConfig = componentConfig.leftPanel?.itemAddConfig; @@ -527,7 +701,7 @@ export const SplitPanelLayoutComponent: React.FC {componentConfig.leftPanel?.title || "좌측 패널"} - {componentConfig.leftPanel?.showAdd && !isDesignMode && ( + {!isDesignMode && componentConfig.leftPanel?.showAdd && ( - )} + {/* 항목 내용 */} +
+
{displayTitle}
+ {displaySubtitle &&
{displaySubtitle}
} +
+ + {/* 항목별 버튼들 */} + {!isDesignMode && ( +
+ {/* 수정 버튼 */} + + + {/* 삭제 버튼 */} + + + {/* 항목별 추가 버튼 */} + {componentConfig.leftPanel?.showItemAddButton && ( + + )} +
+ )} @@ -765,15 +968,20 @@ export const SplitPanelLayoutComponent: React.FC {componentConfig.rightPanel?.title || "우측 패널"} - {componentConfig.rightPanel?.showAdd && !isDesignMode && ( - + {!isDesignMode && ( +
+ {componentConfig.rightPanel?.showAdd && ( + + )} + {/* 우측 패널 수정/삭제는 각 카드에서 처리 */} +
)} {componentConfig.rightPanel?.showSearch && ( @@ -871,13 +1079,13 @@ export const SplitPanelLayoutComponent: React.FC key={itemId} className="bg-card overflow-hidden rounded-lg border shadow-sm transition-all hover:shadow-md" > - {/* 요약 정보 (클릭 가능) */} -
toggleRightItemExpansion(itemId)} - className="cursor-pointer p-3 transition-colors hover:bg-muted" - > + {/* 요약 정보 */} +
-
+
toggleRightItemExpansion(itemId)} + > {firstValues.map(([key, value], idx) => (
{getColumnLabel(key)}
@@ -887,12 +1095,44 @@ export const SplitPanelLayoutComponent: React.FC
))}
-
- {isExpanded ? ( - - ) : ( - +
+ {/* 수정 버튼 */} + {!isDesignMode && ( + )} + {/* 삭제 버튼 */} + {!isDesignMode && ( + + )} + {/* 확장/접기 버튼 */} +
@@ -1085,6 +1325,157 @@ export const SplitPanelLayoutComponent: React.FC + + {/* 수정 모달 */} + + + + + {editModalPanel === "left" + ? `${componentConfig.leftPanel?.title} 수정` + : `${componentConfig.rightPanel?.title} 수정`} + + + 데이터를 수정합니다. 필요한 항목을 변경해주세요. + + + +
+ {editModalItem && (() => { + // 좌측 패널 수정: leftColumn만 수정 가능 + if (editModalPanel === "left") { + const leftColumn = componentConfig.rightPanel?.relation?.leftColumn; + + // leftColumn만 표시 + if (!leftColumn || editModalFormData[leftColumn] === undefined) { + return

수정 가능한 컬럼이 없습니다.

; + } + + return ( +
+ + { + setEditModalFormData(prev => ({ + ...prev, + [leftColumn]: e.target.value + })); + }} + placeholder={`${leftColumn} 입력`} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ ); + } + + // 우측 패널 수정: 우측 패널에 설정된 표시 컬럼들만 + if (editModalPanel === "right") { + const rightColumns = componentConfig.rightPanel?.columns; + + if (rightColumns && rightColumns.length > 0) { + // 설정된 컬럼만 표시 + return rightColumns.map((col) => ( +
+ + { + setEditModalFormData(prev => ({ + ...prev, + [col.name]: e.target.value + })); + }} + placeholder={`${col.label || col.name} 입력`} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ )); + } else { + // 설정이 없으면 모든 컬럼 표시 (company_code, company_name 제외) + return Object.entries(editModalFormData) + .filter(([key]) => key !== 'company_code' && key !== 'company_name') + .map(([key, value]) => ( +
+ + { + setEditModalFormData(prev => ({ + ...prev, + [key]: e.target.value + })); + }} + placeholder={`${key} 입력`} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ )); + } + } + + return null; + })()} +
+ + + + + +
+
+ + {/* 삭제 확인 모달 */} + + + + 삭제 확인 + + 정말로 이 데이터를 삭제하시겠습니까? +
이 작업은 되돌릴 수 없습니다. +
+
+ + + + + +
+
); }; diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx index fe513550..1f3430ec 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx @@ -363,6 +363,22 @@ export const SplitPanelLayoutConfigPanel: React.FC
+
+ + updateLeftPanel({ showEdit: checked })} + /> +
+ +
+ + updateLeftPanel({ showDelete: checked })} + /> +
+
+
+ + updateRightPanel({ showEdit: checked })} + /> +
+ +
+ + updateRightPanel({ showDelete: checked })} + /> +
+ {/* 우측 패널 표시 컬럼 설정 */}
diff --git a/frontend/lib/registry/components/split-panel-layout/types.ts b/frontend/lib/registry/components/split-panel-layout/types.ts index 6e1a4d7f..81bef798 100644 --- a/frontend/lib/registry/components/split-panel-layout/types.ts +++ b/frontend/lib/registry/components/split-panel-layout/types.ts @@ -10,6 +10,8 @@ export interface SplitPanelLayoutConfig { dataSource?: string; // API 엔드포인트 showSearch?: boolean; showAdd?: boolean; + showEdit?: boolean; // 수정 버튼 + showDelete?: boolean; // 삭제 버튼 columns?: Array<{ name: string; label: string; @@ -45,6 +47,8 @@ export interface SplitPanelLayoutConfig { dataSource?: string; showSearch?: boolean; showAdd?: boolean; + showEdit?: boolean; // 수정 버튼 + showDelete?: boolean; // 삭제 버튼 columns?: Array<{ name: string; label: string;