From 004bf28d17e985b627ca67abbb392b63f7ac5f4d Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 18 Sep 2025 18:49:30 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/dynamicFormController.ts | 87 ++++ backend-node/src/routes/dynamicFormRoutes.ts | 6 + .../src/services/dynamicFormService.ts | 168 ++++++- .../app/(main)/screens/[screenId]/page.tsx | 79 ++++ frontend/components/screen/EditModal.tsx | 312 +++++++++++++ .../config-panels/ButtonConfigPanel.tsx | 130 ++++++ frontend/lib/api/dynamicForm.ts | 77 +++ .../lib/registry/DynamicComponentRenderer.tsx | 17 + .../button-primary/ButtonPrimaryComponent.tsx | 85 ++-- .../table-list/TableListComponent.tsx | 441 ++++++++++++++---- .../table-list/TableListConfigPanel.tsx | 57 +++ .../registry/components/table-list/index.ts | 8 + .../registry/components/table-list/types.ts | 19 + frontend/lib/utils/buttonActions.ts | 285 ++++++++++- 14 files changed, 1637 insertions(+), 134 deletions(-) create mode 100644 frontend/components/screen/EditModal.tsx diff --git a/backend-node/src/controllers/dynamicFormController.ts b/backend-node/src/controllers/dynamicFormController.ts index 85f74533..20c74714 100644 --- a/backend-node/src/controllers/dynamicFormController.ts +++ b/backend-node/src/controllers/dynamicFormController.ts @@ -99,6 +99,58 @@ export const updateFormData = async ( } }; +// 폼 데이터 부분 업데이트 (변경된 필드만) +export const updateFormDataPartial = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { id } = req.params; + const { companyCode, userId } = req.user as any; + const { tableName, originalData, newData } = req.body; + + if (!tableName || !originalData || !newData) { + return res.status(400).json({ + success: false, + message: + "필수 필드가 누락되었습니다. (tableName, originalData, newData)", + }); + } + + console.log("🔄 컨트롤러: 부분 업데이트 요청:", { + id, + tableName, + originalData, + newData, + }); + + // 메타데이터 추가 + const newDataWithMeta = { + ...newData, + updated_by: userId, + }; + + const result = await dynamicFormService.updateFormDataPartial( + parseInt(id), + tableName, + originalData, + newDataWithMeta + ); + + res.json({ + success: true, + data: result, + message: "데이터가 성공적으로 업데이트되었습니다.", + }); + } catch (error: any) { + console.error("❌ 부분 업데이트 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "부분 업데이트에 실패했습니다.", + }); + } +}; + // 폼 데이터 삭제 export const deleteFormData = async ( req: AuthenticatedRequest, @@ -131,6 +183,41 @@ export const deleteFormData = async ( } }; +// 테이블의 기본키 조회 +export const getTablePrimaryKeys = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { tableName } = req.params; + + if (!tableName) { + return res.status(400).json({ + success: false, + message: "테이블명이 누락되었습니다.", + }); + } + + console.log(`🔑 테이블 ${tableName}의 기본키 조회 요청`); + + const primaryKeys = await dynamicFormService.getTablePrimaryKeys(tableName); + + console.log(`✅ 테이블 ${tableName}의 기본키:`, primaryKeys); + + res.json({ + success: true, + data: primaryKeys, + message: "기본키 조회가 완료되었습니다.", + }); + } catch (error: any) { + console.error("❌ 기본키 조회 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "기본키 조회에 실패했습니다.", + }); + } +}; + // 단일 폼 데이터 조회 export const getFormData = async ( req: AuthenticatedRequest, diff --git a/backend-node/src/routes/dynamicFormRoutes.ts b/backend-node/src/routes/dynamicFormRoutes.ts index f37a84ae..01d2e264 100644 --- a/backend-node/src/routes/dynamicFormRoutes.ts +++ b/backend-node/src/routes/dynamicFormRoutes.ts @@ -3,11 +3,13 @@ import { authenticateToken } from "../middleware/authMiddleware"; import { saveFormData, updateFormData, + updateFormDataPartial, deleteFormData, getFormData, getFormDataList, validateFormData, getTableColumns, + getTablePrimaryKeys, } from "../controllers/dynamicFormController"; const router = express.Router(); @@ -18,6 +20,7 @@ router.use(authenticateToken); // 폼 데이터 CRUD router.post("/save", saveFormData); router.put("/:id", updateFormData); +router.patch("/:id/partial", updateFormDataPartial); // 부분 업데이트 router.delete("/:id", deleteFormData); router.get("/:id", getFormData); @@ -30,4 +33,7 @@ router.post("/validate", validateFormData); // 테이블 컬럼 정보 조회 (검증용) router.get("/table/:tableName/columns", getTableColumns); +// 테이블 기본키 조회 +router.get("/table/:tableName/primary-keys", getTablePrimaryKeys); + export default router; diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index c292a24c..56b0c42c 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -14,6 +14,12 @@ export interface FormDataResult { updatedBy: string; } +export interface PartialUpdateResult { + success: boolean; + data: any; + message: string; +} + export interface PaginatedFormData { content: FormDataResult[]; totalElements: number; @@ -128,9 +134,9 @@ export class DynamicFormService { } /** - * 테이블의 Primary Key 컬럼 조회 + * 테이블의 Primary Key 컬럼 조회 (공개 메서드로 변경) */ - private async getTablePrimaryKeys(tableName: string): Promise { + async getTablePrimaryKeys(tableName: string): Promise { try { const result = (await prisma.$queryRawUnsafe(` SELECT kcu.column_name @@ -385,6 +391,118 @@ export class DynamicFormService { } } + /** + * 폼 데이터 부분 업데이트 (변경된 필드만 업데이트) + */ + async updateFormDataPartial( + id: number, + tableName: string, + originalData: Record, + newData: Record + ): Promise { + try { + console.log("🔄 서비스: 부분 업데이트 시작:", { + id, + tableName, + originalData, + newData, + }); + + // 테이블의 실제 컬럼 정보 조회 + const tableColumns = await this.getTableColumnNames(tableName); + console.log(`📋 테이블 ${tableName}의 컬럼:`, tableColumns); + + // 변경된 필드만 찾기 + const changedFields: Record = {}; + + for (const [key, value] of Object.entries(newData)) { + // 메타데이터 필드 제외 + if ( + ["created_by", "updated_by", "company_code", "screen_id"].includes( + key + ) + ) { + continue; + } + + // 테이블에 존재하지 않는 컬럼 제외 + if (!tableColumns.includes(key)) { + console.log( + `⚠️ 컬럼 ${key}는 테이블 ${tableName}에 존재하지 않아 제외됨` + ); + continue; + } + + // 값이 실제로 변경된 경우만 포함 + if (originalData[key] !== value) { + changedFields[key] = value; + console.log( + `📝 변경된 필드: ${key} = "${originalData[key]}" → "${value}"` + ); + } + } + + // 변경된 필드가 없으면 업데이트 건너뛰기 + if (Object.keys(changedFields).length === 0) { + console.log("📋 변경된 필드가 없습니다. 업데이트를 건너뜁니다."); + return { + success: true, + data: originalData, + message: "변경사항이 없어 업데이트하지 않았습니다.", + }; + } + + // 업데이트 관련 필드 추가 (변경사항이 있는 경우에만) + if (tableColumns.includes("updated_at")) { + changedFields.updated_at = new Date(); + } + + console.log("🎯 실제 업데이트할 필드들:", changedFields); + + // 동적으로 기본키 조회 + const primaryKeys = await this.getTablePrimaryKeys(tableName); + if (!primaryKeys || primaryKeys.length === 0) { + throw new Error(`테이블 ${tableName}의 기본키를 찾을 수 없습니다.`); + } + + const primaryKeyColumn = primaryKeys[0]; + console.log(`🔑 테이블 ${tableName}의 기본키: ${primaryKeyColumn}`); + + // 동적 UPDATE SQL 생성 (변경된 필드만) + const setClause = Object.keys(changedFields) + .map((key, index) => `${key} = $${index + 1}`) + .join(", "); + + const values: any[] = Object.values(changedFields); + values.push(id); // WHERE 조건용 ID 추가 + + const updateQuery = ` + UPDATE ${tableName} + SET ${setClause} + WHERE ${primaryKeyColumn} = $${values.length} + RETURNING * + `; + + console.log("📝 실행할 부분 UPDATE SQL:", updateQuery); + console.log("📊 SQL 파라미터:", values); + + const result = await prisma.$queryRawUnsafe(updateQuery, ...values); + + console.log("✅ 서비스: 부분 업데이트 성공:", result); + + const updatedRecord = Array.isArray(result) ? result[0] : result; + + return { + success: true, + data: updatedRecord, + message: "데이터가 성공적으로 업데이트되었습니다.", + }; + } catch (error: any) { + console.error("❌ 서비스: 부분 업데이트 실패:", error); + throw new Error(`부분 업데이트 실패: ${error}`); + } + } + /** * 폼 데이터 업데이트 (실제 테이블에서 직접 업데이트) */ @@ -448,11 +566,19 @@ export class DynamicFormService { const values: any[] = Object.values(dataToUpdate); values.push(id); // WHERE 조건용 ID 추가 - // ID 또는 objid로 찾기 시도 + // 동적으로 기본키 조회 + const primaryKeys = await this.getTablePrimaryKeys(tableName); + if (!primaryKeys || primaryKeys.length === 0) { + throw new Error(`테이블 ${tableName}의 기본키를 찾을 수 없습니다.`); + } + + const primaryKeyColumn = primaryKeys[0]; // 첫 번째 기본키 사용 + console.log(`🔑 테이블 ${tableName}의 기본키: ${primaryKeyColumn}`); + const updateQuery = ` UPDATE ${tableName} SET ${setClause} - WHERE (id = $${values.length} OR objid = $${values.length}) + WHERE ${primaryKeyColumn} = $${values.length} RETURNING * `; @@ -524,10 +650,40 @@ export class DynamicFormService { tableName, }); - // 동적 DELETE SQL 생성 + // 1. 먼저 테이블의 기본키 컬럼명을 동적으로 조회 + const primaryKeyQuery = ` + SELECT kcu.column_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + WHERE tc.table_name = $1 + AND tc.constraint_type = 'PRIMARY KEY' + LIMIT 1 + `; + + console.log("🔍 기본키 조회 SQL:", primaryKeyQuery); + console.log("🔍 테이블명:", tableName); + + const primaryKeyResult = await prisma.$queryRawUnsafe( + primaryKeyQuery, + tableName + ); + + if ( + !primaryKeyResult || + !Array.isArray(primaryKeyResult) || + primaryKeyResult.length === 0 + ) { + throw new Error(`테이블 ${tableName}의 기본키를 찾을 수 없습니다.`); + } + + const primaryKeyColumn = (primaryKeyResult[0] as any).column_name; + console.log("🔑 발견된 기본키 컬럼:", primaryKeyColumn); + + // 2. 동적으로 발견된 기본키를 사용한 DELETE SQL 생성 const deleteQuery = ` DELETE FROM ${tableName} - WHERE (id = $1 OR objid = $1) + WHERE ${primaryKeyColumn} = $1 RETURNING * `; diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 9ec377f0..db85e8a7 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -12,6 +12,7 @@ import { DynamicWebTypeRenderer } from "@/lib/registry"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { initializeComponents } from "@/lib/registry/components"; +import { EditModal } from "@/components/screen/EditModal"; export default function ScreenViewPage() { const params = useParams(); @@ -24,6 +25,22 @@ export default function ScreenViewPage() { const [error, setError] = useState(null); const [formData, setFormData] = useState>({}); + // 테이블 선택된 행 상태 (화면 레벨에서 관리) + const [selectedRows, setSelectedRows] = useState([]); + const [selectedRowsData, setSelectedRowsData] = useState([]); + + // 테이블 새로고침을 위한 키 상태 + const [refreshKey, setRefreshKey] = useState(0); + + // 편집 모달 상태 + const [editModalOpen, setEditModalOpen] = useState(false); + const [editModalConfig, setEditModalConfig] = useState<{ + screenId?: number; + modalSize?: "sm" | "md" | "lg" | "xl" | "full"; + editData?: any; + onSave?: () => void; + }>({}); + useEffect(() => { const initComponents = async () => { try { @@ -38,6 +55,29 @@ export default function ScreenViewPage() { initComponents(); }, []); + // 편집 모달 이벤트 리스너 등록 + useEffect(() => { + const handleOpenEditModal = (event: CustomEvent) => { + console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail); + + setEditModalConfig({ + screenId: event.detail.screenId, + modalSize: event.detail.modalSize, + editData: event.detail.editData, + onSave: event.detail.onSave, + }); + setEditModalOpen(true); + }; + + // @ts-ignore + window.addEventListener("openEditModal", handleOpenEditModal); + + return () => { + // @ts-ignore + window.removeEventListener("openEditModal", handleOpenEditModal); + }; + }, []); + useEffect(() => { const loadScreen = async () => { try { @@ -262,10 +302,24 @@ export default function ScreenViewPage() { tableName={screen?.tableName} onRefresh={() => { console.log("화면 새로고침 요청"); + // 테이블 컴포넌트 강제 새로고침을 위한 키 업데이트 + setRefreshKey((prev) => prev + 1); + // 선택된 행 상태도 초기화 + setSelectedRows([]); + setSelectedRowsData([]); }} onClose={() => { console.log("화면 닫기 요청"); }} + // 테이블 선택된 행 정보 전달 + selectedRows={selectedRows} + selectedRowsData={selectedRowsData} + onSelectedRowsChange={(newSelectedRows, newSelectedRowsData) => { + setSelectedRows(newSelectedRows); + setSelectedRowsData(newSelectedRowsData); + }} + // 테이블 새로고침 키 전달 + refreshKey={refreshKey} /> ) : ( )} + + {/* 편집 모달 */} + { + setEditModalOpen(false); + setEditModalConfig({}); + }} + screenId={editModalConfig.screenId} + modalSize={editModalConfig.modalSize} + editData={editModalConfig.editData} + onSave={editModalConfig.onSave} + onDataChange={(changedFormData) => { + console.log("📝 EditModal에서 데이터 변경 수신:", changedFormData); + // 변경된 데이터를 메인 폼에 반영 + setFormData((prev) => { + const updatedFormData = { + ...prev, + ...changedFormData, // 변경된 필드들만 업데이트 + }; + console.log("📊 메인 폼 데이터 업데이트:", updatedFormData); + return updatedFormData; + }); + }} + /> ); } diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx new file mode 100644 index 00000000..2e736840 --- /dev/null +++ b/frontend/components/screen/EditModal.tsx @@ -0,0 +1,312 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { X, Save, RotateCcw } from "lucide-react"; +import { toast } from "sonner"; +import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; +import { screenApi } from "@/lib/api/screen"; +import { ComponentData } from "@/lib/types/screen"; + +interface EditModalProps { + isOpen: boolean; + onClose: () => void; + screenId?: number; + modalSize?: "sm" | "md" | "lg" | "xl" | "full"; + editData?: any; + onSave?: () => void; + onDataChange?: (formData: Record) => void; // 폼 데이터 변경 콜백 추가 +} + +/** + * 편집 모달 컴포넌트 + * 선택된 데이터를 폼 화면에 로드하여 편집할 수 있게 해주는 모달 + */ +export const EditModal: React.FC = ({ + isOpen, + onClose, + screenId, + modalSize = "lg", + editData, + onSave, + onDataChange, +}) => { + const [loading, setLoading] = useState(false); + const [formData, setFormData] = useState({}); + const [originalData, setOriginalData] = useState({}); // 부분 업데이트용 원본 데이터 + const [screenData, setScreenData] = useState(null); + const [components, setComponents] = useState([]); + + // 컴포넌트 기반 동적 크기 계산 + const calculateModalSize = () => { + if (components.length === 0) { + return { width: 600, height: 400 }; // 기본 크기 + } + + const maxWidth = Math.max(...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)), 500) + 100; // 더 넉넉한 여백 + + const maxHeight = Math.max(...components.map((c) => (c.position?.y || 0) + (c.size?.height || 40)), 400) + 20; // 최소한의 여백만 추가 + + console.log(`🎯 계산된 모달 크기: ${maxWidth}px x ${maxHeight}px`); + console.log( + `📍 컴포넌트 위치들:`, + components.map((c) => ({ x: c.position?.x, y: c.position?.y, w: c.size?.width, h: c.size?.height })), + ); + return { width: maxWidth, height: maxHeight }; + }; + + const dynamicSize = calculateModalSize(); + + // DialogContent 크기 강제 적용 + useEffect(() => { + if (isOpen && dynamicSize) { + // 모달이 렌더링된 후 DOM 직접 조작으로 크기 강제 적용 + setTimeout(() => { + const dialogContent = document.querySelector('[role="dialog"] > div'); + const modalContent = document.querySelector('[role="dialog"] [class*="overflow-auto"]'); + + if (dialogContent) { + const targetWidth = dynamicSize.width; + const targetHeight = dynamicSize.height; + + console.log(`🔧 DialogContent 크기 강제 적용: ${targetWidth}px x ${targetHeight}px`); + + dialogContent.style.width = `${targetWidth}px`; + dialogContent.style.height = `${targetHeight}px`; + dialogContent.style.minWidth = `${targetWidth}px`; + dialogContent.style.minHeight = `${targetHeight}px`; + dialogContent.style.maxWidth = "95vw"; + dialogContent.style.maxHeight = "95vh"; + dialogContent.style.padding = "0"; + } + + // 스크롤 완전 제거 + if (modalContent) { + modalContent.style.overflow = "hidden"; + console.log(`🚫 스크롤 완전 비활성화`); + } + }, 100); // 100ms 지연으로 렌더링 완료 후 실행 + } + }, [isOpen, dynamicSize]); + + // 편집 데이터가 변경되면 폼 데이터 및 원본 데이터 초기화 + useEffect(() => { + if (editData) { + console.log("📋 편집 데이터 로드:", editData); + console.log("📋 편집 데이터 키들:", Object.keys(editData)); + + // 원본 데이터와 현재 폼 데이터 모두 설정 + const dataClone = { ...editData }; + setOriginalData(dataClone); // 원본 데이터 저장 (부분 업데이트용) + setFormData(dataClone); // 편집용 폼 데이터 설정 + + console.log("📋 originalData 설정 완료:", dataClone); + console.log("📋 formData 설정 완료:", dataClone); + } else { + console.log("⚠️ editData가 없습니다."); + setOriginalData({}); + setFormData({}); + } + }, [editData]); + + // formData 변경 시 로그 + useEffect(() => { + console.log("🔄 EditModal formData 상태 변경:", formData); + console.log("🔄 formData 키들:", Object.keys(formData || {})); + }, [formData]); + + // 화면 데이터 로드 + useEffect(() => { + const fetchScreenData = async () => { + if (!screenId || !isOpen) return; + + try { + setLoading(true); + console.log("🔄 화면 데이터 로드 시작:", screenId); + + // 화면 정보와 레이아웃 데이터를 동시에 로드 + const [screenInfo, layoutData] = await Promise.all([ + screenApi.getScreen(screenId), + screenApi.getLayout(screenId), + ]); + + console.log("📋 화면 정보:", screenInfo); + console.log("🎨 레이아웃 데이터:", layoutData); + + setScreenData(screenInfo); + + if (layoutData && layoutData.components) { + setComponents(layoutData.components); + console.log("✅ 화면 컴포넌트 로드 완료:", layoutData.components); + + // 컴포넌트와 formData 매칭 정보 출력 + console.log("🔍 컴포넌트-formData 매칭 분석:"); + layoutData.components.forEach((comp) => { + if (comp.columnName) { + const formValue = formData[comp.columnName]; + console.log(` - ${comp.columnName}: "${formValue}" (컴포넌트 ID: ${comp.id})`); + } + }); + } else { + console.log("⚠️ 레이아웃 데이터가 없습니다:", layoutData); + } + } catch (error) { + console.error("❌ 화면 데이터 로드 실패:", error); + toast.error("화면을 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }; + + fetchScreenData(); + }, [screenId, isOpen]); + + // 저장 처리 + const handleSave = async () => { + try { + setLoading(true); + console.log("💾 편집 데이터 저장:", formData); + + // TODO: 실제 저장 API 호출 + // const result = await DynamicFormApi.updateFormData({ + // screenId, + // data: formData, + // }); + + // 임시: 저장 성공 시뮬레이션 + await new Promise((resolve) => setTimeout(resolve, 1000)); + + toast.success("수정이 완료되었습니다."); + onSave?.(); + onClose(); + } catch (error) { + console.error("❌ 저장 실패:", error); + toast.error("저장 중 오류가 발생했습니다."); + } finally { + setLoading(false); + } + }; + + // 초기화 처리 + const handleReset = () => { + if (editData) { + setFormData({ ...editData }); + toast.info("초기값으로 되돌렸습니다."); + } + }; + + // 모달 크기 클래스 매핑 + const getModalSizeClass = () => { + switch (modalSize) { + case "sm": + return "max-w-md"; + case "md": + return "max-w-lg"; + case "lg": + return "max-w-4xl"; + case "xl": + return "max-w-6xl"; + case "full": + return "max-w-[95vw] max-h-[95vh]"; + default: + return "max-w-4xl"; + } + }; + + if (!screenId) { + return null; + } + + return ( + + + + 수정 + + +
+ {loading ? ( +
+
+
+

화면 로딩 중...

+
+
+ ) : screenData && components.length > 0 ? ( + // 원본 화면과 동일한 레이아웃으로 렌더링 +
+ {/* 화면 컴포넌트들 원본 레이아웃 유지하여 렌더링 */} +
+ {components.map((component) => ( +
+ { + console.log("📝 폼 데이터 변경:", fieldName, value); + const newFormData = { ...formData, [fieldName]: value }; + setFormData(newFormData); + + // 변경된 데이터를 즉시 부모로 전달 + if (onDataChange) { + console.log("📤 EditModal -> 부모로 데이터 전달:", newFormData); + onDataChange(newFormData); + } + }} + // 편집 모드로 설정 + mode="edit" + // 모달 내에서 렌더링되고 있음을 표시 + isInModal={true} + // 인터랙티브 모드 활성화 (formData 사용을 위해 필수) + isInteractive={true} + /> +
+ ))} +
+
+ ) : ( +
+
+

화면을 불러올 수 없습니다.

+

화면 ID: {screenId}

+
+
+ )} +
+
+
+ ); +}; diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index 4c79515b..61c0e154 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -272,6 +272,136 @@ export const ButtonConfigPanel: React.FC = ({ component, )} + {/* 수정 액션 설정 */} + {config.action?.type === "edit" && ( +
+

수정 설정

+ +
+ + + + + + +
+ {/* 검색 입력 */} +
+ + setModalSearchTerm(e.target.value)} + className="border-0 p-0 focus-visible:ring-0" + /> +
+ {/* 검색 결과 */} +
+ {(() => { + const filteredScreens = filterScreens(modalSearchTerm); + if (screensLoading) { + return
화면 목록을 불러오는 중...
; + } + if (filteredScreens.length === 0) { + return
검색 결과가 없습니다.
; + } + return filteredScreens.map((screen, index) => ( +
{ + onUpdateProperty("componentConfig.action", { + ...config.action, + targetScreenId: screen.id, + }); + setModalScreenOpen(false); + setModalSearchTerm(""); + }} + > + +
+ {screen.name} + {screen.description && {screen.description}} +
+
+ )); + })()} +
+
+
+
+

