From b1e50f2e0a3142dfacce983ef2b2c67eb55cbe25 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 12 Mar 2026 05:14:27 +0900 Subject: [PATCH 1/5] feat: enhance audit logging and add company name to audit entries - Integrated detailed audit logging for update and delete actions in the CommonCodeController and DDLController. - Added company name retrieval to the audit log entries for better traceability. - Updated the audit log service to include company name in the log entries. - Modified the frontend audit log page to display company names alongside company codes for improved clarity. Made-with: Cursor --- .../src/controllers/adminController.ts | 2 +- .../src/controllers/categoryTreeController.ts | 52 ++++ .../src/controllers/commonCodeController.ts | 27 ++ backend-node/src/controllers/ddlController.ts | 13 + .../controllers/numberingRuleController.ts | 68 +++++ .../controllers/tableManagementController.ts | 17 +- backend-node/src/services/auditLogService.ts | 34 ++- frontend/app/(main)/admin/audit-log/page.tsx | 10 +- frontend/components/common/ScreenModal.tsx | 35 ++- .../screen/panels/V2PropertiesPanel.tsx | 3 + .../v2/config-panels/V2InputConfigPanel.tsx | 260 +++++++++++++----- frontend/lib/api/auditLog.ts | 1 + .../components/v2-input/V2InputRenderer.tsx | 69 +---- .../v2-table-list/TableListComponent.tsx | 13 + 14 files changed, 462 insertions(+), 142 deletions(-) diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 6f0997a9..7cc4b29d 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -1814,7 +1814,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/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/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/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} +

- {/* 위치코드 패턴 설정 */} -
-
위치코드/위치명 패턴
-

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

