From 78d4d7de23a739f76bca2f803b01a066856a8158 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 4 Sep 2025 14:23:35 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B0=9C=EB=B3=84=20=EC=9C=84=EC=A0=AF?= =?UTF-8?q?=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EB=B0=8F=20=EC=9E=90=EB=8F=99=20=EA=B0=92?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BaseComponent에 inputType, autoValueType 속성 추가 - DetailSettingsPanel에 입력 타입 및 자동 값 타입 선택 UI 추가 - RealtimePreview에서 자동 값 타입별 값 생성 및 표시 로직 구현 - 텍스트, 숫자, 날짜 위젯에서 7가지 자동 값 타입 지원 - 현재 날짜시간, 현재 날짜, 현재 시간 - 현재 사용자, UUID, 시퀀스, 사용자 정의 - 자동입력 모드에서 읽기 전용 스타일 적용 (회색 배경) - 백엔드 API에 input_type 처리 로직 추가 - TableTypeSelector에 입력 타입 설정 UI 추가 --- backend-node/src/app.ts | 2 + .../src/controllers/dynamicFormController.ts | 310 ++++++++++ .../controllers/tableManagementController.ts | 5 +- backend-node/src/routes/dynamicFormRoutes.ts | 33 + .../src/services/dynamicFormService.ts | 573 ++++++++++++++++++ .../src/services/tableManagementService.ts | 42 +- backend-node/src/types/screen.ts | 1 + backend-node/src/types/tableManagement.ts | 1 + .../app/(main)/screens/[screenId]/page.tsx | 4 + .../screen/InteractiveScreenViewer.tsx | 305 +++++++++- .../components/screen/RealtimePreview.tsx | 175 +++++- .../components/screen/TableTypeSelector.tsx | 41 ++ .../screen/panels/ButtonConfigPanel.tsx | 56 +- .../screen/panels/DetailSettingsPanel.tsx | 82 +++ frontend/lib/api/dynamicForm.ts | 319 ++++++++++ frontend/lib/api/screen.ts | 2 + frontend/types/screen.ts | 10 + 17 files changed, 1912 insertions(+), 49 deletions(-) create mode 100644 backend-node/src/controllers/dynamicFormController.ts create mode 100644 backend-node/src/routes/dynamicFormRoutes.ts create mode 100644 backend-node/src/services/dynamicFormService.ts create mode 100644 frontend/lib/api/dynamicForm.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 3b281d19..53a00c0b 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -15,6 +15,7 @@ import multilangRoutes from "./routes/multilangRoutes"; import tableManagementRoutes from "./routes/tableManagementRoutes"; import screenManagementRoutes from "./routes/screenManagementRoutes"; import commonCodeRoutes from "./routes/commonCodeRoutes"; +import dynamicFormRoutes from "./routes/dynamicFormRoutes"; // import userRoutes from './routes/userRoutes'; // import menuRoutes from './routes/menuRoutes'; @@ -67,6 +68,7 @@ app.use("/api/multilang", multilangRoutes); app.use("/api/table-management", tableManagementRoutes); app.use("/api/screen-management", screenManagementRoutes); app.use("/api/common-codes", commonCodeRoutes); +app.use("/api/dynamic-form", dynamicFormRoutes); // app.use('/api/users', userRoutes); // app.use('/api/menus', menuRoutes); diff --git a/backend-node/src/controllers/dynamicFormController.ts b/backend-node/src/controllers/dynamicFormController.ts new file mode 100644 index 00000000..b3cb79da --- /dev/null +++ b/backend-node/src/controllers/dynamicFormController.ts @@ -0,0 +1,310 @@ +import { Response } from "express"; +import { dynamicFormService } from "../services/dynamicFormService"; +import { AuthenticatedRequest } from "../types/auth"; + +// 폼 데이터 저장 +export const saveFormData = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, userId } = req.user as any; + const { screenId, tableName, data } = req.body; + + console.log("💾 폼 데이터 저장 요청:", { + userId, + companyCode, + screenId, + tableName, + data, + }); + + // 필수 필드 검증 + if (!screenId || !tableName || !data) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (screenId, tableName, data)", + }); + } + + // 메타데이터 추가 (사용자가 입력한 경우에만 company_code 추가) + const formDataWithMeta = { + ...data, + created_by: userId, + updated_by: userId, + screen_id: screenId, + }; + + // company_code는 사용자가 명시적으로 입력한 경우에만 추가 + if (data.company_code !== undefined) { + formDataWithMeta.company_code = data.company_code; + } else if (companyCode && companyCode !== "*") { + // 기본 company_code가 '*'가 아닌 경우에만 추가 + formDataWithMeta.company_code = companyCode; + } + + const result = await dynamicFormService.saveFormData( + screenId, + tableName, + formDataWithMeta + ); + + console.log("✅ 폼 데이터 저장 성공:", result); + + res.json({ + success: true, + data: result, + message: "데이터가 성공적으로 저장되었습니다.", + }); + } catch (error: any) { + console.error("❌ 폼 데이터 저장 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "데이터 저장에 실패했습니다.", + }); + } +}; + +// 폼 데이터 업데이트 +export const updateFormData = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { id } = req.params; + const { companyCode, userId } = req.user as any; + const { tableName, data } = req.body; + + console.log("🔄 폼 데이터 업데이트 요청:", { + id, + userId, + companyCode, + tableName, + data, + }); + + if (!tableName || !data) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (tableName, data)", + }); + } + + // 메타데이터 추가 + const formDataWithMeta = { + ...data, + updated_by: userId, + updated_at: new Date(), + }; + + const result = await dynamicFormService.updateFormData( + parseInt(id), + tableName, + formDataWithMeta + ); + + console.log("✅ 폼 데이터 업데이트 성공:", result); + + 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, + res: Response +): Promise => { + try { + const { id } = req.params; + const { companyCode } = req.user as any; + const { tableName } = req.body; + + console.log("🗑️ 폼 데이터 삭제 요청:", { id, companyCode, tableName }); + + if (!tableName) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (tableName)", + }); + } + + await dynamicFormService.deleteFormData(parseInt(id), tableName); + + console.log("✅ 폼 데이터 삭제 성공"); + + res.json({ + success: true, + message: "데이터가 성공적으로 삭제되었습니다.", + }); + } catch (error: any) { + console.error("❌ 폼 데이터 삭제 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "데이터 삭제에 실패했습니다.", + }); + } +}; + +// 단일 폼 데이터 조회 +export const getFormData = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { id } = req.params; + const { companyCode } = req.user as any; + + console.log("📄 폼 데이터 단건 조회 요청:", { id, companyCode }); + + const data = await dynamicFormService.getFormData(parseInt(id)); + + if (!data) { + return res.status(404).json({ + success: false, + message: "데이터를 찾을 수 없습니다.", + }); + } + + console.log("✅ 폼 데이터 단건 조회 성공"); + + res.json({ + success: true, + data: data, + }); + } catch (error: any) { + console.error("❌ 폼 데이터 단건 조회 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "데이터 조회에 실패했습니다.", + }); + } +}; + +// 화면별 폼 데이터 목록 조회 +export const getFormDataList = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { screenId } = req.params; + const { companyCode } = req.user as any; + const { + page = 1, + size = 10, + search = "", + sortBy = "created_at", + sortOrder = "desc", + } = req.query; + + console.log("📋 폼 데이터 목록 조회 요청:", { + screenId, + companyCode, + page, + size, + search, + sortBy, + sortOrder, + }); + + const result = await dynamicFormService.getFormDataList( + parseInt(screenId as string), + { + page: parseInt(page as string), + size: parseInt(size as string), + search: search as string, + sortBy: sortBy as string, + sortOrder: sortOrder as "asc" | "desc", + } + ); + + console.log("✅ 폼 데이터 목록 조회 성공"); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + console.error("❌ 폼 데이터 목록 조회 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "데이터 조회에 실패했습니다.", + }); + } +}; + +// 폼 데이터 검증 +export const validateFormData = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { tableName, data } = req.body; + + console.log("✅ 폼 데이터 검증 요청:", { tableName, data }); + + if (!tableName || !data) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (tableName, data)", + }); + } + + const validationResult = await dynamicFormService.validateFormData( + tableName, + data + ); + + console.log("✅ 폼 데이터 검증 성공:", validationResult); + + res.json({ + success: true, + data: validationResult, + }); + } catch (error: any) { + console.error("❌ 폼 데이터 검증 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "데이터 검증에 실패했습니다.", + }); + } +}; + +// 테이블 컬럼 정보 조회 (검증용) +export const getTableColumns = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { tableName } = req.params; + + console.log("📊 테이블 컬럼 정보 조회 요청:", { tableName }); + + const columns = await dynamicFormService.getTableColumns(tableName); + + console.log("✅ 테이블 컬럼 정보 조회 성공"); + + res.json({ + success: true, + data: { + tableName, + columns, + }, + }); + } catch (error: any) { + console.error("❌ 테이블 컬럼 정보 조회 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "테이블 정보 조회에 실패했습니다.", + }); + } +}; diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index bbee31d4..c189b9d8 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -386,7 +386,7 @@ export async function updateColumnWebType( ): Promise { try { const { tableName, columnName } = req.params; - const { webType, detailSettings } = req.body; + const { webType, detailSettings, inputType } = req.body; logger.info( `=== 컬럼 웹 타입 설정 시작: ${tableName}.${columnName} = ${webType} ===` @@ -410,7 +410,8 @@ export async function updateColumnWebType( tableName, columnName, webType, - detailSettings + detailSettings, + inputType ); logger.info( diff --git a/backend-node/src/routes/dynamicFormRoutes.ts b/backend-node/src/routes/dynamicFormRoutes.ts new file mode 100644 index 00000000..f37a84ae --- /dev/null +++ b/backend-node/src/routes/dynamicFormRoutes.ts @@ -0,0 +1,33 @@ +import express from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { + saveFormData, + updateFormData, + deleteFormData, + getFormData, + getFormDataList, + validateFormData, + getTableColumns, +} from "../controllers/dynamicFormController"; + +const router = express.Router(); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +// 폼 데이터 CRUD +router.post("/save", saveFormData); +router.put("/:id", updateFormData); +router.delete("/:id", deleteFormData); +router.get("/:id", getFormData); + +// 화면별 폼 데이터 목록 조회 +router.get("/screen/:screenId", getFormDataList); + +// 폼 데이터 검증 +router.post("/validate", validateFormData); + +// 테이블 컬럼 정보 조회 (검증용) +router.get("/table/:tableName/columns", getTableColumns); + +export default router; diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts new file mode 100644 index 00000000..3b424dac --- /dev/null +++ b/backend-node/src/services/dynamicFormService.ts @@ -0,0 +1,573 @@ +import prisma from "../config/database"; +import { Prisma } from "@prisma/client"; + +export interface FormDataResult { + id: number; + screenId: number; + tableName: string; + data: Record; + createdAt: Date | null; + updatedAt: Date | null; + createdBy: string; + updatedBy: string; +} + +export interface PaginatedFormData { + content: FormDataResult[]; + totalElements: number; + totalPages: number; + currentPage: number; + size: number; +} + +export interface ValidationError { + field: string; + message: string; + code: string; +} + +export interface ValidationResult { + valid: boolean; + errors: ValidationError[]; +} + +export interface TableColumn { + columnName: string; + dataType: string; + nullable: boolean; + primaryKey: boolean; + maxLength?: number; + defaultValue?: any; +} + +export class DynamicFormService { + /** + * 테이블의 컬럼명 목록 조회 (간단 버전) + */ + private async getTableColumnNames(tableName: string): Promise { + try { + const result = (await prisma.$queryRawUnsafe(` + SELECT column_name + FROM information_schema.columns + WHERE table_name = '${tableName}' + AND table_schema = 'public' + `)) as any[]; + + return result.map((row) => row.column_name); + } catch (error) { + console.error(`❌ 테이블 ${tableName} 컬럼 정보 조회 실패:`, error); + return []; + } + } + + /** + * 테이블의 Primary Key 컬럼 조회 + */ + private async getTablePrimaryKeys(tableName: string): Promise { + try { + const result = (await prisma.$queryRawUnsafe(` + 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 = '${tableName}' + AND tc.constraint_type = 'PRIMARY KEY' + AND tc.table_schema = 'public' + `)) as any[]; + + return result.map((row) => row.column_name); + } catch (error) { + console.error(`❌ 테이블 ${tableName} Primary Key 조회 실패:`, error); + return []; + } + } + + /** + * 폼 데이터 저장 (실제 테이블에 직접 저장) + */ + async saveFormData( + screenId: number, + tableName: string, + data: Record + ): Promise { + try { + console.log("💾 서비스: 실제 테이블에 폼 데이터 저장 시작:", { + screenId, + tableName, + data, + }); + + // 테이블의 실제 컬럼 정보와 Primary Key 조회 + const tableColumns = await this.getTableColumnNames(tableName); + const primaryKeys = await this.getTablePrimaryKeys(tableName); + console.log(`📋 테이블 ${tableName}의 컬럼:`, tableColumns); + console.log(`🔑 테이블 ${tableName}의 Primary Key:`, primaryKeys); + + // 메타데이터 제거 (실제 테이블 컬럼이 아님) + const { created_by, updated_by, company_code, screen_id, ...actualData } = + data; + + // 기본 데이터 준비 + const dataToInsert: any = { ...actualData }; + + // 테이블에 존재하는 공통 필드들만 추가 + if (tableColumns.includes("created_at")) { + dataToInsert.created_at = new Date(); + } + if (tableColumns.includes("updated_at")) { + dataToInsert.updated_at = new Date(); + } + if (tableColumns.includes("regdate") && !dataToInsert.regdate) { + dataToInsert.regdate = new Date(); + } + + // 생성자/수정자 정보가 있고 해당 컬럼이 존재한다면 추가 + if (created_by && tableColumns.includes("created_by")) { + dataToInsert.created_by = created_by; + } + if (updated_by && tableColumns.includes("updated_by")) { + dataToInsert.updated_by = updated_by; + } + if (company_code && tableColumns.includes("company_code")) { + dataToInsert.company_code = company_code; + } + + // 존재하지 않는 컬럼 제거 + Object.keys(dataToInsert).forEach((key) => { + if (!tableColumns.includes(key)) { + console.log( + `⚠️ 컬럼 ${key}는 테이블 ${tableName}에 존재하지 않아 제거됨` + ); + delete dataToInsert[key]; + } + }); + + console.log("🎯 실제 테이블에 삽입할 데이터:", { + tableName, + dataToInsert, + }); + + // 동적 SQL을 사용하여 실제 테이블에 UPSERT + const columns = Object.keys(dataToInsert); + const values: any[] = Object.values(dataToInsert); + const placeholders = values.map((_, index) => `$${index + 1}`).join(", "); + + let upsertQuery: string; + + if (primaryKeys.length > 0) { + // Primary Key가 있는 경우 UPSERT 사용 + const conflictColumns = primaryKeys.join(", "); + const updateSet = columns + .filter((col) => !primaryKeys.includes(col)) // Primary Key는 UPDATE에서 제외 + .map((col) => `${col} = EXCLUDED.${col}`) + .join(", "); + + if (updateSet) { + upsertQuery = ` + INSERT INTO ${tableName} (${columns.join(", ")}) + VALUES (${placeholders}) + ON CONFLICT (${conflictColumns}) + DO UPDATE SET ${updateSet} + RETURNING * + `; + } else { + // 업데이트할 컬럼이 없는 경우 (Primary Key만 있는 테이블) + upsertQuery = ` + INSERT INTO ${tableName} (${columns.join(", ")}) + VALUES (${placeholders}) + ON CONFLICT (${conflictColumns}) + DO NOTHING + RETURNING * + `; + } + } else { + // Primary Key가 없는 경우 일반 INSERT + upsertQuery = ` + INSERT INTO ${tableName} (${columns.join(", ")}) + VALUES (${placeholders}) + RETURNING * + `; + } + + console.log("📝 실행할 UPSERT SQL:", upsertQuery); + console.log("📊 SQL 파라미터:", values); + + const result = await prisma.$queryRawUnsafe(upsertQuery, ...values); + + console.log("✅ 서비스: 실제 테이블 저장 성공:", result); + + // 결과를 표준 형식으로 변환 + const insertedRecord = Array.isArray(result) ? result[0] : result; + + return { + id: insertedRecord.id || insertedRecord.objid || 0, + screenId: screenId, + tableName: tableName, + data: insertedRecord as Record, + createdAt: insertedRecord.created_at || new Date(), + updatedAt: insertedRecord.updated_at || new Date(), + createdBy: insertedRecord.created_by || created_by || "system", + updatedBy: insertedRecord.updated_by || updated_by || "system", + }; + } catch (error) { + console.error("❌ 서비스: 실제 테이블 저장 실패:", error); + throw new Error(`실제 테이블 저장 실패: ${error}`); + } + } + + /** + * 폼 데이터 업데이트 (실제 테이블에서 직접 업데이트) + */ + async updateFormData( + id: number, + tableName: string, + data: Record + ): Promise { + try { + console.log("🔄 서비스: 실제 테이블에서 폼 데이터 업데이트 시작:", { + id, + tableName, + data, + }); + + // 테이블의 실제 컬럼 정보 조회 + const tableColumns = await this.getTableColumnNames(tableName); + console.log(`📋 테이블 ${tableName}의 컬럼:`, tableColumns); + + // 메타데이터 제거 + const { created_by, updated_by, company_code, screen_id, ...actualData } = + data; + + // 기본 데이터 준비 + const dataToUpdate: any = { ...actualData }; + + // 테이블에 존재하는 업데이트 관련 필드들만 추가 + if (tableColumns.includes("updated_at")) { + dataToUpdate.updated_at = new Date(); + } + if (tableColumns.includes("regdate") && !dataToUpdate.regdate) { + dataToUpdate.regdate = new Date(); + } + + // 수정자 정보가 있고 해당 컬럼이 존재한다면 추가 + if (updated_by && tableColumns.includes("updated_by")) { + dataToUpdate.updated_by = updated_by; + } + + // 존재하지 않는 컬럼 제거 + Object.keys(dataToUpdate).forEach((key) => { + if (!tableColumns.includes(key)) { + console.log( + `⚠️ 컬럼 ${key}는 테이블 ${tableName}에 존재하지 않아 제거됨` + ); + delete dataToUpdate[key]; + } + }); + + console.log("🎯 실제 테이블에서 업데이트할 데이터:", { + tableName, + id, + dataToUpdate, + }); + + // 동적 UPDATE SQL 생성 + const setClause = Object.keys(dataToUpdate) + .map((key, index) => `${key} = $${index + 1}`) + .join(", "); + + const values: any[] = Object.values(dataToUpdate); + values.push(id); // WHERE 조건용 ID 추가 + + // ID 또는 objid로 찾기 시도 + const updateQuery = ` + UPDATE ${tableName} + SET ${setClause} + WHERE (id = $${values.length} OR objid = $${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 { + id: updatedRecord.id || updatedRecord.objid || id, + screenId: 0, // 실제 테이블에는 screenId가 없으므로 0으로 설정 + tableName: tableName, + data: updatedRecord as Record, + createdAt: updatedRecord.created_at || new Date(), + updatedAt: updatedRecord.updated_at || new Date(), + createdBy: updatedRecord.created_by || "system", + updatedBy: updatedRecord.updated_by || updated_by || "system", + }; + } catch (error) { + console.error("❌ 서비스: 실제 테이블 업데이트 실패:", error); + throw new Error(`실제 테이블 업데이트 실패: ${error}`); + } + } + + /** + * 폼 데이터 삭제 (실제 테이블에서 직접 삭제) + */ + async deleteFormData(id: number, tableName: string): Promise { + try { + console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", { + id, + tableName, + }); + + // 동적 DELETE SQL 생성 + const deleteQuery = ` + DELETE FROM ${tableName} + WHERE (id = $1 OR objid = $1) + RETURNING * + `; + + console.log("📝 실행할 DELETE SQL:", deleteQuery); + console.log("📊 SQL 파라미터:", [id]); + + const result = await prisma.$queryRawUnsafe(deleteQuery, id); + + console.log("✅ 서비스: 실제 테이블 삭제 성공:", result); + } catch (error) { + console.error("❌ 서비스: 실제 테이블 삭제 실패:", error); + throw new Error(`실제 테이블 삭제 실패: ${error}`); + } + } + + /** + * 단일 폼 데이터 조회 + */ + async getFormData(id: number): Promise { + try { + console.log("📄 서비스: 폼 데이터 단건 조회 시작:", { id }); + + const result = await prisma.dynamic_form_data.findUnique({ + where: { id }, + }); + + if (!result) { + console.log("❌ 서비스: 폼 데이터를 찾을 수 없음"); + return null; + } + + console.log("✅ 서비스: 폼 데이터 단건 조회 성공"); + + return { + id: result.id, + screenId: result.screen_id, + tableName: result.table_name, + data: result.form_data as Record, + createdAt: result.created_at, + updatedAt: result.updated_at, + createdBy: result.created_by, + updatedBy: result.updated_by, + }; + } catch (error) { + console.error("❌ 서비스: 폼 데이터 단건 조회 실패:", error); + throw new Error(`폼 데이터 조회 실패: ${error}`); + } + } + + /** + * 화면별 폼 데이터 목록 조회 (페이징) + */ + async getFormDataList( + screenId: number, + params: { + page: number; + size: number; + search?: string; + sortBy?: string; + sortOrder?: "asc" | "desc"; + } + ): Promise { + try { + console.log("📋 서비스: 폼 데이터 목록 조회 시작:", { screenId, params }); + + const { + page, + size, + search, + sortBy = "created_at", + sortOrder = "desc", + } = params; + const skip = (page - 1) * size; + + // 검색 조건 구성 + const where: Prisma.dynamic_form_dataWhereInput = { + screen_id: screenId, + }; + + // 검색어가 있는 경우 form_data 필드에서 검색 + if (search) { + where.OR = [ + { + form_data: { + path: [], + string_contains: search, + }, + }, + { + table_name: { + contains: search, + mode: "insensitive", + }, + }, + ]; + } + + // 정렬 조건 구성 + const orderBy: Prisma.dynamic_form_dataOrderByWithRelationInput = {}; + if (sortBy === "created_at" || sortBy === "updated_at") { + orderBy[sortBy] = sortOrder; + } else { + orderBy.created_at = "desc"; // 기본값 + } + + // 데이터 조회 + const [results, totalCount] = await Promise.all([ + prisma.dynamic_form_data.findMany({ + where, + orderBy, + skip, + take: size, + }), + prisma.dynamic_form_data.count({ where }), + ]); + + const formDataResults: FormDataResult[] = results.map((result) => ({ + id: result.id, + screenId: result.screen_id, + tableName: result.table_name, + data: result.form_data as Record, + createdAt: result.created_at, + updatedAt: result.updated_at, + createdBy: result.created_by, + updatedBy: result.updated_by, + })); + + const totalPages = Math.ceil(totalCount / size); + + console.log("✅ 서비스: 폼 데이터 목록 조회 성공:", { + totalCount, + totalPages, + currentPage: page, + }); + + return { + content: formDataResults, + totalElements: totalCount, + totalPages, + currentPage: page, + size, + }; + } catch (error) { + console.error("❌ 서비스: 폼 데이터 목록 조회 실패:", error); + throw new Error(`폼 데이터 목록 조회 실패: ${error}`); + } + } + + /** + * 폼 데이터 검증 + */ + async validateFormData( + tableName: string, + data: Record + ): Promise { + try { + console.log("✅ 서비스: 폼 데이터 검증 시작:", { tableName, data }); + + const errors: ValidationError[] = []; + + // 기본 검증 로직 (실제로는 테이블 스키마를 확인해야 함) + Object.entries(data).forEach(([key, value]) => { + // 예시: 빈 값 검증 + if (value === null || value === undefined || value === "") { + // 특정 필드가 required인지 확인하는 로직이 필요 + // 지금은 간단히 모든 필드를 선택사항으로 처리 + } + + // 예시: 데이터 타입 검증 + // 실제로는 테이블 스키마의 컬럼 타입과 비교해야 함 + }); + + const result: ValidationResult = { + valid: errors.length === 0, + errors, + }; + + console.log("✅ 서비스: 폼 데이터 검증 완료:", result); + + return result; + } catch (error) { + console.error("❌ 서비스: 폼 데이터 검증 실패:", error); + throw new Error(`폼 데이터 검증 실패: ${error}`); + } + } + + /** + * 테이블 컬럼 정보 조회 (PostgreSQL 시스템 테이블 활용) + */ + async getTableColumns(tableName: string): Promise { + try { + console.log("📊 서비스: 테이블 컬럼 정보 조회 시작:", { tableName }); + + // PostgreSQL의 information_schema를 사용하여 컬럼 정보 조회 + const columns = await prisma.$queryRaw` + SELECT + column_name, + data_type, + is_nullable, + column_default, + character_maximum_length + FROM information_schema.columns + WHERE table_name = ${tableName} + AND table_schema = 'public' + ORDER BY ordinal_position + `; + + // Primary key 정보 조회 + const primaryKeys = await prisma.$queryRaw` + 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.constraint_type = 'PRIMARY KEY' + AND tc.table_name = ${tableName} + AND tc.table_schema = 'public' + `; + + const primaryKeyColumns = new Set( + primaryKeys.map((pk) => pk.column_name) + ); + + const result: TableColumn[] = columns.map((col) => ({ + columnName: col.column_name, + dataType: col.data_type, + nullable: col.is_nullable === "YES", + primaryKey: primaryKeyColumns.has(col.column_name), + maxLength: col.character_maximum_length, + defaultValue: col.column_default, + })); + + console.log("✅ 서비스: 테이블 컬럼 정보 조회 성공:", result); + + return result; + } catch (error) { + console.error("❌ 서비스: 테이블 컬럼 정보 조회 실패:", error); + throw new Error(`테이블 컬럼 정보 조회 실패: ${error}`); + } + } +} + +// 싱글톤 인스턴스 생성 및 export +export const dynamicFormService = new DynamicFormService(); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 2f2b76a5..7abda41a 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -69,6 +69,7 @@ export class TableManagementService { COALESCE(cl.column_label, c.column_name) as "displayName", c.data_type as "dbType", COALESCE(cl.web_type, 'text') as "webType", + COALESCE(cl.input_type, 'direct') as "inputType", COALESCE(cl.detail_settings, '') as "detailSettings", COALESCE(cl.description, '') as "description", c.is_nullable as "isNullable", @@ -368,7 +369,8 @@ export class TableManagementService { tableName: string, columnName: string, webType: string, - detailSettings?: Record + detailSettings?: Record, + inputType?: string ): Promise { try { logger.info( @@ -394,30 +396,42 @@ export class TableManagementService { if (existingColumn) { // 기존 컬럼 라벨 업데이트 + const updateData: any = { + web_type: webType, + detail_settings: JSON.stringify(finalDetailSettings), + updated_date: new Date(), + }; + + if (inputType) { + updateData.input_type = inputType; + } + await prisma.column_labels.update({ where: { id: existingColumn.id, }, - data: { - web_type: webType, - detail_settings: JSON.stringify(finalDetailSettings), - updated_date: new Date(), - }, + data: updateData, }); logger.info( `컬럼 웹 타입 업데이트 완료: ${tableName}.${columnName} = ${webType}` ); } else { // 새로운 컬럼 라벨 생성 + const createData: any = { + table_name: tableName, + column_name: columnName, + web_type: webType, + detail_settings: JSON.stringify(finalDetailSettings), + created_date: new Date(), + updated_date: new Date(), + }; + + if (inputType) { + createData.input_type = inputType; + } + await prisma.column_labels.create({ - data: { - table_name: tableName, - column_name: columnName, - web_type: webType, - detail_settings: JSON.stringify(finalDetailSettings), - created_date: new Date(), - updated_date: new Date(), - }, + data: createData, }); logger.info( `컬럼 라벨 생성 및 웹 타입 설정 완료: ${tableName}.${columnName} = ${webType}` diff --git a/backend-node/src/types/screen.ts b/backend-node/src/types/screen.ts index 8c76b7c4..9cb63cea 100644 --- a/backend-node/src/types/screen.ts +++ b/backend-node/src/types/screen.ts @@ -224,6 +224,7 @@ export interface ColumnInfo { columnLabel?: string; dataType: string; webType?: WebType; + inputType?: "direct" | "auto"; // 입력 타입 isNullable: string; columnDefault?: string; characterMaximumLength?: number; diff --git a/backend-node/src/types/tableManagement.ts b/backend-node/src/types/tableManagement.ts index 8aeb0727..a8a65332 100644 --- a/backend-node/src/types/tableManagement.ts +++ b/backend-node/src/types/tableManagement.ts @@ -12,6 +12,7 @@ export interface ColumnTypeInfo { displayName: string; dbType: string; webType: string; + inputType?: "direct" | "auto"; detailSettings: string; description: string; isNullable: string; diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index d4bf6448..d5d2f8ba 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -206,6 +206,10 @@ export default function ScreenViewPage() { })); }} hideLabel={true} // 라벨 숨김 플래그 전달 + screenInfo={{ + id: screenId, + tableName: screen?.tableName, + }} /> diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index be0cd1af..32cea32b 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -28,6 +28,8 @@ import { ButtonTypeConfig, } from "@/types/screen"; import { InteractiveDataTable } from "./InteractiveDataTable"; +import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm"; +import { useParams } from "next/navigation"; interface InteractiveScreenViewerProps { component: ComponentData; @@ -35,6 +37,10 @@ interface InteractiveScreenViewerProps { formData?: Record; onFormDataChange?: (fieldName: string, value: any) => void; hideLabel?: boolean; + screenInfo?: { + id: number; + tableName?: string; + }; } export const InteractiveScreenViewer: React.FC = ({ @@ -43,6 +49,7 @@ export const InteractiveScreenViewer: React.FC = ( formData: externalFormData, onFormDataChange, hideLabel = false, + screenInfo, }) => { const [localFormData, setLocalFormData] = useState>({}); const [dateValues, setDateValues] = useState>({}); @@ -683,28 +690,300 @@ export const InteractiveScreenViewer: React.FC = ( const widget = comp as WidgetComponent; const config = widget.webTypeConfig as ButtonTypeConfig | undefined; - const handleButtonClick = () => { - if (config?.actionType === "popup" && config.popupTitle) { - alert(`${config.popupTitle}\n\n${config.popupContent || "팝업 내용이 없습니다."}`); - } else if (config?.actionType === "navigate" && config.navigateUrl) { + const handleButtonClick = async () => { + const actionType = config?.actionType || "save"; + + try { + switch (actionType) { + case "save": + await handleSaveAction(); + break; + case "cancel": + handleCancelAction(); + break; + case "delete": + await handleDeleteAction(); + break; + case "edit": + handleEditAction(); + break; + case "add": + handleAddAction(); + break; + case "search": + handleSearchAction(); + break; + case "reset": + handleResetAction(); + break; + case "submit": + await handleSubmitAction(); + break; + case "close": + handleCloseAction(); + break; + case "popup": + handlePopupAction(); + break; + case "navigate": + handleNavigateAction(); + break; + case "custom": + await handleCustomAction(); + break; + default: + console.log(`알 수 없는 액션 타입: ${actionType}`); + } + } catch (error) { + console.error(`버튼 액션 실행 오류 (${actionType}):`, error); + alert(`작업 중 오류가 발생했습니다: ${error.message}`); + } + }; + + // 저장 액션 + const handleSaveAction = async () => { + if (!formData || Object.keys(formData).length === 0) { + alert("저장할 데이터가 없습니다."); + return; + } + + // 필수 항목 검증 + const requiredFields = allComponents.filter(c => c.required && (c.columnName || c.id)); + const missingFields = requiredFields.filter(field => { + const fieldName = field.columnName || field.id; + const value = formData[fieldName]; + return !value || value.toString().trim() === ""; + }); + + if (missingFields.length > 0) { + const fieldNames = missingFields.map(f => f.label || f.columnName || f.id).join(", "); + alert(`다음 필수 항목을 입력해주세요: ${fieldNames}`); + return; + } + + if (!screenInfo?.id) { + alert("화면 정보가 없어 저장할 수 없습니다."); + return; + } + + try { + // 컬럼명 기반으로 데이터 매핑 + const mappedData: Record = {}; + + // 컴포넌트에서 컬럼명이 있는 것들만 매핑 + allComponents.forEach(comp => { + if (comp.columnName) { + const fieldName = comp.columnName; + const componentId = comp.id; + + // formData에서 해당 값 찾기 (컬럼명 우선, 없으면 컴포넌트 ID) + const value = formData[fieldName] || formData[componentId]; + + if (value !== undefined && value !== "") { + mappedData[fieldName] = value; + } + } + }); + + console.log("💾 저장할 데이터 매핑:", { + 원본데이터: formData, + 매핑된데이터: mappedData, + 화면정보: screenInfo, + }); + + // 테이블명 결정 (화면 정보에서 가져오거나 첫 번째 컴포넌트의 테이블명 사용) + const tableName = screenInfo.tableName || + allComponents.find(c => c.columnName)?.tableName || + "dynamic_form_data"; // 기본값 + + const saveData: DynamicFormData = { + screenId: screenInfo.id, + tableName: tableName, + data: mappedData, + }; + + console.log("🚀 API 저장 요청:", saveData); + + const result = await dynamicFormApi.saveFormData(saveData); + + if (result.success) { + alert("저장되었습니다."); + console.log("✅ 저장 성공:", result.data); + + // 저장 후 데이터 초기화 (선택사항) + if (onFormDataChange) { + Object.keys(formData).forEach(key => { + onFormDataChange(key, ""); + }); + } + } else { + throw new Error(result.message || "저장에 실패했습니다."); + } + } catch (error: any) { + console.error("❌ 저장 실패:", error); + alert(`저장 중 오류가 발생했습니다: ${error.message}`); + } + }; + + // 취소 액션 + const handleCancelAction = () => { + if (confirm("변경사항을 취소하시겠습니까?")) { + // 폼 초기화 또는 이전 페이지로 이동 + if (onFormDataChange) { + // 모든 폼 데이터 초기화 + Object.keys(formData).forEach(key => { + onFormDataChange(key, ""); + }); + } + console.log("❌ 작업이 취소되었습니다."); + } + }; + + // 삭제 액션 + const handleDeleteAction = async () => { + const confirmMessage = config?.confirmMessage || "정말로 삭제하시겠습니까?"; + + if (!confirm(confirmMessage)) { + return; + } + + // 삭제할 레코드 ID가 필요 (폼 데이터에서 id 필드 찾기) + const recordId = formData["id"] || formData["ID"] || formData["objid"]; + + if (!recordId) { + alert("삭제할 데이터를 찾을 수 없습니다. (ID가 없음)"); + return; + } + + // 테이블명 결정 + const tableName = screenInfo?.tableName || + allComponents.find(c => c.columnName)?.tableName || + "unknown_table"; + + if (!tableName || tableName === "unknown_table") { + alert("테이블 정보가 없어 삭제할 수 없습니다."); + return; + } + + try { + console.log("🗑️ 삭제 실행:", { recordId, tableName, formData }); + + const result = await dynamicFormApi.deleteFormDataFromTable(recordId, tableName); + + if (result.success) { + alert("삭제되었습니다."); + console.log("✅ 삭제 성공"); + + // 삭제 후 폼 초기화 + if (onFormDataChange) { + Object.keys(formData).forEach(key => { + onFormDataChange(key, ""); + }); + } + } else { + throw new Error(result.message || "삭제에 실패했습니다."); + } + } catch (error: any) { + console.error("❌ 삭제 실패:", error); + alert(`삭제 중 오류가 발생했습니다: ${error.message}`); + } + }; + + // 편집 액션 + const handleEditAction = () => { + console.log("✏️ 편집 모드 활성화"); + // 읽기 전용 모드를 편집 모드로 전환 + alert("편집 모드로 전환되었습니다."); + }; + + // 추가 액션 + const handleAddAction = () => { + console.log("➕ 새 항목 추가"); + // 새 항목 추가 로직 + alert("새 항목을 추가할 수 있습니다."); + }; + + // 검색 액션 + const handleSearchAction = () => { + console.log("🔍 검색 실행:", formData); + // 검색 로직 + const searchTerms = Object.values(formData).filter(v => v && v.toString().trim()); + if (searchTerms.length === 0) { + alert("검색할 내용을 입력해주세요."); + } else { + alert(`검색 실행: ${searchTerms.join(", ")}`); + } + }; + + // 초기화 액션 + const handleResetAction = () => { + if (confirm("모든 입력을 초기화하시겠습니까?")) { + if (onFormDataChange) { + Object.keys(formData).forEach(key => { + onFormDataChange(key, ""); + }); + } + console.log("🔄 폼 초기화 완료"); + alert("입력이 초기화되었습니다."); + } + }; + + // 제출 액션 + const handleSubmitAction = async () => { + console.log("📤 폼 제출:", formData); + // 제출 로직 + alert("제출되었습니다."); + }; + + // 닫기 액션 + const handleCloseAction = () => { + console.log("❌ 창 닫기"); + // 창 닫기 또는 모달 닫기 + if (window.opener) { + window.close(); + } else { + history.back(); + } + }; + + // 팝업 액션 + const handlePopupAction = () => { + if (config?.popupTitle && config?.popupContent) { + // 커스텀 모달 대신 기본 alert 사용 (향후 모달 컴포넌트로 교체 가능) + alert(`${config.popupTitle}\n\n${config.popupContent}`); + } else { + alert("팝업을 표시합니다."); + } + }; + + // 네비게이션 액션 + const handleNavigateAction = () => { + if (config?.navigateUrl) { if (config.navigateTarget === "_blank") { window.open(config.navigateUrl, "_blank"); } else { window.location.href = config.navigateUrl; } - } else if (config?.actionType === "custom" && config.customAction) { + } else { + console.log("🔗 네비게이션 URL이 설정되지 않았습니다."); + } + }; + + // 커스텀 액션 + const handleCustomAction = async () => { + if (config?.customAction) { try { - // 간단한 JavaScript 실행 (보안상 제한적) - eval(config.customAction); + // 보안상 제한적인 eval 사용 + const result = eval(config.customAction); + if (result instanceof Promise) { + await result; + } + console.log("⚡ 커스텀 액션 실행 완료"); } catch (error) { - console.error("커스텀 액션 실행 오류:", error); - } - } else if (config?.actionType === "delete" && config.confirmMessage) { - if (confirm(config.confirmMessage)) { - console.log("삭제 확인됨"); + throw new Error(`커스텀 액션 실행 실패: ${error.message}`); } } else { - console.log(`버튼 클릭: ${config?.actionType || "기본"} 액션`); + console.log("⚡ 커스텀 액션이 설정되지 않았습니다."); } }; diff --git a/frontend/components/screen/RealtimePreview.tsx b/frontend/components/screen/RealtimePreview.tsx index 716cb168..0937017f 100644 --- a/frontend/components/screen/RealtimePreview.tsx +++ b/frontend/components/screen/RealtimePreview.tsx @@ -85,8 +85,64 @@ const renderWidget = (component: ComponentData) => { const widget = component as WidgetComponent; const config = widget.webTypeConfig as TextTypeConfig | undefined; + // 입력 타입에 따른 처리 + const isAutoInput = widget.inputType === "auto"; + + // 자동 값 생성 함수 + const getAutoValue = (autoValueType: string) => { + switch (autoValueType) { + case "current_datetime": + return new Date().toLocaleString("ko-KR"); + case "current_date": + return new Date().toLocaleDateString("ko-KR"); + case "current_time": + return new Date().toLocaleTimeString("ko-KR"); + case "current_user": + return "현재사용자"; + case "uuid": + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); + case "sequence": + return "SEQ_001"; + case "user_defined": + return "사용자정의값"; + default: + return "자동생성값"; + } + }; + + // 자동 값 플레이스홀더 생성 함수 + const getAutoPlaceholder = (autoValueType: string) => { + switch (autoValueType) { + case "current_datetime": + return "현재 날짜시간"; + case "current_date": + return "현재 날짜"; + case "current_time": + return "현재 시간"; + case "current_user": + return "현재 사용자"; + case "uuid": + return "UUID"; + case "sequence": + return "시퀀스"; + case "user_defined": + return "사용자 정의"; + default: + return "자동 생성됨"; + } + }; + // 플레이스홀더 처리 - const finalPlaceholder = config?.placeholder || placeholder || "텍스트를 입력하세요"; + const finalPlaceholder = isAutoInput + ? getAutoPlaceholder(widget.autoValueType || "current_datetime") + : config?.placeholder || placeholder || "텍스트를 입력하세요"; + + // 자동 값 처리 + const autoValue = isAutoInput ? getAutoValue(widget.autoValueType || "current_datetime") : ""; const inputType = widgetType === "email" ? "email" : widgetType === "tel" ? "tel" : "text"; @@ -139,12 +195,14 @@ const renderWidget = (component: ComponentData) => { const inputProps = { ...commonProps, placeholder: finalPlaceholder, + value: isAutoInput ? autoValue : undefined, // 자동입력인 경우 자동 값 표시 minLength: config?.minLength, maxLength: config?.maxLength, pattern: getPatternByFormat(config?.format || "none"), onInput: handleInputChange, onChange: () => {}, // 읽기 전용으로 처리 - readOnly: true, + readOnly: readonly || isAutoInput, // 자동입력인 경우 읽기 전용 + className: `w-full h-full ${borderClass} ${isAutoInput ? "bg-gray-50 text-gray-600" : ""}`, }; // multiline이면 Textarea로 렌더링 @@ -160,19 +218,69 @@ const renderWidget = (component: ComponentData) => { const widget = component as WidgetComponent; const config = widget.webTypeConfig as NumberTypeConfig | undefined; + // 입력 타입에 따른 처리 + const isAutoInput = widget.inputType === "auto"; + + // 자동 값 생성 함수 (숫자용) + const getAutoNumberValue = (autoValueType: string) => { + switch (autoValueType) { + case "current_datetime": + return Date.now().toString(); + case "current_date": + return new Date().getDate().toString(); + case "current_time": + return new Date().getHours().toString(); + case "sequence": + return "1001"; + case "uuid": + return Math.floor(Math.random() * 1000000).toString(); + case "user_defined": + return "999"; + default: + return "0"; + } + }; + + // 자동 값 플레이스홀더 생성 함수 (숫자용) + const getAutoNumberPlaceholder = (autoValueType: string) => { + switch (autoValueType) { + case "current_datetime": + return "타임스탬프"; + case "current_date": + return "현재 일"; + case "current_time": + return "현재 시"; + case "sequence": + return "시퀀스"; + case "uuid": + return "랜덤 숫자"; + case "user_defined": + return "사용자 정의"; + default: + return "자동 생성"; + } + }; + + // 자동 값 처리 + const autoValue = isAutoInput ? getAutoNumberValue(widget.autoValueType || "sequence") : ""; + // 디버깅: 현재 설정값 확인 console.log("🔢 숫자 위젯 렌더링:", { componentId: widget.id, widgetType: widget.widgetType, config, placeholder: widget.placeholder, + inputType: widget.inputType, + isAutoInput, }); // 단계값 결정: webTypeConfig > 기본값 (소수는 0.01, 정수는 1) const step = config?.step || (widgetType === "decimal" ? 0.01 : 1); // 플레이스홀더 처리 - const finalPlaceholder = config?.placeholder || placeholder || "숫자를 입력하세요"; + const finalPlaceholder = isAutoInput + ? getAutoNumberPlaceholder(widget.autoValueType || "sequence") + : config?.placeholder || placeholder || "숫자를 입력하세요"; // 형식에 따른 표시값 처리 const formatValue = (value: string) => { @@ -215,9 +323,10 @@ const renderWidget = (component: ComponentData) => { max={config?.max} {...commonProps} placeholder={finalPlaceholder} - className={`${config?.prefix ? "rounded-l-none" : ""} ${config?.suffix ? "rounded-r-none" : ""} ${borderClass}`} + value={isAutoInput ? autoValue : undefined} // 자동입력인 경우 자동 값 표시 + className={`${config?.prefix ? "rounded-l-none" : ""} ${config?.suffix ? "rounded-r-none" : ""} ${borderClass} ${isAutoInput ? "bg-gray-50 text-gray-600" : ""}`} onChange={() => {}} // 읽기 전용으로 처리 - readOnly + readOnly={readonly || isAutoInput} /> {config.suffix && ( @@ -236,8 +345,10 @@ const renderWidget = (component: ComponentData) => { max={config?.max} {...commonProps} placeholder={finalPlaceholder} + value={isAutoInput ? autoValue : undefined} // 자동입력인 경우 자동 값 표시 + className={`h-full w-full ${borderClass} ${isAutoInput ? "bg-gray-50 text-gray-600" : ""}`} onChange={() => {}} // 읽기 전용으로 처리 - readOnly + readOnly={readonly || isAutoInput} /> ); } @@ -247,6 +358,46 @@ const renderWidget = (component: ComponentData) => { const widget = component as WidgetComponent; const config = widget.webTypeConfig as DateTypeConfig | undefined; + // 입력 타입에 따른 처리 + const isAutoInput = widget.inputType === "auto"; + + // 자동 값 생성 함수 (날짜용) + const getAutoDateValue = (autoValueType: string, inputType: string) => { + const now = new Date(); + switch (autoValueType) { + case "current_datetime": + return inputType === "datetime-local" + ? now.toISOString().slice(0, 16) // YYYY-MM-DDTHH:mm + : now.toISOString().slice(0, 10); // YYYY-MM-DD + case "current_date": + return now.toISOString().slice(0, 10); // YYYY-MM-DD + case "current_time": + return inputType === "datetime-local" + ? now.toISOString().slice(0, 16) // YYYY-MM-DDTHH:mm + : now.toTimeString().slice(0, 5); // HH:mm + case "user_defined": + return inputType === "datetime-local" ? "2024-01-01T09:00" : "2024-01-01"; + default: + return inputType === "datetime-local" ? now.toISOString().slice(0, 16) : now.toISOString().slice(0, 10); + } + }; + + // 자동 값 플레이스홀더 생성 함수 (날짜용) + const getAutoDatePlaceholder = (autoValueType: string) => { + switch (autoValueType) { + case "current_datetime": + return "현재 날짜시간"; + case "current_date": + return "현재 날짜"; + case "current_time": + return "현재 시간"; + case "user_defined": + return "사용자 정의"; + default: + return "자동 생성"; + } + }; + // 웹타입 설정에 따른 input type 결정 let inputType = "date"; if (config?.showTime || config?.format?.includes("HH:mm")) { @@ -273,8 +424,13 @@ const renderWidget = (component: ComponentData) => { } } + // 자동 값 처리 + const autoValue = isAutoInput ? getAutoDateValue(widget.autoValueType || "current_date", inputType) : ""; + // 플레이스홀더 우선순위: webTypeConfig > placeholder > 기본값 - const finalPlaceholder = config?.placeholder || placeholder || "날짜를 선택하세요"; + const finalPlaceholder = isAutoInput + ? getAutoDatePlaceholder(widget.autoValueType || "current_date") + : config?.placeholder || placeholder || "날짜를 선택하세요"; // 디버깅: 현재 설정값 확인 console.log("📅 날짜 위젯 렌더링:", { @@ -331,9 +487,10 @@ const renderWidget = (component: ComponentData) => { placeholder={finalPlaceholder} min={config?.minDate} max={config?.maxDate} - value={processedDefaultValue} + value={isAutoInput ? autoValue : processedDefaultValue} + className={`h-full w-full ${borderClass} ${isAutoInput ? "bg-gray-50 text-gray-600" : ""}`} onChange={() => {}} // 읽기 전용으로 처리 - readOnly + readOnly={readonly || isAutoInput} /> ); } diff --git a/frontend/components/screen/TableTypeSelector.tsx b/frontend/components/screen/TableTypeSelector.tsx index 67557248..50a64587 100644 --- a/frontend/components/screen/TableTypeSelector.tsx +++ b/frontend/components/screen/TableTypeSelector.tsx @@ -173,6 +173,32 @@ export default function TableTypeSelector({ } }; + // 입력 타입 변경 + const handleInputTypeChange = async (columnName: string, inputType: "direct" | "auto") => { + try { + // 현재 컬럼 정보 가져오기 + const currentColumn = columns.find((col) => col.columnName === columnName); + if (!currentColumn) return; + + // 웹 타입과 함께 입력 타입 업데이트 + await tableTypeApi.setColumnWebType( + selectedTable, + columnName, + currentColumn.webType || "text", + undefined, // detailSettings + inputType, + ); + + // 로컬 상태 업데이트 + setColumns((prev) => prev.map((col) => (col.columnName === columnName ? { ...col, inputType } : col))); + + console.log(`컬럼 ${columnName}의 입력 타입을 ${inputType}로 변경했습니다.`); + } catch (error) { + console.error("입력 타입 변경 실패:", error); + alert("입력 타입 설정에 실패했습니다. 다시 시도해주세요."); + } + }; + const filteredTables = tables.filter((table) => table.displayName.toLowerCase().includes(searchTerm.toLowerCase())); return ( @@ -233,6 +259,7 @@ export default function TableTypeSelector({ 라벨 데이터 타입 웹 타입 + 입력 타입 필수 표시 액션 @@ -267,6 +294,20 @@ export default function TableTypeSelector({ + + + {column.isNullable === "NO" ? "필수" : "선택"} diff --git a/frontend/components/screen/panels/ButtonConfigPanel.tsx b/frontend/components/screen/panels/ButtonConfigPanel.tsx index 6aeac75c..a77bdc54 100644 --- a/frontend/components/screen/panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/panels/ButtonConfigPanel.tsx @@ -48,23 +48,42 @@ export const ButtonConfigPanel: React.FC = ({ component, const config = (component.webTypeConfig as ButtonTypeConfig) || {}; // 로컬 상태 관리 - const [localConfig, setLocalConfig] = useState({ - actionType: "custom", - variant: "default", - size: "sm", - ...config, + const [localConfig, setLocalConfig] = useState(() => { + const defaultConfig = { + actionType: "custom" as ButtonActionType, + variant: "default" as ButtonVariant, + size: "sm" as ButtonSize, + }; + + return { + ...defaultConfig, + ...config, // 저장된 값이 기본값을 덮어씀 + }; }); // 컴포넌트 변경 시 로컬 상태 동기화 useEffect(() => { const newConfig = (component.webTypeConfig as ButtonTypeConfig) || {}; + + // 기본값 설정 (실제 값이 있으면 덮어쓰지 않음) + const defaultConfig = { + actionType: "custom" as ButtonActionType, + variant: "default" as ButtonVariant, + size: "sm" as ButtonSize, + }; + + // 실제 저장된 값이 우선순위를 가지도록 설정 setLocalConfig({ - actionType: "custom", - variant: "default", - size: "sm", - ...newConfig, + ...defaultConfig, + ...newConfig, // 저장된 값이 기본값을 덮어씀 }); - }, [component.webTypeConfig]); + + console.log("🔄 ButtonConfigPanel 로컬 상태 동기화:", { + componentId: component.id, + savedConfig: newConfig, + finalConfig: { ...defaultConfig, ...newConfig }, + }); + }, [component.webTypeConfig, component.id]); // 설정 업데이트 함수 const updateConfig = (updates: Partial) => { @@ -194,7 +213,22 @@ export const ButtonConfigPanel: React.FC = ({ component, break; } - updateConfig(updates); + // 로컬 상태 업데이트 후 webTypeConfig도 함께 업데이트 + const newConfig = { ...localConfig, ...updates }; + setLocalConfig(newConfig); + + // webTypeConfig를 마지막에 다시 업데이트하여 확실히 저장되도록 함 + setTimeout(() => { + onUpdateComponent({ + webTypeConfig: newConfig, + }); + + console.log("🎯 ButtonActionType webTypeConfig 최종 업데이트:", { + actionType, + newConfig, + componentId: component.id, + }); + }, 0); }; const selectedActionOption = actionTypeOptions.find((opt) => opt.value === localConfig.actionType); diff --git a/frontend/components/screen/panels/DetailSettingsPanel.tsx b/frontend/components/screen/panels/DetailSettingsPanel.tsx index ffe09415..f77b96bc 100644 --- a/frontend/components/screen/panels/DetailSettingsPanel.tsx +++ b/frontend/components/screen/panels/DetailSettingsPanel.tsx @@ -2,6 +2,7 @@ import React from "react"; import { Settings } from "lucide-react"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { ComponentData, WidgetComponent, @@ -223,6 +224,87 @@ export const DetailSettingsPanel: React.FC = ({ select {widget.widgetType}
컬럼: {widget.columnName}
+ + {/* 입력 타입 설정 */} +
+ + +

