Compare commits

...

2 Commits

Author SHA1 Message Date
kjs 966191786a 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
2026-03-12 11:01:18 +09:00
kjs f65b57410c 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
2026-03-12 10:41:57 +09:00
10 changed files with 196 additions and 92 deletions

View File

@ -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<void> => {
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: "감사 로그 기록 실패" });
}
};

View File

@ -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,

View File

@ -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;

View File

@ -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}개)`
);

View File

@ -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<RealtimePreviewProps> = ({
selectedTabComponentId, // 🆕 선택된 탭 컴포넌트 ID
onSelectPanelComponent, // 🆕 분할 패널 내부 컴포넌트 선택 콜백
selectedPanelComponentId, // 🆕 선택된 분할 패널 컴포넌트 ID
onNestedPanelSelect,
onResize, // 🆕 리사이즈 콜백
}) => {
// 🆕 화면 다국어 컨텍스트
@ -768,6 +770,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
selectedTabComponentId={selectedTabComponentId}
onSelectPanelComponent={onSelectPanelComponent}
selectedPanelComponentId={selectedPanelComponentId}
onNestedPanelSelect={onNestedPanelSelect}
/>
</div>

View File

@ -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

View File

@ -429,29 +429,31 @@ export function TabsWidget({
})) as any;
return (
<ResponsiveGridRenderer
components={componentDataList}
canvasWidth={canvasWidth}
canvasHeight={canvasHeight}
renderComponent={(comp) => (
<DynamicComponentRenderer
{...restProps}
component={comp}
formData={formData}
onFormDataChange={onFormDataChange}
menuObjid={menuObjid}
isDesignMode={false}
isInteractive={true}
selectedRowsData={localSelectedRowsData}
onSelectedRowsChange={handleSelectedRowsChange}
parentTabId={tab.id}
parentTabsComponentId={component.id}
{...(screenInfoMap[tab.id]
? { tableName: screenInfoMap[tab.id].tableName, screenId: screenInfoMap[tab.id].id }
: {})}
/>
)}
/>
<div className="flex min-h-0 flex-1 flex-col">
<ResponsiveGridRenderer
components={componentDataList}
canvasWidth={canvasWidth}
canvasHeight={canvasHeight}
renderComponent={(comp) => (
<DynamicComponentRenderer
{...restProps}
component={comp}
formData={formData}
onFormDataChange={onFormDataChange}
menuObjid={menuObjid}
isDesignMode={false}
isInteractive={true}
selectedRowsData={localSelectedRowsData}
onSelectedRowsChange={handleSelectedRowsChange}
parentTabId={tab.id}
parentTabsComponentId={component.id}
{...(screenInfoMap[tab.id]
? { tableName: screenInfoMap[tab.id].tableName, screenId: screenInfoMap[tab.id].id }
: {})}
/>
)}
/>
</div>
);
};
@ -496,7 +498,7 @@ export function TabsWidget({
</TabsList>
</div>
<div className="relative flex-1 overflow-auto">
<div className="relative flex flex-1 flex-col overflow-auto">
{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)}
</TabsContent>

View File

@ -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<DynamicComponentRendererProps> =
// 🆕 분할 패널 내부 컴포넌트 선택 콜백
onSelectPanelComponent: props.onSelectPanelComponent,
selectedPanelComponentId: props.selectedPanelComponentId,
onNestedPanelSelect: props.onNestedPanelSelect,
};
// 렌더러가 클래스인지 함수인지 확인

View File

@ -720,29 +720,29 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}, [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(),
};
// 계층 구조 빌드 함수 (트리 구조 유지)

View File

@ -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<string>(tabs[0]?.id || "");
const [draggingCompId, setDraggingCompId] = useState<string | null>(null);
const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null);
@ -324,15 +325,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 +342,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);
@ -447,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);
}
},
} : {})}
/>
@ -510,6 +511,7 @@ const TabsWidgetWrapper: React.FC<any> = (props) => {
onUpdateComponent,
onSelectTabComponent,
selectedTabComponentId,
onNestedPanelSelect,
...restProps
} = props;
@ -526,6 +528,7 @@ const TabsWidgetWrapper: React.FC<any> = (props) => {
onUpdateComponent={onUpdateComponent}
onSelectTabComponent={onSelectTabComponent}
selectedTabComponentId={selectedTabComponentId}
onNestedPanelSelect={onNestedPanelSelect}
/>
);
}