+ 선택된 데이터가 이 폼 화면에 자동으로 로드되어 수정할 수 있습니다 +

+
+ +
+ + +
+ + {config.action?.editMode === "modal" && ( +
+ + +
+ )} +
+ )} + {/* 페이지 이동 액션 설정 */} {config.action?.type === "navigate" && (
diff --git a/frontend/lib/api/dynamicForm.ts b/frontend/lib/api/dynamicForm.ts index d306fa95..29524b45 100644 --- a/frontend/lib/api/dynamicForm.ts +++ b/frontend/lib/api/dynamicForm.ts @@ -91,6 +91,53 @@ export class DynamicFormApi { } } + /** + * 폼 데이터 부분 업데이트 (변경된 필드만) + * @param id 레코드 ID + * @param originalData 원본 데이터 + * @param newData 변경할 데이터 + * @param tableName 테이블명 + * @returns 업데이트 결과 + */ + static async updateFormDataPartial( + id: number, + originalData: Record, + newData: Record, + tableName: string, + ): Promise> { + try { + console.log("🔄 폼 데이터 부분 업데이트 요청:", { + id, + originalData, + newData, + tableName, + }); + + const response = await apiClient.patch(`/dynamic-form/${id}/partial`, { + tableName, + originalData, + newData, + }); + + console.log("✅ 폼 데이터 부분 업데이트 성공:", response.data); + return { + success: true, + data: response.data, + message: "데이터가 성공적으로 업데이트되었습니다.", + }; + } catch (error: any) { + console.error("❌ 폼 데이터 부분 업데이트 실패:", error); + + const errorMessage = error.response?.data?.message || error.message || "부분 업데이트 중 오류가 발생했습니다."; + + return { + success: false, + message: errorMessage, + errorCode: error.response?.data?.errorCode, + }; + } + } + /** * 폼 데이터 삭제 * @param id 레코드 ID @@ -313,6 +360,36 @@ export class DynamicFormApi { }; } } + + /** + * 테이블의 기본키 조회 + * @param tableName 테이블명 + * @returns 기본키 컬럼명 배열 + */ + static async getTablePrimaryKeys(tableName: string): Promise> { + try { + console.log("🔑 테이블 기본키 조회 요청:", tableName); + + const response = await apiClient.get(`/dynamic-form/table/${tableName}/primary-keys`); + + console.log("✅ 테이블 기본키 조회 성공:", response.data); + return { + success: true, + data: response.data.data, + message: "기본키 조회가 완료되었습니다.", + }; + } catch (error: any) { + console.error("❌ 테이블 기본키 조회 실패:", error); + + const errorMessage = error.response?.data?.message || error.message || "기본키 조회 중 오류가 발생했습니다."; + + return { + success: false, + message: errorMessage, + errorCode: error.response?.data?.errorCode, + }; + } + } } // 편의를 위한 기본 export diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index cb389b7a..29c8d927 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -12,6 +12,7 @@ export interface ComponentRenderer { isSelected?: boolean; isInteractive?: boolean; formData?: Record; + originalData?: Record; // 부분 업데이트용 원본 데이터 onFormDataChange?: (fieldName: string, value: any) => void; onClick?: (e?: React.MouseEvent) => void; onDragStart?: (e: React.DragEvent) => void; @@ -24,6 +25,14 @@ export interface ComponentRenderer { tableName?: string; onRefresh?: () => void; onClose?: () => void; + // 테이블 선택된 행 정보 (다중 선택 액션용) + selectedRows?: any[]; + selectedRowsData?: any[]; + onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void; + // 테이블 새로고침 키 + refreshKey?: number; + // 편집 모드 + mode?: "view" | "edit"; [key: string]: any; }): React.ReactElement; } @@ -68,11 +77,19 @@ export interface DynamicComponentRendererProps { onDragStart?: (e: React.DragEvent) => void; onDragEnd?: () => void; children?: React.ReactNode; + // 폼 데이터 관련 + formData?: Record; + originalData?: Record; // 부분 업데이트용 원본 데이터 + onFormDataChange?: (fieldName: string, value: any) => void; // 버튼 액션을 위한 추가 props screenId?: number; tableName?: string; onRefresh?: () => void; onClose?: () => void; + // 편집 모드 + mode?: "view" | "edit"; + // 모달 내에서 렌더링 여부 + isInModal?: boolean; [key: string]: any; } diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index e9ce3201..e50632d6 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -28,6 +28,13 @@ export interface ButtonPrimaryComponentProps extends ComponentRendererProps { tableName?: string; onRefresh?: () => void; onClose?: () => void; + + // 폼 데이터 관련 + originalData?: Record; // 부분 업데이트용 원본 데이터 + + // 테이블 선택된 행 정보 (다중 선택 액션용) + selectedRows?: any[]; + selectedRowsData?: any[]; } /** @@ -46,11 +53,14 @@ export const ButtonPrimaryComponent: React.FC = ({ className, style, formData, + originalData, onFormDataChange, screenId, tableName, onRefresh, onClose, + selectedRows, + selectedRowsData, ...props }) => { // 확인 다이얼로그 상태 @@ -84,6 +94,8 @@ export const ButtonPrimaryComponent: React.FC = ({ tableName, onRefresh, onClose, + selectedRows, + selectedRowsData, }); // 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외) @@ -109,40 +121,48 @@ export const ButtonPrimaryComponent: React.FC = ({ let loadingToast: string | number | undefined; try { - console.log("📱 로딩 토스트 표시 시작"); - // 로딩 토스트 표시 - loadingToast = toast.loading( - actionConfig.type === "save" - ? "저장 중..." - : actionConfig.type === "delete" - ? "삭제 중..." - : actionConfig.type === "submit" - ? "제출 중..." - : "처리 중...", - ); - console.log("📱 로딩 토스트 ID:", loadingToast); + // edit 액션을 제외하고만 로딩 토스트 표시 + if (actionConfig.type !== "edit") { + console.log("📱 로딩 토스트 표시 시작"); + loadingToast = toast.loading( + actionConfig.type === "save" + ? "저장 중..." + : actionConfig.type === "delete" + ? "삭제 중..." + : actionConfig.type === "submit" + ? "제출 중..." + : "처리 중...", + ); + console.log("📱 로딩 토스트 ID:", loadingToast); + } console.log("⚡ ButtonActionExecutor.executeAction 호출 시작"); const success = await ButtonActionExecutor.executeAction(actionConfig, context); console.log("⚡ ButtonActionExecutor.executeAction 완료, success:", success); - // 로딩 토스트 제거 - console.log("📱 로딩 토스트 제거"); - toast.dismiss(loadingToast); + // 로딩 토스트 제거 (있는 경우에만) + if (loadingToast) { + console.log("📱 로딩 토스트 제거"); + toast.dismiss(loadingToast); + } - // 성공 시 토스트 표시 - const successMessage = - actionConfig.successMessage || - (actionConfig.type === "save" - ? "저장되었습니다." - : actionConfig.type === "delete" - ? "삭제되었습니다." - : actionConfig.type === "submit" - ? "제출되었습니다." - : "완료되었습니다."); + // edit 액션은 조용히 처리 (모달 열기만 하므로 토스트 불필요) + if (actionConfig.type !== "edit") { + const successMessage = + actionConfig.successMessage || + (actionConfig.type === "save" + ? "저장되었습니다." + : actionConfig.type === "delete" + ? "삭제되었습니다." + : actionConfig.type === "submit" + ? "제출되었습니다." + : "완료되었습니다."); - console.log("🎉 성공 토스트 표시:", successMessage); - toast.success(successMessage); + console.log("🎉 성공 토스트 표시:", successMessage); + toast.success(successMessage); + } else { + console.log("🔕 edit 액션은 조용히 처리 (토스트 없음)"); + } console.log("✅ 버튼 액션 실행 성공:", actionConfig.type); } catch (error) { @@ -186,11 +206,15 @@ export const ButtonPrimaryComponent: React.FC = ({ if (isInteractive && processedConfig.action) { const context: ButtonActionContext = { formData: formData || {}, + originalData: originalData || {}, // 부분 업데이트용 원본 데이터 추가 screenId, tableName, onFormDataChange, onRefresh, onClose, + // 테이블 선택된 행 정보 추가 + selectedRows, + selectedRowsData, }; // 확인이 필요한 액션인지 확인 @@ -245,6 +269,13 @@ export const ButtonPrimaryComponent: React.FC = ({ tableName: _tableName, onRefresh: _onRefresh, onClose: _onClose, + selectedRows: _selectedRows, + selectedRowsData: _selectedRowsData, + onSelectedRowsChange: _onSelectedRowsChange, + originalData: _originalData, // 부분 업데이트용 원본 데이터 필터링 + refreshKey: _refreshKey, // 필터링 추가 + isInModal: _isInModal, // 필터링 추가 + mode: _mode, // 필터링 추가 ...domProps } = props; diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index e42aebeb..21996dd9 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -21,6 +21,7 @@ import { ArrowDown, TableIcon, } from "lucide-react"; +import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/lib/utils"; export interface TableListComponentProps { @@ -48,6 +49,12 @@ export interface TableListComponentProps { onRefresh?: () => void; onClose?: () => void; screenId?: string; + + // 선택된 행 정보 전달 핸들러 + onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void; + + // 테이블 새로고침 키 + refreshKey?: number; } /** @@ -66,6 +73,8 @@ export const TableListComponent: React.FC = ({ style, onFormDataChange, componentConfig, + onSelectedRowsChange, + refreshKey, }) => { // 컴포넌트 설정 const tableConfig = { @@ -90,6 +99,11 @@ export const TableListComponent: React.FC = ({ const [selectedSearchColumn, setSelectedSearchColumn] = useState(""); // 선택된 검색 컬럼 const [displayColumns, setDisplayColumns] = useState([]); // 🎯 표시할 컬럼 (Entity 조인 적용됨) const [columnMeta, setColumnMeta] = useState>({}); // 🎯 컬럼 메타정보 (웹타입, 코드카테고리) + + // 체크박스 상태 관리 + const [selectedRows, setSelectedRows] = useState>(new Set()); // 선택된 행들의 키 집합 + const [isAllSelected, setIsAllSelected] = useState(false); // 전체 선택 상태 + // 🎯 Entity 조인 최적화 훅 사용 const { optimizedConvertCode } = useEntityJoinOptimization(columnMeta, { enableBatchLoading: true, @@ -407,6 +421,74 @@ export const TableListComponent: React.FC = ({ fetchTableData(); }; + // 체크박스 핸들러들 + const getRowKey = (row: any, index: number) => { + // 기본키가 있으면 사용, 없으면 인덱스 사용 + return row.id || row.objid || row.pk || index.toString(); + }; + + const handleRowSelection = (rowKey: string, checked: boolean) => { + const newSelectedRows = new Set(selectedRows); + if (checked) { + newSelectedRows.add(rowKey); + } else { + newSelectedRows.delete(rowKey); + } + setSelectedRows(newSelectedRows); + setIsAllSelected(newSelectedRows.size === data.length && data.length > 0); + + // 선택된 실제 데이터를 상위 컴포넌트로 전달 + const selectedKeys = Array.from(newSelectedRows); + const selectedData = selectedKeys + .map((key) => { + // rowKey를 사용하여 데이터 찾기 (ID 기반 또는 인덱스 기반) + return data.find((row, index) => { + const currentRowKey = getRowKey(row, index); + return currentRowKey === key; + }); + }) + .filter(Boolean); + + console.log("🔍 handleRowSelection 디버그:", { + rowKey, + checked, + selectedKeys, + selectedData, + dataCount: data.length, + }); + + onSelectedRowsChange?.(selectedKeys, selectedData); + + if (tableConfig.onSelectionChange) { + tableConfig.onSelectionChange(selectedData); + } + }; + + const handleSelectAll = (checked: boolean) => { + if (checked) { + const allKeys = data.map((row, index) => getRowKey(row, index)); + setSelectedRows(new Set(allKeys)); + setIsAllSelected(true); + + // 선택된 실제 데이터를 상위 컴포넌트로 전달 + onSelectedRowsChange?.(allKeys, data); + + if (tableConfig.onSelectionChange) { + tableConfig.onSelectionChange(data); + } + } else { + setSelectedRows(new Set()); + setIsAllSelected(false); + + // 빈 선택을 상위 컴포넌트로 전달 + onSelectedRowsChange?.([], []); + + if (tableConfig.onSelectionChange) { + tableConfig.onSelectionChange([]); + } + } + }; + // 효과 useEffect(() => { if (tableConfig.selectedTable) { @@ -442,15 +524,66 @@ export const TableListComponent: React.FC = ({ } }, [tableConfig.selectedTable, localPageSize, currentPage, searchTerm, sortColumn, sortDirection, columnLabels]); - // 표시할 컬럼 계산 (Entity 조인 적용됨) + // refreshKey 변경 시 테이블 데이터 새로고침 + useEffect(() => { + if (refreshKey && refreshKey > 0 && !isDesignMode) { + console.log("🔄 refreshKey 변경 감지, 테이블 데이터 새로고침:", refreshKey); + // 선택된 행 상태 초기화 + setSelectedRows(new Set()); + setIsAllSelected(false); + // 부모 컴포넌트에 빈 선택 상태 전달 + console.log("🔄 선택 상태 초기화 - 빈 배열 전달"); + onSelectedRowsChange?.([], []); + // 테이블 데이터 새로고침 + fetchTableData(); + } + }, [refreshKey]); + + // 표시할 컬럼 계산 (Entity 조인 적용됨 + 체크박스 컬럼 추가) const visibleColumns = useMemo(() => { + // 기본값 처리: checkbox 설정이 없으면 기본값 사용 + const checkboxConfig = tableConfig.checkbox || { + enabled: true, + multiple: true, + position: "left", + selectAll: true, + }; + + let columns: ColumnConfig[] = []; + if (!displayColumns || displayColumns.length === 0) { // displayColumns가 아직 설정되지 않은 경우 기본 컬럼 사용 if (!tableConfig.columns) return []; - return tableConfig.columns.filter((col) => col.visible).sort((a, b) => a.order - b.order); + columns = tableConfig.columns.filter((col) => col.visible).sort((a, b) => a.order - b.order); + } else { + columns = displayColumns.filter((col) => col.visible).sort((a, b) => a.order - b.order); } - return displayColumns.filter((col) => col.visible).sort((a, b) => a.order - b.order); - }, [displayColumns, tableConfig.columns]); + + // 체크박스가 활성화된 경우 체크박스 컬럼을 추가 + if (checkboxConfig.enabled) { + const checkboxColumn: ColumnConfig = { + columnName: "__checkbox__", + displayName: "", + visible: true, + sortable: false, + searchable: false, + width: 50, + align: "center", + order: -1, // 가장 앞에 위치 + fixed: checkboxConfig.position === "left" ? "left" : false, + fixedOrder: 0, // 가장 앞에 고정 + }; + + // 체크박스 위치에 따라 추가 + if (checkboxConfig.position === "left") { + columns.unshift(checkboxColumn); + } else { + columns.push(checkboxColumn); + } + } + + return columns; + }, [displayColumns, tableConfig.columns, tableConfig.checkbox]); // 컬럼을 고정 위치별로 분류 const columnsByPosition = useMemo(() => { @@ -502,6 +635,11 @@ export const TableListComponent: React.FC = ({ const getColumnWidth = (column: ColumnConfig) => { if (column.width) return column.width; + // 체크박스 컬럼인 경우 고정 너비 + if (column.columnName === "__checkbox__") { + return 50; + } + // 컬럼 헤더 텍스트 길이 기반으로 계산 const headerText = columnLabels[column.columnName] || column.displayName || column.columnName; const headerLength = headerText.length; @@ -518,6 +656,49 @@ export const TableListComponent: React.FC = ({ return Math.max(minWidth, calculatedWidth); }; + // 체크박스 헤더 렌더링 + const renderCheckboxHeader = () => { + // 기본값 처리: checkbox 설정이 없으면 기본값 사용 + const checkboxConfig = tableConfig.checkbox || { + enabled: true, + multiple: true, + position: "left", + selectAll: true, + }; + + if (!checkboxConfig.enabled || !checkboxConfig.selectAll) { + return null; + } + + return ; + }; + + // 체크박스 셀 렌더링 + const renderCheckboxCell = (row: any, index: number) => { + // 기본값 처리: checkbox 설정이 없으면 기본값 사용 + const checkboxConfig = tableConfig.checkbox || { + enabled: true, + multiple: true, + position: "left", + selectAll: true, + }; + + if (!checkboxConfig.enabled) { + return null; + } + + const rowKey = getRowKey(row, index); + const isSelected = selectedRows.has(rowKey); + + return ( + handleRowSelection(rowKey, checked as boolean)} + aria-label={`행 ${index + 1} 선택`} + /> + ); + }; + // 🎯 값 포맷팅 (전역 코드 캐시 사용) const formatCellValue = useMemo(() => { return (value: any, format?: string, columnName?: string) => { @@ -597,6 +778,13 @@ export const TableListComponent: React.FC = ({
+ {/* 선택된 항목 정보 표시 */} + {selectedRows.size > 0 && ( +
+ {selectedRows.size}개 선택됨 +
+ )} + {/* 검색 */} {tableConfig.filter?.enabled && tableConfig.filter?.quickSearch && (
@@ -657,36 +845,52 @@ export const TableListComponent: React.FC = ({ {/* 왼쪽 고정 컬럼 */} {columnsByPosition.leftFixed.length > 0 && (
- +
{columnsByPosition.leftFixed.map((column) => ( ))} @@ -703,18 +907,25 @@ export const TableListComponent: React.FC = ({ handleRowClick(row)} > {columnsByPosition.leftFixed.map((column) => ( ))} @@ -727,7 +938,10 @@ export const TableListComponent: React.FC = ({ {/* 스크롤 가능한 중앙 컬럼들 */}
-
column.sortable && handleSort(column.columnName)} > -
- {columnLabels[column.columnName] || column.displayName} - {column.sortable && ( -
- {sortColumn === column.columnName ? ( - sortDirection === "asc" ? ( - + {column.columnName === "__checkbox__" ? ( + renderCheckboxHeader() + ) : ( +
+ {columnLabels[column.columnName] || column.displayName} + {column.sortable && ( +
+ {sortColumn === column.columnName ? ( + sortDirection === "asc" ? ( + + ) : ( + + ) ) : ( - - ) - ) : ( - - )} -
- )} -
+ + )} +
+ )} +
+ )}
- {formatCellValue(row[column.columnName], column.format, column.columnName)} + {column.columnName === "__checkbox__" + ? renderCheckboxCell(row, index) + : formatCellValue(row[column.columnName], column.format, column.columnName) || "\u00A0"}
+
{columnsByPosition.normal.map((column) => ( @@ -735,28 +949,34 @@ export const TableListComponent: React.FC = ({ key={`normal-${column.columnName}`} style={{ minWidth: `${getColumnWidth(column)}px` }} className={cn( - "cursor-pointer border-b px-4 py-3 text-left font-medium whitespace-nowrap text-gray-900 select-none", + column.columnName === "__checkbox__" + ? "h-12 border-b px-4 py-3 text-center" + : "cursor-pointer border-b px-4 py-3 text-left font-medium whitespace-nowrap text-gray-900 select-none", `text-${column.align}`, column.sortable && "hover:bg-gray-50", )} onClick={() => column.sortable && handleSort(column.columnName)} > -
- {columnLabels[column.columnName] || column.displayName} - {column.sortable && ( -
- {sortColumn === column.columnName ? ( - sortDirection === "asc" ? ( - + {column.columnName === "__checkbox__" ? ( + renderCheckboxHeader() + ) : ( +
+ {columnLabels[column.columnName] || column.displayName} + {column.sortable && ( +
+ {sortColumn === column.columnName ? ( + sortDirection === "asc" ? ( + + ) : ( + + ) ) : ( - - ) - ) : ( - - )} -
- )} -
+ + )} +
+ )} +
+ )} ))}
@@ -784,9 +1004,15 @@ export const TableListComponent: React.FC = ({ {columnsByPosition.normal.map((column) => ( ))} @@ -799,36 +1025,52 @@ export const TableListComponent: React.FC = ({ {/* 오른쪽 고정 컬럼 */} {columnsByPosition.rightFixed.length > 0 && (
-
- {formatCellValue(row[column.columnName], column.format, column.columnName)} + {column.columnName === "__checkbox__" + ? renderCheckboxCell(row, index) + : formatCellValue(row[column.columnName], column.format, column.columnName) || "\u00A0"}
+
{columnsByPosition.rightFixed.map((column) => ( ))} @@ -847,18 +1089,25 @@ export const TableListComponent: React.FC = ({ handleRowClick(row)} > {columnsByPosition.rightFixed.map((column) => ( ))} @@ -873,34 +1122,47 @@ export const TableListComponent: React.FC = ({ // 기존 테이블 (가로 스크롤이 필요 없는 경우)
column.sortable && handleSort(column.columnName)} > -
- {columnLabels[column.columnName] || column.displayName} - {column.sortable && ( -
- {sortColumn === column.columnName ? ( - sortDirection === "asc" ? ( - + {column.columnName === "__checkbox__" ? ( + renderCheckboxHeader() + ) : ( +
+ {columnLabels[column.columnName] || column.displayName} + {column.sortable && ( +
+ {sortColumn === column.columnName ? ( + sortDirection === "asc" ? ( + + ) : ( + + ) ) : ( - - ) - ) : ( - - )} -
- )} -
+ + )} +
+ )} +
+ )}
- {formatCellValue(row[column.columnName], column.format, column.columnName)} + {column.columnName === "__checkbox__" + ? renderCheckboxCell(row, index) + : formatCellValue(row[column.columnName], column.format, column.columnName) || "\u00A0"}
- + {visibleColumns.map((column) => ( column.sortable && handleSort(column.columnName)} > -
- {columnLabels[column.columnName] || column.displayName} - {column.sortable && ( -
- {sortColumn === column.columnName ? ( - sortDirection === "asc" ? ( - + {column.columnName === "__checkbox__" ? ( + renderCheckboxHeader() + ) : ( +
+ {columnLabels[column.columnName] || column.displayName} + {column.sortable && ( +
+ {sortColumn === column.columnName ? ( + sortDirection === "asc" ? ( + + ) : ( + + ) ) : ( - - ) - ) : ( - - )} -
- )} -
+ + )} +
+ )} +
+ )}
))}
@@ -917,15 +1179,22 @@ export const TableListComponent: React.FC = ({ handleRowClick(row)} > {visibleColumns.map((column) => ( - - {formatCellValue(row[column.columnName], column.format, column.columnName)} + + {column.columnName === "__checkbox__" + ? renderCheckboxCell(row, index) + : formatCellValue(row[column.columnName], column.format, column.columnName) || "\u00A0"} ))} diff --git a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx index 665b2f40..75509ea9 100644 --- a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx +++ b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx @@ -548,6 +548,63 @@ export const TableListConfigPanel: React.FC = ({ )} + + + + 체크박스 설정 + 행 선택을 위한 체크박스 기능을 설정하세요 + + +
+ handleNestedChange("checkbox", "enabled", checked)} + /> + +
+ + {config.checkbox?.enabled && ( +
+
+ handleNestedChange("checkbox", "multiple", checked)} + /> + +
+ +
+ + +
+ +
+ handleNestedChange("checkbox", "selectAll", checked)} + /> + +
+
+ )} +
+
diff --git a/frontend/lib/registry/components/table-list/index.ts b/frontend/lib/registry/components/table-list/index.ts index 770dddaf..fbec7fc6 100644 --- a/frontend/lib/registry/components/table-list/index.ts +++ b/frontend/lib/registry/components/table-list/index.ts @@ -26,6 +26,14 @@ export const TableListDefinition = createComponentDefinition({ showFooter: true, height: "auto", + // 체크박스 설정 + checkbox: { + enabled: true, + multiple: true, + position: "left", + selectAll: true, + }, + // 컬럼 설정 columns: [], autoWidth: true, diff --git a/frontend/lib/registry/components/table-list/types.ts b/frontend/lib/registry/components/table-list/types.ts index b341eb65..42dbd7d3 100644 --- a/frontend/lib/registry/components/table-list/types.ts +++ b/frontend/lib/registry/components/table-list/types.ts @@ -89,6 +89,16 @@ export interface PaginationConfig { pageSizeOptions: number[]; } +/** + * 체크박스 설정 + */ +export interface CheckboxConfig { + enabled: boolean; // 체크박스 활성화 여부 + multiple: boolean; // 다중 선택 가능 여부 (true: 체크박스, false: 라디오) + position: "left" | "right"; // 체크박스 위치 + selectAll: boolean; // 전체 선택/해제 버튼 표시 여부 +} + /** * TableList 컴포넌트 설정 타입 */ @@ -100,6 +110,9 @@ export interface TableListConfig extends ComponentConfig { showHeader: boolean; showFooter: boolean; + // 체크박스 설정 + checkbox: CheckboxConfig; + // 높이 설정 height: "auto" | "fixed" | "viewport"; fixedHeight?: number; @@ -140,6 +153,9 @@ export interface TableListConfig extends ComponentConfig { onPageChange?: (page: number, pageSize: number) => void; onSortChange?: (column: string, direction: "asc" | "desc") => void; onFilterChange?: (filters: any) => void; + + // 선택된 행 정보 전달 핸들러 + onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void; } /** @@ -182,4 +198,7 @@ export interface TableListProps { onSortChange?: (column: string, direction: "asc" | "desc") => void; onFilterChange?: (filters: any) => void; onRefresh?: () => void; + + // 선택된 행 정보 전달 핸들러 + onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void; } diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index f9740c93..d4c57c9f 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -53,11 +53,16 @@ export interface ButtonActionConfig { */ export interface ButtonActionContext { formData: Record; + originalData?: Record; // 부분 업데이트용 원본 데이터 screenId?: number; tableName?: string; onFormDataChange?: (fieldName: string, value: any) => void; onClose?: () => void; onRefresh?: () => void; + + // 테이블 선택된 행 정보 (다중 선택 액션용) + selectedRows?: any[]; + selectedRowsData?: any[]; } /** @@ -123,10 +128,10 @@ export class ButtonActionExecutor { } /** - * 저장 액션 처리 + * 저장 액션 처리 (INSERT/UPDATE 자동 판단 - DB 기반) */ private static async handleSave(config: ButtonActionConfig, context: ButtonActionContext): Promise { - const { formData, tableName, screenId } = context; + const { formData, originalData, tableName, screenId } = context; // 폼 유효성 검사 if (config.validateForm) { @@ -152,16 +157,59 @@ export class ButtonActionExecutor { throw new Error(`저장 실패: ${response.statusText}`); } } else if (tableName && screenId) { - // 기본 테이블 저장 로직 - console.log("테이블 저장:", { tableName, formData, screenId }); + // DB에서 실제 기본키 조회하여 INSERT/UPDATE 자동 판단 + const primaryKeyResult = await DynamicFormApi.getTablePrimaryKeys(tableName); - // 실제 저장 API 호출 - const saveResult = await DynamicFormApi.saveFormData({ - screenId, + if (!primaryKeyResult.success) { + throw new Error(primaryKeyResult.message || "기본키 조회에 실패했습니다."); + } + + const primaryKeys = primaryKeyResult.data || []; + const primaryKeyValue = this.extractPrimaryKeyValueFromDB(formData, primaryKeys); + const isUpdate = primaryKeyValue !== null && primaryKeyValue !== undefined && primaryKeyValue !== ""; + + console.log("💾 저장 모드 판단 (DB 기반):", { tableName, - data: formData, + formData, + primaryKeys, + primaryKeyValue, + isUpdate: isUpdate ? "UPDATE" : "INSERT", }); + let saveResult; + + if (isUpdate) { + // UPDATE 처리 - 부분 업데이트 사용 (원본 데이터가 있는 경우) + console.log("🔄 UPDATE 모드로 저장:", { + primaryKeyValue, + formData, + originalData, + hasOriginalData: !!originalData, + }); + + if (originalData) { + // 부분 업데이트: 변경된 필드만 업데이트 + console.log("📝 부분 업데이트 실행 (변경된 필드만)"); + saveResult = await DynamicFormApi.updateFormDataPartial(primaryKeyValue, originalData, formData, tableName); + } else { + // 전체 업데이트 (기존 방식) + console.log("📝 전체 업데이트 실행 (모든 필드)"); + saveResult = await DynamicFormApi.updateFormData(primaryKeyValue, { + tableName, + data: formData, + }); + } + } else { + // INSERT 처리 + console.log("🆕 INSERT 모드로 저장:", { formData }); + + saveResult = await DynamicFormApi.saveFormData({ + screenId, + tableName, + data: formData, + }); + } + if (!saveResult.success) { throw new Error(saveResult.message || "저장에 실패했습니다."); } @@ -179,6 +227,76 @@ export class ButtonActionExecutor { } } + /** + * DB에서 조회한 실제 기본키로 formData에서 값 추출 + * @param formData 폼 데이터 + * @param primaryKeys DB에서 조회한 실제 기본키 컬럼명 배열 + * @returns 기본키 값 (복합키의 경우 첫 번째 키 값) + */ + private static extractPrimaryKeyValueFromDB(formData: Record, primaryKeys: string[]): any { + if (!primaryKeys || primaryKeys.length === 0) { + console.log("🔍 DB에서 기본키를 찾을 수 없습니다. INSERT 모드로 처리됩니다."); + return null; + } + + // 첫 번째 기본키 컬럼의 값을 사용 (복합키의 경우) + const primaryKeyColumn = primaryKeys[0]; + + if (formData.hasOwnProperty(primaryKeyColumn)) { + const value = formData[primaryKeyColumn]; + console.log(`🔑 DB 기본키 발견: ${primaryKeyColumn} = ${value}`); + + // 복합키인 경우 로그 출력 + if (primaryKeys.length > 1) { + console.log(`🔗 복합 기본키 감지:`, primaryKeys); + console.log(`📍 첫 번째 키 (${primaryKeyColumn}) 값을 사용: ${value}`); + } + + return value; + } + + // 기본키 컬럼이 formData에 없는 경우 + console.log(`❌ 기본키 컬럼 '${primaryKeyColumn}'이 formData에 없습니다. INSERT 모드로 처리됩니다.`); + console.log("📋 DB 기본키 컬럼들:", primaryKeys); + console.log("📋 사용 가능한 필드들:", Object.keys(formData)); + return null; + } + + /** + * @deprecated DB 기반 조회로 대체됨. extractPrimaryKeyValueFromDB 사용 권장 + * formData에서 기본 키값 추출 (추측 기반) + */ + private static extractPrimaryKeyValue(formData: Record): any { + // 일반적인 기본 키 필드명들 (우선순위 순) + const commonPrimaryKeys = [ + "id", + "ID", // 가장 일반적 + "objid", + "OBJID", // 이 프로젝트에서 자주 사용 + "pk", + "PK", // Primary Key 줄임말 + "_id", // MongoDB 스타일 + "uuid", + "UUID", // UUID 방식 + "key", + "KEY", // 기타 + ]; + + // 우선순위에 따라 기본 키값 찾기 + for (const keyName of commonPrimaryKeys) { + if (formData.hasOwnProperty(keyName)) { + const value = formData[keyName]; + console.log(`🔑 추측 기반 기본 키 발견: ${keyName} = ${value}`); + return value; + } + } + + // 기본 키를 찾지 못한 경우 + console.log("🔍 추측 기반으로 기본 키를 찾을 수 없습니다. INSERT 모드로 처리됩니다."); + console.log("📋 사용 가능한 필드들:", Object.keys(formData)); + return null; + } + /** * 제출 액션 처리 */ @@ -191,20 +309,50 @@ export class ButtonActionExecutor { * 삭제 액션 처리 */ private static async handleDelete(config: ButtonActionConfig, context: ButtonActionContext): Promise { - const { formData, tableName, screenId } = context; + const { formData, tableName, screenId, selectedRowsData } = context; try { + // 다중 선택된 행이 있는 경우 (테이블에서 체크박스로 선택) + if (selectedRowsData && selectedRowsData.length > 0) { + console.log(`다중 삭제 액션 실행: ${selectedRowsData.length}개 항목`, selectedRowsData); + + // 각 선택된 항목을 삭제 + for (const rowData of selectedRowsData) { + // 더 포괄적인 ID 찾기 (테이블 구조에 따라 다양한 필드명 시도) + const deleteId = rowData.id || rowData.objid || rowData.pk || rowData.ID || rowData.OBJID || rowData.PK; + console.log("선택된 행 데이터:", rowData); + console.log("추출된 deleteId:", deleteId); + + if (deleteId) { + console.log("다중 데이터 삭제:", { tableName, screenId, id: deleteId }); + + const deleteResult = await DynamicFormApi.deleteFormDataFromTable(deleteId, tableName); + if (!deleteResult.success) { + throw new Error(`ID ${deleteId} 삭제 실패: ${deleteResult.message}`); + } + } else { + console.error("삭제 ID를 찾을 수 없습니다. 행 데이터:", rowData); + throw new Error(`삭제 ID를 찾을 수 없습니다. 사용 가능한 필드: ${Object.keys(rowData).join(", ")}`); + } + } + + console.log(`✅ 다중 삭제 성공: ${selectedRowsData.length}개 항목`); + context.onRefresh?.(); // 테이블 새로고침 + return true; + } + + // 단일 삭제 (기존 로직) if (tableName && screenId && formData.id) { - console.log("데이터 삭제:", { tableName, screenId, id: formData.id }); + console.log("단일 데이터 삭제:", { tableName, screenId, id: formData.id }); // 실제 삭제 API 호출 - const deleteResult = await DynamicFormApi.deleteFormData(formData.id); + const deleteResult = await DynamicFormApi.deleteFormDataFromTable(formData.id, tableName); if (!deleteResult.success) { throw new Error(deleteResult.message || "삭제에 실패했습니다."); } - console.log("✅ 삭제 성공:", deleteResult); + console.log("✅ 단일 삭제 성공:", deleteResult); } else { throw new Error("삭제에 필요한 정보가 부족합니다. (ID, 테이블명 또는 화면ID 누락)"); } @@ -284,7 +432,7 @@ export class ButtonActionExecutor { size: config.modalSize || "md", }, }); - + window.dispatchEvent(modalEvent); toast.success("모달 화면이 열렸습니다."); } else { @@ -383,11 +531,118 @@ export class ButtonActionExecutor { * 편집 액션 처리 */ private static handleEdit(config: ButtonActionConfig, context: ButtonActionContext): boolean { - console.log("편집 액션 실행:", context); - // 편집 로직 구현 (예: 편집 모드로 전환) + const { selectedRowsData } = context; + + // 선택된 행이 없는 경우 + if (!selectedRowsData || selectedRowsData.length === 0) { + toast.error("수정할 항목을 선택해주세요."); + return false; + } + + // 편집 화면이 설정되지 않은 경우 + if (!config.targetScreenId) { + toast.error("수정 폼 화면이 설정되지 않았습니다. 버튼 설정에서 수정 폼 화면을 선택해주세요."); + return false; + } + + console.log(`📝 편집 액션 실행: ${selectedRowsData.length}개 항목`, { + selectedRowsData, + targetScreenId: config.targetScreenId, + editMode: config.editMode, + }); + + if (selectedRowsData.length === 1) { + // 단일 항목 편집 + const rowData = selectedRowsData[0]; + console.log("📝 단일 항목 편집:", rowData); + + this.openEditForm(config, rowData, context); + } else { + // 다중 항목 편집 - 현재는 단일 편집만 지원 + toast.error("현재 단일 항목 편집만 지원됩니다. 하나의 항목만 선택해주세요."); + return false; + + // TODO: 향후 다중 편집 지원 + // console.log("📝 다중 항목 편집:", selectedRowsData); + // this.openBulkEditForm(config, selectedRowsData, context); + } + return true; } + /** + * 편집 폼 열기 (단일 항목) + */ + private static openEditForm(config: ButtonActionConfig, rowData: any, context: ButtonActionContext): void { + const editMode = config.editMode || "modal"; + + switch (editMode) { + case "modal": + // 모달로 편집 폼 열기 + this.openEditModal(config, rowData, context); + break; + + case "navigate": + // 새 페이지로 이동 + this.navigateToEditScreen(config, rowData, context); + break; + + case "inline": + // 현재 화면에서 인라인 편집 (향후 구현) + toast.info("인라인 편집 기능은 향후 지원 예정입니다."); + break; + + default: + // 기본값: 모달 + this.openEditModal(config, rowData, context); + } + } + + /** + * 편집 모달 열기 + */ + private static openEditModal(config: ButtonActionConfig, rowData: any, context: ButtonActionContext): void { + console.log("🎭 편집 모달 열기:", { + targetScreenId: config.targetScreenId, + modalSize: config.modalSize, + rowData, + }); + + // 모달 열기 이벤트 발생 + const modalEvent = new CustomEvent("openEditModal", { + detail: { + screenId: config.targetScreenId, + modalSize: config.modalSize || "lg", + editData: rowData, + onSave: () => { + // 저장 후 테이블 새로고침 + console.log("💾 편집 저장 완료 - 테이블 새로고침"); + context.onRefresh?.(); + }, + }, + }); + + window.dispatchEvent(modalEvent); + // 편집 모달 열기는 조용히 처리 (토스트 없음) + } + + /** + * 편집 화면으로 이동 + */ + private static navigateToEditScreen(config: ButtonActionConfig, rowData: any, context: ButtonActionContext): void { + const rowId = rowData.id || rowData.objid || rowData.pk || rowData.ID || rowData.OBJID || rowData.PK; + + if (!rowId) { + toast.error("수정할 항목의 ID를 찾을 수 없습니다."); + return; + } + + const editUrl = `/screens/${config.targetScreenId}?mode=edit&id=${rowId}`; + console.log("🔄 편집 화면으로 이동:", editUrl); + + window.location.href = editUrl; + } + /** * 닫기 액션 처리 */