diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index b17484ce..dc8cf064 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -1854,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/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/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/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/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} +

diff --git a/frontend/components/screen/ResponsiveGridRenderer.tsx b/frontend/components/screen/ResponsiveGridRenderer.tsx index 8f671c21..a902d7f3 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({ @@ -227,6 +229,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({ @@ -234,6 +240,8 @@ export function ResponsiveGridRenderer({ mainComponent: fwComp, overlayComps: normalComps, normalComps: [], + rowMinY, + rowMaxBottom, }); } } else if (fullWidthComps.length > 0) { @@ -243,6 +251,8 @@ export function ResponsiveGridRenderer({ mainComponent: fwComp, overlayComps: [], normalComps: [], + rowMinY, + rowMaxBottom, }); } } else { @@ -250,6 +260,8 @@ export function ResponsiveGridRenderer({ type: "normal", overlayComps: [], normalComps, + rowMinY, + rowMaxBottom, }); } } @@ -261,15 +273,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 }}> + +
); } @@ -290,7 +313,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); @@ -337,10 +360,10 @@ export function ResponsiveGridRenderer({ flexGrow: 1, 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/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/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/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 && (