- - {/* 위치코드 패턴 */} -
- - 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-rack-structure/RackStructureComponent.tsx b/frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx index cd107958..cef90668 100644 --- a/frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx +++ b/frontend/lib/registry/components/v2-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 { @@ -494,27 +493,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/v2-rack-structure/RackStructureConfigPanel.tsx b/frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx index ddaebfa2..17e1a781 100644 --- a/frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-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/v2-rack-structure/patternUtils.ts b/frontend/lib/registry/components/v2-rack-structure/patternUtils.ts deleted file mode 100644 index d226db82..00000000 --- a/frontend/lib/registry/components/v2-rack-structure/patternUtils.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * 위치코드/위치명 패턴 변환 유틸리티 - * - * 사용 가능한 변수: - * {warehouse} - 창고 코드 (예: WH002) - * {warehouseName} - 창고명 (예: 2창고) - * {floor} - 층 (예: 2층) - * {zone} - 구역 (예: A구역) - * {row} - 열 번호 (예: 1) - * {row:02} - 열 번호 2자리 (예: 01) - * {row:03} - 열 번호 3자리 (예: 001) - * {level} - 단 번호 (예: 1) - * {level:02} - 단 번호 2자리 (예: 01) - * {level:03} - 단 번호 3자리 (예: 001) - */ - -interface PatternVariables { - warehouse?: string; - warehouseName?: string; - floor?: string; - zone?: string; - row: number; - level: number; -} - -// 기본 패턴 (하드코딩 대체) -export const DEFAULT_CODE_PATTERN = "{warehouse}-{floor}{zone}-{row:02}-{level}"; -export const DEFAULT_NAME_PATTERN = "{zone}-{row:02}열-{level}단"; - -/** - * 패턴 문자열에서 변수를 치환하여 결과 문자열 반환 - */ -export function applyLocationPattern(pattern: string, vars: PatternVariables): string { - let result = pattern; - - // zone에 "구역" 포함 여부에 따른 처리 없이 있는 그대로 치환 - const simpleVars: Record = { - warehouse: vars.warehouse, - warehouseName: vars.warehouseName, - floor: vars.floor, - zone: vars.zone, - }; - - // 단순 문자열 변수 치환 - for (const [key, value] of Object.entries(simpleVars)) { - result = result.replace(new RegExp(`\\{${key}\\}`, "g"), value || ""); - } - - // 숫자 변수 (row, level) - zero-pad 지원 - const numericVars: Record = { - row: vars.row, - level: vars.level, - }; - - for (const [key, value] of Object.entries(numericVars)) { - // {row:02}, {level:03} 같은 zero-pad 패턴 - const padRegex = new RegExp(`\\{${key}:(\\d+)\\}`, "g"); - result = result.replace(padRegex, (_, padWidth) => { - return value.toString().padStart(parseInt(padWidth), "0"); - }); - - // {row}, {level} 같은 단순 패턴 - result = result.replace(new RegExp(`\\{${key}\\}`, "g"), value.toString()); - } - - return result; -} - -// 패턴에서 사용 가능한 변수 목록 -export const PATTERN_VARIABLES = [ - { token: "{warehouse}", description: "창고 코드", example: "WH002" }, - { token: "{warehouseName}", description: "창고명", example: "2창고" }, - { token: "{floor}", description: "층", example: "2층" }, - { token: "{zone}", description: "구역", example: "A구역" }, - { token: "{row}", description: "열 번호", example: "1" }, - { token: "{row:02}", description: "열 번호 (2자리)", example: "01" }, - { token: "{row:03}", description: "열 번호 (3자리)", example: "001" }, - { token: "{level}", description: "단 번호", example: "1" }, - { token: "{level:02}", description: "단 번호 (2자리)", example: "01" }, - { token: "{level:03}", description: "단 번호 (3자리)", example: "001" }, -]; diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 9d987d5e..1bc797d8 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -271,8 +271,9 @@ export const SplitPanelLayoutComponent: React.FC const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null); const [resizingCompId, setResizingCompId] = useState(null); const [resizeSize, setResizeSize] = useState<{ width: number; height: number } | null>(null); - // 🆕 외부에서 전달받은 선택 상태 사용 (탭 컴포넌트와 동일 구조) - const selectedPanelComponentId = externalSelectedPanelComponentId || null; + // 내부 선택 상태 (외부 prop 없을 때 fallback) + const [internalSelectedCompId, setInternalSelectedCompId] = useState(null); + const selectedPanelComponentId = externalSelectedPanelComponentId ?? internalSelectedCompId; // 🆕 커스텀 모드: 분할패널 내 탭 컴포넌트의 선택 상태 관리 const [nestedTabSelectedCompId, setNestedTabSelectedCompId] = useState(undefined); const rafRef = useRef(null); @@ -3052,9 +3053,15 @@ export const SplitPanelLayoutComponent: React.FC
)} - + {/* 좌측 데이터 목록/테이블/커스텀 */} - {console.log("🔍 [SplitPanel] 왼쪽 패널 displayMode:", componentConfig.leftPanel?.displayMode, "isDesignMode:", isDesignMode)} {componentConfig.leftPanel?.displayMode === "custom" ? ( // 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치
}} onClick={(e) => { e.stopPropagation(); - // 패널 컴포넌트 선택 시 탭 내 선택 해제 if (comp.componentType !== "v2-tabs-widget") { setNestedTabSelectedCompId(undefined); } + setInternalSelectedCompId(comp.id); onSelectPanelComponent?.("left", comp.id, comp); }} > @@ -3917,7 +3924,14 @@ export const SplitPanelLayoutComponent: React.FC
)} - + {/* 추가 탭 컨텐츠 */} {activeTabIndex > 0 ? ( (() => { @@ -4289,6 +4303,7 @@ export const SplitPanelLayoutComponent: React.FC if (comp.componentType !== "v2-tabs-widget") { setNestedTabSelectedCompId(undefined); } + setInternalSelectedCompId(comp.id); onSelectPanelComponent?.("right", comp.id, comp); }} > diff --git a/frontend/lib/registry/components/v2-tabs-widget/tabs-component.tsx b/frontend/lib/registry/components/v2-tabs-widget/tabs-component.tsx index 491b45e4..ad42938c 100644 --- a/frontend/lib/registry/components/v2-tabs-widget/tabs-component.tsx +++ b/frontend/lib/registry/components/v2-tabs-widget/tabs-component.tsx @@ -417,12 +417,39 @@ const TabsDesignEditor: React.FC<{ width: displayWidth, height: displayHeight, }} + > -
+
{ + if (!onUpdateComponent) return; + const updatedTabs = tabs.map((t) => { + if (t.id !== activeTabId) return t; + return { + ...t, + components: (t.components || []).map((c) => + c.id === comp.id ? { ...c, componentConfig: updated.componentConfig || updated.overrides || c.componentConfig } : c + ), + }; + }); + const configKey = component.componentConfig ? "componentConfig" : "overrides"; + const existingConfig = component[configKey] || {}; + onUpdateComponent({ + ...component, + [configKey]: { ...existingConfig, tabs: updatedTabs }, + }); + }, + onSelectPanelComponent: (panelSide: string, compId: string, panelComp: any) => { + onSelectTabComponent?.(activeTabId, comp.id, { ...comp, _selectedPanelSide: panelSide, _selectedPanelCompId: compId, _selectedPanelComp: panelComp } as any); + }, + } : {})} />
From f65b57410c38e8eae121d482b22fd47faf04e8ed Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 12 Mar 2026 10:41:57 +0900 Subject: [PATCH 3/5] feat: add createAuditLog endpoint and integrate audit logging for screen copy operations - Implemented a new endpoint for creating audit logs directly from the frontend, allowing for detailed tracking of actions related to screen copying. - Enhanced the existing screen management functionality by removing redundant audit logging within the copy operations and centralizing it through the new createAuditLog endpoint. - Updated the frontend to log group copy actions, capturing relevant details such as resource type, resource ID, and changes made during the operation. Made-with: Cursor --- .../src/controllers/auditLogController.ts | 39 +++++++++++++- .../controllers/screenManagementController.ts | 28 ---------- backend-node/src/routes/auditLogRoutes.ts | 3 +- .../components/screen/CopyScreenModal.tsx | 22 ++++++++ .../components/screen/widgets/TabsWidget.tsx | 52 ++++++++++--------- .../SplitPanelLayoutComponent.tsx | 18 +++---- .../v2-tabs-widget/tabs-component.tsx | 4 -- 7 files changed, 98 insertions(+), 68 deletions(-) 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/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/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/frontend/components/screen/CopyScreenModal.tsx b/frontend/components/screen/CopyScreenModal.tsx index f8190ce7..0d7840d1 100644 --- a/frontend/components/screen/CopyScreenModal.tsx +++ b/frontend/components/screen/CopyScreenModal.tsx @@ -1165,6 +1165,28 @@ export default function CopyScreenModal({ } } + // 그룹 복제 요약 감사 로그 1건 기록 + try { + await apiClient.post("/audit-log", { + action: "COPY", + resourceType: "SCREEN", + resourceId: String(sourceGroup.id), + resourceName: sourceGroup.group_name, + summary: `그룹 "${sourceGroup.group_name}" → "${rootGroupName}" 복제 (그룹 ${stats.groups}개, 화면 ${stats.screens}개)${finalCompanyCode !== sourceGroup.company_code ? ` [${sourceGroup.company_code} → ${finalCompanyCode}]` : ""}`, + changes: { + after: { + 원본그룹: sourceGroup.group_name, + 대상그룹: rootGroupName, + 복제그룹수: stats.groups, + 복제화면수: stats.screens, + 대상회사: finalCompanyCode, + }, + }, + }); + } catch (auditError) { + console.warn("그룹 복제 감사 로그 기록 실패 (무시):", auditError); + } + toast.success( `그룹 복제가 완료되었습니다! (그룹 ${stats.groups}개 + 화면 ${stats.screens}개)` ); diff --git a/frontend/components/screen/widgets/TabsWidget.tsx b/frontend/components/screen/widgets/TabsWidget.tsx index 83c55777..51060ce2 100644 --- a/frontend/components/screen/widgets/TabsWidget.tsx +++ b/frontend/components/screen/widgets/TabsWidget.tsx @@ -429,29 +429,31 @@ export function TabsWidget({ })) as any; return ( - ( - - )} - /> +
+ ( + + )} + /> +
); }; @@ -496,7 +498,7 @@ export function TabsWidget({
-
+
{visibleTabs.map((tab) => { const shouldRender = mountedTabs.has(tab.id); const isActive = selectedTab === tab.id; @@ -506,7 +508,7 @@ export function TabsWidget({ key={tab.id} value={tab.id} forceMount - className={cn("h-full overflow-auto", !isActive && "hidden")} + className={cn("flex min-h-0 flex-1 flex-col overflow-auto", !isActive && "hidden")} > {shouldRender && renderTabContent(tab)} diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 1bc797d8..d5954c6b 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -720,29 +720,29 @@ export const SplitPanelLayoutComponent: React.FC }, [leftData, leftGroupSumConfig]); // 컴포넌트 스타일 - // height 처리: 이미 px 단위면 그대로, 숫자면 px 추가 + // height: component.size?.height 우선, 없으면 component.style?.height, 기본 600px const getHeightValue = () => { + const sizeH = component.size?.height; + if (sizeH && typeof sizeH === "number" && sizeH > 0) return `${sizeH}px`; const height = component.style?.height; if (!height) return "600px"; - if (typeof height === "string") return height; // 이미 '540px' 형태 - return `${height}px`; // 숫자면 px 추가 + if (typeof height === "string") return height; + return `${height}px`; }; const componentStyle: React.CSSProperties = isDesignMode ? { - position: "absolute", - left: `${component.style?.positionX || 0}px`, - top: `${component.style?.positionY || 0}px`, width: "100%", - height: getHeightValue(), - zIndex: component.style?.positionZ || 1, + height: "100%", + minHeight: getHeightValue(), cursor: "pointer", border: isSelected ? "2px solid #3b82f6" : "1px solid #e5e7eb", } : { position: "relative", width: "100%", - height: getHeightValue(), + height: "100%", + minHeight: getHeightValue(), }; // 계층 구조 빌드 함수 (트리 구조 유지) diff --git a/frontend/lib/registry/components/v2-tabs-widget/tabs-component.tsx b/frontend/lib/registry/components/v2-tabs-widget/tabs-component.tsx index ad42938c..efd76407 100644 --- a/frontend/lib/registry/components/v2-tabs-widget/tabs-component.tsx +++ b/frontend/lib/registry/components/v2-tabs-widget/tabs-component.tsx @@ -324,15 +324,12 @@ const TabsDesignEditor: React.FC<{ const isDragging = draggingCompId === comp.id; const isResizing = resizingCompId === comp.id; - // 드래그/리사이즈 중 표시할 크기 - // resizeSize가 있고 해당 컴포넌트이면 resizeSize 우선 사용 (레이아웃 업데이트 반영 전까지) const compWidth = comp.size?.width || 200; const compHeight = comp.size?.height || 100; const isResizingThis = (resizingCompId === comp.id || lastResizedCompId === comp.id) && resizeSize; const displayWidth = isResizingThis ? resizeSize!.width : compWidth; const displayHeight = isResizingThis ? resizeSize!.height : compHeight; - // 컴포넌트 데이터를 DynamicComponentRenderer 형식으로 변환 const componentData = { id: comp.id, type: "component" as const, @@ -344,7 +341,6 @@ const TabsDesignEditor: React.FC<{ style: comp.style || {}, }; - // 드래그 중인 컴포넌트는 dragPosition 사용, 아니면 저장된 position 사용 const displayX = isDragging && dragPosition ? dragPosition.x : (comp.position?.x || 0); const displayY = isDragging && dragPosition ? dragPosition.y : (comp.position?.y || 0); From 966191786a17119406c022d2e245806c139f2b52 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 12 Mar 2026 11:01:18 +0900 Subject: [PATCH 4/5] feat: add nested panel selection support in dynamic components - Introduced a new callback `onNestedPanelSelect` to handle selections of components within nested split panels. - Updated the `RealtimePreviewDynamic`, `DynamicComponentRenderer`, and `TabsDesignEditor` components to support the new nested selection functionality. - Enhanced the layout management logic in `ScreenDesigner` to accommodate updates for nested structures, improving the overall user experience when interacting with nested components. Made-with: Cursor --- .../screen/RealtimePreviewDynamic.tsx | 3 + frontend/components/screen/ScreenDesigner.tsx | 105 ++++++++++++++---- .../lib/registry/DynamicComponentRenderer.tsx | 3 + .../v2-tabs-widget/tabs-component.tsx | 11 +- 4 files changed, 98 insertions(+), 24 deletions(-) diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index 17615147..9c19405c 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -47,6 +47,7 @@ interface RealtimePreviewProps { selectedTabComponentId?: string; // 🆕 선택된 탭 컴포넌트 ID onSelectPanelComponent?: (panelSide: "left" | "right", compId: string, comp: any) => 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/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index e19f01f1..6eeeb4e1 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -6744,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("."); @@ -6769,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] || {}; @@ -6807,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); }); }; @@ -6827,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] || {}; @@ -6849,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, + ), + }, + }; + }), }; }); }; @@ -7457,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/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/v2-tabs-widget/tabs-component.tsx b/frontend/lib/registry/components/v2-tabs-widget/tabs-component.tsx index efd76407..ac6b208e 100644 --- a/frontend/lib/registry/components/v2-tabs-widget/tabs-component.tsx +++ b/frontend/lib/registry/components/v2-tabs-widget/tabs-component.tsx @@ -15,7 +15,8 @@ const TabsDesignEditor: React.FC<{ onUpdateComponent?: (updatedComponent: any) => void; onSelectTabComponent?: (tabId: string, compId: string, comp: TabInlineComponent) => void; selectedTabComponentId?: string; -}> = ({ component, tabs, onUpdateComponent, onSelectTabComponent, selectedTabComponentId }) => { + onNestedPanelSelect?: (splitPanelId: string, panelSide: "left" | "right", compId: string, comp: any) => void; +}> = ({ component, tabs, onUpdateComponent, onSelectTabComponent, selectedTabComponentId, onNestedPanelSelect }) => { const [activeTabId, setActiveTabId] = useState(tabs[0]?.id || ""); const [draggingCompId, setDraggingCompId] = useState(null); const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null); @@ -443,7 +444,11 @@ const TabsDesignEditor: React.FC<{ }); }, onSelectPanelComponent: (panelSide: string, compId: string, panelComp: any) => { - onSelectTabComponent?.(activeTabId, comp.id, { ...comp, _selectedPanelSide: panelSide, _selectedPanelCompId: compId, _selectedPanelComp: panelComp } as any); + if (onNestedPanelSelect) { + onNestedPanelSelect(comp.id, panelSide as "left" | "right", compId, panelComp); + } else { + onSelectTabComponent?.(activeTabId, comp.id, comp); + } }, } : {})} /> @@ -506,6 +511,7 @@ const TabsWidgetWrapper: React.FC = (props) => { onUpdateComponent, onSelectTabComponent, selectedTabComponentId, + onNestedPanelSelect, ...restProps } = props; @@ -522,6 +528,7 @@ const TabsWidgetWrapper: React.FC = (props) => { onUpdateComponent={onUpdateComponent} onSelectTabComponent={onSelectTabComponent} selectedTabComponentId={selectedTabComponentId} + onNestedPanelSelect={onNestedPanelSelect} /> ); } From df47c27b770fda10122988551b87636981d9bfbf Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 12 Mar 2026 14:19:48 +0900 Subject: [PATCH 5/5] feat: enhance ResponsiveGridRenderer with row margin calculations - Added rowMinY and rowMaxBottom properties to ProcessedRow for improved layout calculations. - Implemented dynamic margin adjustments between rows in the ResponsiveGridRenderer to enhance visual spacing. - Refactored TabsWidget to streamline the ResponsiveGridRenderer integration, removing unnecessary wrapper divs for cleaner structure. - Introduced ScaledCustomPanel for better handling of component rendering in split panel layouts. Made-with: Cursor --- .../screen/ResponsiveGridRenderer.tsx | 45 +++- .../components/screen/widgets/TabsWidget.tsx | 8 +- .../SplitPanelLayoutComponent.tsx | 246 ++++++++++-------- .../SplitPanelLayoutConfigPanel.tsx | 63 ++++- .../config-panels/LeftPanelConfigTab.tsx | 5 + .../config-panels/RightPanelConfigTab.tsx | 5 + .../config-panels/SharedComponents.tsx | 28 +- 7 files changed, 261 insertions(+), 139 deletions(-) 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/widgets/TabsWidget.tsx b/frontend/components/screen/widgets/TabsWidget.tsx index 51060ce2..688a6ca7 100644 --- a/frontend/components/screen/widgets/TabsWidget.tsx +++ b/frontend/components/screen/widgets/TabsWidget.tsx @@ -429,8 +429,7 @@ export function TabsWidget({ })) as any; return ( -
- )} /> -
); }; @@ -498,7 +496,7 @@ export function TabsWidget({
-
+
{visibleTabs.map((tab) => { const shouldRender = mountedTabs.has(tab.id); const isActive = selectedTab === tab.id; @@ -508,7 +506,7 @@ export function TabsWidget({ key={tab.id} value={tab.id} forceMount - className={cn("flex min-h-0 flex-1 flex-col overflow-auto", !isActive && "hidden")} + className={cn("h-full overflow-auto", !isActive && "hidden")} > {shouldRender && renderTabContent(tab)} diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index d5954c6b..9ac5b01b 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -91,6 +91,103 @@ const SplitPanelCellImage: React.FC<{ value: string }> = React.memo(({ value }) }); SplitPanelCellImage.displayName = "SplitPanelCellImage"; +/** + * 커스텀 모드 런타임: 디자이너 좌표를 비례 스케일링하여 렌더링 + */ +const ScaledCustomPanel: React.FC<{ + components: PanelInlineComponent[]; + formData: Record; + onFormDataChange: (fieldName: string, value: any) => void; + tableName?: string; + menuObjid?: number; + screenId?: number; + userId?: string; + userName?: string; + companyCode?: string; + allComponents?: any; + selectedRowsData?: any[]; + onSelectedRowsChange?: any; +}> = ({ components, formData, onFormDataChange, tableName, ...restProps }) => { + const containerRef = React.useRef(null); + const [containerWidth, setContainerWidth] = React.useState(0); + + React.useEffect(() => { + const el = containerRef.current; + if (!el) return; + const ro = new ResizeObserver((entries) => { + const w = entries[0]?.contentRect.width; + if (w && w > 0) setContainerWidth(w); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const canvasW = Math.max( + ...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)), + 400, + ); + const canvasH = Math.max( + ...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)), + 200, + ); + return ( +
+ {containerWidth > 0 && + components.map((comp) => { + const x = comp.position?.x || 0; + const y = comp.position?.y || 0; + const w = comp.size?.width || 200; + const h = comp.size?.height || 36; + + const componentData = { + id: comp.id, + type: "component" as const, + componentType: comp.componentType, + label: comp.label, + position: { x, y }, + size: { width: undefined, height: h }, + componentConfig: comp.componentConfig || {}, + style: { ...(comp.style || {}), width: "100%", height: "100%" }, + tableName: comp.componentConfig?.tableName, + columnName: comp.componentConfig?.columnName, + webType: comp.componentConfig?.webType, + inputType: (comp as any).inputType || comp.componentConfig?.inputType, + }; + + return ( +
+ +
+ ); + })} +
+ ); +}; + /** * SplitPanelLayout 컴포넌트 * 마스터-디테일 패턴의 좌우 분할 레이아웃 @@ -741,8 +838,7 @@ export const SplitPanelLayoutComponent: React.FC : { position: "relative", width: "100%", - height: "100%", - minHeight: getHeightValue(), + height: getHeightValue(), }; // 계층 구조 빌드 함수 (트리 구조 유지) @@ -3073,59 +3169,28 @@ export const SplitPanelLayoutComponent: React.FC {/* 커스텀 모드: 디자인/실행 모드 분기 렌더링 */} {componentConfig.leftPanel?.components && componentConfig.leftPanel.components.length > 0 ? ( !isDesignMode ? ( - // 런타임: ResponsiveGridRenderer로 반응형 렌더링 - (() => { - const leftComps = componentConfig.leftPanel!.components; - const canvasW = Math.max(...leftComps.map((c: PanelInlineComponent) => (c.position?.x || 0) + (c.size?.width || 200)), 800); - const canvasH = Math.max(...leftComps.map((c: PanelInlineComponent) => (c.position?.y || 0) + (c.size?.height || 100)), 400); - const compDataList = leftComps.map((c: PanelInlineComponent) => ({ - id: c.id, - type: "component" as const, - componentType: c.componentType, - label: c.label, - position: c.position || { x: 0, y: 0 }, - size: c.size || { width: 400, height: 300 }, - componentConfig: c.componentConfig || {}, - style: c.style || {}, - tableName: c.componentConfig?.tableName, - columnName: c.componentConfig?.columnName, - webType: c.componentConfig?.webType, - inputType: (c as any).inputType || c.componentConfig?.inputType, - })) as any; - return ( - ( - { - if (data?.selectedRowsData && data.selectedRowsData.length > 0) { - setCustomLeftSelectedData(data.selectedRowsData[0]); - setSelectedLeftItem(data.selectedRowsData[0]); - } else if (data?.selectedRowsData && data.selectedRowsData.length === 0) { - setCustomLeftSelectedData({}); - setSelectedLeftItem(null); - } - }} - /> - )} - /> - ); - })() + { + if (data?.selectedRowsData && data.selectedRowsData.length > 0) { + setCustomLeftSelectedData(data.selectedRowsData[0]); + setSelectedLeftItem(data.selectedRowsData[0]); + } else if (data?.selectedRowsData && data.selectedRowsData.length === 0) { + setCustomLeftSelectedData({}); + setSelectedLeftItem(null); + } + }} + tableName={componentConfig.leftPanel?.tableName} + menuObjid={(props as any).menuObjid} + screenId={(props as any).screenId} + userId={(props as any).userId} + userName={(props as any).userName} + companyCode={companyCode} + allComponents={(props as any).allComponents} + selectedRowsData={localSelectedRowsData} + onSelectedRowsChange={handleLocalSelectedRowsChange} + /> ) : (
{componentConfig.leftPanel.components.map((comp: PanelInlineComponent) => { @@ -3416,7 +3481,7 @@ export const SplitPanelLayoutComponent: React.FC ))} {hasGroupedLeftActions && ( - + )} @@ -3452,7 +3517,7 @@ export const SplitPanelLayoutComponent: React.FC ))} {hasGroupedLeftActions && ( - +
{(componentConfig.leftPanel?.showEdit !== false) && (