+ {widget.inputType === "auto" + ? "시스템에서 자동으로 값을 생성합니다 (읽기 전용)" + : "사용자가 직접 값을 입력할 수 있습니다"} +

+ + {/* 자동 값 타입 설정 (자동입력일 때만 표시) */} + {widget.inputType === "auto" && ( +
+ + +

+ {(() => { + switch (widget.autoValueType || "current_datetime") { + case "current_datetime": + return "현재 날짜와 시간을 자동으로 입력합니다"; + case "current_date": + return "현재 날짜를 자동으로 입력합니다"; + case "current_time": + return "현재 시간을 자동으로 입력합니다"; + case "current_user": + return "현재 로그인한 사용자 정보를 입력합니다"; + case "uuid": + return "고유한 UUID를 생성합니다"; + case "sequence": + return "순차적인 번호를 생성합니다"; + case "user_defined": + return "사용자가 정의한 규칙에 따라 값을 생성합니다"; + default: + return ""; + } + })()} +

+
+ )} +
{/* 상세 설정 영역 */} diff --git a/frontend/lib/api/dynamicForm.ts b/frontend/lib/api/dynamicForm.ts new file mode 100644 index 00000000..d306fa95 --- /dev/null +++ b/frontend/lib/api/dynamicForm.ts @@ -0,0 +1,319 @@ +import { apiClient, ApiResponse } from "./client"; + +// 동적 폼 데이터 타입 +export interface DynamicFormData { + screenId: number; + tableName: string; + data: Record; +} + +// 폼 데이터 저장 응답 타입 +export interface SaveFormDataResponse { + id: number; + success: boolean; + message: string; + data?: Record; +} + +// 폼 데이터 조회 응답 타입 +export interface FormDataResponse { + id: number; + screenId: number; + tableName: string; + data: Record; + createdAt: string; + updatedAt: string; +} + +// 동적 폼 API 클래스 +export class DynamicFormApi { + /** + * 폼 데이터 저장 + * @param formData 저장할 폼 데이터 + * @returns 저장 결과 + */ + static async saveFormData(formData: DynamicFormData): Promise> { + try { + console.log("💾 폼 데이터 저장 요청:", formData); + + const response = await apiClient.post("/dynamic-form/save", formData); + + 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 + * @param formData 업데이트할 폼 데이터 + * @returns 업데이트 결과 + */ + static async updateFormData( + id: number, + formData: Partial, + ): Promise> { + try { + console.log("🔄 폼 데이터 업데이트 요청:", { id, formData }); + + const response = await apiClient.put(`/dynamic-form/${id}`, formData); + + 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 + * @returns 삭제 결과 + */ + static async deleteFormData(id: number): Promise> { + try { + console.log("🗑️ 폼 데이터 삭제 요청:", id); + + await apiClient.delete(`/dynamic-form/${id}`); + + console.log("✅ 폼 데이터 삭제 성공"); + return { + success: true, + 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 + * @param tableName 테이블명 + * @returns 삭제 결과 + */ + static async deleteFormDataFromTable(id: number, tableName: string): Promise> { + try { + console.log("🗑️ 실제 테이블에서 폼 데이터 삭제 요청:", { id, tableName }); + + await apiClient.delete(`/dynamic-form/${id}`, { + data: { tableName }, + }); + + console.log("✅ 실제 테이블에서 폼 데이터 삭제 성공"); + return { + success: true, + 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 screenId 화면 ID + * @param params 검색 파라미터 + * @returns 폼 데이터 목록 + */ + static async getFormDataList( + screenId: number, + params?: { + page?: number; + size?: number; + search?: string; + sortBy?: string; + sortOrder?: "asc" | "desc"; + }, + ): Promise< + ApiResponse<{ + content: FormDataResponse[]; + totalElements: number; + totalPages: number; + currentPage: number; + size: number; + }> + > { + try { + console.log("📋 폼 데이터 목록 조회 요청:", { screenId, params }); + + const response = await apiClient.get(`/dynamic-form/screen/${screenId}`, { params }); + + console.log("✅ 폼 데이터 목록 조회 성공:", response.data); + return { + success: true, + data: response.data, + }; + } 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 + * @returns 폼 데이터 + */ + static async getFormData(id: number): Promise> { + try { + console.log("📄 폼 데이터 단건 조회 요청:", id); + + const response = await apiClient.get(`/dynamic-form/${id}`); + + console.log("✅ 폼 데이터 단건 조회 성공:", response.data); + return { + success: true, + data: response.data, + }; + } 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 tableName 테이블명 + * @returns 컬럼 정보 + */ + static async getTableColumns(tableName: string): Promise< + ApiResponse<{ + tableName: string; + columns: Array<{ + columnName: string; + dataType: string; + nullable: boolean; + primaryKey: boolean; + maxLength?: number; + defaultValue?: any; + }>; + }> + > { + try { + console.log("📊 테이블 컬럼 정보 조회 요청:", tableName); + + const response = await apiClient.get(`/dynamic-form/table/${tableName}/columns`); + + console.log("✅ 테이블 컬럼 정보 조회 성공:", response.data); + return { + success: true, + data: response.data, + }; + } 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 tableName 테이블명 + * @param data 검증할 데이터 + * @returns 검증 결과 + */ + static async validateFormData( + tableName: string, + data: Record, + ): Promise< + ApiResponse<{ + valid: boolean; + errors: Array<{ + field: string; + message: string; + code: string; + }>; + }> + > { + try { + console.log("✅ 폼 데이터 검증 요청:", { tableName, data }); + + const response = await apiClient.post(`/dynamic-form/validate`, { + tableName, + data, + }); + + console.log("✅ 폼 데이터 검증 성공:", response.data); + return { + success: true, + data: response.data, + }; + } 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 +export const dynamicFormApi = DynamicFormApi; diff --git a/frontend/lib/api/screen.ts b/frontend/lib/api/screen.ts index 3e593136..dc515865 100644 --- a/frontend/lib/api/screen.ts +++ b/frontend/lib/api/screen.ts @@ -152,10 +152,12 @@ export const tableTypeApi = { columnName: string, webType: string, detailSettings?: Record, + inputType?: "direct" | "auto", ): Promise => { await apiClient.put(`/table-management/tables/${tableName}/columns/${columnName}/web-type`, { webType, detailSettings, + inputType, }); }, diff --git a/frontend/types/screen.ts b/frontend/types/screen.ts index 54f04d0a..e42da10b 100644 --- a/frontend/types/screen.ts +++ b/frontend/types/screen.ts @@ -156,6 +156,15 @@ export interface BaseComponent { tableName?: string; // 테이블명 추가 label?: string; // 라벨 추가 gridColumns?: number; // 그리드에서 차지할 컬럼 수 (1-12) + inputType?: "direct" | "auto"; // 입력 타입 (직접입력/자동입력) + autoValueType?: + | "current_datetime" + | "current_date" + | "current_time" + | "current_user" + | "uuid" + | "sequence" + | "user_defined"; // 자동 값 타입 } // 컨테이너 컴포넌트 @@ -460,6 +469,7 @@ export interface ColumnInfo { dataType: string; webType?: WebType; widgetType?: WebType; // 프론트엔드에서 사용하는 필드 (webType과 동일) + inputType?: "direct" | "auto"; // 입력 타입 isNullable: string; required?: boolean; // isNullable에서 변환된 필드 columnDefault?: string;