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/fileController.ts b/backend-node/src/controllers/fileController.ts index d138bce3..b1e31e3b 100644 --- a/backend-node/src/controllers/fileController.ts +++ b/backend-node/src/controllers/fileController.ts @@ -232,7 +232,11 @@ export const uploadFiles = async ( // 자동 연결 로직 - target_objid 자동 생성 let finalTargetObjid = targetObjid; - if (autoLink === "true" && linkedTable && recordId) { + + // 🔑 템플릿 파일(screen_files:)이나 temp_ 파일은 autoLink 무시 + const isTemplateFile = targetObjid && (targetObjid.startsWith('screen_files:') || targetObjid.startsWith('temp_')); + + if (!isTemplateFile && autoLink === "true" && linkedTable && recordId) { // 가상 파일 컬럼의 경우 컬럼명도 포함한 target_objid 생성 if (isVirtualFileColumn === "true" && columnName) { finalTargetObjid = `${linkedTable}:${recordId}:${columnName}`; @@ -363,6 +367,38 @@ export const deleteFile = async ( const { objid } = req.params; const { writer = "system" } = req.body; + // 🔒 멀티테넌시: 현재 사용자의 회사 코드 + const companyCode = req.user?.companyCode; + + // 파일 정보 조회 + const fileRecord = await queryOne( + `SELECT * FROM attach_file_info WHERE objid = $1`, + [parseInt(objid)] + ); + + if (!fileRecord) { + res.status(404).json({ + success: false, + message: "파일을 찾을 수 없습니다.", + }); + return; + } + + // 🔒 멀티테넌시: 회사 코드 일치 여부 확인 (최고 관리자 제외) + if (companyCode !== "*" && fileRecord.company_code !== companyCode) { + console.warn("⚠️ 다른 회사 파일 삭제 시도:", { + userId: req.user?.userId, + userCompanyCode: companyCode, + fileCompanyCode: fileRecord.company_code, + objid, + }); + res.status(403).json({ + success: false, + message: "접근 권한이 없습니다.", + }); + return; + } + // 파일 상태를 DELETED로 변경 (논리적 삭제) await query( "UPDATE attach_file_info SET status = $1 WHERE objid = $2", @@ -510,6 +546,9 @@ export const getComponentFiles = async ( const { screenId, componentId, tableName, recordId, columnName } = req.query; + // 🔒 멀티테넌시: 현재 사용자의 회사 코드 가져오기 + const companyCode = req.user?.companyCode; + console.log("📂 [getComponentFiles] API 호출:", { screenId, componentId, @@ -517,6 +556,7 @@ export const getComponentFiles = async ( recordId, columnName, user: req.user?.userId, + companyCode, // 🔒 멀티테넌시 로그 }); if (!screenId || !componentId) { @@ -534,32 +574,16 @@ export const getComponentFiles = async ( templateTargetObjid, }); - // 모든 파일 조회해서 실제 저장된 target_objid 패턴 확인 - const allFiles = await query( - `SELECT target_objid, real_file_name, regdate - FROM attach_file_info - WHERE status = $1 - ORDER BY regdate DESC - LIMIT 10`, - ["ACTIVE"] - ); - console.log( - "🗂️ [getComponentFiles] 최근 저장된 파일들의 target_objid:", - allFiles.map((f) => ({ - target_objid: f.target_objid, - name: f.real_file_name, - })) - ); - + // 🔒 멀티테넌시: 회사별 필터링 추가 const templateFiles = await query( `SELECT * FROM attach_file_info - WHERE target_objid = $1 AND status = $2 + WHERE target_objid = $1 AND status = $2 AND company_code = $3 ORDER BY regdate DESC`, - [templateTargetObjid, "ACTIVE"] + [templateTargetObjid, "ACTIVE", companyCode] ); console.log( - "📁 [getComponentFiles] 템플릿 파일 결과:", + "📁 [getComponentFiles] 템플릿 파일 결과 (회사별 필터링):", templateFiles.length ); @@ -567,11 +591,12 @@ export const getComponentFiles = async ( let dataFiles: any[] = []; if (tableName && recordId && columnName) { const dataTargetObjid = `${tableName}:${recordId}:${columnName}`; + // 🔒 멀티테넌시: 회사별 필터링 추가 dataFiles = await query( `SELECT * FROM attach_file_info - WHERE target_objid = $1 AND status = $2 + WHERE target_objid = $1 AND status = $2 AND company_code = $3 ORDER BY regdate DESC`, - [dataTargetObjid, "ACTIVE"] + [dataTargetObjid, "ACTIVE", companyCode] ); } @@ -591,6 +616,7 @@ export const getComponentFiles = async ( regdate: file.regdate?.toISOString(), status: file.status, isTemplate, // 템플릿 파일 여부 표시 + isRepresentative: file.is_representative || false, // 대표 파일 여부 }); const formattedTemplateFiles = templateFiles.map((file) => @@ -643,6 +669,9 @@ export const previewFile = async ( const { objid } = req.params; const { serverFilename } = req.query; + // 🔒 멀티테넌시: 현재 사용자의 회사 코드 + const companyCode = req.user?.companyCode; + const fileRecord = await queryOne( "SELECT * FROM attach_file_info WHERE objid = $1 LIMIT 1", [parseInt(objid)] @@ -656,13 +685,28 @@ export const previewFile = async ( return; } + // 🔒 멀티테넌시: 회사 코드 일치 여부 확인 (최고 관리자 제외) + if (companyCode !== "*" && fileRecord.company_code !== companyCode) { + console.warn("⚠️ 다른 회사 파일 접근 시도:", { + userId: req.user?.userId, + userCompanyCode: companyCode, + fileCompanyCode: fileRecord.company_code, + objid, + }); + res.status(403).json({ + success: false, + message: "접근 권한이 없습니다.", + }); + return; + } + // 파일 경로에서 회사코드와 날짜 폴더 추출 const filePathParts = fileRecord.file_path!.split("/"); - let companyCode = filePathParts[2] || "DEFAULT"; + let fileCompanyCode = filePathParts[2] || "DEFAULT"; // company_* 처리 (실제 회사 코드로 변환) - if (companyCode === "company_*") { - companyCode = "company_*"; // 실제 디렉토리명 유지 + if (fileCompanyCode === "company_*") { + fileCompanyCode = "company_*"; // 실제 디렉토리명 유지 } const fileName = fileRecord.saved_file_name!; @@ -674,7 +718,7 @@ export const previewFile = async ( } const companyUploadDir = getCompanyUploadDir( - companyCode, + fileCompanyCode, dateFolder || undefined ); const filePath = path.join(companyUploadDir, fileName); @@ -724,8 +768,9 @@ export const previewFile = async ( mimeType = "application/octet-stream"; } - // CORS 헤더 설정 (더 포괄적으로) - res.setHeader("Access-Control-Allow-Origin", "*"); + // CORS 헤더 설정 (credentials 모드에서는 구체적인 origin 필요) + const origin = req.headers.origin || "http://localhost:9771"; + res.setHeader("Access-Control-Allow-Origin", origin); res.setHeader( "Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS" @@ -762,6 +807,9 @@ export const downloadFile = async ( try { const { objid } = req.params; + // 🔒 멀티테넌시: 현재 사용자의 회사 코드 + const companyCode = req.user?.companyCode; + const fileRecord = await queryOne( `SELECT * FROM attach_file_info WHERE objid = $1`, [parseInt(objid)] @@ -775,13 +823,28 @@ export const downloadFile = async ( return; } + // 🔒 멀티테넌시: 회사 코드 일치 여부 확인 (최고 관리자 제외) + if (companyCode !== "*" && fileRecord.company_code !== companyCode) { + console.warn("⚠️ 다른 회사 파일 다운로드 시도:", { + userId: req.user?.userId, + userCompanyCode: companyCode, + fileCompanyCode: fileRecord.company_code, + objid, + }); + res.status(403).json({ + success: false, + message: "접근 권한이 없습니다.", + }); + return; + } + // 파일 경로에서 회사코드와 날짜 폴더 추출 (예: /uploads/company_*/2025/09/05/timestamp_filename.ext) const filePathParts = fileRecord.file_path!.split("/"); - let companyCode = filePathParts[2] || "DEFAULT"; // /uploads/company_*/2025/09/05/filename.ext에서 company_* 추출 + let fileCompanyCode = filePathParts[2] || "DEFAULT"; // /uploads/company_*/2025/09/05/filename.ext에서 company_* 추출 // company_* 처리 (실제 회사 코드로 변환) - if (companyCode === "company_*") { - companyCode = "company_*"; // 실제 디렉토리명 유지 + if (fileCompanyCode === "company_*") { + fileCompanyCode = "company_*"; // 실제 디렉토리명 유지 } const fileName = fileRecord.saved_file_name!; @@ -794,7 +857,7 @@ export const downloadFile = async ( } const companyUploadDir = getCompanyUploadDir( - companyCode, + fileCompanyCode, dateFolder || undefined ); const filePath = path.join(companyUploadDir, fileName); @@ -1026,5 +1089,68 @@ export const getFileByToken = async (req: Request, res: Response) => { } }; +/** + * 대표 파일 설정 + */ +export const setRepresentativeFile = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { objid } = req.params; + const companyCode = req.user?.companyCode; + + // 파일 존재 여부 및 권한 확인 + const fileRecord = await queryOne( + `SELECT * FROM attach_file_info WHERE objid = $1 AND status = $2`, + [parseInt(objid), "ACTIVE"] + ); + + if (!fileRecord) { + res.status(404).json({ + success: false, + message: "파일을 찾을 수 없습니다.", + }); + return; + } + + // 멀티테넌시: 회사 코드 확인 + if (companyCode !== "*" && fileRecord.company_code !== companyCode) { + res.status(403).json({ + success: false, + message: "접근 권한이 없습니다.", + }); + return; + } + + // 같은 target_objid의 다른 파일들의 is_representative를 false로 설정 + await query( + `UPDATE attach_file_info + SET is_representative = false + WHERE target_objid = $1 AND objid != $2`, + [fileRecord.target_objid, parseInt(objid)] + ); + + // 선택한 파일을 대표 파일로 설정 + await query( + `UPDATE attach_file_info + SET is_representative = true + WHERE objid = $1`, + [parseInt(objid)] + ); + + res.json({ + success: true, + message: "대표 파일이 설정되었습니다.", + }); + } catch (error) { + console.error("대표 파일 설정 오류:", error); + res.status(500).json({ + success: false, + message: "대표 파일 설정 중 오류가 발생했습니다.", + }); + } +}; + // Multer 미들웨어 export export const uploadMiddleware = upload.array("files", 10); // 최대 10개 파일 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/fileRoutes.ts b/backend-node/src/routes/fileRoutes.ts index e62d479a..64f02d14 100644 --- a/backend-node/src/routes/fileRoutes.ts +++ b/backend-node/src/routes/fileRoutes.ts @@ -10,6 +10,7 @@ import { uploadMiddleware, generateTempToken, getFileByToken, + setRepresentativeFile, } from "../controllers/fileController"; import { authenticateToken } from "../middleware/authMiddleware"; @@ -84,4 +85,11 @@ router.get("/download/:objid", downloadFile); */ router.post("/temp-token/:objid", generateTempToken); +/** + * @route PUT /api/files/representative/:objid + * @desc 대표 파일 설정 + * @access Private + */ +router.put("/representative/:objid", setRepresentativeFile); + export default router; 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/app/(main)/main/page.tsx b/frontend/app/(main)/main/page.tsx index 3784fb06..45f75f67 100644 --- a/frontend/app/(main)/main/page.tsx +++ b/frontend/app/(main)/main/page.tsx @@ -15,7 +15,7 @@ export default function MainPage() {
-

Vexolor에 오신 것을 환영합니다!

+

Vexplor에 오신 것을 환영합니다!

제품 수명 주기 관리 시스템을 통해 효율적인 업무를 시작하세요.

Node.js 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/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 90eec9ca..09ddfe5c 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -26,80 +26,108 @@ import { // 위젯 동적 임포트 const WeatherWidget = dynamic(() => import("@/components/dashboard/widgets/WeatherWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); const ExchangeWidget = dynamic(() => import("@/components/dashboard/widgets/ExchangeWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); const CalculatorWidget = dynamic(() => import("@/components/dashboard/widgets/CalculatorWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); const VehicleStatusWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleStatusWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); const VehicleListWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleListWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); const VehicleMapOnlyWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleMapOnlyWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); // 범용 지도 위젯 (차량, 창고, 고객 등 모든 위치 위젯 통합) const MapSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/MapSummaryWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); // 🧪 테스트용 지도 위젯 (REST API 지원) const MapTestWidget = dynamic(() => import("@/components/dashboard/widgets/MapTestWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); // 🧪 테스트용 지도 위젯 V2 (다중 데이터 소스) const MapTestWidgetV2 = dynamic(() => import("@/components/dashboard/widgets/MapTestWidgetV2"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); // 🧪 테스트용 차트 위젯 (다중 데이터 소스) const ChartTestWidget = dynamic(() => import("@/components/dashboard/widgets/ChartTestWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); const ListTestWidget = dynamic( () => import("@/components/dashboard/widgets/ListTestWidget").then((mod) => ({ default: mod.ListTestWidget })), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }, ); const CustomMetricTestWidget = dynamic(() => import("@/components/dashboard/widgets/CustomMetricTestWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); const RiskAlertTestWidget = dynamic(() => import("@/components/dashboard/widgets/RiskAlertTestWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); // 범용 상태 요약 위젯 (차량, 배송 등 모든 상태 위젯 통합) const StatusSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/StatusSummaryWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); // 범용 목록 위젯 (차량, 기사, 제품 등 모든 목록 위젯 통합) - 다른 분 작업 중, 임시 주석 @@ -128,22 +156,30 @@ const StatusSummaryWidget = dynamic(() => import("@/components/dashboard/widgets const RiskAlertWidget = dynamic(() => import("@/components/dashboard/widgets/RiskAlertWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); const TaskWidget = dynamic(() => import("@/components/dashboard/widgets/TaskWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); const BookingAlertWidget = dynamic(() => import("@/components/dashboard/widgets/BookingAlertWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); const DocumentWidget = dynamic(() => import("@/components/dashboard/widgets/DocumentWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); // 시계 위젯 임포트 @@ -160,25 +196,33 @@ import { Button } from "@/components/ui/button"; // 야드 관리 3D 위젯 const YardManagement3DWidget = dynamic(() => import("./widgets/YardManagement3DWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); // 작업 이력 위젯 const WorkHistoryWidget = dynamic(() => import("@/components/dashboard/widgets/WorkHistoryWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); // 커스텀 통계 카드 위젯 const CustomStatsWidget = dynamic(() => import("@/components/dashboard/widgets/CustomStatsWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); // 사용자 커스텀 카드 위젯 const CustomMetricWidget = dynamic(() => import("@/components/dashboard/widgets/CustomMetricWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); interface CanvasElementProps { @@ -758,7 +802,7 @@ export function CanvasElement({
{element.customTitle || element.title} + {element.customTitle || element.title} ) : null}
@@ -817,7 +861,7 @@ export function CanvasElement({
- - - )) )} @@ -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/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 64b48448..c8e53cdd 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -677,7 +677,7 @@ export const InteractiveScreenViewerDynamic: 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 329e09bb..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, @@ -269,9 +269,9 @@ export const RealtimePreviewDynamic: React.FC = ({ return `${actualHeight}px`; } - // 1순위: style.height가 있으면 우선 사용 + // 1순위: style.height가 있으면 우선 사용 (문자열 그대로 또는 숫자+px) if (componentStyle?.height) { - return componentStyle.height; + return typeof componentStyle.height === "number" ? `${componentStyle.height}px` : componentStyle.height; } // 2순위: size.height (픽셀) @@ -279,9 +279,24 @@ export const RealtimePreviewDynamic: React.FC = ({ return `${Math.max(size?.height || 200, 200)}px`; } - return `${size?.height || 40}px`; + // size.height가 있으면 그대로 사용, 없으면 최소 10px + return `${size?.height || 10}px`; }; + // 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 baseStyle = { left: `${position.x}px`, top: `${position.y}px`, @@ -295,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, @@ -324,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)}%`, }, }); } @@ -375,7 +390,7 @@ export const RealtimePreviewDynamic: React.FC = ({ style={{ width: "100%", maxWidth: "100%" }} > = ({ { 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/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 + /> + +