diff --git a/backend-node/src/routes/dataRoutes.ts b/backend-node/src/routes/dataRoutes.ts index fb0f1518..7d1f0a88 100644 --- a/backend-node/src/routes/dataRoutes.ts +++ b/backend-node/src/routes/dataRoutes.ts @@ -51,21 +51,26 @@ router.get( } } + // 회사 코드 추출 (멀티테넌시 필터링) + const userCompany = req.user?.companyCode; + console.log(`🔗 조인 데이터 조회:`, { leftTable, rightTable, leftColumn, rightColumn, leftValue, + userCompany, }); - // 조인 데이터 조회 + // 조인 데이터 조회 (회사 코드 전달) const result = await dataService.getJoinedData( leftTable as string, rightTable as string, leftColumn as string, rightColumn as string, - leftValue as string + leftValue as string, + userCompany ); if (!result.success) { @@ -352,8 +357,25 @@ router.post( console.log(`➕ 레코드 생성: ${tableName}`, data); + // company_code와 company_name 자동 추가 (멀티테넌시) + const enrichedData = { ...data }; + + // 테이블에 company_code 컬럼이 있는지 확인하고 자동으로 추가 + const hasCompanyCode = await dataService.checkColumnExists(tableName, "company_code"); + if (hasCompanyCode && req.user?.companyCode) { + enrichedData.company_code = req.user.companyCode; + console.log(`🏢 company_code 자동 추가: ${req.user.companyCode}`); + } + + // 테이블에 company_name 컬럼이 있는지 확인하고 자동으로 추가 + const hasCompanyName = await dataService.checkColumnExists(tableName, "company_name"); + if (hasCompanyName && req.user?.companyName) { + enrichedData.company_name = req.user.companyName; + console.log(`🏢 company_name 자동 추가: ${req.user.companyName}`); + } + // 레코드 생성 - const result = await dataService.createRecord(tableName, data); + const result = await dataService.createRecord(tableName, enrichedData); if (!result.success) { return res.status(400).json(result); @@ -437,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/authService.ts b/backend-node/src/services/authService.ts index 7c4f4c8d..11e34576 100644 --- a/backend-node/src/services/authService.ts +++ b/backend-node/src/services/authService.ts @@ -165,12 +165,13 @@ export class AuthService { const authNames = authResult.map((row) => row.auth_name).join(","); // 3. 회사 정보 조회 (Raw Query 전환) - // Note: 현재 회사 정보는 PersonBean에 직접 사용되지 않지만 향후 확장을 위해 유지 const companyResult = await query<{ company_name: string }>( "SELECT company_name FROM company_mng WHERE company_code = $1", [userInfo.company_code || "ILSHIN"] ); + const companyName = companyResult.length > 0 ? companyResult[0].company_name : undefined; + // DB에서 조회한 원본 사용자 정보 상세 로그 //console.log("🔍 AuthService - DB 원본 사용자 정보:", { // userId: userInfo.user_id, @@ -205,6 +206,7 @@ export class AuthService { partnerObjid: userInfo.partner_objid || undefined, authName: authNames || undefined, companyCode: companyCode, + companyName: companyName, // 회사명 추가 photo: userInfo.photo ? `data:image/jpeg;base64,${Buffer.from(userInfo.photo).toString("base64")}` : undefined, diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index 462ebb4d..0cf7ad6b 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -231,6 +231,9 @@ class DataService { const columns = await this.getTableColumnsSimple(tableName); + // PK 컬럼 정보 조회 + const pkColumns = await this.getPrimaryKeyColumns(tableName); + // 컬럼 라벨 정보 추가 const columnsWithLabels = await Promise.all( columns.map(async (column) => { @@ -244,6 +247,7 @@ class DataService { dataType: column.data_type, isNullable: column.is_nullable === "YES", defaultValue: column.column_default, + isPrimaryKey: pkColumns.includes(column.column_name), // PK 여부 추가 }; }) ); @@ -262,6 +266,26 @@ class DataService { } } + /** + * 테이블의 Primary Key 컬럼 목록 조회 + */ + private async getPrimaryKeyColumns(tableName: string): Promise { + try { + const result = 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`, + [tableName] + ); + + return result.map((row) => row.attname); + } catch (error) { + console.error(`PK 컬럼 조회 오류 (${tableName}):`, error); + return []; + } + } + /** * 테이블 존재 여부 확인 */ @@ -286,7 +310,7 @@ class DataService { /** * 특정 컬럼 존재 여부 확인 */ - private async checkColumnExists( + async checkColumnExists( tableName: string, columnName: string ): Promise { @@ -409,7 +433,8 @@ class DataService { rightTable: string, leftColumn: string, rightColumn: string, - leftValue?: string | number + leftValue?: string | number, + userCompany?: string ): Promise> { try { // 왼쪽 테이블 접근 검증 @@ -425,18 +450,42 @@ class DataService { } let queryText = ` - SELECT r.* + SELECT DISTINCT r.* FROM "${rightTable}" r INNER JOIN "${leftTable}" l ON l."${leftColumn}" = r."${rightColumn}" `; const values: any[] = []; + const whereConditions: string[] = []; + let paramIndex = 1; + + // 좌측 값 필터링 if (leftValue !== undefined && leftValue !== null) { - queryText += ` WHERE l."${leftColumn}" = $1`; + whereConditions.push(`l."${leftColumn}" = $${paramIndex}`); values.push(leftValue); + paramIndex++; } + // 우측 테이블 회사별 필터링 (company_code 컬럼이 있는 경우) + if (userCompany && userCompany !== "*") { + const hasCompanyCode = await this.checkColumnExists(rightTable, "company_code"); + if (hasCompanyCode) { + whereConditions.push(`r.company_code = $${paramIndex}`); + values.push(userCompany); + paramIndex++; + console.log(`🏢 우측 패널 회사별 필터링 적용: ${rightTable}.company_code = ${userCompany}`); + } + } + + // WHERE 절 추가 + if (whereConditions.length > 0) { + queryText += ` WHERE ${whereConditions.join(" AND ")}`; + } + + console.log("🔍 조인 쿼리 실행:", queryText); + console.log("📊 조인 쿼리 파라미터:", values); + const result = await query(queryText, values); return { @@ -512,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 @@ -526,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(", "); @@ -550,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], @@ -569,7 +652,7 @@ class DataService { */ async deleteRecord( tableName: string, - id: string | number + id: string | number | Record ): Promise> { try { // 테이블 접근 검증 @@ -578,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/backend-node/src/types/auth.ts b/backend-node/src/types/auth.ts index 35a2c0f5..6abd1e39 100644 --- a/backend-node/src/types/auth.ts +++ b/backend-node/src/types/auth.ts @@ -61,6 +61,7 @@ export interface PersonBean { partnerObjid?: string; authName?: string; companyCode?: string; + companyName?: string; // 회사명 추가 photo?: string; locale?: string; // 권한 레벨 정보 (3단계 체계) @@ -94,6 +95,7 @@ export interface JwtPayload { userName: string; deptName?: string; companyCode?: string; + companyName?: string; // 회사명 추가 userType?: string; userTypeName?: string; iat?: number; diff --git a/backend-node/src/utils/jwtUtils.ts b/backend-node/src/utils/jwtUtils.ts index f65781fc..44f75cbc 100644 --- a/backend-node/src/utils/jwtUtils.ts +++ b/backend-node/src/utils/jwtUtils.ts @@ -17,6 +17,7 @@ export class JwtUtils { userName: userInfo.userName, deptName: userInfo.deptName, companyCode: userInfo.companyCode, + companyName: userInfo.companyName, // 회사명 추가 userType: userInfo.userType, userTypeName: userInfo.userTypeName, }; @@ -45,6 +46,7 @@ export class JwtUtils { userName: decoded.userName, deptName: decoded.deptName, companyCode: decoded.companyCode, + companyName: decoded.companyName, // 회사명 추가 userType: decoded.userType, userTypeName: decoded.userTypeName, }; 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 3f53db1f..208308ff 100644 --- a/frontend/lib/api/data.ts +++ b/frontend/lib/api/data.ts @@ -83,7 +83,7 @@ export const dataApi = { */ createRecord: async (tableName: string, data: Record): Promise => { const response = await apiClient.post(`/data/${tableName}`, data); - return response.data?.data || response.data; + return response.data; // success, data, message 포함된 전체 응답 반환 }, /** @@ -94,15 +94,23 @@ 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 포함된 전체 응답 반환 }, /** * 레코드 삭제 * @param tableName 테이블명 - * @param id 레코드 ID + * @param id 레코드 ID 또는 복합키 객체 */ - deleteRecord: async (tableName: string, id: string | number): Promise => { - await apiClient.delete(`/data/${tableName}/${id}`); + 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/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 3da7ce27..dff4ee3a 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -6,10 +6,12 @@ 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 } 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"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; export interface SplitPanelLayoutComponentProps extends ComponentRendererProps { // 추가 props @@ -45,8 +47,25 @@ export const SplitPanelLayoutComponent: React.FC const [isLoadingLeft, setIsLoadingLeft] = useState(false); const [isLoadingRight, setIsLoadingRight] = useState(false); const [rightTableColumns, setRightTableColumns] = useState([]); // 우측 테이블 컬럼 정보 + const [expandedItems, setExpandedItems] = useState>(new Set()); // 펼쳐진 항목들 const { toast } = useToast(); + // 추가 모달 상태 + 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); const [leftWidth, setLeftWidth] = useState(splitRatio); @@ -81,6 +100,53 @@ export const SplitPanelLayoutComponent: React.FC border: isSelected ? "2px solid #3b82f6" : "1px solid #e5e7eb", }; + // 계층 구조 빌드 함수 (트리 구조 유지) + const buildHierarchy = useCallback((items: any[]): any[] => { + if (!items || items.length === 0) return []; + + const itemAddConfig = componentConfig.leftPanel?.itemAddConfig; + if (!itemAddConfig) return items.map(item => ({ ...item, children: [] })); // 계층 설정이 없으면 평면 목록 + + const { sourceColumn, parentColumn } = itemAddConfig; + if (!sourceColumn || !parentColumn) return items.map(item => ({ ...item, children: [] })); + + // ID를 키로 하는 맵 생성 + const itemMap = new Map(); + const rootItems: any[] = []; + + // 모든 항목을 맵에 추가하고 children 배열 초기화 + items.forEach(item => { + const id = item[sourceColumn]; + itemMap.set(id, { ...item, children: [], level: 0 }); + }); + + // 부모-자식 관계 설정 + items.forEach(item => { + const id = item[sourceColumn]; + const parentId = item[parentColumn]; + const currentItem = itemMap.get(id); + + if (!currentItem) return; + + if (!parentId || parentId === null || parentId === '') { + // 최상위 항목 + rootItems.push(currentItem); + } else { + // 부모가 있는 항목 + const parentItem = itemMap.get(parentId); + if (parentItem) { + currentItem.level = parentItem.level + 1; + parentItem.children.push(currentItem); + } else { + // 부모를 찾을 수 없으면 최상위로 처리 + rootItems.push(currentItem); + } + } + }); + + return rootItems; + }, [componentConfig.leftPanel?.itemAddConfig]); + // 좌측 데이터 로드 const loadLeftData = useCallback(async () => { const leftTableName = componentConfig.leftPanel?.tableName; @@ -93,7 +159,10 @@ export const SplitPanelLayoutComponent: React.FC size: 100, // searchTerm 제거 - 클라이언트 사이드에서 필터링 }); - setLeftData(result.data); + + // 계층 구조 빌드 + const hierarchicalData = buildHierarchy(result.data); + setLeftData(hierarchicalData); } catch (error) { console.error("좌측 데이터 로드 실패:", error); toast({ @@ -104,7 +173,7 @@ export const SplitPanelLayoutComponent: React.FC } finally { setIsLoadingLeft(false); } - }, [componentConfig.leftPanel?.tableName, isDesignMode, toast]); + }, [componentConfig.leftPanel?.tableName, isDesignMode, toast, buildHierarchy]); // 우측 데이터 로드 const loadRightData = useCallback( @@ -208,6 +277,382 @@ export const SplitPanelLayoutComponent: React.FC loadRightTableColumns(); }, [componentConfig.rightPanel?.tableName, isDesignMode]); + // 항목 펼치기/접기 토글 + const toggleExpand = useCallback((itemId: any) => { + setExpandedItems(prev => { + const newSet = new Set(prev); + if (newSet.has(itemId)) { + newSet.delete(itemId); + } else { + newSet.add(itemId); + } + return newSet; + }); + }, []); + + // 추가 버튼 핸들러 + const handleAddClick = useCallback((panel: "left" | "right") => { + setAddModalPanel(panel); + setAddModalFormData({}); + 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 () => { + // 우측 패널 삭제 시 중계 테이블 확인 + 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'; + let primaryKey: any = deleteModalItem[sourceColumn] || deleteModalItem.id || deleteModalItem.ID; + + // 복합키 처리: deleteModalItem 전체를 전달 (백엔드에서 복합키 자동 처리) + if (deleteModalItem && typeof deleteModalItem === 'object') { + primaryKey = deleteModalItem; + console.log("🔑 복합키 가능성: 전체 객체 전달", primaryKey); + } + + 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; + + if (!itemAddConfig) { + toast({ + title: "설정 오류", + description: "하위 항목 추가 설정이 없습니다.", + variant: "destructive", + }); + return; + } + + const { sourceColumn, parentColumn } = itemAddConfig; + + if (!sourceColumn || !parentColumn) { + toast({ + title: "설정 오류", + description: "현재 항목 ID 컬럼과 상위 항목 저장 컬럼을 설정해주세요.", + variant: "destructive", + }); + return; + } + + // 선택된 항목의 sourceColumn 값을 가져와서 parentColumn에 매핑 + const sourceValue = item[sourceColumn]; + + if (!sourceValue) { + toast({ + title: "데이터 오류", + description: `선택한 항목의 ${sourceColumn} 값이 없습니다.`, + variant: "destructive", + }); + return; + } + + // 좌측 패널 추가 모달 열기 (parentColumn 값 미리 채우기) + setAddModalPanel("left-item"); + setAddModalFormData({ [parentColumn]: sourceValue }); + setShowAddModal(true); + }, [componentConfig, toast]); + + // 추가 모달 저장 + const handleAddModalSave = useCallback(async () => { + // 테이블명과 모달 컬럼 결정 + 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") { + // 우측 패널: 중계 테이블 설정이 있는지 확인 + 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; + modalColumns = componentConfig.leftPanel?.itemAddConfig?.addModalColumns; + } + + if (!tableName) { + toast({ + title: "테이블 오류", + description: "테이블명이 설정되지 않았습니다.", + variant: "destructive", + }); + return; + } + + // 필수 필드 검증 + const requiredFields = (modalColumns || []).filter(col => col.required); + for (const field of requiredFields) { + if (!addModalFormData[field.name]) { + toast({ + title: "입력 오류", + description: `${field.label}은(는) 필수 입력 항목입니다.`, + variant: "destructive", + }); + return; + } + } + + try { + console.log("📝 데이터 추가:", { tableName, data: finalData }); + + const result = await dataApi.createRecord(tableName, finalData); + + if (result.success) { + toast({ + title: "성공", + description: "데이터가 성공적으로 추가되었습니다.", + }); + + // 모달 닫기 + setShowAddModal(false); + setAddModalFormData({}); + + // 데이터 새로고침 + if (addModalPanel === "left" || addModalPanel === "left-item") { + // 좌측 패널 데이터 새로고침 (일반 추가 또는 하위 항목 추가) + loadLeftData(); + } else if (addModalPanel === "right" && selectedLeftItem) { + // 우측 패널 데이터 새로고침 + loadRightData(selectedLeftItem); + } + } else { + toast({ + title: "저장 실패", + description: result.message || "데이터 추가에 실패했습니다.", + variant: "destructive", + }); + } + } catch (error: any) { + console.error("데이터 추가 오류:", error); + + // 에러 메시지 추출 + let errorMessage = "데이터 추가 중 오류가 발생했습니다."; + + if (error?.response?.data) { + const responseData = error.response.data; + + // 백엔드에서 반환한 에러 메시지 확인 + if (responseData.error) { + // 중복 키 에러 처리 + if (responseData.error.includes("duplicate key")) { + errorMessage = "이미 존재하는 값입니다. 다른 값을 입력해주세요."; + } + // NOT NULL 제약조건 에러 + else if (responseData.error.includes("null value")) { + const match = responseData.error.match(/column "(\w+)"/); + const columnName = match ? match[1] : "필수"; + errorMessage = `${columnName} 필드는 필수 입력 항목입니다.`; + } + // 외래키 제약조건 에러 + else if (responseData.error.includes("foreign key")) { + errorMessage = "참조하는 데이터가 존재하지 않습니다."; + } + // 기타 에러 + else { + errorMessage = responseData.message || responseData.error; + } + } else if (responseData.message) { + errorMessage = responseData.message; + } + } + + toast({ + title: "오류", + description: errorMessage, + variant: "destructive", + }); + } + }, [addModalPanel, componentConfig, addModalFormData, toast, selectedLeftItem, loadLeftData, loadRightData]); + // 초기 데이터 로드 useEffect(() => { if (!isDesignMode && componentConfig.autoLoad !== false) { @@ -295,8 +740,12 @@ export const SplitPanelLayoutComponent: React.FC {componentConfig.leftPanel?.title || "좌측 패널"} - {componentConfig.leftPanel?.showAdd && ( - @@ -367,30 +816,156 @@ export const SplitPanelLayoutComponent: React.FC }) : leftData; - return filteredLeftData.length > 0 ? ( - // 실제 데이터 표시 - filteredLeftData.map((item, index) => { - const itemId = item.id || item.ID || item[Object.keys(item)[0]] || index; - const isSelected = - selectedLeftItem && (selectedLeftItem.id === itemId || selectedLeftItem === item); - // 첫 번째 2-3개 필드를 표시 - const keys = Object.keys(item).filter((k) => k !== "id" && k !== "ID"); - const displayTitle = item[keys[0]] || item.name || item.title || `항목 ${index + 1}`; - const displaySubtitle = keys[1] ? item[keys[1]] : null; + // 재귀 렌더링 함수 + const renderTreeItem = (item: any, index: number): React.ReactNode => { + const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || 'id'; + const itemId = item[sourceColumn] || item.id || item.ID || index; + const isSelected = selectedLeftItem && (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item); + const hasChildren = item.children && item.children.length > 0; + const isExpanded = expandedItems.has(itemId); + const level = item.level || 0; - return ( + // 조인에 사용하는 leftColumn을 필수로 표시 + const leftColumn = componentConfig.rightPanel?.relation?.leftColumn; + let displayFields: { label: string; value: any }[] = []; + + // 디버그 로그 + if (index === 0) { + console.log("🔍 좌측 패널 표시 로직:"); + console.log(" - leftColumn (조인 키):", leftColumn); + console.log(" - item keys:", Object.keys(item)); + } + + if (leftColumn) { + // 조인 모드: leftColumn 값을 첫 번째로 표시 (필수) + displayFields.push({ + label: leftColumn, + value: item[leftColumn], + }); + + // 추가로 다른 의미있는 필드 1-2개 표시 (name, title 등) + const additionalKeys = Object.keys(item).filter( + (k) => k !== "id" && k !== "ID" && k !== leftColumn && + (k.includes("name") || k.includes("title") || k.includes("desc")) + ); + + if (additionalKeys.length > 0) { + displayFields.push({ + label: additionalKeys[0], + value: item[additionalKeys[0]], + }); + } + + if (index === 0) { + console.log(" ✅ 조인 키 기반 표시:", displayFields); + } + } else { + // 상세 모드 또는 설정 없음: 자동으로 첫 2개 필드 표시 + const keys = Object.keys(item).filter((k) => k !== "id" && k !== "ID"); + displayFields = keys.slice(0, 2).map((key) => ({ + label: key, + value: item[key], + })); + + if (index === 0) { + console.log(" ⚠️ 조인 키 없음, 자동 선택:", displayFields); + } + } + + const displayTitle = displayFields[0]?.value || item.name || item.title || `항목 ${index + 1}`; + const displaySubtitle = displayFields[1]?.value || null; + + return ( + + {/* 현재 항목 */}
handleLeftItemSelect(item)} - className={`cursor-pointer rounded-md p-3 transition-colors hover:bg-muted ${ + className={`group relative cursor-pointer rounded-md p-3 transition-colors hover:bg-muted ${ isSelected ? "bg-primary/10 text-primary" : "text-foreground" }`} + style={{ paddingLeft: `${12 + level * 24}px` }} > -
{displayTitle}
- {displaySubtitle &&
{displaySubtitle}
} +
{ + handleLeftItemSelect(item); + if (hasChildren) { + toggleExpand(itemId); + } + }} + > + {/* 펼치기/접기 아이콘 */} + {hasChildren ? ( +
+ {isExpanded ? ( + + ) : ( + + )} +
+ ) : ( +
+ )} + + {/* 항목 내용 */} +
+
{displayTitle}
+ {displaySubtitle &&
{displaySubtitle}
} +
+ + {/* 항목별 버튼들 */} + {!isDesignMode && ( +
+ {/* 수정 버튼 */} + + + {/* 삭제 버튼 */} + + + {/* 항목별 추가 버튼 */} + {componentConfig.leftPanel?.showItemAddButton && ( + + )} +
+ )} +
- ); - }) + + {/* 자식 항목들 (접혀있으면 표시 안함) */} + {hasChildren && isExpanded && item.children.map((child: any, childIndex: number) => renderTreeItem(child, childIndex))} + + ); + }; + + return filteredLeftData.length > 0 ? ( + // 실제 데이터 표시 + filteredLeftData.map((item, index) => renderTreeItem(item, index)) ) : ( // 검색 결과 없음
@@ -432,11 +1007,20 @@ export const SplitPanelLayoutComponent: React.FC {componentConfig.rightPanel?.title || "우측 패널"} - {componentConfig.rightPanel?.showAdd && ( - + {!isDesignMode && ( +
+ {componentConfig.rightPanel?.showAdd && ( + + )} + {/* 우측 패널 수정/삭제는 각 카드에서 처리 */} +
)}
{componentConfig.rightPanel?.showSearch && ( @@ -488,25 +1072,59 @@ export const SplitPanelLayoutComponent: React.FC {filteredData.map((item, index) => { const itemId = item.id || item.ID || index; const isExpanded = expandedRightItems.has(itemId); - const firstValues = Object.entries(item) - .filter(([key]) => !key.toLowerCase().includes("id")) - .slice(0, 3); - const allValues = Object.entries(item).filter( - ([key, value]) => value !== null && value !== undefined && value !== "", - ); + + // 우측 패널 표시 컬럼 설정 확인 + const rightColumns = componentConfig.rightPanel?.columns; + let firstValues: [string, any][] = []; + let allValues: [string, any][] = []; + + if (index === 0) { + console.log("🔍 우측 패널 표시 로직:"); + console.log(" - rightColumns:", rightColumns); + console.log(" - item keys:", Object.keys(item)); + } + + if (rightColumns && rightColumns.length > 0) { + // 설정된 컬럼만 표시 + firstValues = rightColumns + .slice(0, 3) + .map((col) => [col.name, item[col.name]] as [string, any]) + .filter(([_, value]) => value !== null && value !== undefined && value !== ""); + + allValues = rightColumns + .map((col) => [col.name, item[col.name]] as [string, any]) + .filter(([_, value]) => value !== null && value !== undefined && value !== ""); + + if (index === 0) { + console.log(" ✅ 설정된 컬럼 사용:", rightColumns.map(c => c.name)); + } + } else { + // 설정 없으면 모든 컬럼 표시 (기존 로직) + firstValues = Object.entries(item) + .filter(([key]) => !key.toLowerCase().includes("id")) + .slice(0, 3); + + allValues = Object.entries(item).filter( + ([key, value]) => value !== null && value !== undefined && value !== "", + ); + + if (index === 0) { + console.log(" ⚠️ 컬럼 설정 없음, 모든 컬럼 표시"); + } + } return (
- {/* 요약 정보 (클릭 가능) */} -
toggleRightItemExpansion(itemId)} - className="cursor-pointer p-3 transition-colors hover:bg-muted" - > + {/* 요약 정보 */} +
-
+
toggleRightItemExpansion(itemId)} + > {firstValues.map(([key, value], idx) => (
{getColumnLabel(key)}
@@ -516,12 +1134,44 @@ export const SplitPanelLayoutComponent: React.FC
))}
-
- {isExpanded ? ( - - ) : ( - +
+ {/* 수정 버튼 */} + {!isDesignMode && ( + )} + {/* 삭제 버튼 */} + {!isDesignMode && ( + + )} + {/* 확장/접기 버튼 */} +
@@ -565,21 +1215,39 @@ export const SplitPanelLayoutComponent: React.FC })() ) : ( // 상세 모드: 단일 객체를 상세 정보로 표시 -
- {Object.entries(rightData).map(([key, value]) => { - // null, undefined, 빈 문자열 제외 - if (value === null || value === undefined || value === "") return null; + (() => { + const rightColumns = componentConfig.rightPanel?.columns; + let displayEntries: [string, any][] = []; - return ( -
-
- {key} -
-
{String(value)}
-
+ if (rightColumns && rightColumns.length > 0) { + // 설정된 컬럼만 표시 + displayEntries = rightColumns + .map((col) => [col.name, rightData[col.name]] as [string, any]) + .filter(([_, value]) => value !== null && value !== undefined && value !== ""); + + console.log("🔍 상세 모드 표시 로직:"); + console.log(" ✅ 설정된 컬럼 사용:", rightColumns.map(c => c.name)); + } else { + // 설정 없으면 모든 컬럼 표시 + displayEntries = Object.entries(rightData).filter( + ([_, value]) => value !== null && value !== undefined && value !== "" ); - })} -
+ console.log(" ⚠️ 컬럼 설정 없음, 모든 컬럼 표시"); + } + + return ( +
+ {displayEntries.map(([key, value]) => ( +
+
+ {getColumnLabel(key)} +
+
{String(value)}
+
+ ))} +
+ ); + })() ) ) : selectedLeftItem && isDesignMode ? ( // 디자인 모드: 샘플 데이터 @@ -614,6 +1282,239 @@ export const SplitPanelLayoutComponent: React.FC
+ + {/* 추가 모달 */} + + + + + {addModalPanel === "left" + ? `${componentConfig.leftPanel?.title} 추가` + : addModalPanel === "right" + ? `${componentConfig.rightPanel?.title} 추가` + : `하위 ${componentConfig.leftPanel?.title} 추가`} + + + {addModalPanel === "left-item" + ? "선택한 항목의 하위 항목을 추가합니다. 필수 항목을 입력해주세요." + : "새로운 데이터를 추가합니다. 필수 항목을 입력해주세요."} + + + +
+ {(() => { + // 어떤 컬럼들을 표시할지 결정 + let modalColumns: Array<{ name: string; label: string; required?: boolean }> | undefined; + + if (addModalPanel === "left") { + modalColumns = componentConfig.leftPanel?.addModalColumns; + } else if (addModalPanel === "right") { + modalColumns = componentConfig.rightPanel?.addModalColumns; + } else if (addModalPanel === "left-item") { + modalColumns = componentConfig.leftPanel?.itemAddConfig?.addModalColumns; + } + + return modalColumns?.map((col, index) => { + // 항목별 추가 버튼으로 열렸을 때, parentColumn은 미리 채워져 있고 수정 불가 + const isPreFilled = addModalPanel === "left-item" + && componentConfig.leftPanel?.itemAddConfig?.parentColumn === col.name + && addModalFormData[col.name]; + + return ( +
+ + { + setAddModalFormData(prev => ({ + ...prev, + [col.name]: e.target.value + })); + }} + placeholder={`${col.label} 입력`} + className="h-8 text-xs sm:h-10 sm:text-sm" + required={col.required} + disabled={isPreFilled} + /> +
+ ); + }); + })()} +
+ + + + + +
+
+ + {/* 수정 모달 */} + + + + + {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 d53d90e9..bce30f8f 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx @@ -9,7 +9,7 @@ import { Slider } from "@/components/ui/slider"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; -import { Check, ChevronsUpDown, ArrowRight } from "lucide-react"; +import { Check, ChevronsUpDown, ArrowRight, Plus, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { SplitPanelLayoutConfig } from "./types"; import { TableInfo, ColumnInfo } from "@/types/screen"; @@ -74,6 +74,61 @@ export const SplitPanelLayoutConfigPanel: React.FC { + const leftTableName = config.leftPanel?.tableName || screenTableName; + if (leftTableName && loadedTableColumns[leftTableName] && config.leftPanel?.showAdd) { + const currentAddModalColumns = config.leftPanel?.addModalColumns || []; + const updatedColumns = ensurePrimaryKeysInAddModal(leftTableName, currentAddModalColumns); + + // PK가 추가되었으면 업데이트 + if (updatedColumns.length !== currentAddModalColumns.length) { + console.log(`🔄 좌측 패널: PK 컬럼 자동 추가 (${leftTableName})`); + updateLeftPanel({ addModalColumns: updatedColumns }); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config.leftPanel?.tableName, screenTableName, loadedTableColumns, config.leftPanel?.showAdd]); + + // 좌측 패널 하위 항목 추가 모달 PK 자동 추가 + useEffect(() => { + const leftTableName = config.leftPanel?.tableName || screenTableName; + if (leftTableName && loadedTableColumns[leftTableName] && config.leftPanel?.showItemAddButton) { + const currentAddModalColumns = config.leftPanel?.itemAddConfig?.addModalColumns || []; + const updatedColumns = ensurePrimaryKeysInAddModal(leftTableName, currentAddModalColumns); + + // PK가 추가되었으면 업데이트 + if (updatedColumns.length !== currentAddModalColumns.length) { + console.log(`🔄 좌측 패널 하위 항목 추가: PK 컬럼 자동 추가 (${leftTableName})`); + updateLeftPanel({ + itemAddConfig: { + ...config.leftPanel?.itemAddConfig, + addModalColumns: updatedColumns, + parentColumn: config.leftPanel?.itemAddConfig?.parentColumn || "", + sourceColumn: config.leftPanel?.itemAddConfig?.sourceColumn || "", + } + }); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config.leftPanel?.tableName, screenTableName, loadedTableColumns, config.leftPanel?.showItemAddButton]); + + // 우측 패널 테이블 컬럼 로드 완료 시 PK 자동 추가 + useEffect(() => { + const rightTableName = config.rightPanel?.tableName; + if (rightTableName && loadedTableColumns[rightTableName] && config.rightPanel?.showAdd) { + const currentAddModalColumns = config.rightPanel?.addModalColumns || []; + const updatedColumns = ensurePrimaryKeysInAddModal(rightTableName, currentAddModalColumns); + + // PK가 추가되었으면 업데이트 + if (updatedColumns.length !== currentAddModalColumns.length) { + console.log(`🔄 우측 패널: PK 컬럼 자동 추가 (${rightTableName})`); + updateRightPanel({ addModalColumns: updatedColumns }); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config.rightPanel?.tableName, loadedTableColumns, config.rightPanel?.showAdd]); + // 테이블 컬럼 로드 함수 const loadTableColumns = async (tableName: string) => { if (loadedTableColumns[tableName] || loadingColumns[tableName]) { @@ -98,6 +153,7 @@ export const SplitPanelLayoutConfigPanel: React.FC = [] + ) => { + const tableColumns = loadedTableColumns[tableName]; + if (!tableColumns) { + console.warn(`⚠️ 테이블 ${tableName}의 컬럼 정보가 로드되지 않음`); + return existingColumns; + } + + // PK 컬럼 찾기 + const pkColumns = tableColumns.filter((col) => col.isPrimaryKey); + console.log(`🔑 테이블 ${tableName}의 PK 컬럼:`, pkColumns.map(c => c.columnName)); + + // 자동으로 처리되는 컬럼 (백엔드에서 자동 추가) + const autoHandledColumns = ['company_code', 'company_name']; + + // 기존 컬럼 이름 목록 + const existingColumnNames = existingColumns.map((col) => col.name); + + // PK 컬럼을 맨 앞에 추가 (이미 있거나 자동 처리되는 컬럼은 제외) + const pkColumnsToAdd = pkColumns + .filter((col) => !existingColumnNames.includes(col.columnName)) + .filter((col) => !autoHandledColumns.includes(col.columnName)) // 자동 처리 컬럼 제외 + .map((col) => ({ + name: col.columnName, + label: col.columnLabel || col.columnName, + required: true, // PK는 항상 필수 + })); + + if (pkColumnsToAdd.length > 0) { + console.log(`✅ PK 컬럼 ${pkColumnsToAdd.length}개 자동 추가:`, pkColumnsToAdd.map(c => c.name)); + } + + return [...pkColumnsToAdd, ...existingColumns]; + }; + const updateLeftPanel = (updates: Partial) => { const newConfig = { ...config, @@ -268,6 +362,451 @@ export const SplitPanelLayoutConfigPanel: React.FC updateLeftPanel({ showAdd: checked })} />
+ +
+ + updateLeftPanel({ showItemAddButton: checked })} + /> +
+ + {/* 항목별 + 버튼 설정 (하위 항목 추가) */} + {config.leftPanel?.showItemAddButton && ( +
+ +

+ + 버튼 클릭 시 선택된 항목의 하위 항목을 추가합니다 (예: 부서 → 하위 부서) +

+ + {/* 현재 항목의 값을 가져올 컬럼 (sourceColumn) */} +
+ +

+ 선택된 항목의 어떤 컬럼 값을 사용할지 (예: dept_code) +

+ + + + + + + + 컬럼을 찾을 수 없습니다. + + {leftTableColumns + .filter((column) => !['company_code', 'company_name'].includes(column.columnName)) + .map((column) => ( + { + updateLeftPanel({ + itemAddConfig: { + ...config.leftPanel?.itemAddConfig, + sourceColumn: value, + parentColumn: config.leftPanel?.itemAddConfig?.parentColumn || "", + } + }); + }} + className="text-xs" + > + + {column.columnLabel || column.columnName} + + ({column.columnName}) + + + ))} + + + + +
+ + {/* 상위 항목 ID를 저장할 컬럼 (parentColumn) */} +
+ +

+ 하위 항목에서 상위 항목 ID를 저장할 컬럼 (예: parent_dept_code) +

+ + + + + + + + 컬럼을 찾을 수 없습니다. + + {leftTableColumns + .filter((column) => !['company_code', 'company_name'].includes(column.columnName)) + .map((column) => ( + { + updateLeftPanel({ + itemAddConfig: { + ...config.leftPanel?.itemAddConfig, + parentColumn: value, + sourceColumn: config.leftPanel?.itemAddConfig?.sourceColumn || "", + } + }); + }} + className="text-xs" + > + + {column.columnLabel || column.columnName} + + ({column.columnName}) + + + ))} + + + + +
+ + {/* 하위 항목 추가 모달 컬럼 설정 */} +
+
+ + +
+

+ 하위 항목 추가 시 입력받을 필드를 선택하세요 +

+ +
+ {(config.leftPanel?.itemAddConfig?.addModalColumns || []).length === 0 ? ( +
+

설정된 컬럼이 없습니다

+
+ ) : ( + (config.leftPanel?.itemAddConfig?.addModalColumns || []).map((col, index) => { + const column = leftTableColumns.find(c => c.columnName === col.name); + const isPK = column?.isPrimaryKey || false; + + return ( +
+ {isPK && ( + + PK + + )} +
+ + + + + + + + 컬럼을 찾을 수 없습니다. + + {leftTableColumns + .filter((column) => !['company_code', 'company_name'].includes(column.columnName)) + .map((column) => ( + { + const newColumns = [...(config.leftPanel?.itemAddConfig?.addModalColumns || [])]; + newColumns[index] = { + ...newColumns[index], + name: value, + label: column.columnLabel || value, + }; + updateLeftPanel({ + itemAddConfig: { + ...config.leftPanel?.itemAddConfig, + addModalColumns: newColumns, + parentColumn: config.leftPanel?.itemAddConfig?.parentColumn || "", + sourceColumn: config.leftPanel?.itemAddConfig?.sourceColumn || "", + } + }); + }} + className="text-xs" + > + + {column.columnLabel || column.columnName} + + ({column.columnName}) + + + ))} + + + + +
+
+ +
+ +
+ ); + }) + )} +
+
+
+ )} + + {/* 좌측 패널 추가 모달 컬럼 설정 */} + {config.leftPanel?.showAdd && ( +
+
+ + +
+

+ 추가 버튼 클릭 시 모달에 표시될 입력 필드를 선택하세요 +

+ +
+ {(config.leftPanel?.addModalColumns || []).length === 0 ? ( +
+

설정된 컬럼이 없습니다

+
+ ) : ( + (config.leftPanel?.addModalColumns || []).map((col, index) => { + // 현재 컬럼이 PK인지 확인 + const column = leftTableColumns.find(c => c.columnName === col.name); + const isPK = column?.isPrimaryKey || false; + + return ( +
+ {isPK && ( + + PK + + )} +
+ + + + + + + + 컬럼을 찾을 수 없습니다. + + {leftTableColumns + .filter((column) => !['company_code', 'company_name'].includes(column.columnName)) + .map((column) => ( + { + const newColumns = [...(config.leftPanel?.addModalColumns || [])]; + newColumns[index] = { + ...newColumns[index], + name: value, + label: column.columnLabel || value, + }; + updateLeftPanel({ addModalColumns: newColumns }); + }} + className="text-xs" + > + + {column.columnLabel || column.columnName} + + ({column.columnName}) + + + ))} + + + + +
+
+ +
+ +
+ ); + }) + )} +
+
+ )}
{/* 우측 패널 설정 */} @@ -467,6 +1006,357 @@ export const SplitPanelLayoutConfigPanel: React.FC updateRightPanel({ showAdd: checked })} />
+ + {/* 우측 패널 표시 컬럼 설정 */} +
+
+ + +
+

+ 우측 패널에 표시할 컬럼을 선택하세요. 선택하지 않으면 모든 컬럼이 표시됩니다. +

+ + {/* 선택된 컬럼 목록 */} +
+ {(config.rightPanel?.columns || []).length === 0 ? ( +
+

설정된 컬럼이 없습니다

+

+ 컬럼을 추가하지 않으면 모든 컬럼이 표시됩니다 +

+
+ ) : ( + (config.rightPanel?.columns || []).map((col, index) => ( +
+
+ + + + + + + + 컬럼을 찾을 수 없습니다. + + {rightTableColumns.map((column) => ( + { + const newColumns = [...(config.rightPanel?.columns || [])]; + newColumns[index] = { + ...newColumns[index], + name: value, + label: column.columnLabel || value, + }; + updateRightPanel({ columns: newColumns }); + }} + className="text-xs" + > + + {column.columnLabel || column.columnName} + + ({column.columnName}) + + + ))} + + + + +
+ +
+ )) + )} +
+
+ + {/* 우측 패널 추가 모달 컬럼 설정 */} + {config.rightPanel?.showAdd && ( +
+
+ + +
+

+ 추가 버튼 클릭 시 모달에 표시될 입력 필드를 선택하세요 +

+ +
+ {(config.rightPanel?.addModalColumns || []).length === 0 ? ( +
+

설정된 컬럼이 없습니다

+
+ ) : ( + (config.rightPanel?.addModalColumns || []).map((col, index) => { + // 현재 컬럼이 PK인지 확인 + const column = rightTableColumns.find(c => c.columnName === col.name); + const isPK = column?.isPrimaryKey || false; + + return ( +
+ {isPK && ( + + PK + + )} +
+ + + + + + + + 컬럼을 찾을 수 없습니다. + + {rightTableColumns + .filter((column) => !['company_code', 'company_name'].includes(column.columnName)) + .map((column) => ( + { + const newColumns = [...(config.rightPanel?.addModalColumns || [])]; + newColumns[index] = { + ...newColumns[index], + name: value, + label: column.columnLabel || value, + }; + updateRightPanel({ addModalColumns: newColumns }); + }} + className="text-xs" + > + + {column.columnLabel || column.columnName} + + ({column.columnName}) + + + ))} + + + + +
+
+ +
+ +
+ ); + }) + )} +
+ + {/* 중계 테이블 설정 */} +
+ +

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

+ +
+ + { + 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" + /> +

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

+
+ +
+ +