diff --git a/PLAN.MD b/PLAN.MD index 7c3b1007..787bef69 100644 --- a/PLAN.MD +++ b/PLAN.MD @@ -25,3 +25,4 @@ Digital Twin 에디터(`DigitalTwinEditor.tsx`)에서 발생한 런타임 에러 ## 진행 상태 - [진행중] 1단계 긴급 버그 수정 완료 후 사용자 피드백 대기 중 + diff --git a/PROJECT_STATUS_2025_11_20.md b/PROJECT_STATUS_2025_11_20.md index 570dd789..1fe76c86 100644 --- a/PROJECT_STATUS_2025_11_20.md +++ b/PROJECT_STATUS_2025_11_20.md @@ -55,3 +55,4 @@ - `backend-node/src/routes/digitalTwinRoutes.ts` - `db/migrations/042_refactor_digital_twin_hierarchy.sql` + diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index be51e70e..fc69cdb1 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -57,7 +57,7 @@ import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관 import todoRoutes from "./routes/todoRoutes"; // To-Do 관리 import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리 import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리 -import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D +import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 3D 필드 //import materialRoutes from "./routes/materialRoutes"; // 자재 관리 import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제) import flowRoutes from "./routes/flowRoutes"; // 플로우 관리 @@ -222,7 +222,7 @@ app.use("/api/risk-alerts", riskAlertRoutes); // 리스크/알림 관리 app.use("/api/todos", todoRoutes); // To-Do 관리 app.use("/api/bookings", bookingRoutes); // 예약 요청 관리 app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회 -app.use("/api/yard-layouts", yardLayoutRoutes); // 야드 관리 3D +app.use("/api/yard-layouts", yardLayoutRoutes); // 3D 필드 // app.use("/api/materials", materialRoutes); // 자재 관리 (임시 주석) app.use("/api/digital-twin", digitalTwinRoutes); // 디지털 트윈 (야드 관제) app.use("/api/flow-external-db", flowExternalDbConnectionRoutes); // 플로우 전용 외부 DB 연결 diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 746bf931..da0ea772 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -1097,7 +1097,11 @@ export async function saveMenu( let requestCompanyCode = menuData.companyCode || menuData.company_code; // "none"이나 빈 값은 undefined로 처리하여 사용자 회사 코드 사용 - if (requestCompanyCode === "none" || requestCompanyCode === "" || !requestCompanyCode) { + if ( + requestCompanyCode === "none" || + requestCompanyCode === "" || + !requestCompanyCode + ) { requestCompanyCode = undefined; } @@ -1252,7 +1256,8 @@ export async function updateMenu( } } - const requestCompanyCode = menuData.companyCode || menuData.company_code || currentMenu.company_code; + const requestCompanyCode = + menuData.companyCode || menuData.company_code || currentMenu.company_code; // company_code 변경 시도하는 경우 권한 체크 if (requestCompanyCode !== currentMenu.company_code) { @@ -1268,7 +1273,10 @@ export async function updateMenu( } } // 회사 관리자는 자기 회사로만 변경 가능 - else if (userCompanyCode !== "*" && requestCompanyCode !== userCompanyCode) { + else if ( + userCompanyCode !== "*" && + requestCompanyCode !== userCompanyCode + ) { res.status(403).json({ success: false, message: "해당 회사로 변경할 권한이 없습니다.", @@ -1324,7 +1332,7 @@ export async function updateMenu( if (!menuUrl) { await query( `UPDATE screen_menu_assignments - SET is_active = 'N', updated_date = NOW() + SET is_active = 'N' WHERE menu_objid = $1 AND company_code = $2`, [Number(menuId), companyCode] ); @@ -1493,8 +1501,13 @@ export async function deleteMenusBatch( ); // 권한 체크: 공통 메뉴 포함 여부 확인 - const hasCommonMenu = menusToDelete.some((menu: any) => menu.company_code === "*"); - if (hasCommonMenu && (userCompanyCode !== "*" || userType !== "SUPER_ADMIN")) { + const hasCommonMenu = menusToDelete.some( + (menu: any) => menu.company_code === "*" + ); + if ( + hasCommonMenu && + (userCompanyCode !== "*" || userType !== "SUPER_ADMIN") + ) { res.status(403).json({ success: false, message: "공통 메뉴는 최고 관리자만 삭제할 수 있습니다.", @@ -1506,7 +1519,8 @@ export async function deleteMenusBatch( // 회사 관리자는 자기 회사 메뉴만 삭제 가능 if (userCompanyCode !== "*") { const unauthorizedMenus = menusToDelete.filter( - (menu: any) => menu.company_code !== userCompanyCode && menu.company_code !== "*" + (menu: any) => + menu.company_code !== userCompanyCode && menu.company_code !== "*" ); if (unauthorizedMenus.length > 0) { res.status(403).json({ @@ -2674,7 +2688,10 @@ export const getCompanyByCode = async ( res.status(200).json(response); } catch (error) { - logger.error("회사 정보 조회 실패", { error, companyCode: req.params.companyCode }); + logger.error("회사 정보 조회 실패", { + error, + companyCode: req.params.companyCode, + }); res.status(500).json({ success: false, message: "회사 정보 조회 중 오류가 발생했습니다.", @@ -2740,7 +2757,9 @@ export const updateCompany = async ( // 사업자등록번호 중복 체크 및 유효성 검증 (자기 자신 제외) if (business_registration_number && business_registration_number.trim()) { // 유효성 검증 - const businessNumberValidation = validateBusinessNumber(business_registration_number.trim()); + const businessNumberValidation = validateBusinessNumber( + business_registration_number.trim() + ); if (!businessNumberValidation.isValid) { res.status(400).json({ success: false, @@ -3283,7 +3302,9 @@ export async function copyMenu( // 권한 체크: 최고 관리자만 가능 if (!isSuperAdmin && userType !== "SUPER_ADMIN") { - logger.warn(`권한 없음: ${userId} (userType=${userType}, company_code=${userCompanyCode})`); + logger.warn( + `권한 없음: ${userId} (userType=${userType}, company_code=${userCompanyCode})` + ); res.status(403).json({ success: false, message: "메뉴 복사는 최고 관리자(SUPER_ADMIN)만 가능합니다", diff --git a/backend-node/src/controllers/digitalTwinTemplateController.ts b/backend-node/src/controllers/digitalTwinTemplateController.ts new file mode 100644 index 00000000..882d8e62 --- /dev/null +++ b/backend-node/src/controllers/digitalTwinTemplateController.ts @@ -0,0 +1,163 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { + DigitalTwinTemplateService, + DigitalTwinLayoutTemplate, +} from "../services/DigitalTwinTemplateService"; + +export const listMappingTemplates = async ( + req: AuthenticatedRequest, + res: Response, +): Promise => { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ + success: false, + message: "인증 정보가 없습니다.", + }); + } + + const externalDbConnectionId = req.query.externalDbConnectionId + ? Number(req.query.externalDbConnectionId) + : undefined; + const layoutType = + typeof req.query.layoutType === "string" + ? req.query.layoutType + : undefined; + + const result = await DigitalTwinTemplateService.listTemplates( + companyCode, + { + externalDbConnectionId, + layoutType, + }, + ); + + if (!result.success) { + return res.status(500).json({ + success: false, + message: result.message, + error: result.error, + }); + } + + return res.json({ + success: true, + data: result.data as DigitalTwinLayoutTemplate[], + }); + } catch (error: any) { + return res.status(500).json({ + success: false, + message: "매핑 템플릿 목록 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + +export const getMappingTemplateById = async ( + req: AuthenticatedRequest, + res: Response, +): Promise => { + try { + const companyCode = req.user?.companyCode; + const { id } = req.params; + + if (!companyCode) { + return res.status(401).json({ + success: false, + message: "인증 정보가 없습니다.", + }); + } + + const result = await DigitalTwinTemplateService.getTemplateById( + companyCode, + id, + ); + + if (!result.success) { + return res.status(404).json({ + success: false, + message: result.message || "매핑 템플릿을 찾을 수 없습니다.", + error: result.error, + }); + } + + return res.json({ + success: true, + data: result.data, + }); + } catch (error: any) { + return res.status(500).json({ + success: false, + message: "매핑 템플릿 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + +export const createMappingTemplate = async ( + req: AuthenticatedRequest, + res: Response, +): Promise => { + try { + const companyCode = req.user?.companyCode; + const userId = req.user?.userId; + + if (!companyCode || !userId) { + return res.status(401).json({ + success: false, + message: "인증 정보가 없습니다.", + }); + } + + const { + name, + description, + externalDbConnectionId, + layoutType, + config, + } = req.body; + + if (!name || !externalDbConnectionId || !config) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다.", + }); + } + + const result = await DigitalTwinTemplateService.createTemplate( + companyCode, + userId, + { + name, + description, + externalDbConnectionId, + layoutType, + config, + }, + ); + + if (!result.success || !result.data) { + return res.status(500).json({ + success: false, + message: result.message || "매핑 템플릿 생성 중 오류가 발생했습니다.", + error: result.error, + }); + } + + return res.status(201).json({ + success: true, + data: result.data, + }); + } catch (error: any) { + return res.status(500).json({ + success: false, + message: "매핑 템플릿 생성 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + + + diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index be3a16a3..0ff80988 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -148,11 +148,11 @@ export const updateScreenInfo = async ( try { const { id } = req.params; const { companyCode } = req.user as any; - const { screenName, description, isActive } = req.body; + const { screenName, tableName, description, isActive } = req.body; await screenManagementService.updateScreenInfo( parseInt(id), - { screenName, description, isActive }, + { screenName, tableName, description, isActive }, companyCode ); res.json({ success: true, message: "화면 정보가 수정되었습니다." }); diff --git a/backend-node/src/database/MariaDBConnector.ts b/backend-node/src/database/MariaDBConnector.ts index 3f469330..cde351dc 100644 --- a/backend-node/src/database/MariaDBConnector.ts +++ b/backend-node/src/database/MariaDBConnector.ts @@ -1,7 +1,11 @@ -import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector'; -import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes'; +import { + DatabaseConnector, + ConnectionConfig, + QueryResult, +} from "../interfaces/DatabaseConnector"; +import { ConnectionTestResult, TableInfo } from "../types/externalDbTypes"; // @ts-ignore -import * as mysql from 'mysql2/promise'; +import * as mysql from "mysql2/promise"; export class MariaDBConnector implements DatabaseConnector { private connection: mysql.Connection | null = null; @@ -20,7 +24,7 @@ export class MariaDBConnector implements DatabaseConnector { password: this.config.password, database: this.config.database, connectTimeout: this.config.connectionTimeoutMillis, - ssl: typeof this.config.ssl === 'boolean' ? undefined : this.config.ssl, + ssl: typeof this.config.ssl === "boolean" ? undefined : this.config.ssl, }); } } @@ -36,7 +40,9 @@ export class MariaDBConnector implements DatabaseConnector { const startTime = Date.now(); try { await this.connect(); - const [rows] = await this.connection!.query("SELECT VERSION() as version"); + const [rows] = await this.connection!.query( + "SELECT VERSION() as version" + ); const version = (rows as any[])[0]?.version || "Unknown"; const responseTime = Date.now() - startTime; await this.disconnect(); @@ -89,15 +95,13 @@ export class MariaDBConnector implements DatabaseConnector { ORDER BY TABLE_NAME; `); - const tables: TableInfo[] = []; - for (const row of rows as any[]) { - const columns = await this.getColumns(row.table_name); - tables.push({ - table_name: row.table_name, - description: row.description || null, - columns: columns, - }); - } + // 테이블 목록만 반환 (컬럼 정보는 getColumns에서 개별 조회) + const tables: TableInfo[] = (rows as any[]).map((row) => ({ + table_name: row.table_name, + description: row.description || null, + columns: [], + })); + await this.disconnect(); return tables; } catch (error: any) { @@ -111,21 +115,43 @@ export class MariaDBConnector implements DatabaseConnector { console.log(`[MariaDBConnector] getColumns 호출: tableName=${tableName}`); await this.connect(); console.log(`[MariaDBConnector] 연결 완료, 쿼리 실행 시작`); - - const [rows] = await this.connection!.query(` + + const [rows] = await this.connection!.query( + ` SELECT - COLUMN_NAME as column_name, - DATA_TYPE as data_type, - IS_NULLABLE as is_nullable, - COLUMN_DEFAULT as column_default - FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? - ORDER BY ORDINAL_POSITION; - `, [tableName]); - + c.COLUMN_NAME AS column_name, + c.DATA_TYPE AS data_type, + c.IS_NULLABLE AS is_nullable, + c.COLUMN_DEFAULT AS column_default, + c.COLUMN_COMMENT AS description, + CASE + WHEN tc.CONSTRAINT_TYPE = 'PRIMARY KEY' THEN 'YES' + ELSE 'NO' + END AS is_primary_key + FROM information_schema.COLUMNS c + LEFT JOIN information_schema.KEY_COLUMN_USAGE k + ON c.TABLE_SCHEMA = k.TABLE_SCHEMA + AND c.TABLE_NAME = k.TABLE_NAME + AND c.COLUMN_NAME = k.COLUMN_NAME + LEFT JOIN information_schema.TABLE_CONSTRAINTS tc + ON k.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA + AND k.CONSTRAINT_NAME = tc.CONSTRAINT_NAME + AND k.TABLE_SCHEMA = tc.TABLE_SCHEMA + AND k.TABLE_NAME = tc.TABLE_NAME + AND tc.CONSTRAINT_TYPE = 'PRIMARY KEY' + WHERE c.TABLE_SCHEMA = DATABASE() + AND c.TABLE_NAME = ? + ORDER BY c.ORDINAL_POSITION; + `, + [tableName] + ); + console.log(`[MariaDBConnector] 쿼리 결과:`, rows); - console.log(`[MariaDBConnector] 결과 개수:`, Array.isArray(rows) ? rows.length : 'not array'); - + console.log( + `[MariaDBConnector] 결과 개수:`, + Array.isArray(rows) ? rows.length : "not array" + ); + await this.disconnect(); return rows as any[]; } catch (error: any) { diff --git a/backend-node/src/database/PostgreSQLConnector.ts b/backend-node/src/database/PostgreSQLConnector.ts index 52f9ce19..2461cc53 100644 --- a/backend-node/src/database/PostgreSQLConnector.ts +++ b/backend-node/src/database/PostgreSQLConnector.ts @@ -210,15 +210,33 @@ export class PostgreSQLConnector implements DatabaseConnector { const result = await tempClient.query( ` SELECT - column_name, - data_type, - is_nullable, - column_default, - col_description(c.oid, a.attnum) as column_comment + isc.column_name, + isc.data_type, + isc.is_nullable, + isc.column_default, + col_description(c.oid, a.attnum) as column_comment, + CASE + WHEN tc.constraint_type = 'PRIMARY KEY' THEN 'YES' + ELSE 'NO' + END AS is_primary_key FROM information_schema.columns isc - LEFT JOIN pg_class c ON c.relname = isc.table_name - LEFT JOIN pg_attribute a ON a.attrelid = c.oid AND a.attname = isc.column_name - WHERE isc.table_schema = 'public' AND isc.table_name = $1 + LEFT JOIN pg_class c + ON c.relname = isc.table_name + AND c.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = isc.table_schema) + LEFT JOIN pg_attribute a + ON a.attrelid = c.oid + AND a.attname = isc.column_name + LEFT JOIN information_schema.key_column_usage k + ON k.table_name = isc.table_name + AND k.table_schema = isc.table_schema + AND k.column_name = isc.column_name + LEFT JOIN information_schema.table_constraints tc + ON tc.constraint_name = k.constraint_name + AND tc.table_schema = k.table_schema + AND tc.table_name = k.table_name + AND tc.constraint_type = 'PRIMARY KEY' + WHERE isc.table_schema = 'public' + AND isc.table_name = $1 ORDER BY isc.ordinal_position; `, [tableName] diff --git a/backend-node/src/routes/dataflow/node-flows.ts b/backend-node/src/routes/dataflow/node-flows.ts index 7ede970a..f13d65cf 100644 --- a/backend-node/src/routes/dataflow/node-flows.ts +++ b/backend-node/src/routes/dataflow/node-flows.ts @@ -7,6 +7,7 @@ import { query, queryOne } from "../../database/db"; import { logger } from "../../utils/logger"; import { NodeFlowExecutionService } from "../../services/nodeFlowExecutionService"; import { AuthenticatedRequest } from "../../types/auth"; +import { authenticateToken } from "../../middleware/authMiddleware"; const router = Router(); @@ -217,19 +218,29 @@ router.delete("/:flowId", async (req: Request, res: Response) => { * 플로우 실행 * POST /api/dataflow/node-flows/:flowId/execute */ -router.post("/:flowId/execute", async (req: Request, res: Response) => { +router.post("/:flowId/execute", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { try { const { flowId } = req.params; const contextData = req.body; logger.info(`🚀 플로우 실행 요청: flowId=${flowId}`, { contextDataKeys: Object.keys(contextData), + userId: req.user?.userId, + companyCode: req.user?.companyCode, }); + // 사용자 정보를 contextData에 추가 + const enrichedContextData = { + ...contextData, + userId: req.user?.userId, + userName: req.user?.userName, + companyCode: req.user?.companyCode, + }; + // 플로우 실행 const result = await NodeFlowExecutionService.executeFlow( parseInt(flowId, 10), - contextData + enrichedContextData ); return res.json({ diff --git a/backend-node/src/routes/digitalTwinRoutes.ts b/backend-node/src/routes/digitalTwinRoutes.ts index 904096f7..467813f0 100644 --- a/backend-node/src/routes/digitalTwinRoutes.ts +++ b/backend-node/src/routes/digitalTwinRoutes.ts @@ -9,6 +9,11 @@ import { updateLayout, deleteLayout, } from "../controllers/digitalTwinLayoutController"; +import { + listMappingTemplates, + getMappingTemplateById, + createMappingTemplate, +} from "../controllers/digitalTwinTemplateController"; // 외부 DB 데이터 조회 import { @@ -27,11 +32,16 @@ const router = express.Router(); router.use(authenticateToken); // ========== 레이아웃 관리 API ========== -router.get("/layouts", getLayouts); // 레이아웃 목록 -router.get("/layouts/:id", getLayoutById); // 레이아웃 상세 -router.post("/layouts", createLayout); // 레이아웃 생성 -router.put("/layouts/:id", updateLayout); // 레이아웃 수정 -router.delete("/layouts/:id", deleteLayout); // 레이아웃 삭제 +router.get("/layouts", getLayouts); // 레이아웃 목록 +router.get("/layouts/:id", getLayoutById); // 레이아웃 상세 +router.post("/layouts", createLayout); // 레이아웃 생성 +router.put("/layouts/:id", updateLayout); // 레이아웃 수정 +router.delete("/layouts/:id", deleteLayout); // 레이아웃 삭제 + +// ========== 매핑 템플릿 API ========== +router.get("/mapping-templates", listMappingTemplates); +router.get("/mapping-templates/:id", getMappingTemplateById); +router.post("/mapping-templates", createMappingTemplate); // ========== 외부 DB 데이터 조회 API ========== diff --git a/backend-node/src/services/DigitalTwinTemplateService.ts b/backend-node/src/services/DigitalTwinTemplateService.ts new file mode 100644 index 00000000..d4818b3a --- /dev/null +++ b/backend-node/src/services/DigitalTwinTemplateService.ts @@ -0,0 +1,172 @@ +import { pool } from "../database/db"; +import logger from "../utils/logger"; + +export interface DigitalTwinLayoutTemplate { + id: string; + company_code: string; + name: string; + description?: string | null; + external_db_connection_id: number; + layout_type: string; + config: any; + created_by: string; + created_at: Date; + updated_by: string; + updated_at: Date; +} + +interface ServiceResponse { + success: boolean; + data?: T; + message?: string; + error?: string; +} + +export class DigitalTwinTemplateService { + static async listTemplates( + companyCode: string, + options: { externalDbConnectionId?: number; layoutType?: string } = {}, + ): Promise> { + try { + const params: any[] = [companyCode]; + let paramIndex = 2; + + let query = ` + SELECT * + FROM digital_twin_layout_template + WHERE company_code = $1 + `; + + if (options.layoutType) { + query += ` AND layout_type = $${paramIndex++}`; + params.push(options.layoutType); + } + + if (options.externalDbConnectionId) { + query += ` AND external_db_connection_id = $${paramIndex++}`; + params.push(options.externalDbConnectionId); + } + + query += ` + ORDER BY updated_at DESC, name ASC + `; + + const result = await pool.query(query, params); + + logger.info("디지털 트윈 매핑 템플릿 목록 조회", { + companyCode, + count: result.rowCount, + }); + + return { + success: true, + data: result.rows as DigitalTwinLayoutTemplate[], + }; + } catch (error: any) { + logger.error("디지털 트윈 매핑 템플릿 목록 조회 실패", error); + return { + success: false, + error: error.message, + message: "매핑 템플릿 목록 조회 중 오류가 발생했습니다.", + }; + } + } + + static async getTemplateById( + companyCode: string, + id: string, + ): Promise> { + try { + const query = ` + SELECT * + FROM digital_twin_layout_template + WHERE id = $1 AND company_code = $2 + `; + + const result = await pool.query(query, [id, companyCode]); + + if (result.rowCount === 0) { + return { + success: false, + message: "매핑 템플릿을 찾을 수 없습니다.", + }; + } + + return { + success: true, + data: result.rows[0] as DigitalTwinLayoutTemplate, + }; + } catch (error: any) { + logger.error("디지털 트윈 매핑 템플릿 조회 실패", error); + return { + success: false, + error: error.message, + message: "매핑 템플릿 조회 중 오류가 발생했습니다.", + }; + } + } + + static async createTemplate( + companyCode: string, + userId: string, + payload: { + name: string; + description?: string; + externalDbConnectionId: number; + layoutType?: string; + config: any; + }, + ): Promise> { + try { + const query = ` + INSERT INTO digital_twin_layout_template ( + company_code, + name, + description, + external_db_connection_id, + layout_type, + config, + created_by, + created_at, + updated_by, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $7, NOW()) + RETURNING * + `; + + const values = [ + companyCode, + payload.name, + payload.description || null, + payload.externalDbConnectionId, + payload.layoutType || "yard-3d", + JSON.stringify(payload.config), + userId, + ]; + + const result = await pool.query(query, values); + + logger.info("디지털 트윈 매핑 템플릿 생성", { + companyCode, + templateId: result.rows[0].id, + externalDbConnectionId: payload.externalDbConnectionId, + }); + + return { + success: true, + data: result.rows[0] as DigitalTwinLayoutTemplate, + }; + } catch (error: any) { + logger.error("디지털 트윈 매핑 템플릿 생성 실패", error); + return { + success: false, + error: error.message, + message: "매핑 템플릿 생성 중 오류가 발생했습니다.", + }; + } + } +} + + + diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 965d2833..e9485620 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -496,16 +496,27 @@ export class DynamicFormService { for (const repeater of mergedRepeaterData) { for (const item of repeater.data) { // 헤더 + 품목을 병합 - const mergedData = { ...dataToInsert, ...item }; + const rawMergedData = { ...dataToInsert, ...item }; - // 타입 변환 - Object.keys(mergedData).forEach((columnName) => { + // 🆕 실제 테이블 컬럼만 필터링 (조인/계산 컬럼 제외) + const validColumnNames = columnInfo.map((col) => col.column_name); + const mergedData: Record = {}; + + Object.keys(rawMergedData).forEach((columnName) => { + // 실제 테이블 컬럼인지 확인 + if (validColumnNames.includes(columnName)) { const column = columnInfo.find((col) => col.column_name === columnName); if (column) { + // 타입 변환 mergedData[columnName] = this.convertValueForPostgreSQL( - mergedData[columnName], + rawMergedData[columnName], column.data_type ); + } else { + mergedData[columnName] = rawMergedData[columnName]; + } + } else { + console.log(`⚠️ 조인/계산 컬럼 제외: ${columnName} (값: ${rawMergedData[columnName]})`); } }); @@ -800,9 +811,39 @@ export class DynamicFormService { const primaryKeyColumn = primaryKeys[0]; console.log(`🔑 테이블 ${tableName}의 기본키: ${primaryKeyColumn}`); - // 동적 UPDATE SQL 생성 (변경된 필드만) + // 🆕 컬럼 타입 조회 (타입 캐스팅용) + const columnTypesQuery = ` + SELECT column_name, data_type + FROM information_schema.columns + WHERE table_name = $1 AND table_schema = 'public' + `; + const columnTypesResult = await query<{ column_name: string; data_type: string }>( + columnTypesQuery, + [tableName] + ); + const columnTypes: Record = {}; + columnTypesResult.forEach((row) => { + columnTypes[row.column_name] = row.data_type; + }); + + console.log("📊 컬럼 타입 정보:", columnTypes); + + // 🆕 동적 UPDATE SQL 생성 (타입 캐스팅 포함) const setClause = Object.keys(changedFields) - .map((key, index) => `${key} = $${index + 1}`) + .map((key, index) => { + const dataType = columnTypes[key]; + // 숫자 타입인 경우 명시적 캐스팅 + if (dataType === 'integer' || dataType === 'bigint' || dataType === 'smallint') { + return `${key} = $${index + 1}::integer`; + } else if (dataType === 'numeric' || dataType === 'decimal' || dataType === 'real' || dataType === 'double precision') { + return `${key} = $${index + 1}::numeric`; + } else if (dataType === 'boolean') { + return `${key} = $${index + 1}::boolean`; + } else { + // 문자열 타입은 캐스팅 불필요 + return `${key} = $${index + 1}`; + } + }) .join(", "); const values: any[] = Object.values(changedFields); diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index 546b215a..9cdd85f3 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -938,6 +938,30 @@ export class NodeFlowExecutionService { insertedData[mapping.targetField] = value; }); + // 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우) + const hasWriterMapping = fieldMappings.some((m: any) => m.targetField === "writer"); + const hasCompanyCodeMapping = fieldMappings.some((m: any) => m.targetField === "company_code"); + + // 컨텍스트에서 사용자 정보 추출 + const userId = context.buttonContext?.userId; + const companyCode = context.buttonContext?.companyCode; + + // writer 자동 추가 (매핑에 없고, 컨텍스트에 userId가 있는 경우) + if (!hasWriterMapping && userId) { + fields.push("writer"); + values.push(userId); + insertedData.writer = userId; + console.log(` 🔧 자동 추가: writer = ${userId}`); + } + + // company_code 자동 추가 (매핑에 없고, 컨텍스트에 companyCode가 있는 경우) + if (!hasCompanyCodeMapping && companyCode && companyCode !== "*") { + fields.push("company_code"); + values.push(companyCode); + insertedData.company_code = companyCode; + console.log(` 🔧 자동 추가: company_code = ${companyCode}`); + } + const sql = ` INSERT INTO ${targetTable} (${fields.join(", ")}) VALUES (${fields.map((_, i) => `$${i + 1}`).join(", ")}) diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 6c3a3430..9dbe0270 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -321,7 +321,7 @@ export class ScreenManagementService { */ async updateScreenInfo( screenId: number, - updateData: { screenName: string; description?: string; isActive: string }, + updateData: { screenName: string; tableName?: string; description?: string; isActive: string }, userCompanyCode: string ): Promise { // 권한 확인 @@ -343,16 +343,18 @@ export class ScreenManagementService { throw new Error("이 화면을 수정할 권한이 없습니다."); } - // 화면 정보 업데이트 + // 화면 정보 업데이트 (tableName 포함) await query( `UPDATE screen_definitions SET screen_name = $1, - description = $2, - is_active = $3, - updated_date = $4 - WHERE screen_id = $5`, + table_name = $2, + description = $3, + is_active = $4, + updated_date = $5 + WHERE screen_id = $6`, [ updateData.screenName, + updateData.tableName || null, updateData.description || null, updateData.isActive, new Date(), diff --git a/frontend/app/(main)/main/page.tsx b/frontend/app/(main)/main/page.tsx index 45f75f67..00ef509b 100644 --- a/frontend/app/(main)/main/page.tsx +++ b/frontend/app/(main)/main/page.tsx @@ -9,7 +9,7 @@ import { Badge } from "@/components/ui/badge"; */ export default function MainPage() { return ( -
+
{/* 메인 컨텐츠 */} {/* Welcome Message */} diff --git a/frontend/app/(main)/page.tsx b/frontend/app/(main)/page.tsx index 53c6dfb1..f5d7a153 100644 --- a/frontend/app/(main)/page.tsx +++ b/frontend/app/(main)/page.tsx @@ -1,6 +1,6 @@ export default function MainHomePage() { return ( -
+
{/* 대시보드 컨텐츠 */}

WACE 솔루션에 오신 것을 환영합니다!

diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 3b75f262..ce99a685 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -26,7 +26,7 @@ function ScreenViewPage() { const searchParams = useSearchParams(); const router = useRouter(); const screenId = parseInt(params.screenId as string); - + // URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프) const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined; @@ -178,31 +178,26 @@ function ScreenViewPage() { for (const comp of layout.components) { // type: "component" 또는 type: "widget" 모두 처리 - if (comp.type === 'widget' || comp.type === 'component') { + if (comp.type === "widget" || comp.type === "component") { const widget = comp as any; const fieldName = widget.columnName || widget.id; - + // autoFill 처리 if (widget.autoFill?.enabled || (comp as any).autoFill?.enabled) { const autoFillConfig = widget.autoFill || (comp as any).autoFill; const currentValue = formData[fieldName]; - - if (currentValue === undefined || currentValue === '') { + + if (currentValue === undefined || currentValue === "") { const { sourceTable, filterColumn, userField, displayColumn } = autoFillConfig; - + // 사용자 정보에서 필터 값 가져오기 const userValue = user?.[userField as keyof typeof user]; - + if (userValue && sourceTable && filterColumn && displayColumn) { try { const { tableTypeApi } = await import("@/lib/api/screen"); - const result = await tableTypeApi.getTableRecord( - sourceTable, - filterColumn, - userValue, - displayColumn - ); - + const result = await tableTypeApi.getTableRecord(sourceTable, filterColumn, userValue, displayColumn); + setFormData((prev) => ({ ...prev, [fieldName]: result.value, @@ -233,24 +228,27 @@ function ScreenViewPage() { const designWidth = layout?.screenResolution?.width || 1200; const designHeight = layout?.screenResolution?.height || 800; - // containerRef는 이미 패딩이 적용된 영역 내부이므로 offsetWidth는 패딩을 제외한 크기입니다 + // 컨테이너의 실제 크기 const containerWidth = containerRef.current.offsetWidth; const containerHeight = containerRef.current.offsetHeight; - // 화면이 잘리지 않도록 가로/세로 중 작은 쪽 기준으로 스케일 조정 - const scaleX = containerWidth / designWidth; - const scaleY = containerHeight / designHeight; - // 전체 화면이 보이도록 작은 쪽 기준으로 스케일 설정 - const newScale = Math.min(scaleX, scaleY); - + // 여백 설정: 좌우 16px씩 (총 32px), 상단 패딩 32px (pt-8) + const MARGIN_X = 32; + const availableWidth = containerWidth - MARGIN_X; + + // 가로 기준 스케일 계산 (좌우 여백 16px씩 고정) + const newScale = availableWidth / designWidth; + console.log("📐 스케일 계산:", { containerWidth, containerHeight, + MARGIN_X, + availableWidth, designWidth, designHeight, - scaleX, - scaleY, finalScale: newScale, + "스케일된 화면 크기": `${designWidth * newScale}px × ${designHeight * newScale}px`, + "실제 좌우 여백": `${(containerWidth - designWidth * newScale) / 2}px씩`, }); setScale(newScale); @@ -307,503 +305,531 @@ function ScreenViewPage() { return ( -
- {/* 레이아웃 준비 중 로딩 표시 */} - {!layoutReady && ( -
-
- -

화면 준비 중...

-
+
+ {/* 레이아웃 준비 중 로딩 표시 */} + {!layoutReady && ( +
+
+ +

화면 준비 중...

- )} +
+ )} - {/* 절대 위치 기반 렌더링 (화면관리와 동일한 방식) */} - {layoutReady && layout && layout.components.length > 0 ? ( -
- {/* 최상위 컴포넌트들 렌더링 */} - {(() => { - // 🆕 플로우 버튼 그룹 감지 및 처리 - const topLevelComponents = layout.components.filter((component) => !component.parentId); + {/* 절대 위치 기반 렌더링 (화면관리와 동일한 방식) */} + {layoutReady && layout && layout.components.length > 0 ? ( +
+ {/* 최상위 컴포넌트들 렌더링 */} + {(() => { + // 🆕 플로우 버튼 그룹 감지 및 처리 + const topLevelComponents = layout.components.filter((component) => !component.parentId); - // 화면 관리에서 설정한 해상도를 사용하므로 widthOffset 계산 불필요 - // 모든 컴포넌트는 원본 위치 그대로 사용 - const widthOffset = 0; + // 화면 관리에서 설정한 해상도를 사용하므로 widthOffset 계산 불필요 + // 모든 컴포넌트는 원본 위치 그대로 사용 + const widthOffset = 0; - const buttonGroups: Record = {}; - const processedButtonIds = new Set(); - // 🔍 전체 버튼 목록 확인 - const allButtons = topLevelComponents.filter((component) => { - const isButton = - (component.type === "component" && - ["button-primary", "button-secondary"].includes((component as any).componentType)) || - (component.type === "widget" && (component as any).widgetType === "button"); - return isButton; - }); + const buttonGroups: Record = {}; + const processedButtonIds = new Set(); + // 🔍 전체 버튼 목록 확인 + const allButtons = topLevelComponents.filter((component) => { + const isButton = + (component.type === "component" && + ["button-primary", "button-secondary"].includes((component as any).componentType)) || + (component.type === "widget" && (component as any).widgetType === "button"); + return isButton; + }); - console.log( - "🔍 메뉴에서 발견된 전체 버튼:", - allButtons.map((b) => ({ - id: b.id, - label: b.label, - positionX: b.position.x, - positionY: b.position.y, - width: b.size?.width, - height: b.size?.height, - })), - ); + console.log( + "🔍 메뉴에서 발견된 전체 버튼:", + allButtons.map((b) => ({ + id: b.id, + label: b.label, + positionX: b.position.x, + positionY: b.position.y, + width: b.size?.width, + height: b.size?.height, + })), + ); - topLevelComponents.forEach((component) => { - const isButton = - (component.type === "component" && - ["button-primary", "button-secondary"].includes((component as any).componentType)) || - (component.type === "widget" && (component as any).widgetType === "button"); + topLevelComponents.forEach((component) => { + const isButton = + (component.type === "component" && + ["button-primary", "button-secondary"].includes((component as any).componentType)) || + (component.type === "widget" && (component as any).widgetType === "button"); - if (isButton) { - const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig as - | FlowVisibilityConfig - | undefined; + if (isButton) { + const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig as + | FlowVisibilityConfig + | undefined; - // 🔧 임시: 버튼 그룹 기능 완전 비활성화 - // TODO: 사용자가 명시적으로 그룹을 원하는 경우에만 활성화하도록 UI 개선 필요 - const DISABLE_BUTTON_GROUPS = false; + // 🔧 임시: 버튼 그룹 기능 완전 비활성화 + // TODO: 사용자가 명시적으로 그룹을 원하는 경우에만 활성화하도록 UI 개선 필요 + const DISABLE_BUTTON_GROUPS = false; - if ( - !DISABLE_BUTTON_GROUPS && - flowConfig?.enabled && - flowConfig.layoutBehavior === "auto-compact" && - flowConfig.groupId - ) { - if (!buttonGroups[flowConfig.groupId]) { - buttonGroups[flowConfig.groupId] = []; + if ( + !DISABLE_BUTTON_GROUPS && + flowConfig?.enabled && + flowConfig.layoutBehavior === "auto-compact" && + flowConfig.groupId + ) { + if (!buttonGroups[flowConfig.groupId]) { + buttonGroups[flowConfig.groupId] = []; + } + buttonGroups[flowConfig.groupId].push(component); + processedButtonIds.add(component.id); } - buttonGroups[flowConfig.groupId].push(component); - processedButtonIds.add(component.id); + // else: 모든 버튼을 개별 렌더링 } - // else: 모든 버튼을 개별 렌더링 - } - }); + }); - const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id)); + const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id)); - // TableSearchWidget들을 먼저 찾기 - const tableSearchWidgets = regularComponents.filter( - (c) => (c as any).componentId === "table-search-widget" - ); + // TableSearchWidget들을 먼저 찾기 + const tableSearchWidgets = regularComponents.filter( + (c) => (c as any).componentId === "table-search-widget", + ); - // 디버그: 모든 컴포넌트 타입 확인 - console.log("🔍 전체 컴포넌트 타입:", regularComponents.map(c => ({ - id: c.id, - type: c.type, - componentType: (c as any).componentType, - componentId: (c as any).componentId, - }))); - - // 🆕 조건부 컨테이너들을 찾기 - const conditionalContainers = regularComponents.filter( - (c) => (c as any).componentId === "conditional-container" || (c as any).componentType === "conditional-container" - ); - - console.log("🔍 조건부 컨테이너 발견:", conditionalContainers.map(c => ({ - id: c.id, - y: c.position.y, - size: c.size, - }))); + // 디버그: 모든 컴포넌트 타입 확인 + console.log( + "🔍 전체 컴포넌트 타입:", + regularComponents.map((c) => ({ + id: c.id, + type: c.type, + componentType: (c as any).componentType, + componentId: (c as any).componentId, + })), + ); - // TableSearchWidget 및 조건부 컨테이너 높이 차이를 계산하여 Y 위치 조정 - const adjustedComponents = regularComponents.map((component) => { - const isTableSearchWidget = (component as any).componentId === "table-search-widget"; - const isConditionalContainer = (component as any).componentId === "conditional-container"; - - if (isTableSearchWidget || isConditionalContainer) { - // 자기 자신은 조정하지 않음 - return component; - } - - let totalHeightAdjustment = 0; - - // TableSearchWidget 높이 조정 - for (const widget of tableSearchWidgets) { - const isBelow = component.position.y > widget.position.y; - const heightDiff = getHeightDiff(screenId, widget.id); - - if (isBelow && heightDiff > 0) { - totalHeightAdjustment += heightDiff; + // 🆕 조건부 컨테이너들을 찾기 + const conditionalContainers = regularComponents.filter( + (c) => + (c as any).componentId === "conditional-container" || + (c as any).componentType === "conditional-container", + ); + + console.log( + "🔍 조건부 컨테이너 발견:", + conditionalContainers.map((c) => ({ + id: c.id, + y: c.position.y, + size: c.size, + })), + ); + + // TableSearchWidget 및 조건부 컨테이너 높이 차이를 계산하여 Y 위치 조정 + const adjustedComponents = regularComponents.map((component) => { + const isTableSearchWidget = (component as any).componentId === "table-search-widget"; + const isConditionalContainer = (component as any).componentId === "conditional-container"; + + if (isTableSearchWidget || isConditionalContainer) { + // 자기 자신은 조정하지 않음 + return component; } - } - - // 🆕 조건부 컨테이너 높이 조정 - for (const container of conditionalContainers) { - const isBelow = component.position.y > container.position.y; - const actualHeight = conditionalContainerHeights[container.id]; - const originalHeight = container.size?.height || 200; - const heightDiff = actualHeight ? (actualHeight - originalHeight) : 0; - - console.log(`🔍 높이 조정 체크:`, { - componentId: component.id, - componentY: component.position.y, - containerY: container.position.y, - isBelow, - actualHeight, - originalHeight, - heightDiff, - containerId: container.id, - containerSize: container.size, - }); - - if (isBelow && heightDiff > 0) { - totalHeightAdjustment += heightDiff; - console.log(`📐 컴포넌트 ${component.id} 위치 조정: ${heightDiff}px (조건부 컨테이너 ${container.id})`); + + let totalHeightAdjustment = 0; + + // TableSearchWidget 높이 조정 + for (const widget of tableSearchWidgets) { + const isBelow = component.position.y > widget.position.y; + const heightDiff = getHeightDiff(screenId, widget.id); + + if (isBelow && heightDiff > 0) { + totalHeightAdjustment += heightDiff; + } } - } - - if (totalHeightAdjustment > 0) { - return { - ...component, - position: { - ...component.position, - y: component.position.y + totalHeightAdjustment, - }, - }; - } - - return component; - }); - return ( - <> - {/* 일반 컴포넌트들 */} - {adjustedComponents.map((component) => { - // 화면 관리 해상도를 사용하므로 위치 조정 불필요 - return ( - {}} - menuObjid={menuObjid} - screenId={screenId} - tableName={screen?.tableName} - userId={user?.userId} - userName={userName} - companyCode={companyCode} - menuObjid={menuObjid} - selectedRowsData={selectedRowsData} - sortBy={tableSortBy} - sortOrder={tableSortOrder} - columnOrder={tableColumnOrder} - tableDisplayData={tableDisplayData} - onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder, tableDisplayData) => { - console.log("🔍 화면에서 선택된 행 데이터:", selectedData); - console.log("📊 정렬 정보:", { sortBy, sortOrder, columnOrder }); - console.log("📊 화면 표시 데이터:", { count: tableDisplayData?.length, firstRow: tableDisplayData?.[0] }); - setSelectedRowsData(selectedData); - setTableSortBy(sortBy); - setTableSortOrder(sortOrder || "asc"); - setTableColumnOrder(columnOrder); - setTableDisplayData(tableDisplayData || []); - }} - flowSelectedData={flowSelectedData} - flowSelectedStepId={flowSelectedStepId} - onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => { - setFlowSelectedData(selectedData); - setFlowSelectedStepId(stepId); - }} - refreshKey={tableRefreshKey} - onRefresh={() => { - setTableRefreshKey((prev) => prev + 1); - setSelectedRowsData([]); // 선택 해제 - }} - flowRefreshKey={flowRefreshKey} - onFlowRefresh={() => { - setFlowRefreshKey((prev) => prev + 1); - setFlowSelectedData([]); // 선택 해제 - setFlowSelectedStepId(null); - }} - formData={formData} - onFormDataChange={(fieldName, value) => { - setFormData((prev) => ({ ...prev, [fieldName]: value })); - }} - onHeightChange={(componentId, newHeight) => { - setConditionalContainerHeights((prev) => ({ - ...prev, - [componentId]: newHeight, - })); - }} - > - {/* 자식 컴포넌트들 */} - {(component.type === "group" || component.type === "container" || component.type === "area") && - layout.components - .filter((child) => child.parentId === component.id) - .map((child) => { - // 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정 - const relativeChildComponent = { - ...child, - position: { - x: child.position.x - component.position.x, - y: child.position.y - component.position.y, - z: child.position.z || 1, - }, - }; + // 🆕 조건부 컨테이너 높이 조정 + for (const container of conditionalContainers) { + const isBelow = component.position.y > container.position.y; + const actualHeight = conditionalContainerHeights[container.id]; + const originalHeight = container.size?.height || 200; + const heightDiff = actualHeight ? actualHeight - originalHeight : 0; - return ( - {}} - menuObjid={menuObjid} - screenId={screenId} - tableName={screen?.tableName} - userId={user?.userId} - userName={userName} - companyCode={companyCode} - menuObjid={menuObjid} - selectedRowsData={selectedRowsData} - sortBy={tableSortBy} - sortOrder={tableSortOrder} - columnOrder={tableColumnOrder} - tableDisplayData={tableDisplayData} - onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder, tableDisplayData) => { - console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData); - console.log("📊 정렬 정보 (자식):", { sortBy, sortOrder, columnOrder }); - console.log("📊 화면 표시 데이터 (자식):", { count: tableDisplayData?.length, firstRow: tableDisplayData?.[0] }); - setSelectedRowsData(selectedData); - setTableSortBy(sortBy); - setTableSortOrder(sortOrder || "asc"); - setTableColumnOrder(columnOrder); - setTableDisplayData(tableDisplayData || []); - }} - refreshKey={tableRefreshKey} - onRefresh={() => { - console.log("🔄 테이블 새로고침 요청됨 (자식)"); - setTableRefreshKey((prev) => prev + 1); - setSelectedRowsData([]); // 선택 해제 - }} - formData={formData} - onFormDataChange={(fieldName, value) => { - setFormData((prev) => ({ ...prev, [fieldName]: value })); - }} - /> - ); - })} - - ); - })} - - {/* 🆕 플로우 버튼 그룹들 */} - {Object.entries(buttonGroups).map(([groupId, buttons]) => { - if (buttons.length === 0) return null; - - const firstButton = buttons[0]; - const groupConfig = (firstButton as any).webTypeConfig - ?.flowVisibilityConfig as FlowVisibilityConfig; - - // 🔍 버튼 그룹 설정 확인 - console.log("🔍 버튼 그룹 설정:", { - groupId, - buttonCount: buttons.length, - buttons: buttons.map((b) => ({ - id: b.id, - label: b.label, - x: b.position.x, - y: b.position.y, - })), - groupConfig: { - layoutBehavior: groupConfig.layoutBehavior, - groupDirection: groupConfig.groupDirection, - groupAlign: groupConfig.groupAlign, - groupGap: groupConfig.groupGap, - }, + console.log(`🔍 높이 조정 체크:`, { + componentId: component.id, + componentY: component.position.y, + containerY: container.position.y, + isBelow, + actualHeight, + originalHeight, + heightDiff, + containerId: container.id, + containerSize: container.size, }); - // 🔧 수정: 그룹 컨테이너는 첫 번째 버튼 위치를 기준으로 하되, - // 각 버튼의 상대 위치는 원래 위치를 유지 - const firstButtonPosition = { - x: buttons[0].position.x, - y: buttons[0].position.y, - z: buttons[0].position.z || 2, - }; - - // 버튼 그룹 위치에도 widthOffset 적용 - const adjustedGroupPosition = { - ...firstButtonPosition, - x: firstButtonPosition.x + widthOffset, - }; - - // 그룹의 크기 계산: 버튼들의 실제 크기 + 간격을 기준으로 계산 - const direction = groupConfig.groupDirection || "horizontal"; - const gap = groupConfig.groupGap ?? 8; - - let groupWidth = 0; - let groupHeight = 0; - - if (direction === "horizontal") { - groupWidth = buttons.reduce((total, button, index) => { - const buttonWidth = button.size?.width || 100; - const gapWidth = index < buttons.length - 1 ? gap : 0; - return total + buttonWidth + gapWidth; - }, 0); - groupHeight = Math.max(...buttons.map((b) => b.size?.height || 40)); - } else { - groupWidth = Math.max(...buttons.map((b) => b.size?.width || 100)); - groupHeight = buttons.reduce((total, button, index) => { - const buttonHeight = button.size?.height || 40; - const gapHeight = index < buttons.length - 1 ? gap : 0; - return total + buttonHeight + gapHeight; - }, 0); + if (isBelow && heightDiff > 0) { + totalHeightAdjustment += heightDiff; + console.log( + `📐 컴포넌트 ${component.id} 위치 조정: ${heightDiff}px (조건부 컨테이너 ${container.id})`, + ); } + } - return ( -
- 0) { + return { + ...component, + position: { + ...component.position, + y: component.position.y + totalHeightAdjustment, + }, + }; + } + + return component; + }); + + return ( + <> + {/* 일반 컴포넌트들 */} + {adjustedComponents.map((component) => { + // 화면 관리 해상도를 사용하므로 위치 조정 불필요 + return ( + { - // 🔧 각 버튼의 상대 위치 = 버튼의 원래 위치 - 첫 번째 버튼 위치 - const relativeButton = { - ...button, - position: { - x: button.position.x - firstButtonPosition.x, - y: button.position.y - firstButtonPosition.y, - z: button.position.z || 1, - }, - }; + onClick={() => {}} + menuObjid={menuObjid} + screenId={screenId} + tableName={screen?.tableName} + userId={user?.userId} + userName={userName} + companyCode={companyCode} + menuObjid={menuObjid} + selectedRowsData={selectedRowsData} + sortBy={tableSortBy} + sortOrder={tableSortOrder} + columnOrder={tableColumnOrder} + tableDisplayData={tableDisplayData} + onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder, tableDisplayData) => { + console.log("🔍 화면에서 선택된 행 데이터:", selectedData); + console.log("📊 정렬 정보:", { sortBy, sortOrder, columnOrder }); + console.log("📊 화면 표시 데이터:", { + count: tableDisplayData?.length, + firstRow: tableDisplayData?.[0], + }); + setSelectedRowsData(selectedData); + setTableSortBy(sortBy); + setTableSortOrder(sortOrder || "asc"); + setTableColumnOrder(columnOrder); + setTableDisplayData(tableDisplayData || []); + }} + flowSelectedData={flowSelectedData} + flowSelectedStepId={flowSelectedStepId} + onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => { + setFlowSelectedData(selectedData); + setFlowSelectedStepId(stepId); + }} + refreshKey={tableRefreshKey} + onRefresh={() => { + setTableRefreshKey((prev) => prev + 1); + setSelectedRowsData([]); // 선택 해제 + }} + flowRefreshKey={flowRefreshKey} + onFlowRefresh={() => { + setFlowRefreshKey((prev) => prev + 1); + setFlowSelectedData([]); // 선택 해제 + setFlowSelectedStepId(null); + }} + formData={formData} + onFormDataChange={(fieldName, value) => { + setFormData((prev) => ({ ...prev, [fieldName]: value })); + }} + onHeightChange={(componentId, newHeight) => { + setConditionalContainerHeights((prev) => ({ + ...prev, + [componentId]: newHeight, + })); + }} + > + {/* 자식 컴포넌트들 */} + {(component.type === "group" || + component.type === "container" || + component.type === "area") && + layout.components + .filter((child) => child.parentId === component.id) + .map((child) => { + // 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정 + const relativeChildComponent = { + ...child, + position: { + x: child.position.x - component.position.x, + y: child.position.y - component.position.y, + z: child.position.z || 1, + }, + }; - return ( -
-
- {}} + onClick={() => {}} + menuObjid={menuObjid} screenId={screenId} tableName={screen?.tableName} userId={user?.userId} userName={userName} companyCode={companyCode} - tableDisplayData={tableDisplayData} + menuObjid={menuObjid} selectedRowsData={selectedRowsData} sortBy={tableSortBy} sortOrder={tableSortOrder} columnOrder={tableColumnOrder} - onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder) => { + tableDisplayData={tableDisplayData} + onSelectedRowsChange={( + _, + selectedData, + sortBy, + sortOrder, + columnOrder, + tableDisplayData, + ) => { + console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData); + console.log("📊 정렬 정보 (자식):", { sortBy, sortOrder, columnOrder }); + console.log("📊 화면 표시 데이터 (자식):", { + count: tableDisplayData?.length, + firstRow: tableDisplayData?.[0], + }); setSelectedRowsData(selectedData); setTableSortBy(sortBy); setTableSortOrder(sortOrder || "asc"); setTableColumnOrder(columnOrder); - }} - flowSelectedData={flowSelectedData} - flowSelectedStepId={flowSelectedStepId} - onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => { - setFlowSelectedData(selectedData); - setFlowSelectedStepId(stepId); + setTableDisplayData(tableDisplayData || []); }} refreshKey={tableRefreshKey} onRefresh={() => { + console.log("🔄 테이블 새로고침 요청됨 (자식)"); setTableRefreshKey((prev) => prev + 1); - setSelectedRowsData([]); - }} - flowRefreshKey={flowRefreshKey} - onFlowRefresh={() => { - setFlowRefreshKey((prev) => prev + 1); - setFlowSelectedData([]); - setFlowSelectedStepId(null); + setSelectedRowsData([]); // 선택 해제 }} + formData={formData} onFormDataChange={(fieldName, value) => { setFormData((prev) => ({ ...prev, [fieldName]: value })); }} /> -
-
- ); - }} - /> -
- ); - })} - - ); - })()} -
- ) : ( - // 빈 화면일 때 -
-
-
- 📄 -
-

화면이 비어있습니다

-

이 화면에는 아직 설계된 컴포넌트가 없습니다.

-
-
- )} + ); + })} + + ); + })} - {/* 편집 모달 */} - { - setEditModalOpen(false); - setEditModalConfig({}); - }} - screenId={editModalConfig.screenId} - modalSize={editModalConfig.modalSize} - editData={editModalConfig.editData} - onSave={editModalConfig.onSave} - modalTitle={editModalConfig.modalTitle} - modalDescription={editModalConfig.modalDescription} - onDataChange={(changedFormData) => { - console.log("📝 EditModal에서 데이터 변경 수신:", changedFormData); - // 변경된 데이터를 메인 폼에 반영 - setFormData((prev) => { - const updatedFormData = { - ...prev, - ...changedFormData, // 변경된 필드들만 업데이트 - }; - console.log("📊 메인 폼 데이터 업데이트:", updatedFormData); - return updatedFormData; - }); - }} - /> + {/* 🆕 플로우 버튼 그룹들 */} + {Object.entries(buttonGroups).map(([groupId, buttons]) => { + if (buttons.length === 0) return null; + + const firstButton = buttons[0]; + const groupConfig = (firstButton as any).webTypeConfig + ?.flowVisibilityConfig as FlowVisibilityConfig; + + // 🔍 버튼 그룹 설정 확인 + console.log("🔍 버튼 그룹 설정:", { + groupId, + buttonCount: buttons.length, + buttons: buttons.map((b) => ({ + id: b.id, + label: b.label, + x: b.position.x, + y: b.position.y, + })), + groupConfig: { + layoutBehavior: groupConfig.layoutBehavior, + groupDirection: groupConfig.groupDirection, + groupAlign: groupConfig.groupAlign, + groupGap: groupConfig.groupGap, + }, + }); + + // 🔧 수정: 그룹 컨테이너는 첫 번째 버튼 위치를 기준으로 하되, + // 각 버튼의 상대 위치는 원래 위치를 유지 + const firstButtonPosition = { + x: buttons[0].position.x, + y: buttons[0].position.y, + z: buttons[0].position.z || 2, + }; + + // 버튼 그룹 위치에도 widthOffset 적용 + const adjustedGroupPosition = { + ...firstButtonPosition, + x: firstButtonPosition.x + widthOffset, + }; + + // 그룹의 크기 계산: 버튼들의 실제 크기 + 간격을 기준으로 계산 + const direction = groupConfig.groupDirection || "horizontal"; + const gap = groupConfig.groupGap ?? 8; + + let groupWidth = 0; + let groupHeight = 0; + + if (direction === "horizontal") { + groupWidth = buttons.reduce((total, button, index) => { + const buttonWidth = button.size?.width || 100; + const gapWidth = index < buttons.length - 1 ? gap : 0; + return total + buttonWidth + gapWidth; + }, 0); + groupHeight = Math.max(...buttons.map((b) => b.size?.height || 40)); + } else { + groupWidth = Math.max(...buttons.map((b) => b.size?.width || 100)); + groupHeight = buttons.reduce((total, button, index) => { + const buttonHeight = button.size?.height || 40; + const gapHeight = index < buttons.length - 1 ? gap : 0; + return total + buttonHeight + gapHeight; + }, 0); + } + + return ( +
+ { + // 🔧 각 버튼의 상대 위치 = 버튼의 원래 위치 - 첫 번째 버튼 위치 + const relativeButton = { + ...button, + position: { + x: button.position.x - firstButtonPosition.x, + y: button.position.y - firstButtonPosition.y, + z: button.position.z || 1, + }, + }; + + return ( +
+
+ {}} + screenId={screenId} + tableName={screen?.tableName} + userId={user?.userId} + userName={userName} + companyCode={companyCode} + tableDisplayData={tableDisplayData} + selectedRowsData={selectedRowsData} + sortBy={tableSortBy} + sortOrder={tableSortOrder} + columnOrder={tableColumnOrder} + onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder) => { + setSelectedRowsData(selectedData); + setTableSortBy(sortBy); + setTableSortOrder(sortOrder || "asc"); + setTableColumnOrder(columnOrder); + }} + flowSelectedData={flowSelectedData} + flowSelectedStepId={flowSelectedStepId} + onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => { + setFlowSelectedData(selectedData); + setFlowSelectedStepId(stepId); + }} + refreshKey={tableRefreshKey} + onRefresh={() => { + setTableRefreshKey((prev) => prev + 1); + setSelectedRowsData([]); + }} + flowRefreshKey={flowRefreshKey} + onFlowRefresh={() => { + setFlowRefreshKey((prev) => prev + 1); + setFlowSelectedData([]); + setFlowSelectedStepId(null); + }} + onFormDataChange={(fieldName, value) => { + setFormData((prev) => ({ ...prev, [fieldName]: value })); + }} + /> +
+
+ ); + }} + /> +
+ ); + })} + + ); + })()} +
+ ) : ( + // 빈 화면일 때 +
+
+
+ 📄 +
+

화면이 비어있습니다

+

이 화면에는 아직 설계된 컴포넌트가 없습니다.

+
+
+ )} + + {/* 편집 모달 */} + { + setEditModalOpen(false); + setEditModalConfig({}); + }} + screenId={editModalConfig.screenId} + modalSize={editModalConfig.modalSize} + editData={editModalConfig.editData} + onSave={editModalConfig.onSave} + modalTitle={editModalConfig.modalTitle} + modalDescription={editModalConfig.modalDescription} + onDataChange={(changedFormData) => { + console.log("📝 EditModal에서 데이터 변경 수신:", changedFormData); + // 변경된 데이터를 메인 폼에 반영 + setFormData((prev) => { + const updatedFormData = { + ...prev, + ...changedFormData, // 변경된 필드들만 업데이트 + }; + console.log("📊 메인 폼 데이터 업데이트:", updatedFormData); + return updatedFormData; + }); + }} + />
diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 2bb85051..090985ba 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -193,7 +193,7 @@ import { ListWidget } from "./widgets/ListWidget"; import { X } from "lucide-react"; import { Button } from "@/components/ui/button"; -// 야드 관리 3D 위젯 +// 3D 필드 위젯 const YardManagement3DWidget = dynamic(() => import("./widgets/YardManagement3DWidget"), { ssr: false, loading: () => ( @@ -312,6 +312,24 @@ export function CanvasElement({ return; } + // 위젯 테두리(바깥쪽 영역)를 클릭한 경우에만 선택/드래그 허용 + // - 내용 영역을 클릭해도 대시보드 설정 사이드바가 튀어나오지 않도록 하기 위함 + const container = elementRef.current; + if (container) { + const rect = container.getBoundingClientRect(); + const BORDER_HIT_WIDTH = 8; // px, 테두리로 인식할 범위 + const isOnBorder = + e.clientX <= rect.left + BORDER_HIT_WIDTH || + e.clientX >= rect.right - BORDER_HIT_WIDTH || + e.clientY <= rect.top + BORDER_HIT_WIDTH || + e.clientY >= rect.bottom - BORDER_HIT_WIDTH; + + if (!isOnBorder) { + // 테두리가 아닌 내부 클릭은 선택/드래그 처리하지 않음 + return; + } + } + // 선택되지 않은 경우에만 선택 처리 if (!isSelected) { onSelect(element.id); @@ -1067,7 +1085,7 @@ export function CanvasElement({
) : element.type === "widget" && element.subtype === "yard-management-3d" ? ( - // 야드 관리 3D 위젯 렌더링 + // 3D 필드 위젯 렌더링
리스트 통계 카드 리스크/알림 - 야드 관리 3D + 3D 필드 {/* 커스텀 통계 카드 */} {/* 커스텀 상태 카드 */} diff --git a/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx index db608645..10af48e8 100644 --- a/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx +++ b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx @@ -93,7 +93,7 @@ const getWidgetTitle = (subtype: ElementSubtype): string => { chart: "차트", "map-summary-v2": "지도", "risk-alert-v2": "리스크 알림", - "yard-management-3d": "야드 관리 3D", + "yard-management-3d": "3D 필드", weather: "날씨 위젯", exchange: "환율 위젯", calculator: "계산기", @@ -449,7 +449,7 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
- {/* 레이아웃 선택 (야드 관리 3D 위젯 전용) */} + {/* 레이아웃 선택 (3D 필드 위젯 전용) */} {element.subtype === "yard-management-3d" && (
@@ -199,7 +199,7 @@ export const CheckboxConfigPanel: React.FC = ({ value={localConfig.uncheckedValue || ""} onChange={(e) => updateConfig("uncheckedValue", e.target.value)} placeholder="N" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" />
@@ -232,7 +232,7 @@ export const CheckboxConfigPanel: React.FC = ({ value={localConfig.groupLabel || ""} onChange={(e) => updateConfig("groupLabel", e.target.value)} placeholder="체크박스 그룹 제목" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" />
@@ -244,19 +244,19 @@ export const CheckboxConfigPanel: React.FC = ({ value={newOptionLabel} onChange={(e) => setNewOptionLabel(e.target.value)} placeholder="라벨" - className="flex-1 text-xs" style={{ fontSize: "12px" }} + className="flex-1 text-xs" /> setNewOptionValue(e.target.value)} placeholder="값" - className="flex-1 text-xs" style={{ fontSize: "12px" }} + className="flex-1 text-xs" /> @@ -277,13 +277,13 @@ export const CheckboxConfigPanel: React.FC = ({ value={option.label} onChange={(e) => updateOption(index, "label", e.target.value)} placeholder="라벨" - className="flex-1 text-xs" style={{ fontSize: "12px" }} + className="flex-1 text-xs" /> updateOption(index, "value", e.target.value)} placeholder="값" - className="flex-1 text-xs" style={{ fontSize: "12px" }} + className="flex-1 text-xs" /> = ({ disabled={localConfig.readonly} required={localConfig.required} defaultChecked={localConfig.defaultChecked} - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" />
@@ -308,7 +308,7 @@ export const CodeConfigPanel: React.FC = ({ value={localConfig.placeholder || ""} onChange={(e) => updateConfig("placeholder", e.target.value)} placeholder="코드를 입력하세요..." - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" />
@@ -330,7 +330,7 @@ export const CodeConfigPanel: React.FC = ({ value={localConfig.defaultValue || ""} onChange={(e) => updateConfig("defaultValue", e.target.value)} placeholder="기본 코드 내용" - className="font-mono text-xs" style={{ fontSize: "12px" }} + className="font-mono text-xs" rows={4} /> diff --git a/frontend/components/screen/config-panels/DateConfigPanel.tsx b/frontend/components/screen/config-panels/DateConfigPanel.tsx index 7fcacc57..cddac6cb 100644 --- a/frontend/components/screen/config-panels/DateConfigPanel.tsx +++ b/frontend/components/screen/config-panels/DateConfigPanel.tsx @@ -75,7 +75,7 @@ export const DateConfigPanel: React.FC = ({ return ( - + 날짜 설정 @@ -95,7 +95,7 @@ export const DateConfigPanel: React.FC = ({ value={localConfig.placeholder || ""} onChange={(e) => updateConfig("placeholder", e.target.value)} placeholder="날짜를 선택하세요" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> @@ -149,7 +149,7 @@ export const DateConfigPanel: React.FC = ({ type={localConfig.showTime ? "datetime-local" : "date"} value={localConfig.minDate || ""} onChange={(e) => updateConfig("minDate", e.target.value)} - className="flex-1 text-xs" style={{ fontSize: "12px" }} + className="flex-1 text-xs" /> @@ -211,9 +234,10 @@ export const EntityConfigPanel: React.FC = ({ updateConfig("apiEndpoint", e.target.value)} + onChange={(e) => updateConfigLocal("apiEndpoint", e.target.value)} + onBlur={handleInputBlur} placeholder="/api/entities/user" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> @@ -230,9 +254,10 @@ export const EntityConfigPanel: React.FC = ({ updateConfig("valueField", e.target.value)} + onChange={(e) => updateConfigLocal("valueField", e.target.value)} + onBlur={handleInputBlur} placeholder="id" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> @@ -243,9 +268,10 @@ export const EntityConfigPanel: React.FC = ({ updateConfig("labelField", e.target.value)} + onChange={(e) => updateConfigLocal("labelField", e.target.value)} + onBlur={handleInputBlur} placeholder="name" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> @@ -263,13 +289,13 @@ export const EntityConfigPanel: React.FC = ({ value={newFieldName} onChange={(e) => setNewFieldName(e.target.value)} placeholder="필드명" - className="flex-1 text-xs" style={{ fontSize: "12px" }} + className="flex-1 text-xs" /> setNewFieldLabel(e.target.value)} placeholder="라벨" - className="flex-1 text-xs" style={{ fontSize: "12px" }} + className="flex-1 text-xs" /> updateDisplayField(index, "name", e.target.value)} + onBlur={handleFieldBlur} placeholder="필드명" - className="flex-1 text-xs" style={{ fontSize: "12px" }} + className="flex-1 text-xs" /> updateDisplayField(index, "label", e.target.value)} + onBlur={handleFieldBlur} placeholder="라벨" - className="flex-1 text-xs" style={{ fontSize: "12px" }} + className="flex-1 text-xs" /> updateConfig("placeholder", e.target.value)} + onChange={(e) => updateConfigLocal("placeholder", e.target.value)} + onBlur={handleInputBlur} placeholder="엔티티를 선택하세요" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> @@ -375,9 +407,10 @@ export const EntityConfigPanel: React.FC = ({ updateConfig("emptyMessage", e.target.value)} + onChange={(e) => updateConfigLocal("emptyMessage", e.target.value)} + onBlur={handleInputBlur} placeholder="검색 결과가 없습니다" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> @@ -393,7 +426,7 @@ export const EntityConfigPanel: React.FC = ({ onChange={(e) => updateConfig("minSearchLength", parseInt(e.target.value))} min={0} max={10} - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> @@ -408,7 +441,7 @@ export const EntityConfigPanel: React.FC = ({ onChange={(e) => updateConfig("pageSize", parseInt(e.target.value))} min={5} max={100} - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> @@ -462,7 +495,7 @@ export const EntityConfigPanel: React.FC = ({ } }} placeholder='{"status": "active", "department": "IT"}' - className="font-mono text-xs" style={{ fontSize: "12px" }} + className="font-mono text-xs" rows={3} />

API 요청에 추가될 필터 조건을 JSON 형태로 입력하세요.

diff --git a/frontend/components/screen/config-panels/FileConfigPanel.tsx b/frontend/components/screen/config-panels/FileConfigPanel.tsx index d07f49ff..d00200f1 100644 --- a/frontend/components/screen/config-panels/FileConfigPanel.tsx +++ b/frontend/components/screen/config-panels/FileConfigPanel.tsx @@ -113,7 +113,7 @@ export const FileConfigPanel: React.FC = ({ return ( - + 파일 업로드 설정 @@ -133,7 +133,7 @@ export const FileConfigPanel: React.FC = ({ value={localConfig.uploadText || ""} onChange={(e) => updateConfig("uploadText", e.target.value)} placeholder="파일을 선택하거나 여기에 드래그하세요" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> @@ -146,7 +146,7 @@ export const FileConfigPanel: React.FC = ({ value={localConfig.browseText || ""} onChange={(e) => updateConfig("browseText", e.target.value)} placeholder="파일 선택" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> @@ -196,7 +196,7 @@ export const FileConfigPanel: React.FC = ({ min={0.1} max={1024} step={0.1} - className="flex-1 text-xs" style={{ fontSize: "12px" }} + className="flex-1 text-xs" /> MB @@ -214,7 +214,7 @@ export const FileConfigPanel: React.FC = ({ onChange={(e) => updateConfig("maxFiles", parseInt(e.target.value))} min={1} max={100} - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> )} @@ -257,7 +257,7 @@ export const FileConfigPanel: React.FC = ({ value={newFileType} onChange={(e) => setNewFileType(e.target.value)} placeholder=".pdf 또는 pdf" - className="flex-1 text-xs" style={{ fontSize: "12px" }} + className="flex-1 text-xs" /> @@ -278,7 +278,7 @@ export const RadioConfigPanel: React.FC = ({ value={bulkOptions} onChange={(e) => setBulkOptions(e.target.value)} placeholder="한 줄당 하나씩 입력하세요. 라벨만 입력하면 값과 동일하게 설정됩니다. 라벨|값 형식으로 입력하면 별도 값을 설정할 수 있습니다. 예시: 서울 부산 대구시|daegu" - className="h-20 text-xs" style={{ fontSize: "12px" }} + className="h-20 text-xs" /> @@ -273,7 +273,7 @@ export const SelectConfigPanel: React.FC = ({ value={bulkOptions} onChange={(e) => setBulkOptions(e.target.value)} placeholder="한 줄당 하나씩 입력하세요. 라벨만 입력하면 값과 동일하게 설정됩니다. 라벨|값 형식으로 입력하면 별도 값을 설정할 수 있습니다. 예시: 서울 부산 대구시|daegu" - className="h-20 text-xs" style={{ fontSize: "12px" }} + className="h-20 text-xs" /> + + + {localTabs.length === 0 ? ( +
+

탭이 없습니다

+

+ 탭 추가 버튼을 클릭하여 새 탭을 생성하세요 +

+
+ ) : ( +
+ {localTabs.map((tab, index) => ( +
+
+
+ + 탭 {index + 1} +
+
+ + + +
+
+ +
+ {/* 탭 라벨 */} +
+ + handleLabelChange(tab.id, e.target.value)} + onBlur={handleLabelBlur} + placeholder="탭 이름" + className="h-8 text-xs sm:h-9 sm:text-sm" + /> +
+ + {/* 화면 선택 */} +
+ + {loading ? ( +
+ + 로딩 중... +
+ ) : ( + + handleScreenSelect(tab.id, screenId, screenName) + } + /> + )} + {tab.screenName && ( +

+ 선택된 화면: {tab.screenName} +

+ )} +
+ + {/* 비활성화 */} +
+ + handleDisabledToggle(tab.id, checked)} + /> +
+
+
+ ))} +
+ )} + + + ); +} + +// 화면 선택 Combobox 컴포넌트 +function ScreenSelectCombobox({ + screens, + selectedScreenId, + onSelect, +}: { + screens: ScreenInfo[]; + selectedScreenId?: number; + onSelect: (screenId: number, screenName: string) => void; +}) { + const [open, setOpen] = useState(false); + + const selectedScreen = screens.find((s) => s.screenId === selectedScreenId); + + return ( + + + + + + + + + + 화면을 찾을 수 없습니다. + + + {screens.map((screen) => ( + { + onSelect(screen.screenId, screen.screenName); + setOpen(false); + }} + className="text-xs sm:text-sm" + > + +
+ {screen.screenName} + + 코드: {screen.screenCode} | 테이블: {screen.tableName} + +
+
+ ))} +
+
+
+
+
+ ); +} + diff --git a/frontend/components/screen/config-panels/TextConfigPanel.tsx b/frontend/components/screen/config-panels/TextConfigPanel.tsx index ae5ab0b6..0ef7fa2b 100644 --- a/frontend/components/screen/config-panels/TextConfigPanel.tsx +++ b/frontend/components/screen/config-panels/TextConfigPanel.tsx @@ -55,7 +55,7 @@ export const TextConfigPanel: React.FC = ({ return ( - 텍스트 설정 + 텍스트 설정 텍스트 입력 필드의 세부 설정을 관리합니다. @@ -72,7 +72,7 @@ export const TextConfigPanel: React.FC = ({ value={localConfig.placeholder || ""} onChange={(e) => updateConfig("placeholder", e.target.value)} placeholder="입력 안내 텍스트" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> @@ -88,7 +88,7 @@ export const TextConfigPanel: React.FC = ({ onChange={(e) => updateConfig("minLength", e.target.value ? parseInt(e.target.value) : undefined)} placeholder="0" min="0" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" />
@@ -102,7 +102,7 @@ export const TextConfigPanel: React.FC = ({ onChange={(e) => updateConfig("maxLength", e.target.value ? parseInt(e.target.value) : undefined)} placeholder="100" min="1" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" />
@@ -141,7 +141,7 @@ export const TextConfigPanel: React.FC = ({ value={localConfig.pattern || ""} onChange={(e) => updateConfig("pattern", e.target.value)} placeholder="예: [A-Za-z0-9]+" - className="font-mono text-xs" style={{ fontSize: "12px" }} + className="font-mono text-xs" />

JavaScript 정규식 패턴을 입력하세요.

@@ -219,7 +219,7 @@ export const TextConfigPanel: React.FC = ({ minLength={localConfig.minLength} pattern={localConfig.pattern} autoComplete={localConfig.autoComplete} - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> diff --git a/frontend/components/screen/config-panels/TextareaConfigPanel.tsx b/frontend/components/screen/config-panels/TextareaConfigPanel.tsx index f700e61d..b074074c 100644 --- a/frontend/components/screen/config-panels/TextareaConfigPanel.tsx +++ b/frontend/components/screen/config-panels/TextareaConfigPanel.tsx @@ -68,7 +68,7 @@ export const TextareaConfigPanel: React.FC = ({ return ( - + 텍스트영역 설정 @@ -88,7 +88,7 @@ export const TextareaConfigPanel: React.FC = ({ value={localConfig.placeholder || ""} onChange={(e) => updateConfig("placeholder", e.target.value)} placeholder="내용을 입력하세요" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> @@ -101,7 +101,7 @@ export const TextareaConfigPanel: React.FC = ({ value={localConfig.defaultValue || ""} onChange={(e) => updateConfig("defaultValue", e.target.value)} placeholder="기본 텍스트 내용" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" rows={3} /> {localConfig.showCharCount && ( @@ -151,7 +151,7 @@ export const TextareaConfigPanel: React.FC = ({ placeholder="자동 (CSS로 제어)" min={10} max={200} - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" />

비워두면 CSS width로 제어됩니다.

@@ -203,7 +203,7 @@ export const TextareaConfigPanel: React.FC = ({ }} placeholder="제한 없음" min={0} - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> @@ -221,7 +221,7 @@ export const TextareaConfigPanel: React.FC = ({ }} placeholder="제한 없음" min={1} - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> @@ -333,7 +333,7 @@ export const TextareaConfigPanel: React.FC = ({ resize: localConfig.resizable ? "both" : "none", minHeight: localConfig.autoHeight ? "auto" : undefined, }} - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" wrap={localConfig.wrap} /> {localConfig.showCharCount && ( diff --git a/frontend/components/screen/dialogs/FlowButtonGroupDialog.tsx b/frontend/components/screen/dialogs/FlowButtonGroupDialog.tsx index 606a3071..f40bd9ea 100644 --- a/frontend/components/screen/dialogs/FlowButtonGroupDialog.tsx +++ b/frontend/components/screen/dialogs/FlowButtonGroupDialog.tsx @@ -94,7 +94,7 @@ export const FlowButtonGroupDialog: React.FC = ({ max={100} value={gap} onChange={(e) => setGap(Number(e.target.value))} - className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }} + className="h-6 w-full px-2 py-0 text-xs" /> {gap}px @@ -109,7 +109,7 @@ export const FlowButtonGroupDialog: React.FC = ({ 정렬 방식 = ({ }} placeholder="추가" disabled={!localValues.enableAdd} - className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }} + className="h-6 w-full px-2 py-0 text-xs" />
-
-
@@ -1284,7 +1284,7 @@ const DataTableConfigPanelComponent: React.FC = ({
-
-
-
@@ -1521,7 +1521,7 @@ const DataTableConfigPanelComponent: React.FC = ({ - + 컬럼 설정 @@ -1654,7 +1654,7 @@ const DataTableConfigPanelComponent: React.FC = ({ } }} placeholder="표시명을 입력하세요" - className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }} + className="h-6 w-full px-2 py-0 text-xs" />
@@ -1947,7 +1947,7 @@ const DataTableConfigPanelComponent: React.FC = ({ }); }} placeholder="고정값 입력..." - className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }} + className="h-6 w-full px-2 py-0 text-xs" />
)} @@ -1967,7 +1967,7 @@ const DataTableConfigPanelComponent: React.FC = ({ - + 필터 설정 @@ -1995,7 +1995,7 @@ const DataTableConfigPanelComponent: React.FC = ({ {component.filters.length === 0 ? (
-

필터가 없습니다

+

필터가 없습니다

컬럼을 추가하면 자동으로 필터가 생성됩니다

) : ( @@ -2073,7 +2073,7 @@ const DataTableConfigPanelComponent: React.FC = ({ updateFilter(index, { label: newValue }); }} placeholder="필터 이름 입력..." - className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }} + className="h-6 w-full px-2 py-0 text-xs" />

@@ -2192,7 +2192,7 @@ const DataTableConfigPanelComponent: React.FC = ({ - + 모달 및 페이징 설정 @@ -2342,7 +2342,7 @@ const DataTableConfigPanelComponent: React.FC = ({ }); }} /> -

@@ -196,7 +195,6 @@ export const DetailSettingsPanel: React.FC = ({ } }} className="w-full rounded border border-gray-300 px-2 py-1 text-xs" - style={{ fontSize: "12px" }} />
@@ -211,7 +209,6 @@ export const DetailSettingsPanel: React.FC = ({ onUpdateProperty(layoutComponent.id, "layoutConfig.grid.gap", parseInt(e.target.value)) } className="w-full rounded border border-gray-300 px-2 py-1 text-xs" - style={{ fontSize: "12px" }} /> @@ -256,7 +253,6 @@ export const DetailSettingsPanel: React.FC = ({ } }} className="w-full rounded border border-gray-300 px-2 py-1 text-xs" - style={{ fontSize: "12px" }} > @@ -316,7 +312,6 @@ export const DetailSettingsPanel: React.FC = ({ } }} className="w-20 rounded border border-gray-300 px-2 py-1 text-xs" - style={{ fontSize: "12px" }} /> @@ -332,7 +327,6 @@ export const DetailSettingsPanel: React.FC = ({ onUpdateProperty(layoutComponent.id, "layoutConfig.flexbox.gap", parseInt(e.target.value)) } className="w-full rounded border border-gray-300 px-2 py-1 text-xs" - style={{ fontSize: "12px" }} /> @@ -348,7 +342,6 @@ export const DetailSettingsPanel: React.FC = ({ value={layoutComponent.layoutConfig?.split?.direction || "horizontal"} onChange={(e) => onUpdateProperty(layoutComponent.id, "layoutConfig.split.direction", e.target.value)} className="w-full rounded border border-gray-300 px-2 py-1 text-xs" - style={{ fontSize: "12px" }} > @@ -398,7 +391,6 @@ export const DetailSettingsPanel: React.FC = ({ ) } className="w-full rounded border border-gray-300 px-2 py-1 text-xs" - style={{ fontSize: "12px" }} > {currentTable.columns?.map((column) => ( @@ -421,7 +413,6 @@ export const DetailSettingsPanel: React.FC = ({ ) } className="w-full rounded border border-gray-300 px-2 py-1 text-xs" - style={{ fontSize: "12px" }} > {currentTable.columns?.map((column) => ( @@ -444,7 +435,6 @@ export const DetailSettingsPanel: React.FC = ({ ) } className="w-full rounded border border-gray-300 px-2 py-1 text-xs" - style={{ fontSize: "12px" }} > {currentTable.columns?.map((column) => ( @@ -467,7 +457,6 @@ export const DetailSettingsPanel: React.FC = ({ ) } className="w-full rounded border border-gray-300 px-2 py-1 text-xs" - style={{ fontSize: "12px" }} > {currentTable.columns?.map((column) => ( @@ -495,7 +484,6 @@ export const DetailSettingsPanel: React.FC = ({ ); }} className="bg-primary text-primary-foreground hover:bg-primary/90 rounded px-2 py-1 text-xs" - style={{ fontSize: "12px" }} > + 컬럼 추가 @@ -519,7 +507,6 @@ export const DetailSettingsPanel: React.FC = ({ ); }} className="flex-1 rounded border border-gray-300 px-2 py-1 text-xs" - style={{ fontSize: "12px" }} > {currentTable.columns?.map((col) => ( @@ -542,7 +529,6 @@ export const DetailSettingsPanel: React.FC = ({ ); }} className="bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded px-2 py-1 text-xs" - style={{ fontSize: "12px" }} > 삭제 @@ -578,7 +564,6 @@ export const DetailSettingsPanel: React.FC = ({ onUpdateProperty(layoutComponent.id, "layoutConfig.card.cardsPerRow", parseInt(e.target.value)) } className="w-full rounded border border-gray-300 px-2 py-1 text-xs" - style={{ fontSize: "12px" }} /> @@ -593,7 +578,6 @@ export const DetailSettingsPanel: React.FC = ({ onUpdateProperty(layoutComponent.id, "layoutConfig.card.cardSpacing", parseInt(e.target.value)) } className="w-full rounded border border-gray-300 px-2 py-1 text-xs" - style={{ fontSize: "12px" }} /> @@ -683,7 +667,6 @@ export const DetailSettingsPanel: React.FC = ({ ) } className="w-full rounded border border-gray-300 px-2 py-1 text-xs" - style={{ fontSize: "12px" }} /> @@ -711,7 +694,6 @@ export const DetailSettingsPanel: React.FC = ({ onUpdateProperty(layoutComponent.id, `zones.${index}.size.width`, e.target.value) } className="w-full rounded border border-gray-300 px-2 py-1 text-xs" - style={{ fontSize: "12px" }} placeholder="100%" /> @@ -724,7 +706,6 @@ export const DetailSettingsPanel: React.FC = ({ onUpdateProperty(layoutComponent.id, `zones.${index}.size.height`, e.target.value) } className="w-full rounded border border-gray-300 px-2 py-1 text-xs" - style={{ fontSize: "12px" }} placeholder="auto" /> @@ -1007,7 +988,7 @@ export const DetailSettingsPanel: React.FC = ({

컴포넌트 설정

- + 타입: {componentType} @@ -1057,7 +1038,7 @@ export const DetailSettingsPanel: React.FC = ({

파일 컴포넌트 설정

- + 타입: 파일 업로드 @@ -1146,14 +1127,14 @@ export const DetailSettingsPanel: React.FC = ({ {/* 컴포넌트 정보 */}
- + 컴포넌트: {componentId}
{webType && currentBaseInputType && (
- + 입력 타입: @@ -1163,7 +1144,7 @@ export const DetailSettingsPanel: React.FC = ({ )} {selectedComponent.columnName && (
- + 컬럼: {selectedComponent.columnName} @@ -1375,7 +1356,7 @@ export const DetailSettingsPanel: React.FC = ({

상세 설정

- + 입력 타입: @@ -1390,7 +1371,7 @@ export const DetailSettingsPanel: React.FC = ({
- + @@ -147,8 +147,7 @@ const ResolutionPanel: React.FC = ({ currentResolution, on placeholder="1920" min="1" step="1" - className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }} - style={{ fontSize: "12px" }} + className="h-6 w-full px-2 py-0 text-xs" />
@@ -160,16 +159,14 @@ const ResolutionPanel: React.FC = ({ currentResolution, on placeholder="1080" min="1" step="1" - className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }} - style={{ fontSize: "12px" }} + className="h-6 w-full px-2 py-0 text-xs" />
diff --git a/frontend/components/screen/panels/RowSettingsPanel.tsx b/frontend/components/screen/panels/RowSettingsPanel.tsx index 1378ffe3..c88eda92 100644 --- a/frontend/components/screen/panels/RowSettingsPanel.tsx +++ b/frontend/components/screen/panels/RowSettingsPanel.tsx @@ -109,7 +109,7 @@ export const RowSettingsPanel: React.FC = ({ row, onUpdat variant={row.gap === preset ? "default" : "outline"} size="sm" onClick={() => onUpdateRow({ gap: preset })} - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" > {GAP_PRESETS[preset].label} @@ -130,7 +130,7 @@ export const RowSettingsPanel: React.FC = ({ row, onUpdat variant={row.padding === preset ? "default" : "outline"} size="sm" onClick={() => onUpdateRow({ padding: preset })} - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" > {GAP_PRESETS[preset].label} diff --git a/frontend/components/screen/panels/TemplatesPanel.tsx b/frontend/components/screen/panels/TemplatesPanel.tsx index d4d4bae9..d76ef0c9 100644 --- a/frontend/components/screen/panels/TemplatesPanel.tsx +++ b/frontend/components/screen/panels/TemplatesPanel.tsx @@ -528,7 +528,7 @@ export const TemplatesPanel: React.FC = ({ onDragStart }) =
- 템플릿 로딩 실패, 기본 템플릿 사용 중 + 템플릿 로딩 실패, 기본 템플릿 사용 중
- + +
설정 패널 로딩 중...
+
+ }> + +
); }; @@ -712,8 +718,6 @@ export const UnifiedPropertiesPanel: React.FC = ({ onChange={(e) => handleUpdate("label", e.target.value)} placeholder="라벨" className="h-6 w-full px-2 py-0 text-xs" - style={{ fontSize: "12px" }} - style={{ fontSize: "12px" }} />
@@ -749,7 +753,6 @@ export const UnifiedPropertiesPanel: React.FC = ({ step={1} placeholder="10" className="h-6 w-full px-2 py-0 text-xs" - style={{ fontSize: "12px" }} />
@@ -763,8 +766,6 @@ export const UnifiedPropertiesPanel: React.FC = ({ onChange={(e) => handleUpdate("placeholder", e.target.value)} placeholder="입력 안내 텍스트" className="h-6 w-full px-2 py-0 text-xs" - style={{ fontSize: "12px" }} - style={{ fontSize: "12px" }} /> )} @@ -778,8 +779,6 @@ export const UnifiedPropertiesPanel: React.FC = ({ onChange={(e) => handleUpdate("title", e.target.value)} placeholder="제목" className="h-6 w-full px-2 py-0 text-xs" - style={{ fontSize: "12px" }} - style={{ fontSize: "12px" }} /> )} @@ -793,8 +792,6 @@ export const UnifiedPropertiesPanel: React.FC = ({ onChange={(e) => handleUpdate("description", e.target.value)} placeholder="설명" className="h-6 w-full px-2 py-0 text-xs" - style={{ fontSize: "12px" }} - style={{ fontSize: "12px" }} /> )} @@ -836,7 +833,6 @@ export const UnifiedPropertiesPanel: React.FC = ({ } }} className="h-6 w-full px-2 py-0 text-xs" - style={{ fontSize: "12px" }} /> @@ -848,8 +844,7 @@ export const UnifiedPropertiesPanel: React.FC = ({ value={currentPosition.z || 1} onChange={(e) => handleUpdate("position.z", parseInt(e.target.value) || 1)} className="h-6 w-full px-2 py-0 text-xs" - style={{ fontSize: "12px" }} - style={{ fontSize: "12px" }} + className="text-xs" /> @@ -867,8 +862,7 @@ export const UnifiedPropertiesPanel: React.FC = ({ value={selectedComponent.style?.labelText || selectedComponent.label || ""} onChange={(e) => handleUpdate("style.labelText", e.target.value)} className="h-6 w-full px-2 py-0 text-xs" - style={{ fontSize: "12px" }} - style={{ fontSize: "12px" }} + className="text-xs" />
@@ -878,8 +872,7 @@ export const UnifiedPropertiesPanel: React.FC = ({ value={selectedComponent.style?.labelFontSize || "12px"} onChange={(e) => handleUpdate("style.labelFontSize", e.target.value)} className="h-6 w-full px-2 py-0 text-xs" - style={{ fontSize: "12px" }} - style={{ fontSize: "12px" }} + className="text-xs" />
@@ -889,8 +882,7 @@ export const UnifiedPropertiesPanel: React.FC = ({ value={selectedComponent.style?.labelColor || "#212121"} onChange={(e) => handleUpdate("style.labelColor", e.target.value)} className="h-6 w-full px-2 py-0 text-xs" - style={{ fontSize: "12px" }} - style={{ fontSize: "12px" }} + className="text-xs" />
@@ -901,8 +893,7 @@ export const UnifiedPropertiesPanel: React.FC = ({ value={selectedComponent.style?.labelMarginBottom || "4px"} onChange={(e) => handleUpdate("style.labelMarginBottom", e.target.value)} className="h-6 w-full px-2 py-0 text-xs" - style={{ fontSize: "12px" }} - style={{ fontSize: "12px" }} + className="text-xs" />
@@ -1053,7 +1044,7 @@ export const UnifiedPropertiesPanel: React.FC = ({
handleUpdate("webType", value)}> - + diff --git a/frontend/components/screen/panels/WebTypeConfigPanel.tsx b/frontend/components/screen/panels/WebTypeConfigPanel.tsx index 8c44fb48..da190e91 100644 --- a/frontend/components/screen/panels/WebTypeConfigPanel.tsx +++ b/frontend/components/screen/panels/WebTypeConfigPanel.tsx @@ -109,7 +109,7 @@ export const WebTypeConfigPanel: React.FC = ({ webType, <>
-
-