diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index fd0f1ea8..37936f36 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -65,7 +65,9 @@ import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관 import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회 import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리 import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리 +import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리 import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합 +import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -225,7 +227,9 @@ app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리 app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회 app.use("/api/roles", roleRoutes); // 권한 그룹 관리 app.use("/api/departments", departmentRoutes); // 부서 관리 +app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리 app.use("/api/code-merge", codeMergeRoutes); // 코드 병합 +app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리 // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); diff --git a/backend-node/src/controllers/tableCategoryValueController.ts b/backend-node/src/controllers/tableCategoryValueController.ts new file mode 100644 index 00000000..e489bbf2 --- /dev/null +++ b/backend-node/src/controllers/tableCategoryValueController.ts @@ -0,0 +1,237 @@ +import { Request, Response } from "express"; +import tableCategoryValueService from "../services/tableCategoryValueService"; +import { logger } from "../utils/logger"; + +/** + * 테이블의 카테고리 컬럼 목록 조회 + */ +export const getCategoryColumns = async (req: Request, res: Response) => { + try { + const companyCode = req.user!.companyCode; + const { tableName } = req.params; + + const columns = await tableCategoryValueService.getCategoryColumns( + tableName, + companyCode + ); + + return res.json({ + success: true, + data: columns, + }); + } catch (error: any) { + logger.error(`카테고리 컬럼 조회 실패: ${error.message}`); + return res.status(500).json({ + success: false, + message: "카테고리 컬럼 조회 중 오류가 발생했습니다", + error: error.message, + }); + } +}; + +/** + * 카테고리 값 목록 조회 (메뉴 스코프 적용) + */ +export const getCategoryValues = async (req: Request, res: Response) => { + try { + const companyCode = req.user!.companyCode; + const { tableName, columnName } = req.params; + const includeInactive = req.query.includeInactive === "true"; + + const values = await tableCategoryValueService.getCategoryValues( + tableName, + columnName, + companyCode, + includeInactive + ); + + return res.json({ + success: true, + data: values, + }); + } catch (error: any) { + logger.error(`카테고리 값 조회 실패: ${error.message}`); + return res.status(500).json({ + success: false, + message: "카테고리 값 조회 중 오류가 발생했습니다", + error: error.message, + }); + } +}; + +/** + * 카테고리 값 추가 + */ +export const addCategoryValue = async (req: Request, res: Response) => { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const value = req.body; + + const newValue = await tableCategoryValueService.addCategoryValue( + value, + companyCode, + userId + ); + + return res.status(201).json({ + success: true, + data: newValue, + }); + } catch (error: any) { + logger.error(`카테고리 값 추가 실패: ${error.message}`); + return res.status(500).json({ + success: false, + message: error.message || "카테고리 값 추가 중 오류가 발생했습니다", + error: error.message, + }); + } +}; + +/** + * 카테고리 값 수정 + */ +export const updateCategoryValue = async (req: Request, res: Response) => { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const valueId = parseInt(req.params.valueId); + const updates = req.body; + + if (isNaN(valueId)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 값 ID입니다", + }); + } + + const updatedValue = await tableCategoryValueService.updateCategoryValue( + valueId, + updates, + companyCode, + userId + ); + + return res.json({ + success: true, + data: updatedValue, + }); + } catch (error: any) { + logger.error(`카테고리 값 수정 실패: ${error.message}`); + return res.status(500).json({ + success: false, + message: "카테고리 값 수정 중 오류가 발생했습니다", + error: error.message, + }); + } +}; + +/** + * 카테고리 값 삭제 + */ +export const deleteCategoryValue = async (req: Request, res: Response) => { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const valueId = parseInt(req.params.valueId); + + if (isNaN(valueId)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 값 ID입니다", + }); + } + + await tableCategoryValueService.deleteCategoryValue( + valueId, + companyCode, + userId + ); + + return res.json({ + success: true, + message: "카테고리 값이 삭제되었습니다", + }); + } catch (error: any) { + logger.error(`카테고리 값 삭제 실패: ${error.message}`); + return res.status(500).json({ + success: false, + message: error.message || "카테고리 값 삭제 중 오류가 발생했습니다", + error: error.message, + }); + } +}; + +/** + * 카테고리 값 일괄 삭제 + */ +export const bulkDeleteCategoryValues = async ( + req: Request, + res: Response +) => { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { valueIds } = req.body; + + if (!Array.isArray(valueIds) || valueIds.length === 0) { + return res.status(400).json({ + success: false, + message: "삭제할 값 ID 목록이 필요합니다", + }); + } + + await tableCategoryValueService.bulkDeleteCategoryValues( + valueIds, + companyCode, + userId + ); + + return res.json({ + success: true, + message: `${valueIds.length}개의 카테고리 값이 삭제되었습니다`, + }); + } catch (error: any) { + logger.error(`카테고리 값 일괄 삭제 실패: ${error.message}`); + return res.status(500).json({ + success: false, + message: "카테고리 값 일괄 삭제 중 오류가 발생했습니다", + error: error.message, + }); + } +}; + +/** + * 카테고리 값 순서 변경 + */ +export const reorderCategoryValues = async (req: Request, res: Response) => { + try { + const companyCode = req.user!.companyCode; + const { orderedValueIds } = req.body; + + if (!Array.isArray(orderedValueIds) || orderedValueIds.length === 0) { + return res.status(400).json({ + success: false, + message: "순서 정보가 필요합니다", + }); + } + + await tableCategoryValueService.reorderCategoryValues( + orderedValueIds, + companyCode + ); + + return res.json({ + success: true, + message: "카테고리 값 순서가 변경되었습니다", + }); + } catch (error: any) { + logger.error(`카테고리 값 순서 변경 실패: ${error.message}`); + return res.status(500).json({ + success: false, + message: "카테고리 값 순서 변경 중 오류가 발생했습니다", + error: error.message, + }); + } +}; + diff --git a/backend-node/src/routes/numberingRuleRoutes.ts b/backend-node/src/routes/numberingRuleRoutes.ts new file mode 100644 index 00000000..ca17ceac --- /dev/null +++ b/backend-node/src/routes/numberingRuleRoutes.ts @@ -0,0 +1,14 @@ +/** + * 채번 규칙 관리 라우터 + */ + +import { Router } from "express"; +import numberingRuleController from "../controllers/numberingRuleController"; + +const router = Router(); + +// 모든 채번 규칙 라우트를 컨트롤러에서 가져옴 +router.use("/", numberingRuleController); + +export default router; + diff --git a/backend-node/src/routes/tableCategoryValueRoutes.ts b/backend-node/src/routes/tableCategoryValueRoutes.ts new file mode 100644 index 00000000..cc2ba05f --- /dev/null +++ b/backend-node/src/routes/tableCategoryValueRoutes.ts @@ -0,0 +1,50 @@ +import { Router } from "express"; +import * as tableCategoryValueController from "../controllers/tableCategoryValueController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +// 테이블의 카테고리 컬럼 목록 조회 +router.get( + "/:tableName/columns", + tableCategoryValueController.getCategoryColumns +); + +// 카테고리 값 목록 조회 +router.get( + "/:tableName/:columnName/values", + tableCategoryValueController.getCategoryValues +); + +// 카테고리 값 추가 +router.post("/values", tableCategoryValueController.addCategoryValue); + +// 카테고리 값 수정 +router.put( + "/values/:valueId", + tableCategoryValueController.updateCategoryValue +); + +// 카테고리 값 삭제 +router.delete( + "/values/:valueId", + tableCategoryValueController.deleteCategoryValue +); + +// 카테고리 값 일괄 삭제 +router.post( + "/values/bulk-delete", + tableCategoryValueController.bulkDeleteCategoryValues +); + +// 카테고리 값 순서 변경 +router.post( + "/values/reorder", + tableCategoryValueController.reorderCategoryValues +); + +export default router; + diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index 3de082d7..462ebb4d 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -1,3 +1,18 @@ +/** + * 동적 데이터 서비스 + * + * 주요 특징: + * 1. 화이트리스트 제거 - 모든 테이블에 동적으로 접근 가능 + * 2. 블랙리스트 방식 - 시스템 중요 테이블만 접근 금지 + * 3. 자동 회사별 필터링 - company_code 컬럼 자동 감지 및 필터 적용 + * 4. SQL 인젝션 방지 - 정규식 기반 테이블명/컬럼명 검증 + * + * 보안: + * - 테이블명은 영문, 숫자, 언더스코어만 허용 + * - 시스템 테이블(pg_*, information_schema 등) 접근 금지 + * - company_code 컬럼이 있는 테이블은 자동으로 회사별 격리 + * - 최고 관리자(company_code = "*")만 전체 데이터 조회 가능 + */ import { query, queryOne } from "../database/db"; interface GetTableDataParams { @@ -17,65 +32,72 @@ interface ServiceResponse { } /** - * 안전한 테이블명 목록 (화이트리스트) - * SQL 인젝션 방지를 위해 허용된 테이블만 접근 가능 + * 접근 금지 테이블 목록 (블랙리스트) + * 시스템 중요 테이블 및 보안상 접근 금지할 테이블 */ -const ALLOWED_TABLES = [ - "company_mng", - "user_info", - "dept_info", - "code_info", - "code_category", - "menu_info", - "approval", - "approval_kind", - "board", - "comm_code", - "product_mng", - "part_mng", - "material_mng", - "order_mng_master", - "inventory_mng", - "contract_mgmt", - "project_mgmt", - "screen_definitions", - "screen_layouts", - "layout_standards", - "component_standards", - "web_type_standards", - "button_action_standards", - "template_standards", - "grid_standards", - "style_templates", - "multi_lang_key_master", - "multi_lang_text", - "language_master", - "table_labels", - "column_labels", - "dynamic_form_data", - "work_history", // 작업 이력 테이블 - "delivery_status", // 배송 현황 테이블 +const BLOCKED_TABLES = [ + "pg_catalog", + "pg_statistic", + "pg_database", + "pg_user", + "information_schema", + "session_tokens", // 세션 토큰 테이블 + "password_history", // 패스워드 이력 ]; /** - * 회사별 필터링이 필요한 테이블 목록 + * 테이블 이름 검증 정규식 + * SQL 인젝션 방지: 영문, 숫자, 언더스코어만 허용 */ -const COMPANY_FILTERED_TABLES = [ - "company_mng", - "user_info", - "dept_info", - "approval", - "board", - "product_mng", - "part_mng", - "material_mng", - "order_mng_master", - "inventory_mng", - "contract_mgmt", - "project_mgmt", -]; +const TABLE_NAME_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/; class DataService { + /** + * 테이블 접근 검증 (공통 메서드) + */ + private async validateTableAccess( + tableName: string + ): Promise<{ valid: boolean; error?: ServiceResponse }> { + // 1. 테이블명 형식 검증 (SQL 인젝션 방지) + if (!TABLE_NAME_REGEX.test(tableName)) { + return { + valid: false, + error: { + success: false, + message: `유효하지 않은 테이블명입니다: ${tableName}`, + error: "INVALID_TABLE_NAME", + }, + }; + } + + // 2. 블랙리스트 검증 + if (BLOCKED_TABLES.includes(tableName)) { + return { + valid: false, + error: { + success: false, + message: `접근이 금지된 테이블입니다: ${tableName}`, + error: "TABLE_ACCESS_DENIED", + }, + }; + } + + // 3. 테이블 존재 여부 확인 + const tableExists = await this.checkTableExists(tableName); + if (!tableExists) { + return { + valid: false, + error: { + success: false, + message: `테이블을 찾을 수 없습니다: ${tableName}`, + error: "TABLE_NOT_FOUND", + }, + }; + } + + return { valid: true }; + } + /** * 테이블 데이터 조회 */ @@ -92,23 +114,10 @@ class DataService { } = params; try { - // 테이블명 화이트리스트 검증 - if (!ALLOWED_TABLES.includes(tableName)) { - return { - success: false, - message: `접근이 허용되지 않은 테이블입니다: ${tableName}`, - error: "TABLE_NOT_ALLOWED", - }; - } - - // 테이블 존재 여부 확인 - const tableExists = await this.checkTableExists(tableName); - if (!tableExists) { - return { - success: false, - message: `테이블을 찾을 수 없습니다: ${tableName}`, - error: "TABLE_NOT_FOUND", - }; + // 테이블 접근 검증 + const validation = await this.validateTableAccess(tableName); + if (!validation.valid) { + return validation.error!; } // 동적 SQL 쿼리 생성 @@ -119,13 +128,14 @@ class DataService { // WHERE 조건 생성 const whereConditions: string[] = []; - // 회사별 필터링 추가 - if (COMPANY_FILTERED_TABLES.includes(tableName) && userCompany) { - // 슈퍼관리자(*)가 아닌 경우에만 회사 필터 적용 - if (userCompany !== "*") { + // 4. 회사별 필터링 자동 적용 (company_code 컬럼이 있는 경우) + if (userCompany && userCompany !== "*") { + const hasCompanyCode = await this.checkColumnExists(tableName, "company_code"); + if (hasCompanyCode) { whereConditions.push(`company_code = $${paramIndex}`); queryParams.push(userCompany); paramIndex++; + console.log(`🏢 회사별 필터링 적용: ${tableName}.company_code = ${userCompany}`); } } @@ -213,13 +223,10 @@ class DataService { */ async getTableColumns(tableName: string): Promise> { try { - // 테이블명 화이트리스트 검증 - if (!ALLOWED_TABLES.includes(tableName)) { - return { - success: false, - message: `접근이 허용되지 않은 테이블입니다: ${tableName}`, - error: "TABLE_NOT_ALLOWED", - }; + // 테이블 접근 검증 + const validation = await this.validateTableAccess(tableName); + if (!validation.valid) { + return validation.error!; } const columns = await this.getTableColumnsSimple(tableName); @@ -276,6 +283,31 @@ class DataService { } } + /** + * 특정 컬럼 존재 여부 확인 + */ + private async checkColumnExists( + tableName: string, + columnName: string + ): Promise { + try { + const result = await query<{ exists: boolean }>( + `SELECT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = $1 + AND column_name = $2 + )`, + [tableName, columnName] + ); + + return result[0]?.exists || false; + } catch (error) { + console.error("컬럼 존재 확인 오류:", error); + return false; + } + } + /** * 테이블 컬럼 정보 조회 (간단 버전) */ @@ -324,13 +356,10 @@ class DataService { id: string | number ): Promise> { try { - // 테이블명 화이트리스트 검증 - if (!ALLOWED_TABLES.includes(tableName)) { - return { - success: false, - message: `접근이 허용되지 않은 테이블입니다: ${tableName}`, - error: "TABLE_NOT_ALLOWED", - }; + // 테이블 접근 검증 + const validation = await this.validateTableAccess(tableName); + if (!validation.valid) { + return validation.error!; } // Primary Key 컬럼 찾기 @@ -383,21 +412,16 @@ class DataService { leftValue?: string | number ): Promise> { try { - // 테이블명 화이트리스트 검증 - if (!ALLOWED_TABLES.includes(leftTable)) { - return { - success: false, - message: `접근이 허용되지 않은 테이블입니다: ${leftTable}`, - error: "TABLE_NOT_ALLOWED", - }; + // 왼쪽 테이블 접근 검증 + const leftValidation = await this.validateTableAccess(leftTable); + if (!leftValidation.valid) { + return leftValidation.error!; } - if (!ALLOWED_TABLES.includes(rightTable)) { - return { - success: false, - message: `접근이 허용되지 않은 테이블입니다: ${rightTable}`, - error: "TABLE_NOT_ALLOWED", - }; + // 오른쪽 테이블 접근 검증 + const rightValidation = await this.validateTableAccess(rightTable); + if (!rightValidation.valid) { + return rightValidation.error!; } let queryText = ` @@ -440,13 +464,10 @@ class DataService { data: Record ): Promise> { try { - // 테이블명 화이트리스트 검증 - if (!ALLOWED_TABLES.includes(tableName)) { - return { - success: false, - message: `접근이 허용되지 않은 테이블입니다: ${tableName}`, - error: "TABLE_NOT_ALLOWED", - }; + // 테이블 접근 검증 + const validation = await this.validateTableAccess(tableName); + if (!validation.valid) { + return validation.error!; } const columns = Object.keys(data); @@ -485,13 +506,10 @@ class DataService { data: Record ): Promise> { try { - // 테이블명 화이트리스트 검증 - if (!ALLOWED_TABLES.includes(tableName)) { - return { - success: false, - message: `접근이 허용되지 않은 테이블입니다: ${tableName}`, - error: "TABLE_NOT_ALLOWED", - }; + // 테이블 접근 검증 + const validation = await this.validateTableAccess(tableName); + if (!validation.valid) { + return validation.error!; } // Primary Key 컬럼 찾기 @@ -554,13 +572,10 @@ class DataService { id: string | number ): Promise> { try { - // 테이블명 화이트리스트 검증 - if (!ALLOWED_TABLES.includes(tableName)) { - return { - success: false, - message: `접근이 허용되지 않은 테이블입니다: ${tableName}`, - error: "TABLE_NOT_ALLOWED", - }; + // 테이블 접근 검증 + const validation = await this.validateTableAccess(tableName); + if (!validation.valid) { + return validation.error!; } // Primary Key 컬럼 찾기 diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts new file mode 100644 index 00000000..5e91d332 --- /dev/null +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -0,0 +1,436 @@ +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; +import { + TableCategoryValue, + CategoryColumn, +} from "../types/tableCategoryValue"; + +class TableCategoryValueService { + /** + * 테이블의 카테고리 타입 컬럼 목록 조회 + */ + async getCategoryColumns( + tableName: string, + companyCode: string + ): Promise { + try { + logger.info("카테고리 컬럼 목록 조회", { tableName, companyCode }); + + const pool = getPool(); + const query = ` + SELECT + tc.table_name AS "tableName", + tc.column_name AS "columnName", + tc.column_name AS "columnLabel", + COUNT(cv.value_id) AS "valueCount" + FROM table_type_columns tc + LEFT JOIN table_column_category_values cv + ON tc.table_name = cv.table_name + AND tc.column_name = cv.column_name + AND cv.is_active = true + AND (cv.company_code = $2 OR cv.company_code = '*') + WHERE tc.table_name = $1 + AND tc.input_type = 'category' + GROUP BY tc.table_name, tc.column_name, tc.display_order + ORDER BY tc.display_order, tc.column_name + `; + + const result = await pool.query(query, [tableName, companyCode]); + + logger.info(`카테고리 컬럼 ${result.rows.length}개 조회 완료`, { + tableName, + companyCode, + }); + + return result.rows; + } catch (error: any) { + logger.error(`카테고리 컬럼 조회 실패: ${error.message}`); + throw error; + } + } + + /** + * 특정 컬럼의 카테고리 값 목록 조회 (테이블 스코프) + */ + async getCategoryValues( + tableName: string, + columnName: string, + companyCode: string, + includeInactive: boolean = false + ): Promise { + try { + logger.info("카테고리 값 목록 조회", { + tableName, + columnName, + companyCode, + includeInactive, + }); + + const pool = getPool(); + let query = ` + SELECT + value_id AS "valueId", + table_name AS "tableName", + column_name AS "columnName", + value_code AS "valueCode", + value_label AS "valueLabel", + value_order AS "valueOrder", + parent_value_id AS "parentValueId", + depth, + description, + color, + icon, + is_active AS "isActive", + is_default AS "isDefault", + company_code AS "companyCode", + created_at AS "createdAt", + updated_at AS "updatedAt", + created_by AS "createdBy", + updated_by AS "updatedBy" + FROM table_column_category_values + WHERE table_name = $1 + AND column_name = $2 + AND (company_code = $3 OR company_code = '*') + `; + + const params: any[] = [tableName, columnName, companyCode]; + + if (!includeInactive) { + query += ` AND is_active = true`; + } + + query += ` ORDER BY value_order, value_label`; + + const result = await pool.query(query, params); + + // 계층 구조로 변환 + const values = this.buildHierarchy(result.rows); + + logger.info(`카테고리 값 ${result.rows.length}개 조회 완료`, { + tableName, + columnName, + }); + + return values; + } catch (error: any) { + logger.error(`카테고리 값 조회 실패: ${error.message}`); + throw error; + } + } + + /** + * 카테고리 값 추가 + */ + async addCategoryValue( + value: TableCategoryValue, + companyCode: string, + userId: string + ): Promise { + const pool = getPool(); + + try { + // 중복 코드 체크 + const duplicateQuery = ` + SELECT value_id + FROM table_column_category_values + WHERE table_name = $1 + AND column_name = $2 + AND value_code = $3 + AND (company_code = $4 OR company_code = '*') + `; + + const duplicateResult = await pool.query(duplicateQuery, [ + value.tableName, + value.columnName, + value.valueCode, + companyCode, + ]); + + if (duplicateResult.rows.length > 0) { + throw new Error("이미 존재하는 코드입니다"); + } + + const insertQuery = ` + INSERT INTO table_column_category_values ( + table_name, column_name, value_code, value_label, value_order, + parent_value_id, depth, description, color, icon, + is_active, is_default, company_code, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + RETURNING + value_id AS "valueId", + table_name AS "tableName", + column_name AS "columnName", + value_code AS "valueCode", + value_label AS "valueLabel", + value_order AS "valueOrder", + parent_value_id AS "parentValueId", + depth, + description, + color, + icon, + is_active AS "isActive", + is_default AS "isDefault", + company_code AS "companyCode", + created_at AS "createdAt", + created_by AS "createdBy" + `; + + const result = await pool.query(insertQuery, [ + value.tableName, + value.columnName, + value.valueCode, + value.valueLabel, + value.valueOrder || 0, + value.parentValueId || null, + value.depth || 1, + value.description || null, + value.color || null, + value.icon || null, + value.isActive !== false, + value.isDefault || false, + companyCode, + userId, + ]); + + logger.info("카테고리 값 추가 완료", { + valueId: result.rows[0].valueId, + tableName: value.tableName, + columnName: value.columnName, + }); + + return result.rows[0]; + } catch (error: any) { + logger.error(`카테고리 값 추가 실패: ${error.message}`); + throw error; + } + } + + /** + * 카테고리 값 수정 + */ + async updateCategoryValue( + valueId: number, + updates: Partial, + companyCode: string, + userId: string + ): Promise { + const pool = getPool(); + + try { + const setClauses: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (updates.valueLabel !== undefined) { + setClauses.push(`value_label = $${paramIndex++}`); + values.push(updates.valueLabel); + } + + if (updates.valueOrder !== undefined) { + setClauses.push(`value_order = $${paramIndex++}`); + values.push(updates.valueOrder); + } + + if (updates.description !== undefined) { + setClauses.push(`description = $${paramIndex++}`); + values.push(updates.description); + } + + if (updates.color !== undefined) { + setClauses.push(`color = $${paramIndex++}`); + values.push(updates.color); + } + + if (updates.icon !== undefined) { + setClauses.push(`icon = $${paramIndex++}`); + values.push(updates.icon); + } + + if (updates.isActive !== undefined) { + setClauses.push(`is_active = $${paramIndex++}`); + values.push(updates.isActive); + } + + if (updates.isDefault !== undefined) { + setClauses.push(`is_default = $${paramIndex++}`); + values.push(updates.isDefault); + } + + setClauses.push(`updated_at = NOW()`); + setClauses.push(`updated_by = $${paramIndex++}`); + values.push(userId); + + values.push(valueId, companyCode); + + const updateQuery = ` + UPDATE table_column_category_values + SET ${setClauses.join(", ")} + WHERE value_id = $${paramIndex++} + AND (company_code = $${paramIndex++} OR company_code = '*') + RETURNING + value_id AS "valueId", + table_name AS "tableName", + column_name AS "columnName", + value_code AS "valueCode", + value_label AS "valueLabel", + value_order AS "valueOrder", + description, + color, + icon, + is_active AS "isActive", + is_default AS "isDefault", + updated_at AS "updatedAt", + updated_by AS "updatedBy" + `; + + const result = await pool.query(updateQuery, values); + + if (result.rowCount === 0) { + throw new Error("카테고리 값을 찾을 수 없습니다"); + } + + logger.info("카테고리 값 수정 완료", { valueId, companyCode }); + + return result.rows[0]; + } catch (error: any) { + logger.error(`카테고리 값 수정 실패: ${error.message}`); + throw error; + } + } + + /** + * 카테고리 값 삭제 (비활성화) + */ + async deleteCategoryValue( + valueId: number, + companyCode: string, + userId: string + ): Promise { + const pool = getPool(); + + try { + // 하위 값 체크 + const checkQuery = ` + SELECT COUNT(*) as count + FROM table_column_category_values + WHERE parent_value_id = $1 + AND (company_code = $2 OR company_code = '*') + AND is_active = true + `; + + const checkResult = await pool.query(checkQuery, [valueId, companyCode]); + + if (parseInt(checkResult.rows[0].count) > 0) { + throw new Error("하위 카테고리 값이 있어 삭제할 수 없습니다"); + } + + // 비활성화 + const deleteQuery = ` + UPDATE table_column_category_values + SET is_active = false, updated_at = NOW(), updated_by = $3 + WHERE value_id = $1 + AND (company_code = $2 OR company_code = '*') + `; + + await pool.query(deleteQuery, [valueId, companyCode, userId]); + + logger.info("카테고리 값 삭제(비활성화) 완료", { + valueId, + companyCode, + }); + } catch (error: any) { + logger.error(`카테고리 값 삭제 실패: ${error.message}`); + throw error; + } + } + + /** + * 카테고리 값 일괄 삭제 + */ + async bulkDeleteCategoryValues( + valueIds: number[], + companyCode: string, + userId: string + ): Promise { + const pool = getPool(); + + try { + const deleteQuery = ` + UPDATE table_column_category_values + SET is_active = false, updated_at = NOW(), updated_by = $3 + WHERE value_id = ANY($1::int[]) + AND (company_code = $2 OR company_code = '*') + `; + + await pool.query(deleteQuery, [valueIds, companyCode, userId]); + + logger.info("카테고리 값 일괄 삭제 완료", { + count: valueIds.length, + companyCode, + }); + } catch (error: any) { + logger.error(`카테고리 값 일괄 삭제 실패: ${error.message}`); + throw error; + } + } + + /** + * 카테고리 값 순서 변경 + */ + async reorderCategoryValues( + orderedValueIds: number[], + companyCode: string + ): Promise { + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + for (let i = 0; i < orderedValueIds.length; i++) { + const updateQuery = ` + UPDATE table_column_category_values + SET value_order = $1, updated_at = NOW() + WHERE value_id = $2 + AND (company_code = $3 OR company_code = '*') + `; + + await client.query(updateQuery, [ + i + 1, + orderedValueIds[i], + companyCode, + ]); + } + + await client.query("COMMIT"); + + logger.info("카테고리 값 순서 변경 완료", { + count: orderedValueIds.length, + companyCode, + }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error(`카테고리 값 순서 변경 실패: ${error.message}`); + throw error; + } finally { + client.release(); + } + } + + /** + * 계층 구조 변환 헬퍼 + */ + private buildHierarchy( + values: TableCategoryValue[], + parentId: number | null = null + ): TableCategoryValue[] { + return values + .filter((v) => v.parentValueId === parentId) + .map((v) => ({ + ...v, + children: this.buildHierarchy(values, v.valueId!), + })); + } +} + +export default new TableCategoryValueService(); diff --git a/backend-node/src/types/tableCategoryValue.ts b/backend-node/src/types/tableCategoryValue.ts new file mode 100644 index 00000000..6f0055e7 --- /dev/null +++ b/backend-node/src/types/tableCategoryValue.ts @@ -0,0 +1,45 @@ +/** + * 테이블 컬럼별 카테고리 값 타입 정의 + */ + +export interface TableCategoryValue { + valueId?: number; + tableName: string; + columnName: string; + + // 값 정보 + valueCode: string; + valueLabel: string; + valueOrder?: number; + + // 계층 구조 + parentValueId?: number; + depth?: number; + + // 추가 정보 + description?: string; + color?: string; + icon?: string; + isActive?: boolean; + isDefault?: boolean; + + // 하위 항목 (조회 시) + children?: TableCategoryValue[]; + + // 멀티테넌시 + companyCode?: string; + + // 메타 + createdAt?: string; + updatedAt?: string; + createdBy?: string; + updatedBy?: string; +} + +export interface CategoryColumn { + tableName: string; + columnName: string; + columnLabel: string; + valueCount?: number; // 값 개수 +} + diff --git a/frontend/components/admin/CreateTableModal.tsx b/frontend/components/admin/CreateTableModal.tsx index 85e846c7..b0e6a6e8 100644 --- a/frontend/components/admin/CreateTableModal.tsx +++ b/frontend/components/admin/CreateTableModal.tsx @@ -10,10 +10,10 @@ import { Dialog, DialogContent, DialogHeader, - - - -} from "@/components/ui/resizable-dialog"; + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -321,20 +321,20 @@ export function CreateTableModal({ const isFormValid = !tableNameError && tableName && columns.some((col) => col.name && col.inputType); return ( - - - - + + + + {isDuplicateMode ? "테이블 복제" : "새 테이블 생성"} - - + + {isDuplicateMode ? `${sourceTableName} 테이블을 복제하여 새 테이블을 생성합니다. 테이블명을 입력하고 필요시 컬럼을 수정하세요.` : "최고 관리자만 새로운 테이블을 생성할 수 있습니다. 테이블명과 컬럼 정의를 입력하고 검증 후 생성하세요." } - - + +
{/* 테이블 기본 정보 */} @@ -482,8 +482,8 @@ export function CreateTableModal({ isDuplicateMode ? "복제 생성" : "테이블 생성" )} - - - + + + ); } diff --git a/frontend/components/admin/MenuFormModal.tsx b/frontend/components/admin/MenuFormModal.tsx index b3c14d5f..7c6f6aa5 100644 --- a/frontend/components/admin/MenuFormModal.tsx +++ b/frontend/components/admin/MenuFormModal.tsx @@ -8,7 +8,12 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; -import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/resizable-dialog"; +import { + ResizableDialog, + ResizableDialogContent, + ResizableDialogHeader, + ResizableDialogTitle +} from "@/components/ui/resizable-dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { toast } from "sonner"; diff --git a/frontend/components/admin/UserAuthEditModal.tsx b/frontend/components/admin/UserAuthEditModal.tsx index 3fe771bf..34379b64 100644 --- a/frontend/components/admin/UserAuthEditModal.tsx +++ b/frontend/components/admin/UserAuthEditModal.tsx @@ -2,13 +2,13 @@ import React, { useState, useEffect } from "react"; import { - ResizableDialog, - ResizableDialogContent, - ResizableDialogHeader, - ResizableDialogTitle, - ResizableDialogDescription, - ResizableDialogFooter, -} from "@/components/ui/resizable-dialog"; + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -124,11 +124,11 @@ export function UserAuthEditModal({ isOpen, onClose, onSuccess, user }: UserAuth if (!user) return null; return ( - - - - 사용자 권한 변경 - + + + + 사용자 권한 변경 +
{/* 사용자 정보 */} @@ -211,8 +211,8 @@ export function UserAuthEditModal({ isOpen, onClose, onSuccess, user }: UserAuth > {isLoading ? "처리중..." : showConfirmation ? "확인 및 저장" : "저장"} - - - + + +
); } diff --git a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx index 3886ea36..fc6b883f 100644 --- a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx +++ b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx @@ -203,7 +203,7 @@ export const NumberingRuleDesigner: React.FC = ({ separator: "-", resetPeriod: "none", currentSequence: 1, - scopeType: "global", + scopeType: "menu", }; setSelectedRuleId(newRule.ruleId); @@ -251,16 +251,15 @@ export const NumberingRuleDesigner: React.FC = ({ savedRules.map((rule) => ( handleSelectRule(rule)} > - +
{rule.ruleName} -

규칙 {rule.parts.length}개

- - -
)) )} @@ -316,46 +312,21 @@ export const NumberingRuleDesigner: React.FC = ({
-
- - setCurrentRule((prev) => ({ ...prev!, ruleName: e.target.value }))} - className="h-9" - placeholder="예: 프로젝트 코드" - /> -
- -
- - -

- {currentRule.scopeType === "menu" - ? "⚠️ 현재 화면이 속한 2레벨 메뉴와 그 하위 메뉴(3레벨 이상)에서만 사용됩니다. 형제 메뉴와 구분하여 채번 규칙을 관리할 때 유용합니다." - : "회사 내 모든 메뉴에서 사용 가능한 전역 규칙입니다"} -

-
- - - - 미리보기 - - +
+
+ + setCurrentRule((prev) => ({ ...prev!, ruleName: e.target.value }))} + className="h-9" + placeholder="예: 프로젝트 코드" + /> +
+
+ - - +
+
diff --git a/frontend/components/numbering-rule/NumberingRulePreview.tsx b/frontend/components/numbering-rule/NumberingRulePreview.tsx index e29cd4f4..63e39e04 100644 --- a/frontend/components/numbering-rule/NumberingRulePreview.tsx +++ b/frontend/components/numbering-rule/NumberingRulePreview.tsx @@ -81,11 +81,8 @@ export const NumberingRulePreview: React.FC = ({ } return ( -
-

코드 미리보기

-
- {generatedCode} -
+
+ {generatedCode}
); }; diff --git a/frontend/components/screen/CopyScreenModal.tsx b/frontend/components/screen/CopyScreenModal.tsx index 454d5805..182c92bb 100644 --- a/frontend/components/screen/CopyScreenModal.tsx +++ b/frontend/components/screen/CopyScreenModal.tsx @@ -168,7 +168,7 @@ export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopyS
- + diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index f4123169..3165bfa8 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -144,6 +144,9 @@ export const InteractiveDataTable: React.FC = ({ const [filteredData, setFilteredData] = useState([]); // 필터링된 데이터 const [columnLabels, setColumnLabels] = useState>({}); // 컬럼명 -> 라벨 매핑 + // 카테고리 값 매핑 캐시 (컬럼명 -> {코드 -> 라벨}) + const [categoryMappings, setCategoryMappings] = useState>>({}); + // 공통코드 옵션 가져오기 const loadCodeOptions = useCallback( async (categoryCode: string) => { @@ -178,7 +181,6 @@ export const InteractiveDataTable: React.FC = ({ // 🆕 전역 테이블 새로고침 이벤트 리스너 useEffect(() => { const handleRefreshTable = () => { - console.log("🔄 InteractiveDataTable: 전역 새로고침 이벤트 수신"); if (component.tableName) { loadData(currentPage, searchValues); } @@ -191,6 +193,51 @@ export const InteractiveDataTable: React.FC = ({ }; }, [currentPage, searchValues, loadData, component.tableName]); + // 카테고리 타입 컬럼의 값 매핑 로드 + useEffect(() => { + const loadCategoryMappings = async () => { + if (!component.tableName) return; + + try { + // 카테고리 타입 컬럼 찾기 + const categoryColumns = component.columns?.filter((col) => { + const webType = getColumnWebType(col.columnName); + return webType === "category"; + }); + + if (!categoryColumns || categoryColumns.length === 0) return; + + // 각 카테고리 컬럼의 값 목록 조회 + const mappings: Record> = {}; + + for (const col of categoryColumns) { + try { + const response = await apiClient.get( + `/table-categories/${component.tableName}/${col.columnName}/values` + ); + + if (response.data.success && response.data.data) { + // valueCode -> valueLabel 매핑 생성 + const mapping: Record = {}; + response.data.data.forEach((item: any) => { + mapping[item.valueCode] = item.valueLabel; + }); + mappings[col.columnName] = mapping; + } + } catch (error) { + // 카테고리 값 로드 실패 시 무시 + } + } + + setCategoryMappings(mappings); + } catch (error) { + console.error("카테고리 매핑 로드 실패:", error); + } + }; + + loadCategoryMappings(); + }, [component.tableName, component.columns, getColumnWebType]); + // 파일 상태 확인 함수 const checkFileStatus = useCallback( async (rowData: Record) => { @@ -340,7 +387,6 @@ export const InteractiveDataTable: React.FC = ({ // 대체 URL 생성 (직접 파일 경로 사용) if (previewImage.path) { const altUrl = getDirectFileUrl(previewImage.path); - // console.log("대체 URL 시도:", altUrl); setAlternativeImageUrl(altUrl); } else { toast.error("이미지를 불러올 수 없습니다."); @@ -368,7 +414,7 @@ export const InteractiveDataTable: React.FC = ({ // 검색 가능한 컬럼만 필터링 const visibleColumns = component.columns?.filter((col: DataTableColumn) => col.visible) || []; - // 컬럼의 실제 웹 타입 정보 찾기 + // 컬럼의 실제 웹 타입 정보 찾기 (webType 또는 input_type) const getColumnWebType = useCallback( (columnName: string) => { // 먼저 컴포넌트에 설정된 컬럼에서 찾기 (화면 관리에서 설정한 값 우선) @@ -379,6 +425,14 @@ export const InteractiveDataTable: React.FC = ({ // 없으면 테이블 타입 관리에서 설정된 값 찾기 const tableColumn = tableColumns.find((col) => col.columnName === columnName); + + // input_type 우선 사용 (category 등) + const inputType = (tableColumn as any)?.input_type || (tableColumn as any)?.inputType; + if (inputType) { + return inputType; + } + + // 없으면 webType 사용 return tableColumn?.webType || "text"; }, [component.columns, tableColumns], @@ -398,7 +452,6 @@ export const InteractiveDataTable: React.FC = ({ try { return tableColumn?.detailSettings ? JSON.parse(tableColumn.detailSettings) : {}; } catch { - // console.warn("상세 설정 파싱 실패:", tableColumn?.detailSettings); return {}; } }, @@ -601,15 +654,6 @@ export const InteractiveDataTable: React.FC = ({ const handleRefreshFileStatus = async (event: CustomEvent) => { const { tableName, recordId, columnName, targetObjid, fileCount } = event.detail; - // console.log("🔄 InteractiveDataTable 파일 상태 새로고침 이벤트 수신:", { - // tableName, - // recordId, - // columnName, - // targetObjid, - // fileCount, - // currentTableName: component.tableName - // }); - // 현재 테이블과 일치하는지 확인 if (tableName === component.tableName) { // 해당 행의 파일 상태 업데이트 @@ -619,13 +663,6 @@ export const InteractiveDataTable: React.FC = ({ [recordId]: { hasFiles: fileCount > 0, fileCount }, [columnKey]: { hasFiles: fileCount > 0, fileCount }, })); - - // console.log("✅ 파일 상태 업데이트 완료:", { - // recordId, - // columnKey, - // hasFiles: fileCount > 0, - // fileCount - // }); } }; @@ -1033,7 +1070,6 @@ export const InteractiveDataTable: React.FC = ({ setIsAdding(true); // 실제 API 호출로 데이터 추가 - // console.log("🔥 추가할 데이터:", addFormData); await tableTypeApi.addTableData(component.tableName, addFormData); // 모달 닫기 및 폼 초기화 @@ -1056,9 +1092,6 @@ export const InteractiveDataTable: React.FC = ({ setIsEditing(true); // 실제 API 호출로 데이터 수정 - // console.log("🔥 수정할 데이터:", editFormData); - // console.log("🔥 원본 데이터:", editingRowData); - if (editingRowData) { await tableTypeApi.editTableData(component.tableName, editingRowData, editFormData); @@ -1129,7 +1162,6 @@ export const InteractiveDataTable: React.FC = ({ const selectedData = Array.from(selectedRows).map((index) => data[index]); // 실제 삭제 API 호출 - // console.log("🗑️ 삭제할 데이터:", selectedData); await tableTypeApi.deleteTableData(component.tableName, selectedData); // 선택 해제 및 다이얼로그 닫기 @@ -1414,6 +1446,25 @@ export const InteractiveDataTable: React.FC = ({
); + case "category": { + // 카테고리 셀렉트 (동적 import) + const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent"); + return ( +
+ handleEditFormChange(column.columnName, newValue)} + placeholder={advancedConfig?.placeholder || `${column.label} 선택...`} + required={isRequired} + className={commonProps.className} + /> + {advancedConfig?.helpText &&

{advancedConfig.helpText}

} +
+ ); + } + default: return (
@@ -1676,6 +1727,25 @@ export const InteractiveDataTable: React.FC = ({
); + case "category": { + // 카테고리 셀렉트 (동적 import) + const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent"); + return ( +
+ handleAddFormChange(column.columnName, newValue)} + placeholder={advancedConfig?.placeholder || `${column.label} 선택...`} + required={isRequired} + className={commonProps.className} + /> + {advancedConfig?.helpText &&

{advancedConfig.helpText}

} +
+ ); + } + default: return (
@@ -1747,8 +1817,6 @@ export const InteractiveDataTable: React.FC = ({ const handleDeleteLinkedFile = useCallback( async (fileId: string, fileName: string) => { try { - // console.log("🗑️ 파일 삭제 시작:", { fileId, fileName }); - // 삭제 확인 다이얼로그 if (!confirm(`"${fileName}" 파일을 삭제하시겠습니까?`)) { return; @@ -1763,7 +1831,6 @@ export const InteractiveDataTable: React.FC = ({ }); const result = response.data; - // console.log("📡 파일 삭제 API 응답:", result); if (!result.success) { throw new Error(result.message || "파일 삭제 실패"); @@ -1780,15 +1847,11 @@ export const InteractiveDataTable: React.FC = ({ try { const response = await getLinkedFiles(component.tableName, recordId); setLinkedFiles(response.files || []); - // console.log("📁 파일 목록 새로고침 완료:", response.files?.length || 0); } catch (error) { - // console.error("파일 목록 새로고침 실패:", error); + // 파일 목록 새로고침 실패 시 무시 } } - - // console.log("✅ 파일 삭제 완료:", fileName); } catch (error) { - // console.error("❌ 파일 삭제 실패:", error); toast.error(`"${fileName}" 파일 삭제에 실패했습니다.`); } }, @@ -1800,9 +1863,12 @@ export const InteractiveDataTable: React.FC = ({ // 가상 파일 컬럼의 경우 value가 없어도 파일 아이콘을 표시해야 함 if (!column.isVirtualFileColumn && (value === null || value === undefined)) return ""; + // 실제 웹 타입 가져오기 (input_type 포함) + const actualWebType = getColumnWebType(column.columnName); + // 파일 타입 컬럼 처리 (가상 파일 컬럼 포함) const isFileColumn = - column.widgetType === "file" || column.columnName === "file_path" || column.isVirtualFileColumn; + actualWebType === "file" || column.columnName === "file_path" || column.isVirtualFileColumn; // 파일 타입 컬럼은 파일 아이콘으로 표시 (컬럼별 파일 관리) if (isFileColumn && rowData) { @@ -1842,7 +1908,18 @@ export const InteractiveDataTable: React.FC = ({ ); } - switch (column.widgetType) { + // 실제 웹 타입으로 스위치 (input_type="category"도 포함됨) + switch (actualWebType) { + case "category": { + // 카테고리 타입: 코드값 -> 라벨로 변환 + const mapping = categoryMappings[column.columnName]; + if (mapping && value) { + const label = mapping[String(value)]; + return label || String(value); + } + return String(value || ""); + } + case "date": if (value) { try { diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index fcc477f1..93a823a4 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -615,7 +615,7 @@ export const InteractiveScreenViewerDynamic: React.FC = ({ return ( <> - - + + {assignmentSuccess ? ( // 성공 화면 <> - - + +
{assignmentMessage.includes("나중에") ? "화면 저장 완료" : "화면 할당 완료"} -
- + + {assignmentMessage.includes("나중에") ? "화면이 성공적으로 저장되었습니다. 나중에 메뉴에 할당할 수 있습니다." : "화면이 성공적으로 메뉴에 할당되었습니다."} - -
+ +
@@ -382,7 +382,7 @@ export const MenuAssignmentModal: React.FC = ({
- + - + ) : ( // 기본 할당 화면 <> - - + + 메뉴에 화면 할당 - - + + 저장된 화면을 메뉴에 할당하여 사용자가 접근할 수 있도록 설정합니다. - + {screenInfo && (
@@ -428,7 +428,7 @@ export const MenuAssignmentModal: React.FC = ({ {screenInfo.description &&

{screenInfo.description}

}
)} - +
{/* 메뉴 선택 (검색 기능 포함) */} @@ -568,22 +568,22 @@ export const MenuAssignmentModal: React.FC = ({ )} - + )} - - + +
{/* 화면 교체 확인 대화상자 */} - - - - + + + + 화면 교체 확인 - - 선택한 메뉴에 이미 할당된 화면이 있습니다. - + + 선택한 메뉴에 이미 할당된 화면이 있습니다. +
{/* 기존 화면 목록 */} @@ -648,9 +648,9 @@ export const MenuAssignmentModal: React.FC = ({ )} - - - + + + ); }; diff --git a/frontend/components/screen/RealtimePreview.tsx b/frontend/components/screen/RealtimePreview.tsx index 097e6c71..ab8cc3ae 100644 --- a/frontend/components/screen/RealtimePreview.tsx +++ b/frontend/components/screen/RealtimePreview.tsx @@ -403,7 +403,7 @@ export const RealtimePreviewDynamic: React.FC = ({ const isFlowWidget = type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget"); // 높이 결정 로직 - let finalHeight = size?.height || 40; + let finalHeight = size?.height || 10; if (isFlowWidget && actualHeight) { finalHeight = actualHeight; } diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index 3e2a34dd..48761e42 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -54,7 +54,7 @@ interface RealtimePreviewProps { // 폼 데이터 관련 props formData?: Record; onFormDataChange?: (fieldName: string, value: any) => void; - + // 테이블 정렬 정보 sortBy?: string; sortOrder?: "asc" | "desc"; @@ -229,10 +229,10 @@ export const RealtimePreviewDynamic: React.FC = ({ } // 2순위: x=0인 컴포넌트는 전체 너비 사용 (버튼 제외) - const isButtonComponent = + const isButtonComponent = (component.type === "widget" && (component as WidgetComponent).widgetType === "button") || (component.type === "component" && (component as any).componentType?.includes("button")); - + if (position.x === 0 && !isButtonComponent) { console.log("⚠️ [getWidth] 100% 사용 (x=0):", { componentId: id, @@ -271,9 +271,7 @@ export const RealtimePreviewDynamic: React.FC = ({ // 1순위: style.height가 있으면 우선 사용 (문자열 그대로 또는 숫자+px) if (componentStyle?.height) { - return typeof componentStyle.height === 'number' - ? `${componentStyle.height}px` - : componentStyle.height; + return typeof componentStyle.height === "number" ? `${componentStyle.height}px` : componentStyle.height; } // 2순위: size.height (픽셀) @@ -281,28 +279,23 @@ export const RealtimePreviewDynamic: React.FC = ({ return `${Math.max(size?.height || 200, 200)}px`; } - // 3순위: size.height가 있으면 사용 - if (size?.height) { - return typeof size.height === 'number' - ? `${size.height}px` - : size.height; - } - - return "40px"; + // size.height가 있으면 그대로 사용, 없으면 최소 10px + return `${size?.height || 10}px`; }; // layout 타입 컴포넌트인지 확인 - const isLayoutComponent = component.type === "layout" || - (component.componentConfig as any)?.type?.includes("layout"); - + const isLayoutComponent = component.type === "layout" || (component.componentConfig as any)?.type?.includes("layout"); + // layout 컴포넌트는 component 객체에 style.height 추가 - const enhancedComponent = isLayoutComponent ? { - ...component, - style: { - ...component.style, - height: getHeight(), - } - } : component; + const enhancedComponent = isLayoutComponent + ? { + ...component, + style: { + ...component.style, + height: getHeight(), + }, + } + : component; const baseStyle = { left: `${position.x}px`, @@ -317,14 +310,14 @@ export const RealtimePreviewDynamic: React.FC = ({ // 🔍 DOM 렌더링 후 실제 크기 측정 const innerDivRef = React.useRef(null); const outerDivRef = React.useRef(null); - + React.useEffect(() => { if (outerDivRef.current && innerDivRef.current) { const outerRect = outerDivRef.current.getBoundingClientRect(); const innerRect = innerDivRef.current.getBoundingClientRect(); const computedOuter = window.getComputedStyle(outerDivRef.current); const computedInner = window.getComputedStyle(innerDivRef.current); - + console.log("📐 [DOM 실제 크기 상세]:", { componentId: id, label: component.label, @@ -346,7 +339,7 @@ export const RealtimePreviewDynamic: React.FC = ({ }, "4. 너비 비교": { "외부 / 내부": `${outerRect.width}px / ${innerRect.width}px`, - "비율": `${((innerRect.width / outerRect.width) * 100).toFixed(2)}%`, + 비율: `${((innerRect.width / outerRect.width) * 100).toFixed(2)}%`, }, }); } diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index d057930f..a7c3c2cf 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -619,7 +619,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const fullColumnWidth = columnWidth + (gap || 16); // 외부 격자와 동일한 크기 const widthInColumns = Math.max(1, Math.round(newComp.size.width / fullColumnWidth)); const snappedWidth = widthInColumns * fullColumnWidth - (gap || 16); // gap 제거하여 실제 컴포넌트 크기 - const snappedHeight = Math.max(10, Math.round(newComp.size.height / 10) * 10); + // 높이는 사용자가 입력한 값 그대로 사용 (스냅 제거) + const snappedHeight = Math.max(10, newComp.size.height); newComp.position = { x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보 @@ -2555,6 +2556,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD componentConfig: { type: componentId, // text-input, number-input 등 webType: column.widgetType, // 원본 웹타입 보존 + inputType: column.inputType, // ✅ input_type 추가 (category 등) ...getDefaultWebTypeConfig(column.widgetType), // 코드 타입인 경우 코드 카테고리 정보 추가 ...(column.widgetType === "code" && @@ -2618,6 +2620,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD componentConfig: { type: componentId, // text-input, number-input 등 webType: column.widgetType, // 원본 웹타입 보존 + inputType: column.inputType, // ✅ input_type 추가 (category 등) ...getDefaultWebTypeConfig(column.widgetType), // 코드 타입인 경우 코드 카테고리 정보 추가 ...(column.widgetType === "code" && @@ -3026,7 +3029,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const fullColumnWidth = columnWidth + (gap || 16); // 외부 격자와 동일한 크기 const widthInColumns = Math.max(1, Math.round(comp.size.width / fullColumnWidth)); const snappedWidth = widthInColumns * fullColumnWidth - (gap || 16); // gap 제거하여 실제 컴포넌트 크기 - const snappedHeight = Math.max(40, Math.round(comp.size.height / 20) * 20); + // 높이는 사용자가 입력한 값 그대로 사용 (스냅 제거) + const snappedHeight = Math.max(40, comp.size.height); newPosition = { x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보 diff --git a/frontend/components/screen/ScreenList.tsx b/frontend/components/screen/ScreenList.tsx index 9b3b5f3f..2f911818 100644 --- a/frontend/components/screen/ScreenList.tsx +++ b/frontend/components/screen/ScreenList.tsx @@ -25,12 +25,12 @@ import { } from "@/components/ui/alert-dialog"; import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; -import { - ResizableDialog, - ResizableDialogContent, - ResizableDialogHeader, - ResizableDialogTitle, - ResizableDialogFooter +import { + ResizableDialog, + ResizableDialogContent, + ResizableDialogHeader, + ResizableDialogTitle, + ResizableDialogFooter, } from "@/components/ui/resizable-dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette, RotateCcw, Trash } from "lucide-react"; @@ -456,7 +456,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr }`} onClick={() => onDesignScreen(screen)} > - +
{screen.screenName}
{screen.description && ( @@ -696,7 +696,10 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr {deletedScreens.map((screen) => ( - + diff --git a/frontend/components/screen/panels/PropertiesPanel.tsx b/frontend/components/screen/panels/PropertiesPanel.tsx index 277c6e9c..3d429123 100644 --- a/frontend/components/screen/panels/PropertiesPanel.tsx +++ b/frontend/components/screen/panels/PropertiesPanel.tsx @@ -695,6 +695,7 @@ const PropertiesPanelComponent: React.FC = ({ { const isDragging = dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id; if (isDragging) { @@ -725,6 +726,7 @@ const PropertiesPanelComponent: React.FC = ({ { const isDragging = dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id; if (isDragging) { @@ -762,6 +764,7 @@ const PropertiesPanelComponent: React.FC = ({ type="number" min={1} max={gridSettings?.columns || 12} + step="1" value={(selectedComponent as any)?.gridColumns || 1} onChange={(e) => { const value = parseInt(e.target.value, 10); @@ -961,27 +964,27 @@ const PropertiesPanelComponent: React.FC = ({
{ - const units = Math.max(1, Math.min(100, Number(e.target.value))); - const newHeight = units * 10; + const newHeight = Math.max(10, Number(e.target.value)); setLocalInputs((prev) => ({ ...prev, height: newHeight.toString() })); onUpdateProperty("size.height", newHeight); }} className="flex-1" /> - 단위 = {localInputs.height || 10}px + {localInputs.height || 40}px

- 1단위 = 10px (현재 {Math.round((localInputs.height || 10) / 10)}단위) - 내부 콘텐츠에 맞춰 늘어남 + 높이 자유 조절 (10px ~ 2000px, 1px 단위)

@@ -996,11 +999,12 @@ const PropertiesPanelComponent: React.FC = ({ - { const newValue = e.target.value; @@ -1266,6 +1270,7 @@ const PropertiesPanelComponent: React.FC = ({ type="number" min="1" max="12" + step="1" value={(selectedComponent as AreaComponent).layoutConfig?.gridColumns || 3} onChange={(e) => { const value = Number(e.target.value); @@ -1279,6 +1284,7 @@ const PropertiesPanelComponent: React.FC = ({ { const value = Number(e.target.value); @@ -1315,6 +1321,7 @@ const PropertiesPanelComponent: React.FC = ({ { const value = Number(e.target.value); @@ -1345,6 +1352,7 @@ const PropertiesPanelComponent: React.FC = ({ { const value = Number(e.target.value); diff --git a/frontend/components/screen/panels/ResolutionPanel.tsx b/frontend/components/screen/panels/ResolutionPanel.tsx index 90680f01..3fac225d 100644 --- a/frontend/components/screen/panels/ResolutionPanel.tsx +++ b/frontend/components/screen/panels/ResolutionPanel.tsx @@ -146,6 +146,7 @@ const ResolutionPanel: React.FC = ({ currentResolution, on onChange={(e) => setCustomWidth(e.target.value)} placeholder="1920" min="1" + step="1" className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }} style={{ fontSize: "12px" }} /> @@ -158,6 +159,7 @@ const ResolutionPanel: React.FC = ({ currentResolution, on onChange={(e) => setCustomHeight(e.target.value)} placeholder="1080" min="1" + step="1" className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }} style={{ fontSize: "12px" }} /> diff --git a/frontend/components/screen/panels/RowSettingsPanel.tsx b/frontend/components/screen/panels/RowSettingsPanel.tsx index 2bd48a12..1378ffe3 100644 --- a/frontend/components/screen/panels/RowSettingsPanel.tsx +++ b/frontend/components/screen/panels/RowSettingsPanel.tsx @@ -57,6 +57,7 @@ export const RowSettingsPanel: React.FC = ({ row, onUpdat placeholder="100" min={50} max={1000} + step="1" />
)} @@ -73,6 +74,7 @@ export const RowSettingsPanel: React.FC = ({ row, onUpdat placeholder="50" min={0} max={1000} + step="1" />
)} @@ -89,6 +91,7 @@ export const RowSettingsPanel: React.FC = ({ row, onUpdat placeholder="500" min={0} max={2000} + step="1" />
)} diff --git a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx index f2e50db8..8d7cd091 100644 --- a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx +++ b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx @@ -104,6 +104,9 @@ export const UnifiedPropertiesPanel: React.FC = ({ }) => { const { webTypes } = useWebTypes({ active: "Y" }); const [localComponentDetailType, setLocalComponentDetailType] = useState(""); + + // 높이 입력 로컬 상태 (격자 스냅 방지) + const [localHeight, setLocalHeight] = useState(""); // 새로운 컴포넌트 시스템의 webType 동기화 useEffect(() => { @@ -114,6 +117,13 @@ export const UnifiedPropertiesPanel: React.FC = ({ } } }, [selectedComponent?.type, selectedComponent?.componentConfig?.webType, selectedComponent?.id]); + + // 높이 값 동기화 + useEffect(() => { + if (selectedComponent?.size?.height !== undefined) { + setLocalHeight(String(selectedComponent.size.height)); + } + }, [selectedComponent?.size?.height, selectedComponent?.id]); // 격자 설정 업데이트 함수 (early return 이전에 정의) const updateGridSetting = (key: string, value: any) => { @@ -180,6 +190,7 @@ export const UnifiedPropertiesPanel: React.FC = ({ id="columns" type="number" min={1} + step="1" value={gridSettings.columns} onChange={(e) => { const value = parseInt(e.target.value, 10); @@ -361,17 +372,32 @@ export const UnifiedPropertiesPanel: React.FC = ({ { - const value = parseInt(e.target.value) || 0; - const roundedValue = Math.max(10, Math.round(value / 10) * 10); - handleUpdate("size.height", roundedValue); + // 입력 중에는 로컬 상태만 업데이트 (격자 스냅 방지) + setLocalHeight(e.target.value); }} - step={10} + onBlur={(e) => { + // 포커스를 잃을 때만 실제로 업데이트 + const value = parseInt(e.target.value) || 0; + if (value >= 1) { + handleUpdate("size.height", value); + } + }} + onKeyDown={(e) => { + // Enter 키를 누르면 즉시 적용 + if (e.key === "Enter") { + const value = parseInt(e.currentTarget.value) || 0; + if (value >= 1) { + handleUpdate("size.height", value); + } + e.currentTarget.blur(); // 포커스 제거 + } + }} + step={1} placeholder="10" className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }} - style={{ fontSize: "12px" }} /> @@ -431,6 +457,7 @@ export const UnifiedPropertiesPanel: React.FC = ({ type="number" min={1} max={gridSettings?.columns || 12} + step="1" value={(selectedComponent as any).gridColumns || 1} onChange={(e) => { const value = parseInt(e.target.value, 10); @@ -456,6 +483,7 @@ export const UnifiedPropertiesPanel: React.FC = ({ handleUpdate("position.z", parseInt(e.target.value) || 1)} className="h-6 w-full px-2 py-0 text-xs" diff --git a/frontend/components/screen/panels/webtype-configs/NumberTypeConfigPanel.tsx b/frontend/components/screen/panels/webtype-configs/NumberTypeConfigPanel.tsx index aaa66688..924feeee 100644 --- a/frontend/components/screen/panels/webtype-configs/NumberTypeConfigPanel.tsx +++ b/frontend/components/screen/panels/webtype-configs/NumberTypeConfigPanel.tsx @@ -132,6 +132,7 @@ export const NumberTypeConfigPanel: React.FC = ({ co updateConfig("min", e.target.value ? Number(e.target.value) : undefined)} className="mt-1" @@ -146,6 +147,7 @@ export const NumberTypeConfigPanel: React.FC = ({ co updateConfig("max", e.target.value ? Number(e.target.value) : undefined)} className="mt-1" @@ -181,6 +183,7 @@ export const NumberTypeConfigPanel: React.FC = ({ co type="number" min="0" max="10" + step="1" value={localValues.decimalPlaces} onChange={(e) => updateConfig("decimalPlaces", e.target.value ? Number(e.target.value) : undefined)} className="mt-1" diff --git a/frontend/components/screen/panels/webtype-configs/TextTypeConfigPanel.tsx b/frontend/components/screen/panels/webtype-configs/TextTypeConfigPanel.tsx index cb46ec50..abb35347 100644 --- a/frontend/components/screen/panels/webtype-configs/TextTypeConfigPanel.tsx +++ b/frontend/components/screen/panels/webtype-configs/TextTypeConfigPanel.tsx @@ -168,6 +168,7 @@ export const TextTypeConfigPanel: React.FC = ({ config id="minLength" type="number" min="0" + step="1" value={localValues.minLength} onChange={(e) => updateConfig("minLength", e.target.value ? Number(e.target.value) : undefined)} className="mt-1" @@ -183,6 +184,7 @@ export const TextTypeConfigPanel: React.FC = ({ config id="maxLength" type="number" min="0" + step="1" value={localValues.maxLength} onChange={(e) => updateConfig("maxLength", e.target.value ? Number(e.target.value) : undefined)} className="mt-1" diff --git a/frontend/components/screen/templates/NumberingRuleTemplate.ts b/frontend/components/screen/templates/NumberingRuleTemplate.ts index ee386c4b..fbe15d8d 100644 --- a/frontend/components/screen/templates/NumberingRuleTemplate.ts +++ b/frontend/components/screen/templates/NumberingRuleTemplate.ts @@ -75,3 +75,4 @@ export const numberingRuleTemplate = { ], }; + diff --git a/frontend/components/screen/widgets/CategoryWidget.tsx b/frontend/components/screen/widgets/CategoryWidget.tsx new file mode 100644 index 00000000..54c8f98b --- /dev/null +++ b/frontend/components/screen/widgets/CategoryWidget.tsx @@ -0,0 +1,104 @@ +"use client"; + +import React, { useState, useRef, useCallback } from "react"; +import { CategoryColumnList } from "@/components/table-category/CategoryColumnList"; +import { CategoryValueManager } from "@/components/table-category/CategoryValueManager"; +import { GripVertical } from "lucide-react"; + +interface CategoryWidgetProps { + widgetId: string; + tableName: string; // 현재 화면의 테이블 +} + +/** + * 카테고리 관리 위젯 (좌우 분할) + * - 좌측: 현재 테이블의 카테고리 타입 컬럼 목록 + * - 우측: 선택된 컬럼의 카테고리 값 관리 (테이블 스코프) + */ +export function CategoryWidget({ widgetId, tableName }: CategoryWidgetProps) { + const [selectedColumn, setSelectedColumn] = useState<{ + columnName: string; + columnLabel: string; + } | null>(null); + + const [leftWidth, setLeftWidth] = useState(15); // 초기값 15% + const containerRef = useRef(null); + const isDraggingRef = useRef(false); + + const handleMouseDown = useCallback(() => { + isDraggingRef.current = true; + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + }, []); + + const handleMouseMove = useCallback((e: MouseEvent) => { + if (!isDraggingRef.current || !containerRef.current) return; + + const containerRect = containerRef.current.getBoundingClientRect(); + const newLeftWidth = ((e.clientX - containerRect.left) / containerRect.width) * 100; + + // 최소 10%, 최대 40%로 제한 + if (newLeftWidth >= 10 && newLeftWidth <= 40) { + setLeftWidth(newLeftWidth); + } + }, []); + + const handleMouseUp = useCallback(() => { + isDraggingRef.current = false; + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }, []); + + React.useEffect(() => { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [handleMouseMove, handleMouseUp]); + + return ( +
+ {/* 좌측: 카테고리 컬럼 리스트 */} +
+ + setSelectedColumn({ columnName, columnLabel }) + } + /> +
+ + {/* 리사이저 */} +
+ +
+ + {/* 우측: 카테고리 값 관리 */} +
+ {selectedColumn ? ( + + ) : ( +
+
+

+ 좌측에서 관리할 카테고리 컬럼을 선택하세요 +

+
+
+ )} +
+
+ ); +} + diff --git a/frontend/components/screen/widgets/FlowWidget.tsx b/frontend/components/screen/widgets/FlowWidget.tsx index fd24ea19..e5a2e541 100644 --- a/frontend/components/screen/widgets/FlowWidget.tsx +++ b/frontend/components/screen/widgets/FlowWidget.tsx @@ -66,28 +66,6 @@ export function FlowWidget({ const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 const { user } = useAuth(); // 사용자 정보 가져오기 - // 숫자 포맷팅 함수 - const formatValue = (value: any): string => { - if (value === null || value === undefined || value === "") { - return "-"; - } - - // 숫자 타입이거나 숫자로 변환 가능한 문자열인 경우 포맷팅 - if (typeof value === "number") { - return value.toLocaleString("ko-KR"); - } - - if (typeof value === "string") { - const numValue = parseFloat(value); - // 숫자로 변환 가능하고, 변환 후 원래 값과 같은 경우에만 포맷팅 - if (!isNaN(numValue) && numValue.toString() === value.trim()) { - return numValue.toLocaleString("ko-KR"); - } - } - - return String(value); - }; - // 🆕 전역 상태 관리 const setSelectedStep = useFlowStepStore((state) => state.setSelectedStep); const resetFlow = useFlowStepStore((state) => state.resetFlow); @@ -114,6 +92,40 @@ export function FlowWidget({ const [allAvailableColumns, setAllAvailableColumns] = useState([]); // 전체 컬럼 목록 const [filteredData, setFilteredData] = useState([]); // 필터링된 데이터 + // 카테고리 값 매핑 캐시 (컬럼명 -> {코드 -> 라벨}) + const [categoryMappings, setCategoryMappings] = useState>>({}); + + // 값 포맷팅 함수 (숫자, 카테고리 등) + const formatValue = useCallback((value: any, columnName?: string): string => { + if (value === null || value === undefined || value === "") { + return "-"; + } + + // 카테고리 타입: 코드값 -> 라벨로 변환 + if (columnName && categoryMappings[columnName]) { + const mapping = categoryMappings[columnName]; + const label = mapping[String(value)]; + if (label) { + return label; + } + } + + // 숫자 타입이거나 숫자로 변환 가능한 문자열인 경우 포맷팅 + if (typeof value === "number") { + return value.toLocaleString("ko-KR"); + } + + if (typeof value === "string") { + const numValue = parseFloat(value); + // 숫자로 변환 가능하고, 변환 후 원래 값과 같은 경우에만 포맷팅 + if (!isNaN(numValue) && numValue.toString() === value.trim()) { + return numValue.toLocaleString("ko-KR"); + } + } + + return String(value); + }, [categoryMappings]); + // 🆕 그룹 설정 관련 상태 const [isGroupSettingOpen, setIsGroupSettingOpen] = useState(false); // 그룹 설정 다이얼로그 const [groupByColumns, setGroupByColumns] = useState([]); // 그룹화할 컬럼 목록 @@ -370,12 +382,6 @@ export function FlowWidget({ }); setFilteredData(filtered); - console.log("🔍 검색 실행:", { - totalRows: stepData.length, - filteredRows: filtered.length, - searchValues, - hasSearchValue, - }); }, [searchValues, stepData]); // stepData와 searchValues가 변경될 때마다 실행 // 선택된 스텝의 데이터를 다시 로드하는 함수 @@ -459,7 +465,6 @@ export function FlowWidget({ // 프리뷰 모드에서는 샘플 데이터만 표시 if (isPreviewMode) { - console.log("🔒 프리뷰 모드: 플로우 데이터 로드 차단 - 샘플 데이터 표시"); setFlowData({ id: flowId || 0, flowName: flowName || "샘플 플로우", @@ -636,16 +641,9 @@ export function FlowWidget({ try { // 컬럼 라벨 조회 const labelsResponse = await getStepColumnLabels(flowId!, stepId); - console.log("🏷️ 컬럼 라벨 조회 결과:", { - stepId, - success: labelsResponse.success, - labelsCount: labelsResponse.data ? Object.keys(labelsResponse.data).length : 0, - labels: labelsResponse.data, - }); if (labelsResponse.success && labelsResponse.data) { setColumnLabels(labelsResponse.data); } else { - console.warn("⚠️ 컬럼 라벨 조회 실패 또는 데이터 없음:", labelsResponse); setColumnLabels({}); } @@ -677,6 +675,61 @@ export function FlowWidget({ } }; + // 카테고리 타입 컬럼의 값 매핑 로드 + useEffect(() => { + const loadCategoryMappings = async () => { + if (!selectedStepId || !steps.length) return; + + try { + const currentStep = steps.find((s) => s.id === selectedStepId); + const tableName = currentStep?.stepConfig?.tableName; + + if (!tableName) return; + + // 테이블 컬럼 정보 조회하여 카테고리 타입 찾기 + const apiClient = (await import("@/lib/api/client")).apiClient; + const columnsResponse = await apiClient.get(`/table-management/tables/${tableName}/columns`); + + if (!columnsResponse.data?.success) return; + + const columns = columnsResponse.data.data?.columns || []; + const categoryColumns = columns.filter((col: any) => + (col.inputType === "category" || col.input_type === "category") + ); + + if (categoryColumns.length === 0) return; + + // 각 카테고리 컬럼의 값 목록 조회 + const mappings: Record> = {}; + + for (const col of categoryColumns) { + const columnName = col.columnName || col.column_name; + try { + const response = await apiClient.get( + `/table-categories/${tableName}/${columnName}/values` + ); + + if (response.data.success && response.data.data) { + const mapping: Record = {}; + response.data.data.forEach((item: any) => { + mapping[item.valueCode] = item.valueLabel; + }); + mappings[columnName] = mapping; + } + } catch (error) { + // 카테고리 값 로드 실패 시 무시 + } + } + + setCategoryMappings(mappings); + } catch (error) { + console.error("FlowWidget 카테고리 매핑 로드 실패:", error); + } + }; + + loadCategoryMappings(); + }, [selectedStepId, steps]); + // 체크박스 토글 const toggleRowSelection = (rowIndex: number) => { // 프리뷰 모드에서는 행 선택 차단 @@ -694,13 +747,6 @@ export function FlowWidget({ // 선택된 데이터를 상위로 전달 const selectedData = Array.from(newSelected).map((index) => stepData[index]); - console.log("🌊 FlowWidget - 체크박스 토글, 상위로 전달:", { - rowIndex, - newSelectedSize: newSelected.size, - selectedData, - selectedStepId, - hasCallback: !!onSelectedDataChange, - }); onSelectedDataChange?.(selectedData, selectedStepId); }; @@ -1017,7 +1063,7 @@ export function FlowWidget({ {stepDataColumns.map((col) => (
{columnLabels[col] || col}: - {formatValue(row[col])} + {formatValue(row[col], col)}
))} @@ -1095,7 +1141,7 @@ export function FlowWidget({ )} {stepDataColumns.map((col) => ( - {formatValue(row[col])} + {formatValue(row[col], col)} ))} @@ -1125,7 +1171,7 @@ export function FlowWidget({ )} {stepDataColumns.map((col) => ( - {formatValue(row[col])} + {formatValue(row[col], col)} ))} diff --git a/frontend/components/screen/widgets/types/ButtonWidget.tsx b/frontend/components/screen/widgets/types/ButtonWidget.tsx index 6bc9e1ff..808cf5d0 100644 --- a/frontend/components/screen/widgets/types/ButtonWidget.tsx +++ b/frontend/components/screen/widgets/types/ButtonWidget.tsx @@ -30,7 +30,7 @@ export const ButtonWidget: React.FC = ({ type="button" onClick={handleClick} disabled={disabled || readonly} - className={`rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors duration-200 hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-300 disabled:text-gray-500 ${className || ""} `} + className={`flex items-center justify-center rounded-md bg-blue-600 px-4 text-sm font-medium text-white transition-colors duration-200 hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-300 disabled:text-gray-500 ${className || ""} `} style={{ ...style, width: "100%", diff --git a/frontend/components/table-category/CategoryColumnList.tsx b/frontend/components/table-category/CategoryColumnList.tsx new file mode 100644 index 00000000..7b7ebd32 --- /dev/null +++ b/frontend/components/table-category/CategoryColumnList.tsx @@ -0,0 +1,179 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { apiClient } from "@/lib/api/client"; +import { getCategoryValues } from "@/lib/api/tableCategoryValue"; +import { FolderTree, Loader2 } from "lucide-react"; + +interface CategoryColumn { + columnName: string; + columnLabel: string; + inputType: string; + valueCount?: number; +} + +interface CategoryColumnListProps { + tableName: string; + selectedColumn: string | null; + onColumnSelect: (columnName: string, columnLabel: string) => void; +} + +/** + * 카테고리 컬럼 목록 (좌측 패널) + * - 현재 테이블에서 input_type='category'인 컬럼들을 표시 (테이블 스코프) + */ +export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect }: CategoryColumnListProps) { + const [columns, setColumns] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + loadCategoryColumns(); + }, [tableName]); + + const loadCategoryColumns = async () => { + setIsLoading(true); + try { + // table_type_columns에서 input_type = 'category'인 컬럼 조회 + const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); + + console.log("🔍 테이블 컬럼 API 응답:", { + tableName, + response: response.data, + type: typeof response.data, + isArray: Array.isArray(response.data), + }); + + // API 응답 구조 파싱 (여러 가능성 대응) + let allColumns: any[] = []; + + if (Array.isArray(response.data)) { + // response.data가 직접 배열인 경우 + allColumns = response.data; + } else if (response.data.data && response.data.data.columns && Array.isArray(response.data.data.columns)) { + // response.data.data.columns가 배열인 경우 (table-management API) + allColumns = response.data.data.columns; + } else if (response.data.data && Array.isArray(response.data.data)) { + // response.data.data가 배열인 경우 + allColumns = response.data.data; + } else if (response.data.columns && Array.isArray(response.data.columns)) { + // response.data.columns가 배열인 경우 + allColumns = response.data.columns; + } else { + console.warn("⚠️ 예상하지 못한 API 응답 구조:", response.data); + allColumns = []; + } + + console.log("🔍 파싱된 컬럼 목록:", { + totalColumns: allColumns.length, + sample: allColumns.slice(0, 3), + }); + + // category 타입만 필터링 + const categoryColumns = allColumns.filter( + (col: any) => col.inputType === "category" || col.input_type === "category", + ); + + console.log("✅ 카테고리 컬럼:", { + count: categoryColumns.length, + columns: categoryColumns.map((c: any) => ({ + name: c.columnName || c.column_name, + type: c.inputType || c.input_type, + })), + }); + + const columnsWithCount = await Promise.all( + categoryColumns.map(async (col: any) => { + const colName = col.columnName || col.column_name; + const colLabel = col.columnLabel || col.column_label || col.displayName || colName; + + // 각 컬럼의 값 개수 가져오기 + let valueCount = 0; + try { + const valuesResult = await getCategoryValues(tableName, colName, false); + if (valuesResult.success && valuesResult.data) { + valueCount = valuesResult.data.length; + } + } catch (error) { + console.error(`항목 개수 조회 실패 (${colName}):`, error); + } + + return { + columnName: colName, + columnLabel: colLabel, + inputType: col.inputType || col.input_type, + valueCount, + }; + }), + ); + + setColumns(columnsWithCount); + + // 첫 번째 컬럼 자동 선택 + if (columnsWithCount.length > 0 && !selectedColumn) { + const firstCol = columnsWithCount[0]; + onColumnSelect(firstCol.columnName, firstCol.columnLabel); + } + } catch (error) { + console.error("❌ 카테고리 컬럼 조회 실패:", error); + setColumns([]); + } finally { + setIsLoading(false); + } + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (columns.length === 0) { + return ( +
+

카테고리 컬럼

+
+ +

카테고리 타입 컬럼이 없습니다

+

+ 테이블 타입 관리에서 컬럼의 입력 타입을 '카테고리'로 설정하세요 +

+
+
+ ); + } + + return ( +
+
+

카테고리 컬럼

+

관리할 카테고리 컬럼을 선택하세요

+
+ +
+ {columns.map((column) => ( +
onColumnSelect(column.columnName, column.columnLabel || column.columnName)} + className={`cursor-pointer rounded-lg border px-4 py-2 transition-all ${ + selectedColumn === column.columnName ? "border-primary bg-primary/10 shadow-sm" : "hover:bg-muted/50" + }`} + > +
+ +
+

{column.columnLabel || column.columnName}

+
+ + {column.valueCount !== undefined ? `${column.valueCount}개` : "..."} + +
+
+ ))} +
+
+ ); +} diff --git a/frontend/components/table-category/CategoryValueAddDialog.tsx b/frontend/components/table-category/CategoryValueAddDialog.tsx new file mode 100644 index 00000000..99aa02b1 --- /dev/null +++ b/frontend/components/table-category/CategoryValueAddDialog.tsx @@ -0,0 +1,123 @@ +"use client"; + +import React, { useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { TableCategoryValue } from "@/types/tableCategoryValue"; + +interface CategoryValueAddDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onAdd: (value: TableCategoryValue) => void; + columnLabel: string; +} + +export const CategoryValueAddDialog: React.FC< + CategoryValueAddDialogProps +> = ({ open, onOpenChange, onAdd, columnLabel }) => { + const [valueLabel, setValueLabel] = useState(""); + const [description, setDescription] = useState(""); + + // 라벨에서 코드 자동 생성 + const generateCode = (label: string): string => { + // 한글을 영문으로 변환하거나, 영문/숫자만 추출하여 대문자로 + const cleaned = label + .replace(/[^a-zA-Z0-9가-힣\s]/g, "") // 특수문자 제거 + .trim() + .toUpperCase(); + + // 영문이 있으면 영문만, 없으면 타임스탬프 기반 + const englishOnly = cleaned.replace(/[^A-Z0-9\s]/g, "").replace(/\s+/g, "_"); + + if (englishOnly.length > 0) { + return englishOnly.substring(0, 20); // 최대 20자 + } + + // 영문이 없으면 CATEGORY_TIMESTAMP 형식 + return `CATEGORY_${Date.now().toString().slice(-6)}`; + }; + + const handleSubmit = () => { + if (!valueLabel.trim()) { + return; + } + + const valueCode = generateCode(valueLabel); + + onAdd({ + tableName: "", + columnName: "", + valueCode, + valueLabel: valueLabel.trim(), + description: description.trim(), + color: "#3b82f6", + isDefault: false, + }); + + // 초기화 + setValueLabel(""); + setDescription(""); + }; + + return ( + + + + + 새 카테고리 값 추가 + + + {columnLabel}에 새로운 값을 추가합니다 + + + +
+ setValueLabel(e.target.value)} + className="h-8 text-xs sm:h-10 sm:text-sm" + autoFocus + /> + +