diff --git a/.gitignore b/.gitignore index 08276481..5e66bd12 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,10 @@ dist/ build/ build/Release +# Gradle +.gradle/ +**/backend/.gradle/ + # Cache .npm .eslintcache diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 6f0997a9..dc8cf064 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -108,6 +108,46 @@ export async function getUserMenus( } } +/** + * POP 메뉴 목록 조회 + * [POP] 태그가 있는 L1 메뉴의 하위 active 메뉴를 반환 + */ +export async function getPopMenus( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const userCompanyCode = req.user?.companyCode || "ILSHIN"; + const userType = req.user?.userType; + + const result = await AdminService.getPopMenuList({ + userCompanyCode, + userType, + }); + + const response: ApiResponse = { + success: true, + message: "POP 메뉴 목록 조회 성공", + data: result, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("POP 메뉴 목록 조회 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "POP 메뉴 목록 조회 중 오류가 발생했습니다.", + error: { + code: "POP_MENU_LIST_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + /** * 메뉴 정보 조회 */ @@ -1814,7 +1854,7 @@ export async function toggleMenuStatus( // 현재 상태 및 회사 코드 조회 const currentMenu = await queryOne( - `SELECT objid, status, company_code FROM menu_info WHERE objid = $1`, + `SELECT objid, status, company_code, menu_name_kor FROM menu_info WHERE objid = $1`, [Number(menuId)] ); diff --git a/backend-node/src/controllers/auditLogController.ts b/backend-node/src/controllers/auditLogController.ts index 828529bd..cd59a435 100644 --- a/backend-node/src/controllers/auditLogController.ts +++ b/backend-node/src/controllers/auditLogController.ts @@ -1,6 +1,6 @@ import { Response } from "express"; import { AuthenticatedRequest } from "../middleware/authMiddleware"; -import { auditLogService } from "../services/auditLogService"; +import { auditLogService, getClientIp, AuditAction, AuditResourceType } from "../services/auditLogService"; import { query } from "../database/db"; import logger from "../utils/logger"; @@ -137,3 +137,40 @@ export const getAuditLogUsers = async ( }); } }; + +/** + * 프론트엔드에서 직접 감사 로그 기록 (그룹 복제 등 프론트 오케스트레이션 작업용) + */ +export const createAuditLog = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { action, resourceType, resourceId, resourceName, tableName, summary, changes } = req.body; + + if (!action || !resourceType) { + res.status(400).json({ success: false, message: "action, resourceType은 필수입니다." }); + return; + } + + await auditLogService.log({ + companyCode: req.user?.companyCode || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: action as AuditAction, + resourceType: resourceType as AuditResourceType, + resourceId: resourceId || undefined, + resourceName: resourceName || undefined, + tableName: tableName || undefined, + summary: summary || undefined, + changes: changes || undefined, + ipAddress: getClientIp(req), + requestPath: req.originalUrl, + }); + + res.json({ success: true }); + } catch (error: any) { + logger.error("감사 로그 기록 실패", { error: error.message }); + res.status(500).json({ success: false, message: "감사 로그 기록 실패" }); + } +}; diff --git a/backend-node/src/controllers/authController.ts b/backend-node/src/controllers/authController.ts index 2bb72876..809513b6 100644 --- a/backend-node/src/controllers/authController.ts +++ b/backend-node/src/controllers/authController.ts @@ -51,29 +51,24 @@ export class AuthController { logger.debug(`로그인 사용자 정보: ${userInfo.userId} (${userInfo.companyCode})`); + // 메뉴 조회를 위한 공통 파라미터 + const { AdminService } = await import("../services/adminService"); + const paramMap = { + userId: loginResult.userInfo.userId, + userCompanyCode: loginResult.userInfo.companyCode || "ILSHIN", + userType: loginResult.userInfo.userType, + userLang: "ko", + }; + // 사용자의 첫 번째 접근 가능한 메뉴 조회 let firstMenuPath: string | null = null; try { - const { AdminService } = await import("../services/adminService"); - const paramMap = { - userId: loginResult.userInfo.userId, - userCompanyCode: loginResult.userInfo.companyCode || "ILSHIN", - userType: loginResult.userInfo.userType, - userLang: "ko", - }; - const menuList = await AdminService.getUserMenuList(paramMap); logger.debug(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`); - // 접근 가능한 첫 번째 메뉴 찾기 - // 조건: - // 1. LEV (레벨)이 2 이상 (최상위 폴더 제외) - // 2. MENU_URL이 있고 비어있지 않음 - // 3. 이미 PATH, SEQ로 정렬되어 있으므로 첫 번째로 찾은 것이 첫 번째 메뉴 const firstMenu = menuList.find((menu: any) => { const level = menu.lev || menu.level; const url = menu.menu_url || menu.url; - return level >= 2 && url && url.trim() !== "" && url !== "#"; }); @@ -94,6 +89,22 @@ export class AuthController { useType: "접속", }).catch(() => {}); + // POP 랜딩 경로 조회 + let popLandingPath: string | null = null; + try { + const popResult = await AdminService.getPopMenuList(paramMap); + if (popResult.landingMenu?.menu_url) { + popLandingPath = popResult.landingMenu.menu_url; + } else if (popResult.childMenus.length === 1) { + popLandingPath = popResult.childMenus[0].menu_url; + } else if (popResult.childMenus.length > 1) { + popLandingPath = "/pop"; + } + logger.debug(`POP 랜딩 경로: ${popLandingPath}`); + } catch (popError) { + logger.warn("POP 메뉴 조회 중 오류 (무시):", popError); + } + res.status(200).json({ success: true, message: "로그인 성공", @@ -101,6 +112,7 @@ export class AuthController { userInfo, token: loginResult.token, firstMenuPath, + popLandingPath, }, }); } else { diff --git a/backend-node/src/controllers/categoryTreeController.ts b/backend-node/src/controllers/categoryTreeController.ts index 54b93ee4..98d74fa4 100644 --- a/backend-node/src/controllers/categoryTreeController.ts +++ b/backend-node/src/controllers/categoryTreeController.ts @@ -6,6 +6,7 @@ import { Router, Request, Response } from "express"; import { categoryTreeService, CreateCategoryValueInput, UpdateCategoryValueInput } from "../services/categoryTreeService"; import { logger } from "../utils/logger"; import { authenticateToken } from "../middleware/authMiddleware"; +import { auditLogService, getClientIp } from "../services/auditLogService"; const router = Router(); @@ -16,6 +17,7 @@ router.use(authenticateToken); interface AuthenticatedRequest extends Request { user?: { userId: string; + userName: string; companyCode: string; }; } @@ -157,6 +159,21 @@ router.post("/test/value", async (req: AuthenticatedRequest, res: Response) => { const value = await categoryTreeService.createCategoryValue(companyCode, input, createdBy); + auditLogService.log({ + companyCode, + userId: req.user?.userId || "", + userName: req.user?.userName, + action: "CREATE", + resourceType: "CODE_CATEGORY", + resourceId: String(value.valueId), + resourceName: input.valueLabel, + tableName: "category_values", + summary: `카테고리 값 "${input.valueLabel}" 생성 (${input.tableName}.${input.columnName})`, + changes: { after: { tableName: input.tableName, columnName: input.columnName, valueCode: input.valueCode, valueLabel: input.valueLabel } }, + ipAddress: getClientIp(req), + requestPath: req.originalUrl, + }); + res.json({ success: true, data: value, @@ -182,6 +199,7 @@ router.put("/test/value/:valueId", async (req: AuthenticatedRequest, res: Respon const companyCode = req.user?.companyCode || "*"; const updatedBy = req.user?.userId; + const beforeValue = await categoryTreeService.getCategoryValue(companyCode, Number(valueId)); const value = await categoryTreeService.updateCategoryValue(companyCode, Number(valueId), input, updatedBy); if (!value) { @@ -191,6 +209,24 @@ router.put("/test/value/:valueId", async (req: AuthenticatedRequest, res: Respon }); } + auditLogService.log({ + companyCode, + userId: req.user?.userId || "", + userName: req.user?.userName, + action: "UPDATE", + resourceType: "CODE_CATEGORY", + resourceId: valueId, + resourceName: value.valueLabel, + tableName: "category_values", + summary: `카테고리 값 "${value.valueLabel}" 수정 (${value.tableName}.${value.columnName})`, + changes: { + before: beforeValue ? { valueLabel: beforeValue.valueLabel, valueCode: beforeValue.valueCode } : undefined, + after: input, + }, + ipAddress: getClientIp(req), + requestPath: req.originalUrl, + }); + res.json({ success: true, data: value, @@ -239,6 +275,7 @@ router.delete("/test/value/:valueId", async (req: AuthenticatedRequest, res: Res const { valueId } = req.params; const companyCode = req.user?.companyCode || "*"; + const beforeValue = await categoryTreeService.getCategoryValue(companyCode, Number(valueId)); const success = await categoryTreeService.deleteCategoryValue(companyCode, Number(valueId)); if (!success) { @@ -248,6 +285,21 @@ router.delete("/test/value/:valueId", async (req: AuthenticatedRequest, res: Res }); } + auditLogService.log({ + companyCode, + userId: req.user?.userId || "", + userName: req.user?.userName, + action: "DELETE", + resourceType: "CODE_CATEGORY", + resourceId: valueId, + resourceName: beforeValue?.valueLabel || valueId, + tableName: "category_values", + summary: `카테고리 값 "${beforeValue?.valueLabel || valueId}" 삭제 (${beforeValue?.tableName || ""}.${beforeValue?.columnName || ""})`, + changes: beforeValue ? { before: { valueLabel: beforeValue.valueLabel, valueCode: beforeValue.valueCode, tableName: beforeValue.tableName, columnName: beforeValue.columnName } } : undefined, + ipAddress: getClientIp(req), + requestPath: req.originalUrl, + }); + res.json({ success: true, message: "삭제되었습니다", diff --git a/backend-node/src/controllers/commonCodeController.ts b/backend-node/src/controllers/commonCodeController.ts index a9bd0755..a67ba44e 100644 --- a/backend-node/src/controllers/commonCodeController.ts +++ b/backend-node/src/controllers/commonCodeController.ts @@ -396,6 +396,20 @@ export class CommonCodeController { companyCode ); + auditLogService.log({ + companyCode: companyCode || "", + userId: userId || "", + action: "UPDATE", + resourceType: "CODE", + resourceId: codeValue, + resourceName: codeData.codeName || codeValue, + tableName: "code_info", + summary: `코드 "${categoryCode}.${codeValue}" 수정`, + changes: { after: codeData }, + ipAddress: getClientIp(req as any), + requestPath: req.originalUrl, + }); + return res.json({ success: true, data: code, @@ -440,6 +454,19 @@ export class CommonCodeController { companyCode ); + auditLogService.log({ + companyCode: companyCode || "", + userId: req.user?.userId || "", + action: "DELETE", + resourceType: "CODE", + resourceId: codeValue, + tableName: "code_info", + summary: `코드 "${categoryCode}.${codeValue}" 삭제`, + changes: { before: { categoryCode, codeValue } }, + ipAddress: getClientIp(req as any), + requestPath: req.originalUrl, + }); + return res.json({ success: true, message: "코드 삭제 성공", diff --git a/backend-node/src/controllers/ddlController.ts b/backend-node/src/controllers/ddlController.ts index 631b6360..00baf75d 100644 --- a/backend-node/src/controllers/ddlController.ts +++ b/backend-node/src/controllers/ddlController.ts @@ -438,6 +438,19 @@ export class DDLController { ); if (result.success) { + auditLogService.log({ + companyCode: userCompanyCode || "", + userId, + action: "DELETE", + resourceType: "TABLE", + resourceId: tableName, + resourceName: tableName, + tableName, + summary: `테이블 "${tableName}" 삭제`, + ipAddress: getClientIp(req as any), + requestPath: req.originalUrl, + }); + res.status(200).json({ success: true, message: result.message, diff --git a/backend-node/src/controllers/numberingRuleController.ts b/backend-node/src/controllers/numberingRuleController.ts index a3887ab8..9d05a1b7 100644 --- a/backend-node/src/controllers/numberingRuleController.ts +++ b/backend-node/src/controllers/numberingRuleController.ts @@ -193,6 +193,7 @@ router.post( auditLogService.log({ companyCode, userId, + userName: req.user?.userName, action: "CREATE", resourceType: "NUMBERING_RULE", resourceId: String(newRule.ruleId), @@ -243,6 +244,7 @@ router.put( auditLogService.log({ companyCode, userId: req.user?.userId || "", + userName: req.user?.userName, action: "UPDATE", resourceType: "NUMBERING_RULE", resourceId: ruleId, @@ -285,6 +287,7 @@ router.delete( auditLogService.log({ companyCode, userId: req.user?.userId || "", + userName: req.user?.userName, action: "DELETE", resourceType: "NUMBERING_RULE", resourceId: ruleId, @@ -521,6 +524,56 @@ router.post( companyCode, userId ); + + const isUpdate = !!ruleConfig.ruleId; + + const resetPeriodLabel: Record = { + none: "초기화 안함", daily: "일별", monthly: "월별", yearly: "연별", + }; + const partTypeLabel: Record = { + sequence: "순번", number: "숫자", date: "날짜", text: "문자", category: "카테고리", reference: "참조", + }; + const partsDescription = (ruleConfig.parts || []) + .sort((a: any, b: any) => (a.order || 0) - (b.order || 0)) + .map((p: any) => { + const type = partTypeLabel[p.partType] || p.partType; + if (p.partType === "text" && p.autoConfig?.textValue) return `${type}("${p.autoConfig.textValue}")`; + if (p.partType === "sequence" && p.autoConfig?.sequenceLength) return `${type}(${p.autoConfig.sequenceLength}자리)`; + if (p.partType === "date" && p.autoConfig?.dateFormat) return `${type}(${p.autoConfig.dateFormat})`; + if (p.partType === "category") return `${type}(${p.autoConfig?.categoryKey || ""})`; + if (p.partType === "reference") return `${type}(${p.autoConfig?.referenceColumnName || ""})`; + return type; + }) + .join(` ${ruleConfig.separator || "-"} `); + + auditLogService.log({ + companyCode, + userId, + userName: req.user?.userName, + action: isUpdate ? "UPDATE" : "CREATE", + resourceType: "NUMBERING_RULE", + resourceId: String(savedRule.ruleId), + resourceName: ruleConfig.ruleName, + tableName: "numbering_rules", + summary: isUpdate + ? `채번 규칙 "${ruleConfig.ruleName}" 수정` + : `채번 규칙 "${ruleConfig.ruleName}" 생성`, + changes: { + after: { + 규칙명: ruleConfig.ruleName, + 적용테이블: ruleConfig.tableName || "(미지정)", + 적용컬럼: ruleConfig.columnName || "(미지정)", + 구분자: ruleConfig.separator || "-", + 리셋주기: resetPeriodLabel[ruleConfig.resetPeriod] || ruleConfig.resetPeriod || "초기화 안함", + 적용범위: ruleConfig.scopeType === "menu" ? "메뉴별" : "전역", + 코드구성: partsDescription || "(파트 없음)", + 파트수: (ruleConfig.parts || []).length, + }, + }, + ipAddress: getClientIp(req), + requestPath: req.originalUrl, + }); + return res.json({ success: true, data: savedRule }); } catch (error: any) { logger.error("[테스트] 채번 규칙 저장 실패", { error: error.message }); @@ -535,10 +588,25 @@ router.delete( authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const companyCode = req.user!.companyCode; + const userId = req.user!.userId; const { ruleId } = req.params; try { await numberingRuleService.deleteRuleFromTest(ruleId, companyCode); + + auditLogService.log({ + companyCode, + userId, + userName: req.user?.userName, + action: "DELETE", + resourceType: "NUMBERING_RULE", + resourceId: ruleId, + tableName: "numbering_rules", + summary: `채번 규칙(ID:${ruleId}) 삭제`, + ipAddress: getClientIp(req), + requestPath: req.originalUrl, + }); + return res.json({ success: true, message: "테스트 채번 규칙이 삭제되었습니다", diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index cb6df7c4..a232c03d 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -614,20 +614,6 @@ export const copyScreenWithModals = async ( modalScreens: modalScreens || [], }); - auditLogService.log({ - companyCode: targetCompanyCode || companyCode, - userId: userId || "", - userName: (req.user as any)?.userName || "", - action: "COPY", - resourceType: "SCREEN", - resourceId: id, - resourceName: mainScreen?.screenName, - summary: `화면 일괄 복사 (메인 1개 + 모달 ${result.modalScreens.length}개, 원본 ID:${id})`, - changes: { after: { sourceScreenId: id, targetCompanyCode, mainScreenName: mainScreen?.screenName } }, - ipAddress: getClientIp(req), - requestPath: req.originalUrl, - }); - res.json({ success: true, data: result, @@ -663,20 +649,6 @@ export const copyScreen = async ( } ); - auditLogService.log({ - companyCode, - userId: userId || "", - userName: (req.user as any)?.userName || "", - action: "COPY", - resourceType: "SCREEN", - resourceId: String(copiedScreen?.screenId || ""), - resourceName: screenName, - summary: `화면 "${screenName}" 복사 (원본 ID:${id})`, - changes: { after: { sourceScreenId: id, screenName, screenCode } }, - ipAddress: getClientIp(req), - requestPath: req.originalUrl, - }); - res.json({ success: true, data: copiedScreen, diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 0ab73e09..5c53094f 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -963,6 +963,15 @@ export async function addTableData( logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${result.insertedId}`); + const systemFields = new Set([ + "id", "created_date", "updated_date", "writer", "company_code", + "createdDate", "updatedDate", "companyCode", + ]); + const auditData: Record = {}; + for (const [k, v] of Object.entries(data)) { + if (!systemFields.has(k)) auditData[k] = v; + } + auditLogService.log({ companyCode: req.user?.companyCode || "", userId: req.user?.userId || "", @@ -973,7 +982,7 @@ export async function addTableData( resourceName: tableName, tableName, summary: `${tableName} 데이터 추가`, - changes: { after: data }, + changes: { after: auditData }, ipAddress: getClientIp(req), requestPath: req.originalUrl, }); @@ -1096,10 +1105,14 @@ export async function editTableData( return; } - // 변경된 필드만 추출 + const systemFieldsForEdit = new Set([ + "id", "created_date", "updated_date", "writer", "company_code", + "createdDate", "updatedDate", "companyCode", + ]); const changedBefore: Record = {}; const changedAfter: Record = {}; for (const key of Object.keys(updatedData)) { + if (systemFieldsForEdit.has(key)) continue; if (String(originalData[key] ?? "") !== String(updatedData[key] ?? "")) { changedBefore[key] = originalData[key]; changedAfter[key] = updatedData[key]; diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index b9964962..a0779d50 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -2,6 +2,7 @@ import { Router } from "express"; import { getAdminMenus, getUserMenus, + getPopMenus, getMenuInfo, saveMenu, // 메뉴 추가 updateMenu, // 메뉴 수정 @@ -40,6 +41,7 @@ router.use(authenticateToken); // 메뉴 관련 API router.get("/menus", getAdminMenus); router.get("/user-menus", getUserMenus); +router.get("/pop-menus", getPopMenus); router.get("/menus/:menuId", getMenuInfo); router.post("/menus", saveMenu); // 메뉴 추가 router.post("/menus/:menuObjid/copy", copyMenu); // 메뉴 복사 (NEW!) diff --git a/backend-node/src/routes/auditLogRoutes.ts b/backend-node/src/routes/auditLogRoutes.ts index 0d219018..4c6392a8 100644 --- a/backend-node/src/routes/auditLogRoutes.ts +++ b/backend-node/src/routes/auditLogRoutes.ts @@ -1,11 +1,12 @@ import { Router } from "express"; import { authenticateToken } from "../middleware/authMiddleware"; -import { getAuditLogs, getAuditLogStats, getAuditLogUsers } from "../controllers/auditLogController"; +import { getAuditLogs, getAuditLogStats, getAuditLogUsers, createAuditLog } from "../controllers/auditLogController"; const router = Router(); router.get("/", authenticateToken, getAuditLogs); router.get("/stats", authenticateToken, getAuditLogStats); router.get("/users", authenticateToken, getAuditLogUsers); +router.post("/", authenticateToken, createAuditLog); export default router; diff --git a/backend-node/src/routes/popActionRoutes.ts b/backend-node/src/routes/popActionRoutes.ts index 730572d8..d25c6bdc 100644 --- a/backend-node/src/routes/popActionRoutes.ts +++ b/backend-node/src/routes/popActionRoutes.ts @@ -17,6 +17,7 @@ interface AutoGenMappingInfo { numberingRuleId: string; targetColumn: string; showResultModal?: boolean; + shareAcrossItems?: boolean; } interface HiddenMappingInfo { @@ -182,6 +183,31 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`); } + const allAutoGen = [ + ...(fieldMapping?.autoGenMappings ?? []), + ...(cardMapping?.autoGenMappings ?? []), + ]; + + // 일괄 채번: shareAcrossItems=true인 매핑은 루프 전 1회만 채번 + const sharedCodes: Record = {}; + for (const ag of allAutoGen) { + if (!ag.shareAcrossItems) continue; + if (!ag.numberingRuleId || !ag.targetColumn) continue; + if (!isSafeIdentifier(ag.targetColumn)) continue; + try { + const code = await numberingRuleService.allocateCode( + ag.numberingRuleId, companyCode, { ...fieldValues, ...(items[0] ?? {}) }, + ); + sharedCodes[ag.targetColumn] = code; + generatedCodes.push({ targetColumn: ag.targetColumn, code, showResultModal: ag.showResultModal ?? false }); + logger.info("[pop/execute-action] 일괄 채번 완료", { + ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, code, + }); + } catch (err: any) { + logger.error("[pop/execute-action] 일괄 채번 실패", { ruleId: ag.numberingRuleId, error: err.message }); + } + } + for (const item of items) { const columns: string[] = ["company_code"]; const values: unknown[] = [companyCode]; @@ -225,26 +251,41 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp values.push(value); } - const allAutoGen = [ - ...(fieldMapping?.autoGenMappings ?? []), - ...(cardMapping?.autoGenMappings ?? []), - ]; for (const ag of allAutoGen) { if (!ag.numberingRuleId || !ag.targetColumn) continue; if (!isSafeIdentifier(ag.targetColumn)) continue; if (columns.includes(`"${ag.targetColumn}"`)) continue; - try { - const generatedCode = await numberingRuleService.allocateCode( - ag.numberingRuleId, companyCode, { ...fieldValues, ...item }, - ); + + if (ag.shareAcrossItems && sharedCodes[ag.targetColumn]) { columns.push(`"${ag.targetColumn}"`); - values.push(generatedCode); - generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false }); - } catch (err: any) { - logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message }); + values.push(sharedCodes[ag.targetColumn]); + } else if (!ag.shareAcrossItems) { + try { + const generatedCode = await numberingRuleService.allocateCode( + ag.numberingRuleId, companyCode, { ...fieldValues, ...item }, + ); + columns.push(`"${ag.targetColumn}"`); + values.push(generatedCode); + generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false }); + } catch (err: any) { + logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message }); + } } } + if (!columns.includes('"created_date"')) { + columns.push('"created_date"'); + values.push(new Date().toISOString()); + } + if (!columns.includes('"updated_date"')) { + columns.push('"updated_date"'); + values.push(new Date().toISOString()); + } + if (!columns.includes('"writer"') && userId) { + columns.push('"writer"'); + values.push(userId); + } + if (columns.length > 1) { const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); await client.query( @@ -292,8 +333,9 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp for (let i = 0; i < lookupValues.length; i++) { const item = items[i] ?? {}; const resolved = resolveStatusValue("conditional", task.fixedValue ?? "", task.conditionalValue, item); + const autoUpdated = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : ""; await client.query( - `UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" = $3`, + `UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1${autoUpdated} WHERE company_code = $2 AND "${pkColumn}" = $3`, [resolved, companyCode, lookupValues[i]], ); processedCount++; @@ -311,9 +353,10 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp const caseSql = `CASE WHEN COALESCE("${task.compareColumn}"::numeric, 0) ${op} COALESCE("${task.compareWith}"::numeric, 0) THEN $1 ELSE $2 END`; + const autoUpdatedDb = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : ""; const placeholders = lookupValues.map((_, i) => `$${i + 4}`).join(", "); await client.query( - `UPDATE "${task.targetTable}" SET "${task.targetColumn}" = ${caseSql} WHERE company_code = $3 AND "${pkColumn}" IN (${placeholders})`, + `UPDATE "${task.targetTable}" SET "${task.targetColumn}" = ${caseSql}${autoUpdatedDb} WHERE company_code = $3 AND "${pkColumn}" IN (${placeholders})`, [thenVal, elseVal, companyCode, ...lookupValues], ); processedCount += lookupValues.length; @@ -325,7 +368,14 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp if (valSource === "linked") { value = item[task.sourceField ?? ""] ?? null; } else { - value = task.fixedValue ?? ""; + const raw = task.fixedValue ?? ""; + if (raw === "__CURRENT_USER__") { + value = userId; + } else if (raw === "__CURRENT_TIME__") { + value = new Date().toISOString(); + } else { + value = raw; + } } let setSql: string; @@ -341,8 +391,9 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp setSql = `"${task.targetColumn}" = $1`; } + const autoUpdatedDate = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : ""; await client.query( - `UPDATE "${task.targetTable}" SET ${setSql} WHERE company_code = $2 AND "${pkColumn}" = $3`, + `UPDATE "${task.targetTable}" SET ${setSql}${autoUpdatedDate} WHERE company_code = $2 AND "${pkColumn}" = $3`, [value, companyCode, lookupValues[i]], ); processedCount++; @@ -448,6 +499,31 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`); } + const allAutoGen = [ + ...(fieldMapping?.autoGenMappings ?? []), + ...(cardMapping?.autoGenMappings ?? []), + ]; + + // 일괄 채번: shareAcrossItems=true인 매핑은 루프 전 1회만 채번 + const sharedCodes: Record = {}; + for (const ag of allAutoGen) { + if (!ag.shareAcrossItems) continue; + if (!ag.numberingRuleId || !ag.targetColumn) continue; + if (!isSafeIdentifier(ag.targetColumn)) continue; + try { + const code = await numberingRuleService.allocateCode( + ag.numberingRuleId, companyCode, { ...fieldValues, ...(items[0] ?? {}) }, + ); + sharedCodes[ag.targetColumn] = code; + generatedCodes.push({ targetColumn: ag.targetColumn, code, showResultModal: ag.showResultModal ?? false }); + logger.info("[pop/execute-action] 일괄 채번 완료", { + ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, code, + }); + } catch (err: any) { + logger.error("[pop/execute-action] 일괄 채번 실패", { ruleId: ag.numberingRuleId, error: err.message }); + } + } + for (const item of items) { const columns: string[] = ["company_code"]; const values: unknown[] = [companyCode]; @@ -467,7 +543,6 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp } } - // 숨은 필드 매핑 처리 (고정값 / JSON추출 / DB컬럼) const allHidden = [ ...(fieldMapping?.hiddenMappings ?? []), ...(cardMapping?.hiddenMappings ?? []), @@ -494,37 +569,44 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp values.push(value); } - // 채번 규칙 실행: field + cardList의 autoGenMappings에서 코드 발급 - const allAutoGen = [ - ...(fieldMapping?.autoGenMappings ?? []), - ...(cardMapping?.autoGenMappings ?? []), - ]; for (const ag of allAutoGen) { if (!ag.numberingRuleId || !ag.targetColumn) continue; if (!isSafeIdentifier(ag.targetColumn)) continue; if (columns.includes(`"${ag.targetColumn}"`)) continue; - try { - const generatedCode = await numberingRuleService.allocateCode( - ag.numberingRuleId, - companyCode, - { ...fieldValues, ...item }, - ); + + if (ag.shareAcrossItems && sharedCodes[ag.targetColumn]) { columns.push(`"${ag.targetColumn}"`); - values.push(generatedCode); - generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false }); - logger.info("[pop/execute-action] 채번 완료", { - ruleId: ag.numberingRuleId, - targetColumn: ag.targetColumn, - generatedCode, - }); - } catch (err: any) { - logger.error("[pop/execute-action] 채번 실패", { - ruleId: ag.numberingRuleId, - error: err.message, - }); + values.push(sharedCodes[ag.targetColumn]); + } else if (!ag.shareAcrossItems) { + try { + const generatedCode = await numberingRuleService.allocateCode( + ag.numberingRuleId, companyCode, { ...fieldValues, ...item }, + ); + columns.push(`"${ag.targetColumn}"`); + values.push(generatedCode); + generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false }); + logger.info("[pop/execute-action] 채번 완료", { + ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, generatedCode, + }); + } catch (err: any) { + logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message }); + } } } + if (!columns.includes('"created_date"')) { + columns.push('"created_date"'); + values.push(new Date().toISOString()); + } + if (!columns.includes('"updated_date"')) { + columns.push('"updated_date"'); + values.push(new Date().toISOString()); + } + if (!columns.includes('"writer"') && userId) { + columns.push('"writer"'); + values.push(userId); + } + if (columns.length > 1) { const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); const sql = `INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`; @@ -558,6 +640,19 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp values.push(fieldValues[sourceField] ?? null); } + if (!columns.includes('"created_date"')) { + columns.push('"created_date"'); + values.push(new Date().toISOString()); + } + if (!columns.includes('"updated_date"')) { + columns.push('"updated_date"'); + values.push(new Date().toISOString()); + } + if (!columns.includes('"writer"') && userId) { + columns.push('"writer"'); + values.push(userId); + } + if (columns.length > 1) { const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); const sql = `INSERT INTO "${fieldMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`; @@ -609,16 +704,18 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp } if (valueType === "fixed") { + const autoUpd = rule.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : ""; const placeholders = lookupValues.map((_, i) => `$${i + 3}`).join(", "); - const sql = `UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" IN (${placeholders})`; + const sql = `UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1${autoUpd} WHERE company_code = $2 AND "${pkColumn}" IN (${placeholders})`; await client.query(sql, [fixedValue, companyCode, ...lookupValues]); processedCount += lookupValues.length; } else { for (let i = 0; i < lookupValues.length; i++) { const item = items[i] ?? {}; const resolvedValue = resolveStatusValue(valueType, fixedValue, rule.conditionalValue, item); + const autoUpd2 = rule.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : ""; await client.query( - `UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" = $3`, + `UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1${autoUpd2} WHERE company_code = $2 AND "${pkColumn}" = $3`, [resolvedValue, companyCode, lookupValues[i]] ); processedCount++; diff --git a/backend-node/src/services/adminService.ts b/backend-node/src/services/adminService.ts index e5d0c1a0..a27fcc77 100644 --- a/backend-node/src/services/adminService.ts +++ b/backend-node/src/services/adminService.ts @@ -621,6 +621,74 @@ export class AdminService { } } + /** + * POP 메뉴 목록 조회 + * menu_name_kor에 'POP'이 포함되거나 menu_desc에 [POP] 태그가 있는 L1 메뉴의 하위 active 메뉴를 반환 + * [POP_LANDING] 태그가 있는 하위 메뉴를 landingMenu로 별도 반환 + */ + static async getPopMenuList(paramMap: any): Promise<{ parentMenu: any | null; childMenus: any[]; landingMenu: any | null }> { + try { + const { userCompanyCode, userType } = paramMap; + logger.info("AdminService.getPopMenuList 시작", { userCompanyCode, userType }); + + let queryParams: any[] = []; + let paramIndex = 1; + + let companyFilter = ""; + if (userType === "SUPER_ADMIN" && userCompanyCode === "*") { + companyFilter = `AND COMPANY_CODE = '*'`; + } else { + companyFilter = `AND COMPANY_CODE = $${paramIndex}`; + queryParams.push(userCompanyCode); + paramIndex++; + } + + // POP L1 메뉴 조회 + const parentMenus = await query( + `SELECT OBJID, MENU_NAME_KOR, MENU_URL, MENU_DESC, SEQ, COMPANY_CODE, STATUS + FROM MENU_INFO + WHERE PARENT_OBJ_ID = 0 + AND MENU_TYPE = 1 + AND ( + MENU_DESC LIKE '%[POP]%' + OR UPPER(MENU_NAME_KOR) LIKE '%POP%' + ) + ${companyFilter} + ORDER BY SEQ + LIMIT 1`, + queryParams + ); + + if (parentMenus.length === 0) { + logger.info("POP 메뉴 없음 (L1 POP 메뉴 미발견)"); + return { parentMenu: null, childMenus: [], landingMenu: null }; + } + + const parentMenu = parentMenus[0]; + + // 하위 active 메뉴 조회 (부모와 같은 company_code로 필터링) + const childMenus = await query( + `SELECT OBJID, MENU_NAME_KOR, MENU_URL, MENU_DESC, SEQ, COMPANY_CODE, STATUS + FROM MENU_INFO + WHERE PARENT_OBJ_ID = $1 + AND STATUS = 'active' + AND COMPANY_CODE = $2 + ORDER BY SEQ`, + [parentMenu.objid, parentMenu.company_code] + ); + + // [POP_LANDING] 태그가 있는 메뉴를 랜딩 화면으로 지정 + const landingMenu = childMenus.find((m: any) => m.menu_desc?.includes("[POP_LANDING]")) || null; + + logger.info(`POP 메뉴 조회 완료: 부모=${parentMenu.menu_name_kor}, 하위=${childMenus.length}개, 랜딩=${landingMenu?.menu_name_kor || '없음'}`); + + return { parentMenu, childMenus, landingMenu }; + } catch (error) { + logger.error("AdminService.getPopMenuList 오류:", error); + throw error; + } + } + /** * 메뉴 정보 조회 */ diff --git a/backend-node/src/services/auditLogService.ts b/backend-node/src/services/auditLogService.ts index 9ac3e35e..c86a71fd 100644 --- a/backend-node/src/services/auditLogService.ts +++ b/backend-node/src/services/auditLogService.ts @@ -66,6 +66,7 @@ export interface AuditLogParams { export interface AuditLogEntry { id: number; company_code: string; + company_name: string | null; user_id: string; user_name: string | null; action: string; @@ -107,6 +108,7 @@ class AuditLogService { */ async log(params: AuditLogParams): Promise { try { + logger.info(`[AuditLog] 기록 시도: ${params.resourceType} / ${params.action} / ${params.resourceName || params.resourceId || "N/A"}`); await query( `INSERT INTO system_audit_log (company_code, user_id, user_name, action, resource_type, @@ -128,8 +130,9 @@ class AuditLogService { params.requestPath || null, ] ); - } catch (error) { - logger.error("감사 로그 기록 실패 (무시됨)", { error, params }); + logger.info(`[AuditLog] 기록 성공: ${params.resourceType} / ${params.action}`); + } catch (error: any) { + logger.error(`[AuditLog] 기록 실패: ${params.resourceType} / ${params.action} - ${error?.message}`, { error, params }); } } @@ -186,40 +189,40 @@ class AuditLogService { let paramIndex = 1; if (!isSuperAdmin && filters.companyCode) { - conditions.push(`company_code = $${paramIndex++}`); + conditions.push(`sal.company_code = $${paramIndex++}`); params.push(filters.companyCode); } else if (isSuperAdmin && filters.companyCode) { - conditions.push(`company_code = $${paramIndex++}`); + conditions.push(`sal.company_code = $${paramIndex++}`); params.push(filters.companyCode); } if (filters.userId) { - conditions.push(`user_id = $${paramIndex++}`); + conditions.push(`sal.user_id = $${paramIndex++}`); params.push(filters.userId); } if (filters.resourceType) { - conditions.push(`resource_type = $${paramIndex++}`); + conditions.push(`sal.resource_type = $${paramIndex++}`); params.push(filters.resourceType); } if (filters.action) { - conditions.push(`action = $${paramIndex++}`); + conditions.push(`sal.action = $${paramIndex++}`); params.push(filters.action); } if (filters.tableName) { - conditions.push(`table_name = $${paramIndex++}`); + conditions.push(`sal.table_name = $${paramIndex++}`); params.push(filters.tableName); } if (filters.dateFrom) { - conditions.push(`created_at >= $${paramIndex++}::timestamptz`); + conditions.push(`sal.created_at >= $${paramIndex++}::timestamptz`); params.push(filters.dateFrom); } if (filters.dateTo) { - conditions.push(`created_at <= $${paramIndex++}::timestamptz`); + conditions.push(`sal.created_at <= $${paramIndex++}::timestamptz`); params.push(filters.dateTo); } if (filters.search) { conditions.push( - `(summary ILIKE $${paramIndex} OR resource_name ILIKE $${paramIndex} OR user_name ILIKE $${paramIndex})` + `(sal.summary ILIKE $${paramIndex} OR sal.resource_name ILIKE $${paramIndex} OR sal.user_name ILIKE $${paramIndex})` ); params.push(`%${filters.search}%`); paramIndex++; @@ -233,14 +236,17 @@ class AuditLogService { const offset = (page - 1) * limit; const countResult = await query<{ count: string }>( - `SELECT COUNT(*) as count FROM system_audit_log ${whereClause}`, + `SELECT COUNT(*) as count FROM system_audit_log sal ${whereClause}`, params ); const total = parseInt(countResult[0].count, 10); const data = await query( - `SELECT * FROM system_audit_log ${whereClause} - ORDER BY created_at DESC + `SELECT sal.*, ci.company_name + FROM system_audit_log sal + LEFT JOIN company_mng ci ON sal.company_code = ci.company_code + ${whereClause} + ORDER BY sal.created_at DESC LIMIT $${paramIndex++} OFFSET $${paramIndex++}`, [...params, limit, offset] ); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 0273b1fc..2ddae736 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -4504,26 +4504,30 @@ export class TableManagementService { const rawColumns = await query( `SELECT - column_name as "columnName", - column_name as "displayName", - data_type as "dataType", - udt_name as "dbType", - is_nullable as "isNullable", - column_default as "defaultValue", - character_maximum_length as "maxLength", - numeric_precision as "numericPrecision", - numeric_scale as "numericScale", + c.column_name as "columnName", + c.column_name as "displayName", + c.data_type as "dataType", + c.udt_name as "dbType", + c.is_nullable as "isNullable", + c.column_default as "defaultValue", + c.character_maximum_length as "maxLength", + c.numeric_precision as "numericPrecision", + c.numeric_scale as "numericScale", CASE - WHEN column_name IN ( - SELECT column_name FROM information_schema.key_column_usage - WHERE table_name = $1 AND constraint_name LIKE '%_pkey' + WHEN c.column_name IN ( + SELECT kcu.column_name FROM information_schema.key_column_usage kcu + WHERE kcu.table_name = $1 AND kcu.constraint_name LIKE '%_pkey' ) THEN true ELSE false - END as "isPrimaryKey" - FROM information_schema.columns - WHERE table_name = $1 - AND table_schema = 'public' - ORDER BY ordinal_position`, + END as "isPrimaryKey", + col_description( + (SELECT oid FROM pg_class WHERE relname = $1 AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')), + c.ordinal_position + ) as "columnComment" + FROM information_schema.columns c + WHERE c.table_name = $1 + AND c.table_schema = 'public' + ORDER BY c.ordinal_position`, [tableName] ); @@ -4533,10 +4537,10 @@ export class TableManagementService { displayName: col.displayName, dataType: col.dataType, dbType: col.dbType, - webType: "text", // 기본값 + webType: "text", inputType: "direct", detailSettings: "{}", - description: "", // 필수 필드 추가 + description: col.columnComment || "", isNullable: col.isNullable, isPrimaryKey: col.isPrimaryKey, defaultValue: col.defaultValue, @@ -4547,6 +4551,7 @@ export class TableManagementService { numericScale: col.numericScale ? Number(col.numericScale) : undefined, displayOrder: 0, isVisible: true, + columnComment: col.columnComment || "", })); logger.info( diff --git a/frontend/app/(auth)/login/page.tsx b/frontend/app/(auth)/login/page.tsx index 62e48e08..73a8de80 100644 --- a/frontend/app/(auth)/login/page.tsx +++ b/frontend/app/(auth)/login/page.tsx @@ -6,8 +6,17 @@ import { LoginForm } from "@/components/auth/LoginForm"; import { LoginFooter } from "@/components/auth/LoginFooter"; export default function LoginPage() { - const { formData, isLoading, error, showPassword, handleInputChange, handleLogin, togglePasswordVisibility } = - useLogin(); + const { + formData, + isLoading, + error, + showPassword, + isPopMode, + handleInputChange, + handleLogin, + togglePasswordVisibility, + togglePopMode, + } = useLogin(); return (
@@ -19,9 +28,11 @@ export default function LoginPage() { isLoading={isLoading} error={error} showPassword={showPassword} + isPopMode={isPopMode} onInputChange={handleInputChange} onSubmit={handleLogin} onTogglePassword={togglePasswordVisibility} + onTogglePop={togglePopMode} /> diff --git a/frontend/app/(main)/admin/audit-log/page.tsx b/frontend/app/(main)/admin/audit-log/page.tsx index 8fbe5e95..747d4640 100644 --- a/frontend/app/(main)/admin/audit-log/page.tsx +++ b/frontend/app/(main)/admin/audit-log/page.tsx @@ -77,14 +77,12 @@ const RESOURCE_TYPE_CONFIG: Record< NODE_FLOW: { label: "플로우 제어", icon: GitBranch, color: "bg-teal-100 text-teal-700" }, USER: { label: "사용자", icon: User, color: "bg-amber-100 text-orange-700" }, ROLE: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" }, - PERMISSION: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" }, COMPANY: { label: "회사", icon: Building2, color: "bg-indigo-100 text-indigo-700" }, CODE_CATEGORY: { label: "코드 카테고리", icon: Hash, color: "bg-cyan-100 text-cyan-700" }, CODE: { label: "코드", icon: Hash, color: "bg-cyan-100 text-cyan-700" }, DATA: { label: "데이터", icon: Database, color: "bg-muted text-foreground" }, TABLE: { label: "테이블", icon: Database, color: "bg-muted text-foreground" }, NUMBERING_RULE: { label: "채번 규칙", icon: FileText, color: "bg-amber-100 text-amber-700" }, - BATCH: { label: "배치", icon: RefreshCw, color: "bg-teal-100 text-teal-700" }, }; const ACTION_CONFIG: Record = { @@ -817,7 +815,7 @@ export default function AuditLogPage() { {entry.company_code && entry.company_code !== "*" && ( - [{entry.company_code}] + [{entry.company_name || entry.company_code}] )}
@@ -862,9 +860,11 @@ export default function AuditLogPage() {
-

{selectedEntry.company_code}

+

+ {selectedEntry.company_name || selectedEntry.company_code} +

)} + {/* 일반 모드 네비게이션 바 */} + {!isPreviewMode && ( +
+ + {screen.screenName} + +
+ )} + {/* POP 화면 컨텐츠 */}
- {/* 현재 모드 표시 (일반 모드) */} - {!isPreviewMode && ( -
- {currentModeKey.replace("_", " ")} -
- )}
= ({ }); // 화면 할당 관련 상태 - const [urlType, setUrlType] = useState<"direct" | "screen" | "dashboard">("screen"); // URL 직접 입력 or 화면 할당 or 대시보드 할당 (기본값: 화면 할당) + const [urlType, setUrlType] = useState<"direct" | "screen" | "dashboard" | "pop">("screen"); const [selectedScreen, setSelectedScreen] = useState(null); const [screens, setScreens] = useState([]); const [screenSearchText, setScreenSearchText] = useState(""); const [isScreenDropdownOpen, setIsScreenDropdownOpen] = useState(false); + // POP 화면 할당 관련 상태 + const [selectedPopScreen, setSelectedPopScreen] = useState(null); + const [popScreenSearchText, setPopScreenSearchText] = useState(""); + const [isPopScreenDropdownOpen, setIsPopScreenDropdownOpen] = useState(false); + const [isPopLanding, setIsPopLanding] = useState(false); + const [hasOtherPopLanding, setHasOtherPopLanding] = useState(false); + // 대시보드 할당 관련 상태 const [selectedDashboard, setSelectedDashboard] = useState(null); const [dashboards, setDashboards] = useState([]); @@ -196,8 +203,27 @@ export const MenuFormModal: React.FC = ({ toast.success(`대시보드가 선택되었습니다: ${dashboard.title}`); }; + // POP 화면 선택 시 URL 자동 설정 + const handlePopScreenSelect = (screen: ScreenDefinition) => { + const actualScreenId = screen.screenId || screen.id; + if (!actualScreenId) { + toast.error("화면 ID를 찾을 수 없습니다."); + return; + } + + setSelectedPopScreen(screen); + setIsPopScreenDropdownOpen(false); + + const popUrl = `/pop/screens/${actualScreenId}`; + + setFormData((prev) => ({ + ...prev, + menuUrl: popUrl, + })); + }; + // URL 타입 변경 시 처리 - const handleUrlTypeChange = (type: "direct" | "screen" | "dashboard") => { + const handleUrlTypeChange = (type: "direct" | "screen" | "dashboard" | "pop") => { // console.log("🔄 URL 타입 변경:", { // from: urlType, // to: type, @@ -208,36 +234,53 @@ export const MenuFormModal: React.FC = ({ setUrlType(type); if (type === "direct") { - // 직접 입력 모드로 변경 시 선택된 화면 초기화 setSelectedScreen(null); - // URL 필드와 screenCode 초기화 (사용자가 직접 입력할 수 있도록) + setSelectedPopScreen(null); setFormData((prev) => ({ ...prev, menuUrl: "", - screenCode: undefined, // 화면 코드도 함께 초기화 + screenCode: undefined, })); - } else { - // 화면 할당 모드로 변경 시 - // 기존에 선택된 화면이 있고, 해당 화면의 URL이 있다면 유지 + } else if (type === "pop") { + setSelectedScreen(null); + if (selectedPopScreen) { + const actualScreenId = selectedPopScreen.screenId || selectedPopScreen.id; + setFormData((prev) => ({ + ...prev, + menuUrl: `/pop/screens/${actualScreenId}`, + })); + } else { + setFormData((prev) => ({ + ...prev, + menuUrl: "", + })); + } + } else if (type === "screen") { + setSelectedPopScreen(null); if (selectedScreen) { - console.log("📋 기존 선택된 화면 유지:", selectedScreen.screenName); - // 현재 선택된 화면으로 URL 재생성 const actualScreenId = selectedScreen.screenId || selectedScreen.id; let screenUrl = `/screens/${actualScreenId}`; - - // 관리자 메뉴인 경우 mode=admin 파라미터 추가 const isAdminMenu = menuType === "0" || menuType === "admin" || formData.menuType === "0"; if (isAdminMenu) { screenUrl += "?mode=admin"; } - setFormData((prev) => ({ ...prev, menuUrl: screenUrl, - screenCode: selectedScreen.screenCode, // 화면 코드도 함께 유지 + screenCode: selectedScreen.screenCode, })); } else { - // 선택된 화면이 없으면 URL과 screenCode 초기화 + setFormData((prev) => ({ + ...prev, + menuUrl: "", + screenCode: undefined, + })); + } + } else { + // dashboard + setSelectedScreen(null); + setSelectedPopScreen(null); + if (!selectedDashboard) { setFormData((prev) => ({ ...prev, menuUrl: "", @@ -297,8 +340,8 @@ export const MenuFormModal: React.FC = ({ const menuUrl = menu.menu_url || menu.MENU_URL || ""; - // URL이 "/screens/"로 시작하면 화면 할당으로 판단 (실제 라우팅 패턴에 맞게 수정) - const isScreenUrl = menuUrl.startsWith("/screens/"); + const isPopScreenUrl = menuUrl.startsWith("/pop/screens/"); + const isScreenUrl = !isPopScreenUrl && menuUrl.startsWith("/screens/"); setFormData({ objid: menu.objid || menu.OBJID, @@ -360,10 +403,31 @@ export const MenuFormModal: React.FC = ({ }, 500); } } + } else if (isPopScreenUrl) { + setUrlType("pop"); + setSelectedScreen(null); + + // [POP_LANDING] 태그 감지 + const menuDesc = menu.menu_desc || menu.MENU_DESC || ""; + setIsPopLanding(menuDesc.includes("[POP_LANDING]")); + + const popScreenId = menuUrl.match(/\/pop\/screens\/(\d+)/)?.[1]; + if (popScreenId) { + const setPopScreenFromId = () => { + const screen = screens.find((s) => s.screenId.toString() === popScreenId || s.id?.toString() === popScreenId); + if (screen) { + setSelectedPopScreen(screen); + } + }; + if (screens.length > 0) { + setPopScreenFromId(); + } else { + setTimeout(setPopScreenFromId, 500); + } + } } else if (menuUrl.startsWith("/dashboard/")) { setUrlType("dashboard"); setSelectedScreen(null); - // 대시보드 ID 추출 및 선택은 useEffect에서 처리됨 } else { setUrlType("direct"); setSelectedScreen(null); @@ -408,6 +472,7 @@ export const MenuFormModal: React.FC = ({ } else { console.log("메뉴 등록 모드 - parentId:", parentId, "menuType:", menuType); setIsEdit(false); + setIsPopLanding(false); // 메뉴 타입 변환 (0 -> 0, 1 -> 1, admin -> 0, user -> 1) let defaultMenuType = "1"; // 기본값은 사용자 @@ -470,6 +535,31 @@ export const MenuFormModal: React.FC = ({ } }, [isOpen, formData.companyCode]); + // POP 기본 화면 중복 체크: 같은 부모 하위에 이미 [POP_LANDING]이 있는 다른 메뉴가 있는지 확인 + useEffect(() => { + if (!isOpen) return; + + const checkOtherPopLanding = async () => { + try { + const res = await menuApi.getPopMenus(); + if (res.success && res.data?.landingMenu) { + const landingObjId = res.data.landingMenu.objid?.toString(); + const currentObjId = formData.objid?.toString(); + // 현재 수정 중인 메뉴가 아닌 다른 메뉴에 [POP_LANDING]이 있으면 중복 + setHasOtherPopLanding(!!landingObjId && landingObjId !== currentObjId); + } else { + setHasOtherPopLanding(false); + } + } catch { + setHasOtherPopLanding(false); + } + }; + + if (urlType === "pop") { + checkOtherPopLanding(); + } + }, [isOpen, urlType, formData.objid]); + // 화면 목록 및 대시보드 목록 로드 useEffect(() => { if (isOpen) { @@ -517,6 +607,22 @@ export const MenuFormModal: React.FC = ({ } }, [dashboards, isEdit, formData.menuUrl, urlType, selectedDashboard]); + // POP 화면 목록 로드 완료 후 기존 할당 설정 + useEffect(() => { + if (screens.length > 0 && isEdit && formData.menuUrl && urlType === "pop") { + const menuUrl = formData.menuUrl; + if (menuUrl.startsWith("/pop/screens/")) { + const popScreenId = menuUrl.match(/\/pop\/screens\/(\d+)/)?.[1]; + if (popScreenId && !selectedPopScreen) { + const screen = screens.find((s) => s.screenId.toString() === popScreenId || s.id?.toString() === popScreenId); + if (screen) { + setSelectedPopScreen(screen); + } + } + } + } + }, [screens, isEdit, formData.menuUrl, urlType, selectedPopScreen]); + // 드롭다운 외부 클릭 시 닫기 useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -533,16 +639,20 @@ export const MenuFormModal: React.FC = ({ setIsDashboardDropdownOpen(false); setDashboardSearchText(""); } + if (!target.closest(".pop-screen-dropdown")) { + setIsPopScreenDropdownOpen(false); + setPopScreenSearchText(""); + } }; - if (isLangKeyDropdownOpen || isScreenDropdownOpen || isDashboardDropdownOpen) { + if (isLangKeyDropdownOpen || isScreenDropdownOpen || isDashboardDropdownOpen || isPopScreenDropdownOpen) { document.addEventListener("mousedown", handleClickOutside); } return () => { document.removeEventListener("mousedown", handleClickOutside); }; - }, [isLangKeyDropdownOpen, isScreenDropdownOpen]); + }, [isLangKeyDropdownOpen, isScreenDropdownOpen, isDashboardDropdownOpen, isPopScreenDropdownOpen]); const loadCompanies = async () => { try { @@ -590,10 +700,17 @@ export const MenuFormModal: React.FC = ({ try { setLoading(true); + // POP 기본 화면 태그 처리 + let finalMenuDesc = formData.menuDesc; + if (urlType === "pop") { + const descWithoutTag = finalMenuDesc.replace(/\[POP_LANDING\]/g, "").trim(); + finalMenuDesc = isPopLanding ? `${descWithoutTag} [POP_LANDING]`.trim() : descWithoutTag; + } + // 백엔드에 전송할 데이터 변환 const submitData = { ...formData, - // 상태를 소문자로 변환 (백엔드에서 소문자 기대) + menuDesc: finalMenuDesc, status: formData.status.toLowerCase(), }; @@ -853,7 +970,7 @@ export const MenuFormModal: React.FC = ({ {/* URL 타입 선택 */} - +
+
+ + +
)} + {/* POP 화면 할당 */} + {urlType === "pop" && ( +
+
+ + + {isPopScreenDropdownOpen && ( +
+
+
+ + setPopScreenSearchText(e.target.value)} + className="pl-8" + /> +
+
+ +
+ {screens + .filter( + (screen) => + screen.screenName.toLowerCase().includes(popScreenSearchText.toLowerCase()) || + screen.screenCode.toLowerCase().includes(popScreenSearchText.toLowerCase()), + ) + .map((screen, index) => ( +
handlePopScreenSelect(screen)} + className="cursor-pointer border-b px-3 py-2 last:border-b-0 hover:bg-gray-100" + > +
+
+
{screen.screenName}
+
{screen.screenCode}
+
+
ID: {screen.screenId || screen.id || "N/A"}
+
+
+ ))} + {screens.filter( + (screen) => + screen.screenName.toLowerCase().includes(popScreenSearchText.toLowerCase()) || + screen.screenCode.toLowerCase().includes(popScreenSearchText.toLowerCase()), + ).length === 0 &&
검색 결과가 없습니다.
} +
+
+ )} +
+ + {selectedPopScreen && ( +
+
{selectedPopScreen.screenName}
+
코드: {selectedPopScreen.screenCode}
+
생성된 URL: {formData.menuUrl}
+
+ )} + + {/* POP 기본 화면 설정 */} +
+ setIsPopLanding(e.target.checked)} + className="h-4 w-4 rounded border-gray-300 accent-primary disabled:cursor-not-allowed disabled:opacity-50" + /> + + {!isPopLanding && hasOtherPopLanding && ( + + (이미 다른 메뉴가 기본 화면으로 설정되어 있습니다) + + )} +
+ {isPopLanding && ( +

+ 프로필에서 POP 모드 전환 시 이 화면으로 바로 이동합니다. +

+ )} +
+ )} + {/* URL 직접 입력 */} {urlType === "direct" && ( ) => void; onSubmit: (e: React.FormEvent) => void; onTogglePassword: () => void; + onTogglePop: () => void; } /** @@ -24,9 +27,11 @@ export function LoginForm({ isLoading, error, showPassword, + isPopMode, onInputChange, onSubmit, onTogglePassword, + onTogglePop, }: LoginFormProps) { return ( @@ -82,6 +87,19 @@ export function LoginForm({
+ {/* POP 모드 토글 */} +
+
+ + POP 모드 +
+ +
+ {/* 로그인 버튼 */} )} + {scannedCode && ( + + )} + {scannedCode && !autoSubmit && ( + )} + {/* 사용자 배지 */} )} + + {conn.filterConfig?.targetColumn && ( +
+ + {conn.filterConfig.targetColumn} + + + {conn.filterConfig.filterMode} + + {conn.filterConfig.isSubTable && ( + + 하위 테이블 + + )} +
+ )} )} ))} - {isFilterSource ? ( - onAddConnection?.(data)} - submitLabel="연결 추가" - /> - ) : ( - onAddConnection?.(data)} - submitLabel="연결 추가" - /> - )} + onAddConnection?.(data)} + submitLabel="연결 추가" + /> ); } @@ -263,6 +205,19 @@ interface SimpleConnectionFormProps { submitLabel: string; } +function extractSubTableName(comp: PopComponentDefinitionV5): string | null { + const cfg = comp.config as Record | undefined; + if (!cfg) return null; + + const grid = cfg.cardGrid as { cells?: Array<{ timelineSource?: { processTable?: string } }> } | undefined; + if (grid?.cells) { + for (const cell of grid.cells) { + if (cell.timelineSource?.processTable) return cell.timelineSource.processTable; + } + } + return null; +} + function SimpleConnectionForm({ component, allComponents, @@ -274,6 +229,18 @@ function SimpleConnectionForm({ const [selectedTargetId, setSelectedTargetId] = React.useState( initial?.targetComponent || "" ); + const [isSubTable, setIsSubTable] = React.useState( + initial?.filterConfig?.isSubTable || false + ); + const [targetColumn, setTargetColumn] = React.useState( + initial?.filterConfig?.targetColumn || "" + ); + const [filterMode, setFilterMode] = React.useState( + initial?.filterConfig?.filterMode || "equals" + ); + + const [subColumns, setSubColumns] = React.useState([]); + const [loadingColumns, setLoadingColumns] = React.useState(false); const targetCandidates = allComponents.filter((c) => { if (c.id === component.id) return false; @@ -281,14 +248,39 @@ function SimpleConnectionForm({ return reg?.connectionMeta?.receivable && reg.connectionMeta.receivable.length > 0; }); + const sourceReg = PopComponentRegistry.getComponent(component.type); + const targetComp = allComponents.find((c) => c.id === selectedTargetId); + const targetReg = targetComp ? PopComponentRegistry.getComponent(targetComp.type) : null; + const isFilterConnection = sourceReg?.connectionMeta?.sendable?.some((s) => s.type === "filter_value") + && targetReg?.connectionMeta?.receivable?.some((r) => r.type === "filter_value"); + + const subTableName = targetComp ? extractSubTableName(targetComp) : null; + + React.useEffect(() => { + if (!isSubTable || !subTableName) { + setSubColumns([]); + return; + } + setLoadingColumns(true); + getTableColumns(subTableName) + .then((res) => { + const cols = res.success && res.data?.columns; + if (Array.isArray(cols)) { + setSubColumns(cols.map((c) => c.columnName || "").filter(Boolean)); + } + }) + .catch(() => setSubColumns([])) + .finally(() => setLoadingColumns(false)); + }, [isSubTable, subTableName]); + const handleSubmit = () => { if (!selectedTargetId) return; - const targetComp = allComponents.find((c) => c.id === selectedTargetId); + const tComp = allComponents.find((c) => c.id === selectedTargetId); const srcLabel = component.label || component.id; - const tgtLabel = targetComp?.label || targetComp?.id || "?"; + const tgtLabel = tComp?.label || tComp?.id || "?"; - onSubmit({ + const conn: Omit = { sourceComponent: component.id, sourceField: "", sourceOutput: "_auto", @@ -296,10 +288,23 @@ function SimpleConnectionForm({ targetField: "", targetInput: "_auto", label: `${srcLabel} → ${tgtLabel}`, - }); + }; + + if (isFilterConnection && isSubTable && targetColumn) { + conn.filterConfig = { + targetColumn, + filterMode: filterMode as "equals" | "contains" | "starts_with" | "range", + isSubTable: true, + }; + } + + onSubmit(conn); if (!initial) { setSelectedTargetId(""); + setIsSubTable(false); + setTargetColumn(""); + setFilterMode("equals"); } }; @@ -319,224 +324,12 @@ function SimpleConnectionForm({
어디로? - -
- - - - ); -} - -// ======================================== -// 필터 연결 폼 (검색 컴포넌트용: 기존 UI 유지) -// ======================================== - -interface FilterConnectionFormProps { - component: PopComponentDefinitionV5; - meta: ComponentConnectionMeta; - allComponents: PopComponentDefinitionV5[]; - initial?: PopDataConnection; - onSubmit: (data: Omit) => void; - onCancel?: () => void; - submitLabel: string; -} - -function FilterConnectionForm({ - component, - meta, - allComponents, - initial, - onSubmit, - onCancel, - submitLabel, -}: FilterConnectionFormProps) { - const [selectedOutput, setSelectedOutput] = React.useState( - initial?.sourceOutput || meta.sendable[0]?.key || "" - ); - const [selectedTargetId, setSelectedTargetId] = React.useState( - initial?.targetComponent || "" - ); - const [selectedTargetInput, setSelectedTargetInput] = React.useState( - initial?.targetInput || "" - ); - const [filterColumns, setFilterColumns] = React.useState( - initial?.filterConfig?.targetColumns || - (initial?.filterConfig?.targetColumn ? [initial.filterConfig.targetColumn] : []) - ); - const [filterMode, setFilterMode] = React.useState< - "equals" | "contains" | "starts_with" | "range" - >(initial?.filterConfig?.filterMode || "contains"); - - const targetCandidates = allComponents.filter((c) => { - if (c.id === component.id) return false; - const reg = PopComponentRegistry.getComponent(c.type); - return reg?.connectionMeta?.receivable && reg.connectionMeta.receivable.length > 0; - }); - - const targetComp = selectedTargetId - ? allComponents.find((c) => c.id === selectedTargetId) - : null; - - const targetMeta = targetComp - ? PopComponentRegistry.getComponent(targetComp.type)?.connectionMeta - : null; - - React.useEffect(() => { - if (!selectedOutput || !targetMeta?.receivable?.length) return; - if (selectedTargetInput) return; - - const receivables = targetMeta.receivable; - const exactMatch = receivables.find((r) => r.key === selectedOutput); - if (exactMatch) { - setSelectedTargetInput(exactMatch.key); - return; - } - if (receivables.length === 1) { - setSelectedTargetInput(receivables[0].key); - } - }, [selectedOutput, targetMeta, selectedTargetInput]); - - const displayColumns = React.useMemo( - () => extractDisplayColumns(targetComp || undefined), - [targetComp] - ); - - const tableName = React.useMemo( - () => extractTableName(targetComp || undefined), - [targetComp] - ); - const [allDbColumns, setAllDbColumns] = React.useState([]); - const [dbColumnsLoading, setDbColumnsLoading] = React.useState(false); - - React.useEffect(() => { - if (!tableName) { - setAllDbColumns([]); - return; - } - let cancelled = false; - setDbColumnsLoading(true); - getTableColumns(tableName).then((res) => { - if (cancelled) return; - if (res.success && res.data?.columns) { - setAllDbColumns(res.data.columns.map((c) => c.columnName)); - } else { - setAllDbColumns([]); - } - setDbColumnsLoading(false); - }); - return () => { cancelled = true; }; - }, [tableName]); - - const displaySet = React.useMemo(() => new Set(displayColumns), [displayColumns]); - const dataOnlyColumns = React.useMemo( - () => allDbColumns.filter((c) => !displaySet.has(c)), - [allDbColumns, displaySet] - ); - const hasAnyColumns = displayColumns.length > 0 || dataOnlyColumns.length > 0; - - const toggleColumn = (col: string) => { - setFilterColumns((prev) => - prev.includes(col) ? prev.filter((c) => c !== col) : [...prev, col] - ); - }; - - const handleSubmit = () => { - if (!selectedOutput || !selectedTargetId || !selectedTargetInput) return; - - const isEvent = isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput); - - onSubmit({ - sourceComponent: component.id, - sourceField: "", - sourceOutput: selectedOutput, - targetComponent: selectedTargetId, - targetField: "", - targetInput: selectedTargetInput, - filterConfig: - !isEvent && filterColumns.length > 0 - ? { - targetColumn: filterColumns[0], - targetColumns: filterColumns, - filterMode, - } - : undefined, - label: buildConnectionLabel( - component, - selectedOutput, - allComponents.find((c) => c.id === selectedTargetId), - selectedTargetInput, - filterColumns - ), - }); - - if (!initial) { - setSelectedTargetId(""); - setSelectedTargetInput(""); - setFilterColumns([]); - } - }; - - return ( -
- {onCancel && ( -
-

연결 수정

- -
- )} - {!onCancel && ( -

새 연결 추가

- )} - -
- 보내는 값 - -
- -
- 받는 컴포넌트
- {targetMeta && ( -
- 받는 방식 - -
- )} - - {selectedTargetInput && !isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput) && ( -
-

필터할 컬럼

- - {dbColumnsLoading ? ( -
- - 컬럼 조회 중... -
- ) : hasAnyColumns ? ( -
- {displayColumns.length > 0 && ( -
-

화면 표시 컬럼

- {displayColumns.map((col) => ( -
- toggleColumn(col)} - /> - -
- ))} -
- )} - - {dataOnlyColumns.length > 0 && ( -
- {displayColumns.length > 0 && ( -
- )} -

데이터 전용 컬럼

- {dataOnlyColumns.map((col) => ( -
- toggleColumn(col)} - /> - -
- ))} -
- )} -
- ) : ( - setFilterColumns(e.target.value ? [e.target.value] : [])} - placeholder="컬럼명 입력" - className="h-7 text-xs" + {isFilterConnection && selectedTargetId && subTableName && ( +
+
+ { + setIsSubTable(v === true); + if (!v) setTargetColumn(""); + }} /> - )} - - {filterColumns.length > 0 && ( -

- {filterColumns.length}개 컬럼 중 하나라도 일치하면 표시 -

- )} - -
-

필터 방식

- +
+ + {isSubTable && ( +
+
+ 대상 컬럼 + {loadingColumns ? ( +
+ + 컬럼 로딩 중... +
+ ) : ( + + )} +
+ +
+ 비교 방식 + +
+
+ )}
)} @@ -662,7 +408,7 @@ function FilterConnectionForm({ size="sm" variant="outline" className="h-7 w-full text-xs" - disabled={!selectedOutput || !selectedTargetId || !selectedTargetInput} + disabled={!selectedTargetId} onClick={handleSubmit} > {!initial && } @@ -722,32 +468,3 @@ function ReceiveSection({ ); } -// ======================================== -// 유틸 -// ======================================== - -function isEventTypeConnection( - sourceMeta: ComponentConnectionMeta | undefined, - outputKey: string, - targetMeta: ComponentConnectionMeta | null | undefined, - inputKey: string, -): boolean { - const sourceItem = sourceMeta?.sendable?.find((s) => s.key === outputKey); - const targetItem = targetMeta?.receivable?.find((r) => r.key === inputKey); - return sourceItem?.type === "event" || targetItem?.type === "event"; -} - -function buildConnectionLabel( - source: PopComponentDefinitionV5, - _outputKey: string, - target: PopComponentDefinitionV5 | undefined, - _inputKey: string, - columns?: string[] -): string { - const srcLabel = source.label || source.id; - const tgtLabel = target?.label || target?.id || "?"; - const colInfo = columns && columns.length > 0 - ? ` [${columns.join(", ")}]` - : ""; - return `${srcLabel} → ${tgtLabel}${colInfo}`; -} diff --git a/frontend/components/pop/designer/renderers/PopRenderer.tsx b/frontend/components/pop/designer/renderers/PopRenderer.tsx index 598f0e90..a9c7db6e 100644 --- a/frontend/components/pop/designer/renderers/PopRenderer.tsx +++ b/frontend/components/pop/designer/renderers/PopRenderer.tsx @@ -72,10 +72,14 @@ const COMPONENT_TYPE_LABELS: Record = { "pop-icon": "아이콘", "pop-dashboard": "대시보드", "pop-card-list": "카드 목록", + "pop-card-list-v2": "카드 목록 V2", "pop-button": "버튼", "pop-string-list": "리스트 목록", "pop-search": "검색", + "pop-status-bar": "상태 바", "pop-field": "입력", + "pop-scanner": "스캐너", + "pop-profile": "프로필", }; // ======================================== @@ -554,7 +558,7 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect if (ActualComp) { // 아이콘 컴포넌트는 클릭 이벤트가 필요하므로 pointer-events 허용 // CardList 컴포넌트도 버튼 클릭이 필요하므로 pointer-events 허용 - const needsPointerEvents = component.type === "pop-icon" || component.type === "pop-card-list"; + const needsPointerEvents = component.type === "pop-icon" || component.type === "pop-card-list" || component.type === "pop-card-list-v2"; return (
void; // 🆕 분할 패널 내부 컴포넌트 선택 콜백 selectedPanelComponentId?: string; // 🆕 선택된 분할 패널 컴포넌트 ID + onNestedPanelSelect?: (splitPanelId: string, panelSide: "left" | "right", compId: string, comp: any) => void; onResize?: (componentId: string, newSize: { width: number; height: number }) => void; // 🆕 리사이즈 콜백 // 버튼 액션을 위한 props @@ -150,6 +151,7 @@ const RealtimePreviewDynamicComponent: React.FC = ({ selectedTabComponentId, // 🆕 선택된 탭 컴포넌트 ID onSelectPanelComponent, // 🆕 분할 패널 내부 컴포넌트 선택 콜백 selectedPanelComponentId, // 🆕 선택된 분할 패널 컴포넌트 ID + onNestedPanelSelect, onResize, // 🆕 리사이즈 콜백 }) => { // 🆕 화면 다국어 컨텍스트 @@ -768,6 +770,7 @@ const RealtimePreviewDynamicComponent: React.FC = ({ selectedTabComponentId={selectedTabComponentId} onSelectPanelComponent={onSelectPanelComponent} selectedPanelComponentId={selectedPanelComponentId} + onNestedPanelSelect={onNestedPanelSelect} />
diff --git a/frontend/components/screen/ResponsiveGridRenderer.tsx b/frontend/components/screen/ResponsiveGridRenderer.tsx index c3c13a94..00e0dc53 100644 --- a/frontend/components/screen/ResponsiveGridRenderer.tsx +++ b/frontend/components/screen/ResponsiveGridRenderer.tsx @@ -109,6 +109,8 @@ interface ProcessedRow { mainComponent?: ComponentData; overlayComps: ComponentData[]; normalComps: ComponentData[]; + rowMinY?: number; + rowMaxBottom?: number; } function FullWidthOverlayRow({ @@ -299,6 +301,10 @@ export function ResponsiveGridRenderer({ } } + const allComps = [...fullWidthComps, ...normalComps]; + const rowMinY = allComps.length > 0 ? Math.min(...allComps.map(c => c.position.y)) : 0; + const rowMaxBottom = allComps.length > 0 ? Math.max(...allComps.map(c => c.position.y + (c.size?.height || 40))) : 0; + if (fullWidthComps.length > 0 && normalComps.length > 0) { for (const fwComp of fullWidthComps) { processedRows.push({ @@ -306,6 +312,8 @@ export function ResponsiveGridRenderer({ mainComponent: fwComp, overlayComps: normalComps, normalComps: [], + rowMinY, + rowMaxBottom, }); } } else if (fullWidthComps.length > 0) { @@ -315,6 +323,8 @@ export function ResponsiveGridRenderer({ mainComponent: fwComp, overlayComps: [], normalComps: [], + rowMinY, + rowMaxBottom, }); } } else { @@ -322,6 +332,8 @@ export function ResponsiveGridRenderer({ type: "normal", overlayComps: [], normalComps, + rowMinY, + rowMaxBottom, }); } } @@ -333,15 +345,26 @@ export function ResponsiveGridRenderer({ style={{ minHeight: "200px" }} > {processedRows.map((processedRow, rowIndex) => { + const rowMarginTop = (() => { + if (rowIndex === 0) return 0; + const prevRow = processedRows[rowIndex - 1]; + const prevBottom = prevRow.rowMaxBottom ?? 0; + const currTop = processedRow.rowMinY ?? 0; + const designGap = currTop - prevBottom; + if (designGap <= 0) return 0; + return Math.min(Math.max(Math.round(designGap * 0.5), 4), 48); + })(); + if (processedRow.type === "fullwidth" && processedRow.mainComponent) { return ( - +
0 ? `${rowMarginTop}px` : undefined }}> + +
); } @@ -362,7 +385,7 @@ export function ResponsiveGridRenderer({ allButtons && "justify-end px-2 py-1", hasFlexHeightComp ? "min-h-0 flex-1" : "flex-shrink-0" )} - style={{ gap: `${gap}px` }} + style={{ gap: `${gap}px`, marginTop: rowMarginTop > 0 ? `${rowMarginTop}px` : undefined }} > {normalComps.map((component) => { const typeId = getComponentTypeId(component); @@ -409,10 +432,10 @@ export function ResponsiveGridRenderer({ flexGrow: percentWidth, flexShrink: 1, minWidth: isMobile ? "100%" : undefined, - minHeight: useFlexHeight ? "300px" : undefined, - height: useFlexHeight ? "100%" : (component.size?.height + minHeight: useFlexHeight ? "300px" : (component.size?.height ? `${component.size.height}px` - : "auto"), + : undefined), + height: useFlexHeight ? "100%" : "auto", }} > {renderComponent(component)} diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 10e1153d..6eeeb4e1 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -2861,9 +2861,190 @@ export default function ScreenDesigner({ } } - // 🎯 탭 컨테이너 내부 드롭 처리 (중첩 구조 지원) + // 🎯 컨테이너 드롭 우선순위: 가장 안쪽(innermost) 컨테이너 우선 + // 분할패널과 탭 둘 다 감지될 경우, DOM 트리에서 더 가까운 쪽을 우선 처리 const tabsContainer = dropTarget.closest('[data-tabs-container="true"]'); - if (tabsContainer) { + const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]'); + + // 분할패널이 탭보다 안쪽에 있으면 분할패널 우선 처리 + const splitPanelFirst = + splitPanelContainer && + (!tabsContainer || tabsContainer.contains(splitPanelContainer)); + + if (splitPanelFirst && splitPanelContainer) { + const containerId = splitPanelContainer.getAttribute("data-component-id"); + const panelSide = splitPanelContainer.getAttribute("data-panel-side"); + if (containerId && panelSide) { + // 분할 패널을 최상위 또는 중첩(탭 안)에서 찾기 + let targetComponent: any = layout.components.find((c) => c.id === containerId); + let parentTabsId: string | null = null; + let parentTabId: string | null = null; + let parentSplitId: string | null = null; + let parentSplitSide: string | null = null; + + if (!targetComponent) { + // 탭 안에 중첩된 분할패널 찾기 + // top-level: overrides.type / overrides.tabs + // nested: componentType / componentConfig.tabs + for (const comp of layout.components) { + const compType = (comp as any)?.componentType || (comp as any)?.overrides?.type; + const compConfig = (comp as any)?.componentConfig || (comp as any)?.overrides || {}; + + if (compType === "tabs-widget" || compType === "v2-tabs-widget") { + const tabs = compConfig.tabs || []; + for (const tab of tabs) { + const found = (tab.components || []).find((c: any) => c.id === containerId); + if (found) { + targetComponent = found; + parentTabsId = comp.id; + parentTabId = tab.id; + break; + } + } + if (targetComponent) break; + } + + if (compType === "split-panel-layout" || compType === "v2-split-panel-layout") { + for (const side of ["leftPanel", "rightPanel"] as const) { + const panelComps = compConfig[side]?.components || []; + for (const pc of panelComps) { + const pct = pc.componentType || pc.overrides?.type; + if (pct === "tabs-widget" || pct === "v2-tabs-widget") { + const tabs = (pc.componentConfig || pc.overrides || {}).tabs || []; + for (const tab of tabs) { + const found = (tab.components || []).find((c: any) => c.id === containerId); + if (found) { + targetComponent = found; + parentSplitId = comp.id; + parentSplitSide = side === "leftPanel" ? "left" : "right"; + parentTabsId = pc.id; + parentTabId = tab.id; + break; + } + } + if (targetComponent) break; + } + } + if (targetComponent) break; + } + if (targetComponent) break; + } + } + } + + + + const compType = (targetComponent as any)?.componentType; + if (targetComponent && (compType === "split-panel-layout" || compType === "v2-split-panel-layout")) { + const currentConfig = (targetComponent as any).componentConfig || {}; + const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel"; + const panelConfig = currentConfig[panelKey] || {}; + const currentComponents = panelConfig.components || []; + + const panelRect = splitPanelContainer.getBoundingClientRect(); + const cs1 = window.getComputedStyle(splitPanelContainer); + const dropX = (e.clientX - panelRect.left - (parseFloat(cs1.paddingLeft) || 0)) / zoomLevel; + const dropY = (e.clientY - panelRect.top - (parseFloat(cs1.paddingTop) || 0)) / zoomLevel; + + const componentType = component.id || component.componentType || "v2-text-display"; + + const newPanelComponent = { + id: `panel_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + componentType: componentType, + label: component.name || component.label || "새 컴포넌트", + position: { x: Math.max(0, dropX), y: Math.max(0, dropY) }, + size: component.defaultSize || { width: 200, height: 100 }, + componentConfig: component.defaultConfig || {}, + }; + + const updatedPanelConfig = { + ...panelConfig, + components: [...currentComponents, newPanelComponent], + }; + + const updatedSplitPanel = { + ...targetComponent, + componentConfig: { + ...currentConfig, + [panelKey]: updatedPanelConfig, + }, + }; + + let newLayout; + if (parentTabsId && parentTabId) { + // 중첩: (최상위 분할패널 →) 탭 → 분할패널 + const updateTabsComponent = (tabsComp: any) => { + const ck = tabsComp.componentConfig ? "componentConfig" : "overrides"; + const cfg = tabsComp[ck] || {}; + const tabs = cfg.tabs || []; + return { + ...tabsComp, + [ck]: { + ...cfg, + tabs: tabs.map((tab: any) => + tab.id === parentTabId + ? { + ...tab, + components: (tab.components || []).map((c: any) => + c.id === containerId ? updatedSplitPanel : c, + ), + } + : tab, + ), + }, + }; + }; + + if (parentSplitId && parentSplitSide) { + // 최상위 분할패널 → 탭 → 분할패널 + const pKey = parentSplitSide === "left" ? "leftPanel" : "rightPanel"; + newLayout = { + ...layout, + components: layout.components.map((c) => { + if (c.id === parentSplitId) { + const sc = (c as any).componentConfig || {}; + return { + ...c, + componentConfig: { + ...sc, + [pKey]: { + ...sc[pKey], + components: (sc[pKey]?.components || []).map((pc: any) => + pc.id === parentTabsId ? updateTabsComponent(pc) : pc, + ), + }, + }, + }; + } + return c; + }), + }; + } else { + // 최상위 탭 → 분할패널 + newLayout = { + ...layout, + components: layout.components.map((c) => + c.id === parentTabsId ? updateTabsComponent(c) : c, + ), + }; + } + } else { + // 최상위 분할패널 + newLayout = { + ...layout, + components: layout.components.map((c) => (c.id === containerId ? updatedSplitPanel : c)), + }; + } + + setLayout(newLayout); + saveToHistory(newLayout); + toast.success(`컴포넌트가 ${panelSide === "left" ? "좌측" : "우측"} 패널에 추가되었습니다`); + return; + } + } + } + + if (tabsContainer && !splitPanelFirst) { const containerId = tabsContainer.getAttribute("data-component-id"); const activeTabId = tabsContainer.getAttribute("data-active-tab-id"); if (containerId && activeTabId) { @@ -3004,69 +3185,6 @@ export default function ScreenDesigner({ } } - // 🎯 분할 패널 커스텀 모드 컨테이너 내부 드롭 처리 - const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]'); - if (splitPanelContainer) { - const containerId = splitPanelContainer.getAttribute("data-component-id"); - const panelSide = splitPanelContainer.getAttribute("data-panel-side"); // "left" or "right" - if (containerId && panelSide) { - const targetComponent = layout.components.find((c) => c.id === containerId); - const compType = (targetComponent as any)?.componentType; - if (targetComponent && (compType === "split-panel-layout" || compType === "v2-split-panel-layout")) { - const currentConfig = (targetComponent as any).componentConfig || {}; - const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel"; - const panelConfig = currentConfig[panelKey] || {}; - const currentComponents = panelConfig.components || []; - - // 드롭 위치 계산 - const panelRect = splitPanelContainer.getBoundingClientRect(); - const dropX = (e.clientX - panelRect.left) / zoomLevel; - const dropY = (e.clientY - panelRect.top) / zoomLevel; - - // 새 컴포넌트 생성 - const componentType = component.id || component.componentType || "v2-text-display"; - - console.log("🎯 분할 패널에 컴포넌트 드롭:", { - componentId: component.id, - componentType: componentType, - panelSide: panelSide, - dropPosition: { x: dropX, y: dropY }, - }); - - const newPanelComponent = { - id: `panel_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, - componentType: componentType, - label: component.name || component.label || "새 컴포넌트", - position: { x: Math.max(0, dropX), y: Math.max(0, dropY) }, - size: component.defaultSize || { width: 200, height: 100 }, - componentConfig: component.defaultConfig || {}, - }; - - const updatedPanelConfig = { - ...panelConfig, - components: [...currentComponents, newPanelComponent], - }; - - const updatedComponent = { - ...targetComponent, - componentConfig: { - ...currentConfig, - [panelKey]: updatedPanelConfig, - }, - }; - - const newLayout = { - ...layout, - components: layout.components.map((c) => (c.id === containerId ? updatedComponent : c)), - }; - - setLayout(newLayout); - saveToHistory(newLayout); - toast.success(`컴포넌트가 ${panelSide === "left" ? "좌측" : "우측"} 패널에 추가되었습니다`); - return; // 분할 패널 처리 완료 - } - } - } const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return; @@ -3378,15 +3496,12 @@ export default function ScreenDesigner({ e.preventDefault(); const dragData = e.dataTransfer.getData("application/json"); - // console.log("🎯 드롭 이벤트:", { dragData }); if (!dragData) { - // console.log("❌ 드래그 데이터가 없습니다"); return; } try { const parsedData = JSON.parse(dragData); - // console.log("📋 파싱된 데이터:", parsedData); // 템플릿 드래그인 경우 if (parsedData.type === "template") { @@ -3480,9 +3595,225 @@ export default function ScreenDesigner({ } } - // 🎯 탭 컨테이너 내부에 컬럼 드롭 시 처리 (중첩 구조 지원) + // 🎯 컨테이너 감지: innermost 우선 (분할패널 > 탭) const tabsContainer = dropTarget.closest('[data-tabs-container="true"]'); - if (tabsContainer && type === "column" && column) { + const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]'); + + // 분할패널이 탭 안에 있으면 분할패널이 innermost → 분할패널 우선 + const splitPanelFirst = + splitPanelContainer && + (!tabsContainer || tabsContainer.contains(splitPanelContainer)); + + // 🎯 분할패널 커스텀 모드 컨테이너 내부에 컬럼 드롭 시 처리 (우선 처리) + if (splitPanelFirst && splitPanelContainer && type === "column" && column) { + const containerId = splitPanelContainer.getAttribute("data-component-id"); + let panelSide = splitPanelContainer.getAttribute("data-panel-side"); + + // panelSide가 없으면 드롭 좌표와 splitRatio로 좌/우 판별 + if (!panelSide) { + const splitRatio = parseInt(splitPanelContainer.getAttribute("data-split-ratio") || "40", 10); + const containerRect = splitPanelContainer.getBoundingClientRect(); + const relativeX = e.clientX - containerRect.left; + const splitPoint = containerRect.width * (splitRatio / 100); + panelSide = relativeX < splitPoint ? "left" : "right"; + } + + if (containerId && panelSide) { + // 최상위에서 찾기 + let targetComponent: any = layout.components.find((c) => c.id === containerId); + let parentTabsId: string | null = null; + let parentTabId: string | null = null; + let parentSplitId: string | null = null; + let parentSplitSide: string | null = null; + + if (!targetComponent) { + // 탭 안 중첩 분할패널 찾기 + // top-level 컴포넌트: overrides.type / overrides.tabs + // nested 컴포넌트: componentType / componentConfig.tabs + for (const comp of layout.components) { + const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type; + const compConfig = (comp as any)?.componentConfig || (comp as any)?.overrides || {}; + + if (ct === "tabs-widget" || ct === "v2-tabs-widget") { + const tabs = compConfig.tabs || []; + for (const tab of tabs) { + const found = (tab.components || []).find((c: any) => c.id === containerId); + if (found) { + targetComponent = found; + parentTabsId = comp.id; + parentTabId = tab.id; + break; + } + } + if (targetComponent) break; + } + // 분할패널 → 탭 → 분할패널 중첩 + if (ct === "split-panel-layout" || ct === "v2-split-panel-layout") { + for (const side of ["leftPanel", "rightPanel"] as const) { + const panelComps = compConfig[side]?.components || []; + for (const pc of panelComps) { + const pct = pc.componentType || pc.overrides?.type; + if (pct === "tabs-widget" || pct === "v2-tabs-widget") { + const tabs = (pc.componentConfig || pc.overrides || {}).tabs || []; + for (const tab of tabs) { + const found = (tab.components || []).find((c: any) => c.id === containerId); + if (found) { + targetComponent = found; + parentSplitId = comp.id; + parentSplitSide = side === "leftPanel" ? "left" : "right"; + parentTabsId = pc.id; + parentTabId = tab.id; + break; + } + } + if (targetComponent) break; + } + } + if (targetComponent) break; + } + if (targetComponent) break; + } + } + } + + const compType = (targetComponent as any)?.componentType; + if (targetComponent && (compType === "split-panel-layout" || compType === "v2-split-panel-layout")) { + const currentConfig = (targetComponent as any).componentConfig || {}; + const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel"; + const panelConfig = currentConfig[panelKey] || {}; + const currentComponents = panelConfig.components || []; + + const panelRect = splitPanelContainer.getBoundingClientRect(); + const computedStyle = window.getComputedStyle(splitPanelContainer); + const padLeft = parseFloat(computedStyle.paddingLeft) || 0; + const padTop = parseFloat(computedStyle.paddingTop) || 0; + const dropX = (e.clientX - panelRect.left - padLeft) / zoomLevel; + const dropY = (e.clientY - panelRect.top - padTop) / zoomLevel; + + const v2Mapping = createV2ConfigFromColumn({ + widgetType: column.widgetType, + columnName: column.columnName, + columnLabel: column.columnLabel, + codeCategory: column.codeCategory, + inputType: column.inputType, + required: column.required, + detailSettings: column.detailSettings, + referenceTable: column.referenceTable, + referenceColumn: column.referenceColumn, + displayColumn: column.displayColumn, + }); + + const newPanelComponent = { + id: `panel_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + componentType: v2Mapping.componentType, + label: column.columnLabel || column.columnName, + position: { x: Math.max(0, dropX), y: Math.max(0, dropY) }, + size: { width: 200, height: 36 }, + inputType: column.inputType || column.widgetType, + widgetType: column.widgetType, + componentConfig: { + ...v2Mapping.componentConfig, + columnName: column.columnName, + tableName: column.tableName, + inputType: column.inputType || column.widgetType, + }, + }; + + const updatedSplitPanel = { + ...targetComponent, + componentConfig: { + ...currentConfig, + [panelKey]: { + ...panelConfig, + displayMode: "custom", + components: [...currentComponents, newPanelComponent], + }, + }, + }; + + let newLayout; + + if (parentSplitId && parentSplitSide && parentTabsId && parentTabId) { + // 분할패널 → 탭 → 분할패널 3중 중첩 + newLayout = { + ...layout, + components: layout.components.map((c) => { + if (c.id !== parentSplitId) return c; + const sc = (c as any).componentConfig || {}; + const pk = parentSplitSide === "left" ? "leftPanel" : "rightPanel"; + return { + ...c, + componentConfig: { + ...sc, + [pk]: { + ...sc[pk], + components: (sc[pk]?.components || []).map((pc: any) => { + if (pc.id !== parentTabsId) return pc; + return { + ...pc, + componentConfig: { + ...pc.componentConfig, + tabs: (pc.componentConfig?.tabs || []).map((tab: any) => { + if (tab.id !== parentTabId) return tab; + return { + ...tab, + components: (tab.components || []).map((tc: any) => + tc.id === containerId ? updatedSplitPanel : tc, + ), + }; + }), + }, + }; + }), + }, + }, + }; + }), + }; + } else if (parentTabsId && parentTabId) { + // 탭 → 분할패널 2중 중첩 + newLayout = { + ...layout, + components: layout.components.map((c) => { + if (c.id !== parentTabsId) return c; + // top-level은 overrides, nested는 componentConfig + const configKey = (c as any).componentConfig ? "componentConfig" : "overrides"; + const tabsConfig = (c as any)[configKey] || {}; + return { + ...c, + [configKey]: { + ...tabsConfig, + tabs: (tabsConfig.tabs || []).map((tab: any) => { + if (tab.id !== parentTabId) return tab; + return { + ...tab, + components: (tab.components || []).map((tc: any) => + tc.id === containerId ? updatedSplitPanel : tc, + ), + }; + }), + }, + }; + }), + }; + } else { + // 최상위 분할패널 + newLayout = { + ...layout, + components: layout.components.map((c) => (c.id === containerId ? updatedSplitPanel : c)), + }; + } + + toast.success("컬럼이 분할패널에 추가되었습니다"); + setLayout(newLayout); + saveToHistory(newLayout); + return; + } + } + } + + // 🎯 탭 컨테이너 내부에 컬럼 드롭 시 처리 (중첩 구조 지원) + if (tabsContainer && !splitPanelFirst && type === "column" && column) { const containerId = tabsContainer.getAttribute("data-component-id"); const activeTabId = tabsContainer.getAttribute("data-active-tab-id"); if (containerId && activeTabId) { @@ -3648,9 +3979,8 @@ export default function ScreenDesigner({ } } - // 🎯 분할 패널 커스텀 모드 컨테이너 내부에 컬럼 드롭 시 처리 - const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]'); - if (splitPanelContainer && type === "column" && column) { + // 🎯 분할 패널 커스텀 모드 (탭 밖 최상위) 컬럼 드롭 처리 + if (splitPanelContainer && !splitPanelFirst && type === "column" && column) { const containerId = splitPanelContainer.getAttribute("data-component-id"); const panelSide = splitPanelContainer.getAttribute("data-panel-side"); // "left" or "right" if (containerId && panelSide) { @@ -3662,12 +3992,11 @@ export default function ScreenDesigner({ const panelConfig = currentConfig[panelKey] || {}; const currentComponents = panelConfig.components || []; - // 드롭 위치 계산 const panelRect = splitPanelContainer.getBoundingClientRect(); - const dropX = (e.clientX - panelRect.left) / zoomLevel; - const dropY = (e.clientY - panelRect.top) / zoomLevel; + const cs2 = window.getComputedStyle(splitPanelContainer); + const dropX = (e.clientX - panelRect.left - (parseFloat(cs2.paddingLeft) || 0)) / zoomLevel; + const dropY = (e.clientY - panelRect.top - (parseFloat(cs2.paddingTop) || 0)) / zoomLevel; - // V2 컴포넌트 매핑 사용 const v2Mapping = createV2ConfigFromColumn({ widgetType: column.widgetType, columnName: column.columnName, @@ -6415,15 +6744,6 @@ export default function ScreenDesigner({ const { splitPanelId, panelSide } = selectedPanelComponentInfo; const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel"; - console.log("🔧 updatePanelComponentProperty 호출:", { - componentId, - path, - value, - splitPanelId, - panelSide, - }); - - // 🆕 안전한 깊은 경로 업데이트 헬퍼 함수 const setNestedValue = (obj: any, pathStr: string, val: any): any => { const result = JSON.parse(JSON.stringify(obj)); const parts = pathStr.split("."); @@ -6440,9 +6760,27 @@ export default function ScreenDesigner({ return result; }; + // 중첩 구조 포함 분할패널 찾기 헬퍼 + const findSplitPanelInLayout = (components: any[]): { found: any; path: "top" | "nested"; parentTabId?: string; parentTabTabId?: string } | null => { + const direct = components.find((c) => c.id === splitPanelId); + if (direct) return { found: direct, path: "top" }; + for (const comp of components) { + const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type; + const cfg = (comp as any)?.componentConfig || (comp as any)?.overrides || {}; + if (ct === "tabs-widget" || ct === "v2-tabs-widget") { + for (const tab of (cfg.tabs || [])) { + const nested = (tab.components || []).find((c: any) => c.id === splitPanelId); + if (nested) return { found: nested, path: "nested", parentTabId: comp.id, parentTabTabId: tab.id }; + } + } + } + return null; + }; + setLayout((prevLayout) => { - const splitPanelComponent = prevLayout.components.find((c) => c.id === splitPanelId); - if (!splitPanelComponent) return prevLayout; + const result = findSplitPanelInLayout(prevLayout.components); + if (!result) return prevLayout; + const splitPanelComponent = result.found; const currentConfig = (splitPanelComponent as any).componentConfig || {}; const panelConfig = currentConfig[panelKey] || {}; @@ -6478,17 +6816,37 @@ export default function ScreenDesigner({ }, }; - // selectedPanelComponentInfo 업데이트 setSelectedPanelComponentInfo((prev) => prev ? { ...prev, component: updatedComp } : null, ); - return { - ...prevLayout, - components: prevLayout.components.map((c) => - c.id === splitPanelId ? updatedComponent : c, - ), + // 중첩 구조 반영 + const applyUpdatedSplitPanel = (layout: any, updated: any, info: any) => { + if (info.path === "top") { + return { ...layout, components: layout.components.map((c: any) => c.id === splitPanelId ? updated : c) }; + } + return { + ...layout, + components: layout.components.map((c: any) => { + if (c.id !== info.parentTabId) return c; + const cfgKey = c.componentConfig?.tabs ? "componentConfig" : "overrides"; + const cfg = c[cfgKey] || {}; + return { + ...c, + [cfgKey]: { + ...cfg, + tabs: (cfg.tabs || []).map((t: any) => + t.id === info.parentTabTabId + ? { ...t, components: (t.components || []).map((tc: any) => tc.id === splitPanelId ? updated : tc) } + : t, + ), + }, + }; + }), + }; }; + + return applyUpdatedSplitPanel(prevLayout, updatedComponent, result); }); }; @@ -6498,8 +6856,23 @@ export default function ScreenDesigner({ const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel"; setLayout((prevLayout) => { - const splitPanelComponent = prevLayout.components.find((c) => c.id === splitPanelId); - if (!splitPanelComponent) return prevLayout; + const findResult = (() => { + const direct = prevLayout.components.find((c: any) => c.id === splitPanelId); + if (direct) return { found: direct, path: "top" as const }; + for (const comp of prevLayout.components) { + const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type; + const cfg = (comp as any)?.componentConfig || (comp as any)?.overrides || {}; + if (ct === "tabs-widget" || ct === "v2-tabs-widget") { + for (const tab of (cfg.tabs || [])) { + const nested = (tab.components || []).find((c: any) => c.id === splitPanelId); + if (nested) return { found: nested, path: "nested" as const, parentTabId: comp.id, parentTabTabId: tab.id }; + } + } + } + return null; + })(); + if (!findResult) return prevLayout; + const splitPanelComponent = findResult.found; const currentConfig = (splitPanelComponent as any).componentConfig || {}; const panelConfig = currentConfig[panelKey] || {}; @@ -6520,11 +6893,27 @@ export default function ScreenDesigner({ setSelectedPanelComponentInfo(null); + if (findResult.path === "top") { + return { ...prevLayout, components: prevLayout.components.map((c: any) => c.id === splitPanelId ? updatedComponent : c) }; + } return { ...prevLayout, - components: prevLayout.components.map((c) => - c.id === splitPanelId ? updatedComponent : c, - ), + components: prevLayout.components.map((c: any) => { + if (c.id !== findResult.parentTabId) return c; + const cfgKey = c.componentConfig?.tabs ? "componentConfig" : "overrides"; + const cfg = c[cfgKey] || {}; + return { + ...c, + [cfgKey]: { + ...cfg, + tabs: (cfg.tabs || []).map((t: any) => + t.id === findResult.parentTabTabId + ? { ...t, components: (t.components || []).map((tc: any) => tc.id === splitPanelId ? updatedComponent : tc) } + : t, + ), + }, + }; + }), }; }); }; @@ -7128,6 +7517,7 @@ export default function ScreenDesigner({ onSelectPanelComponent={(panelSide, compId, comp) => handleSelectPanelComponent(component.id, panelSide, compId, comp) } + onNestedPanelSelect={handleSelectPanelComponent} selectedPanelComponentId={ selectedPanelComponentInfo?.splitPanelId === component.id ? selectedPanelComponentInfo.componentId diff --git a/frontend/components/screen/panels/V2PropertiesPanel.tsx b/frontend/components/screen/panels/V2PropertiesPanel.tsx index b3377139..8885463d 100644 --- a/frontend/components/screen/panels/V2PropertiesPanel.tsx +++ b/frontend/components/screen/panels/V2PropertiesPanel.tsx @@ -247,6 +247,9 @@ export const V2PropertiesPanel: React.FC = ({ extraProps.currentTableName = currentTableName; extraProps.screenTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName; } + if (componentId === "v2-input") { + extraProps.allComponents = allComponents; + } return (
diff --git a/frontend/components/screen/widgets/TabsWidget.tsx b/frontend/components/screen/widgets/TabsWidget.tsx index 83c55777..688a6ca7 100644 --- a/frontend/components/screen/widgets/TabsWidget.tsx +++ b/frontend/components/screen/widgets/TabsWidget.tsx @@ -430,28 +430,28 @@ export function TabsWidget({ return ( ( - - )} - /> + components={componentDataList} + canvasWidth={canvasWidth} + canvasHeight={canvasHeight} + renderComponent={(comp) => ( + + )} + /> ); }; diff --git a/frontend/components/v2/config-panels/V2InputConfigPanel.tsx b/frontend/components/v2/config-panels/V2InputConfigPanel.tsx index 6e25d811..58b109a9 100644 --- a/frontend/components/v2/config-panels/V2InputConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2InputConfigPanel.tsx @@ -19,10 +19,11 @@ import { NumberingRuleConfig } from "@/types/numbering-rule"; interface V2InputConfigPanelProps { config: Record; onChange: (config: Record) => void; - menuObjid?: number; // 메뉴 OBJID (채번 규칙 필터링용) + menuObjid?: number; + allComponents?: any[]; } -export const V2InputConfigPanel: React.FC = ({ config, onChange, menuObjid }) => { +export const V2InputConfigPanel: React.FC = ({ config, onChange, menuObjid, allComponents = [] }) => { // 채번 규칙 목록 상태 const [numberingRules, setNumberingRules] = useState([]); const [loadingRules, setLoadingRules] = useState(false); @@ -483,73 +484,202 @@ export const V2InputConfigPanel: React.FC = ({ config, {/* 데이터 바인딩 설정 */} -
-
- { - if (checked) { - updateConfig("dataBinding", { - sourceComponentId: config.dataBinding?.sourceComponentId || "", - sourceColumn: config.dataBinding?.sourceColumn || "", - }); - } else { - updateConfig("dataBinding", undefined); - } - }} - /> - -
- - {config.dataBinding && ( -
-

- v2-table-list에서 행 선택 시 해당 컬럼 값이 자동으로 채워집니다 -

-
- - { - updateConfig("dataBinding", { - ...config.dataBinding, - sourceComponentId: e.target.value, - }); - }} - placeholder="예: tbl_items" - className="h-7 text-xs" - /> -

- 같은 화면 내 v2-table-list 컴포넌트의 ID -

-
-
- - { - updateConfig("dataBinding", { - ...config.dataBinding, - sourceColumn: e.target.value, - }); - }} - placeholder="예: item_number" - className="h-7 text-xs" - /> -

- 선택된 행에서 가져올 컬럼명 -

-
-
- )} -
+
); }; V2InputConfigPanel.displayName = "V2InputConfigPanel"; +/** + * 데이터 바인딩 설정 섹션 + * 같은 화면의 v2-table-list 컴포넌트를 자동 감지하여 드롭다운으로 표시 + */ +function DataBindingSection({ + config, + onChange, + allComponents, +}: { + config: Record; + onChange: (config: Record) => void; + allComponents: any[]; +}) { + const [tableColumns, setTableColumns] = useState([]); + const [loadingColumns, setLoadingColumns] = useState(false); + + // 같은 화면의 v2-table-list 컴포넌트만 필터링 + const tableListComponents = React.useMemo(() => { + return allComponents.filter((comp) => { + const type = + comp.componentType || + comp.widgetType || + comp.componentConfig?.type || + (comp.url && comp.url.split("/").pop()); + return type === "v2-table-list"; + }); + }, [allComponents]); + + // 선택된 테이블 컴포넌트의 테이블명 추출 + const selectedTableComponent = React.useMemo(() => { + if (!config.dataBinding?.sourceComponentId) return null; + return tableListComponents.find((comp) => comp.id === config.dataBinding.sourceComponentId); + }, [tableListComponents, config.dataBinding?.sourceComponentId]); + + const selectedTableName = React.useMemo(() => { + if (!selectedTableComponent) return null; + return ( + selectedTableComponent.componentConfig?.selectedTable || + selectedTableComponent.selectedTable || + null + ); + }, [selectedTableComponent]); + + // 선택된 테이블의 컬럼 목록 로드 + useEffect(() => { + if (!selectedTableName) { + setTableColumns([]); + return; + } + + const loadColumns = async () => { + setLoadingColumns(true); + try { + const { tableTypeApi } = await import("@/lib/api/screen"); + const response = await tableTypeApi.getTableTypeColumns(selectedTableName); + if (response.success && response.data) { + const cols = response.data.map((col: any) => col.column_name).filter(Boolean); + setTableColumns(cols); + } + } catch { + // 컬럼 정보를 못 가져오면 테이블 컴포넌트의 columns에서 추출 + const configColumns = selectedTableComponent?.componentConfig?.columns; + if (Array.isArray(configColumns)) { + setTableColumns(configColumns.map((c: any) => c.columnName).filter(Boolean)); + } + } finally { + setLoadingColumns(false); + } + }; + loadColumns(); + }, [selectedTableName, selectedTableComponent]); + + const updateConfig = (field: string, value: any) => { + onChange({ ...config, [field]: value }); + }; + + return ( +
+
+ { + if (checked) { + const firstTable = tableListComponents[0]; + updateConfig("dataBinding", { + sourceComponentId: firstTable?.id || "", + sourceColumn: "", + }); + } else { + updateConfig("dataBinding", undefined); + } + }} + /> + +
+ + {config.dataBinding && ( +
+

+ 테이블에서 행 선택 시 해당 컬럼 값이 자동으로 채워집니다 +

+ + {/* 소스 테이블 컴포넌트 선택 */} +
+ + {tableListComponents.length === 0 ? ( +

이 화면에 v2-table-list 컴포넌트가 없습니다

+ ) : ( + + )} +
+ + {/* 소스 컬럼 선택 */} + {config.dataBinding?.sourceComponentId && ( +
+ + {loadingColumns ? ( +

컬럼 로딩 중...

+ ) : tableColumns.length === 0 ? ( + <> + { + updateConfig("dataBinding", { + ...config.dataBinding, + sourceColumn: e.target.value, + }); + }} + placeholder="컬럼명 직접 입력" + className="h-7 text-xs" + /> +

컬럼 정보를 불러올 수 없어 직접 입력

+ + ) : ( + + )} +
+ )} +
+ )} +
+ ); +} + export default V2InputConfigPanel; diff --git a/frontend/hooks/pop/executePopAction.ts b/frontend/hooks/pop/executePopAction.ts index ada6ad77..ad0981b6 100644 --- a/frontend/hooks/pop/executePopAction.ts +++ b/frontend/hooks/pop/executePopAction.ts @@ -322,7 +322,9 @@ export async function executeTaskList( } case "custom-event": - if (task.eventName) { + if (task.flowId) { + await apiClient.post(`/dataflow/node-flows/${task.flowId}/execute`, {}); + } else if (task.eventName) { publish(task.eventName, task.eventPayload ?? {}); } break; diff --git a/frontend/hooks/pop/useConnectionResolver.ts b/frontend/hooks/pop/useConnectionResolver.ts index 14bd321a..4aa03be3 100644 --- a/frontend/hooks/pop/useConnectionResolver.ts +++ b/frontend/hooks/pop/useConnectionResolver.ts @@ -20,7 +20,6 @@ import { usePopEvent } from "./usePopEvent"; import type { PopDataConnection } from "@/components/pop/designer/types/pop-layout"; import { PopComponentRegistry, - type ConnectionMetaItem, } from "@/lib/registry/PopComponentRegistry"; interface UseConnectionResolverOptions { @@ -29,14 +28,21 @@ interface UseConnectionResolverOptions { componentTypes?: Map; } +interface AutoMatchPair { + sourceKey: string; + targetKey: string; + isFilter: boolean; +} + /** - * 소스/타겟의 connectionMeta에서 자동 매칭 가능한 이벤트 쌍을 찾는다. - * 규칙: category="event"이고 key가 동일한 쌍 + * 소스/타겟의 connectionMeta에서 자동 매칭 가능한 쌍을 찾는다. + * 규칙 1: category="event"이고 key가 동일한 쌍 (이벤트 매칭) + * 규칙 2: 소스 type="filter_value" + 타겟 type="filter_value" (필터 매칭) */ function getAutoMatchPairs( sourceType: string, targetType: string -): { sourceKey: string; targetKey: string }[] { +): AutoMatchPair[] { const sourceDef = PopComponentRegistry.getComponent(sourceType); const targetDef = PopComponentRegistry.getComponent(targetType); @@ -44,14 +50,18 @@ function getAutoMatchPairs( return []; } - const pairs: { sourceKey: string; targetKey: string }[] = []; + const pairs: AutoMatchPair[] = []; for (const s of sourceDef.connectionMeta.sendable) { - if (s.category !== "event") continue; for (const r of targetDef.connectionMeta.receivable) { - if (r.category !== "event") continue; - if (s.key === r.key) { - pairs.push({ sourceKey: s.key, targetKey: r.key }); + if (s.category === "event" && r.category === "event" && s.key === r.key) { + pairs.push({ sourceKey: s.key, targetKey: r.key, isFilter: false }); + } + if (s.type === "filter_value" && r.type === "filter_value") { + pairs.push({ sourceKey: s.key, targetKey: r.key, isFilter: true }); + } + if (s.type === "all_rows" && r.type === "all_rows") { + pairs.push({ sourceKey: s.key, targetKey: r.key, isFilter: false }); } } } @@ -93,10 +103,30 @@ export function useConnectionResolver({ const targetEvent = `__comp_input__${conn.targetComponent}__${pair.targetKey}`; const unsub = subscribe(sourceEvent, (payload: unknown) => { - publish(targetEvent, { - value: payload, - _connectionId: conn.id, - }); + if (pair.isFilter) { + const data = payload as Record | null; + const fieldName = data?.fieldName as string | undefined; + const filterColumns = data?.filterColumns as string[] | undefined; + const filterMode = (data?.filterMode as string) || "contains"; + // conn.filterConfig에 targetColumn이 명시되어 있으면 우선 사용 + const effectiveColumn = conn.filterConfig?.targetColumn || fieldName; + const effectiveMode = conn.filterConfig?.filterMode || filterMode; + const baseFilterConfig = effectiveColumn + ? { targetColumn: effectiveColumn, targetColumns: conn.filterConfig?.targetColumns || (filterColumns?.length ? filterColumns : [effectiveColumn]), filterMode: effectiveMode } + : conn.filterConfig; + publish(targetEvent, { + value: payload, + filterConfig: conn.filterConfig?.isSubTable + ? { ...baseFilterConfig, isSubTable: true } + : baseFilterConfig, + _connectionId: conn.id, + }); + } else { + publish(targetEvent, { + value: payload, + _connectionId: conn.id, + }); + } }); unsubscribers.push(unsub); } @@ -121,13 +151,22 @@ export function useConnectionResolver({ const unsub = subscribe(sourceEvent, (payload: unknown) => { const targetEvent = `__comp_input__${conn.targetComponent}__${conn.targetInput || conn.targetField}`; - const enrichedPayload = { - value: payload, - filterConfig: conn.filterConfig, - _connectionId: conn.id, - }; + let resolvedFilterConfig = conn.filterConfig; + if (!resolvedFilterConfig) { + const data = payload as Record | null; + const fieldName = data?.fieldName as string | undefined; + const filterColumns = data?.filterColumns as string[] | undefined; + if (fieldName) { + const filterMode = (data?.filterMode as string) || "contains"; + resolvedFilterConfig = { targetColumn: fieldName, targetColumns: filterColumns?.length ? filterColumns : [fieldName], filterMode: filterMode as "equals" | "contains" | "starts_with" | "range" }; + } + } - publish(targetEvent, enrichedPayload); + publish(targetEvent, { + value: payload, + filterConfig: resolvedFilterConfig, + _connectionId: conn.id, + }); }); unsubscribers.push(unsub); } diff --git a/frontend/hooks/useLogin.ts b/frontend/hooks/useLogin.ts index 09c32d5f..bd0cf9a2 100644 --- a/frontend/hooks/useLogin.ts +++ b/frontend/hooks/useLogin.ts @@ -20,6 +20,21 @@ export const useLogin = () => { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(""); const [showPassword, setShowPassword] = useState(false); + const [isPopMode, setIsPopMode] = useState(false); + + // localStorage에서 POP 모드 상태 복원 + useEffect(() => { + const saved = localStorage.getItem("popLoginMode"); + if (saved === "true") setIsPopMode(true); + }, []); + + const togglePopMode = useCallback(() => { + setIsPopMode((prev) => { + const next = !prev; + localStorage.setItem("popLoginMode", String(next)); + return next; + }); + }, []); /** * 폼 입력값 변경 처리 @@ -141,17 +156,22 @@ export const useLogin = () => { // 쿠키에도 저장 (미들웨어에서 사용) document.cookie = `authToken=${result.data.token}; path=/; max-age=86400; SameSite=Lax`; - // 로그인 성공 - 첫 번째 접근 가능한 메뉴로 리다이렉트 - const firstMenuPath = result.data?.firstMenuPath; - - if (firstMenuPath) { - // 접근 가능한 메뉴가 있으면 해당 메뉴로 이동 - console.log("첫 번째 접근 가능한 메뉴로 이동:", firstMenuPath); - router.push(firstMenuPath); + if (isPopMode) { + const popPath = result.data?.popLandingPath; + if (popPath) { + router.push(popPath); + } else { + setError("POP 화면이 설정되어 있지 않습니다. 관리자에게 메뉴 관리에서 POP 화면을 설정해달라고 요청하세요."); + setIsLoading(false); + return; + } } else { - // 접근 가능한 메뉴가 없으면 메인 페이지로 이동 - console.log("접근 가능한 메뉴가 없어 메인 페이지로 이동"); - router.push(AUTH_CONFIG.ROUTES.MAIN); + const firstMenuPath = result.data?.firstMenuPath; + if (firstMenuPath) { + router.push(firstMenuPath); + } else { + router.push(AUTH_CONFIG.ROUTES.MAIN); + } } } else { // 로그인 실패 @@ -165,7 +185,7 @@ export const useLogin = () => { setIsLoading(false); } }, - [formData, validateForm, apiCall, router], + [formData, validateForm, apiCall, router, isPopMode], ); // 컴포넌트 마운트 시 기존 인증 상태 확인 @@ -179,10 +199,12 @@ export const useLogin = () => { isLoading, error, showPassword, + isPopMode, // 액션 handleInputChange, handleLogin, togglePasswordVisibility, + togglePopMode, }; }; diff --git a/frontend/lib/api/auditLog.ts b/frontend/lib/api/auditLog.ts index 96c5463e..dd8c94d7 100644 --- a/frontend/lib/api/auditLog.ts +++ b/frontend/lib/api/auditLog.ts @@ -3,6 +3,7 @@ import { apiClient } from "./client"; export interface AuditLogEntry { id: number; company_code: string; + company_name: string | null; user_id: string; user_name: string | null; action: string; diff --git a/frontend/lib/api/menu.ts b/frontend/lib/api/menu.ts index adbd53a0..7ddafaa0 100644 --- a/frontend/lib/api/menu.ts +++ b/frontend/lib/api/menu.ts @@ -81,6 +81,23 @@ export interface ApiResponse { errorCode?: string; } +export interface PopMenuItem { + objid: string; + menu_name_kor: string; + menu_url: string; + menu_desc: string; + seq: number; + company_code: string; + status: string; + screenId?: number; +} + +export interface PopMenuResponse { + parentMenu: PopMenuItem | null; + childMenus: PopMenuItem[]; + landingMenu: PopMenuItem | null; +} + export const menuApi = { // 관리자 메뉴 목록 조회 (좌측 사이드바용 - active만 표시) getAdminMenus: async (): Promise> => { @@ -96,6 +113,12 @@ export const menuApi = { return response.data; }, + // POP 메뉴 목록 조회 ([POP] 태그 L1 하위 active 메뉴) + getPopMenus: async (): Promise> => { + const response = await apiClient.get("/admin/pop-menus"); + return response.data; + }, + // 관리자 메뉴 목록 조회 (메뉴 관리 화면용 - 모든 상태 표시) getAdminMenusForManagement: async (): Promise> => { const response = await apiClient.get("/admin/menus", { params: { menuType: "0", includeInactive: "true" } }); diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 8ee1ba20..867c8a86 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -235,6 +235,8 @@ export interface DynamicComponentRendererProps { // 🆕 분할 패널 내부 컴포넌트 선택 콜백 onSelectPanelComponent?: (panelSide: "left" | "right", compId: string, comp: any) => void; selectedPanelComponentId?: string; + // 중첩된 분할패널 내부 컴포넌트 선택 콜백 (탭 안의 분할패널) + onNestedPanelSelect?: (splitPanelId: string, panelSide: "left" | "right", compId: string, comp: any) => void; flowSelectedStepId?: number | null; onFlowSelectedDataChange?: (selectedData: any[], stepId: number | null) => void; // 테이블 새로고침 키 @@ -868,6 +870,7 @@ export const DynamicComponentRenderer: React.FC = // 🆕 분할 패널 내부 컴포넌트 선택 콜백 onSelectPanelComponent: props.onSelectPanelComponent, selectedPanelComponentId: props.selectedPanelComponentId, + onNestedPanelSelect: props.onNestedPanelSelect, }; // 렌더러가 클래스인지 함수인지 확인 diff --git a/frontend/lib/registry/PopComponentRegistry.ts b/frontend/lib/registry/PopComponentRegistry.ts index 3793bc2d..2fe44592 100644 --- a/frontend/lib/registry/PopComponentRegistry.ts +++ b/frontend/lib/registry/PopComponentRegistry.ts @@ -35,6 +35,7 @@ export interface PopComponentDefinition { preview?: React.ComponentType<{ config?: any }>; // 디자이너 미리보기용 defaultProps?: Record; connectionMeta?: ComponentConnectionMeta; + getDynamicConnectionMeta?: (config: Record) => ComponentConnectionMeta; // POP 전용 속성 touchOptimized?: boolean; minTouchArea?: number; diff --git a/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx b/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx index 4c3d3112..63a4288a 100644 --- a/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx +++ b/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx @@ -20,7 +20,6 @@ import { GeneratedLocation, RackStructureContext, } from "./types"; -import { applyLocationPattern, DEFAULT_CODE_PATTERN, DEFAULT_NAME_PATTERN } from "./patternUtils"; // 기존 위치 데이터 타입 interface ExistingLocation { @@ -513,27 +512,23 @@ export const RackStructureComponent: React.FC = ({ return { totalLocations, totalRows, maxLevel }; }, [conditions]); - // 위치 코드 생성 (패턴 기반) + // 위치 코드 생성 const generateLocationCode = useCallback( (row: number, level: number): { code: string; name: string } => { - const vars = { - warehouse: context?.warehouseCode || "WH001", - warehouseName: context?.warehouseName || "", - floor: context?.floor || "1", - zone: context?.zone || "A", - row, - level, - }; + const warehouseCode = context?.warehouseCode || "WH001"; + const floor = context?.floor || "1"; + const zone = context?.zone || "A"; - const codePattern = config.codePattern || DEFAULT_CODE_PATTERN; - const namePattern = config.namePattern || DEFAULT_NAME_PATTERN; + // 코드 생성 (예: WH001-1층D구역-01-1) + const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`; - return { - code: applyLocationPattern(codePattern, vars), - name: applyLocationPattern(namePattern, vars), - }; + // 이름 생성 - zone에 이미 "구역"이 포함되어 있으면 그대로 사용 + const zoneName = zone.includes("구역") ? zone : `${zone}구역`; + const name = `${zoneName}-${row.toString().padStart(2, "0")}열-${level}단`; + + return { code, name }; }, - [context, config.codePattern, config.namePattern], + [context], ); // 미리보기 생성 diff --git a/frontend/lib/registry/components/rack-structure/RackStructureConfigPanel.tsx b/frontend/lib/registry/components/rack-structure/RackStructureConfigPanel.tsx index ddaebfa2..17e1a781 100644 --- a/frontend/lib/registry/components/rack-structure/RackStructureConfigPanel.tsx +++ b/frontend/lib/registry/components/rack-structure/RackStructureConfigPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useMemo } from "react"; +import React, { useState, useEffect } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; @@ -12,47 +12,6 @@ import { SelectValue, } from "@/components/ui/select"; import { RackStructureComponentConfig, FieldMapping } from "./types"; -import { applyLocationPattern, DEFAULT_CODE_PATTERN, DEFAULT_NAME_PATTERN, PATTERN_VARIABLES } from "./patternUtils"; - -// 패턴 미리보기 서브 컴포넌트 -const PatternPreview: React.FC<{ - codePattern?: string; - namePattern?: string; -}> = ({ codePattern, namePattern }) => { - const sampleVars = { - warehouse: "WH002", - warehouseName: "2창고", - floor: "2층", - zone: "A구역", - row: 1, - level: 3, - }; - - const previewCode = useMemo( - () => applyLocationPattern(codePattern || DEFAULT_CODE_PATTERN, sampleVars), - [codePattern], - ); - const previewName = useMemo( - () => applyLocationPattern(namePattern || DEFAULT_NAME_PATTERN, sampleVars), - [namePattern], - ); - - return ( -
-
미리보기 (2창고 / 2층 / A구역 / 1열 / 3단)
-
-
- 위치코드: - {previewCode} -
-
- 위치명: - {previewName} -
-
-
- ); -}; interface RackStructureConfigPanelProps { config: RackStructureComponentConfig; @@ -246,61 +205,6 @@ export const RackStructureConfigPanel: React.FC =
- {/* 위치코드 패턴 설정 */} -
-
위치코드/위치명 패턴
-

- 변수를 조합하여 위치코드와 위치명 생성 규칙을 설정하세요 -

- - {/* 위치코드 패턴 */} -
- - handleChange("codePattern", e.target.value || undefined)} - placeholder="{warehouse}-{floor}{zone}-{row:02}-{level}" - className="h-8 font-mono text-xs" - /> -

- 비워두면 기본값: {"{warehouse}-{floor}{zone}-{row:02}-{level}"} -

-
- - {/* 위치명 패턴 */} -
- - handleChange("namePattern", e.target.value || undefined)} - placeholder="{zone}-{row:02}열-{level}단" - className="h-8 font-mono text-xs" - /> -

- 비워두면 기본값: {"{zone}-{row:02}열-{level}단"} -

-
- - {/* 실시간 미리보기 */} - - - {/* 사용 가능한 변수 목록 */} -
-
사용 가능한 변수
-
- {PATTERN_VARIABLES.map((v) => ( -
- {v.token} - {v.description} -
- ))} -
-
-
- {/* 제한 설정 */}
제한 설정
diff --git a/frontend/lib/registry/components/rack-structure/patternUtils.ts b/frontend/lib/registry/components/rack-structure/patternUtils.ts deleted file mode 100644 index b5139c0b..00000000 --- a/frontend/lib/registry/components/rack-structure/patternUtils.ts +++ /dev/null @@ -1,7 +0,0 @@ -// rack-structure는 v2-rack-structure의 patternUtils를 재사용 -export { - applyLocationPattern, - DEFAULT_CODE_PATTERN, - DEFAULT_NAME_PATTERN, - PATTERN_VARIABLES, -} from "../v2-rack-structure/patternUtils"; diff --git a/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx b/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx index b6f929be..03997ce0 100644 --- a/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx +++ b/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx @@ -5,71 +5,45 @@ import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponent import { V2InputDefinition } from "./index"; import { V2Input } from "@/components/v2/V2Input"; import { isColumnRequiredByMeta } from "../../DynamicComponentRenderer"; -import { v2EventBus, V2_EVENTS } from "@/lib/v2-core"; /** * dataBinding이 설정된 v2-input을 위한 wrapper - * v2-table-list의 TABLE_DATA_CHANGE 이벤트를 구독하여 + * v2-table-list의 선택 이벤트를 window CustomEvent로 수신하여 * 선택된 행의 특정 컬럼 값을 자동으로 formData에 반영 */ function DataBindingWrapper({ dataBinding, columnName, onFormDataChange, - isInteractive, children, }: { dataBinding: { sourceComponentId: string; sourceColumn: string }; columnName: string; onFormDataChange?: (field: string, value: any) => void; - isInteractive?: boolean; children: React.ReactNode; }) { const lastBoundValueRef = useRef(null); + const onFormDataChangeRef = useRef(onFormDataChange); + onFormDataChangeRef.current = onFormDataChange; useEffect(() => { if (!dataBinding?.sourceComponentId || !dataBinding?.sourceColumn) return; - console.log("[DataBinding] 구독 시작:", { - sourceComponentId: dataBinding.sourceComponentId, - sourceColumn: dataBinding.sourceColumn, - targetColumn: columnName, - isInteractive, - hasOnFormDataChange: !!onFormDataChange, - }); + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (!detail || detail.source !== dataBinding.sourceComponentId) return; - const unsubscribe = v2EventBus.subscribe(V2_EVENTS.TABLE_DATA_CHANGE, (payload: any) => { - console.log("[DataBinding] TABLE_DATA_CHANGE 수신:", { - payloadSource: payload.source, - expectedSource: dataBinding.sourceComponentId, - dataLength: payload.data?.length, - match: payload.source === dataBinding.sourceComponentId, - }); - - if (payload.source !== dataBinding.sourceComponentId) return; - - const selectedData = payload.data; - if (selectedData && selectedData.length > 0) { - const value = selectedData[0][dataBinding.sourceColumn]; - console.log("[DataBinding] 바인딩 값:", { column: dataBinding.sourceColumn, value, columnName }); - if (value !== lastBoundValueRef.current) { - lastBoundValueRef.current = value; - if (onFormDataChange && columnName) { - onFormDataChange(columnName, value ?? ""); - } - } - } else { - if (lastBoundValueRef.current !== null) { - lastBoundValueRef.current = null; - if (onFormDataChange && columnName) { - onFormDataChange(columnName, ""); - } - } + const selectedRow = detail.data?.[0]; + const value = selectedRow?.[dataBinding.sourceColumn] ?? ""; + if (value !== lastBoundValueRef.current) { + lastBoundValueRef.current = value; + onFormDataChangeRef.current?.(columnName, value); } - }); + }; - return () => unsubscribe(); - }, [dataBinding?.sourceComponentId, dataBinding?.sourceColumn, columnName, onFormDataChange, isInteractive]); + window.addEventListener("v2-table-selection", handler); + return () => window.removeEventListener("v2-table-selection", handler); + }, [dataBinding?.sourceComponentId, dataBinding?.sourceColumn, columnName]); return <>{children}; } @@ -102,18 +76,6 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer { const dataBinding = config.dataBinding || (component as any).dataBinding || config.componentConfig?.dataBinding; - if (dataBinding || (config as any).dataBinding || (component as any).dataBinding) { - console.log("[V2InputRenderer] dataBinding 탐색:", { - componentId: component.id, - columnName, - configKeys: Object.keys(config), - configDataBinding: config.dataBinding, - componentDataBinding: (component as any).dataBinding, - nestedDataBinding: config.componentConfig?.dataBinding, - finalDataBinding: dataBinding, - }); - } - const inputElement = ( {inputElement} diff --git a/frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx b/frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx index 127ffac8..627cf1fa 100644 --- a/frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx +++ b/frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx @@ -96,12 +96,12 @@ const ConditionCard: React.FC = ({ }; return ( -
+
{/* 헤더 */} -
+
조건 {index + 1} {!readonly && ( - )} @@ -112,7 +112,7 @@ const ConditionCard: React.FC = ({ {/* 열 범위 */}
-
-
{/* 계산 결과 */} -
+
{locationCount > 0 ? ( <> {localValues.startRow}열 ~ {localValues.endRow}열 x {localValues.levels}단 ={" "} @@ -627,7 +627,7 @@ export const RackStructureComponent: React.FC = ({ -
렉 라인 구조 설정 +
렉 라인 구조 설정 {!readonly && (
@@ -720,8 +720,8 @@ export const RackStructureComponent: React.FC = ({ {/* 기존 데이터 존재 알림 */} {!isCheckingDuplicates && existingLocations.length > 0 && !hasDuplicateWithExisting && ( - - + + 해당 창고/층/구역에 {existingLocations.length}개의 위치가 이미 등록되어 있습니다. @@ -730,9 +730,9 @@ export const RackStructureComponent: React.FC = ({ {/* 현재 매핑된 값 표시 */} {(context.warehouseCode || context.warehouseName || context.floor || context.zone) && ( -
+
{(context.warehouseCode || context.warehouseName) && ( - + 창고: {context.warehouseName || context.warehouseCode} {context.warehouseName && context.warehouseCode && ` (${context.warehouseCode})`} @@ -749,28 +749,28 @@ export const RackStructureComponent: React.FC = ({ )} {context.status && ( - 상태: {context.status} + 상태: {context.status} )}
)} {/* 안내 메시지 */} -
-
    +
    +
    1. - + 1 조건 추가 버튼을 클릭하여 렉 라인 조건을 생성하세요
    2. - + 2 각 조건마다 열 범위와 단 수를 입력하세요
    3. - + 3 예시: 조건1(1~3열, 3단), 조건2(4~6열, 5단) @@ -780,9 +780,9 @@ export const RackStructureComponent: React.FC = ({ {/* 조건 목록 또는 빈 상태 */} {conditions.length === 0 ? ( -
      -
      📦
      -

      조건을 추가하여 렉 구조를 설정하세요

      +
      +
      📦
      +

      조건을 추가하여 렉 구조를 설정하세요

      {!readonly && (