From aa283d11dac7406aaac9e9c3021fa11fca3305ee Mon Sep 17 00:00:00 2001
From: dohyeons
Date: Wed, 24 Dec 2025 09:48:37 +0900
Subject: [PATCH 01/28] =?UTF-8?q?=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EC=BB=B4?=
=?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=8D=94=EB=B8=94=20=ED=81=B4?=
=?UTF-8?q?=EB=A6=AD=20=EC=8B=9C=20=EC=BB=A8=ED=85=90=EC=B8=A0=20=ED=81=AC?=
=?UTF-8?q?=EA=B8=B0=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EC=9E=90=EB=8F=99=20?=
=?UTF-8?q?=EC=A1=B0=EC=A0=88?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../report/designer/CanvasComponent.tsx | 130 ++++++++++++++----
1 file changed, 100 insertions(+), 30 deletions(-)
diff --git a/frontend/components/report/designer/CanvasComponent.tsx b/frontend/components/report/designer/CanvasComponent.tsx
index ccc3aa8a..8f7c1db2 100644
--- a/frontend/components/report/designer/CanvasComponent.tsx
+++ b/frontend/components/report/designer/CanvasComponent.tsx
@@ -190,6 +190,105 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
const isLocked = component.locked === true;
const isGrouped = !!component.groupId;
+ // 표시할 값 결정
+ const getDisplayValue = (): string => {
+ // 쿼리와 필드가 연결되어 있으면 실제 데이터 조회
+ if (component.queryId && component.fieldName) {
+ const queryResult = getQueryResult(component.queryId);
+
+ // 실행 결과가 있으면 첫 번째 행의 해당 필드 값 표시
+ if (queryResult && queryResult.rows.length > 0) {
+ const firstRow = queryResult.rows[0];
+ const value = firstRow[component.fieldName];
+
+ // 값이 있으면 문자열로 변환하여 반환
+ if (value !== null && value !== undefined) {
+ return String(value);
+ }
+ }
+
+ // 실행 결과가 없거나 값이 없으면 필드명 표시
+ return `{${component.fieldName}}`;
+ }
+
+ // 기본값이 있으면 기본값 표시
+ if (component.defaultValue) {
+ return component.defaultValue;
+ }
+
+ // 둘 다 없으면 타입에 따라 기본 텍스트
+ return component.type === "text" ? "텍스트 입력" : "레이블 텍스트";
+ };
+
+ // 텍스트 컴포넌트: 더블 클릭 시 컨텐츠에 맞게 크기 조절
+ const fitTextToContent = () => {
+ if (isLocked) return;
+ if (component.type !== "text" && component.type !== "label") return;
+
+ const minWidth = 50;
+ const minHeight = 30;
+
+ // 여백을 px로 변환
+ const marginRightPx = margins.right * MM_TO_PX;
+ const marginBottomPx = margins.bottom * MM_TO_PX;
+ const canvasWidthPx = canvasWidth * MM_TO_PX;
+ const canvasHeightPx = canvasHeight * MM_TO_PX;
+
+ // 최대 크기 (여백 고려)
+ const maxWidth = canvasWidthPx - marginRightPx - component.x;
+ const maxHeight = canvasHeightPx - marginBottomPx - component.y;
+
+ const displayValue = getDisplayValue();
+ const fontSize = component.fontSize || 14;
+
+ // 줄바꿈으로 분리하여 각 줄의 너비 측정
+ const lines = displayValue.split("\n");
+ let maxLineWidth = 0;
+
+ lines.forEach((line) => {
+ const measureEl = document.createElement("span");
+ measureEl.style.position = "absolute";
+ measureEl.style.visibility = "hidden";
+ measureEl.style.whiteSpace = "nowrap";
+ measureEl.style.fontSize = `${fontSize}px`;
+ measureEl.style.fontWeight = component.fontWeight || "normal";
+ measureEl.style.fontFamily = "system-ui, -apple-system, sans-serif";
+ measureEl.textContent = line || " "; // 빈 줄은 공백으로
+ document.body.appendChild(measureEl);
+
+ const lineWidth = measureEl.getBoundingClientRect().width;
+ maxLineWidth = Math.max(maxLineWidth, lineWidth);
+ document.body.removeChild(measureEl);
+ });
+
+ // 컴포넌트 padding (p-2 = 8px * 2) + 여유분
+ const horizontalPadding = 24;
+ const verticalPadding = 20;
+
+ // 줄 높이 계산 (font-size * line-height 약 1.5)
+ const lineHeight = fontSize * 1.5;
+ const totalHeight = lines.length * lineHeight;
+
+ const finalWidth = Math.min(maxLineWidth + horizontalPadding, maxWidth);
+ const finalHeight = Math.min(totalHeight + verticalPadding, maxHeight);
+
+ const newWidth = Math.max(minWidth, finalWidth);
+ const newHeight = Math.max(minHeight, finalHeight);
+
+ // 크기 업데이트
+ updateComponent(component.id, {
+ width: snapValueToGrid(newWidth),
+ height: snapValueToGrid(newHeight),
+ });
+ };
+
+ // 더블 클릭 핸들러 (텍스트 컴포넌트만)
+ const handleDoubleClick = (e: React.MouseEvent) => {
+ if (component.type !== "text" && component.type !== "label") return;
+ e.stopPropagation();
+ fitTextToContent();
+ };
+
// 드래그 시작
const handleMouseDown = (e: React.MouseEvent) => {
if ((e.target as HTMLElement).classList.contains("resize-handle")) {
@@ -405,36 +504,6 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
canvasHeight,
]);
- // 표시할 값 결정
- const getDisplayValue = (): string => {
- // 쿼리와 필드가 연결되어 있으면 실제 데이터 조회
- if (component.queryId && component.fieldName) {
- const queryResult = getQueryResult(component.queryId);
-
- // 실행 결과가 있으면 첫 번째 행의 해당 필드 값 표시
- if (queryResult && queryResult.rows.length > 0) {
- const firstRow = queryResult.rows[0];
- const value = firstRow[component.fieldName];
-
- // 값이 있으면 문자열로 변환하여 반환
- if (value !== null && value !== undefined) {
- return String(value);
- }
- }
-
- // 실행 결과가 없거나 값이 없으면 필드명 표시
- return `{${component.fieldName}}`;
- }
-
- // 기본값이 있으면 기본값 표시
- if (component.defaultValue) {
- return component.defaultValue;
- }
-
- // 둘 다 없으면 타입에 따라 기본 텍스트
- return component.type === "text" ? "텍스트 입력" : "레이블 텍스트";
- };
-
// 컴포넌트 타입별 렌더링
const renderContent = () => {
const displayValue = getDisplayValue();
@@ -1182,6 +1251,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
: "1px solid #e5e7eb",
}}
onMouseDown={handleMouseDown}
+ onDoubleClick={handleDoubleClick}
>
{renderContent()}
From 352f9f441fe49cf867de29c08b903540af2855dd Mon Sep 17 00:00:00 2001
From: dohyeons
Date: Wed, 24 Dec 2025 10:10:52 +0900
Subject: [PATCH 02/28] =?UTF-8?q?=EB=93=9C=EB=9E=98=EA=B7=B8=20=EC=98=81?=
=?UTF-8?q?=EC=97=AD=20=EC=84=A0=ED=83=9D(Marquee=20Selection)=20=EA=B8=B0?=
=?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../report/designer/ReportDesignerCanvas.tsx | 152 +++++++++++++++++-
frontend/contexts/ReportDesignerContext.tsx | 13 ++
2 files changed, 163 insertions(+), 2 deletions(-)
diff --git a/frontend/components/report/designer/ReportDesignerCanvas.tsx b/frontend/components/report/designer/ReportDesignerCanvas.tsx
index 6684047b..e795c2c9 100644
--- a/frontend/components/report/designer/ReportDesignerCanvas.tsx
+++ b/frontend/components/report/designer/ReportDesignerCanvas.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useRef, useEffect } from "react";
+import { useRef, useEffect, useState } from "react";
import { useDrop } from "react-dnd";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { ComponentConfig, WatermarkConfig } from "@/types/report";
@@ -201,6 +201,7 @@ export function ReportDesignerCanvas() {
canvasHeight,
margins,
selectComponent,
+ selectMultipleComponents,
selectedComponentId,
selectedComponentIds,
removeComponent,
@@ -216,6 +217,22 @@ export function ReportDesignerCanvas() {
layoutConfig,
} = useReportDesigner();
+ // 드래그 영역 선택 (Marquee Selection) 상태
+ const [isMarqueeSelecting, setIsMarqueeSelecting] = useState(false);
+ const [marqueeStart, setMarqueeStart] = useState({ x: 0, y: 0 });
+ const [marqueeEnd, setMarqueeEnd] = useState({ x: 0, y: 0 });
+ // 클로저 문제 해결을 위한 refs (동기적으로 업데이트)
+ const marqueeStartRef = useRef({ x: 0, y: 0 });
+ const marqueeEndRef = useRef({ x: 0, y: 0 });
+ const componentsRef = useRef(components);
+ const selectMultipleRef = useRef(selectMultipleComponents);
+ // 마퀴 선택 직후 click 이벤트 무시를 위한 플래그
+ const justFinishedMarqueeRef = useRef(false);
+
+ // refs 동기적 업데이트 (useEffect 대신 직접 할당)
+ componentsRef.current = components;
+ selectMultipleRef.current = selectMultipleComponents;
+
const [{ isOver }, drop] = useDrop(() => ({
accept: "component",
drop: (item: { componentType: string }, monitor) => {
@@ -420,12 +437,127 @@ export function ReportDesignerCanvas() {
}),
}));
+ // 캔버스 클릭 시 선택 해제 (드래그 선택이 아닐 때만)
const handleCanvasClick = (e: React.MouseEvent) => {
- if (e.target === e.currentTarget) {
+ // 마퀴 선택 직후의 click 이벤트는 무시
+ if (justFinishedMarqueeRef.current) {
+ justFinishedMarqueeRef.current = false;
+ return;
+ }
+ if (e.target === e.currentTarget && !isMarqueeSelecting) {
selectComponent(null);
}
};
+ // 드래그 영역 선택 시작
+ const handleCanvasMouseDown = (e: React.MouseEvent) => {
+ // 캔버스 자체를 클릭했을 때만 (컴포넌트 클릭 시 제외)
+ if (e.target !== e.currentTarget) return;
+ if (!canvasRef.current) return;
+
+ const rect = canvasRef.current.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const y = e.clientY - rect.top;
+
+ // state와 ref 모두 설정
+ setIsMarqueeSelecting(true);
+ setMarqueeStart({ x, y });
+ setMarqueeEnd({ x, y });
+ marqueeStartRef.current = { x, y };
+ marqueeEndRef.current = { x, y };
+
+ // Ctrl/Cmd 키가 눌리지 않았으면 기존 선택 해제
+ if (!e.ctrlKey && !e.metaKey) {
+ selectComponent(null);
+ }
+ };
+
+ // 드래그 영역 선택 중
+ useEffect(() => {
+ if (!isMarqueeSelecting) return;
+
+ const handleMouseMove = (e: MouseEvent) => {
+ if (!canvasRef.current) return;
+
+ const rect = canvasRef.current.getBoundingClientRect();
+ const x = Math.max(0, Math.min(e.clientX - rect.left, canvasWidth * MM_TO_PX));
+ const y = Math.max(0, Math.min(e.clientY - rect.top, canvasHeight * MM_TO_PX));
+
+ // state와 ref 둘 다 업데이트
+ setMarqueeEnd({ x, y });
+ marqueeEndRef.current = { x, y };
+ };
+
+ const handleMouseUp = () => {
+ // ref에서 최신 값 가져오기 (클로저 문제 해결)
+ const currentStart = marqueeStartRef.current;
+ const currentEnd = marqueeEndRef.current;
+ const currentComponents = componentsRef.current;
+ const currentSelectMultiple = selectMultipleRef.current;
+
+ // 선택 영역 계산
+ const selectionRect = {
+ left: Math.min(currentStart.x, currentEnd.x),
+ top: Math.min(currentStart.y, currentEnd.y),
+ right: Math.max(currentStart.x, currentEnd.x),
+ bottom: Math.max(currentStart.y, currentEnd.y),
+ };
+
+ // 최소 드래그 거리 체크 (5px 이상이어야 선택으로 인식)
+ const dragDistance = Math.sqrt(
+ Math.pow(currentEnd.x - currentStart.x, 2) + Math.pow(currentEnd.y - currentStart.y, 2)
+ );
+
+ if (dragDistance > 5) {
+ // 선택 영역과 교차하는 컴포넌트 찾기
+ const intersectingComponents = currentComponents.filter((comp) => {
+ const compRect = {
+ left: comp.x,
+ top: comp.y,
+ right: comp.x + comp.width,
+ bottom: comp.y + comp.height,
+ };
+
+ // 두 사각형이 교차하는지 확인
+ return !(
+ compRect.right < selectionRect.left ||
+ compRect.left > selectionRect.right ||
+ compRect.bottom < selectionRect.top ||
+ compRect.top > selectionRect.bottom
+ );
+ });
+
+ // 교차하는 컴포넌트들 한번에 선택
+ if (intersectingComponents.length > 0) {
+ const ids = intersectingComponents.map((comp) => comp.id);
+ currentSelectMultiple(ids);
+ // click 이벤트가 선택을 해제하지 않도록 플래그 설정
+ justFinishedMarqueeRef.current = true;
+ }
+ }
+
+ setIsMarqueeSelecting(false);
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+
+ return () => {
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ };
+ }, [isMarqueeSelecting, canvasWidth, canvasHeight]);
+
+ // 선택 영역 사각형 계산
+ const getMarqueeRect = () => {
+ return {
+ left: Math.min(marqueeStart.x, marqueeEnd.x),
+ top: Math.min(marqueeStart.y, marqueeEnd.y),
+ width: Math.abs(marqueeEnd.x - marqueeStart.x),
+ height: Math.abs(marqueeEnd.y - marqueeStart.y),
+ };
+ };
+
// 키보드 단축키 (Delete, Ctrl+C, Ctrl+V, 화살표 이동)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -592,8 +724,10 @@ export function ReportDesignerCanvas() {
`
: undefined,
backgroundSize: showGrid ? `${gridSize}px ${gridSize}px` : undefined,
+ cursor: isMarqueeSelecting ? "crosshair" : "default",
}}
onClick={handleCanvasClick}
+ onMouseDown={handleCanvasMouseDown}
>
{/* 페이지 여백 가이드 */}
{currentPage && (
@@ -648,6 +782,20 @@ export function ReportDesignerCanvas() {
))}
+ {/* 드래그 영역 선택 사각형 */}
+ {isMarqueeSelecting && (
+
+ )}
+
{/* 빈 캔버스 안내 */}
{components.length === 0 && (
diff --git a/frontend/contexts/ReportDesignerContext.tsx b/frontend/contexts/ReportDesignerContext.tsx
index 3db07bc9..098e419e 100644
--- a/frontend/contexts/ReportDesignerContext.tsx
+++ b/frontend/contexts/ReportDesignerContext.tsx
@@ -63,6 +63,7 @@ interface ReportDesignerContextType {
updateComponent: (id: string, updates: Partial
) => void;
removeComponent: (id: string) => void;
selectComponent: (id: string | null, isMultiSelect?: boolean) => void;
+ selectMultipleComponents: (ids: string[]) => void; // 여러 컴포넌트 한번에 선택
// 레이아웃 관리
updateLayout: (updates: Partial) => void;
@@ -1344,6 +1345,17 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
}
}, []);
+ // 여러 컴포넌트 한번에 선택 (마퀴 선택용)
+ const selectMultipleComponents = useCallback((ids: string[]) => {
+ if (ids.length === 0) {
+ setSelectedComponentId(null);
+ setSelectedComponentIds([]);
+ return;
+ }
+ setSelectedComponentId(ids[0]);
+ setSelectedComponentIds(ids);
+ }, []);
+
// 레이아웃 업데이트
const updateLayout = useCallback(
(updates: Partial) => {
@@ -1639,6 +1651,7 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
updateComponent,
removeComponent,
selectComponent,
+ selectMultipleComponents,
updateLayout,
saveLayout,
loadLayout,
From a299195b428cf8f1c18214cb589121c9253471a6 Mon Sep 17 00:00:00 2001
From: dohyeons
Date: Wed, 24 Dec 2025 10:16:37 +0900
Subject: [PATCH 03/28] =?UTF-8?q?=EB=8B=A4=EC=A4=91=20=EC=84=A0=ED=83=9D?=
=?UTF-8?q?=20=ED=9B=84=20=ED=95=A8=EA=BB=98=20=EC=9D=B4=EB=8F=99=ED=95=98?=
=?UTF-8?q?=EB=8A=94=20=EA=B8=B0=EB=8A=A5=EC=9D=84=20=EA=B5=AC=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../report/designer/CanvasComponent.tsx | 44 ++++++++++++++-----
1 file changed, 34 insertions(+), 10 deletions(-)
diff --git a/frontend/components/report/designer/CanvasComponent.tsx b/frontend/components/report/designer/CanvasComponent.tsx
index 8f7c1db2..ea84b81e 100644
--- a/frontend/components/report/designer/CanvasComponent.tsx
+++ b/frontend/components/report/designer/CanvasComponent.tsx
@@ -309,15 +309,20 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
// Ctrl/Cmd 키 감지 (다중 선택)
const isMultiSelect = e.ctrlKey || e.metaKey;
- // 그룹화된 컴포넌트 클릭 시: 같은 그룹의 모든 컴포넌트 선택
- if (isGrouped && !isMultiSelect) {
- const groupMembers = components.filter((c) => c.groupId === component.groupId);
- const groupMemberIds = groupMembers.map((c) => c.id);
- // 첫 번째 컴포넌트를 선택하고, 나머지를 다중 선택에 추가
- selectComponent(groupMemberIds[0], false);
- groupMemberIds.slice(1).forEach((id) => selectComponent(id, true));
- } else {
- selectComponent(component.id, isMultiSelect);
+ // 이미 다중 선택의 일부인 경우: 선택 상태 유지 (드래그만 시작)
+ const isPartOfMultiSelection = selectedComponentIds.length > 1 && selectedComponentIds.includes(component.id);
+
+ if (!isPartOfMultiSelection) {
+ // 그룹화된 컴포넌트 클릭 시: 같은 그룹의 모든 컴포넌트 선택
+ if (isGrouped && !isMultiSelect) {
+ const groupMembers = components.filter((c) => c.groupId === component.groupId);
+ const groupMemberIds = groupMembers.map((c) => c.id);
+ // 첫 번째 컴포넌트를 선택하고, 나머지를 다중 선택에 추가
+ selectComponent(groupMemberIds[0], false);
+ groupMemberIds.slice(1).forEach((id) => selectComponent(id, true));
+ } else {
+ selectComponent(component.id, isMultiSelect);
+ }
}
setIsDragging(true);
@@ -389,8 +394,27 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
y: snappedY,
});
+ // 다중 선택된 경우: 선택된 다른 컴포넌트들도 함께 이동
+ if (selectedComponentIds.length > 1 && selectedComponentIds.includes(component.id)) {
+ components.forEach((c) => {
+ // 현재 컴포넌트는 이미 이동됨, 잠긴 컴포넌트는 제외
+ if (c.id !== component.id && selectedComponentIds.includes(c.id) && !c.locked) {
+ const newMultiX = c.x + deltaX;
+ const newMultiY = c.y + deltaY;
+
+ // 경계 체크
+ const multiMaxX = canvasWidthPx - marginRightPx - c.width;
+ const multiMaxY = canvasHeightPx - marginBottomPx - c.height;
+
+ updateComponent(c.id, {
+ x: Math.min(Math.max(marginLeftPx, newMultiX), multiMaxX),
+ y: Math.min(Math.max(marginTopPx, newMultiY), multiMaxY),
+ });
+ }
+ });
+ }
// 그룹화된 경우: 같은 그룹의 다른 컴포넌트도 함께 이동
- if (isGrouped) {
+ else if (isGrouped) {
components.forEach((c) => {
if (c.groupId === component.groupId && c.id !== component.id) {
const newGroupX = c.x + deltaX;
From 386ce629ace0aa4b563c44a5b6359cbcbc04e1d0 Mon Sep 17 00:00:00 2001
From: dohyeons
Date: Wed, 24 Dec 2025 10:20:21 +0900
Subject: [PATCH 04/28] =?UTF-8?q?=EB=8B=A4=EC=A4=91=20=EC=84=A0=ED=83=9D?=
=?UTF-8?q?=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=8F=99=EC=8B=9C=20?=
=?UTF-8?q?=EC=9D=B4=EB=8F=99=20=EB=B0=8F=20=EC=9E=A0=EA=B8=88=20=EC=BB=B4?=
=?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B3=B5=EC=82=AC=20=EC=A0=9C?=
=?UTF-8?q?=ED=95=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/contexts/ReportDesignerContext.tsx | 22 ++++++++++++++++++++-
1 file changed, 21 insertions(+), 1 deletion(-)
diff --git a/frontend/contexts/ReportDesignerContext.tsx b/frontend/contexts/ReportDesignerContext.tsx
index 098e419e..a55a1c6d 100644
--- a/frontend/contexts/ReportDesignerContext.tsx
+++ b/frontend/contexts/ReportDesignerContext.tsx
@@ -285,7 +285,18 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
// 복사 (Ctrl+C)
const copyComponents = useCallback(() => {
if (selectedComponentIds.length > 0) {
- const componentsToCopy = components.filter((comp) => selectedComponentIds.includes(comp.id));
+ // 잠긴 컴포넌트는 복사에서 제외
+ const componentsToCopy = components.filter(
+ (comp) => selectedComponentIds.includes(comp.id) && !comp.locked
+ );
+ if (componentsToCopy.length === 0) {
+ toast({
+ title: "복사 불가",
+ description: "잠긴 컴포넌트는 복사할 수 없습니다.",
+ variant: "destructive",
+ });
+ return;
+ }
setClipboard(componentsToCopy);
toast({
title: "복사 완료",
@@ -294,6 +305,15 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
} else if (selectedComponentId) {
const componentToCopy = components.find((comp) => comp.id === selectedComponentId);
if (componentToCopy) {
+ // 잠긴 컴포넌트는 복사 불가
+ if (componentToCopy.locked) {
+ toast({
+ title: "복사 불가",
+ description: "잠긴 컴포넌트는 복사할 수 없습니다.",
+ variant: "destructive",
+ });
+ return;
+ }
setClipboard([componentToCopy]);
toast({
title: "복사 완료",
From f300b637d10b60cd2d19fb287043519f950a0ac0 Mon Sep 17 00:00:00 2001
From: dohyeons
Date: Wed, 24 Dec 2025 10:42:34 +0900
Subject: [PATCH 05/28] =?UTF-8?q?=EB=B3=B5=EC=A0=9C=20=EB=B0=8F=20?=
=?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=20=EB=B3=B5=EC=82=AC=20=EA=B8=B0?=
=?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../report/designer/CanvasComponent.tsx | 100 +++++++++
.../report/designer/ReportDesignerCanvas.tsx | 33 ++-
frontend/contexts/ReportDesignerContext.tsx | 194 ++++++++++++++++++
3 files changed, 325 insertions(+), 2 deletions(-)
diff --git a/frontend/components/report/designer/CanvasComponent.tsx b/frontend/components/report/designer/CanvasComponent.tsx
index ea84b81e..1bd6db73 100644
--- a/frontend/components/report/designer/CanvasComponent.tsx
+++ b/frontend/components/report/designer/CanvasComponent.tsx
@@ -168,6 +168,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
selectedComponentId,
selectedComponentIds,
selectComponent,
+ selectMultipleComponents,
updateComponent,
getQueryResult,
snapValueToGrid,
@@ -178,12 +179,19 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
margins,
layoutConfig,
currentPageId,
+ duplicateAtPosition,
} = useReportDesigner();
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [resizeStart, setResizeStart] = useState({ x: 0, y: 0, width: 0, height: 0 });
const componentRef = useRef(null);
+
+ // Alt+드래그 복제를 위한 상태
+ const [isAltDuplicating, setIsAltDuplicating] = useState(false);
+ const duplicatedIdsRef = useRef([]);
+ // 복제 시 원본 컴포넌트들의 위치 저장 (상대적 위치 유지용)
+ const originalPositionsRef = useRef>(new Map());
const isSelected = selectedComponentId === component.id;
const isMultiSelected = selectedComponentIds.includes(component.id);
@@ -308,6 +316,8 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
// Ctrl/Cmd 키 감지 (다중 선택)
const isMultiSelect = e.ctrlKey || e.metaKey;
+ // Alt 키 감지 (복제 드래그)
+ const isAltPressed = e.altKey;
// 이미 다중 선택의 일부인 경우: 선택 상태 유지 (드래그만 시작)
const isPartOfMultiSelection = selectedComponentIds.length > 1 && selectedComponentIds.includes(component.id);
@@ -325,6 +335,66 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
}
}
+ // Alt+드래그: 복제 모드
+ if (isAltPressed) {
+ // 복제할 컴포넌트 ID 목록 결정
+ let idsToClone: string[] = [];
+
+ if (isPartOfMultiSelection) {
+ // 다중 선택된 경우: 잠기지 않은 선택된 모든 컴포넌트 복제
+ idsToClone = selectedComponentIds.filter((id) => {
+ const c = components.find((comp) => comp.id === id);
+ return c && !c.locked;
+ });
+ } else if (isGrouped) {
+ // 그룹화된 경우: 같은 그룹의 모든 컴포넌트 복제
+ idsToClone = components
+ .filter((c) => c.groupId === component.groupId && !c.locked)
+ .map((c) => c.id);
+ } else {
+ // 단일 컴포넌트
+ idsToClone = [component.id];
+ }
+
+ if (idsToClone.length > 0) {
+ // 원본 컴포넌트들의 위치 저장 (복제본 ID -> 원본 위치 매핑용)
+ const positionsMap = new Map();
+ idsToClone.forEach((id) => {
+ const comp = components.find((c) => c.id === id);
+ if (comp) {
+ positionsMap.set(id, { x: comp.x, y: comp.y });
+ }
+ });
+
+ // 복제 생성 (오프셋 없이 원래 위치에)
+ const newIds = duplicateAtPosition(idsToClone, 0, 0);
+ if (newIds.length > 0) {
+ // 복제된 컴포넌트 ID와 원본 위치 매핑
+ // newIds[i]는 idsToClone[i]에서 복제됨
+ const dupPositionsMap = new Map();
+ newIds.forEach((newId, index) => {
+ const originalId = idsToClone[index];
+ const originalPos = positionsMap.get(originalId);
+ if (originalPos) {
+ dupPositionsMap.set(newId, originalPos);
+ }
+ });
+ originalPositionsRef.current = dupPositionsMap;
+
+ // 복제된 컴포넌트들을 선택하고 드래그 시작
+ duplicatedIdsRef.current = newIds;
+ setIsAltDuplicating(true);
+
+ // 복제된 컴포넌트들 선택
+ if (newIds.length === 1) {
+ selectComponent(newIds[0], false);
+ } else {
+ selectMultipleComponents(newIds);
+ }
+ }
+ }
+ }
+
setIsDragging(true);
setDragStart({
x: e.clientX - component.x,
@@ -388,6 +458,31 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
const deltaX = snappedX - component.x;
const deltaY = snappedY - component.y;
+ // Alt+드래그 복제 모드: 원본은 이동하지 않고 복제본만 이동
+ if (isAltDuplicating && duplicatedIdsRef.current.length > 0) {
+ // 복제된 컴포넌트들 이동 (각각의 원본 위치 기준으로 절대 위치 설정)
+ duplicatedIdsRef.current.forEach((dupId) => {
+ const dupComp = components.find((c) => c.id === dupId);
+ const originalPos = originalPositionsRef.current.get(dupId);
+
+ if (dupComp && originalPos) {
+ // 각 복제본의 원본 위치에서 delta만큼 이동
+ const targetX = originalPos.x + deltaX;
+ const targetY = originalPos.y + deltaY;
+
+ // 경계 체크
+ const dupMaxX = canvasWidthPx - marginRightPx - dupComp.width;
+ const dupMaxY = canvasHeightPx - marginBottomPx - dupComp.height;
+
+ updateComponent(dupId, {
+ x: Math.min(Math.max(marginLeftPx, targetX), dupMaxX),
+ y: Math.min(Math.max(marginTopPx, targetY), dupMaxY),
+ });
+ }
+ });
+ return; // 원본 컴포넌트는 이동하지 않음
+ }
+
// 현재 컴포넌트 이동
updateComponent(component.id, {
x: snappedX,
@@ -492,6 +587,10 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
const handleMouseUp = () => {
setIsDragging(false);
setIsResizing(false);
+ // Alt 복제 상태 초기화
+ setIsAltDuplicating(false);
+ duplicatedIdsRef.current = [];
+ originalPositionsRef.current = new Map();
// 가이드라인 초기화
clearAlignmentGuides();
};
@@ -506,6 +605,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
}, [
isDragging,
isResizing,
+ isAltDuplicating,
dragStart.x,
dragStart.y,
resizeStart.x,
diff --git a/frontend/components/report/designer/ReportDesignerCanvas.tsx b/frontend/components/report/designer/ReportDesignerCanvas.tsx
index e795c2c9..c3bd337a 100644
--- a/frontend/components/report/designer/ReportDesignerCanvas.tsx
+++ b/frontend/components/report/designer/ReportDesignerCanvas.tsx
@@ -211,6 +211,9 @@ export function ReportDesignerCanvas() {
alignmentGuides,
copyComponents,
pasteComponents,
+ duplicateComponents,
+ copyStyles,
+ pasteStyles,
undo,
redo,
showRuler,
@@ -629,16 +632,39 @@ export function ReportDesignerCanvas() {
}
}
+ // Ctrl+Shift+C (또는 Cmd+Shift+C): 스타일 복사 (일반 복사보다 먼저 체크)
+ if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "c") {
+ e.preventDefault();
+ copyStyles();
+ return;
+ }
+
+ // Ctrl+Shift+V (또는 Cmd+Shift+V): 스타일 붙여넣기 (일반 붙여넣기보다 먼저 체크)
+ if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "v") {
+ e.preventDefault();
+ pasteStyles();
+ return;
+ }
+
// Ctrl+C (또는 Cmd+C): 복사
- if ((e.ctrlKey || e.metaKey) && e.key === "c") {
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "c") {
e.preventDefault();
copyComponents();
+ return;
}
// Ctrl+V (또는 Cmd+V): 붙여넣기
- if ((e.ctrlKey || e.metaKey) && e.key === "v") {
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "v") {
e.preventDefault();
pasteComponents();
+ return;
+ }
+
+ // Ctrl+D (또는 Cmd+D): 복제
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "d") {
+ e.preventDefault();
+ duplicateComponents();
+ return;
}
// Ctrl+Shift+Z 또는 Ctrl+Y (또는 Cmd+Shift+Z / Cmd+Y): Redo (Undo보다 먼저 체크)
@@ -670,6 +696,9 @@ export function ReportDesignerCanvas() {
removeComponent,
copyComponents,
pasteComponents,
+ duplicateComponents,
+ copyStyles,
+ pasteStyles,
undo,
redo,
]);
diff --git a/frontend/contexts/ReportDesignerContext.tsx b/frontend/contexts/ReportDesignerContext.tsx
index a55a1c6d..b58a6f69 100644
--- a/frontend/contexts/ReportDesignerContext.tsx
+++ b/frontend/contexts/ReportDesignerContext.tsx
@@ -101,6 +101,10 @@ interface ReportDesignerContextType {
// 복사/붙여넣기
copyComponents: () => void;
pasteComponents: () => void;
+ duplicateComponents: () => void; // Ctrl+D 즉시 복제
+ copyStyles: () => void; // Ctrl+Shift+C 스타일만 복사
+ pasteStyles: () => void; // Ctrl+Shift+V 스타일만 붙여넣기
+ duplicateAtPosition: (componentIds: string[], offsetX?: number, offsetY?: number) => string[]; // Alt+드래그 복제용
// Undo/Redo
undo: () => void;
@@ -268,6 +272,9 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
// 클립보드 (복사/붙여넣기)
const [clipboard, setClipboard] = useState([]);
+ // 스타일 클립보드 (스타일만 복사/붙여넣기)
+ const [styleClipboard, setStyleClipboard] = useState | null>(null);
+
// Undo/Redo 히스토리
const [history, setHistory] = useState([]);
const [historyIndex, setHistoryIndex] = useState(-1);
@@ -353,6 +360,189 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
});
}, [clipboard, components.length, toast]);
+ // 복제 (Ctrl+D) - 선택된 컴포넌트를 즉시 복제
+ const duplicateComponents = useCallback(() => {
+ // 복제할 컴포넌트 결정
+ let componentsToDuplicate: ComponentConfig[] = [];
+
+ if (selectedComponentIds.length > 0) {
+ componentsToDuplicate = components.filter(
+ (comp) => selectedComponentIds.includes(comp.id) && !comp.locked
+ );
+ } else if (selectedComponentId) {
+ const comp = components.find((c) => c.id === selectedComponentId);
+ if (comp && !comp.locked) {
+ componentsToDuplicate = [comp];
+ }
+ }
+
+ if (componentsToDuplicate.length === 0) {
+ toast({
+ title: "복제 불가",
+ description: "복제할 컴포넌트가 없거나 잠긴 컴포넌트입니다.",
+ variant: "destructive",
+ });
+ return;
+ }
+
+ const newComponents = componentsToDuplicate.map((comp) => ({
+ ...comp,
+ id: `comp_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
+ x: comp.x + 20,
+ y: comp.y + 20,
+ zIndex: components.length,
+ locked: false, // 복제된 컴포넌트는 잠금 해제
+ }));
+
+ setComponents((prev) => [...prev, ...newComponents]);
+
+ // 복제된 컴포넌트 선택
+ if (newComponents.length === 1) {
+ setSelectedComponentId(newComponents[0].id);
+ setSelectedComponentIds([newComponents[0].id]);
+ } else {
+ setSelectedComponentIds(newComponents.map((c) => c.id));
+ setSelectedComponentId(newComponents[0].id);
+ }
+
+ toast({
+ title: "복제 완료",
+ description: `${newComponents.length}개의 컴포넌트가 복제되었습니다.`,
+ });
+ }, [selectedComponentId, selectedComponentIds, components, toast]);
+
+ // 스타일 복사 (Ctrl+Shift+C)
+ const copyStyles = useCallback(() => {
+ // 단일 컴포넌트만 스타일 복사 가능
+ const targetId = selectedComponentId || selectedComponentIds[0];
+ if (!targetId) {
+ toast({
+ title: "스타일 복사 불가",
+ description: "컴포넌트를 선택해주세요.",
+ variant: "destructive",
+ });
+ return;
+ }
+
+ const component = components.find((c) => c.id === targetId);
+ if (!component) return;
+
+ // 스타일 관련 속성만 추출
+ const styleProperties: Partial = {
+ fontSize: component.fontSize,
+ fontColor: component.fontColor,
+ fontWeight: component.fontWeight,
+ fontFamily: component.fontFamily,
+ textAlign: component.textAlign,
+ backgroundColor: component.backgroundColor,
+ borderWidth: component.borderWidth,
+ borderColor: component.borderColor,
+ borderStyle: component.borderStyle,
+ borderRadius: component.borderRadius,
+ boxShadow: component.boxShadow,
+ opacity: component.opacity,
+ padding: component.padding,
+ letterSpacing: component.letterSpacing,
+ lineHeight: component.lineHeight,
+ };
+
+ // undefined 값 제거
+ Object.keys(styleProperties).forEach((key) => {
+ if (styleProperties[key as keyof typeof styleProperties] === undefined) {
+ delete styleProperties[key as keyof typeof styleProperties];
+ }
+ });
+
+ setStyleClipboard(styleProperties);
+ toast({
+ title: "스타일 복사 완료",
+ description: "스타일이 복사되었습니다. Ctrl+Shift+V로 적용할 수 있습니다.",
+ });
+ }, [selectedComponentId, selectedComponentIds, components, toast]);
+
+ // 스타일 붙여넣기 (Ctrl+Shift+V)
+ const pasteStyles = useCallback(() => {
+ if (!styleClipboard) {
+ toast({
+ title: "스타일 붙여넣기 불가",
+ description: "먼저 Ctrl+Shift+C로 스타일을 복사해주세요.",
+ variant: "destructive",
+ });
+ return;
+ }
+
+ // 선택된 컴포넌트들에 스타일 적용
+ const targetIds =
+ selectedComponentIds.length > 0
+ ? selectedComponentIds
+ : selectedComponentId
+ ? [selectedComponentId]
+ : [];
+
+ if (targetIds.length === 0) {
+ toast({
+ title: "스타일 붙여넣기 불가",
+ description: "스타일을 적용할 컴포넌트를 선택해주세요.",
+ variant: "destructive",
+ });
+ return;
+ }
+
+ // 잠긴 컴포넌트 필터링
+ const applicableIds = targetIds.filter((id) => {
+ const comp = components.find((c) => c.id === id);
+ return comp && !comp.locked;
+ });
+
+ if (applicableIds.length === 0) {
+ toast({
+ title: "스타일 붙여넣기 불가",
+ description: "잠긴 컴포넌트에는 스타일을 적용할 수 없습니다.",
+ variant: "destructive",
+ });
+ return;
+ }
+
+ setComponents((prev) =>
+ prev.map((comp) => {
+ if (applicableIds.includes(comp.id)) {
+ return { ...comp, ...styleClipboard };
+ }
+ return comp;
+ })
+ );
+
+ toast({
+ title: "스타일 적용 완료",
+ description: `${applicableIds.length}개의 컴포넌트에 스타일이 적용되었습니다.`,
+ });
+ }, [styleClipboard, selectedComponentId, selectedComponentIds, components, toast]);
+
+ // Alt+드래그 복제용: 지정된 위치에 컴포넌트 복제
+ const duplicateAtPosition = useCallback(
+ (componentIds: string[], offsetX: number = 0, offsetY: number = 0): string[] => {
+ const componentsToDuplicate = components.filter(
+ (comp) => componentIds.includes(comp.id) && !comp.locked
+ );
+
+ if (componentsToDuplicate.length === 0) return [];
+
+ const newComponents = componentsToDuplicate.map((comp) => ({
+ ...comp,
+ id: `comp_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
+ x: comp.x + offsetX,
+ y: comp.y + offsetY,
+ zIndex: components.length,
+ locked: false,
+ }));
+
+ setComponents((prev) => [...prev, ...newComponents]);
+
+ return newComponents.map((c) => c.id);
+ },
+ [components]
+ );
+
// 히스토리에 현재 상태 저장
const saveToHistory = useCallback(
(newComponents: ComponentConfig[]) => {
@@ -1695,6 +1885,10 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
// 복사/붙여넣기
copyComponents,
pasteComponents,
+ duplicateComponents,
+ copyStyles,
+ pasteStyles,
+ duplicateAtPosition,
// Undo/Redo
undo,
redo,
From c20e393a1a930741147069a082f1af1ddc49ee67 Mon Sep 17 00:00:00 2001
From: dohyeons
Date: Wed, 24 Dec 2025 10:58:41 +0900
Subject: [PATCH 06/28] =?UTF-8?q?=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EC=9D=B8?=
=?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=ED=8E=B8=EC=A7=91=20=EA=B8=B0=EB=8A=A5=20?=
=?UTF-8?q?=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../report/designer/CanvasComponent.tsx | 91 ++++++++++++++-
.../report/designer/ReportDesignerCanvas.tsx | 9 ++
frontend/contexts/ReportDesignerContext.tsx | 110 ++++++++++++++++++
3 files changed, 208 insertions(+), 2 deletions(-)
diff --git a/frontend/components/report/designer/CanvasComponent.tsx b/frontend/components/report/designer/CanvasComponent.tsx
index 1bd6db73..da440abc 100644
--- a/frontend/components/report/designer/CanvasComponent.tsx
+++ b/frontend/components/report/designer/CanvasComponent.tsx
@@ -193,6 +193,11 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
// 복제 시 원본 컴포넌트들의 위치 저장 (상대적 위치 유지용)
const originalPositionsRef = useRef>(new Map());
+ // 인라인 편집 상태
+ const [isEditing, setIsEditing] = useState(false);
+ const [editValue, setEditValue] = useState("");
+ const textareaRef = useRef(null);
+
const isSelected = selectedComponentId === component.id;
const isMultiSelected = selectedComponentIds.includes(component.id);
const isLocked = component.locked === true;
@@ -290,15 +295,76 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
});
};
- // 더블 클릭 핸들러 (텍스트 컴포넌트만)
+ // 더블 클릭 핸들러 (텍스트 컴포넌트: 인라인 편집 모드 진입)
const handleDoubleClick = (e: React.MouseEvent) => {
if (component.type !== "text" && component.type !== "label") return;
+ if (isLocked) return; // 잠긴 컴포넌트는 편집 불가
+
e.stopPropagation();
- fitTextToContent();
+
+ // 인라인 편집 모드 진입
+ setEditValue(component.defaultValue || "");
+ setIsEditing(true);
+ };
+
+ // 인라인 편집 시작 시 textarea에 포커스
+ useEffect(() => {
+ if (isEditing && textareaRef.current) {
+ textareaRef.current.focus();
+ textareaRef.current.select();
+ }
+ }, [isEditing]);
+
+ // 선택 해제 시 편집 모드 종료를 위한 ref
+ const editValueRef = useRef(editValue);
+ const isEditingRef = useRef(isEditing);
+ editValueRef.current = editValue;
+ isEditingRef.current = isEditing;
+
+ // 선택 해제 시 편집 모드 종료 (저장 후 종료)
+ useEffect(() => {
+ if (!isSelected && !isMultiSelected && isEditingRef.current) {
+ // 현재 편집 값으로 저장
+ if (editValueRef.current !== component.defaultValue) {
+ updateComponent(component.id, { defaultValue: editValueRef.current });
+ }
+ setIsEditing(false);
+ }
+ }, [isSelected, isMultiSelected, component.id, component.defaultValue, updateComponent]);
+
+ // 인라인 편집 저장
+ const handleEditSave = () => {
+ if (!isEditing) return;
+
+ updateComponent(component.id, {
+ defaultValue: editValue,
+ });
+ setIsEditing(false);
+ };
+
+ // 인라인 편집 취소
+ const handleEditCancel = () => {
+ setIsEditing(false);
+ setEditValue("");
+ };
+
+ // 인라인 편집 키보드 핸들러
+ const handleEditKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Escape") {
+ e.preventDefault();
+ handleEditCancel();
+ } else if (e.key === "Enter" && !e.shiftKey) {
+ // Enter: 저장 (Shift+Enter는 줄바꿈)
+ e.preventDefault();
+ handleEditSave();
+ }
};
// 드래그 시작
const handleMouseDown = (e: React.MouseEvent) => {
+ // 편집 모드에서는 드래그 비활성화
+ if (isEditing) return;
+
if ((e.target as HTMLElement).classList.contains("resize-handle")) {
return;
}
@@ -636,6 +702,27 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
switch (component.type) {
case "text":
case "label":
+ // 인라인 편집 모드
+ if (isEditing) {
+ return (
+
{/* 자식 항목 */}
@@ -1360,11 +1514,6 @@ export const SplitPanelLayout2Component: React.FC {
- return config.leftPanel?.primaryKeyColumn || config.leftPanel?.hierarchyConfig?.idColumn || "id";
- }, [config.leftPanel?.primaryKeyColumn, config.leftPanel?.hierarchyConfig?.idColumn]);
-
// 왼쪽 패널 테이블 렌더링
const renderLeftTable = () => {
const displayColumns = config.leftPanel?.displayColumns || [];
@@ -1586,8 +1735,8 @@ export const SplitPanelLayout2Component: React.FC 0 && filteredRightData.every((item) => selectedRightItems.has(item[pkColumn]));
- const someSelected = filteredRightData.some((item) => selectedRightItems.has(item[pkColumn]));
+ filteredRightData.length > 0 && filteredRightData.every((item) => selectedRightItems.has(item[pkColumn] as string | number));
+ const someSelected = filteredRightData.some((item) => selectedRightItems.has(item[pkColumn] as string | number));
return (
@@ -1633,7 +1782,7 @@ export const SplitPanelLayout2Component: React.FC
) : (
filteredRightData.map((item, index) => {
- const itemId = item[pkColumn];
+ const itemId = item[pkColumn] as string | number;
return (
{showCheckbox && (
@@ -1962,11 +2111,7 @@ export const SplitPanelLayout2Component: React.FC {
- if (btn.action === "add") {
- handleLeftAddClick();
- }
- }}
+ onClick={() => handleLeftActionButton(btn)}
>
{btn.icon && {btn.icon} }
{btn.label || "버튼"}
diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx
index 1c7b7c77..5094a292 100644
--- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx
+++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx
@@ -992,6 +992,42 @@ export const SplitPanelLayout2ConfigPanel: React.FC
+ {/* 개별 수정/삭제 버튼 (좌측) */}
+
+
개별 수정/삭제
+
각 행에 표시되는 수정/삭제 버튼
+
+
+ 수정 버튼
+ updateConfig("leftPanel.showEditButton", checked)}
+ />
+
+
+ 삭제 버튼
+ updateConfig("leftPanel.showDeleteButton", checked)}
+ />
+
+ {(config.leftPanel?.showEditButton || config.leftPanel?.showDeleteButton) && (
+
+
기본키 컬럼
+
updateConfig("leftPanel.primaryKeyColumn", value)}
+ placeholder="기본키 컬럼 선택 (기본: id)"
+ />
+
+ 수정/삭제 시 레코드 식별에 사용
+
+
+ )}
+
+
+
{/* 탭 설정 (좌측) */}
@@ -1274,6 +1310,42 @@ export const SplitPanelLayout2ConfigPanel: React.FC
+ {/* 개별 수정/삭제 버튼 */}
+
+
개별 수정/삭제
+
각 행에 표시되는 수정/삭제 버튼
+
+
+ 수정 버튼
+ updateConfig("rightPanel.showEditButton", checked)}
+ />
+
+
+ 삭제 버튼
+ updateConfig("rightPanel.showDeleteButton", checked)}
+ />
+
+ {(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && (
+
+
기본키 컬럼
+
updateConfig("rightPanel.primaryKeyColumn", value)}
+ placeholder="기본키 컬럼 선택 (기본: id)"
+ />
+
+ 수정/삭제 시 레코드 식별에 사용
+
+
+ )}
+
+
+
{/* 탭 설정 (우측) */}
@@ -1348,39 +1420,6 @@ export const SplitPanelLayout2ConfigPanel: React.FC updateConfig("rightPanel.showCheckbox", checked)}
/>
-
- {/* 수정/삭제 버튼 */}
-
-
개별 수정/삭제
-
- 수정 버튼 표시
- updateConfig("rightPanel.showEditButton", checked)}
- />
-
-
- 삭제 버튼 표시
- updateConfig("rightPanel.showDeleteButton", checked)}
- />
-
-
-
- {/* 기본키 컬럼 */}
-
-
기본키 컬럼
-
updateConfig("rightPanel.primaryKeyColumn", value)}
- placeholder="기본키 컬럼 선택 (기본: id)"
- />
-
- 수정/삭제 시 사용할 기본키 컬럼 (미선택 시 id 사용)
-
-
diff --git a/frontend/lib/registry/components/split-panel-layout2/types.ts b/frontend/lib/registry/components/split-panel-layout2/types.ts
index 00c468e1..fbe8c912 100644
--- a/frontend/lib/registry/components/split-panel-layout2/types.ts
+++ b/frontend/lib/registry/components/split-panel-layout2/types.ts
@@ -158,6 +158,9 @@ export interface LeftPanelConfig {
showAddButton?: boolean; // 추가 버튼 표시 (하위 호환성)
addButtonLabel?: string; // 추가 버튼 라벨 (하위 호환성)
addModalScreenId?: number; // 추가 모달 화면 ID (하위 호환성)
+ showEditButton?: boolean; // 수정 버튼 표시
+ showDeleteButton?: boolean; // 삭제 버튼 표시
+ editModalScreenId?: number; // 수정 모달 화면 ID
actionButtons?: ActionButtonConfig[]; // 복수 액션 버튼 배열
displayMode?: "card" | "table"; // 표시 모드 (card: 카드형, table: 테이블형)
primaryKeyColumn?: string; // 기본키 컬럼명 (선택용, 기본: id)
diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx
index bc217299..64c2f826 100644
--- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx
@@ -1223,12 +1223,11 @@ export function UniversalFormModalComponent({
if (subTableConfig.options?.saveMainAsFirst) {
mainFieldMappings = [];
- // 메인 섹션(비반복)의 필드들을 서브 테이블에 매핑
- // 서브 테이블의 fieldMappings에서 targetColumn을 찾아서 매핑
+ // fieldMappings에 정의된 targetColumn만 매핑 (서브 테이블에 존재하는 컬럼만)
for (const mapping of subTableConfig.fieldMappings || []) {
if (mapping.targetColumn) {
// 메인 데이터에서 동일한 컬럼명이 있으면 매핑
- if (mainData[mapping.targetColumn] !== undefined) {
+ if (mainData[mapping.targetColumn] !== undefined && mainData[mapping.targetColumn] !== null && mainData[mapping.targetColumn] !== "") {
mainFieldMappings.push({
formField: mapping.targetColumn,
targetColumn: mapping.targetColumn,
@@ -1239,7 +1238,7 @@ export function UniversalFormModalComponent({
config.sections.forEach((section) => {
if (section.repeatable || section.type === "table") return;
const matchingField = (section.fields || []).find((f) => f.columnName === mapping.targetColumn);
- if (matchingField && mainData[matchingField.columnName] !== undefined) {
+ if (matchingField && mainData[matchingField.columnName] !== undefined && mainData[matchingField.columnName] !== null && mainData[matchingField.columnName] !== "") {
mainFieldMappings!.push({
formField: matchingField.columnName,
targetColumn: mapping.targetColumn,
@@ -1374,6 +1373,11 @@ export function UniversalFormModalComponent({
if (onSave) {
onSave({ ...formData, _saveCompleted: true });
}
+
+ // 저장 완료 후 모달 닫기 이벤트 발생
+ if (config.saveConfig.afterSave?.closeModal !== false) {
+ window.dispatchEvent(new CustomEvent("closeEditModal"));
+ }
} catch (error: any) {
console.error("저장 실패:", error);
// axios 에러의 경우 서버 응답 메시지 추출
@@ -1492,6 +1496,22 @@ export function UniversalFormModalComponent({
return `${valueVal} - ${displayVal}`;
case "name_code":
return `${displayVal} (${valueVal})`;
+ case "custom":
+ // 커스텀 형식: {컬럼명}을 실제 값으로 치환
+ if (lfg.customDisplayFormat) {
+ let result = lfg.customDisplayFormat;
+ // {컬럼명} 패턴을 찾아서 실제 값으로 치환
+ const matches = result.match(/\{([^}]+)\}/g);
+ if (matches) {
+ matches.forEach((match) => {
+ const columnName = match.slice(1, -1); // { } 제거
+ const columnValue = row[columnName];
+ result = result.replace(match, columnValue !== undefined && columnValue !== null ? String(columnValue) : "");
+ });
+ }
+ return result;
+ }
+ return String(displayVal);
case "name_only":
default:
return String(displayVal);
diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts
index 935d46be..43377764 100644
--- a/frontend/lib/registry/components/universal-form-modal/types.ts
+++ b/frontend/lib/registry/components/universal-form-modal/types.ts
@@ -81,7 +81,10 @@ export interface FormFieldConfig {
enabled?: boolean; // 사용 여부
sourceTable?: string; // 소스 테이블 (예: dept_info)
displayColumn?: string; // 표시할 컬럼 (예: dept_name) - 드롭다운에 보여줄 텍스트
- displayFormat?: "name_only" | "code_name" | "name_code"; // 표시 형식
+ displayFormat?: "name_only" | "code_name" | "name_code" | "custom"; // 표시 형식
+ // 커스텀 표시 형식 (displayFormat이 "custom"일 때 사용)
+ // 형식: {컬럼명} 으로 치환됨 (예: "{item_name} ({item_number})" → "철판 (ITEM-001)")
+ customDisplayFormat?: string;
mappings?: LinkedFieldMapping[]; // 저장할 컬럼 매핑 (첫 번째 매핑의 sourceColumn이 드롭다운 값으로 사용됨)
};
@@ -254,6 +257,53 @@ export interface TableSectionConfig {
multiSelect?: boolean; // 다중 선택 허용 (기본: true)
maxHeight?: string; // 테이블 최대 높이 (기본: "400px")
};
+
+ // 7. 조건부 테이블 설정 (고급)
+ conditionalTable?: ConditionalTableConfig;
+}
+
+/**
+ * 조건부 테이블 설정
+ * 조건(검사유형 등)에 따라 다른 데이터를 표시하고 저장합니다.
+ *
+ * 사용 예시:
+ * - 품목검사정보: 검사유형(입고/공정/출고/재고/최종)별로 검사항목 관리
+ * - BOM 관리: 품목유형별 자재 구성
+ * - 공정 관리: 공정유형별 작업 항목
+ */
+export interface ConditionalTableConfig {
+ enabled: boolean;
+
+ // 트리거 UI 타입
+ // - checkbox: 체크박스로 다중 선택 (선택된 조건들을 탭으로 표시)
+ // - dropdown: 드롭다운으로 단일 선택
+ // - tabs: 모든 옵션을 탭으로 표시
+ triggerType: "checkbox" | "dropdown" | "tabs";
+
+ // 조건 값을 저장할 컬럼 (예: inspection_type)
+ // 저장 시 각 행에 이 컬럼으로 조건 값이 자동 저장됨
+ conditionColumn: string;
+
+ // 조건 옵션 목록
+ options: ConditionalTableOption[];
+
+ // 옵션을 테이블에서 동적으로 로드할 경우
+ optionSource?: {
+ enabled: boolean;
+ tableName: string; // 예: inspection_type_code
+ valueColumn: string; // 예: type_code
+ labelColumn: string; // 예: type_name
+ filterCondition?: string; // 예: is_active = 'Y'
+ };
+}
+
+/**
+ * 조건부 테이블 옵션
+ */
+export interface ConditionalTableOption {
+ id: string;
+ value: string; // 저장될 값 (예: "입고검사")
+ label: string; // 표시 라벨 (예: "입고검사")
}
/**
@@ -650,6 +700,7 @@ export interface ModalConfig {
showCloseButton?: boolean;
// 버튼 설정
+ showSaveButton?: boolean; // 저장 버튼 표시 (기본: true)
saveButtonText?: string; // 저장 버튼 텍스트 (기본: "저장")
cancelButtonText?: string; // 취소 버튼 텍스트 (기본: "취소")
showResetButton?: boolean; // 초기화 버튼 표시
@@ -748,6 +799,7 @@ export const LINKED_FIELD_DISPLAY_FORMAT_OPTIONS = [
{ value: "name_only", label: "이름만 (예: 영업부)" },
{ value: "code_name", label: "코드 - 이름 (예: SALES - 영업부)" },
{ value: "name_code", label: "이름 (코드) (예: 영업부 (SALES))" },
+ { value: "custom", label: "커스텀 형식 (직접 입력)" },
] as const;
// ============================================
@@ -815,3 +867,10 @@ export const LOOKUP_CONDITION_SOURCE_OPTIONS = [
{ value: "sectionField", label: "다른 섹션" },
{ value: "externalTable", label: "외부 테이블" },
] as const;
+
+// 조건부 테이블 트리거 타입 옵션
+export const CONDITIONAL_TABLE_TRIGGER_OPTIONS = [
+ { value: "checkbox", label: "체크박스 (다중 선택)" },
+ { value: "dropdown", label: "드롭다운 (단일 선택)" },
+ { value: "tabs", label: "탭 (전체 표시)" },
+] as const;
From 722b4787e26129e73740a3b8a91f6beb9d5aaed8 Mon Sep 17 00:00:00 2001
From: kjs
Date: Wed, 24 Dec 2025 18:37:53 +0900
Subject: [PATCH 09/28] =?UTF-8?q?2=EB=A0=88=EB=B2=A8=EB=A9=94=EB=89=B4=20?=
=?UTF-8?q?=EB=B3=B5=EC=82=AC=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend-node/src/services/menuCopyService.ts | 13 +++++++++----
1 file changed, 9 insertions(+), 4 deletions(-)
diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts
index 075a8229..a163f30c 100644
--- a/backend-node/src/services/menuCopyService.ts
+++ b/backend-node/src/services/menuCopyService.ts
@@ -2201,15 +2201,20 @@ export class MenuCopyService {
"system",
]);
- await client.query(
+ const result = await client.query(
`INSERT INTO screen_menu_assignments (
screen_id, menu_objid, company_code, display_order, is_active, created_by
- ) VALUES ${assignmentValues}`,
+ ) VALUES ${assignmentValues}
+ ON CONFLICT (screen_id, menu_objid, company_code) DO NOTHING`,
assignmentParams
);
- }
- logger.info(`✅ 화면-메뉴 할당 완료: ${validAssignments.length}개`);
+ logger.info(
+ `✅ 화면-메뉴 할당 완료: ${result.rowCount}개 삽입 (${validAssignments.length - (result.rowCount || 0)}개 중복 무시)`
+ );
+ } else {
+ logger.info(`📭 화면-메뉴 할당할 항목 없음`);
+ }
}
/**
From 47b23d1aa381ad935733ca7f9016ce09946dd657 Mon Sep 17 00:00:00 2001
From: SeongHyun Kim
Date: Sun, 28 Dec 2025 19:32:13 +0900
Subject: [PATCH 10/28] =?UTF-8?q?feat(universal-form-modal):=20=EC=A1=B0?=
=?UTF-8?q?=EA=B1=B4=EB=B6=80=20=ED=85=8C=EC=9D=B4=EB=B8=94,=20=EB=8F=99?=
=?UTF-8?q?=EC=A0=81=20Select=20=EC=98=B5=EC=85=98,=20=EC=84=9C=EB=B8=8C?=
=?UTF-8?q?=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=88=98=EC=A0=95=20=EB=A1=9C?=
=?UTF-8?q?=EB=93=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20=EC=A1=B0?=
=?UTF-8?q?=EA=B1=B4=EB=B6=80=20=ED=85=8C=EC=9D=B4=EB=B8=94:=20=EC=B2=B4?=
=?UTF-8?q?=ED=81=AC=EB=B0=95=EC=8A=A4/=ED=83=AD=EC=9C=BC=EB=A1=9C=20?=
=?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=EC=84=A0=ED=83=9D=20=EC=8B=9C=20=EB=8B=A4?=
=?UTF-8?q?=EB=A5=B8=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EB=8D=B0=EC=9D=B4?=
=?UTF-8?q?=ED=84=B0=20=EA=B4=80=EB=A6=AC=20=EB=8F=99=EC=A0=81=20Select=20?=
=?UTF-8?q?=EC=98=B5=EC=85=98:=20=EC=86=8C=EC=8A=A4=20=ED=85=8C=EC=9D=B4?=
=?UTF-8?q?=EB=B8=94=EC=97=90=EC=84=9C=20=EB=93=9C=EB=A1=AD=EB=8B=A4?=
=?UTF-8?q?=EC=9A=B4=20=EC=98=B5=EC=85=98=20=EB=8F=99=EC=A0=81=20=EB=A1=9C?=
=?UTF-8?q?=EB=93=9C=20=ED=96=89=20=EC=84=A0=ED=83=9D=20=EB=AA=A8=EB=93=9C?=
=?UTF-8?q?:=20Select=20=EA=B0=92=20=EB=B3=80=EA=B2=BD=20=EC=8B=9C=20?=
=?UTF-8?q?=EA=B0=99=EC=9D=80=20=EC=86=8C=EC=8A=A4=20=ED=96=89=EC=9D=98=20?=
=?UTF-8?q?=EC=97=B0=EA=B4=80=20=EC=BB=AC=EB=9F=BC=20=EC=9E=90=EB=8F=99=20?=
=?UTF-8?q?=EC=B1=84=EC=9B=80=20=EC=88=98=EC=A0=95=20=EB=AA=A8=EB=93=9C=20?=
=?UTF-8?q?=EC=84=9C=EB=B8=8C=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EB=A1=9C?=
=?UTF-8?q?=EB=93=9C:=20loadOnEdit=20=EC=98=B5=EC=85=98=EC=9C=BC=EB=A1=9C?=
=?UTF-8?q?=20=EB=B0=98=EB=B3=B5=20=EC=84=B9=EC=85=98=20=EB=8D=B0=EC=9D=B4?=
=?UTF-8?q?=ED=84=B0=20=EC=9E=90=EB=8F=99=20=EB=A1=9C=EB=93=9C=20SplitPane?=
=?UTF-8?q?lLayout2=20=EB=A9=94=EC=9D=B8=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?=
=?UTF-8?q?=EB=B3=91=ED=95=A9:=20=EC=84=9C=EB=B8=8C=20=ED=85=8C=EC=9D=B4?=
=?UTF-8?q?=EB=B8=94=20=EC=88=98=EC=A0=95=20=EC=8B=9C=20=EB=A9=94=EC=9D=B8?=
=?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=ED=95=A8=EA=BB=98=20=EC=A1=B0?=
=?UTF-8?q?=ED=9A=8C=20=EC=97=B0=EA=B2=B0=20=ED=95=84=EB=93=9C=20=EA=B7=B8?=
=?UTF-8?q?=EB=A3=B9=20=ED=91=9C=EC=8B=9C=20=ED=98=95=EC=8B=9D:=20subDispl?=
=?UTF-8?q?ayColumn=20=EC=B6=94=EA=B0=80=EB=A1=9C=20=EB=A9=94=EC=9D=B8/?=
=?UTF-8?q?=EC=84=9C=EB=B8=8C=20=EC=BB=AC=EB=9F=BC=20=EB=B6=84=EB=A6=AC=20?=
=?UTF-8?q?=EC=84=A4=EC=A0=95=20UX=20=EA=B0=9C=EC=84=A0:=20=EC=BB=AC?=
=?UTF-8?q?=EB=9F=BC=20=EC=84=A0=ED=83=9D=20UI=EB=A5=BC=20=EA=B2=80?=
=?UTF-8?q?=EC=83=89=20=EA=B0=80=EB=8A=A5=ED=95=9C=20Combobox=EB=A1=9C=20?=
=?UTF-8?q?=EC=A0=84=ED=99=98=20saveMainAsFirst=20=EB=A1=9C=EC=A7=81=20?=
=?UTF-8?q?=EA=B0=9C=EC=84=A0:=20items=20=EC=97=86=EC=96=B4=EB=8F=84=20?=
=?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=80?=
=?UTF-8?q?=EC=9E=A5=20=EA=B0=80=EB=8A=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../controllers/tableManagementController.ts | 22 +-
frontend/components/screen/EditModal.tsx | 8 -
.../SplitPanelLayout2Component.tsx | 41 +-
.../SplitPanelLayout2ConfigPanel.tsx | 59 ++
.../components/split-panel-layout2/types.ts | 14 +
.../TableSectionRenderer.tsx | 951 ++++++++++++++++-
.../UniversalFormModalComponent.tsx | 212 +++-
.../components/universal-form-modal/config.ts | 34 +
.../modals/FieldDetailSettingsModal.tsx | 362 +++++--
.../modals/SaveSettingsModal.tsx | 644 ++++++++++--
.../modals/TableColumnSettingsModal.tsx | 351 +++++++
.../modals/TableSectionSettingsModal.tsx | 961 +++++++++++++++++-
.../components/universal-form-modal/types.ts | 43 +-
13 files changed, 3461 insertions(+), 241 deletions(-)
diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts
index 04fa1add..7c84898b 100644
--- a/backend-node/src/controllers/tableManagementController.ts
+++ b/backend-node/src/controllers/tableManagementController.ts
@@ -1973,15 +1973,21 @@ export async function multiTableSave(
for (const subTableConfig of subTables || []) {
const { tableName, linkColumn, items, options } = subTableConfig;
- if (!tableName || !items || items.length === 0) {
- logger.info(`서브 테이블 ${tableName} 스킵: 데이터 없음`);
+ // saveMainAsFirst가 활성화된 경우, items가 비어있어도 메인 데이터를 서브 테이블에 저장해야 함
+ const hasSaveMainAsFirst = options?.saveMainAsFirst &&
+ options?.mainFieldMappings &&
+ options.mainFieldMappings.length > 0;
+
+ if (!tableName || (!items?.length && !hasSaveMainAsFirst)) {
+ logger.info(`서브 테이블 ${tableName} 스킵: 데이터 없음 (saveMainAsFirst: ${hasSaveMainAsFirst})`);
continue;
}
logger.info(`서브 테이블 ${tableName} 저장 시작:`, {
- itemsCount: items.length,
+ itemsCount: items?.length || 0,
linkColumn,
options,
+ hasSaveMainAsFirst,
});
// 기존 데이터 삭제 옵션
@@ -1999,7 +2005,15 @@ export async function multiTableSave(
}
// 메인 데이터도 서브 테이블에 저장 (옵션)
- if (options?.saveMainAsFirst && options?.mainFieldMappings && linkColumn?.subColumn) {
+ // mainFieldMappings가 비어 있으면 건너뜀 (필수 컬럼 누락 방지)
+ logger.info(`saveMainAsFirst 옵션 확인:`, {
+ saveMainAsFirst: options?.saveMainAsFirst,
+ mainFieldMappings: options?.mainFieldMappings,
+ mainFieldMappingsLength: options?.mainFieldMappings?.length,
+ linkColumn,
+ mainDataKeys: Object.keys(mainData),
+ });
+ if (options?.saveMainAsFirst && options?.mainFieldMappings && options.mainFieldMappings.length > 0 && linkColumn?.subColumn) {
const mainSubItem: Record = {
[linkColumn.subColumn]: savedPkValue,
};
diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx
index 1969f562..58149088 100644
--- a/frontend/components/screen/EditModal.tsx
+++ b/frontend/components/screen/EditModal.tsx
@@ -996,14 +996,6 @@ export const EditModal: React.FC = ({ className }) => {
screenId: modalState.screenId, // 화면 ID 추가
};
- // 🔍 디버깅: enrichedFormData 확인
- console.log("🔑 [EditModal] enrichedFormData 생성:", {
- "screenData.screenInfo": screenData.screenInfo,
- "screenData.screenInfo?.tableName": screenData.screenInfo?.tableName,
- "enrichedFormData.tableName": enrichedFormData.tableName,
- "enrichedFormData.id": enrichedFormData.id,
- });
-
return (
{
+ async (item: any) => {
// 수정용 모달 화면 ID 결정 (editModalScreenId 우선, 없으면 addModalScreenId 사용)
const modalScreenId = config.rightPanel?.editModalScreenId || config.rightPanel?.addModalScreenId;
@@ -684,13 +684,42 @@ export const SplitPanelLayout2Component: React.FC {
if (selectedLeftItem) {
@@ -700,9 +729,9 @@ export const SplitPanelLayout2Component: React.FC());
} else if (itemToDelete) {
- // 단일 삭제 - 해당 항목 데이터를 body로 전달
+ // 단일 삭제 - 해당 항목 데이터를 배열로 감싸서 body로 전달 (백엔드가 배열을 기대함)
console.log(`[SplitPanelLayout2] ${deleteTargetPanel === "left" ? "좌측" : "우측"} 단일 삭제:`, itemToDelete);
await apiClient.delete(`/table-management/tables/${tableName}/delete`, {
- data: itemToDelete,
+ data: [itemToDelete],
});
toast.success("항목이 삭제되었습니다.");
}
diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx
index 5094a292..8ff83b6f 100644
--- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx
+++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx
@@ -1343,6 +1343,65 @@ export const SplitPanelLayout2ConfigPanel: React.FC
)}
+
+ {/* 수정 시 메인 테이블 조회 설정 */}
+ {config.rightPanel?.showEditButton && (
+
+
+ 수정 시 메인 테이블 조회
+ {
+ if (checked) {
+ updateConfig("rightPanel.mainTableForEdit", {
+ tableName: "",
+ linkColumn: { mainColumn: "", subColumn: "" },
+ });
+ } else {
+ updateConfig("rightPanel.mainTableForEdit", undefined);
+ }
+ }}
+ />
+
+
+ 우측 패널이 서브 테이블일 때, 수정 모달에 메인 테이블 데이터도 함께 전달
+
+
+ {config.rightPanel?.mainTableForEdit && (
+
+
+ 메인 테이블
+ updateConfig("rightPanel.mainTableForEdit.tableName", e.target.value)}
+ placeholder="예: user_info"
+ className="h-7 text-xs mt-1"
+ />
+
+
+
+ )}
+
+ )}
diff --git a/frontend/lib/registry/components/split-panel-layout2/types.ts b/frontend/lib/registry/components/split-panel-layout2/types.ts
index fbe8c912..ae8c71ed 100644
--- a/frontend/lib/registry/components/split-panel-layout2/types.ts
+++ b/frontend/lib/registry/components/split-panel-layout2/types.ts
@@ -211,6 +211,20 @@ export interface RightPanelConfig {
* - 결과: 부서별 사원 목록에 이메일, 전화번호 등 개인정보 함께 표시
*/
joinTables?: JoinTableConfig[];
+
+ /**
+ * 수정 시 메인 테이블 데이터 조회 설정
+ * 우측 패널이 서브 테이블(예: user_dept)이고, 수정 모달이 메인 테이블(예: user_info) 기준일 때
+ * 수정 버튼 클릭 시 메인 테이블 데이터를 함께 조회하여 모달에 전달합니다.
+ */
+ mainTableForEdit?: {
+ tableName: string; // 메인 테이블명 (예: user_info)
+ linkColumn: {
+ mainColumn: string; // 메인 테이블의 연결 컬럼 (예: user_id)
+ subColumn: string; // 서브 테이블의 연결 컬럼 (예: user_id)
+ };
+ };
+
// 탭 설정
tabConfig?: TabConfig;
}
diff --git a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx
index 047849b6..64418541 100644
--- a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx
@@ -2,20 +2,23 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
-import { Plus, Columns, AlignJustify } from "lucide-react";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Badge } from "@/components/ui/badge";
+import { Plus, Columns, AlignJustify, Trash2, Search } from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
// 기존 ModalRepeaterTable 컴포넌트 재사용
import { RepeaterTable } from "../modal-repeater-table/RepeaterTable";
import { ItemSelectionModal } from "../modal-repeater-table/ItemSelectionModal";
-import { RepeaterColumnConfig, CalculationRule, DynamicDataSourceOption } from "../modal-repeater-table/types";
+import { RepeaterColumnConfig, CalculationRule } from "../modal-repeater-table/types";
// 타입 정의
import {
TableSectionConfig,
TableColumnConfig,
- ValueMappingConfig,
TableJoinCondition,
FormDataState,
} from "./types";
@@ -26,9 +29,16 @@ interface TableSectionRendererProps {
formData: FormDataState;
onFormDataChange: (field: string, value: any) => void;
onTableDataChange: (data: any[]) => void;
+ // 조건부 테이블용 콜백 (조건별 데이터 변경)
+ onConditionalTableDataChange?: (conditionValue: string, data: any[]) => void;
className?: string;
}
+// 조건부 테이블 데이터 타입
+interface ConditionalTableData {
+ [conditionValue: string]: any[];
+}
+
/**
* TableColumnConfig를 RepeaterColumnConfig로 변환
* columnModes 또는 lookup이 있으면 dynamicDataSource로 변환
@@ -319,16 +329,30 @@ export function TableSectionRenderer({
formData,
onFormDataChange,
onTableDataChange,
+ onConditionalTableDataChange,
className,
}: TableSectionRendererProps) {
- // 테이블 데이터 상태
+ // 테이블 데이터 상태 (일반 모드)
const [tableData, setTableData] = useState([]);
+ // 조건부 테이블 데이터 상태 (조건별로 분리)
+ const [conditionalTableData, setConditionalTableData] = useState({});
+
+ // 조건부 테이블: 선택된 조건들 (체크박스 모드)
+ const [selectedConditions, setSelectedConditions] = useState([]);
+
+ // 조건부 테이블: 현재 활성 탭
+ const [activeConditionTab, setActiveConditionTab] = useState("");
+
+ // 조건부 테이블: 현재 모달이 열린 조건 (어떤 조건의 테이블에 추가할지)
+ const [modalCondition, setModalCondition] = useState("");
+
// 모달 상태
const [modalOpen, setModalOpen] = useState(false);
- // 체크박스 선택 상태
+ // 체크박스 선택 상태 (조건별로 분리)
const [selectedRows, setSelectedRows] = useState>(new Set());
+ const [conditionalSelectedRows, setConditionalSelectedRows] = useState>>({});
// 너비 조정 트리거 (홀수: 자동맞춤, 짝수: 균등분배)
const [widthTrigger, setWidthTrigger] = useState(0);
@@ -341,6 +365,257 @@ export function TableSectionRenderer({
// 초기 데이터 로드 완료 플래그 (무한 루프 방지)
const initialDataLoadedRef = React.useRef(false);
+
+ // 조건부 테이블 설정
+ const conditionalConfig = tableConfig.conditionalTable;
+ const isConditionalMode = conditionalConfig?.enabled ?? false;
+
+ // 조건부 테이블: 동적 옵션 로드 상태
+ const [dynamicOptions, setDynamicOptions] = useState<{ id: string; value: string; label: string }[]>([]);
+ const [dynamicOptionsLoading, setDynamicOptionsLoading] = useState(false);
+ const dynamicOptionsLoadedRef = React.useRef(false);
+
+ // 조건부 테이블: 동적 옵션 로드 (optionSource 설정이 있는 경우)
+ useEffect(() => {
+ if (!isConditionalMode) return;
+ if (!conditionalConfig?.optionSource?.enabled) return;
+ if (dynamicOptionsLoadedRef.current) return;
+
+ const { tableName, valueColumn, labelColumn, filterCondition } = conditionalConfig.optionSource;
+
+ if (!tableName || !valueColumn) return;
+
+ const loadDynamicOptions = async () => {
+ setDynamicOptionsLoading(true);
+ try {
+ // DISTINCT 값을 가져오기 위한 API 호출
+ const response = await apiClient.post(
+ `/table-management/tables/${tableName}/data`,
+ {
+ search: filterCondition ? { _raw: filterCondition } : {},
+ size: 1000,
+ page: 1,
+ }
+ );
+
+ if (response.data.success && response.data.data?.data) {
+ const rows = response.data.data.data;
+
+ // 중복 제거하여 고유 값 추출
+ const uniqueValues = new Map();
+ for (const row of rows) {
+ const value = row[valueColumn];
+ if (value && !uniqueValues.has(value)) {
+ const label = labelColumn ? (row[labelColumn] || value) : value;
+ uniqueValues.set(value, label);
+ }
+ }
+
+ // 옵션 배열로 변환
+ const options = Array.from(uniqueValues.entries()).map(([value, label], index) => ({
+ id: `dynamic_${index}`,
+ value,
+ label,
+ }));
+
+ console.log("[TableSectionRenderer] 동적 옵션 로드 완료:", {
+ tableName,
+ valueColumn,
+ optionCount: options.length,
+ options,
+ });
+
+ setDynamicOptions(options);
+ dynamicOptionsLoadedRef.current = true;
+ }
+ } catch (error) {
+ console.error("[TableSectionRenderer] 동적 옵션 로드 실패:", error);
+ } finally {
+ setDynamicOptionsLoading(false);
+ }
+ };
+
+ loadDynamicOptions();
+ }, [isConditionalMode, conditionalConfig?.optionSource]);
+
+ // ============================================
+ // 동적 Select 옵션 (소스 테이블에서 드롭다운 옵션 로드)
+ // ============================================
+
+ // 소스 테이블 데이터 캐시 (동적 Select 옵션용)
+ const [sourceDataCache, setSourceDataCache] = useState([]);
+ const sourceDataLoadedRef = React.useRef(false);
+
+ // 동적 Select 옵션이 있는 컬럼 확인
+ const hasDynamicSelectColumns = useMemo(() => {
+ return tableConfig.columns?.some(col => col.dynamicSelectOptions?.enabled);
+ }, [tableConfig.columns]);
+
+ // 소스 테이블 데이터 로드 (동적 Select 옵션용)
+ useEffect(() => {
+ if (!hasDynamicSelectColumns) return;
+ if (sourceDataLoadedRef.current) return;
+ if (!tableConfig.source?.tableName) return;
+
+ const loadSourceData = async () => {
+ try {
+ // 조건부 테이블 필터 조건 적용
+ const filterCondition: Record = {};
+
+ // 소스 필터가 활성화되어 있고 조건이 선택되어 있으면 필터 적용
+ if (conditionalConfig?.sourceFilter?.enabled && activeConditionTab) {
+ filterCondition[conditionalConfig.sourceFilter.filterColumn] = activeConditionTab;
+ }
+
+ const response = await apiClient.post(
+ `/table-management/tables/${tableConfig.source.tableName}/data`,
+ {
+ search: filterCondition,
+ size: 1000,
+ page: 1,
+ }
+ );
+
+ if (response.data.success && response.data.data?.data) {
+ setSourceDataCache(response.data.data.data);
+ sourceDataLoadedRef.current = true;
+ console.log("[TableSectionRenderer] 소스 데이터 로드 완료:", {
+ tableName: tableConfig.source.tableName,
+ rowCount: response.data.data.data.length,
+ filter: filterCondition,
+ });
+ }
+ } catch (error) {
+ console.error("[TableSectionRenderer] 소스 데이터 로드 실패:", error);
+ }
+ };
+
+ loadSourceData();
+ }, [hasDynamicSelectColumns, tableConfig.source?.tableName, conditionalConfig?.sourceFilter, activeConditionTab]);
+
+ // 조건 탭 변경 시 소스 데이터 다시 로드
+ useEffect(() => {
+ if (!hasDynamicSelectColumns) return;
+ if (!conditionalConfig?.sourceFilter?.enabled) return;
+ if (!activeConditionTab) return;
+
+ // 조건 변경 시 캐시 리셋하고 다시 로드
+ sourceDataLoadedRef.current = false;
+ setSourceDataCache([]);
+ }, [activeConditionTab, hasDynamicSelectColumns, conditionalConfig?.sourceFilter?.enabled]);
+
+ // 컬럼별 동적 Select 옵션 생성
+ const dynamicSelectOptionsMap = useMemo(() => {
+ const optionsMap: Record = {};
+
+ if (!sourceDataCache.length) return optionsMap;
+
+ for (const col of tableConfig.columns || []) {
+ if (!col.dynamicSelectOptions?.enabled) continue;
+
+ const { sourceField, labelField, distinct = true } = col.dynamicSelectOptions;
+
+ if (!sourceField) continue;
+
+ // 소스 데이터에서 옵션 추출
+ const seenValues = new Set();
+ const options: { value: string; label: string }[] = [];
+
+ for (const row of sourceDataCache) {
+ const value = row[sourceField];
+ if (value === undefined || value === null || value === "") continue;
+
+ const stringValue = String(value);
+
+ if (distinct && seenValues.has(stringValue)) continue;
+ seenValues.add(stringValue);
+
+ const label = labelField ? (row[labelField] || stringValue) : stringValue;
+ options.push({ value: stringValue, label: String(label) });
+ }
+
+ optionsMap[col.field] = options;
+ }
+
+ return optionsMap;
+ }, [sourceDataCache, tableConfig.columns]);
+
+ // 행 선택 모드: 드롭다운 값 변경 시 같은 소스 행의 다른 컬럼들 자동 채움
+ const handleDynamicSelectChange = useCallback(
+ (rowIndex: number, columnField: string, selectedValue: string, conditionValue?: string) => {
+ const column = tableConfig.columns?.find(col => col.field === columnField);
+ if (!column?.dynamicSelectOptions?.rowSelectionMode?.enabled) {
+ // 행 선택 모드가 아니면 일반 값 변경만
+ if (conditionValue && isConditionalMode) {
+ const currentData = conditionalTableData[conditionValue] || [];
+ const newData = [...currentData];
+ newData[rowIndex] = { ...newData[rowIndex], [columnField]: selectedValue };
+ setConditionalTableData(prev => ({ ...prev, [conditionValue]: newData }));
+ onConditionalTableDataChange?.(conditionValue, newData);
+ } else {
+ const newData = [...tableData];
+ newData[rowIndex] = { ...newData[rowIndex], [columnField]: selectedValue };
+ handleDataChange(newData);
+ }
+ return;
+ }
+
+ // 행 선택 모드: 소스 데이터에서 해당 값을 가진 행 찾기
+ const { sourceField } = column.dynamicSelectOptions;
+ const { autoFillColumns, sourceIdColumn, targetIdField } = column.dynamicSelectOptions.rowSelectionMode;
+
+ const sourceRow = sourceDataCache.find(row => String(row[sourceField]) === selectedValue);
+
+ if (!sourceRow) {
+ console.warn(`[TableSectionRenderer] 소스 행을 찾을 수 없음: ${sourceField} = ${selectedValue}`);
+ return;
+ }
+
+ // 현재 행 데이터 가져오기
+ let currentData: any[];
+ if (conditionValue && isConditionalMode) {
+ currentData = conditionalTableData[conditionValue] || [];
+ } else {
+ currentData = tableData;
+ }
+
+ const newData = [...currentData];
+ const updatedRow = { ...newData[rowIndex], [columnField]: selectedValue };
+
+ // 자동 채움 매핑 적용
+ if (autoFillColumns) {
+ for (const mapping of autoFillColumns) {
+ const sourceValue = sourceRow[mapping.sourceColumn];
+ if (sourceValue !== undefined) {
+ updatedRow[mapping.targetField] = sourceValue;
+ }
+ }
+ }
+
+ // 소스 ID 저장
+ if (sourceIdColumn && targetIdField) {
+ updatedRow[targetIdField] = sourceRow[sourceIdColumn];
+ }
+
+ newData[rowIndex] = updatedRow;
+
+ // 데이터 업데이트
+ if (conditionValue && isConditionalMode) {
+ setConditionalTableData(prev => ({ ...prev, [conditionValue]: newData }));
+ onConditionalTableDataChange?.(conditionValue, newData);
+ } else {
+ handleDataChange(newData);
+ }
+
+ console.log("[TableSectionRenderer] 행 선택 모드 자동 채움:", {
+ columnField,
+ selectedValue,
+ sourceRow,
+ updatedRow,
+ });
+ },
+ [tableConfig.columns, sourceDataCache, tableData, conditionalTableData, isConditionalMode, handleDataChange, onConditionalTableDataChange]
+ );
// formData에서 초기 테이블 데이터 로드 (수정 모드에서 _groupedData 표시)
useEffect(() => {
@@ -360,8 +635,19 @@ export function TableSectionRenderer({
}
}, [sectionId, formData]);
- // RepeaterColumnConfig로 변환
- const columns: RepeaterColumnConfig[] = (tableConfig.columns || []).map(convertToRepeaterColumn);
+ // RepeaterColumnConfig로 변환 (동적 Select 옵션 반영)
+ const columns: RepeaterColumnConfig[] = useMemo(() => {
+ return (tableConfig.columns || []).map(col => {
+ const baseColumn = convertToRepeaterColumn(col);
+
+ // 동적 Select 옵션이 있으면 적용
+ if (col.dynamicSelectOptions?.enabled && dynamicSelectOptionsMap[col.field]) {
+ baseColumn.selectOptions = dynamicSelectOptionsMap[col.field];
+ }
+
+ return baseColumn;
+ });
+ }, [tableConfig.columns, dynamicSelectOptionsMap]);
// 계산 규칙 변환
const calculationRules: CalculationRule[] = (tableConfig.calculations || []).map(convertToCalculationRule);
@@ -444,15 +730,47 @@ export function TableSectionRenderer({
[onTableDataChange, tableConfig.columns, batchAppliedFields]
);
- // 행 변경 핸들러
+ // 행 변경 핸들러 (동적 Select 행 선택 모드 지원)
const handleRowChange = useCallback(
- (index: number, newRow: any) => {
+ (index: number, newRow: any, conditionValue?: string) => {
+ const oldRow = conditionValue && isConditionalMode
+ ? (conditionalTableData[conditionValue]?.[index] || {})
+ : (tableData[index] || {});
+
+ // 변경된 필드 찾기
+ const changedFields: string[] = [];
+ for (const key of Object.keys(newRow)) {
+ if (oldRow[key] !== newRow[key]) {
+ changedFields.push(key);
+ }
+ }
+
+ // 동적 Select 컬럼의 행 선택 모드 확인
+ for (const changedField of changedFields) {
+ const column = tableConfig.columns?.find(col => col.field === changedField);
+ if (column?.dynamicSelectOptions?.rowSelectionMode?.enabled) {
+ // 행 선택 모드 처리 (자동 채움)
+ handleDynamicSelectChange(index, changedField, newRow[changedField], conditionValue);
+ return; // 행 선택 모드에서 처리 완료
+ }
+ }
+
+ // 일반 행 변경 처리
const calculatedRow = calculateRow(newRow);
- const newData = [...tableData];
- newData[index] = calculatedRow;
- handleDataChange(newData);
+
+ if (conditionValue && isConditionalMode) {
+ const currentData = conditionalTableData[conditionValue] || [];
+ const newData = [...currentData];
+ newData[index] = calculatedRow;
+ setConditionalTableData(prev => ({ ...prev, [conditionValue]: newData }));
+ onConditionalTableDataChange?.(conditionValue, newData);
+ } else {
+ const newData = [...tableData];
+ newData[index] = calculatedRow;
+ handleDataChange(newData);
+ }
},
- [tableData, calculateRow, handleDataChange]
+ [tableData, conditionalTableData, isConditionalMode, tableConfig.columns, calculateRow, handleDataChange, handleDynamicSelectChange, onConditionalTableDataChange]
);
// 행 삭제 핸들러
@@ -778,19 +1096,35 @@ export function TableSectionRenderer({
const sourceSearchFields = source.searchColumns;
const columnLabels = source.columnLabels || {};
const modalTitle = uiConfig?.modalTitle || "항목 검색 및 선택";
- const addButtonText = uiConfig?.addButtonText || "항목 검색";
+ const addButtonType = uiConfig?.addButtonType || "search";
+ const addButtonText = uiConfig?.addButtonText || (addButtonType === "addRow" ? "항목 추가" : "항목 검색");
const multiSelect = uiConfig?.multiSelect ?? true;
// 기본 필터 조건 생성 (사전 필터만 - 모달 필터는 ItemSelectionModal에서 처리)
- const baseFilterCondition: Record = {};
- if (filters?.preFilters) {
- for (const filter of filters.preFilters) {
- // 간단한 "=" 연산자만 처리 (확장 가능)
- if (filter.operator === "=") {
- baseFilterCondition[filter.column] = filter.value;
+ const baseFilterCondition: Record = useMemo(() => {
+ const condition: Record = {};
+ if (filters?.preFilters) {
+ for (const filter of filters.preFilters) {
+ // 간단한 "=" 연산자만 처리 (확장 가능)
+ if (filter.operator === "=") {
+ condition[filter.column] = filter.value;
+ }
}
}
- }
+ return condition;
+ }, [filters?.preFilters]);
+
+ // 조건부 테이블용 필터 조건 생성 (선택된 조건값으로 소스 테이블 필터링)
+ const conditionalFilterCondition = useMemo(() => {
+ const filter = { ...baseFilterCondition };
+
+ // 조건부 테이블의 소스 필터 설정이 있고, 모달에서 선택된 조건이 있으면 적용
+ if (conditionalConfig?.sourceFilter?.enabled && modalCondition) {
+ filter[conditionalConfig.sourceFilter.filterColumn] = modalCondition;
+ }
+
+ return filter;
+ }, [baseFilterCondition, conditionalConfig?.sourceFilter, modalCondition]);
// 모달 필터 설정을 ItemSelectionModal에 전달할 형식으로 변환
const modalFiltersForModal = useMemo(() => {
@@ -806,6 +1140,553 @@ export function TableSectionRenderer({
}));
}, [filters?.modalFilters]);
+ // ============================================
+ // 조건부 테이블 관련 핸들러
+ // ============================================
+
+ // 조건부 테이블: 조건 체크박스 토글
+ const handleConditionToggle = useCallback((conditionValue: string, checked: boolean) => {
+ setSelectedConditions((prev) => {
+ if (checked) {
+ const newConditions = [...prev, conditionValue];
+ // 첫 번째 조건 선택 시 해당 탭 활성화
+ if (prev.length === 0) {
+ setActiveConditionTab(conditionValue);
+ }
+ return newConditions;
+ } else {
+ const newConditions = prev.filter((c) => c !== conditionValue);
+ // 현재 활성 탭이 제거된 경우 다른 탭으로 전환
+ if (activeConditionTab === conditionValue && newConditions.length > 0) {
+ setActiveConditionTab(newConditions[0]);
+ }
+ return newConditions;
+ }
+ });
+ }, [activeConditionTab]);
+
+ // 조건부 테이블: 조건별 데이터 변경
+ const handleConditionalDataChange = useCallback((conditionValue: string, newData: any[]) => {
+ setConditionalTableData((prev) => ({
+ ...prev,
+ [conditionValue]: newData,
+ }));
+
+ // 부모에게 조건별 데이터 변경 알림
+ if (onConditionalTableDataChange) {
+ onConditionalTableDataChange(conditionValue, newData);
+ }
+
+ // 전체 데이터를 flat array로 변환하여 onTableDataChange 호출
+ // (저장 시 조건 컬럼 값이 자동으로 추가됨)
+ const conditionColumn = conditionalConfig?.conditionColumn;
+ const allData: any[] = [];
+
+ // 현재 변경된 조건의 데이터 업데이트
+ const updatedConditionalData = { ...conditionalTableData, [conditionValue]: newData };
+
+ for (const [condition, data] of Object.entries(updatedConditionalData)) {
+ for (const row of data) {
+ allData.push({
+ ...row,
+ ...(conditionColumn ? { [conditionColumn]: condition } : {}),
+ });
+ }
+ }
+
+ onTableDataChange(allData);
+ }, [conditionalTableData, conditionalConfig?.conditionColumn, onConditionalTableDataChange, onTableDataChange]);
+
+ // 조건부 테이블: 조건별 행 변경
+ const handleConditionalRowChange = useCallback((conditionValue: string, index: number, newRow: any) => {
+ const calculatedRow = calculateRow(newRow);
+ const currentData = conditionalTableData[conditionValue] || [];
+ const newData = [...currentData];
+ newData[index] = calculatedRow;
+ handleConditionalDataChange(conditionValue, newData);
+ }, [conditionalTableData, calculateRow, handleConditionalDataChange]);
+
+ // 조건부 테이블: 조건별 행 삭제
+ const handleConditionalRowDelete = useCallback((conditionValue: string, index: number) => {
+ const currentData = conditionalTableData[conditionValue] || [];
+ const newData = currentData.filter((_, i) => i !== index);
+ handleConditionalDataChange(conditionValue, newData);
+ }, [conditionalTableData, handleConditionalDataChange]);
+
+ // 조건부 테이블: 조건별 선택 행 일괄 삭제
+ const handleConditionalBulkDelete = useCallback((conditionValue: string) => {
+ const selected = conditionalSelectedRows[conditionValue] || new Set();
+ if (selected.size === 0) return;
+
+ const currentData = conditionalTableData[conditionValue] || [];
+ const newData = currentData.filter((_, index) => !selected.has(index));
+ handleConditionalDataChange(conditionValue, newData);
+
+ // 선택 상태 초기화
+ setConditionalSelectedRows((prev) => ({
+ ...prev,
+ [conditionValue]: new Set(),
+ }));
+ }, [conditionalTableData, conditionalSelectedRows, handleConditionalDataChange]);
+
+ // 조건부 테이블: 아이템 추가 (특정 조건에)
+ const handleConditionalAddItems = useCallback(async (items: any[]) => {
+ if (!modalCondition) return;
+
+ // 기존 handleAddItems 로직을 재사용하여 매핑된 아이템 생성
+ const mappedItems = await Promise.all(
+ items.map(async (sourceItem) => {
+ const newItem: any = {};
+
+ for (const col of tableConfig.columns) {
+ const mapping = col.valueMapping;
+
+ // 소스 필드에서 값 복사 (기본)
+ if (!mapping) {
+ const sourceField = col.sourceField || col.field;
+ if (sourceItem[sourceField] !== undefined) {
+ newItem[col.field] = sourceItem[sourceField];
+ }
+ continue;
+ }
+
+ // valueMapping 처리
+ if (mapping.type === "source" && mapping.sourceField) {
+ const value = sourceItem[mapping.sourceField];
+ if (value !== undefined) {
+ newItem[col.field] = value;
+ }
+ } else if (mapping.type === "manual") {
+ newItem[col.field] = col.defaultValue || "";
+ } else if (mapping.type === "internal" && mapping.internalField) {
+ newItem[col.field] = formData[mapping.internalField];
+ }
+ }
+
+ // 원본 소스 데이터 보존
+ newItem._sourceData = sourceItem;
+
+ return newItem;
+ })
+ );
+
+ // 현재 조건의 데이터에 추가
+ const currentData = conditionalTableData[modalCondition] || [];
+ const newData = [...currentData, ...mappedItems];
+ handleConditionalDataChange(modalCondition, newData);
+
+ setModalOpen(false);
+ }, [modalCondition, tableConfig.columns, formData, conditionalTableData, handleConditionalDataChange]);
+
+ // 조건부 테이블: 모달 열기 (특정 조건에 대해)
+ const openConditionalModal = useCallback((conditionValue: string) => {
+ setModalCondition(conditionValue);
+ setModalOpen(true);
+ }, []);
+
+ // 조건부 테이블: 빈 행 추가 (addRow 모드에서 사용)
+ const addEmptyRowToCondition = useCallback((conditionValue: string) => {
+ const newRow: Record = {};
+
+ // 각 컬럼의 기본값으로 빈 행 생성
+ for (const col of tableConfig.columns) {
+ if (col.defaultValue !== undefined) {
+ newRow[col.field] = col.defaultValue;
+ } else if (col.type === "number") {
+ newRow[col.field] = 0;
+ } else if (col.type === "checkbox") {
+ newRow[col.field] = false;
+ } else {
+ newRow[col.field] = "";
+ }
+ }
+
+ // 조건 컬럼에 현재 조건 값 설정
+ if (conditionalConfig?.conditionColumn) {
+ newRow[conditionalConfig.conditionColumn] = conditionValue;
+ }
+
+ // 현재 조건의 데이터에 추가
+ const currentData = conditionalTableData[conditionValue] || [];
+ const newData = [...currentData, newRow];
+ handleConditionalDataChange(conditionValue, newData);
+ }, [tableConfig.columns, conditionalConfig?.conditionColumn, conditionalTableData, handleConditionalDataChange]);
+
+ // 버튼 클릭 핸들러 (addButtonType에 따라 다르게 동작)
+ const handleAddButtonClick = useCallback((conditionValue: string) => {
+ const addButtonType = tableConfig.uiConfig?.addButtonType || "search";
+
+ if (addButtonType === "addRow") {
+ // 빈 행 직접 추가
+ addEmptyRowToCondition(conditionValue);
+ } else {
+ // 검색 모달 열기
+ openConditionalModal(conditionValue);
+ }
+ }, [tableConfig.uiConfig?.addButtonType, addEmptyRowToCondition, openConditionalModal]);
+
+ // 조건부 테이블: 초기 데이터 로드 (수정 모드)
+ useEffect(() => {
+ if (!isConditionalMode) return;
+ if (initialDataLoadedRef.current) return;
+
+ const tableSectionKey = `_tableSection_${sectionId}`;
+ const initialData = formData[tableSectionKey];
+
+ if (Array.isArray(initialData) && initialData.length > 0) {
+ const conditionColumn = conditionalConfig?.conditionColumn;
+
+ if (conditionColumn) {
+ // 조건별로 데이터 그룹핑
+ const grouped: ConditionalTableData = {};
+ const conditions = new Set();
+
+ for (const row of initialData) {
+ const conditionValue = row[conditionColumn] || "";
+ if (conditionValue) {
+ if (!grouped[conditionValue]) {
+ grouped[conditionValue] = [];
+ }
+ grouped[conditionValue].push(row);
+ conditions.add(conditionValue);
+ }
+ }
+
+ setConditionalTableData(grouped);
+ setSelectedConditions(Array.from(conditions));
+
+ // 첫 번째 조건을 활성 탭으로 설정
+ if (conditions.size > 0) {
+ setActiveConditionTab(Array.from(conditions)[0]);
+ }
+
+ initialDataLoadedRef.current = true;
+ }
+ }
+ }, [isConditionalMode, sectionId, formData, conditionalConfig?.conditionColumn]);
+
+ // 조건부 테이블: 전체 항목 수 계산
+ const totalConditionalItems = useMemo(() => {
+ return Object.values(conditionalTableData).reduce((sum, data) => sum + data.length, 0);
+ }, [conditionalTableData]);
+
+ // ============================================
+ // 조건부 테이블 렌더링
+ // ============================================
+ if (isConditionalMode && conditionalConfig) {
+ const { triggerType } = conditionalConfig;
+
+ // 정적 옵션과 동적 옵션 병합 (동적 옵션이 있으면 우선 사용)
+ const effectiveOptions = conditionalConfig.optionSource?.enabled && dynamicOptions.length > 0
+ ? dynamicOptions
+ : conditionalConfig.options || [];
+
+ // 로딩 중이면 로딩 표시
+ if (dynamicOptionsLoading) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {/* 조건 선택 UI */}
+ {triggerType === "checkbox" && (
+
+
+ {effectiveOptions.map((option) => (
+
+ handleConditionToggle(option.value, !!checked)}
+ />
+ {option.label}
+ {conditionalTableData[option.value]?.length > 0 && (
+
+ {conditionalTableData[option.value].length}
+
+ )}
+
+ ))}
+
+
+ {selectedConditions.length > 0 && (
+
+ {selectedConditions.length}개 유형 선택됨, 총 {totalConditionalItems}개 항목
+
+ )}
+
+ )}
+
+ {triggerType === "dropdown" && (
+
+ 유형 선택:
+ {
+ setSelectedConditions([value]);
+ setActiveConditionTab(value);
+ }}
+ >
+
+
+
+
+ {effectiveOptions.map((option) => (
+
+ {option.label}
+ {conditionalTableData[option.value]?.length > 0 &&
+ ` (${conditionalTableData[option.value].length})`}
+
+ ))}
+
+
+
+ )}
+
+ {/* 선택된 조건들의 테이블 (탭 형태) */}
+ {selectedConditions.length > 0 && (
+
+
+ {selectedConditions.map((conditionValue) => {
+ const option = effectiveOptions.find((o) => o.value === conditionValue);
+ const itemCount = conditionalTableData[conditionValue]?.length || 0;
+ return (
+
+ {option?.label || conditionValue}
+ {itemCount > 0 && (
+
+ {itemCount}
+
+ )}
+
+ );
+ })}
+
+
+ {selectedConditions.map((conditionValue) => {
+ const data = conditionalTableData[conditionValue] || [];
+ const selected = conditionalSelectedRows[conditionValue] || new Set();
+
+ return (
+
+ {/* 테이블 상단 컨트롤 */}
+
+
+
+ {data.length > 0 && `${data.length}개 항목`}
+ {selected.size > 0 && ` (${selected.size}개 선택됨)`}
+
+ {columns.length > 0 && (
+
setWidthTrigger((prev) => prev + 1)}
+ className="h-7 px-2 text-xs"
+ title={widthTrigger % 2 === 0 ? "내용에 맞게 자동 조정" : "균등 분배"}
+ >
+ {widthTrigger % 2 === 0 ? (
+ <>
+
+ 자동 맞춤
+ >
+ ) : (
+ <>
+
+ 균등 분배
+ >
+ )}
+
+ )}
+
+
+ {selected.size > 0 && (
+
handleConditionalBulkDelete(conditionValue)}
+ className="h-8 text-xs"
+ >
+
+ 선택 삭제 ({selected.size})
+
+ )}
+
handleAddButtonClick(conditionValue)}
+ className="h-8 text-xs"
+ >
+ {addButtonType === "addRow" ? (
+
+ ) : (
+
+ )}
+ {addButtonText}
+
+
+
+
+ {/* 테이블 */}
+ handleConditionalDataChange(conditionValue, newData)}
+ onRowChange={(index, newRow) => handleConditionalRowChange(conditionValue, index, newRow)}
+ onRowDelete={(index) => handleConditionalRowDelete(conditionValue, index)}
+ activeDataSources={activeDataSources}
+ onDataSourceChange={handleDataSourceChange}
+ selectedRows={selected}
+ onSelectionChange={(newSelected) => {
+ setConditionalSelectedRows((prev) => ({
+ ...prev,
+ [conditionValue]: newSelected,
+ }));
+ }}
+ equalizeWidthsTrigger={widthTrigger}
+ />
+
+ );
+ })}
+
+ )}
+
+ {/* tabs 모드: 모든 옵션을 탭으로 표시 (선택 UI 없음) */}
+ {triggerType === "tabs" && effectiveOptions.length > 0 && (
+
+
+ {effectiveOptions.map((option) => {
+ const itemCount = conditionalTableData[option.value]?.length || 0;
+ return (
+
+ {option.label}
+ {itemCount > 0 && (
+
+ {itemCount}
+
+ )}
+
+ );
+ })}
+
+
+ {effectiveOptions.map((option) => {
+ const data = conditionalTableData[option.value] || [];
+ const selected = conditionalSelectedRows[option.value] || new Set();
+
+ return (
+
+
+
+
+ {data.length > 0 && `${data.length}개 항목`}
+ {selected.size > 0 && ` (${selected.size}개 선택됨)`}
+
+
+
+ {selected.size > 0 && (
+
handleConditionalBulkDelete(option.value)}
+ className="h-8 text-xs"
+ >
+
+ 선택 삭제 ({selected.size})
+
+ )}
+
handleAddButtonClick(option.value)}
+ className="h-8 text-xs"
+ >
+ {addButtonType === "addRow" ? (
+
+ ) : (
+
+ )}
+ {addButtonText}
+
+
+
+
+ handleConditionalDataChange(option.value, newData)}
+ onRowChange={(index, newRow) => handleConditionalRowChange(option.value, index, newRow)}
+ onRowDelete={(index) => handleConditionalRowDelete(option.value, index)}
+ activeDataSources={activeDataSources}
+ onDataSourceChange={handleDataSourceChange}
+ selectedRows={selected}
+ onSelectionChange={(newSelected) => {
+ setConditionalSelectedRows((prev) => ({
+ ...prev,
+ [option.value]: newSelected,
+ }));
+ }}
+ equalizeWidthsTrigger={widthTrigger}
+ />
+
+ );
+ })}
+
+ )}
+
+ {/* 조건이 선택되지 않은 경우 안내 메시지 (checkbox/dropdown 모드에서만) */}
+ {selectedConditions.length === 0 && triggerType !== "tabs" && (
+
+
+ {triggerType === "checkbox"
+ ? "위에서 유형을 선택하여 검사항목을 추가하세요."
+ : "유형을 선택하세요."}
+
+
+ )}
+
+ {/* 옵션이 없는 경우 안내 메시지 */}
+ {effectiveOptions.length === 0 && (
+
+
+ 조건 옵션이 설정되지 않았습니다.
+
+
+ )}
+
+ {/* 항목 선택 모달 (조건부 테이블용) */}
+
o.value === modalCondition)?.label || modalCondition} - ${modalTitle}`}
+ alreadySelected={conditionalTableData[modalCondition] || []}
+ uniqueField={tableConfig.saveConfig?.uniqueField}
+ onSelect={handleConditionalAddItems}
+ columnLabels={columnLabels}
+ modalFilters={modalFiltersForModal}
+ />
+
+ );
+ }
+
+ // ============================================
+ // 일반 테이블 렌더링 (기존 로직)
+ // ============================================
return (
{/* 추가 버튼 영역 */}
@@ -848,10 +1729,34 @@ export function TableSectionRenderer({
)}
setModalOpen(true)}
+ onClick={() => {
+ if (addButtonType === "addRow") {
+ // 빈 행 추가
+ const newRow: Record = {};
+ for (const col of columns) {
+ if (col.defaultValue !== undefined) {
+ newRow[col.field] = col.defaultValue;
+ } else if (col.type === "number") {
+ newRow[col.field] = 0;
+ } else if (col.type === "checkbox") {
+ newRow[col.field] = false;
+ } else {
+ newRow[col.field] = "";
+ }
+ }
+ handleDataChange([...tableData, newRow]);
+ } else {
+ // 검색 모달 열기
+ setModalOpen(true);
+ }
+ }}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
-
+ {addButtonType === "addRow" ? (
+
+ ) : (
+
+ )}
{addButtonText}
diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx
index 64c2f826..16778725 100644
--- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx
@@ -212,15 +212,23 @@ export function UniversalFormModalComponent({
// 마지막으로 초기화된 데이터의 ID를 추적 (수정 모달에서 다른 항목 선택 시 재초기화 필요)
const lastInitializedId = useRef(undefined);
- // 초기화 - 최초 마운트 시 또는 initialData의 ID가 변경되었을 때 실행
+ // 초기화 - 최초 마운트 시 또는 initialData가 변경되었을 때 실행
useEffect(() => {
// initialData에서 ID 값 추출 (id, ID, objid 등)
const currentId = initialData?.id || initialData?.ID || initialData?.objid;
const currentIdString = currentId !== undefined ? String(currentId) : undefined;
+
+ // 생성 모드에서 부모로부터 전달받은 데이터 해시 (ID가 없을 때만)
+ const createModeDataHash = !currentIdString && initialData && Object.keys(initialData).length > 0
+ ? JSON.stringify(initialData)
+ : undefined;
- // 이미 초기화되었고, ID가 동일하면 스킵
+ // 이미 초기화되었고, ID가 동일하고, 생성 모드 데이터도 동일하면 스킵
if (hasInitialized.current && lastInitializedId.current === currentIdString) {
- return;
+ // 생성 모드에서 데이터가 새로 전달된 경우는 재초기화 필요
+ if (!createModeDataHash || capturedInitialData.current) {
+ return;
+ }
}
// 🆕 수정 모드: initialData에 데이터가 있으면서 ID가 변경된 경우 재초기화
@@ -245,7 +253,7 @@ export function UniversalFormModalComponent({
hasInitialized.current = true;
initializeForm();
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [initialData?.id, initialData?.ID, initialData?.objid]); // ID 값 변경 시 재초기화
+ }, [initialData]); // initialData 전체 변경 시 재초기화
// config 변경 시에만 재초기화 (initialData 변경은 무시) - 채번규칙 제외
useEffect(() => {
@@ -478,6 +486,82 @@ export function UniversalFormModalComponent({
setActivatedOptionalFieldGroups(newActivatedGroups);
setOriginalData(effectiveInitialData || {});
+ // 수정 모드에서 서브 테이블 데이터 로드 (겸직 등)
+ const multiTable = config.saveConfig?.customApiSave?.multiTable;
+ if (multiTable && effectiveInitialData) {
+ const pkColumn = multiTable.mainTable?.primaryKeyColumn;
+ const pkValue = effectiveInitialData[pkColumn];
+
+ // PK 값이 있으면 수정 모드로 판단
+ if (pkValue) {
+ console.log("[initializeForm] 수정 모드 - 서브 테이블 데이터 로드 시작");
+
+ for (const subTableConfig of multiTable.subTables || []) {
+ // loadOnEdit 옵션이 활성화된 경우에만 로드
+ if (!subTableConfig.enabled || !subTableConfig.options?.loadOnEdit) {
+ continue;
+ }
+
+ const { tableName, linkColumn, repeatSectionId, fieldMappings, options } = subTableConfig;
+ if (!tableName || !linkColumn?.subColumn || !repeatSectionId) {
+ continue;
+ }
+
+ try {
+ // 서브 테이블에서 데이터 조회
+ const filters: Record = {
+ [linkColumn.subColumn]: pkValue,
+ };
+
+ // 서브 항목만 로드 (메인 항목 제외)
+ if (options?.loadOnlySubItems && options?.mainMarkerColumn) {
+ filters[options.mainMarkerColumn] = options.subMarkerValue ?? false;
+ }
+
+ console.log(`[initializeForm] 서브 테이블 ${tableName} 조회:`, filters);
+
+ const response = await apiClient.get(`/table-management/tables/${tableName}/data`, {
+ params: {
+ filters: JSON.stringify(filters),
+ page: 1,
+ pageSize: 100,
+ },
+ });
+
+ if (response.data?.success && response.data?.data?.items) {
+ const subItems = response.data.data.items;
+ console.log(`[initializeForm] 서브 테이블 ${tableName} 데이터 ${subItems.length}건 로드됨`);
+
+ // 역매핑: 서브 테이블 데이터 → 반복 섹션 데이터
+ const repeatItems: RepeatSectionItem[] = subItems.map((item: any, index: number) => {
+ const repeatItem: RepeatSectionItem = {
+ _id: generateUniqueId("repeat"),
+ _index: index,
+ _originalData: item, // 원본 데이터 보관 (수정 시 필요)
+ };
+
+ // 필드 매핑 역변환 (targetColumn → formField)
+ for (const mapping of fieldMappings || []) {
+ if (mapping.formField && mapping.targetColumn) {
+ repeatItem[mapping.formField] = item[mapping.targetColumn];
+ }
+ }
+
+ return repeatItem;
+ });
+
+ // 반복 섹션에 데이터 설정
+ newRepeatSections[repeatSectionId] = repeatItems;
+ setRepeatSections({ ...newRepeatSections });
+ console.log(`[initializeForm] 반복 섹션 ${repeatSectionId}에 ${repeatItems.length}건 설정`);
+ }
+ } catch (error) {
+ console.error(`[initializeForm] 서브 테이블 ${tableName} 로드 실패:`, error);
+ }
+ }
+ }
+ }
+
// 채번규칙 자동 생성
console.log("[initializeForm] generateNumberingValues 호출");
await generateNumberingValues(newFormData);
@@ -1142,6 +1226,20 @@ export function UniversalFormModalComponent({
}
});
});
+
+ // 1-0. receiveFromParent 필드 값도 mainData에 추가 (서브 테이블 저장용)
+ // 이 필드들은 메인 테이블에는 저장되지 않지만, 서브 테이블 저장 시 필요할 수 있음
+ config.sections.forEach((section) => {
+ if (section.repeatable || section.type === "table") return;
+ (section.fields || []).forEach((field) => {
+ if (field.receiveFromParent && !mainData[field.columnName]) {
+ const value = formData[field.columnName];
+ if (value !== undefined && value !== null && value !== "") {
+ mainData[field.columnName] = value;
+ }
+ }
+ });
+ });
// 1-1. 채번규칙 처리 (저장 시점에 실제 순번 할당)
for (const section of config.sections) {
@@ -1185,36 +1283,42 @@ export function UniversalFormModalComponent({
}> = [];
for (const subTableConfig of multiTable.subTables || []) {
- if (!subTableConfig.enabled || !subTableConfig.tableName || !subTableConfig.repeatSectionId) {
+ // 서브 테이블이 활성화되어 있고 테이블명이 있어야 함
+ // repeatSectionId는 선택사항 (saveMainAsFirst만 사용하는 경우 없을 수 있음)
+ if (!subTableConfig.enabled || !subTableConfig.tableName) {
continue;
}
const subItems: Record[] = [];
- const repeatData = repeatSections[subTableConfig.repeatSectionId] || [];
+
+ // 반복 섹션이 있는 경우에만 반복 데이터 처리
+ if (subTableConfig.repeatSectionId) {
+ const repeatData = repeatSections[subTableConfig.repeatSectionId] || [];
- // 반복 섹션 데이터를 필드 매핑에 따라 변환
- for (const item of repeatData) {
- const mappedItem: Record = {};
+ // 반복 섹션 데이터를 필드 매핑에 따라 변환
+ for (const item of repeatData) {
+ const mappedItem: Record = {};
- // 연결 컬럼 값 설정
- if (subTableConfig.linkColumn?.mainField && subTableConfig.linkColumn?.subColumn) {
- mappedItem[subTableConfig.linkColumn.subColumn] = mainData[subTableConfig.linkColumn.mainField];
- }
-
- // 필드 매핑에 따라 데이터 변환
- for (const mapping of subTableConfig.fieldMappings || []) {
- if (mapping.formField && mapping.targetColumn) {
- mappedItem[mapping.targetColumn] = item[mapping.formField];
+ // 연결 컬럼 값 설정
+ if (subTableConfig.linkColumn?.mainField && subTableConfig.linkColumn?.subColumn) {
+ mappedItem[subTableConfig.linkColumn.subColumn] = mainData[subTableConfig.linkColumn.mainField];
}
- }
- // 메인/서브 구분 컬럼 설정 (서브 데이터는 서브 마커 값)
- if (subTableConfig.options?.mainMarkerColumn) {
- mappedItem[subTableConfig.options.mainMarkerColumn] = subTableConfig.options?.subMarkerValue ?? false;
- }
+ // 필드 매핑에 따라 데이터 변환
+ for (const mapping of subTableConfig.fieldMappings || []) {
+ if (mapping.formField && mapping.targetColumn) {
+ mappedItem[mapping.targetColumn] = item[mapping.formField];
+ }
+ }
- if (Object.keys(mappedItem).length > 0) {
- subItems.push(mappedItem);
+ // 메인/서브 구분 컬럼 설정 (서브 데이터는 서브 마커 값)
+ if (subTableConfig.options?.mainMarkerColumn) {
+ mappedItem[subTableConfig.options.mainMarkerColumn] = subTableConfig.options?.subMarkerValue ?? false;
+ }
+
+ if (Object.keys(mappedItem).length > 0) {
+ subItems.push(mappedItem);
+ }
}
}
@@ -1226,8 +1330,9 @@ export function UniversalFormModalComponent({
// fieldMappings에 정의된 targetColumn만 매핑 (서브 테이블에 존재하는 컬럼만)
for (const mapping of subTableConfig.fieldMappings || []) {
if (mapping.targetColumn) {
- // 메인 데이터에서 동일한 컬럼명이 있으면 매핑
- if (mainData[mapping.targetColumn] !== undefined && mainData[mapping.targetColumn] !== null && mainData[mapping.targetColumn] !== "") {
+ // formData에서 동일한 컬럼명이 있으면 매핑 (receiveFromParent 필드 포함)
+ const formValue = formData[mapping.targetColumn];
+ if (formValue !== undefined && formValue !== null && formValue !== "") {
mainFieldMappings.push({
formField: mapping.targetColumn,
targetColumn: mapping.targetColumn,
@@ -1238,11 +1343,14 @@ export function UniversalFormModalComponent({
config.sections.forEach((section) => {
if (section.repeatable || section.type === "table") return;
const matchingField = (section.fields || []).find((f) => f.columnName === mapping.targetColumn);
- if (matchingField && mainData[matchingField.columnName] !== undefined && mainData[matchingField.columnName] !== null && mainData[matchingField.columnName] !== "") {
- mainFieldMappings!.push({
- formField: matchingField.columnName,
- targetColumn: mapping.targetColumn,
- });
+ if (matchingField) {
+ const fieldValue = formData[matchingField.columnName];
+ if (fieldValue !== undefined && fieldValue !== null && fieldValue !== "") {
+ mainFieldMappings!.push({
+ formField: matchingField.columnName,
+ targetColumn: mapping.targetColumn,
+ });
+ }
}
});
}
@@ -1255,15 +1363,18 @@ export function UniversalFormModalComponent({
);
}
- subTablesData.push({
- tableName: subTableConfig.tableName,
- linkColumn: subTableConfig.linkColumn,
- items: subItems,
- options: {
- ...subTableConfig.options,
- mainFieldMappings, // 메인 데이터 매핑 추가
- },
- });
+ // 서브 테이블 데이터 추가 (반복 데이터가 없어도 saveMainAsFirst가 있으면 추가)
+ if (subItems.length > 0 || subTableConfig.options?.saveMainAsFirst) {
+ subTablesData.push({
+ tableName: subTableConfig.tableName,
+ linkColumn: subTableConfig.linkColumn,
+ items: subItems,
+ options: {
+ ...subTableConfig.options,
+ mainFieldMappings, // 메인 데이터 매핑 추가
+ },
+ });
+ }
}
// 3. 범용 다중 테이블 저장 API 호출
@@ -1489,13 +1600,20 @@ export function UniversalFormModalComponent({
// 표시 텍스트 생성 함수
const getDisplayText = (row: Record): string => {
- const displayVal = row[lfg.displayColumn || ""] || "";
- const valueVal = row[valueColumn] || "";
+ // 메인 표시 컬럼 (displayColumn)
+ const mainDisplayVal = row[lfg.displayColumn || ""] || "";
+ // 서브 표시 컬럼 (subDisplayColumn이 있으면 사용, 없으면 valueColumn 사용)
+ const subDisplayVal = lfg.subDisplayColumn
+ ? (row[lfg.subDisplayColumn] || "")
+ : (row[valueColumn] || "");
+
switch (lfg.displayFormat) {
case "code_name":
- return `${valueVal} - ${displayVal}`;
+ // 서브 - 메인 형식
+ return `${subDisplayVal} - ${mainDisplayVal}`;
case "name_code":
- return `${displayVal} (${valueVal})`;
+ // 메인 (서브) 형식
+ return `${mainDisplayVal} (${subDisplayVal})`;
case "custom":
// 커스텀 형식: {컬럼명}을 실제 값으로 치환
if (lfg.customDisplayFormat) {
@@ -1511,10 +1629,10 @@ export function UniversalFormModalComponent({
}
return result;
}
- return String(displayVal);
+ return String(mainDisplayVal);
case "name_only":
default:
- return String(displayVal);
+ return String(mainDisplayVal);
}
};
diff --git a/frontend/lib/registry/components/universal-form-modal/config.ts b/frontend/lib/registry/components/universal-form-modal/config.ts
index e8b239f6..41c18043 100644
--- a/frontend/lib/registry/components/universal-form-modal/config.ts
+++ b/frontend/lib/registry/components/universal-form-modal/config.ts
@@ -11,6 +11,8 @@ import {
TablePreFilter,
TableModalFilter,
TableCalculationRule,
+ ConditionalTableConfig,
+ ConditionalTableOption,
} from "./types";
// 기본 설정값
@@ -133,6 +135,33 @@ export const defaultTableSectionConfig: TableSectionConfig = {
multiSelect: true,
maxHeight: "400px",
},
+ conditionalTable: undefined,
+};
+
+// 기본 조건부 테이블 설정
+export const defaultConditionalTableConfig: ConditionalTableConfig = {
+ enabled: false,
+ triggerType: "checkbox",
+ conditionColumn: "",
+ options: [],
+ optionSource: {
+ enabled: false,
+ tableName: "",
+ valueColumn: "",
+ labelColumn: "",
+ filterCondition: "",
+ },
+ sourceFilter: {
+ enabled: false,
+ filterColumn: "",
+ },
+};
+
+// 기본 조건부 테이블 옵션 설정
+export const defaultConditionalTableOptionConfig: ConditionalTableOption = {
+ id: "",
+ value: "",
+ label: "",
};
// 기본 테이블 컬럼 설정
@@ -300,3 +329,8 @@ export const generateColumnModeId = (): string => {
export const generateFilterId = (): string => {
return generateUniqueId("filter");
};
+
+// 유틸리티: 조건부 테이블 옵션 ID 생성
+export const generateConditionalOptionId = (): string => {
+ return generateUniqueId("cond");
+};
diff --git a/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx
index 2404cc4c..8882d9bc 100644
--- a/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx
@@ -98,6 +98,9 @@ export function FieldDetailSettingsModal({
// Combobox 열림 상태
const [sourceTableOpen, setSourceTableOpen] = useState(false);
const [targetColumnOpenMap, setTargetColumnOpenMap] = useState>({});
+ const [displayColumnOpen, setDisplayColumnOpen] = useState(false);
+ const [subDisplayColumnOpen, setSubDisplayColumnOpen] = useState(false); // 서브 표시 컬럼 Popover 상태
+ const [sourceColumnOpenMap, setSourceColumnOpenMap] = useState>({});
// open이 변경될 때마다 필드 데이터 동기화
useEffect(() => {
@@ -105,6 +108,16 @@ export function FieldDetailSettingsModal({
setLocalField(field);
}
}, [open, field]);
+
+ // 모달이 열릴 때 소스 테이블 컬럼 자동 로드
+ useEffect(() => {
+ if (open && field.linkedFieldGroup?.sourceTable) {
+ // tableColumns에 해당 테이블 컬럼이 없으면 로드
+ if (!tableColumns[field.linkedFieldGroup.sourceTable] || tableColumns[field.linkedFieldGroup.sourceTable].length === 0) {
+ onLoadTableColumns(field.linkedFieldGroup.sourceTable);
+ }
+ }
+ }, [open, field.linkedFieldGroup?.sourceTable, tableColumns, onLoadTableColumns]);
// 모든 카테고리 컬럼 목록 로드 (모달 열릴 때)
useEffect(() => {
@@ -735,32 +748,108 @@ export function FieldDetailSettingsModal({
값을 가져올 소스 테이블 (예: customer_mng)
+ {/* 표시 형식 선택 */}
-
표시 컬럼
+
표시 형식
+
+ updateField({
+ linkedFieldGroup: {
+ ...localField.linkedFieldGroup,
+ displayFormat: value as "name_only" | "code_name" | "name_code",
+ // name_only 선택 시 서브 컬럼 초기화
+ ...(value === "name_only" ? { subDisplayColumn: undefined } : {}),
+ },
+ })
+ }
+ >
+
+
+
+
+ {LINKED_FIELD_DISPLAY_FORMAT_OPTIONS.map((opt) => (
+
+
+ {opt.label}
+
+ {opt.value === "name_only" && "메인 컬럼만 표시"}
+ {opt.value === "code_name" && "서브 - 메인 형식"}
+ {opt.value === "name_code" && "메인 (서브) 형식"}
+
+
+
+ ))}
+
+
+
드롭다운에 표시할 형식을 선택합니다
+
+
+ {/* 메인 표시 컬럼 */}
+
+
메인 표시 컬럼
{sourceTableColumns.length > 0 ? (
-
- updateField({
- linkedFieldGroup: {
- ...localField.linkedFieldGroup,
- displayColumn: value,
- },
- })
- }
- >
-
-
-
-
- {sourceTableColumns.map((col) => (
-
- {col.name}
- {col.label !== col.name && ` (${col.label})`}
-
- ))}
-
-
+
+
+
+ {localField.linkedFieldGroup?.displayColumn
+ ? (() => {
+ const selectedCol = sourceTableColumns.find(
+ (c) => c.name === localField.linkedFieldGroup?.displayColumn
+ );
+ return selectedCol
+ ? `${selectedCol.name} (${selectedCol.label})`
+ : localField.linkedFieldGroup?.displayColumn;
+ })()
+ : "컬럼 선택..."}
+
+
+
+
+
+
+
+
+ 컬럼을 찾을 수 없습니다.
+
+
+ {sourceTableColumns.map((col) => (
+ {
+ updateField({
+ linkedFieldGroup: {
+ ...localField.linkedFieldGroup,
+ displayColumn: col.name,
+ },
+ });
+ setDisplayColumnOpen(false);
+ }}
+ className="text-xs"
+ >
+
+ {col.name}
+ ({col.label})
+
+ ))}
+
+
+
+
+
) : (
)}
-
드롭다운에 표시할 컬럼 (예: customer_name)
+
드롭다운에 표시할 메인 컬럼 (예: item_name)
-
- 표시 형식
-
- updateField({
- linkedFieldGroup: {
- ...localField.linkedFieldGroup,
- displayFormat: value as "name_only" | "code_name" | "name_code",
- },
- })
- }
- >
-
-
-
-
- {LINKED_FIELD_DISPLAY_FORMAT_OPTIONS.map((opt) => (
-
- {opt.label}
-
- ))}
-
-
- 드롭다운에 표시될 형식을 선택하세요
-
+ {/* 서브 표시 컬럼 - 표시 형식이 name_only가 아닌 경우에만 표시 */}
+ {localField.linkedFieldGroup?.displayFormat &&
+ localField.linkedFieldGroup.displayFormat !== "name_only" && (
+
+
서브 표시 컬럼
+ {sourceTableColumns.length > 0 ? (
+
+
+
+ {localField.linkedFieldGroup?.subDisplayColumn
+ ? (() => {
+ const selectedCol = sourceTableColumns.find(
+ (c) => c.name === localField.linkedFieldGroup?.subDisplayColumn
+ );
+ return selectedCol
+ ? `${selectedCol.name} (${selectedCol.label})`
+ : localField.linkedFieldGroup?.subDisplayColumn;
+ })()
+ : "컬럼 선택..."}
+
+
+
+
+
+
+
+
+ 컬럼을 찾을 수 없습니다.
+
+
+ {sourceTableColumns.map((col) => (
+ {
+ updateField({
+ linkedFieldGroup: {
+ ...localField.linkedFieldGroup,
+ subDisplayColumn: col.name,
+ },
+ });
+ setSubDisplayColumnOpen(false);
+ }}
+ className="text-xs"
+ >
+
+ {col.name}
+ ({col.label})
+
+ ))}
+
+
+
+
+
+ ) : (
+
+ updateField({
+ linkedFieldGroup: {
+ ...localField.linkedFieldGroup,
+ subDisplayColumn: e.target.value,
+ },
+ })
+ }
+ placeholder="item_code"
+ className="h-7 text-xs mt-1"
+ />
+ )}
+
+ {localField.linkedFieldGroup?.displayFormat === "code_name"
+ ? "메인 앞에 표시될 서브 컬럼 (예: 서브 - 메인)"
+ : "메인 뒤에 표시될 서브 컬럼 (예: 메인 (서브))"}
+
+
+ )}
+
+ {/* 미리보기 - 메인 컬럼이 선택된 경우에만 표시 */}
+ {localField.linkedFieldGroup?.displayColumn && (
+
+
미리보기:
+ {(() => {
+ const mainCol = localField.linkedFieldGroup?.displayColumn || "";
+ const subCol = localField.linkedFieldGroup?.subDisplayColumn || "";
+ const mainLabel = sourceTableColumns.find(c => c.name === mainCol)?.label || mainCol;
+ const subLabel = sourceTableColumns.find(c => c.name === subCol)?.label || subCol;
+ const format = localField.linkedFieldGroup?.displayFormat || "name_only";
+
+ let preview = "";
+ if (format === "name_only") {
+ preview = mainLabel;
+ } else if (format === "code_name" && subCol) {
+ preview = `${subLabel} - ${mainLabel}`;
+ } else if (format === "name_code" && subCol) {
+ preview = `${mainLabel} (${subLabel})`;
+ } else if (format !== "name_only" && !subCol) {
+ preview = `${mainLabel} (서브 컬럼을 선택하세요)`;
+ } else {
+ preview = mainLabel;
+ }
+
+ return (
+
{preview}
+ );
+ })()}
+
+ )}
@@ -846,24 +1029,67 @@ export function FieldDetailSettingsModal({
소스 컬럼 (가져올 값)
{sourceTableColumns.length > 0 ? (
-
- updateLinkedFieldMapping(index, { sourceColumn: value })
+
+ setSourceColumnOpenMap((prev) => ({ ...prev, [index]: open }))
}
>
-
-
-
-
- {sourceTableColumns.map((col) => (
-
- {col.name}
- {col.label !== col.name && ` (${col.label})`}
-
- ))}
-
-
+
+
+ {mapping.sourceColumn
+ ? (() => {
+ const selectedCol = sourceTableColumns.find(
+ (c) => c.name === mapping.sourceColumn
+ );
+ return selectedCol
+ ? `${selectedCol.name} (${selectedCol.label})`
+ : mapping.sourceColumn;
+ })()
+ : "컬럼 선택..."}
+
+
+
+
+
+
+
+
+ 컬럼을 찾을 수 없습니다.
+
+
+ {sourceTableColumns.map((col) => (
+ {
+ updateLinkedFieldMapping(index, { sourceColumn: col.name });
+ setSourceColumnOpenMap((prev) => ({ ...prev, [index]: false }));
+ }}
+ className="text-[9px]"
+ >
+
+ {col.name}
+ ({col.label})
+
+ ))}
+
+
+
+
+
) : (
>({});
+ // 컬럼 검색 Popover 상태
+ const [mainKeyColumnSearchOpen, setMainKeyColumnSearchOpen] = useState(false);
+ const [mainFieldSearchOpen, setMainFieldSearchOpen] = useState
>({});
+ const [subColumnSearchOpen, setSubColumnSearchOpen] = useState>({});
+ const [subTableColumnSearchOpen, setSubTableColumnSearchOpen] = useState>({});
+ const [markerColumnSearchOpen, setMarkerColumnSearchOpen] = useState>({});
+
// open이 변경될 때마다 데이터 동기화
useEffect(() => {
if (open) {
setLocalSaveConfig(saveConfig);
setSaveMode(saveConfig.customApiSave?.enabled && saveConfig.customApiSave?.multiTable?.enabled ? "multi" : "single");
+
+ // 모달이 열릴 때 기존에 설정된 테이블들의 컬럼 정보 로드
+ const mainTableName = saveConfig.customApiSave?.multiTable?.mainTable?.tableName;
+ if (mainTableName && !tableColumns[mainTableName]) {
+ onLoadTableColumns(mainTableName);
+ }
+
+ // 서브 테이블들의 컬럼 정보도 로드
+ const subTables = saveConfig.customApiSave?.multiTable?.subTables || [];
+ subTables.forEach((subTable) => {
+ if (subTable.tableName && !tableColumns[subTable.tableName]) {
+ onLoadTableColumns(subTable.tableName);
+ }
+ });
}
- }, [open, saveConfig]);
+ }, [open, saveConfig, tableColumns, onLoadTableColumns]);
// 저장 설정 업데이트 함수
const updateSaveConfig = (updates: Partial) => {
@@ -558,35 +579,76 @@ export function SaveSettingsModal({
메인 테이블 키 컬럼
{mainTableColumns.length > 0 ? (
-
- updateSaveConfig({
- customApiSave: {
- ...localSaveConfig.customApiSave,
- multiTable: {
- ...localSaveConfig.customApiSave?.multiTable,
- mainTable: {
- ...localSaveConfig.customApiSave?.multiTable?.mainTable,
- primaryKeyColumn: value,
- },
- },
- },
- })
- }
- >
-
-
-
-
- {mainTableColumns.map((col) => (
-
- {col.name}
- {col.label !== col.name && ` (${col.label})`}
-
- ))}
-
-
+
+
+
+ {localSaveConfig.customApiSave?.multiTable?.mainTable?.primaryKeyColumn ? (
+ <>
+ {localSaveConfig.customApiSave.multiTable.mainTable.primaryKeyColumn}
+ {(() => {
+ const col = mainTableColumns.find(c => c.name === localSaveConfig.customApiSave?.multiTable?.mainTable?.primaryKeyColumn);
+ return col?.label && col.label !== col.name ? ` (${col.label})` : "";
+ })()}
+ >
+ ) : (
+ 컬럼 선택
+ )}
+
+
+
+
+
+
+
+
+ 컬럼을 찾을 수 없습니다.
+
+
+ {mainTableColumns.map((col) => (
+ {
+ updateSaveConfig({
+ customApiSave: {
+ ...localSaveConfig.customApiSave,
+ multiTable: {
+ ...localSaveConfig.customApiSave?.multiTable,
+ mainTable: {
+ ...localSaveConfig.customApiSave?.multiTable?.mainTable,
+ primaryKeyColumn: col.name,
+ },
+ },
+ },
+ });
+ setMainKeyColumnSearchOpen(false);
+ }}
+ className="text-xs"
+ >
+
+
+ {col.name}
+ {col.label && col.label !== col.name && (
+ {col.label}
+ )}
+
+
+ ))}
+
+
+
+
+
) : (
메인 필드
{mainTableColumns.length > 0 ? (
-
- updateSubTable(subIndex, {
- linkColumn: { ...subTable.linkColumn, mainField: value },
- })
- }
+ setMainFieldSearchOpen(prev => ({ ...prev, [subIndex]: open }))}
>
-
-
-
-
- {mainTableColumns.map((col) => (
-
- {col.name}
-
- ))}
-
-
+
+
+ {subTable.linkColumn?.mainField ? (
+ <>
+ {subTable.linkColumn.mainField}
+ {(() => {
+ const col = mainTableColumns.find(c => c.name === subTable.linkColumn?.mainField);
+ return col?.label && col.label !== col.name ? ` (${col.label})` : "";
+ })()}
+ >
+ ) : (
+ 필드 선택
+ )}
+
+
+
+
+
+
+
+
+ 컬럼을 찾을 수 없습니다.
+
+
+ {mainTableColumns.map((col) => (
+ {
+ updateSubTable(subIndex, {
+ linkColumn: { ...subTable.linkColumn, mainField: col.name },
+ });
+ setMainFieldSearchOpen(prev => ({ ...prev, [subIndex]: false }));
+ }}
+ className="text-[10px]"
+ >
+
+
+ {col.name}
+ {col.label && col.label !== col.name && (
+ {col.label}
+ )}
+
+
+ ))}
+
+
+
+
+
) : (
서브 컬럼
{subTableColumns.length > 0 ? (
-
- updateSubTable(subIndex, {
- linkColumn: { ...subTable.linkColumn, subColumn: value },
- })
- }
+ setSubColumnSearchOpen(prev => ({ ...prev, [subIndex]: open }))}
>
-
-
-
-
- {subTableColumns.map((col) => (
-
- {col.name}
-
- ))}
-
-
+
+
+ {subTable.linkColumn?.subColumn ? (
+ <>
+ {subTable.linkColumn.subColumn}
+ {(() => {
+ const col = subTableColumns.find(c => c.name === subTable.linkColumn?.subColumn);
+ return col?.label && col.label !== col.name ? ` (${col.label})` : "";
+ })()}
+ >
+ ) : (
+ 컬럼 선택
+ )}
+
+
+
+
+
+
+
+
+ 컬럼을 찾을 수 없습니다.
+
+
+ {subTableColumns.map((col) => (
+ {
+ updateSubTable(subIndex, {
+ linkColumn: { ...subTable.linkColumn, subColumn: col.name },
+ });
+ setSubColumnSearchOpen(prev => ({ ...prev, [subIndex]: false }));
+ }}
+ className="text-[10px]"
+ >
+
+
+ {col.name}
+ {col.label && col.label !== col.name && (
+ {col.label}
+ )}
+
+
+ ))}
+
+
+
+
+
) : (
서브 테이블 컬럼
{subTableColumns.length > 0 ? (
-
- updateFieldMapping(subIndex, mapIndex, { targetColumn: value })
- }
+ setSubTableColumnSearchOpen(prev => ({ ...prev, [`${subIndex}-${mapIndex}`]: open }))}
>
-
-
-
-
- {subTableColumns.map((col) => (
-
- {col.name}
-
- ))}
-
-
+
+
+ {mapping.targetColumn ? (
+ <>
+ {mapping.targetColumn}
+ {(() => {
+ const col = subTableColumns.find(c => c.name === mapping.targetColumn);
+ return col?.label && col.label !== col.name ? ` (${col.label})` : "";
+ })()}
+ >
+ ) : (
+ 컬럼 선택
+ )}
+
+
+
+
+
+
+
+
+ 컬럼을 찾을 수 없습니다.
+
+
+ {subTableColumns.map((col) => (
+ {
+ updateFieldMapping(subIndex, mapIndex, { targetColumn: col.name });
+ setSubTableColumnSearchOpen(prev => ({ ...prev, [`${subIndex}-${mapIndex}`]: false }));
+ }}
+ className="text-[10px]"
+ >
+
+
+ {col.name}
+ {col.label && col.label !== col.name && (
+ {col.label}
+ )}
+
+
+ ))}
+
+
+
+
+
) : (
)}
+
+
+
+ {/* 대표 데이터 구분 저장 옵션 */}
+
+ {!subTable.options?.saveMainAsFirst ? (
+ // 비활성화 상태: 추가 버튼 표시
+
+
+
+
대표/일반 구분 저장
+
+ 저장되는 데이터를 대표와 일반으로 구분합니다
+
+
+
updateSubTable(subIndex, {
+ options: {
+ ...subTable.options,
+ saveMainAsFirst: true,
+ mainMarkerColumn: "",
+ mainMarkerValue: true,
+ subMarkerValue: false,
+ }
+ })}
+ className="h-6 text-[9px] px-2 shrink-0"
+ >
+
+ 추가
+
+
+
+ ) : (
+ // 활성화 상태: 설정 필드 표시
+
+
+
+
대표/일반 구분 저장
+
+ 저장되는 데이터를 대표와 일반으로 구분합니다
+
+
+
updateSubTable(subIndex, {
+ options: {
+ ...subTable.options,
+ saveMainAsFirst: false,
+ mainMarkerColumn: undefined,
+ mainMarkerValue: undefined,
+ subMarkerValue: undefined,
+ }
+ })}
+ className="h-6 text-[9px] px-2 text-destructive hover:text-destructive"
+ >
+
+ 제거
+
+
+
+
+
+
구분 컬럼
+ {subTableColumns.length > 0 ? (
+
setMarkerColumnSearchOpen(prev => ({ ...prev, [subIndex]: open }))}
+ >
+
+
+ {subTable.options?.mainMarkerColumn ? (
+ <>
+ {subTable.options.mainMarkerColumn}
+ {(() => {
+ const col = subTableColumns.find(c => c.name === subTable.options?.mainMarkerColumn);
+ return col?.label && col.label !== col.name ? ` (${col.label})` : "";
+ })()}
+ >
+ ) : (
+ 컬럼 선택
+ )}
+
+
+
+
+
+
+
+
+ 컬럼을 찾을 수 없습니다.
+
+
+ {subTableColumns.map((col) => (
+ {
+ updateSubTable(subIndex, {
+ options: {
+ ...subTable.options,
+ mainMarkerColumn: col.name,
+ }
+ });
+ setMarkerColumnSearchOpen(prev => ({ ...prev, [subIndex]: false }));
+ }}
+ className="text-[10px]"
+ >
+
+
+ {col.name}
+ {col.label && col.label !== col.name && (
+ {col.label}
+ )}
+
+
+ ))}
+
+
+
+
+
+ ) : (
+
updateSubTable(subIndex, {
+ options: {
+ ...subTable.options,
+ mainMarkerColumn: e.target.value,
+ }
+ })}
+ placeholder="is_primary"
+ className="h-6 text-[9px] mt-0.5"
+ />
+ )}
+
대표/일반을 구분하는 컬럼
+
+
+
+
+ 함께 저장 (대표)
+ {
+ const val = e.target.value;
+ // true/false 문자열은 boolean으로 변환
+ let parsedValue: any = val;
+ if (val === "true") parsedValue = true;
+ else if (val === "false") parsedValue = false;
+ else if (!isNaN(Number(val)) && val !== "") parsedValue = Number(val);
+
+ updateSubTable(subIndex, {
+ options: {
+ ...subTable.options,
+ mainMarkerValue: parsedValue,
+ }
+ });
+ }}
+ placeholder="true, Y, 1 등"
+ className="h-6 text-[9px] mt-0.5"
+ />
+ 기본 정보와 함께 저장될 때 값
+
+
+ 액션 활성화시 저장 (일반)
+ {
+ const val = e.target.value;
+ let parsedValue: any = val;
+ if (val === "true") parsedValue = true;
+ else if (val === "false") parsedValue = false;
+ else if (!isNaN(Number(val)) && val !== "") parsedValue = Number(val);
+
+ updateSubTable(subIndex, {
+ options: {
+ ...subTable.options,
+ subMarkerValue: parsedValue,
+ }
+ });
+ }}
+ placeholder="false, N, 0 등"
+ className="h-6 text-[9px] mt-0.5"
+ />
+ 겸직 추가 시 저장될 때 값
+
+
+
+
+ )}
+
+
+
+
+ {/* 수정 시 데이터 로드 옵션 */}
+
+ {!subTable.options?.loadOnEdit ? (
+ // 비활성화 상태: 추가 버튼 표시
+
+
+
+
수정 시 데이터 로드
+
+ 수정 모드에서 서브 테이블 데이터를 불러옵니다
+
+
+
updateSubTable(subIndex, {
+ options: {
+ ...subTable.options,
+ loadOnEdit: true,
+ loadOnlySubItems: true,
+ }
+ })}
+ className="h-6 text-[9px] px-2 shrink-0"
+ >
+
+ 추가
+
+
+
+ ) : (
+ // 활성화 상태: 설정 필드 표시
+
+
+
+
수정 시 데이터 로드
+
+ 수정 모드에서 서브 테이블 데이터를 불러옵니다
+
+
+
updateSubTable(subIndex, {
+ options: {
+ ...subTable.options,
+ loadOnEdit: false,
+ loadOnlySubItems: undefined,
+ }
+ })}
+ className="h-6 text-[9px] px-2 text-destructive hover:text-destructive"
+ >
+
+ 제거
+
+
+
+
+ updateSubTable(subIndex, {
+ options: {
+ ...subTable.options,
+ loadOnlySubItems: checked,
+ }
+ })}
+ />
+
+ 일반 항목만 로드 (대표 항목 제외)
+
+
+
+ 활성화하면 겸직 데이터만 불러오고, 비활성화하면 모든 데이터를 불러옵니다
+
+
+ )}
+
diff --git a/frontend/lib/registry/components/universal-form-modal/modals/TableColumnSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/TableColumnSettingsModal.tsx
index 797bce55..79a78d10 100644
--- a/frontend/lib/registry/components/universal-form-modal/modals/TableColumnSettingsModal.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/modals/TableColumnSettingsModal.tsx
@@ -699,6 +699,357 @@ export function TableColumnSettingsModal({
+
+ {/* 동적 Select 옵션 (소스 테이블에서 로드) */}
+
+
+
+
+
동적 옵션 (소스 테이블에서 로드)
+
+ 소스 테이블에서 옵션을 동적으로 가져옵니다. 조건부 테이블 필터가 자동 적용됩니다.
+
+
+
{
+ updateColumn({
+ dynamicSelectOptions: checked
+ ? {
+ enabled: true,
+ sourceField: "",
+ distinct: true,
+ }
+ : undefined,
+ });
+ }}
+ />
+
+
+ {localColumn.dynamicSelectOptions?.enabled && (
+
+ {/* 소스 필드 */}
+
+
소스 컬럼
+
+ 소스 테이블에서 옵션 값을 가져올 컬럼
+
+ {sourceTableColumns.length > 0 ? (
+
{
+ updateColumn({
+ dynamicSelectOptions: {
+ ...localColumn.dynamicSelectOptions!,
+ sourceField: value,
+ },
+ });
+ }}
+ >
+
+
+
+
+ {sourceTableColumns.map((col) => (
+
+ {col.column_name} {col.comment && `(${col.comment})`}
+
+ ))}
+
+
+ ) : (
+
{
+ updateColumn({
+ dynamicSelectOptions: {
+ ...localColumn.dynamicSelectOptions!,
+ sourceField: e.target.value,
+ },
+ });
+ }}
+ placeholder="inspection_item"
+ className="h-8 text-xs"
+ />
+ )}
+
+
+ {/* 라벨 필드 */}
+
+
라벨 컬럼 (선택)
+
+ 표시할 라벨 컬럼 (없으면 소스 컬럼 값 사용)
+
+ {sourceTableColumns.length > 0 ? (
+
{
+ updateColumn({
+ dynamicSelectOptions: {
+ ...localColumn.dynamicSelectOptions!,
+ labelField: value || undefined,
+ },
+ });
+ }}
+ >
+
+
+
+
+ 없음 (소스 컬럼 사용)
+ {sourceTableColumns.map((col) => (
+
+ {col.column_name} {col.comment && `(${col.comment})`}
+
+ ))}
+
+
+ ) : (
+
{
+ updateColumn({
+ dynamicSelectOptions: {
+ ...localColumn.dynamicSelectOptions!,
+ labelField: e.target.value || undefined,
+ },
+ });
+ }}
+ placeholder="(비워두면 소스 컬럼 사용)"
+ className="h-8 text-xs"
+ />
+ )}
+
+
+ {/* 행 선택 모드 */}
+
+
+
{
+ updateColumn({
+ dynamicSelectOptions: {
+ ...localColumn.dynamicSelectOptions!,
+ rowSelectionMode: checked
+ ? {
+ enabled: true,
+ autoFillMappings: [],
+ }
+ : undefined,
+ },
+ });
+ }}
+ className="scale-75"
+ />
+
+
행 선택 모드
+
+ 이 컬럼 선택 시 같은 소스 행의 다른 컬럼들도 자동 채움
+
+
+
+
+ {localColumn.dynamicSelectOptions.rowSelectionMode?.enabled && (
+
+ {/* 소스 ID 저장 설정 */}
+
+
+ 소스 ID 컬럼
+ {sourceTableColumns.length > 0 ? (
+ {
+ updateColumn({
+ dynamicSelectOptions: {
+ ...localColumn.dynamicSelectOptions!,
+ rowSelectionMode: {
+ ...localColumn.dynamicSelectOptions!.rowSelectionMode!,
+ sourceIdColumn: value || undefined,
+ },
+ },
+ });
+ }}
+ >
+
+
+
+
+ {sourceTableColumns.map((col) => (
+
+ {col.column_name}
+
+ ))}
+
+
+ ) : (
+ {
+ updateColumn({
+ dynamicSelectOptions: {
+ ...localColumn.dynamicSelectOptions!,
+ rowSelectionMode: {
+ ...localColumn.dynamicSelectOptions!.rowSelectionMode!,
+ sourceIdColumn: e.target.value || undefined,
+ },
+ },
+ });
+ }}
+ placeholder="id"
+ className="h-7 text-xs mt-1"
+ />
+ )}
+
+
+ 저장할 필드
+ {
+ updateColumn({
+ dynamicSelectOptions: {
+ ...localColumn.dynamicSelectOptions!,
+ rowSelectionMode: {
+ ...localColumn.dynamicSelectOptions!.rowSelectionMode!,
+ targetIdField: e.target.value || undefined,
+ },
+ },
+ });
+ }}
+ placeholder="inspection_standard_id"
+ className="h-7 text-xs mt-1"
+ />
+
+
+
+ {/* 자동 채움 매핑 */}
+
+
+
자동 채움 매핑
+
{
+ const currentMappings = localColumn.dynamicSelectOptions?.rowSelectionMode?.autoFillMappings || [];
+ updateColumn({
+ dynamicSelectOptions: {
+ ...localColumn.dynamicSelectOptions!,
+ rowSelectionMode: {
+ ...localColumn.dynamicSelectOptions!.rowSelectionMode!,
+ autoFillMappings: [...currentMappings, { sourceColumn: "", targetField: "" }],
+ },
+ },
+ });
+ }}
+ className="h-6 text-[10px] px-2"
+ >
+
+ 매핑 추가
+
+
+
+ {(localColumn.dynamicSelectOptions.rowSelectionMode.autoFillMappings || []).map((mapping, idx) => (
+
+ {sourceTableColumns.length > 0 ? (
+ {
+ const newMappings = [...(localColumn.dynamicSelectOptions?.rowSelectionMode?.autoFillMappings || [])];
+ newMappings[idx] = { ...newMappings[idx], sourceColumn: value };
+ updateColumn({
+ dynamicSelectOptions: {
+ ...localColumn.dynamicSelectOptions!,
+ rowSelectionMode: {
+ ...localColumn.dynamicSelectOptions!.rowSelectionMode!,
+ autoFillMappings: newMappings,
+ },
+ },
+ });
+ }}
+ >
+
+
+
+
+ {sourceTableColumns.map((col) => (
+
+ {col.column_name}
+
+ ))}
+
+
+ ) : (
+ {
+ const newMappings = [...(localColumn.dynamicSelectOptions?.rowSelectionMode?.autoFillMappings || [])];
+ newMappings[idx] = { ...newMappings[idx], sourceColumn: e.target.value };
+ updateColumn({
+ dynamicSelectOptions: {
+ ...localColumn.dynamicSelectOptions!,
+ rowSelectionMode: {
+ ...localColumn.dynamicSelectOptions!.rowSelectionMode!,
+ autoFillMappings: newMappings,
+ },
+ },
+ });
+ }}
+ placeholder="소스 컬럼"
+ className="h-7 text-xs flex-1"
+ />
+ )}
+ →
+ {
+ const newMappings = [...(localColumn.dynamicSelectOptions?.rowSelectionMode?.autoFillMappings || [])];
+ newMappings[idx] = { ...newMappings[idx], targetField: e.target.value };
+ updateColumn({
+ dynamicSelectOptions: {
+ ...localColumn.dynamicSelectOptions!,
+ rowSelectionMode: {
+ ...localColumn.dynamicSelectOptions!.rowSelectionMode!,
+ autoFillMappings: newMappings,
+ },
+ },
+ });
+ }}
+ placeholder="타겟 필드"
+ className="h-7 text-xs flex-1"
+ />
+ {
+ const newMappings = (localColumn.dynamicSelectOptions?.rowSelectionMode?.autoFillMappings || []).filter((_, i) => i !== idx);
+ updateColumn({
+ dynamicSelectOptions: {
+ ...localColumn.dynamicSelectOptions!,
+ rowSelectionMode: {
+ ...localColumn.dynamicSelectOptions!.rowSelectionMode!,
+ autoFillMappings: newMappings,
+ },
+ },
+ });
+ }}
+ className="h-7 w-7 p-0 text-destructive"
+ >
+
+
+
+ ))}
+ {(localColumn.dynamicSelectOptions.rowSelectionMode.autoFillMappings || []).length === 0 && (
+
+ 매핑을 추가하세요 (예: inspection_criteria → inspection_standard)
+
+ )}
+
+
+
+ )}
+
+
+ )}
+
>
)}
diff --git a/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx
index effb7927..535be447 100644
--- a/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx
@@ -25,12 +25,14 @@ import {
TableModalFilter,
TableCalculationRule,
LookupOption,
- ExternalTableLookup,
+ LookupCondition,
+ ConditionalTableOption,
TABLE_COLUMN_TYPE_OPTIONS,
FILTER_OPERATOR_OPTIONS,
MODAL_FILTER_TYPE_OPTIONS,
LOOKUP_TYPE_OPTIONS,
LOOKUP_CONDITION_SOURCE_OPTIONS,
+ CONDITIONAL_TABLE_TRIGGER_OPTIONS,
} from "../types";
import {
@@ -39,8 +41,10 @@ import {
defaultPreFilterConfig,
defaultModalFilterConfig,
defaultCalculationRuleConfig,
+ defaultConditionalTableConfig,
generateTableColumnId,
generateFilterId,
+ generateConditionalOptionId,
} from "../config";
// 도움말 텍스트 컴포넌트
@@ -48,6 +52,236 @@ const HelpText = ({ children }: { children: React.ReactNode }) => (
{children}
);
+// 옵션 소스 설정 컴포넌트 (검색 가능한 Combobox)
+interface OptionSourceConfigProps {
+ optionSource: {
+ enabled: boolean;
+ tableName: string;
+ valueColumn: string;
+ labelColumn: string;
+ filterCondition?: string;
+ };
+ tables: { table_name: string; comment?: string }[];
+ tableColumns: Record;
+ onUpdate: (updates: Partial) => void;
+}
+
+const OptionSourceConfig: React.FC = ({
+ optionSource,
+ tables,
+ tableColumns,
+ onUpdate,
+}) => {
+ const [tableOpen, setTableOpen] = useState(false);
+ const [valueColumnOpen, setValueColumnOpen] = useState(false);
+
+ // 선택된 테이블의 컬럼 목록
+ const selectedTableColumns = useMemo(() => {
+ return tableColumns[optionSource.tableName] || [];
+ }, [tableColumns, optionSource.tableName]);
+
+ return (
+
+ {/* 테이블 선택 Combobox */}
+
+
테이블
+
+
+
+
+ {optionSource.tableName
+ ? tables.find((t) => t.table_name === optionSource.tableName)?.comment || optionSource.tableName
+ : "테이블 선택"}
+
+
+
+
+
+
+
+
+
+ 테이블을 찾을 수 없습니다.
+
+
+ {tables.map((table) => (
+ {
+ onUpdate({
+ tableName: table.table_name,
+ valueColumn: "", // 테이블 변경 시 컬럼 초기화
+ labelColumn: "",
+ });
+ setTableOpen(false);
+ }}
+ className="text-xs"
+ >
+
+
+ {table.table_name}
+ {table.comment && (
+ {table.comment}
+ )}
+
+
+ ))}
+
+
+
+
+
+
+
+ {/* 참조할 값 컬럼 선택 Combobox */}
+
+
참조할 값
+
+
+
+
+ {optionSource.valueColumn
+ ? selectedTableColumns.find((c) => c.column_name === optionSource.valueColumn)?.comment
+ ? `${optionSource.valueColumn} (${selectedTableColumns.find((c) => c.column_name === optionSource.valueColumn)?.comment})`
+ : optionSource.valueColumn
+ : "컬럼 선택"}
+
+
+
+
+
+
+
+
+
+ 컬럼을 찾을 수 없습니다.
+
+
+ {selectedTableColumns.map((column) => (
+ {
+ onUpdate({ valueColumn: column.column_name });
+ setValueColumnOpen(false);
+ }}
+ className="text-xs"
+ >
+
+
+ {column.column_name}
+ {column.comment && (
+ {column.comment}
+ )}
+
+
+ ))}
+
+
+
+
+
+
+
+ {/* 출력할 값 컬럼 선택 Combobox */}
+
+
출력할 값
+
+
+
+
+ {optionSource.labelColumn
+ ? selectedTableColumns.find((c) => c.column_name === optionSource.labelColumn)?.comment
+ ? `${optionSource.labelColumn} (${selectedTableColumns.find((c) => c.column_name === optionSource.labelColumn)?.comment})`
+ : optionSource.labelColumn
+ : "(참조할 값과 동일)"}
+
+
+
+
+
+
+
+
+
+ 컬럼을 찾을 수 없습니다.
+
+
+ {/* 값 컬럼 사용 옵션 */}
+ onUpdate({ labelColumn: "" })}
+ className="text-xs text-muted-foreground"
+ >
+
+ (참조할 값과 동일)
+
+ {selectedTableColumns.map((column) => (
+ onUpdate({ labelColumn: column.column_name })}
+ className="text-xs"
+ >
+
+
+ {column.column_name}
+ {column.comment && (
+ {column.comment}
+ )}
+
+
+ ))}
+
+
+
+
+
+
+ 비워두면 참조할 값을 그대로 표시
+
+
+
+ );
+};
+
// 부모 화면에서 전달 가능한 필드 타입
interface AvailableParentField {
name: string; // 필드명 (columnName)
@@ -1218,6 +1452,340 @@ function ColumnSettingItem({
)}
)}
+
+ {/* 동적 Select 옵션 (소스 테이블 필터링이 활성화되고, 타입이 select일 때만 표시) */}
+ {col.type === "select" && tableConfig.conditionalTable?.sourceFilter?.enabled && (
+
+
+
+
동적 드롭다운 옵션
+
+ 소스 테이블에서 옵션을 동적으로 로드합니다. 조건부 테이블 필터가 자동 적용됩니다.
+
+
+
{
+ onUpdate({
+ dynamicSelectOptions: checked
+ ? {
+ enabled: true,
+ sourceField: "",
+ distinct: true,
+ }
+ : undefined,
+ });
+ }}
+ className="scale-75"
+ />
+
+
+ {col.dynamicSelectOptions?.enabled && (
+
+ {/* 소스 컬럼 선택 */}
+
+
+
소스 컬럼 (옵션 값)
+
+ 드롭다운 옵션으로 사용할 컬럼
+
+ {sourceTableColumns.length > 0 ? (
+
{
+ onUpdate({
+ dynamicSelectOptions: {
+ ...col.dynamicSelectOptions!,
+ sourceField: value,
+ // 라벨 필드가 비어있으면 소스 필드와 동일하게 설정
+ labelField: col.dynamicSelectOptions?.labelField || value,
+ },
+ });
+ }}
+ >
+
+
+
+
+ {sourceTableColumns.map((c) => (
+
+ {c.column_name} {c.comment && `(${c.comment})`}
+
+ ))}
+
+
+ ) : (
+
{
+ onUpdate({
+ dynamicSelectOptions: {
+ ...col.dynamicSelectOptions!,
+ sourceField: e.target.value,
+ },
+ });
+ }}
+ placeholder="inspection_item"
+ className="h-7 text-xs"
+ />
+ )}
+
+
+
+
라벨 컬럼 (선택)
+
+ 표시할 라벨 (비워두면 소스 컬럼과 동일)
+
+ {sourceTableColumns.length > 0 ? (
+
{
+ onUpdate({
+ dynamicSelectOptions: {
+ ...col.dynamicSelectOptions!,
+ labelField: value,
+ },
+ });
+ }}
+ >
+
+
+
+
+
+ (소스 컬럼과 동일)
+
+ {sourceTableColumns.map((c) => (
+
+ {c.column_name} {c.comment && `(${c.comment})`}
+
+ ))}
+
+
+ ) : (
+
{
+ onUpdate({
+ dynamicSelectOptions: {
+ ...col.dynamicSelectOptions!,
+ labelField: e.target.value,
+ },
+ });
+ }}
+ placeholder="(비워두면 소스 컬럼과 동일)"
+ className="h-7 text-xs"
+ />
+ )}
+
+
+
+ {/* 행 선택 모드 */}
+
+
+ {
+ onUpdate({
+ dynamicSelectOptions: {
+ ...col.dynamicSelectOptions!,
+ rowSelectionMode: checked
+ ? {
+ enabled: true,
+ autoFillColumns: [],
+ }
+ : undefined,
+ },
+ });
+ }}
+ className="scale-75"
+ />
+ 행 선택 모드 (자동 채움)
+
+
+ 이 컬럼 선택 시 같은 소스 행의 다른 컬럼 값을 자동으로 채웁니다.
+
+
+ {col.dynamicSelectOptions.rowSelectionMode?.enabled && (
+
+
+
자동 채움 매핑
+
{
+ const currentMappings = col.dynamicSelectOptions?.rowSelectionMode?.autoFillColumns || [];
+ onUpdate({
+ dynamicSelectOptions: {
+ ...col.dynamicSelectOptions!,
+ rowSelectionMode: {
+ ...col.dynamicSelectOptions!.rowSelectionMode!,
+ autoFillColumns: [...currentMappings, { sourceColumn: "", targetField: "" }],
+ },
+ },
+ });
+ }}
+ className="h-6 text-[10px] px-2"
+ >
+
+ 매핑 추가
+
+
+
+ {(col.dynamicSelectOptions.rowSelectionMode?.autoFillColumns || []).length === 0 ? (
+
+ "매핑 추가" 버튼을 클릭하여 자동 채움 매핑을 추가하세요.
+
+ ) : (
+
+ {(col.dynamicSelectOptions.rowSelectionMode?.autoFillColumns || []).map((mapping, mappingIndex) => (
+
+ {/* 소스 컬럼 */}
+
+ 소스 컬럼
+ {sourceTableColumns.length > 0 ? (
+ {
+ const newMappings = [...(col.dynamicSelectOptions?.rowSelectionMode?.autoFillColumns || [])];
+ newMappings[mappingIndex] = { ...newMappings[mappingIndex], sourceColumn: value };
+ onUpdate({
+ dynamicSelectOptions: {
+ ...col.dynamicSelectOptions!,
+ rowSelectionMode: {
+ ...col.dynamicSelectOptions!.rowSelectionMode!,
+ autoFillColumns: newMappings,
+ },
+ },
+ });
+ }}
+ >
+
+
+
+
+ {sourceTableColumns.map((c) => (
+
+ {c.column_name}
+
+ ))}
+
+
+ ) : (
+ {
+ const newMappings = [...(col.dynamicSelectOptions?.rowSelectionMode?.autoFillColumns || [])];
+ newMappings[mappingIndex] = { ...newMappings[mappingIndex], sourceColumn: e.target.value };
+ onUpdate({
+ dynamicSelectOptions: {
+ ...col.dynamicSelectOptions!,
+ rowSelectionMode: {
+ ...col.dynamicSelectOptions!.rowSelectionMode!,
+ autoFillColumns: newMappings,
+ },
+ },
+ });
+ }}
+ placeholder="소스 컬럼"
+ className="h-6 text-[10px]"
+ />
+ )}
+
+
+
→
+
+ {/* 타겟 필드 */}
+
+ 타겟 필드
+ {
+ const newMappings = [...(col.dynamicSelectOptions?.rowSelectionMode?.autoFillColumns || [])];
+ newMappings[mappingIndex] = { ...newMappings[mappingIndex], targetField: value };
+ onUpdate({
+ dynamicSelectOptions: {
+ ...col.dynamicSelectOptions!,
+ rowSelectionMode: {
+ ...col.dynamicSelectOptions!.rowSelectionMode!,
+ autoFillColumns: newMappings,
+ },
+ },
+ });
+ }}
+ >
+
+
+
+
+ {(tableConfig.columns || [])
+ .filter((c) => c.field !== col.field) // 현재 컬럼 제외
+ .map((c) => (
+
+ {c.label || c.field}
+
+ ))}
+
+
+
+
+ {/* 삭제 버튼 */}
+
{
+ const newMappings = (col.dynamicSelectOptions?.rowSelectionMode?.autoFillColumns || []).filter(
+ (_, i) => i !== mappingIndex
+ );
+ onUpdate({
+ dynamicSelectOptions: {
+ ...col.dynamicSelectOptions!,
+ rowSelectionMode: {
+ ...col.dynamicSelectOptions!.rowSelectionMode!,
+ autoFillColumns: newMappings,
+ },
+ },
+ });
+ }}
+ className="h-6 w-6 p-0 text-destructive hover:text-destructive"
+ >
+
+
+
+ ))}
+
+ )}
+
+ {/* 매핑 설명 */}
+ {(col.dynamicSelectOptions.rowSelectionMode?.autoFillColumns || []).length > 0 && (
+
+ {col.label || col.field} 선택 시:{" "}
+ {(col.dynamicSelectOptions.rowSelectionMode?.autoFillColumns || [])
+ .filter((m) => m.sourceColumn && m.targetField)
+ .map((m) => {
+ const targetCol = tableConfig.columns?.find((c) => c.field === m.targetField);
+ return `${m.sourceColumn} → ${targetCol?.label || m.targetField}`;
+ })
+ .join(", ")}
+
+ )}
+
+ )}
+
+
+ {/* 설정 요약 */}
+ {col.dynamicSelectOptions.sourceField && (
+
+ {sourceTableName}.{col.dynamicSelectOptions.sourceField}
+ {tableConfig.conditionalTable?.sourceFilter?.filterColumn && (
+ <> (조건: {tableConfig.conditionalTable.sourceFilter.filterColumn} = 선택된 검사유형)>
+ )}
+
+ )}
+
+ )}
+
+ )}
);
}
@@ -2160,12 +2728,37 @@ export function TableSectionSettingsModal({
UI 설정
+
+
추가 버튼 동작
+
updateUiConfig({ addButtonType: value as "search" | "addRow" })}
+ >
+
+
+
+
+
+
+ 검색 모달 열기
+ 기존 데이터에서 선택
+
+
+
+
+ 빈 행 추가
+ 새 데이터 직접 입력
+
+
+
+
+
추가 버튼 텍스트
updateUiConfig({ addButtonText: e.target.value })}
- placeholder="항목 검색"
+ placeholder={tableConfig.uiConfig?.addButtonType === "addRow" ? "항목 추가" : "항목 검색"}
className="h-8 text-xs mt-1"
/>
@@ -2176,7 +2769,11 @@ export function TableSectionSettingsModal({
onChange={(e) => updateUiConfig({ modalTitle: e.target.value })}
placeholder="항목 검색 및 선택"
className="h-8 text-xs mt-1"
+ disabled={tableConfig.uiConfig?.addButtonType === "addRow"}
/>
+ {tableConfig.uiConfig?.addButtonType === "addRow" && (
+
빈 행 추가 모드에서는 모달이 열리지 않습니다
+ )}
테이블 최대 높이
@@ -2193,6 +2790,7 @@ export function TableSectionSettingsModal({
checked={tableConfig.uiConfig?.multiSelect ?? true}
onCheckedChange={(checked) => updateUiConfig({ multiSelect: checked })}
className="scale-75"
+ disabled={tableConfig.uiConfig?.addButtonType === "addRow"}
/>
다중 선택 허용
@@ -2254,6 +2852,365 @@ export function TableSectionSettingsModal({
))}
+
+ {/* 조건부 테이블 설정 */}
+
+
+
+
조건부 테이블
+
+ 조건(검사유형 등)에 따라 다른 데이터를 표시하고 저장합니다.
+
+
+
{
+ setTableConfig({
+ ...tableConfig,
+ conditionalTable: checked
+ ? { ...defaultConditionalTableConfig, enabled: true }
+ : { ...defaultConditionalTableConfig, enabled: false },
+ });
+ }}
+ className="scale-75"
+ />
+
+
+ {tableConfig.conditionalTable?.enabled && (
+
+ {/* 트리거 유형 및 조건 컬럼 */}
+
+
+ 트리거 유형
+ {
+ setTableConfig({
+ ...tableConfig,
+ conditionalTable: {
+ ...tableConfig.conditionalTable!,
+ triggerType: value,
+ },
+ });
+ }}
+ >
+
+
+
+
+ {CONDITIONAL_TABLE_TRIGGER_OPTIONS.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+
+ 체크박스: 다중 선택 후 탭으로 표시 / 드롭다운: 단일 선택 / 탭: 모든 옵션 표시
+
+
+
+ 조건 값 저장 컬럼
+ {
+ setTableConfig({
+ ...tableConfig,
+ conditionalTable: {
+ ...tableConfig.conditionalTable!,
+ conditionColumn: value,
+ },
+ });
+ }}
+ >
+
+
+
+
+ {saveTableColumns.map((col) => (
+
+ {col.comment || col.column_name}
+
+ ))}
+
+
+ 저장 시 각 행에 조건 값이 이 컬럼에 자동 저장됩니다.
+
+
+
+ {/* 조건 옵션 목록 */}
+
+
+
조건 옵션
+
+
{
+ const newOption: ConditionalTableOption = {
+ id: generateConditionalOptionId(),
+ value: "",
+ label: "",
+ };
+ setTableConfig({
+ ...tableConfig,
+ conditionalTable: {
+ ...tableConfig.conditionalTable!,
+ options: [...(tableConfig.conditionalTable?.options || []), newOption],
+ },
+ });
+ }}
+ className="h-7 text-xs"
+ >
+
+ 옵션 추가
+
+
+
+
+ {/* 옵션 목록 */}
+
+ {(tableConfig.conditionalTable?.options || []).map((option, index) => (
+
+ {
+ const newOptions = [...(tableConfig.conditionalTable?.options || [])];
+ newOptions[index] = { ...newOptions[index], value: e.target.value };
+ // label이 비어있으면 value와 동일하게 설정
+ if (!newOptions[index].label) {
+ newOptions[index].label = e.target.value;
+ }
+ setTableConfig({
+ ...tableConfig,
+ conditionalTable: {
+ ...tableConfig.conditionalTable!,
+ options: newOptions,
+ },
+ });
+ }}
+ placeholder="저장 값 (예: 입고검사)"
+ className="h-8 text-xs flex-1"
+ />
+ {
+ const newOptions = [...(tableConfig.conditionalTable?.options || [])];
+ newOptions[index] = { ...newOptions[index], label: e.target.value };
+ setTableConfig({
+ ...tableConfig,
+ conditionalTable: {
+ ...tableConfig.conditionalTable!,
+ options: newOptions,
+ },
+ });
+ }}
+ placeholder="표시 라벨 (예: 입고검사)"
+ className="h-8 text-xs flex-1"
+ />
+ {
+ const newOptions = (tableConfig.conditionalTable?.options || []).filter(
+ (_, i) => i !== index
+ );
+ setTableConfig({
+ ...tableConfig,
+ conditionalTable: {
+ ...tableConfig.conditionalTable!,
+ options: newOptions,
+ },
+ });
+ }}
+ className="h-8 w-8 p-0 text-destructive hover:text-destructive"
+ >
+
+
+
+ ))}
+
+ {(tableConfig.conditionalTable?.options || []).length === 0 && (
+
+ 조건 옵션을 추가하세요. (예: 입고검사, 공정검사, 출고검사 등)
+
+ )}
+
+
+
+ {/* 테이블에서 옵션 로드 설정 */}
+
+
+ {
+ setTableConfig({
+ ...tableConfig,
+ conditionalTable: {
+ ...tableConfig.conditionalTable!,
+ optionSource: {
+ ...tableConfig.conditionalTable?.optionSource,
+ enabled: checked,
+ tableName: tableConfig.conditionalTable?.optionSource?.tableName || "",
+ valueColumn: tableConfig.conditionalTable?.optionSource?.valueColumn || "",
+ labelColumn: tableConfig.conditionalTable?.optionSource?.labelColumn || "",
+ },
+ },
+ });
+ }}
+ className="scale-75"
+ />
+ 테이블에서 옵션 동적 로드
+
+
+ {tableConfig.conditionalTable?.optionSource?.enabled && (
+
{
+ setTableConfig({
+ ...tableConfig,
+ conditionalTable: {
+ ...tableConfig.conditionalTable!,
+ optionSource: {
+ ...tableConfig.conditionalTable?.optionSource!,
+ ...updates,
+ },
+ },
+ });
+ }}
+ />
+ )}
+
+
+ {/* 소스 테이블 필터링 설정 */}
+
+
+
{
+ setTableConfig({
+ ...tableConfig,
+ conditionalTable: {
+ ...tableConfig.conditionalTable!,
+ sourceFilter: {
+ enabled: checked,
+ filterColumn: tableConfig.conditionalTable?.sourceFilter?.filterColumn || "",
+ },
+ },
+ });
+ }}
+ className="scale-75"
+ />
+
+
소스 테이블 필터링
+
+ 조건 선택 시 소스 테이블에서 해당 조건으로 필터링합니다
+
+
+
+
+ {tableConfig.conditionalTable?.sourceFilter?.enabled && (
+
+
필터링할 소스 컬럼
+
+ 소스 테이블({tableConfig.source?.tableName || "미설정"})에서 조건값으로 필터링할 컬럼
+
+ {sourceTableColumns.length > 0 ? (
+
+
+
+ {tableConfig.conditionalTable.sourceFilter.filterColumn
+ ? (() => {
+ const col = sourceTableColumns.find(
+ (c) => c.column_name === tableConfig.conditionalTable?.sourceFilter?.filterColumn
+ );
+ return col
+ ? `${col.column_name} (${col.comment || col.column_name})`
+ : tableConfig.conditionalTable.sourceFilter.filterColumn;
+ })()
+ : "컬럼 선택..."}
+
+
+
+
+
+
+
+
+ 컬럼을 찾을 수 없습니다.
+
+
+ {sourceTableColumns.map((col) => (
+ {
+ setTableConfig({
+ ...tableConfig,
+ conditionalTable: {
+ ...tableConfig.conditionalTable!,
+ sourceFilter: {
+ ...tableConfig.conditionalTable?.sourceFilter!,
+ filterColumn: col.column_name,
+ },
+ },
+ });
+ }}
+ className="text-xs"
+ >
+
+ {col.column_name}
+ {col.comment && (
+ ({col.comment})
+ )}
+
+ ))}
+
+
+
+
+
+ ) : (
+
{
+ setTableConfig({
+ ...tableConfig,
+ conditionalTable: {
+ ...tableConfig.conditionalTable!,
+ sourceFilter: {
+ ...tableConfig.conditionalTable?.sourceFilter!,
+ filterColumn: e.target.value,
+ },
+ },
+ });
+ }}
+ placeholder="inspection_type"
+ className="h-7 text-xs"
+ />
+ )}
+
+ 예: 검사유형 "입고검사" 선택 시 → inspection_type = '입고검사' 조건 적용
+
+
+ )}
+
+
+ )}
+
diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts
index 43377764..31388e96 100644
--- a/frontend/lib/registry/components/universal-form-modal/types.ts
+++ b/frontend/lib/registry/components/universal-form-modal/types.ts
@@ -80,7 +80,8 @@ export interface FormFieldConfig {
linkedFieldGroup?: {
enabled?: boolean; // 사용 여부
sourceTable?: string; // 소스 테이블 (예: dept_info)
- displayColumn?: string; // 표시할 컬럼 (예: dept_name) - 드롭다운에 보여줄 텍스트
+ displayColumn?: string; // 메인 표시 컬럼 (예: item_name) - 드롭다운에 보여줄 메인 텍스트
+ subDisplayColumn?: string; // 서브 표시 컬럼 (예: item_number) - 메인과 함께 표시될 서브 텍스트
displayFormat?: "name_only" | "code_name" | "name_code" | "custom"; // 표시 형식
// 커스텀 표시 형식 (displayFormat이 "custom"일 때 사용)
// 형식: {컬럼명} 으로 치환됨 (예: "{item_name} ({item_number})" → "철판 (ITEM-001)")
@@ -256,6 +257,11 @@ export interface TableSectionConfig {
modalTitle?: string; // 모달 제목 (기본: "항목 검색 및 선택")
multiSelect?: boolean; // 다중 선택 허용 (기본: true)
maxHeight?: string; // 테이블 최대 높이 (기본: "400px")
+
+ // 추가 버튼 타입
+ // - search: 검색 모달 열기 (기본값) - 기존 데이터에서 선택
+ // - addRow: 빈 행 직접 추가 - 새 데이터 직접 입력
+ addButtonType?: "search" | "addRow";
};
// 7. 조건부 테이블 설정 (고급)
@@ -295,6 +301,13 @@ export interface ConditionalTableConfig {
labelColumn: string; // 예: type_name
filterCondition?: string; // 예: is_active = 'Y'
};
+
+ // 소스 테이블 필터링 설정
+ // 조건 선택 시 소스 테이블(검사기준 등)에서 해당 조건으로 필터링
+ sourceFilter?: {
+ enabled: boolean;
+ filterColumn: string; // 소스 테이블에서 필터링할 컬럼 (예: inspection_type)
+ };
}
/**
@@ -373,6 +386,30 @@ export interface TableColumnConfig {
// Select 옵션 (type이 "select"일 때)
selectOptions?: { value: string; label: string }[];
+ // 동적 Select 옵션 (소스 테이블에서 옵션 로드)
+ // 조건부 테이블의 sourceFilter가 활성화되어 있으면 자동으로 필터 적용
+ dynamicSelectOptions?: {
+ enabled: boolean;
+ sourceField: string; // 소스 테이블에서 가져올 컬럼 (예: inspection_item)
+ labelField?: string; // 표시 라벨 컬럼 (없으면 sourceField 사용)
+ distinct?: boolean; // 중복 제거 (기본: true)
+
+ // 행 선택 모드: 이 컬럼 선택 시 같은 소스 행의 다른 컬럼들도 자동 채움
+ // 활성화하면 이 컬럼이 "대표 컬럼"이 되어 선택 시 연관 컬럼들이 자동으로 채워짐
+ rowSelectionMode?: {
+ enabled: boolean;
+ // 자동 채움할 컬럼 매핑 (소스 컬럼 → 타겟 필드)
+ // 예: [{ sourceColumn: "inspection_criteria", targetField: "inspection_standard" }]
+ autoFillColumns?: {
+ sourceColumn: string; // 소스 테이블의 컬럼
+ targetField: string; // 현재 테이블의 필드
+ }[];
+ // 소스 테이블의 ID 컬럼 (참조 ID 저장용)
+ sourceIdColumn?: string; // 예: "id"
+ targetIdField?: string; // 예: "inspection_standard_id"
+ };
+ };
+
// 값 매핑 (핵심 기능) - 고급 설정용
valueMapping?: ValueMappingConfig;
@@ -642,6 +679,10 @@ export interface SubTableSaveConfig {
// 저장 전 기존 데이터 삭제
deleteExistingBefore?: boolean;
deleteOnlySubItems?: boolean; // 메인 항목은 유지하고 서브만 삭제
+
+ // 수정 모드에서 서브 테이블 데이터 로드
+ loadOnEdit?: boolean; // 수정 시 서브 테이블 데이터 로드 여부
+ loadOnlySubItems?: boolean; // 서브 항목만 로드 (메인 항목 제외)
};
}
From 00376202fdbc699f6e7ee5078b1559ec9da7cd7b Mon Sep 17 00:00:00 2001
From: SeongHyun Kim
Date: Mon, 29 Dec 2025 09:06:07 +0900
Subject: [PATCH 11/28] =?UTF-8?q?feat(universal-form-modal):=20=EC=A1=B0?=
=?UTF-8?q?=EA=B1=B4=EB=B6=80=20=ED=85=8C=EC=9D=B4=EB=B8=94,=20=EB=8F=99?=
=?UTF-8?q?=EC=A0=81=20Select=20=EC=98=B5=EC=85=98,=20=EC=84=9C=EB=B8=8C?=
=?UTF-8?q?=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=88=98=EC=A0=95=20=EB=A1=9C?=
=?UTF-8?q?=EB=93=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 조건부 테이블: 체크박스/탭으로 조건 선택 시 다른 테이블 데이터 관리
- 동적 Select 옵션: 소스 테이블에서 드롭다운 옵션 동적 로드
- 행 선택 모드: Select 값 변경 시 같은 소스 행의 연관 컬럼 자동 채움
- 수정 모드 서브 테이블 로드: loadOnEdit 옵션으로 반복 섹션 데이터 자동 로드
- SplitPanelLayout2 메인 테이블 병합: 서브 테이블 수정 시 메인 데이터 함께 조회
- 연결 필드 그룹 표시 형식: subDisplayColumn 추가로 메인/서브 컬럼 분리 설정
- UX 개선: 컬럼 선택 UI를 검색 가능한 Combobox로 전환
- saveMainAsFirst 로직 개선: items 없어도 메인 데이터 저장 가능
---
.../ActionButtonConfigModal.tsx | 1 +
.../split-panel-layout2/ColumnConfigModal.tsx | 1 +
.../components/split-panel-layout2/README.md | 1 +
.../SplitPanelLayout2Renderer.tsx | 1 +
.../components/SearchableColumnSelect.tsx | 1 +
.../components/SortableColumnItem.tsx | 1 +
.../modals/SectionLayoutModal.tsx | 1 +
.../modals/TableSectionSettingsModal.tsx | 32 +++++++++++++++++--
8 files changed, 36 insertions(+), 3 deletions(-)
diff --git a/frontend/lib/registry/components/split-panel-layout2/ActionButtonConfigModal.tsx b/frontend/lib/registry/components/split-panel-layout2/ActionButtonConfigModal.tsx
index aeff27c2..5efd59b8 100644
--- a/frontend/lib/registry/components/split-panel-layout2/ActionButtonConfigModal.tsx
+++ b/frontend/lib/registry/components/split-panel-layout2/ActionButtonConfigModal.tsx
@@ -672,3 +672,4 @@ export const ActionButtonConfigModal: React.FC = (
export default ActionButtonConfigModal;
+
diff --git a/frontend/lib/registry/components/split-panel-layout2/ColumnConfigModal.tsx b/frontend/lib/registry/components/split-panel-layout2/ColumnConfigModal.tsx
index 89866651..ae6c4093 100644
--- a/frontend/lib/registry/components/split-panel-layout2/ColumnConfigModal.tsx
+++ b/frontend/lib/registry/components/split-panel-layout2/ColumnConfigModal.tsx
@@ -803,3 +803,4 @@ export const ColumnConfigModal: React.FC = ({
export default ColumnConfigModal;
+
diff --git a/frontend/lib/registry/components/split-panel-layout2/README.md b/frontend/lib/registry/components/split-panel-layout2/README.md
index 26d2a669..aafdc38d 100644
--- a/frontend/lib/registry/components/split-panel-layout2/README.md
+++ b/frontend/lib/registry/components/split-panel-layout2/README.md
@@ -102,3 +102,4 @@
+
diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Renderer.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Renderer.tsx
index 642de9a2..57979869 100644
--- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Renderer.tsx
+++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Renderer.tsx
@@ -42,3 +42,4 @@ SplitPanelLayout2Renderer.registerSelf();
+
diff --git a/frontend/lib/registry/components/split-panel-layout2/components/SearchableColumnSelect.tsx b/frontend/lib/registry/components/split-panel-layout2/components/SearchableColumnSelect.tsx
index 24602860..da160f3e 100644
--- a/frontend/lib/registry/components/split-panel-layout2/components/SearchableColumnSelect.tsx
+++ b/frontend/lib/registry/components/split-panel-layout2/components/SearchableColumnSelect.tsx
@@ -161,3 +161,4 @@ export const SearchableColumnSelect: React.FC = ({
export default SearchableColumnSelect;
+
diff --git a/frontend/lib/registry/components/split-panel-layout2/components/SortableColumnItem.tsx b/frontend/lib/registry/components/split-panel-layout2/components/SortableColumnItem.tsx
index 8e3cef91..4a188983 100644
--- a/frontend/lib/registry/components/split-panel-layout2/components/SortableColumnItem.tsx
+++ b/frontend/lib/registry/components/split-panel-layout2/components/SortableColumnItem.tsx
@@ -116,3 +116,4 @@ export const SortableColumnItem: React.FC = ({
export default SortableColumnItem;
+
diff --git a/frontend/lib/registry/components/universal-form-modal/modals/SectionLayoutModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/SectionLayoutModal.tsx
index b47c2424..0031e5d0 100644
--- a/frontend/lib/registry/components/universal-form-modal/modals/SectionLayoutModal.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/modals/SectionLayoutModal.tsx
@@ -1125,3 +1125,4 @@ export function SectionLayoutModal({
}
+
diff --git a/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx
index 535be447..f8b77aba 100644
--- a/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx
@@ -1538,12 +1538,12 @@ function ColumnSettingItem({
{sourceTableColumns.length > 0 ? (
{
onUpdate({
dynamicSelectOptions: {
...col.dynamicSelectOptions!,
- labelField: value,
+ labelField: value === "__same_as_source__" ? "" : value,
},
});
}}
@@ -1552,7 +1552,7 @@ function ColumnSettingItem({
-
+
(소스 컬럼과 동일)
{sourceTableColumns.map((c) => (
@@ -3207,6 +3207,32 @@ export function TableSectionSettingsModal({
)}
+
+ {/* 사용 가이드 */}
+
+
사용 가이드
+
+
1. 소스 테이블 필터링 활성화 후:
+
+ 항목 검색 : 검색 모달에서 필터링된 데이터만 표시
+ 빈 행 추가 : 드롭다운 옵션이 필터링된 데이터로 제한
+
+
2. 컬럼 설정에서 추가 설정:
+
+ 컬럼 타입을 "선택(드롭다운)" 으로 변경
+ "동적 드롭다운 옵션" 섹션이 나타남
+ 소스 컬럼 선택 → 해당 컬럼 값이 드롭다운 옵션으로 표시
+ "행 선택 모드" 활성화 시 → 선택한 값의 같은 행 데이터를 다른 컬럼에 자동 채움
+
+
3. 예시 (품목검사정보):
+
+ "입고검사" 체크박스 선택 → 테이블 탭 표시
+ "항목 추가" 클릭 → 빈 행 생성
+ "검사항목" 드롭다운 → inspection_type='입고검사'인 항목만 표시
+ 검사항목 선택 시 → 검사기준, 검사방법 자동 채움 (행 선택 모드)
+
+
+
)}
From ef991b3b26cd1bd75f7028817087bc9a01f94f1a Mon Sep 17 00:00:00 2001
From: SeongHyun Kim
Date: Mon, 29 Dec 2025 17:42:30 +0900
Subject: [PATCH 12/28] =?UTF-8?q?feat(universal-form-modal):=20=ED=85=8C?=
=?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EC=BB=AC=EB=9F=BC=20=EC=A0=80=EC=9E=A5=20?=
=?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=B0=8F=20=EC=B0=B8=EC=A1=B0=20=ED=91=9C?=
=?UTF-8?q?=EC=8B=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20=EC=BB=AC?=
=?UTF-8?q?=EB=9F=BC=EB=B3=84=20=EC=A0=80=EC=9E=A5=20=EC=97=AC=EB=B6=80=20?=
=?UTF-8?q?=EC=84=A4=EC=A0=95=20(saveToTarget:=20true/false)=20=EC=A0=80?=
=?UTF-8?q?=EC=9E=A5=20=EC=95=88=20=ED=95=A8=20=EC=BB=AC=EB=9F=BC:=20?=
=?UTF-8?q?=EC=B0=B8=EC=A1=B0=20ID=EB=A1=9C=20=EC=86=8C=EC=8A=A4=20?=
=?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=A1=B0=ED=9A=8C=ED=95=98?=
=?UTF-8?q?=EC=97=AC=20=ED=91=9C=EC=8B=9C=EB=A7=8C=20=ED=95=A8=20=EC=88=98?=
=?UTF-8?q?=EC=A0=95=20=EB=AA=A8=EB=93=9C=EC=97=90=EC=84=9C=20=EC=B0=B8?=
=?UTF-8?q?=EC=A1=B0=20=EC=BB=AC=EB=9F=BC=20=EA=B0=92=20=EC=9E=90=EB=8F=99?=
=?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20(loadReferenceColumnValues)=20Select=20?=
=?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B9=88=20=EA=B0=92=20?=
=?UTF-8?q?=ED=95=84=ED=84=B0=EB=A7=81=EC=9C=BC=EB=A1=9C=20=EC=95=88?=
=?UTF-8?q?=EC=A0=95=EC=84=B1=20=EA=B0=9C=EC=84=A0=20=EC=A1=B0=EA=B1=B4=20?=
=?UTF-8?q?=ED=83=AD=20=EB=B3=80=EA=B2=BD=20=EC=8B=9C=20=EC=86=8C=EC=8A=A4?=
=?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A6=89=EC=8B=9C=20=EB=A1=9C?=
=?UTF-8?q?=EB=93=9C=20=EC=BB=AC=EB=9F=BC=20=ED=95=84=EB=93=9C=20=EC=84=A0?=
=?UTF-8?q?=ED=83=9D=20=EC=95=88=20=ED=95=A8=20=EC=98=B5=EC=85=98=20?=
=?UTF-8?q?=EC=B6=94=EA=B0=80=20(=ED=91=9C=EC=8B=9C=20=EC=A0=84=EC=9A=A9?=
=?UTF-8?q?=20=EC=BB=AC=EB=9F=BC)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../ModalRepeaterTableConfigPanel.tsx | 8 +-
.../modal-repeater-table/RepeaterTable.tsx | 2 +-
.../SimpleRepeaterTableComponent.tsx | 2 +-
.../SimpleRepeaterTableConfigPanel.tsx | 2 +-
.../TableSectionRenderer.tsx | 209 +++++++++++++----
.../UniversalFormModalComponent.tsx | 44 ++--
.../UniversalFormModalConfigPanel.tsx | 6 +-
.../modals/TableSectionSettingsModal.tsx | 217 +++++++++++++++++-
.../components/universal-form-modal/types.ts | 25 ++
9 files changed, 439 insertions(+), 76 deletions(-)
diff --git a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx
index 2e1cf659..f22eb497 100644
--- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx
+++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx
@@ -1636,7 +1636,7 @@ export function ModalRepeaterTableConfigPanel({
- {(localConfig.columns || []).map((col) => (
+ {(localConfig.columns || []).filter((col) => col.field && col.field !== "").map((col) => (
{col.label} ({col.field})
@@ -1900,7 +1900,7 @@ export function ModalRepeaterTableConfigPanel({
- {(localConfig.columns || []).map((col) => (
+ {(localConfig.columns || []).filter((col) => col.field && col.field !== "").map((col) => (
{col.label}
@@ -2056,7 +2056,7 @@ export function ModalRepeaterTableConfigPanel({
- {(localConfig.columns || []).map((col) => (
+ {(localConfig.columns || []).filter((col) => col.field && col.field !== "").map((col) => (
{col.label} ({col.field})
@@ -2303,7 +2303,7 @@ export function ModalRepeaterTableConfigPanel({
- {(localConfig.columns || []).map((col) => (
+ {(localConfig.columns || []).filter((col) => col.field && col.field !== "").map((col) => (
{col.label}
diff --git a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx
index 88da4aef..70b15e7d 100644
--- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx
+++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx
@@ -481,7 +481,7 @@ export function RepeaterTable({
- {column.selectOptions?.map((option) => (
+ {column.selectOptions?.filter((option) => option.value && option.value !== "").map((option) => (
{option.label}
diff --git a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx
index 6303cdee..add34d5f 100644
--- a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx
+++ b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx
@@ -561,7 +561,7 @@ export function SimpleRepeaterTableComponent({
- {column.selectOptions?.map((option) => (
+ {column.selectOptions?.filter((option) => option.value && option.value !== "").map((option) => (
{option.label}
diff --git a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableConfigPanel.tsx b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableConfigPanel.tsx
index 41e70e08..4ef47e39 100644
--- a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableConfigPanel.tsx
+++ b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableConfigPanel.tsx
@@ -1539,7 +1539,7 @@ export function SimpleRepeaterTableConfigPanel({
- {(localConfig.columns || []).filter(c => c.type === "number").map((col) => (
+ {(localConfig.columns || []).filter(c => c.type === "number" && c.field && c.field !== "").map((col) => (
{col.label}
diff --git a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx
index 64418541..ba03d2b9 100644
--- a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx
@@ -498,11 +498,43 @@ export function TableSectionRenderer({
if (!hasDynamicSelectColumns) return;
if (!conditionalConfig?.sourceFilter?.enabled) return;
if (!activeConditionTab) return;
+ if (!tableConfig.source?.tableName) return;
- // 조건 변경 시 캐시 리셋하고 다시 로드
+ // 조건 변경 시 캐시 리셋하고 즉시 다시 로드
sourceDataLoadedRef.current = false;
setSourceDataCache([]);
- }, [activeConditionTab, hasDynamicSelectColumns, conditionalConfig?.sourceFilter?.enabled]);
+
+ // 즉시 데이터 다시 로드 (기존 useEffect에 의존하지 않고 직접 호출)
+ const loadSourceData = async () => {
+ try {
+ const filterCondition: Record = {};
+ filterCondition[conditionalConfig.sourceFilter!.filterColumn] = activeConditionTab;
+
+ const response = await apiClient.post(
+ `/table-management/tables/${tableConfig.source!.tableName}/data`,
+ {
+ search: filterCondition,
+ size: 1000,
+ page: 1,
+ }
+ );
+
+ if (response.data.success && response.data.data?.data) {
+ setSourceDataCache(response.data.data.data);
+ sourceDataLoadedRef.current = true;
+ console.log("[TableSectionRenderer] 조건 탭 변경 - 소스 데이터 로드 완료:", {
+ tableName: tableConfig.source!.tableName,
+ rowCount: response.data.data.data.length,
+ filter: filterCondition,
+ });
+ }
+ } catch (error) {
+ console.error("[TableSectionRenderer] 소스 데이터 로드 실패:", error);
+ }
+ };
+
+ loadSourceData();
+ }, [activeConditionTab, hasDynamicSelectColumns, conditionalConfig?.sourceFilter?.enabled, conditionalConfig?.sourceFilter?.filterColumn, tableConfig.source?.tableName]);
// 컬럼별 동적 Select 옵션 생성
const dynamicSelectOptionsMap = useMemo(() => {
@@ -540,6 +572,45 @@ export function TableSectionRenderer({
return optionsMap;
}, [sourceDataCache, tableConfig.columns]);
+ // 데이터 변경 핸들러 (날짜 일괄 적용 로직 포함) - 다른 함수에서 참조하므로 먼저 정의
+ const handleDataChange = useCallback(
+ (newData: any[]) => {
+ let processedData = newData;
+
+ // 날짜 일괄 적용 로직: batchApply가 활성화된 날짜 컬럼 처리
+ const batchApplyColumns = tableConfig.columns.filter(
+ (col) => col.type === "date" && col.batchApply === true
+ );
+
+ for (const dateCol of batchApplyColumns) {
+ // 이미 일괄 적용된 컬럼은 건너뜀
+ if (batchAppliedFields.has(dateCol.field)) continue;
+
+ // 해당 컬럼에 값이 있는 행과 없는 행 분류
+ const itemsWithDate = processedData.filter((item) => item[dateCol.field]);
+ const itemsWithoutDate = processedData.filter((item) => !item[dateCol.field]);
+
+ // 조건: 정확히 1개만 날짜가 있고, 나머지는 모두 비어있을 때
+ if (itemsWithDate.length === 1 && itemsWithoutDate.length > 0) {
+ const selectedDate = itemsWithDate[0][dateCol.field];
+
+ // 모든 행에 동일한 날짜 적용
+ processedData = processedData.map((item) => ({
+ ...item,
+ [dateCol.field]: selectedDate,
+ }));
+
+ // 플래그 활성화 (이후 개별 수정 가능)
+ setBatchAppliedFields((prev) => new Set([...prev, dateCol.field]));
+ }
+ }
+
+ setTableData(processedData);
+ onTableDataChange(processedData);
+ },
+ [onTableDataChange, tableConfig.columns, batchAppliedFields]
+ );
+
// 행 선택 모드: 드롭다운 값 변경 시 같은 소스 행의 다른 컬럼들 자동 채움
const handleDynamicSelectChange = useCallback(
(rowIndex: number, columnField: string, selectedValue: string, conditionValue?: string) => {
@@ -617,6 +688,91 @@ export function TableSectionRenderer({
[tableConfig.columns, sourceDataCache, tableData, conditionalTableData, isConditionalMode, handleDataChange, onConditionalTableDataChange]
);
+ // 참조 컬럼 값 조회 함수 (saveToTarget: false인 컬럼에 대해 소스 테이블 조회)
+ const loadReferenceColumnValues = useCallback(async (data: any[]) => {
+ // saveToTarget: false이고 referenceDisplay가 설정된 컬럼 찾기
+ const referenceColumns = (tableConfig.columns || []).filter(
+ (col) => col.saveConfig?.saveToTarget === false && col.saveConfig?.referenceDisplay
+ );
+
+ if (referenceColumns.length === 0) return;
+
+ const sourceTableName = tableConfig.source?.tableName;
+ if (!sourceTableName) {
+ console.warn("[TableSectionRenderer] 참조 조회를 위한 소스 테이블이 설정되지 않았습니다.");
+ return;
+ }
+
+ // 참조 ID들 수집 (중복 제거)
+ const referenceIdSet = new Set();
+
+ for (const col of referenceColumns) {
+ const refDisplay = col.saveConfig!.referenceDisplay!;
+
+ for (const row of data) {
+ const refId = row[refDisplay.referenceIdField];
+ if (refId !== undefined && refId !== null && refId !== "") {
+ referenceIdSet.add(String(refId));
+ }
+ }
+ }
+
+ if (referenceIdSet.size === 0) return;
+
+ try {
+ // 소스 테이블에서 참조 ID에 해당하는 데이터 조회
+ const response = await apiClient.post(
+ `/table-management/tables/${sourceTableName}/data`,
+ {
+ search: { id: Array.from(referenceIdSet) }, // ID 배열로 조회
+ size: 1000,
+ page: 1,
+ }
+ );
+
+ if (!response.data?.success || !response.data?.data?.data) {
+ console.warn("[TableSectionRenderer] 참조 데이터 조회 실패");
+ return;
+ }
+
+ const sourceData: any[] = response.data.data.data;
+
+ // ID를 키로 하는 맵 생성
+ const sourceDataMap: Record = {};
+ for (const sourceRow of sourceData) {
+ sourceDataMap[String(sourceRow.id)] = sourceRow;
+ }
+
+ // 각 행에 참조 컬럼 값 채우기
+ const updatedData = data.map((row) => {
+ const newRow = { ...row };
+
+ for (const col of referenceColumns) {
+ const refDisplay = col.saveConfig!.referenceDisplay!;
+ const refId = row[refDisplay.referenceIdField];
+
+ if (refId !== undefined && refId !== null && refId !== "") {
+ const sourceRow = sourceDataMap[String(refId)];
+ if (sourceRow) {
+ newRow[col.field] = sourceRow[refDisplay.sourceColumn];
+ }
+ }
+ }
+
+ return newRow;
+ });
+
+ console.log("[TableSectionRenderer] 참조 컬럼 값 조회 완료:", {
+ referenceColumns: referenceColumns.map((c) => c.field),
+ updatedRowCount: updatedData.length,
+ });
+
+ setTableData(updatedData);
+ } catch (error) {
+ console.error("[TableSectionRenderer] 참조 데이터 조회 실패:", error);
+ }
+ }, [tableConfig.columns, tableConfig.source?.tableName]);
+
// formData에서 초기 테이블 데이터 로드 (수정 모드에서 _groupedData 표시)
useEffect(() => {
// 이미 초기화되었으면 스킵
@@ -632,8 +788,11 @@ export function TableSectionRenderer({
});
setTableData(initialData);
initialDataLoadedRef.current = true;
+
+ // 참조 컬럼 값 조회 (saveToTarget: false인 컬럼)
+ loadReferenceColumnValues(initialData);
}
- }, [sectionId, formData]);
+ }, [sectionId, formData, loadReferenceColumnValues]);
// RepeaterColumnConfig로 변환 (동적 Select 옵션 반영)
const columns: RepeaterColumnConfig[] = useMemo(() => {
@@ -691,45 +850,6 @@ export function TableSectionRenderer({
[calculateRow]
);
- // 데이터 변경 핸들러 (날짜 일괄 적용 로직 포함)
- const handleDataChange = useCallback(
- (newData: any[]) => {
- let processedData = newData;
-
- // 날짜 일괄 적용 로직: batchApply가 활성화된 날짜 컬럼 처리
- const batchApplyColumns = tableConfig.columns.filter(
- (col) => col.type === "date" && col.batchApply === true
- );
-
- for (const dateCol of batchApplyColumns) {
- // 이미 일괄 적용된 컬럼은 건너뜀
- if (batchAppliedFields.has(dateCol.field)) continue;
-
- // 해당 컬럼에 값이 있는 행과 없는 행 분류
- const itemsWithDate = processedData.filter((item) => item[dateCol.field]);
- const itemsWithoutDate = processedData.filter((item) => !item[dateCol.field]);
-
- // 조건: 정확히 1개만 날짜가 있고, 나머지는 모두 비어있을 때
- if (itemsWithDate.length === 1 && itemsWithoutDate.length > 0) {
- const selectedDate = itemsWithDate[0][dateCol.field];
-
- // 모든 행에 동일한 날짜 적용
- processedData = processedData.map((item) => ({
- ...item,
- [dateCol.field]: selectedDate,
- }));
-
- // 플래그 활성화 (이후 개별 수정 가능)
- setBatchAppliedFields((prev) => new Set([...prev, dateCol.field]));
- }
- }
-
- setTableData(processedData);
- onTableDataChange(processedData);
- },
- [onTableDataChange, tableConfig.columns, batchAppliedFields]
- );
-
// 행 변경 핸들러 (동적 Select 행 선택 모드 지원)
const handleRowChange = useCallback(
(index: number, newRow: any, conditionValue?: string) => {
@@ -1377,9 +1497,10 @@ export function TableSectionRenderer({
const { triggerType } = conditionalConfig;
// 정적 옵션과 동적 옵션 병합 (동적 옵션이 있으면 우선 사용)
- const effectiveOptions = conditionalConfig.optionSource?.enabled && dynamicOptions.length > 0
+ // 빈 value를 가진 옵션은 제외 (Select.Item은 빈 문자열 value를 허용하지 않음)
+ const effectiveOptions = (conditionalConfig.optionSource?.enabled && dynamicOptions.length > 0
? dynamicOptions
- : conditionalConfig.options || [];
+ : conditionalConfig.options || []).filter(opt => opt.value && opt.value.trim() !== "");
// 로딩 중이면 로딩 표시
if (dynamicOptionsLoading) {
diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx
index 16778725..b4921a51 100644
--- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx
@@ -102,11 +102,13 @@ const CascadingSelectField: React.FC = ({
: config.noOptionsMessage || "선택 가능한 항목이 없습니다"}
) : (
- options.map((option) => (
-
- {option.label}
-
- ))
+ options
+ .filter((option) => option.value && option.value !== "")
+ .map((option) => (
+
+ {option.label}
+
+ ))
)}
@@ -1081,6 +1083,14 @@ export function UniversalFormModalComponent({
// 공통 필드 병합 + 개별 품목 데이터
const itemToSave = { ...commonFieldsData, ...item };
+ // saveToTarget: false인 컬럼은 저장에서 제외
+ const columns = section.tableConfig?.columns || [];
+ for (const col of columns) {
+ if (col.saveConfig?.saveToTarget === false && col.field in itemToSave) {
+ delete itemToSave[col.field];
+ }
+ }
+
// 메인 레코드와 연결이 필요한 경우
if (mainRecordId && config.saveConfig.primaryKeyColumn) {
itemToSave[config.saveConfig.primaryKeyColumn] = mainRecordId;
@@ -1680,11 +1690,13 @@ export function UniversalFormModalComponent({
{sourceData.length > 0 ? (
- sourceData.map((row, index) => (
-
- {getDisplayText(row)}
-
- ))
+ sourceData
+ .filter((row) => row[valueColumn] !== null && row[valueColumn] !== undefined && String(row[valueColumn]) !== "")
+ .map((row, index) => (
+
+ {getDisplayText(row)}
+
+ ))
) : (
{cachedData === undefined ? "데이터를 불러오는 중..." : "데이터가 없습니다"}
@@ -2345,11 +2357,13 @@ function SelectField({ fieldId, value, onChange, optionConfig, placeholder, disa
- {options.map((option) => (
-
- {option.label}
-
- ))}
+ {options
+ .filter((option) => option.value && option.value !== "")
+ .map((option) => (
+
+ {option.label}
+
+ ))}
);
diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx
index 61ad2016..27af68f1 100644
--- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx
@@ -728,13 +728,13 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
{/* 테이블 컬럼 목록 (테이블 타입만) */}
{section.type === "table" && section.tableConfig?.columns && section.tableConfig.columns.length > 0 && (
- {section.tableConfig.columns.slice(0, 4).map((col) => (
+ {section.tableConfig.columns.slice(0, 4).map((col, idx) => (
- {col.label}
+ {col.label || col.field || `컬럼 ${idx + 1}`}
))}
{section.tableConfig.columns.length > 4 && (
diff --git a/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx
index f8b77aba..ebd16c44 100644
--- a/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx
@@ -450,10 +450,13 @@ function ColumnSettingItem({
variant="outline"
role="combobox"
aria-expanded={fieldSearchOpen}
- className="h-8 w-full justify-between text-xs mt-1"
+ className={cn(
+ "h-8 w-full justify-between text-xs mt-1",
+ !col.field && "text-muted-foreground"
+ )}
>
- {col.field || "필드 선택..."}
+ {col.field || "(저장 안 함)"}
@@ -466,6 +469,25 @@ function ColumnSettingItem({
필드를 찾을 수 없습니다.
+ {/* 선택 안 함 옵션 */}
+ {
+ onUpdate({ field: "" });
+ setFieldSearchOpen(false);
+ }}
+ className="text-xs text-muted-foreground"
+ >
+
+ (선택 안 함 - 저장하지 않음)
+
+ {/* 실제 컬럼 목록 */}
{saveTableColumns.map((column) => (
)}
+
+ {/* ============================================ */}
+ {/* 저장 설정 섹션 */}
+ {/* ============================================ */}
+
+
+ 저장 설정
+
+
+ 이 컬럼의 값을 DB에 저장할지 설정합니다.
+
+
+ {/* 저장 여부 라디오 버튼 */}
+
+ {/* 저장함 옵션 */}
+
+ {
+ onUpdate({
+ saveConfig: {
+ saveToTarget: true,
+ },
+ });
+ }}
+ className="mt-0.5"
+ />
+
+
저장함 (기본)
+
+ 사용자가 입력/선택한 값이 DB에 저장됩니다.
+
+
+
+
+ {/* 저장 안 함 옵션 */}
+
+ {
+ onUpdate({
+ saveConfig: {
+ saveToTarget: false,
+ referenceDisplay: {
+ referenceIdField: "",
+ sourceColumn: "",
+ },
+ },
+ });
+ }}
+ className="mt-0.5"
+ />
+
+
저장 안 함 - 참조만 표시
+
+ 다른 컬럼의 ID로 소스 테이블을 조회해서 표시만 합니다.
+
+
+
+
+
+ {/* 참조 설정 패널 (저장 안 함 선택 시) */}
+ {col.saveConfig?.saveToTarget === false && (
+
+
+
+ 참조 설정
+
+
+ {/* Step 1: ID 컬럼 선택 */}
+
+
+ 1. 어떤 ID 컬럼을 기준으로 조회할까요?
+
+
{
+ onUpdate({
+ saveConfig: {
+ ...col.saveConfig!,
+ referenceDisplay: {
+ ...col.saveConfig!.referenceDisplay!,
+ referenceIdField: value,
+ },
+ },
+ });
+ }}
+ >
+
+
+
+
+ {(tableConfig.columns || [])
+ .filter((c) => c.field !== col.field) // 현재 컬럼 제외
+ .map((c) => (
+
+ {c.label || c.field}
+
+ ))}
+
+
+
+ 이 컬럼에 저장된 ID로 소스 테이블을 조회합니다.
+
+
+
+ {/* Step 2: 소스 컬럼 선택 */}
+
+
+ 2. 소스 테이블의 어떤 컬럼 값을 표시할까요?
+
+ {sourceTableColumns.length > 0 ? (
+
{
+ onUpdate({
+ saveConfig: {
+ ...col.saveConfig!,
+ referenceDisplay: {
+ ...col.saveConfig!.referenceDisplay!,
+ sourceColumn: value,
+ },
+ },
+ });
+ }}
+ >
+
+
+
+
+ {sourceTableColumns.map((c) => (
+
+ {c.column_name} {c.comment && `(${c.comment})`}
+
+ ))}
+
+
+ ) : (
+
{
+ onUpdate({
+ saveConfig: {
+ ...col.saveConfig!,
+ referenceDisplay: {
+ ...col.saveConfig!.referenceDisplay!,
+ sourceColumn: e.target.value,
+ },
+ },
+ });
+ }}
+ placeholder="소스 컬럼명 입력"
+ className="h-7 text-xs"
+ />
+ )}
+
+ 조회된 행에서 이 컬럼의 값을 화면에 표시합니다.
+
+
+
+ {/* 설정 요약 */}
+ {col.saveConfig?.referenceDisplay?.referenceIdField && col.saveConfig?.referenceDisplay?.sourceColumn && (
+
+ 설정 요약:
+
+ - 이 컬럼({col.label || col.field})은 저장되지 않습니다.
+
+ - 수정 화면에서 {col.saveConfig.referenceDisplay.referenceIdField} 로{" "}
+ {sourceTableName} 테이블을 조회하여{" "}
+ {col.saveConfig.referenceDisplay.sourceColumn} 값을 표시합니다.
+
+ )}
+
+ )}
+
);
}
@@ -2826,11 +3027,13 @@ export function TableSectionSettingsModal({
컬럼 설정에서 먼저 컬럼을 추가하세요
) : (
- (tableConfig.columns || []).map((col) => (
-
- {col.label || col.field}
-
- ))
+ (tableConfig.columns || [])
+ .filter((col) => col.field) // 빈 필드명 제외
+ .map((col, idx) => (
+
+ {col.label || col.field}
+
+ ))
)}
diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts
index 31388e96..1f2015eb 100644
--- a/frontend/lib/registry/components/universal-form-modal/types.ts
+++ b/frontend/lib/registry/components/universal-form-modal/types.ts
@@ -426,6 +426,31 @@ export interface TableColumnConfig {
// 부모에서 값 받기 (모든 행에 동일한 값 적용)
receiveFromParent?: boolean; // 부모에서 값 받기 활성화
parentFieldName?: string; // 부모 필드명 (미지정 시 field와 동일)
+
+ // 저장 설정 (컬럼별 저장 여부 및 참조 표시)
+ saveConfig?: TableColumnSaveConfig;
+}
+
+/**
+ * 테이블 컬럼 저장 설정
+ * - 컬럼별로 저장 여부를 설정하고, 저장하지 않는 컬럼은 참조 ID로 조회하여 표시
+ */
+export interface TableColumnSaveConfig {
+ // 저장 여부 (기본값: true)
+ // true: 사용자가 입력/선택한 값을 DB에 저장
+ // false: 저장하지 않고, 참조 ID로 소스 테이블을 조회하여 표시만 함
+ saveToTarget: boolean;
+
+ // 참조 표시 설정 (saveToTarget이 false일 때 사용)
+ referenceDisplay?: {
+ // 참조할 ID 컬럼 (같은 테이블 내의 다른 컬럼)
+ // 예: "inspection_standard_id"
+ referenceIdField: string;
+
+ // 소스 테이블에서 가져올 컬럼
+ // 예: "inspection_item" → 소스 테이블의 inspection_item 값을 표시
+ sourceColumn: string;
+ };
}
// ============================================
From 87caa4b3caaf3a8dfc0ee6ae565e1f6efeef6bd9 Mon Sep 17 00:00:00 2001
From: DDD1542
Date: Mon, 29 Dec 2025 17:56:26 +0900
Subject: [PATCH 13/28] =?UTF-8?q?=EC=BB=A4=EB=B0=8B=20=EB=A9=94=EC=84=B8?=
=?UTF-8?q?=EC=A7=80=20=EB=A9=94=EB=89=B4=EB=B3=84=20=EB=8C=80=EC=A4=91?=
=?UTF-8?q?=EC=86=8C=20=EC=A0=95=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../batchmngList}/create/page.tsx | 0
.../batchmngList}/edit/[id]/page.tsx | 0
.../{batchmng => automaticMng/batchmngList}/page.tsx | 0
.../exCallConfList}/page.tsx | 0
.../flowMgmtList}/[id]/page.tsx | 0
.../flowMgmtList}/page.tsx | 0
.../admin/{ => automaticMng}/mail/accounts/page.tsx | 0
.../admin/{ => automaticMng}/mail/bulk-send/page.tsx | 0
.../mail/dashboardList}/page.tsx | 0
.../admin/{ => automaticMng}/mail/drafts/page.tsx | 2 +-
.../admin/{ => automaticMng}/mail/receive/page.tsx | 0
.../(main)/admin/{ => automaticMng}/mail/send/page.tsx | 2 +-
.../(main)/admin/{ => automaticMng}/mail/sent/page.tsx | 2 +-
.../admin/{ => automaticMng}/mail/templates/page.tsx | 0
.../admin/{ => automaticMng}/mail/trash/page.tsx | 0
.../admin/{external-connections => exconList}/page.tsx | 0
frontend/app/(main)/admin/page.tsx | 4 ++--
.../dashboardList}/DashboardListClient.tsx | 10 +++++-----
.../dashboardList}/edit/[id]/page.tsx | 0
.../dashboardList}/new/page.tsx | 0
.../{dashboard => screenMng/dashboardList}/page.tsx | 2 +-
.../reportList}/designer/[reportId]/page.tsx | 4 ++--
.../admin/{report => screenMng/reportList}/page.tsx | 2 +-
.../admin/screenMng/{ => screenMngList}/page.tsx | 0
.../collection-managementList}/page.tsx | 0
.../{commonCode => systemMng/commonCodeList}/page.tsx | 0
.../{ => systemMng}/dataflow/edit/[diagramId]/page.tsx | 0
.../dataflow/node-editorList}/page.tsx | 0
.../app/(main)/admin/{ => systemMng}/dataflow/page.tsx | 0
.../(main)/admin/{i18n => systemMng/i18nList}/page.tsx | 0
.../{tableMng => systemMng/tableMngList}/page.tsx | 0
.../companyList}/[companyCode]/departments/page.tsx | 0
.../admin/{company => userMng/companyList}/page.tsx | 0
.../admin/{roles => userMng/rolesList}/[id]/page.tsx | 0
.../(main)/admin/{roles => userMng/rolesList}/page.tsx | 0
.../admin/{userAuth => userMng/userAuthList}/page.tsx | 0
.../(main)/admin/userMng/{ => userMngList}/page.tsx | 0
frontend/app/(main)/dashboard/[dashboardId]/page.tsx | 2 +-
frontend/app/(main)/dashboard/page.tsx | 6 +++---
frontend/components/admin/CompanyTable.tsx | 2 +-
frontend/components/admin/RoleDetailManagement.tsx | 4 ++--
frontend/components/admin/RoleManagement.tsx | 2 +-
.../components/admin/dashboard/DashboardDesigner.tsx | 4 ++--
.../admin/dashboard/data-sources/DatabaseConfig.tsx | 4 ++--
.../admin/department/DepartmentManagement.tsx | 2 +-
frontend/components/mail/MailDetailModal.tsx | 4 ++--
frontend/components/report/ReportListTable.tsx | 2 +-
.../report/designer/ReportDesignerToolbar.tsx | 4 ++--
48 files changed, 32 insertions(+), 32 deletions(-)
rename frontend/app/(main)/admin/{batchmng => automaticMng/batchmngList}/create/page.tsx (100%)
rename frontend/app/(main)/admin/{batchmng => automaticMng/batchmngList}/edit/[id]/page.tsx (100%)
rename frontend/app/(main)/admin/{batchmng => automaticMng/batchmngList}/page.tsx (100%)
rename frontend/app/(main)/admin/{external-call-configs => automaticMng/exCallConfList}/page.tsx (100%)
rename frontend/app/(main)/admin/{flow-management => automaticMng/flowMgmtList}/[id]/page.tsx (100%)
rename frontend/app/(main)/admin/{flow-management => automaticMng/flowMgmtList}/page.tsx (100%)
rename frontend/app/(main)/admin/{ => automaticMng}/mail/accounts/page.tsx (100%)
rename frontend/app/(main)/admin/{ => automaticMng}/mail/bulk-send/page.tsx (100%)
rename frontend/app/(main)/admin/{mail/dashboard => automaticMng/mail/dashboardList}/page.tsx (100%)
rename frontend/app/(main)/admin/{ => automaticMng}/mail/drafts/page.tsx (99%)
rename frontend/app/(main)/admin/{ => automaticMng}/mail/receive/page.tsx (100%)
rename frontend/app/(main)/admin/{ => automaticMng}/mail/send/page.tsx (99%)
rename frontend/app/(main)/admin/{ => automaticMng}/mail/sent/page.tsx (99%)
rename frontend/app/(main)/admin/{ => automaticMng}/mail/templates/page.tsx (100%)
rename frontend/app/(main)/admin/{ => automaticMng}/mail/trash/page.tsx (100%)
rename frontend/app/(main)/admin/{external-connections => exconList}/page.tsx (100%)
rename frontend/app/(main)/admin/{dashboard => screenMng/dashboardList}/DashboardListClient.tsx (97%)
rename frontend/app/(main)/admin/{dashboard => screenMng/dashboardList}/edit/[id]/page.tsx (100%)
rename frontend/app/(main)/admin/{dashboard => screenMng/dashboardList}/new/page.tsx (100%)
rename frontend/app/(main)/admin/{dashboard => screenMng/dashboardList}/page.tsx (87%)
rename frontend/app/(main)/admin/{report => screenMng/reportList}/designer/[reportId]/page.tsx (96%)
rename frontend/app/(main)/admin/{report => screenMng/reportList}/page.tsx (98%)
rename frontend/app/(main)/admin/screenMng/{ => screenMngList}/page.tsx (100%)
rename frontend/app/(main)/admin/{collection-management => systemMng/collection-managementList}/page.tsx (100%)
rename frontend/app/(main)/admin/{commonCode => systemMng/commonCodeList}/page.tsx (100%)
rename frontend/app/(main)/admin/{ => systemMng}/dataflow/edit/[diagramId]/page.tsx (100%)
rename frontend/app/(main)/admin/{dataflow/node-editor => systemMng/dataflow/node-editorList}/page.tsx (100%)
rename frontend/app/(main)/admin/{ => systemMng}/dataflow/page.tsx (100%)
rename frontend/app/(main)/admin/{i18n => systemMng/i18nList}/page.tsx (100%)
rename frontend/app/(main)/admin/{tableMng => systemMng/tableMngList}/page.tsx (100%)
rename frontend/app/(main)/admin/{company => userMng/companyList}/[companyCode]/departments/page.tsx (100%)
rename frontend/app/(main)/admin/{company => userMng/companyList}/page.tsx (100%)
rename frontend/app/(main)/admin/{roles => userMng/rolesList}/[id]/page.tsx (100%)
rename frontend/app/(main)/admin/{roles => userMng/rolesList}/page.tsx (100%)
rename frontend/app/(main)/admin/{userAuth => userMng/userAuthList}/page.tsx (100%)
rename frontend/app/(main)/admin/userMng/{ => userMngList}/page.tsx (100%)
diff --git a/frontend/app/(main)/admin/batchmng/create/page.tsx b/frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx
similarity index 100%
rename from frontend/app/(main)/admin/batchmng/create/page.tsx
rename to frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx
diff --git a/frontend/app/(main)/admin/batchmng/edit/[id]/page.tsx b/frontend/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page.tsx
similarity index 100%
rename from frontend/app/(main)/admin/batchmng/edit/[id]/page.tsx
rename to frontend/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page.tsx
diff --git a/frontend/app/(main)/admin/batchmng/page.tsx b/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx
similarity index 100%
rename from frontend/app/(main)/admin/batchmng/page.tsx
rename to frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx
diff --git a/frontend/app/(main)/admin/external-call-configs/page.tsx b/frontend/app/(main)/admin/automaticMng/exCallConfList/page.tsx
similarity index 100%
rename from frontend/app/(main)/admin/external-call-configs/page.tsx
rename to frontend/app/(main)/admin/automaticMng/exCallConfList/page.tsx
diff --git a/frontend/app/(main)/admin/flow-management/[id]/page.tsx b/frontend/app/(main)/admin/automaticMng/flowMgmtList/[id]/page.tsx
similarity index 100%
rename from frontend/app/(main)/admin/flow-management/[id]/page.tsx
rename to frontend/app/(main)/admin/automaticMng/flowMgmtList/[id]/page.tsx
diff --git a/frontend/app/(main)/admin/flow-management/page.tsx b/frontend/app/(main)/admin/automaticMng/flowMgmtList/page.tsx
similarity index 100%
rename from frontend/app/(main)/admin/flow-management/page.tsx
rename to frontend/app/(main)/admin/automaticMng/flowMgmtList/page.tsx
diff --git a/frontend/app/(main)/admin/mail/accounts/page.tsx b/frontend/app/(main)/admin/automaticMng/mail/accounts/page.tsx
similarity index 100%
rename from frontend/app/(main)/admin/mail/accounts/page.tsx
rename to frontend/app/(main)/admin/automaticMng/mail/accounts/page.tsx
diff --git a/frontend/app/(main)/admin/mail/bulk-send/page.tsx b/frontend/app/(main)/admin/automaticMng/mail/bulk-send/page.tsx
similarity index 100%
rename from frontend/app/(main)/admin/mail/bulk-send/page.tsx
rename to frontend/app/(main)/admin/automaticMng/mail/bulk-send/page.tsx
diff --git a/frontend/app/(main)/admin/mail/dashboard/page.tsx b/frontend/app/(main)/admin/automaticMng/mail/dashboardList/page.tsx
similarity index 100%
rename from frontend/app/(main)/admin/mail/dashboard/page.tsx
rename to frontend/app/(main)/admin/automaticMng/mail/dashboardList/page.tsx
diff --git a/frontend/app/(main)/admin/mail/drafts/page.tsx b/frontend/app/(main)/admin/automaticMng/mail/drafts/page.tsx
similarity index 99%
rename from frontend/app/(main)/admin/mail/drafts/page.tsx
rename to frontend/app/(main)/admin/automaticMng/mail/drafts/page.tsx
index c96129c1..e098352f 100644
--- a/frontend/app/(main)/admin/mail/drafts/page.tsx
+++ b/frontend/app/(main)/admin/automaticMng/mail/drafts/page.tsx
@@ -51,7 +51,7 @@ export default function DraftsPage() {
content: draft.htmlContent,
accountId: draft.accountId,
});
- router.push(`/admin/mail/send?${params.toString()}`);
+ router.push(`/admin/automaticMng/mail/send?${params.toString()}`);
};
const handleDelete = async (id: string) => {
diff --git a/frontend/app/(main)/admin/mail/receive/page.tsx b/frontend/app/(main)/admin/automaticMng/mail/receive/page.tsx
similarity index 100%
rename from frontend/app/(main)/admin/mail/receive/page.tsx
rename to frontend/app/(main)/admin/automaticMng/mail/receive/page.tsx
diff --git a/frontend/app/(main)/admin/mail/send/page.tsx b/frontend/app/(main)/admin/automaticMng/mail/send/page.tsx
similarity index 99%
rename from frontend/app/(main)/admin/mail/send/page.tsx
rename to frontend/app/(main)/admin/automaticMng/mail/send/page.tsx
index 56922043..cdd8feae 100644
--- a/frontend/app/(main)/admin/mail/send/page.tsx
+++ b/frontend/app/(main)/admin/automaticMng/mail/send/page.tsx
@@ -1056,7 +1056,7 @@ ${data.originalBody}`;
router.push(`/admin/mail/templates`)}
+ onClick={() => router.push(`/admin/automaticMng/mail/templates`)}
className="flex items-center gap-1"
>
diff --git a/frontend/app/(main)/admin/mail/sent/page.tsx b/frontend/app/(main)/admin/automaticMng/mail/sent/page.tsx
similarity index 99%
rename from frontend/app/(main)/admin/mail/sent/page.tsx
rename to frontend/app/(main)/admin/automaticMng/mail/sent/page.tsx
index 4337b5bd..229217d2 100644
--- a/frontend/app/(main)/admin/mail/sent/page.tsx
+++ b/frontend/app/(main)/admin/automaticMng/mail/sent/page.tsx
@@ -336,7 +336,7 @@ export default function SentMailPage() {
새로고침
- router.push("/admin/mail/send")} size="sm">
+ router.push("/admin/automaticMng/mail/send")} size="sm">
메일 작성
diff --git a/frontend/app/(main)/admin/mail/templates/page.tsx b/frontend/app/(main)/admin/automaticMng/mail/templates/page.tsx
similarity index 100%
rename from frontend/app/(main)/admin/mail/templates/page.tsx
rename to frontend/app/(main)/admin/automaticMng/mail/templates/page.tsx
diff --git a/frontend/app/(main)/admin/mail/trash/page.tsx b/frontend/app/(main)/admin/automaticMng/mail/trash/page.tsx
similarity index 100%
rename from frontend/app/(main)/admin/mail/trash/page.tsx
rename to frontend/app/(main)/admin/automaticMng/mail/trash/page.tsx
diff --git a/frontend/app/(main)/admin/external-connections/page.tsx b/frontend/app/(main)/admin/exconList/page.tsx
similarity index 100%
rename from frontend/app/(main)/admin/external-connections/page.tsx
rename to frontend/app/(main)/admin/exconList/page.tsx
diff --git a/frontend/app/(main)/admin/page.tsx b/frontend/app/(main)/admin/page.tsx
index f8d5d8d6..3060e0fc 100644
--- a/frontend/app/(main)/admin/page.tsx
+++ b/frontend/app/(main)/admin/page.tsx
@@ -168,7 +168,7 @@ export default function AdminPage() {
-
+
@@ -182,7 +182,7 @@ export default function AdminPage() {
-
+
diff --git a/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx b/frontend/app/(main)/admin/screenMng/dashboardList/DashboardListClient.tsx
similarity index 97%
rename from frontend/app/(main)/admin/dashboard/DashboardListClient.tsx
rename to frontend/app/(main)/admin/screenMng/dashboardList/DashboardListClient.tsx
index 613ab16b..c50aaa51 100644
--- a/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx
+++ b/frontend/app/(main)/admin/screenMng/dashboardList/DashboardListClient.tsx
@@ -180,7 +180,7 @@ export default function DashboardListClient() {
총 {totalCount.toLocaleString()} 건
-
router.push("/admin/dashboard/new")} className="h-10 gap-2 text-sm font-medium">
+ router.push("/admin/screenMng/dashboardList/new")} className="h-10 gap-2 text-sm font-medium">
새 대시보드 생성
@@ -292,7 +292,7 @@ export default function DashboardListClient() {
router.push(`/admin/dashboard/edit/${dashboard.id}`)}
+ onClick={() => router.push(`/admin/screenMng/dashboardList/edit/${dashboard.id}`)}
className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
>
{dashboard.title}
@@ -319,7 +319,7 @@ export default function DashboardListClient() {
router.push(`/admin/dashboard/edit/${dashboard.id}`)}
+ onClick={() => router.push(`/admin/screenMng/dashboardList/edit/${dashboard.id}`)}
className="gap-2 text-sm"
>
@@ -356,7 +356,7 @@ export default function DashboardListClient() {
router.push(`/admin/dashboard/edit/${dashboard.id}`)}
+ onClick={() => router.push(`/admin/screenMng/dashboardList/edit/${dashboard.id}`)}
className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
>
{dashboard.title}
@@ -391,7 +391,7 @@ export default function DashboardListClient() {
variant="outline"
size="sm"
className="h-9 flex-1 gap-2 text-sm"
- onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
+ onClick={() => router.push(`/admin/screenMng/dashboardList/edit/${dashboard.id}`)}
>
편집
diff --git a/frontend/app/(main)/admin/dashboard/edit/[id]/page.tsx b/frontend/app/(main)/admin/screenMng/dashboardList/edit/[id]/page.tsx
similarity index 100%
rename from frontend/app/(main)/admin/dashboard/edit/[id]/page.tsx
rename to frontend/app/(main)/admin/screenMng/dashboardList/edit/[id]/page.tsx
diff --git a/frontend/app/(main)/admin/dashboard/new/page.tsx b/frontend/app/(main)/admin/screenMng/dashboardList/new/page.tsx
similarity index 100%
rename from frontend/app/(main)/admin/dashboard/new/page.tsx
rename to frontend/app/(main)/admin/screenMng/dashboardList/new/page.tsx
diff --git a/frontend/app/(main)/admin/dashboard/page.tsx b/frontend/app/(main)/admin/screenMng/dashboardList/page.tsx
similarity index 87%
rename from frontend/app/(main)/admin/dashboard/page.tsx
rename to frontend/app/(main)/admin/screenMng/dashboardList/page.tsx
index 7d09bafc..62587c54 100644
--- a/frontend/app/(main)/admin/dashboard/page.tsx
+++ b/frontend/app/(main)/admin/screenMng/dashboardList/page.tsx
@@ -1,4 +1,4 @@
-import DashboardListClient from "@/app/(main)/admin/dashboard/DashboardListClient";
+import DashboardListClient from "@/app/(main)/admin/screenMng/dashboardList/DashboardListClient";
/**
* 대시보드 관리 페이지
diff --git a/frontend/app/(main)/admin/report/designer/[reportId]/page.tsx b/frontend/app/(main)/admin/screenMng/reportList/designer/[reportId]/page.tsx
similarity index 96%
rename from frontend/app/(main)/admin/report/designer/[reportId]/page.tsx
rename to frontend/app/(main)/admin/screenMng/reportList/designer/[reportId]/page.tsx
index 03d5bcd9..55972a18 100644
--- a/frontend/app/(main)/admin/report/designer/[reportId]/page.tsx
+++ b/frontend/app/(main)/admin/screenMng/reportList/designer/[reportId]/page.tsx
@@ -37,7 +37,7 @@ export default function ReportDesignerPage() {
description: "리포트를 찾을 수 없습니다.",
variant: "destructive",
});
- router.push("/admin/report");
+ router.push("/admin/screenMng/reportList");
}
} catch (error: any) {
toast({
@@ -45,7 +45,7 @@ export default function ReportDesignerPage() {
description: error.message || "리포트를 불러오는데 실패했습니다.",
variant: "destructive",
});
- router.push("/admin/report");
+ router.push("/admin/screenMng/reportList");
} finally {
setIsLoading(false);
}
diff --git a/frontend/app/(main)/admin/report/page.tsx b/frontend/app/(main)/admin/screenMng/reportList/page.tsx
similarity index 98%
rename from frontend/app/(main)/admin/report/page.tsx
rename to frontend/app/(main)/admin/screenMng/reportList/page.tsx
index 37270683..4b3816be 100644
--- a/frontend/app/(main)/admin/report/page.tsx
+++ b/frontend/app/(main)/admin/screenMng/reportList/page.tsx
@@ -26,7 +26,7 @@ export default function ReportManagementPage() {
const handleCreateNew = () => {
// 새 리포트는 'new'라는 특수 ID로 디자이너 진입
- router.push("/admin/report/designer/new");
+ router.push("/admin/screenMng/reportList/designer/new");
};
return (
diff --git a/frontend/app/(main)/admin/screenMng/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx
similarity index 100%
rename from frontend/app/(main)/admin/screenMng/page.tsx
rename to frontend/app/(main)/admin/screenMng/screenMngList/page.tsx
diff --git a/frontend/app/(main)/admin/collection-management/page.tsx b/frontend/app/(main)/admin/systemMng/collection-managementList/page.tsx
similarity index 100%
rename from frontend/app/(main)/admin/collection-management/page.tsx
rename to frontend/app/(main)/admin/systemMng/collection-managementList/page.tsx
diff --git a/frontend/app/(main)/admin/commonCode/page.tsx b/frontend/app/(main)/admin/systemMng/commonCodeList/page.tsx
similarity index 100%
rename from frontend/app/(main)/admin/commonCode/page.tsx
rename to frontend/app/(main)/admin/systemMng/commonCodeList/page.tsx
diff --git a/frontend/app/(main)/admin/dataflow/edit/[diagramId]/page.tsx b/frontend/app/(main)/admin/systemMng/dataflow/edit/[diagramId]/page.tsx
similarity index 100%
rename from frontend/app/(main)/admin/dataflow/edit/[diagramId]/page.tsx
rename to frontend/app/(main)/admin/systemMng/dataflow/edit/[diagramId]/page.tsx
diff --git a/frontend/app/(main)/admin/dataflow/node-editor/page.tsx b/frontend/app/(main)/admin/systemMng/dataflow/node-editorList/page.tsx
similarity index 100%
rename from frontend/app/(main)/admin/dataflow/node-editor/page.tsx
rename to frontend/app/(main)/admin/systemMng/dataflow/node-editorList/page.tsx
diff --git a/frontend/app/(main)/admin/dataflow/page.tsx b/frontend/app/(main)/admin/systemMng/dataflow/page.tsx
similarity index 100%
rename from frontend/app/(main)/admin/dataflow/page.tsx
rename to frontend/app/(main)/admin/systemMng/dataflow/page.tsx
diff --git a/frontend/app/(main)/admin/i18n/page.tsx b/frontend/app/(main)/admin/systemMng/i18nList/page.tsx
similarity index 100%
rename from frontend/app/(main)/admin/i18n/page.tsx
rename to frontend/app/(main)/admin/systemMng/i18nList/page.tsx
diff --git a/frontend/app/(main)/admin/tableMng/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx
similarity index 100%
rename from frontend/app/(main)/admin/tableMng/page.tsx
rename to frontend/app/(main)/admin/systemMng/tableMngList/page.tsx
diff --git a/frontend/app/(main)/admin/company/[companyCode]/departments/page.tsx b/frontend/app/(main)/admin/userMng/companyList/[companyCode]/departments/page.tsx
similarity index 100%
rename from frontend/app/(main)/admin/company/[companyCode]/departments/page.tsx
rename to frontend/app/(main)/admin/userMng/companyList/[companyCode]/departments/page.tsx
diff --git a/frontend/app/(main)/admin/company/page.tsx b/frontend/app/(main)/admin/userMng/companyList/page.tsx
similarity index 100%
rename from frontend/app/(main)/admin/company/page.tsx
rename to frontend/app/(main)/admin/userMng/companyList/page.tsx
diff --git a/frontend/app/(main)/admin/roles/[id]/page.tsx b/frontend/app/(main)/admin/userMng/rolesList/[id]/page.tsx
similarity index 100%
rename from frontend/app/(main)/admin/roles/[id]/page.tsx
rename to frontend/app/(main)/admin/userMng/rolesList/[id]/page.tsx
diff --git a/frontend/app/(main)/admin/roles/page.tsx b/frontend/app/(main)/admin/userMng/rolesList/page.tsx
similarity index 100%
rename from frontend/app/(main)/admin/roles/page.tsx
rename to frontend/app/(main)/admin/userMng/rolesList/page.tsx
diff --git a/frontend/app/(main)/admin/userAuth/page.tsx b/frontend/app/(main)/admin/userMng/userAuthList/page.tsx
similarity index 100%
rename from frontend/app/(main)/admin/userAuth/page.tsx
rename to frontend/app/(main)/admin/userMng/userAuthList/page.tsx
diff --git a/frontend/app/(main)/admin/userMng/page.tsx b/frontend/app/(main)/admin/userMng/userMngList/page.tsx
similarity index 100%
rename from frontend/app/(main)/admin/userMng/page.tsx
rename to frontend/app/(main)/admin/userMng/userMngList/page.tsx
diff --git a/frontend/app/(main)/dashboard/[dashboardId]/page.tsx b/frontend/app/(main)/dashboard/[dashboardId]/page.tsx
index 16827791..44ca30b2 100644
--- a/frontend/app/(main)/dashboard/[dashboardId]/page.tsx
+++ b/frontend/app/(main)/dashboard/[dashboardId]/page.tsx
@@ -142,7 +142,7 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
{/* 편집 버튼 *\/}
{
- router.push(`/admin/dashboard?load=${resolvedParams.dashboardId}`);
+ router.push(`/admin/screenMng/dashboardList?load=${resolvedParams.dashboardId}`);
}}
className="rounded-lg bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
>
diff --git a/frontend/app/(main)/dashboard/page.tsx b/frontend/app/(main)/dashboard/page.tsx
index 5130d203..3725511d 100644
--- a/frontend/app/(main)/dashboard/page.tsx
+++ b/frontend/app/(main)/dashboard/page.tsx
@@ -130,7 +130,7 @@ export default function DashboardListPage() {
➕ 새 대시보드 만들기
@@ -185,7 +185,7 @@ export default function DashboardListPage() {
{!searchTerm && (
➕ 대시보드 만들기
@@ -251,7 +251,7 @@ function DashboardCard({ dashboard }: DashboardCardProps) {
보기
편집
diff --git a/frontend/components/admin/CompanyTable.tsx b/frontend/components/admin/CompanyTable.tsx
index b36a757b..9c253765 100644
--- a/frontend/components/admin/CompanyTable.tsx
+++ b/frontend/components/admin/CompanyTable.tsx
@@ -22,7 +22,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
// 부서 관리 페이지로 이동
const handleManageDepartments = (company: Company) => {
- router.push(`/admin/company/${company.company_code}/departments`);
+ router.push(`/admin/userMng/companyList/${company.company_code}/departments`);
};
// 디스크 사용량 포맷팅 함수
diff --git a/frontend/components/admin/RoleDetailManagement.tsx b/frontend/components/admin/RoleDetailManagement.tsx
index 27a6c07d..92d03143 100644
--- a/frontend/components/admin/RoleDetailManagement.tsx
+++ b/frontend/components/admin/RoleDetailManagement.tsx
@@ -236,7 +236,7 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
오류 발생
{error || "권한 그룹을 찾을 수 없습니다."}
-
router.push("/admin/roles")}>
+ router.push("/admin/userMng/rolesList")}>
목록으로 돌아가기
@@ -248,7 +248,7 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
{/* 페이지 헤더 */}
-
router.push("/admin/roles")} className="h-10 w-10">
+ router.push("/admin/userMng/rolesList")} className="h-10 w-10">
diff --git a/frontend/components/admin/RoleManagement.tsx b/frontend/components/admin/RoleManagement.tsx
index fe527fe4..3834b2a4 100644
--- a/frontend/components/admin/RoleManagement.tsx
+++ b/frontend/components/admin/RoleManagement.tsx
@@ -141,7 +141,7 @@ export function RoleManagement() {
// 상세 페이지로 이동
const handleViewDetail = useCallback(
(role: RoleGroup) => {
- router.push(`/admin/roles/${role.objid}`);
+ router.push(`/admin/userMng/rolesList/${role.objid}`);
},
[router],
);
diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx
index 08296fd1..b945cb3d 100644
--- a/frontend/components/admin/dashboard/DashboardDesigner.tsx
+++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx
@@ -643,7 +643,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
open={successModalOpen}
onOpenChange={() => {
setSuccessModalOpen(false);
- router.push("/admin/dashboard");
+ router.push("/admin/screenMng/dashboardList");
}}
>
@@ -660,7 +660,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
{
setSuccessModalOpen(false);
- router.push("/admin/dashboard");
+ router.push("/admin/screenMng/dashboardList");
}}
>
확인
diff --git a/frontend/components/admin/dashboard/data-sources/DatabaseConfig.tsx b/frontend/components/admin/dashboard/data-sources/DatabaseConfig.tsx
index 8ca94e30..db0ff9d1 100644
--- a/frontend/components/admin/dashboard/data-sources/DatabaseConfig.tsx
+++ b/frontend/components/admin/dashboard/data-sources/DatabaseConfig.tsx
@@ -91,7 +91,7 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
외부 커넥션
{
- router.push("/admin/external-connections");
+ router.push("/admin/automaticMng/exconList");
}}
className="text-primary hover:text-primary flex items-center gap-1 text-[11px] transition-colors"
>
@@ -124,7 +124,7 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
등록된 커넥션이 없습니다
{
- router.push("/admin/external-connections");
+ router.push("/admin/automaticMng/exconList");
}}
className="text-warning text-[11px] underline hover:no-underline"
>
diff --git a/frontend/components/admin/department/DepartmentManagement.tsx b/frontend/components/admin/department/DepartmentManagement.tsx
index e82be525..4939e24e 100644
--- a/frontend/components/admin/department/DepartmentManagement.tsx
+++ b/frontend/components/admin/department/DepartmentManagement.tsx
@@ -45,7 +45,7 @@ export function DepartmentManagement({ companyCode }: DepartmentManagementProps)
}, [companyCode]);
const handleBackToList = () => {
- router.push("/admin/company");
+ router.push("/admin/userMng/companyList");
};
return (
diff --git a/frontend/components/mail/MailDetailModal.tsx b/frontend/components/mail/MailDetailModal.tsx
index 0a25c2a3..31c2d7c0 100644
--- a/frontend/components/mail/MailDetailModal.tsx
+++ b/frontend/components/mail/MailDetailModal.tsx
@@ -250,7 +250,7 @@ export default function MailDetailModal({
originalDate: mail.date,
originalBody: mail.body,
};
- router.push(`/admin/mail/send?action=reply&data=${encodeURIComponent(JSON.stringify(replyData))}`);
+ router.push(`/admin/automaticMng/mail/send?action=reply&data=${encodeURIComponent(JSON.stringify(replyData))}`);
onClose();
}}
>
@@ -270,7 +270,7 @@ export default function MailDetailModal({
originalBody: mail.body,
originalAttachments: mail.attachments,
};
- router.push(`/admin/mail/send?action=forward&data=${encodeURIComponent(JSON.stringify(forwardData))}`);
+ router.push(`/admin/automaticMng/mail/send?action=forward&data=${encodeURIComponent(JSON.stringify(forwardData))}`);
onClose();
}}
>
diff --git a/frontend/components/report/ReportListTable.tsx b/frontend/components/report/ReportListTable.tsx
index f8ad96ad..0629da51 100644
--- a/frontend/components/report/ReportListTable.tsx
+++ b/frontend/components/report/ReportListTable.tsx
@@ -49,7 +49,7 @@ export function ReportListTable({
// 수정
const handleEdit = (reportId: string) => {
- router.push(`/admin/report/designer/${reportId}`);
+ router.push(`/admin/screenMng/reportList/designer/${reportId}`);
};
// 복사
diff --git a/frontend/components/report/designer/ReportDesignerToolbar.tsx b/frontend/components/report/designer/ReportDesignerToolbar.tsx
index 2b0ef7b0..acb0fc94 100644
--- a/frontend/components/report/designer/ReportDesignerToolbar.tsx
+++ b/frontend/components/report/designer/ReportDesignerToolbar.tsx
@@ -140,7 +140,7 @@ export function ReportDesignerToolbar() {
const handleMenuSelectConfirm = async (selectedMenuObjids: number[]) => {
await saveLayoutWithMenus(selectedMenuObjids);
if (pendingSaveAndClose) {
- router.push("/admin/report");
+ router.push("/admin/screenMng/reportList");
}
};
@@ -151,7 +151,7 @@ export function ReportDesignerToolbar() {
const handleBackConfirm = () => {
setShowBackConfirm(false);
- router.push("/admin/report");
+ router.push("/admin/screenMng/reportList");
};
const handleSaveAsTemplate = async (data: {
From 6476a83d861895afff6042959f5aade24c0bf9e0 Mon Sep 17 00:00:00 2001
From: DDD1542
Date: Mon, 29 Dec 2025 18:19:17 +0900
Subject: [PATCH 14/28] =?UTF-8?q?=EB=B3=80=EA=B2=BD=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/app/(main)/admin/{ => automaticMng}/exconList/page.tsx | 0
.../(main)/admin/systemMng/dataflow/node-editorList/page.tsx | 2 +-
2 files changed, 1 insertion(+), 1 deletion(-)
rename frontend/app/(main)/admin/{ => automaticMng}/exconList/page.tsx (100%)
diff --git a/frontend/app/(main)/admin/exconList/page.tsx b/frontend/app/(main)/admin/automaticMng/exconList/page.tsx
similarity index 100%
rename from frontend/app/(main)/admin/exconList/page.tsx
rename to frontend/app/(main)/admin/automaticMng/exconList/page.tsx
diff --git a/frontend/app/(main)/admin/systemMng/dataflow/node-editorList/page.tsx b/frontend/app/(main)/admin/systemMng/dataflow/node-editorList/page.tsx
index 9e1cfab6..55435ab2 100644
--- a/frontend/app/(main)/admin/systemMng/dataflow/node-editorList/page.tsx
+++ b/frontend/app/(main)/admin/systemMng/dataflow/node-editorList/page.tsx
@@ -13,7 +13,7 @@ export default function NodeEditorPage() {
useEffect(() => {
// /admin/dataflow 메인 페이지로 리다이렉트
- router.replace("/admin/dataflow");
+ router.replace("/admin/systemMng/dataflow");
}, [router]);
return (
From c32bd8a4bf4ba572260ff9be9f3c527d76e3912d Mon Sep 17 00:00:00 2001
From: kjs
Date: Tue, 30 Dec 2025 10:48:11 +0900
Subject: [PATCH 15/28] =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=83=9D?=
=?UTF-8?q?=EC=84=B1=EA=B7=9C=EC=B9=99?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.cursor/rules/table-type-sql-guide.mdc | 368 +++++++++++++++++++++++++
1 file changed, 368 insertions(+)
create mode 100644 .cursor/rules/table-type-sql-guide.mdc
diff --git a/.cursor/rules/table-type-sql-guide.mdc b/.cursor/rules/table-type-sql-guide.mdc
new file mode 100644
index 00000000..501c3218
--- /dev/null
+++ b/.cursor/rules/table-type-sql-guide.mdc
@@ -0,0 +1,368 @@
+# 테이블 타입 관리 SQL 작성 가이드
+
+테이블 타입 관리에서 테이블 생성 시 적용되는 컬럼, 타입, 메타데이터 등록 로직을 기반으로 한 SQL 작성 가이드입니다.
+
+## 핵심 원칙
+
+1. **모든 비즈니스 컬럼은 `VARCHAR(500)`로 통일**: 날짜 타입 외 모든 컬럼은 `VARCHAR(500)`
+2. **날짜/시간 컬럼만 `TIMESTAMP` 사용**: `created_date`, `updated_date` 등
+3. **기본 컬럼 5개 자동 포함**: 모든 테이블에 id, created_date, updated_date, writer, company_code 필수
+4. **3개 메타데이터 테이블 등록 필수**: `table_labels`, `column_labels`, `table_type_columns`
+
+---
+
+## 1. 테이블 생성 DDL 템플릿
+
+### 기본 구조
+
+```sql
+CREATE TABLE "테이블명" (
+ -- 시스템 기본 컬럼 (자동 포함)
+ "id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
+ "created_date" timestamp DEFAULT now(),
+ "updated_date" timestamp DEFAULT now(),
+ "writer" varchar(500) DEFAULT NULL,
+ "company_code" varchar(500),
+
+ -- 사용자 정의 컬럼 (모두 VARCHAR(500))
+ "컬럼1" varchar(500),
+ "컬럼2" varchar(500),
+ "컬럼3" varchar(500)
+);
+```
+
+### 예시: 고객 테이블 생성
+
+```sql
+CREATE TABLE "customer_info" (
+ "id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
+ "created_date" timestamp DEFAULT now(),
+ "updated_date" timestamp DEFAULT now(),
+ "writer" varchar(500) DEFAULT NULL,
+ "company_code" varchar(500),
+
+ "customer_name" varchar(500),
+ "customer_code" varchar(500),
+ "phone" varchar(500),
+ "email" varchar(500),
+ "address" varchar(500),
+ "status" varchar(500),
+ "registration_date" varchar(500)
+);
+```
+
+---
+
+## 2. 메타데이터 테이블 등록
+
+테이블 생성 시 반드시 아래 3개 테이블에 메타데이터를 등록해야 합니다.
+
+### 2.1 table_labels (테이블 메타데이터)
+
+```sql
+INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
+VALUES ('테이블명', '테이블 라벨', '테이블 설명', now(), now())
+ON CONFLICT (table_name)
+DO UPDATE SET
+ table_label = EXCLUDED.table_label,
+ description = EXCLUDED.description,
+ updated_date = now();
+```
+
+### 2.2 table_type_columns (컬럼 타입 정보)
+
+**필수 컬럼**: `table_name`, `column_name`, `company_code`, `input_type`, `display_order`
+
+```sql
+-- 기본 컬럼 등록 (display_order: -5 ~ -1)
+INSERT INTO table_type_columns (
+ table_name, column_name, company_code, input_type, detail_settings,
+ is_nullable, display_order, created_date, updated_date
+) VALUES
+ ('테이블명', 'id', '*', 'text', '{}', 'Y', -5, now(), now()),
+ ('테이블명', 'created_date', '*', 'date', '{}', 'Y', -4, now(), now()),
+ ('테이블명', 'updated_date', '*', 'date', '{}', 'Y', -3, now(), now()),
+ ('테이블명', 'writer', '*', 'text', '{}', 'Y', -2, now(), now()),
+ ('테이블명', 'company_code', '*', 'text', '{}', 'Y', -1, now(), now())
+ON CONFLICT (table_name, column_name, company_code)
+DO UPDATE SET
+ input_type = EXCLUDED.input_type,
+ display_order = EXCLUDED.display_order,
+ updated_date = now();
+
+-- 사용자 정의 컬럼 등록 (display_order: 0부터 시작)
+INSERT INTO table_type_columns (
+ table_name, column_name, company_code, input_type, detail_settings,
+ is_nullable, display_order, created_date, updated_date
+) VALUES
+ ('테이블명', '컬럼1', '*', 'text', '{}', 'Y', 0, now(), now()),
+ ('테이블명', '컬럼2', '*', 'number', '{}', 'Y', 1, now(), now()),
+ ('테이블명', '컬럼3', '*', 'code', '{"codeCategory":"카테고리코드"}', 'Y', 2, now(), now())
+ON CONFLICT (table_name, column_name, company_code)
+DO UPDATE SET
+ input_type = EXCLUDED.input_type,
+ detail_settings = EXCLUDED.detail_settings,
+ display_order = EXCLUDED.display_order,
+ updated_date = now();
+```
+
+### 2.3 column_labels (레거시 호환용 - 필수)
+
+```sql
+-- 기본 컬럼 등록
+INSERT INTO column_labels (
+ table_name, column_name, column_label, input_type, detail_settings,
+ description, display_order, is_visible, created_date, updated_date
+) VALUES
+ ('테이블명', 'id', 'ID', 'text', '{}', '기본키 (자동생성)', -5, true, now(), now()),
+ ('테이블명', 'created_date', '생성일시', 'date', '{}', '레코드 생성일시', -4, true, now(), now()),
+ ('테이블명', 'updated_date', '수정일시', 'date', '{}', '레코드 수정일시', -3, true, now(), now()),
+ ('테이블명', 'writer', '작성자', 'text', '{}', '레코드 작성자', -2, true, now(), now()),
+ ('테이블명', 'company_code', '회사코드', 'text', '{}', '회사 구분 코드', -1, true, now(), now())
+ON CONFLICT (table_name, column_name)
+DO UPDATE SET
+ column_label = EXCLUDED.column_label,
+ input_type = EXCLUDED.input_type,
+ detail_settings = EXCLUDED.detail_settings,
+ description = EXCLUDED.description,
+ display_order = EXCLUDED.display_order,
+ is_visible = EXCLUDED.is_visible,
+ updated_date = now();
+
+-- 사용자 정의 컬럼 등록
+INSERT INTO column_labels (
+ table_name, column_name, column_label, input_type, detail_settings,
+ description, display_order, is_visible, created_date, updated_date
+) VALUES
+ ('테이블명', '컬럼1', '컬럼1 라벨', 'text', '{}', '컬럼1 설명', 0, true, now(), now()),
+ ('테이블명', '컬럼2', '컬럼2 라벨', 'number', '{}', '컬럼2 설명', 1, true, now(), now())
+ON CONFLICT (table_name, column_name)
+DO UPDATE SET
+ column_label = EXCLUDED.column_label,
+ input_type = EXCLUDED.input_type,
+ detail_settings = EXCLUDED.detail_settings,
+ description = EXCLUDED.description,
+ display_order = EXCLUDED.display_order,
+ is_visible = EXCLUDED.is_visible,
+ updated_date = now();
+```
+
+---
+
+## 3. Input Type 정의
+
+### 지원되는 Input Type 목록
+
+| input_type | 설명 | DB 저장 타입 | UI 컴포넌트 |
+| ---------- | ------------- | ------------ | -------------------- |
+| `text` | 텍스트 입력 | VARCHAR(500) | Input |
+| `number` | 숫자 입력 | VARCHAR(500) | Input (type=number) |
+| `date` | 날짜/시간 | VARCHAR(500) | DatePicker |
+| `code` | 공통코드 선택 | VARCHAR(500) | Select (코드 목록) |
+| `entity` | 엔티티 참조 | VARCHAR(500) | Select (테이블 참조) |
+| `select` | 선택 목록 | VARCHAR(500) | Select |
+| `checkbox` | 체크박스 | VARCHAR(500) | Checkbox |
+| `radio` | 라디오 버튼 | VARCHAR(500) | RadioGroup |
+| `textarea` | 긴 텍스트 | VARCHAR(500) | Textarea |
+| `file` | 파일 업로드 | VARCHAR(500) | FileUpload |
+
+### WebType → InputType 변환 규칙
+
+```
+text, textarea, email, tel, url, password → text
+number, decimal → number
+date, datetime, time → date
+select, dropdown → select
+checkbox, boolean → checkbox
+radio → radio
+code → code
+entity → entity
+file → text
+button → text
+```
+
+---
+
+## 4. Detail Settings 설정
+
+### 4.1 Code 타입 (공통코드 참조)
+
+```json
+{
+ "codeCategory": "코드_카테고리_ID"
+}
+```
+
+```sql
+INSERT INTO table_type_columns (..., input_type, detail_settings, ...)
+VALUES (..., 'code', '{"codeCategory":"STATUS_CODE"}', ...);
+```
+
+### 4.2 Entity 타입 (테이블 참조)
+
+```json
+{
+ "referenceTable": "참조_테이블명",
+ "referenceColumn": "참조_컬럼명(보통 id)",
+ "displayColumn": "표시할_컬럼명"
+}
+```
+
+```sql
+INSERT INTO table_type_columns (..., input_type, detail_settings, ...)
+VALUES (..., 'entity', '{"referenceTable":"user_info","referenceColumn":"id","displayColumn":"user_name"}', ...);
+```
+
+### 4.3 Select 타입 (정적 옵션)
+
+```json
+{
+ "options": [
+ { "label": "옵션1", "value": "value1" },
+ { "label": "옵션2", "value": "value2" }
+ ]
+}
+```
+
+---
+
+## 5. 전체 예시: 주문 테이블 생성
+
+### Step 1: DDL 실행
+
+```sql
+CREATE TABLE "order_info" (
+ "id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
+ "created_date" timestamp DEFAULT now(),
+ "updated_date" timestamp DEFAULT now(),
+ "writer" varchar(500) DEFAULT NULL,
+ "company_code" varchar(500),
+
+ "order_no" varchar(500),
+ "order_date" varchar(500),
+ "customer_id" varchar(500),
+ "total_amount" varchar(500),
+ "status" varchar(500),
+ "notes" varchar(500)
+);
+```
+
+### Step 2: table_labels 등록
+
+```sql
+INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
+VALUES ('order_info', '주문 정보', '주문 관리 테이블', now(), now())
+ON CONFLICT (table_name)
+DO UPDATE SET
+ table_label = EXCLUDED.table_label,
+ description = EXCLUDED.description,
+ updated_date = now();
+```
+
+### Step 3: table_type_columns 등록
+
+```sql
+-- 기본 컬럼
+INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date)
+VALUES
+ ('order_info', 'id', '*', 'text', '{}', 'Y', -5, now(), now()),
+ ('order_info', 'created_date', '*', 'date', '{}', 'Y', -4, now(), now()),
+ ('order_info', 'updated_date', '*', 'date', '{}', 'Y', -3, now(), now()),
+ ('order_info', 'writer', '*', 'text', '{}', 'Y', -2, now(), now()),
+ ('order_info', 'company_code', '*', 'text', '{}', 'Y', -1, now(), now())
+ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = EXCLUDED.input_type, display_order = EXCLUDED.display_order, updated_date = now();
+
+-- 사용자 정의 컬럼
+INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date)
+VALUES
+ ('order_info', 'order_no', '*', 'text', '{}', 'Y', 0, now(), now()),
+ ('order_info', 'order_date', '*', 'date', '{}', 'Y', 1, now(), now()),
+ ('order_info', 'customer_id', '*', 'entity', '{"referenceTable":"customer_info","referenceColumn":"id","displayColumn":"customer_name"}', 'Y', 2, now(), now()),
+ ('order_info', 'total_amount', '*', 'number', '{}', 'Y', 3, now(), now()),
+ ('order_info', 'status', '*', 'code', '{"codeCategory":"ORDER_STATUS"}', 'Y', 4, now(), now()),
+ ('order_info', 'notes', '*', 'textarea', '{}', 'Y', 5, now(), now())
+ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = EXCLUDED.input_type, detail_settings = EXCLUDED.detail_settings, display_order = EXCLUDED.display_order, updated_date = now();
+```
+
+### Step 4: column_labels 등록 (레거시 호환)
+
+```sql
+-- 기본 컬럼
+INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, created_date, updated_date)
+VALUES
+ ('order_info', 'id', 'ID', 'text', '{}', '기본키', -5, true, now(), now()),
+ ('order_info', 'created_date', '생성일시', 'date', '{}', '레코드 생성일시', -4, true, now(), now()),
+ ('order_info', 'updated_date', '수정일시', 'date', '{}', '레코드 수정일시', -3, true, now(), now()),
+ ('order_info', 'writer', '작성자', 'text', '{}', '레코드 작성자', -2, true, now(), now()),
+ ('order_info', 'company_code', '회사코드', 'text', '{}', '회사 구분 코드', -1, true, now(), now())
+ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, updated_date = now();
+
+-- 사용자 정의 컬럼
+INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, created_date, updated_date)
+VALUES
+ ('order_info', 'order_no', '주문번호', 'text', '{}', '주문 식별 번호', 0, true, now(), now()),
+ ('order_info', 'order_date', '주문일자', 'date', '{}', '주문 발생 일자', 1, true, now(), now()),
+ ('order_info', 'customer_id', '고객', 'entity', '{"referenceTable":"customer_info","referenceColumn":"id","displayColumn":"customer_name"}', '주문 고객', 2, true, now(), now()),
+ ('order_info', 'total_amount', '총금액', 'number', '{}', '주문 총 금액', 3, true, now(), now()),
+ ('order_info', 'status', '상태', 'code', '{"codeCategory":"ORDER_STATUS"}', '주문 상태', 4, true, now(), now()),
+ ('order_info', 'notes', '비고', 'textarea', '{}', '추가 메모', 5, true, now(), now())
+ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, detail_settings = EXCLUDED.detail_settings, description = EXCLUDED.description, display_order = EXCLUDED.display_order, updated_date = now();
+```
+
+---
+
+## 6. 컬럼 추가 시
+
+### DDL
+
+```sql
+ALTER TABLE "테이블명" ADD COLUMN "새컬럼명" varchar(500);
+```
+
+### 메타데이터 등록
+
+```sql
+-- table_type_columns
+INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date)
+VALUES ('테이블명', '새컬럼명', '*', 'text', '{}', 'Y', (SELECT COALESCE(MAX(display_order), 0) + 1 FROM table_type_columns WHERE table_name = '테이블명'), now(), now())
+ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = EXCLUDED.input_type, display_order = EXCLUDED.display_order, updated_date = now();
+
+-- column_labels
+INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, created_date, updated_date)
+VALUES ('테이블명', '새컬럼명', '새컬럼 라벨', 'text', '{}', '새컬럼 설명', (SELECT COALESCE(MAX(display_order), 0) + 1 FROM column_labels WHERE table_name = '테이블명'), true, now(), now())
+ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, updated_date = now();
+```
+
+---
+
+## 7. 체크리스트
+
+테이블 생성/수정 시 반드시 확인할 사항:
+
+- [ ] DDL에 기본 5개 컬럼 포함 (id, created_date, updated_date, writer, company_code)
+- [ ] 모든 비즈니스 컬럼은 `VARCHAR(500)` 타입 사용
+- [ ] `table_labels`에 테이블 메타데이터 등록
+- [ ] `table_type_columns`에 모든 컬럼 등록 (company_code = '\*')
+- [ ] `column_labels`에 모든 컬럼 등록 (레거시 호환)
+- [ ] 기본 컬럼 display_order: -5 ~ -1
+- [ ] 사용자 정의 컬럼 display_order: 0부터 순차
+- [ ] code/entity 타입은 detail_settings에 참조 정보 포함
+- [ ] ON CONFLICT 절로 중복 시 UPDATE 처리
+
+---
+
+## 8. 금지 사항
+
+1. **DB 타입 직접 지정 금지**: NUMBER, INTEGER, DATE 등 DB 타입 직접 사용 금지
+2. **VARCHAR 길이 변경 금지**: 반드시 `VARCHAR(500)` 사용
+3. **기본 컬럼 누락 금지**: id, created_date, updated_date, writer, company_code 필수
+4. **메타데이터 미등록 금지**: 3개 테이블 모두 등록 필수
+5. **web_type 사용 금지**: 레거시 컬럼이므로 `input_type` 사용
+
+---
+
+## 참조 파일
+
+- `backend-node/src/services/ddlExecutionService.ts`: DDL 실행 서비스
+- `backend-node/src/types/ddl.ts`: DDL 타입 정의
+- `backend-node/src/controllers/ddlController.ts`: DDL API 컨트롤러
From 06d5069566ee9cde0c85adbe2047ae1aba5bda32 Mon Sep 17 00:00:00 2001
From: kjs
Date: Tue, 30 Dec 2025 10:54:06 +0900
Subject: [PATCH 16/28] =?UTF-8?q?=EB=A1=9C=EA=B7=B8=ED=85=8C=EC=9D=B4?=
=?UTF-8?q?=EB=B8=94=20=EA=B4=80=EB=A0=A8=20=EB=82=B4=EC=9A=A9=20=EC=B6=94?=
=?UTF-8?q?=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.cursor/rules/table-type-sql-guide.mdc | 230 ++++++++++++++++++++++++-
1 file changed, 227 insertions(+), 3 deletions(-)
diff --git a/.cursor/rules/table-type-sql-guide.mdc b/.cursor/rules/table-type-sql-guide.mdc
index 501c3218..3c53c537 100644
--- a/.cursor/rules/table-type-sql-guide.mdc
+++ b/.cursor/rules/table-type-sql-guide.mdc
@@ -335,9 +335,222 @@ ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.colu
---
-## 7. 체크리스트
+## 7. 로그 테이블 생성 (선택사항)
-테이블 생성/수정 시 반드시 확인할 사항:
+변경 이력 추적이 필요한 테이블에는 로그 테이블을 생성할 수 있습니다.
+
+### 7.1 로그 테이블 DDL 템플릿
+
+```sql
+-- 로그 테이블 생성
+CREATE TABLE 테이블명_log (
+ log_id SERIAL PRIMARY KEY,
+ operation_type VARCHAR(10) NOT NULL, -- INSERT/UPDATE/DELETE
+ original_id VARCHAR(100), -- 원본 테이블 PK 값
+ changed_column VARCHAR(100), -- 변경된 컬럼명
+ old_value TEXT, -- 변경 전 값
+ new_value TEXT, -- 변경 후 값
+ changed_by VARCHAR(50), -- 변경자 ID
+ changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 변경 시각
+ ip_address VARCHAR(50), -- 변경 요청 IP
+ user_agent TEXT, -- User Agent
+ full_row_before JSONB, -- 변경 전 전체 행
+ full_row_after JSONB -- 변경 후 전체 행
+);
+
+-- 인덱스 생성
+CREATE INDEX idx_테이블명_log_original_id ON 테이블명_log(original_id);
+CREATE INDEX idx_테이블명_log_changed_at ON 테이블명_log(changed_at);
+CREATE INDEX idx_테이블명_log_operation ON 테이블명_log(operation_type);
+
+-- 코멘트 추가
+COMMENT ON TABLE 테이블명_log IS '테이블명 테이블 변경 이력';
+```
+
+### 7.2 트리거 함수 DDL 템플릿
+
+```sql
+CREATE OR REPLACE FUNCTION 테이블명_log_trigger_func()
+RETURNS TRIGGER AS $$
+DECLARE
+ v_column_name TEXT;
+ v_old_value TEXT;
+ v_new_value TEXT;
+ v_user_id VARCHAR(50);
+ v_ip_address VARCHAR(50);
+BEGIN
+ v_user_id := current_setting('app.user_id', TRUE);
+ v_ip_address := current_setting('app.ip_address', TRUE);
+
+ IF (TG_OP = 'INSERT') THEN
+ INSERT INTO 테이블명_log (operation_type, original_id, changed_by, ip_address, full_row_after)
+ VALUES ('INSERT', NEW.id, v_user_id, v_ip_address, row_to_json(NEW)::jsonb);
+ RETURN NEW;
+
+ ELSIF (TG_OP = 'UPDATE') THEN
+ FOR v_column_name IN
+ SELECT column_name
+ FROM information_schema.columns
+ WHERE table_name = '테이블명'
+ AND table_schema = 'public'
+ LOOP
+ EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name)
+ INTO v_old_value, v_new_value
+ USING OLD, NEW;
+
+ IF v_old_value IS DISTINCT FROM v_new_value THEN
+ INSERT INTO 테이블명_log (
+ operation_type, original_id, changed_column, old_value, new_value,
+ changed_by, ip_address, full_row_before, full_row_after
+ )
+ VALUES (
+ 'UPDATE', NEW.id, v_column_name, v_old_value, v_new_value,
+ v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb
+ );
+ END IF;
+ END LOOP;
+ RETURN NEW;
+
+ ELSIF (TG_OP = 'DELETE') THEN
+ INSERT INTO 테이블명_log (operation_type, original_id, changed_by, ip_address, full_row_before)
+ VALUES ('DELETE', OLD.id, v_user_id, v_ip_address, row_to_json(OLD)::jsonb);
+ RETURN OLD;
+ END IF;
+
+ RETURN NULL;
+END;
+$$ LANGUAGE plpgsql;
+```
+
+### 7.3 트리거 DDL 템플릿
+
+```sql
+CREATE TRIGGER 테이블명_audit_trigger
+AFTER INSERT OR UPDATE OR DELETE ON 테이블명
+FOR EACH ROW EXECUTE FUNCTION 테이블명_log_trigger_func();
+```
+
+### 7.4 로그 설정 등록
+
+```sql
+INSERT INTO table_log_config (
+ original_table_name, log_table_name, trigger_name,
+ trigger_function_name, is_active, created_by, created_at
+) VALUES (
+ '테이블명', '테이블명_log', '테이블명_audit_trigger',
+ '테이블명_log_trigger_func', 'Y', '생성자ID', now()
+);
+```
+
+### 7.5 table_labels에 use_log_table 플래그 설정
+
+```sql
+UPDATE table_labels
+SET use_log_table = 'Y', updated_date = now()
+WHERE table_name = '테이블명';
+```
+
+### 7.6 전체 예시: order_info 로그 테이블 생성
+
+```sql
+-- Step 1: 로그 테이블 생성
+CREATE TABLE order_info_log (
+ log_id SERIAL PRIMARY KEY,
+ operation_type VARCHAR(10) NOT NULL,
+ original_id VARCHAR(100),
+ changed_column VARCHAR(100),
+ old_value TEXT,
+ new_value TEXT,
+ changed_by VARCHAR(50),
+ changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ ip_address VARCHAR(50),
+ user_agent TEXT,
+ full_row_before JSONB,
+ full_row_after JSONB
+);
+
+CREATE INDEX idx_order_info_log_original_id ON order_info_log(original_id);
+CREATE INDEX idx_order_info_log_changed_at ON order_info_log(changed_at);
+CREATE INDEX idx_order_info_log_operation ON order_info_log(operation_type);
+
+COMMENT ON TABLE order_info_log IS 'order_info 테이블 변경 이력';
+
+-- Step 2: 트리거 함수 생성
+CREATE OR REPLACE FUNCTION order_info_log_trigger_func()
+RETURNS TRIGGER AS $$
+DECLARE
+ v_column_name TEXT;
+ v_old_value TEXT;
+ v_new_value TEXT;
+ v_user_id VARCHAR(50);
+ v_ip_address VARCHAR(50);
+BEGIN
+ v_user_id := current_setting('app.user_id', TRUE);
+ v_ip_address := current_setting('app.ip_address', TRUE);
+
+ IF (TG_OP = 'INSERT') THEN
+ INSERT INTO order_info_log (operation_type, original_id, changed_by, ip_address, full_row_after)
+ VALUES ('INSERT', NEW.id, v_user_id, v_ip_address, row_to_json(NEW)::jsonb);
+ RETURN NEW;
+ ELSIF (TG_OP = 'UPDATE') THEN
+ FOR v_column_name IN
+ SELECT column_name FROM information_schema.columns
+ WHERE table_name = 'order_info' AND table_schema = 'public'
+ LOOP
+ EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name)
+ INTO v_old_value, v_new_value USING OLD, NEW;
+ IF v_old_value IS DISTINCT FROM v_new_value THEN
+ INSERT INTO order_info_log (operation_type, original_id, changed_column, old_value, new_value, changed_by, ip_address, full_row_before, full_row_after)
+ VALUES ('UPDATE', NEW.id, v_column_name, v_old_value, v_new_value, v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb);
+ END IF;
+ END LOOP;
+ RETURN NEW;
+ ELSIF (TG_OP = 'DELETE') THEN
+ INSERT INTO order_info_log (operation_type, original_id, changed_by, ip_address, full_row_before)
+ VALUES ('DELETE', OLD.id, v_user_id, v_ip_address, row_to_json(OLD)::jsonb);
+ RETURN OLD;
+ END IF;
+ RETURN NULL;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Step 3: 트리거 생성
+CREATE TRIGGER order_info_audit_trigger
+AFTER INSERT OR UPDATE OR DELETE ON order_info
+FOR EACH ROW EXECUTE FUNCTION order_info_log_trigger_func();
+
+-- Step 4: 로그 설정 등록
+INSERT INTO table_log_config (original_table_name, log_table_name, trigger_name, trigger_function_name, is_active, created_by, created_at)
+VALUES ('order_info', 'order_info_log', 'order_info_audit_trigger', 'order_info_log_trigger_func', 'Y', 'system', now());
+
+-- Step 5: table_labels 플래그 업데이트
+UPDATE table_labels SET use_log_table = 'Y', updated_date = now() WHERE table_name = 'order_info';
+```
+
+### 7.7 로그 테이블 삭제
+
+```sql
+-- 트리거 삭제
+DROP TRIGGER IF EXISTS 테이블명_audit_trigger ON 테이블명;
+
+-- 트리거 함수 삭제
+DROP FUNCTION IF EXISTS 테이블명_log_trigger_func();
+
+-- 로그 테이블 삭제
+DROP TABLE IF EXISTS 테이블명_log;
+
+-- 로그 설정 삭제
+DELETE FROM table_log_config WHERE original_table_name = '테이블명';
+
+-- table_labels 플래그 업데이트
+UPDATE table_labels SET use_log_table = 'N', updated_date = now() WHERE table_name = '테이블명';
+```
+
+---
+
+## 8. 체크리스트
+
+### 테이블 생성/수정 시 반드시 확인할 사항:
- [ ] DDL에 기본 5개 컬럼 포함 (id, created_date, updated_date, writer, company_code)
- [ ] 모든 비즈니스 컬럼은 `VARCHAR(500)` 타입 사용
@@ -349,9 +562,18 @@ ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.colu
- [ ] code/entity 타입은 detail_settings에 참조 정보 포함
- [ ] ON CONFLICT 절로 중복 시 UPDATE 처리
+### 로그 테이블 생성 시 확인할 사항 (선택):
+
+- [ ] 로그 테이블 생성 (`테이블명_log`)
+- [ ] 인덱스 3개 생성 (original_id, changed_at, operation_type)
+- [ ] 트리거 함수 생성 (`테이블명_log_trigger_func`)
+- [ ] 트리거 생성 (`테이블명_audit_trigger`)
+- [ ] `table_log_config`에 로그 설정 등록
+- [ ] `table_labels.use_log_table = 'Y'` 업데이트
+
---
-## 8. 금지 사항
+## 9. 금지 사항
1. **DB 타입 직접 지정 금지**: NUMBER, INTEGER, DATE 등 DB 타입 직접 사용 금지
2. **VARCHAR 길이 변경 금지**: 반드시 `VARCHAR(500)` 사용
@@ -364,5 +586,7 @@ ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.colu
## 참조 파일
- `backend-node/src/services/ddlExecutionService.ts`: DDL 실행 서비스
+- `backend-node/src/services/tableManagementService.ts`: 로그 테이블 생성 서비스
- `backend-node/src/types/ddl.ts`: DDL 타입 정의
- `backend-node/src/controllers/ddlController.ts`: DDL API 컨트롤러
+- `backend-node/src/controllers/tableManagementController.ts`: 로그 테이블 API 컨트롤러
From 3c4e251e9b2cc6aebe166c134c8cf1f2fd47f322 Mon Sep 17 00:00:00 2001
From: kjs
Date: Tue, 30 Dec 2025 12:33:17 +0900
Subject: [PATCH 17/28] =?UTF-8?q?=ED=8F=BC=20=EB=8B=A4=EC=A4=91=ED=85=8C?=
=?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EC=A0=80=EC=9E=A5=20=EC=A7=80=EC=9B=90?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../UniversalFormModalComponent.tsx | 82 +++--
frontend/lib/utils/buttonActions.ts | 309 +++++++++++++-----
2 files changed, 284 insertions(+), 107 deletions(-)
diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx
index b4921a51..9edf4054 100644
--- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx
@@ -963,6 +963,13 @@ export function UniversalFormModalComponent({
}
}
+ // 별도 테이블에 저장해야 하는 테이블 섹션 목록
+ const tableSectionsForSeparateTable = config.sections.filter(
+ (s) => s.type === "table" &&
+ s.tableConfig?.saveConfig?.targetTable &&
+ s.tableConfig.saveConfig.targetTable !== config.saveConfig.tableName
+ );
+
// 테이블 섹션이 있고 메인 테이블에 품목별로 저장하는 경우 (공통 + 개별 병합 저장)
// targetTable이 없거나 메인 테이블과 같은 경우
const tableSectionsForMainTable = config.sections.filter(
@@ -971,6 +978,12 @@ export function UniversalFormModalComponent({
s.tableConfig.saveConfig.targetTable === config.saveConfig.tableName)
);
+ console.log("[saveSingleRow] 메인 테이블:", config.saveConfig.tableName);
+ console.log("[saveSingleRow] 메인 테이블에 저장할 테이블 섹션:", tableSectionsForMainTable.map(s => s.id));
+ console.log("[saveSingleRow] 별도 테이블에 저장할 테이블 섹션:", tableSectionsForSeparateTable.map(s => s.id));
+ console.log("[saveSingleRow] 테이블 섹션 데이터 키:", Object.keys(tableSectionData));
+ console.log("[saveSingleRow] dataToSave 키:", Object.keys(dataToSave));
+
if (tableSectionsForMainTable.length > 0) {
// 공통 저장 필드 수집 (sectionSaveModes 설정에 따라)
const commonFieldsData: Record = {};
@@ -1050,35 +1063,51 @@ export function UniversalFormModalComponent({
// 메인 레코드 ID가 필요한 경우 (response.data에서 가져오기)
const mainRecordId = response.data?.data?.id;
- // 공통 저장 필드 수집 (sectionSaveModes 설정에 따라)
+ // 공통 저장 필드 수집: 다른 섹션(필드 타입)에서 공통 저장으로 설정된 필드 값
+ // 기본값: 필드 타입 섹션은 'common', 테이블 타입 섹션은 'individual'
const commonFieldsData: Record = {};
const { sectionSaveModes } = config.saveConfig;
- if (sectionSaveModes && sectionSaveModes.length > 0) {
- // 다른 섹션에서 공통 저장으로 설정된 필드 값 수집
- for (const otherSection of config.sections) {
- if (otherSection.id === section.id) continue; // 현재 테이블 섹션은 건너뛰기
-
- const sectionMode = sectionSaveModes.find((s) => s.sectionId === otherSection.id);
- const defaultMode = otherSection.type === "table" ? "individual" : "common";
- const sectionSaveMode = sectionMode?.saveMode || defaultMode;
-
- // 필드 타입 섹션의 필드들 처리
- if (otherSection.type !== "table" && otherSection.fields) {
- for (const field of otherSection.fields) {
- // 필드별 오버라이드 확인
- const fieldOverride = sectionMode?.fieldOverrides?.find((f) => f.fieldName === field.columnName);
- const fieldSaveMode = fieldOverride?.saveMode || sectionSaveMode;
-
- // 공통 저장이면 formData에서 값을 가져와 모든 품목에 적용
- if (fieldSaveMode === "common" && formData[field.columnName] !== undefined) {
- commonFieldsData[field.columnName] = formData[field.columnName];
+ // 다른 섹션에서 공통 저장으로 설정된 필드 값 수집
+ for (const otherSection of config.sections) {
+ if (otherSection.id === section.id) continue; // 현재 테이블 섹션은 건너뛰기
+
+ const sectionMode = sectionSaveModes?.find((s) => s.sectionId === otherSection.id);
+ // 기본값: 필드 타입 섹션은 'common', 테이블 타입 섹션은 'individual'
+ const defaultMode = otherSection.type === "table" ? "individual" : "common";
+ const sectionSaveMode = sectionMode?.saveMode || defaultMode;
+
+ // 필드 타입 섹션의 필드들 처리
+ if (otherSection.type !== "table" && otherSection.fields) {
+ for (const field of otherSection.fields) {
+ // 필드별 오버라이드 확인
+ const fieldOverride = sectionMode?.fieldOverrides?.find((f) => f.fieldName === field.columnName);
+ const fieldSaveMode = fieldOverride?.saveMode || sectionSaveMode;
+
+ // 공통 저장이면 formData에서 값을 가져와 모든 품목에 적용
+ if (fieldSaveMode === "common" && formData[field.columnName] !== undefined) {
+ commonFieldsData[field.columnName] = formData[field.columnName];
+ }
+ }
+ }
+
+ // 🆕 선택적 필드 그룹 (optionalFieldGroups)도 처리
+ if (otherSection.optionalFieldGroups && otherSection.optionalFieldGroups.length > 0) {
+ for (const optGroup of otherSection.optionalFieldGroups) {
+ if (optGroup.fields) {
+ for (const field of optGroup.fields) {
+ // 선택적 필드 그룹은 기본적으로 common 저장
+ if (formData[field.columnName] !== undefined) {
+ commonFieldsData[field.columnName] = formData[field.columnName];
+ }
}
}
}
}
}
+ console.log("[saveSingleRow] 별도 테이블 저장 - 공통 필드:", Object.keys(commonFieldsData));
+
for (const item of sectionData) {
// 공통 필드 병합 + 개별 품목 데이터
const itemToSave = { ...commonFieldsData, ...item };
@@ -1091,15 +1120,26 @@ export function UniversalFormModalComponent({
}
}
+ // _sourceData 등 내부 메타데이터 제거
+ Object.keys(itemToSave).forEach((key) => {
+ if (key.startsWith("_")) {
+ delete itemToSave[key];
+ }
+ });
+
// 메인 레코드와 연결이 필요한 경우
if (mainRecordId && config.saveConfig.primaryKeyColumn) {
itemToSave[config.saveConfig.primaryKeyColumn] = mainRecordId;
}
- await apiClient.post(
+ const saveResponse = await apiClient.post(
`/table-management/tables/${section.tableConfig.saveConfig.targetTable}/add`,
itemToSave
);
+
+ if (!saveResponse.data?.success) {
+ throw new Error(saveResponse.data?.message || `${section.title || "테이블 섹션"} 저장 실패`);
+ }
}
}
}
diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts
index de98028a..9a6a606e 100644
--- a/frontend/lib/utils/buttonActions.ts
+++ b/frontend/lib/utils/buttonActions.ts
@@ -1491,6 +1491,7 @@ export class ButtonActionExecutor {
* 🆕 Universal Form Modal 테이블 섹션 병합 저장 처리
* 범용_폼_모달 내부의 공통 필드 + _tableSection_ 데이터를 병합하여 품목별로 저장
* 수정 모드: INSERT/UPDATE/DELETE 지원
+ * 🆕 섹션별 저장 테이블(targetTable) 지원 추가
*/
private static async handleUniversalFormModalTableSectionSave(
config: ButtonActionConfig,
@@ -1514,7 +1515,66 @@ export class ButtonActionExecutor {
console.log("🎯 [handleUniversalFormModalTableSectionSave] Universal Form Modal 감지:", universalFormModalKey);
const modalData = formData[universalFormModalKey];
-
+
+ // 🆕 universal-form-modal 컴포넌트 설정 가져오기
+ // 1. componentConfigs에서 컴포넌트 ID로 찾기
+ // 2. allComponents에서 columnName으로 찾기
+ // 3. 화면 레이아웃 API에서 가져오기
+ let modalComponentConfig = context.componentConfigs?.[universalFormModalKey];
+
+ // componentConfigs에서 직접 찾지 못한 경우, allComponents에서 columnName으로 찾기
+ if (!modalComponentConfig && context.allComponents) {
+ const modalComponent = context.allComponents.find(
+ (comp: any) =>
+ comp.columnName === universalFormModalKey || comp.properties?.columnName === universalFormModalKey,
+ );
+ if (modalComponent) {
+ modalComponentConfig = modalComponent.componentConfig || modalComponent.properties?.componentConfig;
+ console.log("🎯 [handleUniversalFormModalTableSectionSave] allComponents에서 설정 찾음:", modalComponent.id);
+ }
+ }
+
+ // 🆕 아직도 설정을 찾지 못했으면 화면 레이아웃 API에서 가져오기
+ if (!modalComponentConfig && screenId) {
+ try {
+ console.log("🔍 [handleUniversalFormModalTableSectionSave] 화면 레이아웃 API에서 설정 조회:", screenId);
+ const { screenApi } = await import("@/lib/api/screen");
+ const layoutData = await screenApi.getLayout(screenId);
+
+ if (layoutData && layoutData.components) {
+ // 레이아웃에서 universal-form-modal 컴포넌트 찾기
+ const modalLayout = (layoutData.components as any[]).find(
+ (comp) =>
+ comp.properties?.columnName === universalFormModalKey || comp.columnName === universalFormModalKey,
+ );
+ if (modalLayout) {
+ modalComponentConfig = modalLayout.properties?.componentConfig || modalLayout.componentConfig;
+ console.log(
+ "🎯 [handleUniversalFormModalTableSectionSave] 화면 레이아웃에서 설정 찾음:",
+ modalLayout.componentId,
+ );
+ }
+ }
+ } catch (error) {
+ console.warn("⚠️ [handleUniversalFormModalTableSectionSave] 화면 레이아웃 조회 실패:", error);
+ }
+ }
+
+ const sections: any[] = modalComponentConfig?.sections || [];
+ const saveConfig = modalComponentConfig?.saveConfig || {};
+
+ console.log("🎯 [handleUniversalFormModalTableSectionSave] 컴포넌트 설정:", {
+ hasComponentConfig: !!modalComponentConfig,
+ sectionsCount: sections.length,
+ mainTableName: saveConfig.tableName || tableName,
+ sectionSaveModes: saveConfig.sectionSaveModes,
+ sectionDetails: sections.map((s: any) => ({
+ id: s.id,
+ type: s.type,
+ targetTable: s.tableConfig?.saveConfig?.targetTable,
+ })),
+ });
+
// _tableSection_ 데이터 추출
const tableSectionData: Record = {};
const commonFieldsData: Record = {};
@@ -1564,10 +1624,64 @@ export class ButtonActionExecutor {
let insertedCount = 0;
let updatedCount = 0;
let deletedCount = 0;
+ let mainRecordId: number | null = null;
+
+ // 🆕 먼저 메인 테이블에 공통 데이터 저장 (별도 테이블이 있는 경우에만)
+ const hasSeparateTargetTable = sections.some(
+ (s) =>
+ s.type === "table" &&
+ s.tableConfig?.saveConfig?.targetTable &&
+ s.tableConfig.saveConfig.targetTable !== tableName,
+ );
+
+ if (hasSeparateTargetTable && Object.keys(commonFieldsData).length > 0) {
+ console.log("📦 [handleUniversalFormModalTableSectionSave] 메인 테이블에 공통 데이터 저장:", tableName);
+
+ const mainRowToSave = { ...commonFieldsData, ...userInfo };
+
+ // 메타데이터 제거
+ Object.keys(mainRowToSave).forEach((key) => {
+ if (key.startsWith("_")) {
+ delete mainRowToSave[key];
+ }
+ });
+
+ console.log("📦 [handleUniversalFormModalTableSectionSave] 메인 테이블 저장 데이터:", mainRowToSave);
+
+ const mainSaveResult = await DynamicFormApi.saveFormData({
+ screenId: screenId!,
+ tableName: tableName!,
+ data: mainRowToSave,
+ });
+
+ if (!mainSaveResult.success) {
+ throw new Error(mainSaveResult.message || "메인 데이터 저장 실패");
+ }
+
+ mainRecordId = mainSaveResult.data?.id || null;
+ console.log("✅ [handleUniversalFormModalTableSectionSave] 메인 테이블 저장 완료, ID:", mainRecordId);
+ }
// 각 테이블 섹션 처리
for (const [sectionId, currentItems] of Object.entries(tableSectionData)) {
- console.log(`🔄 [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} 처리 시작: ${currentItems.length}개 품목`);
+ console.log(
+ `🔄 [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} 처리 시작: ${currentItems.length}개 품목`,
+ );
+
+ // 🆕 해당 섹션의 설정 찾기
+ const sectionConfig = sections.find((s) => s.id === sectionId);
+ const targetTableName = sectionConfig?.tableConfig?.saveConfig?.targetTable;
+
+ // 🆕 실제 저장할 테이블 결정
+ // - targetTable이 있으면 해당 테이블에 저장
+ // - targetTable이 없으면 메인 테이블에 저장
+ const saveTableName = targetTableName || tableName!;
+
+ console.log(`📊 [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} 저장 테이블:`, {
+ targetTableName,
+ saveTableName,
+ isMainTable: saveTableName === tableName,
+ });
// 1️⃣ 신규 품목 INSERT (id가 없는 항목)
const newItems = currentItems.filter((item) => !item.id);
@@ -1581,11 +1695,16 @@ export class ButtonActionExecutor {
}
});
- console.log("➕ [INSERT] 신규 품목:", rowToSave);
+ // 🆕 메인 레코드 ID 연결 (별도 테이블에 저장하는 경우)
+ if (targetTableName && mainRecordId && saveConfig.primaryKeyColumn) {
+ rowToSave[saveConfig.primaryKeyColumn] = mainRecordId;
+ }
+
+ console.log("➕ [INSERT] 신규 품목:", { tableName: saveTableName, data: rowToSave });
const saveResult = await DynamicFormApi.saveFormData({
screenId: screenId!,
- tableName: tableName!,
+ tableName: saveTableName,
data: rowToSave,
});
@@ -1612,9 +1731,14 @@ export class ButtonActionExecutor {
});
delete rowToSave.id; // id 제거하여 INSERT
+ // 🆕 메인 레코드 ID 연결 (별도 테이블에 저장하는 경우)
+ if (targetTableName && mainRecordId && saveConfig.primaryKeyColumn) {
+ rowToSave[saveConfig.primaryKeyColumn] = mainRecordId;
+ }
+
const saveResult = await DynamicFormApi.saveFormData({
screenId: screenId!,
- tableName: tableName!,
+ tableName: saveTableName,
data: rowToSave,
});
@@ -1631,14 +1755,14 @@ export class ButtonActionExecutor {
const hasChanges = this.checkForChanges(originalItem, currentDataWithCommon);
if (hasChanges) {
- console.log(`🔄 [UPDATE] 품목 수정: id=${item.id}`);
+ console.log(`🔄 [UPDATE] 품목 수정: id=${item.id}, tableName=${saveTableName}`);
// 변경된 필드만 추출하여 부분 업데이트
const updateResult = await DynamicFormApi.updateFormDataPartial(
item.id,
originalItem,
currentDataWithCommon,
- tableName!,
+ saveTableName,
);
if (!updateResult.success) {
@@ -1656,9 +1780,9 @@ export class ButtonActionExecutor {
const deletedItems = originalGroupedData.filter((orig) => orig.id && !currentIds.has(orig.id));
for (const deletedItem of deletedItems) {
- console.log(`🗑️ [DELETE] 품목 삭제: id=${deletedItem.id}`);
+ console.log(`🗑️ [DELETE] 품목 삭제: id=${deletedItem.id}, tableName=${saveTableName}`);
- const deleteResult = await DynamicFormApi.deleteFormDataFromTable(tableName!, deletedItem.id);
+ const deleteResult = await DynamicFormApi.deleteFormDataFromTable(saveTableName, deletedItem.id);
if (!deleteResult.success) {
throw new Error(deleteResult.message || "품목 삭제 실패");
@@ -1670,6 +1794,7 @@ export class ButtonActionExecutor {
// 결과 메시지 생성
const resultParts: string[] = [];
+ if (mainRecordId) resultParts.push("메인 데이터 저장");
if (insertedCount > 0) resultParts.push(`${insertedCount}개 추가`);
if (updatedCount > 0) resultParts.push(`${updatedCount}개 수정`);
if (deletedCount > 0) resultParts.push(`${deletedCount}개 삭제`);
@@ -2145,17 +2270,20 @@ export class ButtonActionExecutor {
* 연관 데이터 버튼의 선택 데이터로 모달 열기
* RelatedDataButtons 컴포넌트에서 선택된 버튼 데이터를 모달로 전달
*/
- private static async handleOpenRelatedModal(config: ButtonActionConfig, context: ButtonActionContext): Promise {
+ private static async handleOpenRelatedModal(
+ config: ButtonActionConfig,
+ context: ButtonActionContext,
+ ): Promise {
// 버튼 설정에서 targetScreenId 가져오기 (여러 위치에서 확인)
const targetScreenId = config.relatedModalConfig?.targetScreenId || config.targetScreenId;
-
+
console.log("🔍 [openRelatedModal] 설정 확인:", {
config,
relatedModalConfig: config.relatedModalConfig,
targetScreenId: config.targetScreenId,
finalTargetScreenId: targetScreenId,
});
-
+
if (!targetScreenId) {
console.error("❌ [openRelatedModal] targetScreenId가 설정되지 않았습니다.");
toast.error("모달 화면 ID가 설정되지 않았습니다.");
@@ -2164,13 +2292,13 @@ export class ButtonActionExecutor {
// RelatedDataButtons에서 선택된 데이터 가져오기
const relatedData = window.__relatedButtonsSelectedData;
-
+
console.log("🔍 [openRelatedModal] RelatedDataButtons 데이터:", {
relatedData,
selectedItem: relatedData?.selectedItem,
config: relatedData?.config,
});
-
+
if (!relatedData?.selectedItem) {
console.warn("⚠️ [openRelatedModal] 선택된 버튼이 없습니다.");
toast.warning("먼저 버튼을 선택해주세요.");
@@ -2181,14 +2309,14 @@ export class ButtonActionExecutor {
// 데이터 매핑 적용
const initialData: Record = {};
-
+
console.log("🔍 [openRelatedModal] 매핑 설정:", {
modalLink: relatedConfig?.modalLink,
dataMapping: relatedConfig?.modalLink?.dataMapping,
});
-
+
if (relatedConfig?.modalLink?.dataMapping && relatedConfig.modalLink.dataMapping.length > 0) {
- relatedConfig.modalLink.dataMapping.forEach(mapping => {
+ relatedConfig.modalLink.dataMapping.forEach((mapping) => {
console.log("🔍 [openRelatedModal] 매핑 처리:", {
mapping,
sourceField: mapping.sourceField,
@@ -2197,7 +2325,7 @@ export class ButtonActionExecutor {
selectedItemId: selectedItem.id,
rawDataValue: selectedItem.rawData[mapping.sourceField],
});
-
+
if (mapping.sourceField === "value") {
initialData[mapping.targetField] = selectedItem.value;
} else if (mapping.sourceField === "id") {
@@ -2219,18 +2347,20 @@ export class ButtonActionExecutor {
});
// 모달 열기 이벤트 발생 (ScreenModal은 editData를 사용)
- window.dispatchEvent(new CustomEvent("openScreenModal", {
- detail: {
- screenId: targetScreenId,
- title: config.modalTitle,
- description: config.modalDescription,
- editData: initialData, // ScreenModal은 editData로 폼 데이터를 받음
- onSuccess: () => {
- // 성공 후 데이터 새로고침
- window.dispatchEvent(new CustomEvent("refreshTableData"));
+ window.dispatchEvent(
+ new CustomEvent("openScreenModal", {
+ detail: {
+ screenId: targetScreenId,
+ title: config.modalTitle,
+ description: config.modalDescription,
+ editData: initialData, // ScreenModal은 editData로 폼 데이터를 받음
+ onSuccess: () => {
+ // 성공 후 데이터 새로고침
+ window.dispatchEvent(new CustomEvent("refreshTableData"));
+ },
},
- },
- }));
+ }),
+ );
return true;
}
@@ -3296,10 +3426,7 @@ export class ButtonActionExecutor {
* EditModal 등 외부에서도 호출 가능하도록 public으로 변경
* 다중 제어 순차 실행 지원
*/
- public static async executeAfterSaveControl(
- config: ButtonActionConfig,
- context: ButtonActionContext,
- ): Promise {
+ public static async executeAfterSaveControl(config: ButtonActionConfig, context: ButtonActionContext): Promise {
console.log("🎯 저장 후 제어 실행:", {
enableDataflowControl: config.enableDataflowControl,
dataflowConfig: config.dataflowConfig,
@@ -4742,7 +4869,7 @@ export class ButtonActionExecutor {
// 추적 중인지 확인 (새로고침 후에도 DB 상태 기반 종료 가능하도록 수정)
const isTrackingActive = !!this.trackingIntervalId;
-
+
if (!isTrackingActive) {
// 추적 중이 아니어도 DB 상태 변경은 진행 (새로고침 후 종료 지원)
console.log("⚠️ [handleTrackingStop] trackingIntervalId 없음 - DB 상태 기반 종료 진행");
@@ -4758,25 +4885,26 @@ export class ButtonActionExecutor {
let dbDeparture: string | null = null;
let dbArrival: string | null = null;
let dbVehicleId: string | null = null;
-
+
const userId = context.userId || this.trackingUserId;
if (userId) {
try {
const { apiClient } = await import("@/lib/api/client");
- const statusTableName = config.trackingStatusTableName || this.trackingConfig?.trackingStatusTableName || context.tableName || "vehicles";
+ const statusTableName =
+ config.trackingStatusTableName ||
+ this.trackingConfig?.trackingStatusTableName ||
+ context.tableName ||
+ "vehicles";
const keyField = config.trackingStatusKeyField || this.trackingConfig?.trackingStatusKeyField || "user_id";
-
+
// DB에서 현재 차량 정보 조회
- const vehicleResponse = await apiClient.post(
- `/table-management/tables/${statusTableName}/data`,
- {
- page: 1,
- size: 1,
- search: { [keyField]: userId },
- autoFilter: true,
- },
- );
-
+ const vehicleResponse = await apiClient.post(`/table-management/tables/${statusTableName}/data`, {
+ page: 1,
+ size: 1,
+ search: { [keyField]: userId },
+ autoFilter: true,
+ });
+
const vehicleData = vehicleResponse.data?.data?.data?.[0] || vehicleResponse.data?.data?.rows?.[0];
if (vehicleData) {
dbDeparture = vehicleData.departure || null;
@@ -4792,14 +4920,18 @@ export class ButtonActionExecutor {
// 마지막 위치 저장 (추적 중이었던 경우에만)
if (isTrackingActive) {
// DB 값 우선, 없으면 formData 사용
- const departure = dbDeparture ||
- this.trackingContext?.formData?.[this.trackingConfig?.trackingDepartureField || "departure"] || null;
- const arrival = dbArrival ||
- this.trackingContext?.formData?.[this.trackingConfig?.trackingArrivalField || "arrival"] || null;
+ const departure =
+ dbDeparture ||
+ this.trackingContext?.formData?.[this.trackingConfig?.trackingDepartureField || "departure"] ||
+ null;
+ const arrival =
+ dbArrival || this.trackingContext?.formData?.[this.trackingConfig?.trackingArrivalField || "arrival"] || null;
const departureName = this.trackingContext?.formData?.["departure_name"] || null;
const destinationName = this.trackingContext?.formData?.["destination_name"] || null;
- const vehicleId = dbVehicleId ||
- this.trackingContext?.formData?.[this.trackingConfig?.trackingVehicleIdField || "vehicle_id"] || null;
+ const vehicleId =
+ dbVehicleId ||
+ this.trackingContext?.formData?.[this.trackingConfig?.trackingVehicleIdField || "vehicle_id"] ||
+ null;
await this.saveLocationToHistory(
tripId,
@@ -5681,10 +5813,10 @@ export class ButtonActionExecutor {
const columnMappings = quickInsertConfig.columnMappings || [];
for (const mapping of columnMappings) {
- console.log(`📍 매핑 처리 시작:`, mapping);
-
+ console.log("📍 매핑 처리 시작:", mapping);
+
if (!mapping.targetColumn) {
- console.log(`📍 targetColumn 없음, 스킵`);
+ console.log("📍 targetColumn 없음, 스킵");
continue;
}
@@ -5692,12 +5824,12 @@ export class ButtonActionExecutor {
switch (mapping.sourceType) {
case "component":
- console.log(`📍 component 타입 처리:`, {
+ console.log("📍 component 타입 처리:", {
sourceComponentId: mapping.sourceComponentId,
sourceColumnName: mapping.sourceColumnName,
targetColumn: mapping.targetColumn,
});
-
+
// 컴포넌트의 현재 값
if (mapping.sourceComponentId) {
// 1. sourceColumnName이 있으면 직접 사용 (가장 확실한 방법)
@@ -5705,34 +5837,34 @@ export class ButtonActionExecutor {
value = formData?.[mapping.sourceColumnName];
console.log(`📍 방법1 (sourceColumnName): ${mapping.sourceColumnName} = ${value}`);
}
-
+
// 2. 없으면 컴포넌트 ID로 직접 찾기
if (value === undefined) {
value = formData?.[mapping.sourceComponentId];
console.log(`📍 방법2 (sourceComponentId): ${mapping.sourceComponentId} = ${value}`);
}
-
+
// 3. 없으면 allComponents에서 컴포넌트를 찾아 columnName으로 시도
if (value === undefined && context.allComponents) {
const comp = context.allComponents.find((c: any) => c.id === mapping.sourceComponentId);
- console.log(`📍 방법3 찾은 컴포넌트:`, comp);
+ console.log("📍 방법3 찾은 컴포넌트:", comp);
if (comp?.columnName) {
value = formData?.[comp.columnName];
console.log(`📍 방법3 (allComponents): ${mapping.sourceComponentId} → ${comp.columnName} = ${value}`);
}
}
-
+
// 4. targetColumn과 같은 이름의 키가 formData에 있으면 사용 (폴백)
if (value === undefined && mapping.targetColumn && formData?.[mapping.targetColumn] !== undefined) {
value = formData[mapping.targetColumn];
console.log(`📍 방법4 (targetColumn 폴백): ${mapping.targetColumn} = ${value}`);
}
-
+
// 5. 그래도 없으면 formData의 모든 키를 확인하고 로깅
if (value === undefined) {
console.log("📍 방법5: formData에서 값을 찾지 못함. formData 키들:", Object.keys(formData || {}));
}
-
+
// sourceColumn이 지정된 경우 해당 속성 추출
if (mapping.sourceColumn && value && typeof value === "object") {
value = value[mapping.sourceColumn];
@@ -5742,7 +5874,7 @@ export class ButtonActionExecutor {
break;
case "leftPanel":
- console.log(`📍 leftPanel 타입 처리:`, {
+ console.log("📍 leftPanel 타입 처리:", {
sourceColumn: mapping.sourceColumn,
selectedLeftData: splitPanelContext?.selectedLeftData,
});
@@ -5775,18 +5907,18 @@ export class ButtonActionExecutor {
}
console.log(`📍 currentUser 값: ${value}`);
break;
-
+
default:
console.log(`📍 알 수 없는 sourceType: ${mapping.sourceType}`);
}
console.log(`📍 매핑 결과: targetColumn=${mapping.targetColumn}, value=${value}, type=${typeof value}`);
-
+
if (value !== undefined && value !== null && value !== "") {
insertData[mapping.targetColumn] = value;
console.log(`📍 insertData에 추가됨: ${mapping.targetColumn} = ${value}`);
} else {
- console.log(`📍 값이 비어있어서 insertData에 추가 안됨`);
+ console.log("📍 값이 비어있어서 insertData에 추가 안됨");
}
}
@@ -5794,12 +5926,12 @@ export class ButtonActionExecutor {
if (splitPanelContext?.selectedLeftData) {
const leftData = splitPanelContext.selectedLeftData;
console.log("📍 좌측 패널 자동 매핑 시작:", leftData);
-
+
// 대상 테이블의 컬럼 목록 조회
let targetTableColumns: string[] = [];
try {
const columnsResponse = await apiClient.get(
- `/table-management/tables/${quickInsertConfig.targetTable}/columns`
+ `/table-management/tables/${quickInsertConfig.targetTable}/columns`,
);
if (columnsResponse.data?.success && columnsResponse.data?.data) {
const columnsData = columnsResponse.data.data.columns || columnsResponse.data.data;
@@ -5809,35 +5941,35 @@ export class ButtonActionExecutor {
} catch (error) {
console.error("대상 테이블 컬럼 조회 실패:", error);
}
-
+
for (const [key, val] of Object.entries(leftData)) {
// 이미 매핑된 컬럼은 스킵
if (insertData[key] !== undefined) {
console.log(`📍 자동 매핑 스킵 (이미 존재): ${key}`);
continue;
}
-
+
// 대상 테이블에 해당 컬럼이 없으면 스킵
if (targetTableColumns.length > 0 && !targetTableColumns.includes(key)) {
console.log(`📍 자동 매핑 스킵 (대상 테이블에 없는 컬럼): ${key}`);
continue;
}
-
+
// 시스템 컬럼 제외 (id, created_date, updated_date, writer 등)
- const systemColumns = ['id', 'created_date', 'updated_date', 'writer', 'writer_name'];
+ const systemColumns = ["id", "created_date", "updated_date", "writer", "writer_name"];
if (systemColumns.includes(key)) {
console.log(`📍 자동 매핑 스킵 (시스템 컬럼): ${key}`);
continue;
}
-
+
// _label, _name 으로 끝나는 표시용 컬럼 제외
- if (key.endsWith('_label') || key.endsWith('_name')) {
+ if (key.endsWith("_label") || key.endsWith("_name")) {
console.log(`📍 자동 매핑 스킵 (표시용 컬럼): ${key}`);
continue;
}
-
+
// 값이 있으면 자동 추가
- if (val !== undefined && val !== null && val !== '') {
+ if (val !== undefined && val !== null && val !== "") {
insertData[key] = val;
console.log(`📍 자동 매핑 추가: ${key} = ${val}`);
}
@@ -5857,7 +5989,7 @@ export class ButtonActionExecutor {
enabled: quickInsertConfig.duplicateCheck?.enabled,
columns: quickInsertConfig.duplicateCheck?.columns,
});
-
+
if (quickInsertConfig.duplicateCheck?.enabled && quickInsertConfig.duplicateCheck?.columns?.length > 0) {
const duplicateCheckData: Record = {};
for (const col of quickInsertConfig.duplicateCheck.columns) {
@@ -5877,15 +6009,20 @@ export class ButtonActionExecutor {
page: 1,
pageSize: 1,
search: duplicateCheckData,
- }
+ },
);
console.log("📍 중복 체크 응답:", checkResponse.data);
// 응답 구조: { success: true, data: { data: [...], total: N } } 또는 { success: true, data: [...] }
const existingData = checkResponse.data?.data?.data || checkResponse.data?.data || [];
- console.log("📍 기존 데이터:", existingData, "길이:", Array.isArray(existingData) ? existingData.length : 0);
-
+ console.log(
+ "📍 기존 데이터:",
+ existingData,
+ "길이:",
+ Array.isArray(existingData) ? existingData.length : 0,
+ );
+
if (Array.isArray(existingData) && existingData.length > 0) {
toast.error(quickInsertConfig.duplicateCheck.errorMessage || "이미 존재하는 데이터입니다.");
return false;
@@ -5902,20 +6039,20 @@ export class ButtonActionExecutor {
// 데이터 저장
const response = await apiClient.post(
`/table-management/tables/${quickInsertConfig.targetTable}/add`,
- insertData
+ insertData,
);
if (response.data?.success) {
console.log("✅ Quick Insert 저장 성공");
-
+
// 저장 후 동작 설정 로그
console.log("📍 afterInsert 설정:", quickInsertConfig.afterInsert);
-
+
// 🆕 데이터 새로고침 (테이블리스트, 카드 디스플레이 컴포넌트 새로고침)
// refreshData가 명시적으로 false가 아니면 기본적으로 새로고침 실행
const shouldRefresh = quickInsertConfig.afterInsert?.refreshData !== false;
console.log("📍 데이터 새로고침 여부:", shouldRefresh);
-
+
if (shouldRefresh) {
console.log("📍 데이터 새로고침 이벤트 발송");
// 전역 이벤트로 테이블/카드 컴포넌트들에게 새로고침 알림
From fd58e9cce2ce64d145ffae72b5af404c9600a195 Mon Sep 17 00:00:00 2001
From: kjs
Date: Tue, 30 Dec 2025 13:32:49 +0900
Subject: [PATCH 18/28] =?UTF-8?q?=ED=96=89=EC=B6=94=EA=B0=80,=EB=AA=A8?=
=?UTF-8?q?=EB=8B=AC=20=EB=8F=99=EC=8B=9C=EC=9E=85=EB=A0=A5=EA=B8=B0?=
=?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../TableSectionRenderer.tsx | 1099 +++++++++--------
.../modals/TableSectionSettingsModal.tsx | 91 +-
.../components/universal-form-modal/types.ts | 12 +-
3 files changed, 639 insertions(+), 563 deletions(-)
diff --git a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx
index ba03d2b9..a1c0bd76 100644
--- a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx
@@ -16,12 +16,7 @@ import { ItemSelectionModal } from "../modal-repeater-table/ItemSelectionModal";
import { RepeaterColumnConfig, CalculationRule } from "../modal-repeater-table/types";
// 타입 정의
-import {
- TableSectionConfig,
- TableColumnConfig,
- TableJoinCondition,
- FormDataState,
-} from "./types";
+import { TableSectionConfig, TableColumnConfig, TableJoinCondition, FormDataState } from "./types";
interface TableSectionRendererProps {
sectionId: string;
@@ -56,7 +51,7 @@ function convertToRepeaterColumn(col: TableColumnConfig): RepeaterColumnConfig {
selectOptions: col.selectOptions,
// valueMapping은 별도로 처리
};
-
+
// lookup 설정을 dynamicDataSource로 변환 (새로운 조회 기능)
if (col.lookup?.enabled && col.lookup.options && col.lookup.options.length > 0) {
baseColumn.dynamicDataSource = {
@@ -75,17 +70,19 @@ function convertToRepeaterColumn(col: TableColumnConfig): RepeaterColumnConfig {
sourceField: cond.sourceField,
targetField: cond.targetColumn,
// sourceType에 따른 데이터 출처 설정
- sourceType: cond.sourceType, // "currentRow" | "sectionField" | "externalTable"
+ sourceType: cond.sourceType, // "currentRow" | "sectionField" | "externalTable"
fromFormData: cond.sourceType === "sectionField",
sectionId: cond.sectionId,
// 외부 테이블 조회 설정 (sourceType이 "externalTable"인 경우)
externalLookup: cond.externalLookup,
// 값 변환 설정 전달 (레거시 호환)
- transform: cond.transform?.enabled ? {
- tableName: cond.transform.tableName,
- matchColumn: cond.transform.matchColumn,
- resultColumn: cond.transform.resultColumn,
- } : undefined,
+ transform: cond.transform?.enabled
+ ? {
+ tableName: cond.transform.tableName,
+ matchColumn: cond.transform.matchColumn,
+ resultColumn: cond.transform.resultColumn,
+ }
+ : undefined,
})),
},
// 조회 유형 정보 추가
@@ -115,14 +112,18 @@ function convertToRepeaterColumn(col: TableColumnConfig): RepeaterColumnConfig {
defaultOptionId: col.columnModes.find((m) => m.isDefault)?.id || col.columnModes[0]?.id,
};
}
-
+
return baseColumn;
}
/**
* TableCalculationRule을 CalculationRule로 변환
*/
-function convertToCalculationRule(calc: { resultField: string; formula: string; dependencies: string[] }): CalculationRule {
+function convertToCalculationRule(calc: {
+ resultField: string;
+ formula: string;
+ dependencies: string[];
+}): CalculationRule {
return {
result: calc.resultField,
formula: calc.formula,
@@ -136,7 +137,7 @@ function convertToCalculationRule(calc: { resultField: string; formula: string;
*/
async function transformValue(
value: any,
- transform: { tableName: string; matchColumn: string; resultColumn: string }
+ transform: { tableName: string; matchColumn: string; resultColumn: string },
): Promise {
if (!value || !transform.tableName || !transform.matchColumn || !transform.resultColumn) {
return value;
@@ -144,19 +145,16 @@ async function transformValue(
try {
// 정확히 일치하는 검색
- const response = await apiClient.post(
- `/table-management/tables/${transform.tableName}/data`,
- {
- search: {
- [transform.matchColumn]: {
- value: value,
- operator: "equals"
- }
- },
- size: 1,
- page: 1
- }
- );
+ const response = await apiClient.post(`/table-management/tables/${transform.tableName}/data`, {
+ search: {
+ [transform.matchColumn]: {
+ value: value,
+ operator: "equals",
+ },
+ },
+ size: 1,
+ page: 1,
+ });
if (response.data.success && response.data.data?.data?.length > 0) {
const transformedValue = response.data.data.data[0][transform.resultColumn];
@@ -186,7 +184,7 @@ async function fetchExternalLookupValue(
},
rowData: any,
sourceData: any,
- formData: FormDataState
+ formData: FormDataState,
): Promise {
// 1. 비교 값 가져오기
let matchValue: any;
@@ -199,31 +197,32 @@ async function fetchExternalLookupValue(
}
if (matchValue === undefined || matchValue === null || matchValue === "") {
- console.warn(`외부 테이블 조회: 비교 값이 없습니다. (${externalLookup.matchSourceType}.${externalLookup.matchSourceField})`);
+ console.warn(
+ `외부 테이블 조회: 비교 값이 없습니다. (${externalLookup.matchSourceType}.${externalLookup.matchSourceField})`,
+ );
return undefined;
}
// 2. 외부 테이블에서 값 조회 (정확히 일치하는 검색)
try {
- const response = await apiClient.post(
- `/table-management/tables/${externalLookup.tableName}/data`,
- {
- search: {
- [externalLookup.matchColumn]: {
- value: matchValue,
- operator: "equals"
- }
- },
- size: 1,
- page: 1
- }
- );
+ const response = await apiClient.post(`/table-management/tables/${externalLookup.tableName}/data`, {
+ search: {
+ [externalLookup.matchColumn]: {
+ value: matchValue,
+ operator: "equals",
+ },
+ },
+ size: 1,
+ page: 1,
+ });
if (response.data.success && response.data.data?.data?.length > 0) {
return response.data.data.data[0][externalLookup.resultColumn];
}
- console.warn(`외부 테이블 조회: ${externalLookup.tableName}.${externalLookup.matchColumn} = "${matchValue}" 인 행을 찾을 수 없습니다.`);
+ console.warn(
+ `외부 테이블 조회: ${externalLookup.tableName}.${externalLookup.matchColumn} = "${matchValue}" 인 행을 찾을 수 없습니다.`,
+ );
return undefined;
} catch (error) {
console.error("외부 테이블 조회 오류:", error);
@@ -233,7 +232,7 @@ async function fetchExternalLookupValue(
/**
* 외부 테이블에서 값을 조회하는 함수
- *
+ *
* @param tableName - 조회할 테이블명
* @param valueColumn - 가져올 컬럼명
* @param joinConditions - 조인 조건 목록
@@ -247,7 +246,7 @@ async function fetchExternalValue(
joinConditions: TableJoinCondition[],
rowData: any,
sourceData: any,
- formData: FormDataState
+ formData: FormDataState,
): Promise {
if (joinConditions.length === 0) {
return undefined;
@@ -298,15 +297,16 @@ async function fetchExternalValue(
// 정확히 일치하는 검색을 위해 operator: "equals" 사용
whereConditions[condition.targetColumn] = {
value: convertedValue,
- operator: "equals"
+ operator: "equals",
};
}
// API 호출
- const response = await apiClient.post(
- `/table-management/tables/${tableName}/data`,
- { search: whereConditions, size: 1, page: 1 }
- );
+ const response = await apiClient.post(`/table-management/tables/${tableName}/data`, {
+ search: whereConditions,
+ size: 1,
+ page: 1,
+ });
if (response.data.success && response.data.data?.data?.length > 0) {
return response.data.data.data[0][valueColumn];
@@ -334,42 +334,42 @@ export function TableSectionRenderer({
}: TableSectionRendererProps) {
// 테이블 데이터 상태 (일반 모드)
const [tableData, setTableData] = useState([]);
-
+
// 조건부 테이블 데이터 상태 (조건별로 분리)
const [conditionalTableData, setConditionalTableData] = useState({});
-
+
// 조건부 테이블: 선택된 조건들 (체크박스 모드)
const [selectedConditions, setSelectedConditions] = useState([]);
-
+
// 조건부 테이블: 현재 활성 탭
const [activeConditionTab, setActiveConditionTab] = useState("");
-
+
// 조건부 테이블: 현재 모달이 열린 조건 (어떤 조건의 테이블에 추가할지)
const [modalCondition, setModalCondition] = useState("");
-
+
// 모달 상태
const [modalOpen, setModalOpen] = useState(false);
-
+
// 체크박스 선택 상태 (조건별로 분리)
const [selectedRows, setSelectedRows] = useState>(new Set());
const [conditionalSelectedRows, setConditionalSelectedRows] = useState>>({});
-
+
// 너비 조정 트리거 (홀수: 자동맞춤, 짝수: 균등분배)
const [widthTrigger, setWidthTrigger] = useState(0);
-
+
// 동적 데이터 소스 활성화 상태
const [activeDataSources, setActiveDataSources] = useState>({});
-
+
// 날짜 일괄 적용 완료 플래그 (컬럼별로 한 번만 적용)
const [batchAppliedFields, setBatchAppliedFields] = useState>(new Set());
// 초기 데이터 로드 완료 플래그 (무한 루프 방지)
const initialDataLoadedRef = React.useRef(false);
-
+
// 조건부 테이블 설정
const conditionalConfig = tableConfig.conditionalTable;
const isConditionalMode = conditionalConfig?.enabled ?? false;
-
+
// 조건부 테이블: 동적 옵션 로드 상태
const [dynamicOptions, setDynamicOptions] = useState<{ id: string; value: string; label: string }[]>([]);
const [dynamicOptionsLoading, setDynamicOptionsLoading] = useState(false);
@@ -380,51 +380,48 @@ export function TableSectionRenderer({
if (!isConditionalMode) return;
if (!conditionalConfig?.optionSource?.enabled) return;
if (dynamicOptionsLoadedRef.current) return;
-
+
const { tableName, valueColumn, labelColumn, filterCondition } = conditionalConfig.optionSource;
-
+
if (!tableName || !valueColumn) return;
-
+
const loadDynamicOptions = async () => {
setDynamicOptionsLoading(true);
try {
// DISTINCT 값을 가져오기 위한 API 호출
- const response = await apiClient.post(
- `/table-management/tables/${tableName}/data`,
- {
- search: filterCondition ? { _raw: filterCondition } : {},
- size: 1000,
- page: 1,
- }
- );
-
+ const response = await apiClient.post(`/table-management/tables/${tableName}/data`, {
+ search: filterCondition ? { _raw: filterCondition } : {},
+ size: 1000,
+ page: 1,
+ });
+
if (response.data.success && response.data.data?.data) {
const rows = response.data.data.data;
-
+
// 중복 제거하여 고유 값 추출
const uniqueValues = new Map();
for (const row of rows) {
const value = row[valueColumn];
if (value && !uniqueValues.has(value)) {
- const label = labelColumn ? (row[labelColumn] || value) : value;
+ const label = labelColumn ? row[labelColumn] || value : value;
uniqueValues.set(value, label);
}
}
-
+
// 옵션 배열로 변환
const options = Array.from(uniqueValues.entries()).map(([value, label], index) => ({
id: `dynamic_${index}`,
value,
label,
}));
-
+
console.log("[TableSectionRenderer] 동적 옵션 로드 완료:", {
tableName,
valueColumn,
optionCount: options.length,
options,
});
-
+
setDynamicOptions(options);
dynamicOptionsLoadedRef.current = true;
}
@@ -434,48 +431,45 @@ export function TableSectionRenderer({
setDynamicOptionsLoading(false);
}
};
-
+
loadDynamicOptions();
}, [isConditionalMode, conditionalConfig?.optionSource]);
// ============================================
// 동적 Select 옵션 (소스 테이블에서 드롭다운 옵션 로드)
// ============================================
-
+
// 소스 테이블 데이터 캐시 (동적 Select 옵션용)
const [sourceDataCache, setSourceDataCache] = useState([]);
const sourceDataLoadedRef = React.useRef(false);
-
+
// 동적 Select 옵션이 있는 컬럼 확인
const hasDynamicSelectColumns = useMemo(() => {
- return tableConfig.columns?.some(col => col.dynamicSelectOptions?.enabled);
+ return tableConfig.columns?.some((col) => col.dynamicSelectOptions?.enabled);
}, [tableConfig.columns]);
-
+
// 소스 테이블 데이터 로드 (동적 Select 옵션용)
useEffect(() => {
if (!hasDynamicSelectColumns) return;
if (sourceDataLoadedRef.current) return;
if (!tableConfig.source?.tableName) return;
-
+
const loadSourceData = async () => {
try {
// 조건부 테이블 필터 조건 적용
const filterCondition: Record = {};
-
+
// 소스 필터가 활성화되어 있고 조건이 선택되어 있으면 필터 적용
if (conditionalConfig?.sourceFilter?.enabled && activeConditionTab) {
filterCondition[conditionalConfig.sourceFilter.filterColumn] = activeConditionTab;
}
-
- const response = await apiClient.post(
- `/table-management/tables/${tableConfig.source.tableName}/data`,
- {
- search: filterCondition,
- size: 1000,
- page: 1,
- }
- );
-
+
+ const response = await apiClient.post(`/table-management/tables/${tableConfig.source.tableName}/data`, {
+ search: filterCondition,
+ size: 1000,
+ page: 1,
+ });
+
if (response.data.success && response.data.data?.data) {
setSourceDataCache(response.data.data.data);
sourceDataLoadedRef.current = true;
@@ -489,36 +483,33 @@ export function TableSectionRenderer({
console.error("[TableSectionRenderer] 소스 데이터 로드 실패:", error);
}
};
-
+
loadSourceData();
}, [hasDynamicSelectColumns, tableConfig.source?.tableName, conditionalConfig?.sourceFilter, activeConditionTab]);
-
+
// 조건 탭 변경 시 소스 데이터 다시 로드
useEffect(() => {
if (!hasDynamicSelectColumns) return;
if (!conditionalConfig?.sourceFilter?.enabled) return;
if (!activeConditionTab) return;
if (!tableConfig.source?.tableName) return;
-
+
// 조건 변경 시 캐시 리셋하고 즉시 다시 로드
sourceDataLoadedRef.current = false;
setSourceDataCache([]);
-
+
// 즉시 데이터 다시 로드 (기존 useEffect에 의존하지 않고 직접 호출)
const loadSourceData = async () => {
try {
const filterCondition: Record = {};
filterCondition[conditionalConfig.sourceFilter!.filterColumn] = activeConditionTab;
-
- const response = await apiClient.post(
- `/table-management/tables/${tableConfig.source!.tableName}/data`,
- {
- search: filterCondition,
- size: 1000,
- page: 1,
- }
- );
-
+
+ const response = await apiClient.post(`/table-management/tables/${tableConfig.source!.tableName}/data`, {
+ search: filterCondition,
+ size: 1000,
+ page: 1,
+ });
+
if (response.data.success && response.data.data?.data) {
setSourceDataCache(response.data.data.data);
sourceDataLoadedRef.current = true;
@@ -532,96 +523,100 @@ export function TableSectionRenderer({
console.error("[TableSectionRenderer] 소스 데이터 로드 실패:", error);
}
};
-
+
loadSourceData();
- }, [activeConditionTab, hasDynamicSelectColumns, conditionalConfig?.sourceFilter?.enabled, conditionalConfig?.sourceFilter?.filterColumn, tableConfig.source?.tableName]);
-
+ }, [
+ activeConditionTab,
+ hasDynamicSelectColumns,
+ conditionalConfig?.sourceFilter?.enabled,
+ conditionalConfig?.sourceFilter?.filterColumn,
+ tableConfig.source?.tableName,
+ ]);
+
// 컬럼별 동적 Select 옵션 생성
const dynamicSelectOptionsMap = useMemo(() => {
const optionsMap: Record = {};
-
+
if (!sourceDataCache.length) return optionsMap;
-
+
for (const col of tableConfig.columns || []) {
if (!col.dynamicSelectOptions?.enabled) continue;
-
+
const { sourceField, labelField, distinct = true } = col.dynamicSelectOptions;
-
+
if (!sourceField) continue;
-
+
// 소스 데이터에서 옵션 추출
const seenValues = new Set();
const options: { value: string; label: string }[] = [];
-
+
for (const row of sourceDataCache) {
const value = row[sourceField];
if (value === undefined || value === null || value === "") continue;
-
+
const stringValue = String(value);
-
+
if (distinct && seenValues.has(stringValue)) continue;
seenValues.add(stringValue);
-
- const label = labelField ? (row[labelField] || stringValue) : stringValue;
+
+ const label = labelField ? row[labelField] || stringValue : stringValue;
options.push({ value: stringValue, label: String(label) });
}
-
+
optionsMap[col.field] = options;
}
-
+
return optionsMap;
}, [sourceDataCache, tableConfig.columns]);
-
+
// 데이터 변경 핸들러 (날짜 일괄 적용 로직 포함) - 다른 함수에서 참조하므로 먼저 정의
const handleDataChange = useCallback(
(newData: any[]) => {
let processedData = newData;
-
+
// 날짜 일괄 적용 로직: batchApply가 활성화된 날짜 컬럼 처리
- const batchApplyColumns = tableConfig.columns.filter(
- (col) => col.type === "date" && col.batchApply === true
- );
-
+ const batchApplyColumns = tableConfig.columns.filter((col) => col.type === "date" && col.batchApply === true);
+
for (const dateCol of batchApplyColumns) {
// 이미 일괄 적용된 컬럼은 건너뜀
if (batchAppliedFields.has(dateCol.field)) continue;
-
+
// 해당 컬럼에 값이 있는 행과 없는 행 분류
const itemsWithDate = processedData.filter((item) => item[dateCol.field]);
const itemsWithoutDate = processedData.filter((item) => !item[dateCol.field]);
-
+
// 조건: 정확히 1개만 날짜가 있고, 나머지는 모두 비어있을 때
if (itemsWithDate.length === 1 && itemsWithoutDate.length > 0) {
const selectedDate = itemsWithDate[0][dateCol.field];
-
+
// 모든 행에 동일한 날짜 적용
processedData = processedData.map((item) => ({
...item,
[dateCol.field]: selectedDate,
}));
-
+
// 플래그 활성화 (이후 개별 수정 가능)
setBatchAppliedFields((prev) => new Set([...prev, dateCol.field]));
}
}
-
+
setTableData(processedData);
onTableDataChange(processedData);
},
- [onTableDataChange, tableConfig.columns, batchAppliedFields]
+ [onTableDataChange, tableConfig.columns, batchAppliedFields],
);
-
+
// 행 선택 모드: 드롭다운 값 변경 시 같은 소스 행의 다른 컬럼들 자동 채움
const handleDynamicSelectChange = useCallback(
(rowIndex: number, columnField: string, selectedValue: string, conditionValue?: string) => {
- const column = tableConfig.columns?.find(col => col.field === columnField);
+ const column = tableConfig.columns?.find((col) => col.field === columnField);
if (!column?.dynamicSelectOptions?.rowSelectionMode?.enabled) {
// 행 선택 모드가 아니면 일반 값 변경만
if (conditionValue && isConditionalMode) {
const currentData = conditionalTableData[conditionValue] || [];
const newData = [...currentData];
newData[rowIndex] = { ...newData[rowIndex], [columnField]: selectedValue };
- setConditionalTableData(prev => ({ ...prev, [conditionValue]: newData }));
+ setConditionalTableData((prev) => ({ ...prev, [conditionValue]: newData }));
onConditionalTableDataChange?.(conditionValue, newData);
} else {
const newData = [...tableData];
@@ -630,18 +625,18 @@ export function TableSectionRenderer({
}
return;
}
-
+
// 행 선택 모드: 소스 데이터에서 해당 값을 가진 행 찾기
const { sourceField } = column.dynamicSelectOptions;
const { autoFillColumns, sourceIdColumn, targetIdField } = column.dynamicSelectOptions.rowSelectionMode;
-
- const sourceRow = sourceDataCache.find(row => String(row[sourceField]) === selectedValue);
-
+
+ const sourceRow = sourceDataCache.find((row) => String(row[sourceField]) === selectedValue);
+
if (!sourceRow) {
console.warn(`[TableSectionRenderer] 소스 행을 찾을 수 없음: ${sourceField} = ${selectedValue}`);
return;
}
-
+
// 현재 행 데이터 가져오기
let currentData: any[];
if (conditionValue && isConditionalMode) {
@@ -649,10 +644,10 @@ export function TableSectionRenderer({
} else {
currentData = tableData;
}
-
+
const newData = [...currentData];
const updatedRow = { ...newData[rowIndex], [columnField]: selectedValue };
-
+
// 자동 채움 매핑 적용
if (autoFillColumns) {
for (const mapping of autoFillColumns) {
@@ -662,22 +657,22 @@ export function TableSectionRenderer({
}
}
}
-
+
// 소스 ID 저장
if (sourceIdColumn && targetIdField) {
updatedRow[targetIdField] = sourceRow[sourceIdColumn];
}
-
+
newData[rowIndex] = updatedRow;
-
+
// 데이터 업데이트
if (conditionValue && isConditionalMode) {
- setConditionalTableData(prev => ({ ...prev, [conditionValue]: newData }));
+ setConditionalTableData((prev) => ({ ...prev, [conditionValue]: newData }));
onConditionalTableDataChange?.(conditionValue, newData);
} else {
handleDataChange(newData);
}
-
+
console.log("[TableSectionRenderer] 행 선택 모드 자동 채움:", {
columnField,
selectedValue,
@@ -685,93 +680,101 @@ export function TableSectionRenderer({
updatedRow,
});
},
- [tableConfig.columns, sourceDataCache, tableData, conditionalTableData, isConditionalMode, handleDataChange, onConditionalTableDataChange]
+ [
+ tableConfig.columns,
+ sourceDataCache,
+ tableData,
+ conditionalTableData,
+ isConditionalMode,
+ handleDataChange,
+ onConditionalTableDataChange,
+ ],
);
// 참조 컬럼 값 조회 함수 (saveToTarget: false인 컬럼에 대해 소스 테이블 조회)
- const loadReferenceColumnValues = useCallback(async (data: any[]) => {
- // saveToTarget: false이고 referenceDisplay가 설정된 컬럼 찾기
- const referenceColumns = (tableConfig.columns || []).filter(
- (col) => col.saveConfig?.saveToTarget === false && col.saveConfig?.referenceDisplay
- );
-
- if (referenceColumns.length === 0) return;
-
- const sourceTableName = tableConfig.source?.tableName;
- if (!sourceTableName) {
- console.warn("[TableSectionRenderer] 참조 조회를 위한 소스 테이블이 설정되지 않았습니다.");
- return;
- }
-
- // 참조 ID들 수집 (중복 제거)
- const referenceIdSet = new Set();
-
- for (const col of referenceColumns) {
- const refDisplay = col.saveConfig!.referenceDisplay!;
-
- for (const row of data) {
- const refId = row[refDisplay.referenceIdField];
- if (refId !== undefined && refId !== null && refId !== "") {
- referenceIdSet.add(String(refId));
+ const loadReferenceColumnValues = useCallback(
+ async (data: any[]) => {
+ // saveToTarget: false이고 referenceDisplay가 설정된 컬럼 찾기
+ const referenceColumns = (tableConfig.columns || []).filter(
+ (col) => col.saveConfig?.saveToTarget === false && col.saveConfig?.referenceDisplay,
+ );
+
+ if (referenceColumns.length === 0) return;
+
+ const sourceTableName = tableConfig.source?.tableName;
+ if (!sourceTableName) {
+ console.warn("[TableSectionRenderer] 참조 조회를 위한 소스 테이블이 설정되지 않았습니다.");
+ return;
+ }
+
+ // 참조 ID들 수집 (중복 제거)
+ const referenceIdSet = new Set();
+
+ for (const col of referenceColumns) {
+ const refDisplay = col.saveConfig!.referenceDisplay!;
+
+ for (const row of data) {
+ const refId = row[refDisplay.referenceIdField];
+ if (refId !== undefined && refId !== null && refId !== "") {
+ referenceIdSet.add(String(refId));
+ }
}
}
- }
-
- if (referenceIdSet.size === 0) return;
-
- try {
- // 소스 테이블에서 참조 ID에 해당하는 데이터 조회
- const response = await apiClient.post(
- `/table-management/tables/${sourceTableName}/data`,
- {
+
+ if (referenceIdSet.size === 0) return;
+
+ try {
+ // 소스 테이블에서 참조 ID에 해당하는 데이터 조회
+ const response = await apiClient.post(`/table-management/tables/${sourceTableName}/data`, {
search: { id: Array.from(referenceIdSet) }, // ID 배열로 조회
size: 1000,
page: 1,
+ });
+
+ if (!response.data?.success || !response.data?.data?.data) {
+ console.warn("[TableSectionRenderer] 참조 데이터 조회 실패");
+ return;
}
- );
-
- if (!response.data?.success || !response.data?.data?.data) {
- console.warn("[TableSectionRenderer] 참조 데이터 조회 실패");
- return;
- }
-
- const sourceData: any[] = response.data.data.data;
-
- // ID를 키로 하는 맵 생성
- const sourceDataMap: Record = {};
- for (const sourceRow of sourceData) {
- sourceDataMap[String(sourceRow.id)] = sourceRow;
- }
-
- // 각 행에 참조 컬럼 값 채우기
- const updatedData = data.map((row) => {
- const newRow = { ...row };
-
- for (const col of referenceColumns) {
- const refDisplay = col.saveConfig!.referenceDisplay!;
- const refId = row[refDisplay.referenceIdField];
-
- if (refId !== undefined && refId !== null && refId !== "") {
- const sourceRow = sourceDataMap[String(refId)];
- if (sourceRow) {
- newRow[col.field] = sourceRow[refDisplay.sourceColumn];
+
+ const sourceData: any[] = response.data.data.data;
+
+ // ID를 키로 하는 맵 생성
+ const sourceDataMap: Record = {};
+ for (const sourceRow of sourceData) {
+ sourceDataMap[String(sourceRow.id)] = sourceRow;
+ }
+
+ // 각 행에 참조 컬럼 값 채우기
+ const updatedData = data.map((row) => {
+ const newRow = { ...row };
+
+ for (const col of referenceColumns) {
+ const refDisplay = col.saveConfig!.referenceDisplay!;
+ const refId = row[refDisplay.referenceIdField];
+
+ if (refId !== undefined && refId !== null && refId !== "") {
+ const sourceRow = sourceDataMap[String(refId)];
+ if (sourceRow) {
+ newRow[col.field] = sourceRow[refDisplay.sourceColumn];
+ }
}
}
- }
-
- return newRow;
- });
-
- console.log("[TableSectionRenderer] 참조 컬럼 값 조회 완료:", {
- referenceColumns: referenceColumns.map((c) => c.field),
- updatedRowCount: updatedData.length,
- });
-
- setTableData(updatedData);
- } catch (error) {
- console.error("[TableSectionRenderer] 참조 데이터 조회 실패:", error);
- }
- }, [tableConfig.columns, tableConfig.source?.tableName]);
+
+ return newRow;
+ });
+
+ console.log("[TableSectionRenderer] 참조 컬럼 값 조회 완료:", {
+ referenceColumns: referenceColumns.map((c) => c.field),
+ updatedRowCount: updatedData.length,
+ });
+
+ setTableData(updatedData);
+ } catch (error) {
+ console.error("[TableSectionRenderer] 참조 데이터 조회 실패:", error);
+ }
+ },
+ [tableConfig.columns, tableConfig.source?.tableName],
+ );
// formData에서 초기 테이블 데이터 로드 (수정 모드에서 _groupedData 표시)
useEffect(() => {
@@ -788,7 +791,7 @@ export function TableSectionRenderer({
});
setTableData(initialData);
initialDataLoadedRef.current = true;
-
+
// 참조 컬럼 값 조회 (saveToTarget: false인 컬럼)
loadReferenceColumnValues(initialData);
}
@@ -796,14 +799,14 @@ export function TableSectionRenderer({
// RepeaterColumnConfig로 변환 (동적 Select 옵션 반영)
const columns: RepeaterColumnConfig[] = useMemo(() => {
- return (tableConfig.columns || []).map(col => {
+ return (tableConfig.columns || []).map((col) => {
const baseColumn = convertToRepeaterColumn(col);
-
+
// 동적 Select 옵션이 있으면 적용
if (col.dynamicSelectOptions?.enabled && dynamicSelectOptionsMap[col.field]) {
baseColumn.selectOptions = dynamicSelectOptionsMap[col.field];
}
-
+
return baseColumn;
});
}, [tableConfig.columns, dynamicSelectOptionsMap]);
@@ -840,23 +843,24 @@ export function TableSectionRenderer({
return updatedRow;
},
- [calculationRules]
+ [calculationRules],
);
const calculateAll = useCallback(
(data: any[]): any[] => {
return data.map((row) => calculateRow(row));
},
- [calculateRow]
+ [calculateRow],
);
// 행 변경 핸들러 (동적 Select 행 선택 모드 지원)
const handleRowChange = useCallback(
(index: number, newRow: any, conditionValue?: string) => {
- const oldRow = conditionValue && isConditionalMode
- ? (conditionalTableData[conditionValue]?.[index] || {})
- : (tableData[index] || {});
-
+ const oldRow =
+ conditionValue && isConditionalMode
+ ? conditionalTableData[conditionValue]?.[index] || {}
+ : tableData[index] || {};
+
// 변경된 필드 찾기
const changedFields: string[] = [];
for (const key of Object.keys(newRow)) {
@@ -864,25 +868,25 @@ export function TableSectionRenderer({
changedFields.push(key);
}
}
-
+
// 동적 Select 컬럼의 행 선택 모드 확인
for (const changedField of changedFields) {
- const column = tableConfig.columns?.find(col => col.field === changedField);
+ const column = tableConfig.columns?.find((col) => col.field === changedField);
if (column?.dynamicSelectOptions?.rowSelectionMode?.enabled) {
// 행 선택 모드 처리 (자동 채움)
handleDynamicSelectChange(index, changedField, newRow[changedField], conditionValue);
return; // 행 선택 모드에서 처리 완료
}
}
-
+
// 일반 행 변경 처리
const calculatedRow = calculateRow(newRow);
-
+
if (conditionValue && isConditionalMode) {
const currentData = conditionalTableData[conditionValue] || [];
const newData = [...currentData];
newData[index] = calculatedRow;
- setConditionalTableData(prev => ({ ...prev, [conditionValue]: newData }));
+ setConditionalTableData((prev) => ({ ...prev, [conditionValue]: newData }));
onConditionalTableDataChange?.(conditionValue, newData);
} else {
const newData = [...tableData];
@@ -890,7 +894,16 @@ export function TableSectionRenderer({
handleDataChange(newData);
}
},
- [tableData, conditionalTableData, isConditionalMode, tableConfig.columns, calculateRow, handleDataChange, handleDynamicSelectChange, onConditionalTableDataChange]
+ [
+ tableData,
+ conditionalTableData,
+ isConditionalMode,
+ tableConfig.columns,
+ calculateRow,
+ handleDataChange,
+ handleDynamicSelectChange,
+ onConditionalTableDataChange,
+ ],
);
// 행 삭제 핸들러
@@ -899,7 +912,7 @@ export function TableSectionRenderer({
const newData = tableData.filter((_, i) => i !== index);
handleDataChange(newData);
},
- [tableData, handleDataChange]
+ [tableData, handleDataChange],
);
// 선택된 항목 일괄 삭제
@@ -908,7 +921,7 @@ export function TableSectionRenderer({
const newData = tableData.filter((_, index) => !selectedRows.has(index));
handleDataChange(newData);
setSelectedRows(new Set());
-
+
// 데이터가 모두 삭제되면 일괄 적용 플래그도 리셋
if (newData.length === 0) {
setBatchAppliedFields(new Set());
@@ -931,7 +944,7 @@ export function TableSectionRenderer({
// 현재 활성화된 옵션 또는 기본 옵션 사용
const activeOptionId = activeDataSources[col.field];
const defaultOption = col.lookup.options.find((o) => o.isDefault) || col.lookup.options[0];
- const selectedOption = activeOptionId
+ const selectedOption = activeOptionId
? col.lookup.options.find((o) => o.id === activeOptionId) || defaultOption
: defaultOption;
@@ -969,11 +982,13 @@ export function TableSectionRenderer({
// 외부 테이블 조회 설정
externalLookup: cond.externalLookup,
// 값 변환 설정 전달 (레거시 호환)
- transform: cond.transform?.enabled ? {
- tableName: cond.transform.tableName,
- matchColumn: cond.transform.matchColumn,
- resultColumn: cond.transform.resultColumn,
- } : undefined,
+ transform: cond.transform?.enabled
+ ? {
+ tableName: cond.transform.tableName,
+ matchColumn: cond.transform.matchColumn,
+ resultColumn: cond.transform.resultColumn,
+ }
+ : undefined,
};
});
@@ -982,15 +997,15 @@ export function TableSectionRenderer({
selectedOption.tableName,
selectedOption.valueColumn,
joinConditions,
- { ...sourceItem, ...newItem }, // rowData (현재 행)
- sourceItem, // sourceData (소스 테이블 원본)
- formData
+ { ...sourceItem, ...newItem }, // rowData (현재 행)
+ sourceItem, // sourceData (소스 테이블 원본)
+ formData,
);
-
+
if (value !== undefined) {
newItem[col.field] = value;
}
-
+
// _sourceData에 원본 저장
newItem._sourceData = sourceItem;
}
@@ -1045,8 +1060,8 @@ export function TableSectionRenderer({
valueColumn,
joinConditions,
{ ...sourceItem, ...newItem }, // rowData
- sourceItem, // sourceData
- formData
+ sourceItem, // sourceData
+ formData,
);
if (value !== undefined) {
newItem[col.field] = value;
@@ -1070,7 +1085,7 @@ export function TableSectionRenderer({
}
return newItem;
- })
+ }),
);
// 계산 필드 업데이트
@@ -1080,7 +1095,7 @@ export function TableSectionRenderer({
const newData = [...tableData, ...calculatedItems];
handleDataChange(newData);
},
- [tableConfig.columns, formData, tableData, calculateAll, handleDataChange, activeDataSources]
+ [tableConfig.columns, formData, tableData, calculateAll, handleDataChange, activeDataSources],
);
// 컬럼 모드/조회 옵션 변경 핸들러
@@ -1093,7 +1108,7 @@ export function TableSectionRenderer({
// 해당 컬럼의 모든 행 데이터 재조회
const column = tableConfig.columns.find((col) => col.field === columnField);
-
+
// lookup 설정이 있는 경우 (새로운 조회 기능)
if (column?.lookup?.enabled && column.lookup.options) {
const selectedOption = column.lookup.options.find((opt) => opt.id === optionId);
@@ -1140,11 +1155,13 @@ export function TableSectionRenderer({
// 외부 테이블 조회 설정
externalLookup: cond.externalLookup,
// 값 변환 설정 전달 (레거시 호환)
- transform: cond.transform?.enabled ? {
- tableName: cond.transform.tableName,
- matchColumn: cond.transform.matchColumn,
- resultColumn: cond.transform.resultColumn,
- } : undefined,
+ transform: cond.transform?.enabled
+ ? {
+ tableName: cond.transform.tableName,
+ matchColumn: cond.transform.matchColumn,
+ resultColumn: cond.transform.resultColumn,
+ }
+ : undefined,
};
});
@@ -1156,15 +1173,15 @@ export function TableSectionRenderer({
joinConditions,
row,
sourceData,
- formData
+ formData,
);
-
+
if (value !== undefined) {
newValue = value;
}
return { ...row, [columnField]: newValue };
- })
+ }),
);
// 계산 필드 업데이트
@@ -1199,14 +1216,14 @@ export function TableSectionRenderer({
}
return { ...row, [columnField]: newValue };
- })
+ }),
);
// 계산 필드 업데이트
const calculatedData = calculateAll(updatedData);
handleDataChange(calculatedData);
},
- [tableConfig.columns, tableData, formData, calculateAll, handleDataChange]
+ [tableConfig.columns, tableData, formData, calculateAll, handleDataChange],
);
// 소스 테이블 정보
@@ -1216,10 +1233,16 @@ export function TableSectionRenderer({
const sourceSearchFields = source.searchColumns;
const columnLabels = source.columnLabels || {};
const modalTitle = uiConfig?.modalTitle || "항목 검색 및 선택";
- const addButtonType = uiConfig?.addButtonType || "search";
- const addButtonText = uiConfig?.addButtonText || (addButtonType === "addRow" ? "항목 추가" : "항목 검색");
const multiSelect = uiConfig?.multiSelect ?? true;
+ // 버튼 표시 설정 (두 버튼 동시 표시 가능)
+ // 레거시 호환: 기존 addButtonType 설정이 있으면 그에 맞게 변환
+ const legacyAddButtonType = uiConfig?.addButtonType;
+ const showSearchButton = legacyAddButtonType === "addRow" ? false : (uiConfig?.showSearchButton ?? true);
+ const showAddRowButton = legacyAddButtonType === "addRow" ? true : (uiConfig?.showAddRowButton ?? false);
+ const searchButtonText = uiConfig?.searchButtonText || uiConfig?.addButtonText || "품목 검색";
+ const addRowButtonText = uiConfig?.addRowButtonText || "직접 입력";
+
// 기본 필터 조건 생성 (사전 필터만 - 모달 필터는 ItemSelectionModal에서 처리)
const baseFilterCondition: Record = useMemo(() => {
const condition: Record = {};
@@ -1233,19 +1256,19 @@ export function TableSectionRenderer({
}
return condition;
}, [filters?.preFilters]);
-
+
// 조건부 테이블용 필터 조건 생성 (선택된 조건값으로 소스 테이블 필터링)
const conditionalFilterCondition = useMemo(() => {
const filter = { ...baseFilterCondition };
-
+
// 조건부 테이블의 소스 필터 설정이 있고, 모달에서 선택된 조건이 있으면 적용
if (conditionalConfig?.sourceFilter?.enabled && modalCondition) {
filter[conditionalConfig.sourceFilter.filterColumn] = modalCondition;
}
-
+
return filter;
}, [baseFilterCondition, conditionalConfig?.sourceFilter, modalCondition]);
-
+
// 모달 필터 설정을 ItemSelectionModal에 전달할 형식으로 변환
const modalFiltersForModal = useMemo(() => {
if (!filters?.modalFilters) return [];
@@ -1253,7 +1276,7 @@ export function TableSectionRenderer({
column: filter.column,
label: filter.label || filter.column,
// category 타입을 select로 변환 (ModalFilterConfig 호환)
- type: filter.type === "category" ? "select" as const : filter.type as "text" | "select",
+ type: filter.type === "category" ? ("select" as const) : (filter.type as "text" | "select"),
options: filter.options,
categoryRef: filter.categoryRef,
defaultValue: filter.defaultValue,
@@ -1265,138 +1288,156 @@ export function TableSectionRenderer({
// ============================================
// 조건부 테이블: 조건 체크박스 토글
- const handleConditionToggle = useCallback((conditionValue: string, checked: boolean) => {
- setSelectedConditions((prev) => {
- if (checked) {
- const newConditions = [...prev, conditionValue];
- // 첫 번째 조건 선택 시 해당 탭 활성화
- if (prev.length === 0) {
- setActiveConditionTab(conditionValue);
+ const handleConditionToggle = useCallback(
+ (conditionValue: string, checked: boolean) => {
+ setSelectedConditions((prev) => {
+ if (checked) {
+ const newConditions = [...prev, conditionValue];
+ // 첫 번째 조건 선택 시 해당 탭 활성화
+ if (prev.length === 0) {
+ setActiveConditionTab(conditionValue);
+ }
+ return newConditions;
+ } else {
+ const newConditions = prev.filter((c) => c !== conditionValue);
+ // 현재 활성 탭이 제거된 경우 다른 탭으로 전환
+ if (activeConditionTab === conditionValue && newConditions.length > 0) {
+ setActiveConditionTab(newConditions[0]);
+ }
+ return newConditions;
}
- return newConditions;
- } else {
- const newConditions = prev.filter((c) => c !== conditionValue);
- // 현재 활성 탭이 제거된 경우 다른 탭으로 전환
- if (activeConditionTab === conditionValue && newConditions.length > 0) {
- setActiveConditionTab(newConditions[0]);
- }
- return newConditions;
- }
- });
- }, [activeConditionTab]);
+ });
+ },
+ [activeConditionTab],
+ );
// 조건부 테이블: 조건별 데이터 변경
- const handleConditionalDataChange = useCallback((conditionValue: string, newData: any[]) => {
- setConditionalTableData((prev) => ({
- ...prev,
- [conditionValue]: newData,
- }));
-
- // 부모에게 조건별 데이터 변경 알림
- if (onConditionalTableDataChange) {
- onConditionalTableDataChange(conditionValue, newData);
- }
-
- // 전체 데이터를 flat array로 변환하여 onTableDataChange 호출
- // (저장 시 조건 컬럼 값이 자동으로 추가됨)
- const conditionColumn = conditionalConfig?.conditionColumn;
- const allData: any[] = [];
-
- // 현재 변경된 조건의 데이터 업데이트
- const updatedConditionalData = { ...conditionalTableData, [conditionValue]: newData };
-
- for (const [condition, data] of Object.entries(updatedConditionalData)) {
- for (const row of data) {
- allData.push({
- ...row,
- ...(conditionColumn ? { [conditionColumn]: condition } : {}),
- });
+ const handleConditionalDataChange = useCallback(
+ (conditionValue: string, newData: any[]) => {
+ setConditionalTableData((prev) => ({
+ ...prev,
+ [conditionValue]: newData,
+ }));
+
+ // 부모에게 조건별 데이터 변경 알림
+ if (onConditionalTableDataChange) {
+ onConditionalTableDataChange(conditionValue, newData);
}
- }
-
- onTableDataChange(allData);
- }, [conditionalTableData, conditionalConfig?.conditionColumn, onConditionalTableDataChange, onTableDataChange]);
+
+ // 전체 데이터를 flat array로 변환하여 onTableDataChange 호출
+ // (저장 시 조건 컬럼 값이 자동으로 추가됨)
+ const conditionColumn = conditionalConfig?.conditionColumn;
+ const allData: any[] = [];
+
+ // 현재 변경된 조건의 데이터 업데이트
+ const updatedConditionalData = { ...conditionalTableData, [conditionValue]: newData };
+
+ for (const [condition, data] of Object.entries(updatedConditionalData)) {
+ for (const row of data) {
+ allData.push({
+ ...row,
+ ...(conditionColumn ? { [conditionColumn]: condition } : {}),
+ });
+ }
+ }
+
+ onTableDataChange(allData);
+ },
+ [conditionalTableData, conditionalConfig?.conditionColumn, onConditionalTableDataChange, onTableDataChange],
+ );
// 조건부 테이블: 조건별 행 변경
- const handleConditionalRowChange = useCallback((conditionValue: string, index: number, newRow: any) => {
- const calculatedRow = calculateRow(newRow);
- const currentData = conditionalTableData[conditionValue] || [];
- const newData = [...currentData];
- newData[index] = calculatedRow;
- handleConditionalDataChange(conditionValue, newData);
- }, [conditionalTableData, calculateRow, handleConditionalDataChange]);
+ const handleConditionalRowChange = useCallback(
+ (conditionValue: string, index: number, newRow: any) => {
+ const calculatedRow = calculateRow(newRow);
+ const currentData = conditionalTableData[conditionValue] || [];
+ const newData = [...currentData];
+ newData[index] = calculatedRow;
+ handleConditionalDataChange(conditionValue, newData);
+ },
+ [conditionalTableData, calculateRow, handleConditionalDataChange],
+ );
// 조건부 테이블: 조건별 행 삭제
- const handleConditionalRowDelete = useCallback((conditionValue: string, index: number) => {
- const currentData = conditionalTableData[conditionValue] || [];
- const newData = currentData.filter((_, i) => i !== index);
- handleConditionalDataChange(conditionValue, newData);
- }, [conditionalTableData, handleConditionalDataChange]);
+ const handleConditionalRowDelete = useCallback(
+ (conditionValue: string, index: number) => {
+ const currentData = conditionalTableData[conditionValue] || [];
+ const newData = currentData.filter((_, i) => i !== index);
+ handleConditionalDataChange(conditionValue, newData);
+ },
+ [conditionalTableData, handleConditionalDataChange],
+ );
// 조건부 테이블: 조건별 선택 행 일괄 삭제
- const handleConditionalBulkDelete = useCallback((conditionValue: string) => {
- const selected = conditionalSelectedRows[conditionValue] || new Set();
- if (selected.size === 0) return;
-
- const currentData = conditionalTableData[conditionValue] || [];
- const newData = currentData.filter((_, index) => !selected.has(index));
- handleConditionalDataChange(conditionValue, newData);
-
- // 선택 상태 초기화
- setConditionalSelectedRows((prev) => ({
- ...prev,
- [conditionValue]: new Set(),
- }));
- }, [conditionalTableData, conditionalSelectedRows, handleConditionalDataChange]);
+ const handleConditionalBulkDelete = useCallback(
+ (conditionValue: string) => {
+ const selected = conditionalSelectedRows[conditionValue] || new Set();
+ if (selected.size === 0) return;
+
+ const currentData = conditionalTableData[conditionValue] || [];
+ const newData = currentData.filter((_, index) => !selected.has(index));
+ handleConditionalDataChange(conditionValue, newData);
+
+ // 선택 상태 초기화
+ setConditionalSelectedRows((prev) => ({
+ ...prev,
+ [conditionValue]: new Set(),
+ }));
+ },
+ [conditionalTableData, conditionalSelectedRows, handleConditionalDataChange],
+ );
// 조건부 테이블: 아이템 추가 (특정 조건에)
- const handleConditionalAddItems = useCallback(async (items: any[]) => {
- if (!modalCondition) return;
-
- // 기존 handleAddItems 로직을 재사용하여 매핑된 아이템 생성
- const mappedItems = await Promise.all(
- items.map(async (sourceItem) => {
- const newItem: any = {};
-
- for (const col of tableConfig.columns) {
- const mapping = col.valueMapping;
-
- // 소스 필드에서 값 복사 (기본)
- if (!mapping) {
- const sourceField = col.sourceField || col.field;
- if (sourceItem[sourceField] !== undefined) {
- newItem[col.field] = sourceItem[sourceField];
+ const handleConditionalAddItems = useCallback(
+ async (items: any[]) => {
+ if (!modalCondition) return;
+
+ // 기존 handleAddItems 로직을 재사용하여 매핑된 아이템 생성
+ const mappedItems = await Promise.all(
+ items.map(async (sourceItem) => {
+ const newItem: any = {};
+
+ for (const col of tableConfig.columns) {
+ const mapping = col.valueMapping;
+
+ // 소스 필드에서 값 복사 (기본)
+ if (!mapping) {
+ const sourceField = col.sourceField || col.field;
+ if (sourceItem[sourceField] !== undefined) {
+ newItem[col.field] = sourceItem[sourceField];
+ }
+ continue;
}
- continue;
- }
-
- // valueMapping 처리
- if (mapping.type === "source" && mapping.sourceField) {
- const value = sourceItem[mapping.sourceField];
- if (value !== undefined) {
- newItem[col.field] = value;
+
+ // valueMapping 처리
+ if (mapping.type === "source" && mapping.sourceField) {
+ const value = sourceItem[mapping.sourceField];
+ if (value !== undefined) {
+ newItem[col.field] = value;
+ }
+ } else if (mapping.type === "manual") {
+ newItem[col.field] = col.defaultValue || "";
+ } else if (mapping.type === "internal" && mapping.internalField) {
+ newItem[col.field] = formData[mapping.internalField];
}
- } else if (mapping.type === "manual") {
- newItem[col.field] = col.defaultValue || "";
- } else if (mapping.type === "internal" && mapping.internalField) {
- newItem[col.field] = formData[mapping.internalField];
}
- }
-
- // 원본 소스 데이터 보존
- newItem._sourceData = sourceItem;
-
- return newItem;
- })
- );
-
- // 현재 조건의 데이터에 추가
- const currentData = conditionalTableData[modalCondition] || [];
- const newData = [...currentData, ...mappedItems];
- handleConditionalDataChange(modalCondition, newData);
-
- setModalOpen(false);
- }, [modalCondition, tableConfig.columns, formData, conditionalTableData, handleConditionalDataChange]);
+
+ // 원본 소스 데이터 보존
+ newItem._sourceData = sourceItem;
+
+ return newItem;
+ }),
+ );
+
+ // 현재 조건의 데이터에 추가
+ const currentData = conditionalTableData[modalCondition] || [];
+ const newData = [...currentData, ...mappedItems];
+ handleConditionalDataChange(modalCondition, newData);
+
+ setModalOpen(false);
+ },
+ [modalCondition, tableConfig.columns, formData, conditionalTableData, handleConditionalDataChange],
+ );
// 조건부 테이블: 모달 열기 (특정 조건에 대해)
const openConditionalModal = useCallback((conditionValue: string) => {
@@ -1405,62 +1446,68 @@ export function TableSectionRenderer({
}, []);
// 조건부 테이블: 빈 행 추가 (addRow 모드에서 사용)
- const addEmptyRowToCondition = useCallback((conditionValue: string) => {
- const newRow: Record = {};
-
- // 각 컬럼의 기본값으로 빈 행 생성
- for (const col of tableConfig.columns) {
- if (col.defaultValue !== undefined) {
- newRow[col.field] = col.defaultValue;
- } else if (col.type === "number") {
- newRow[col.field] = 0;
- } else if (col.type === "checkbox") {
- newRow[col.field] = false;
- } else {
- newRow[col.field] = "";
- }
- }
-
- // 조건 컬럼에 현재 조건 값 설정
- if (conditionalConfig?.conditionColumn) {
- newRow[conditionalConfig.conditionColumn] = conditionValue;
- }
-
- // 현재 조건의 데이터에 추가
- const currentData = conditionalTableData[conditionValue] || [];
- const newData = [...currentData, newRow];
- handleConditionalDataChange(conditionValue, newData);
- }, [tableConfig.columns, conditionalConfig?.conditionColumn, conditionalTableData, handleConditionalDataChange]);
+ const addEmptyRowToCondition = useCallback(
+ (conditionValue: string) => {
+ const newRow: Record = {};
- // 버튼 클릭 핸들러 (addButtonType에 따라 다르게 동작)
- const handleAddButtonClick = useCallback((conditionValue: string) => {
- const addButtonType = tableConfig.uiConfig?.addButtonType || "search";
-
- if (addButtonType === "addRow") {
- // 빈 행 직접 추가
- addEmptyRowToCondition(conditionValue);
- } else {
- // 검색 모달 열기
+ // 각 컬럼의 기본값으로 빈 행 생성
+ for (const col of tableConfig.columns) {
+ if (col.defaultValue !== undefined) {
+ newRow[col.field] = col.defaultValue;
+ } else if (col.type === "number") {
+ newRow[col.field] = 0;
+ } else if (col.type === "checkbox") {
+ newRow[col.field] = false;
+ } else {
+ newRow[col.field] = "";
+ }
+ }
+
+ // 조건 컬럼에 현재 조건 값 설정
+ if (conditionalConfig?.conditionColumn) {
+ newRow[conditionalConfig.conditionColumn] = conditionValue;
+ }
+
+ // 현재 조건의 데이터에 추가
+ const currentData = conditionalTableData[conditionValue] || [];
+ const newData = [...currentData, newRow];
+ handleConditionalDataChange(conditionValue, newData);
+ },
+ [tableConfig.columns, conditionalConfig?.conditionColumn, conditionalTableData, handleConditionalDataChange],
+ );
+
+ // 검색 버튼 클릭 핸들러
+ const handleSearchButtonClick = useCallback(
+ (conditionValue: string) => {
openConditionalModal(conditionValue);
- }
- }, [tableConfig.uiConfig?.addButtonType, addEmptyRowToCondition, openConditionalModal]);
+ },
+ [openConditionalModal],
+ );
+
+ // 행 추가 버튼 클릭 핸들러
+ const handleAddRowButtonClick = useCallback(
+ (conditionValue: string) => {
+ addEmptyRowToCondition(conditionValue);
+ },
+ [addEmptyRowToCondition],
+ );
// 조건부 테이블: 초기 데이터 로드 (수정 모드)
useEffect(() => {
if (!isConditionalMode) return;
if (initialDataLoadedRef.current) return;
-
+
const tableSectionKey = `_tableSection_${sectionId}`;
const initialData = formData[tableSectionKey];
-
+
if (Array.isArray(initialData) && initialData.length > 0) {
const conditionColumn = conditionalConfig?.conditionColumn;
-
+
if (conditionColumn) {
// 조건별로 데이터 그룹핑
const grouped: ConditionalTableData = {};
const conditions = new Set();
-
+
for (const row of initialData) {
const conditionValue = row[conditionColumn] || "";
if (conditionValue) {
@@ -1471,15 +1518,15 @@ export function TableSectionRenderer({
conditions.add(conditionValue);
}
}
-
+
setConditionalTableData(grouped);
setSelectedConditions(Array.from(conditions));
-
+
// 첫 번째 조건을 활성 탭으로 설정
if (conditions.size > 0) {
setActiveConditionTab(Array.from(conditions)[0]);
}
-
+
initialDataLoadedRef.current = true;
}
}
@@ -1495,27 +1542,29 @@ export function TableSectionRenderer({
// ============================================
if (isConditionalMode && conditionalConfig) {
const { triggerType } = conditionalConfig;
-
+
// 정적 옵션과 동적 옵션 병합 (동적 옵션이 있으면 우선 사용)
// 빈 value를 가진 옵션은 제외 (Select.Item은 빈 문자열 value를 허용하지 않음)
- const effectiveOptions = (conditionalConfig.optionSource?.enabled && dynamicOptions.length > 0
- ? dynamicOptions
- : conditionalConfig.options || []).filter(opt => opt.value && opt.value.trim() !== "");
-
+ const effectiveOptions = (
+ conditionalConfig.optionSource?.enabled && dynamicOptions.length > 0
+ ? dynamicOptions
+ : conditionalConfig.options || []
+ ).filter((opt) => opt.value && opt.value.trim() !== "");
+
// 로딩 중이면 로딩 표시
if (dynamicOptionsLoading) {
return (
-
);
}
-
+
return (
{/* 조건 선택 UI */}
@@ -1525,7 +1574,7 @@ export function TableSectionRenderer({
{effectiveOptions.map((option) => (
))}
-
+
{selectedConditions.length > 0 && (
-
+
{selectedConditions.length}개 유형 선택됨, 총 {totalConditionalItems}개 항목
)}
)}
-
+
{triggerType === "dropdown" && (
유형 선택:
@@ -1566,7 +1615,7 @@ export function TableSectionRenderer({
{effectiveOptions.map((option) => (
{option.label}
- {conditionalTableData[option.value]?.length > 0 &&
+ {conditionalTableData[option.value]?.length > 0 &&
` (${conditionalTableData[option.value].length})`}
))}
@@ -1574,7 +1623,7 @@ export function TableSectionRenderer({
)}
-
+
{/* 선택된 조건들의 테이블 (탭 형태) */}
{selectedConditions.length > 0 && (
@@ -1594,17 +1643,17 @@ export function TableSectionRenderer({
);
})}
-
+
{selectedConditions.map((conditionValue) => {
const data = conditionalTableData[conditionValue] || [];
const selected = conditionalSelectedRows[conditionValue] || new Set();
-
+
return (
{/* 테이블 상단 컨트롤 */}
-
+
{data.length > 0 && `${data.length}개 항목`}
{selected.size > 0 && ` (${selected.size}개 선택됨)`}
@@ -1642,20 +1691,25 @@ export function TableSectionRenderer({
선택 삭제 ({selected.size})
)}
- handleAddButtonClick(conditionValue)}
- className="h-8 text-xs"
- >
- {addButtonType === "addRow" ? (
-
- ) : (
+ {showSearchButton && (
+ handleSearchButtonClick(conditionValue)} className="h-8 text-xs">
- )}
- {addButtonText}
-
+ {searchButtonText}
+
+ )}
+ {showAddRowButton && (
+ handleAddRowButtonClick(conditionValue)}
+ className="h-8 text-xs"
+ >
+
+ {addRowButtonText}
+
+ )}
-
+
{/* 테이블 */}
)}
-
+
{/* tabs 모드: 모든 옵션을 탭으로 표시 (선택 UI 없음) */}
{triggerType === "tabs" && effectiveOptions.length > 0 && (
-
@@ -1702,16 +1756,16 @@ export function TableSectionRenderer({
);
})}
-
+
{effectiveOptions.map((option) => {
const data = conditionalTableData[option.value] || [];
const selected = conditionalSelectedRows[option.value] || new Set();
-
+
return (
-
+
{data.length > 0 && `${data.length}개 항목`}
{selected.size > 0 && ` (${selected.size}개 선택됨)`}
@@ -1728,20 +1782,25 @@ export function TableSectionRenderer({
선택 삭제 ({selected.size})
)}
- handleAddButtonClick(option.value)}
- className="h-8 text-xs"
- >
- {addButtonType === "addRow" ? (
-
- ) : (
+ {showSearchButton && (
+ handleSearchButtonClick(option.value)} className="h-8 text-xs">
- )}
- {addButtonText}
-
+ {searchButtonText}
+
+ )}
+ {showAddRowButton && (
+ handleAddRowButtonClick(option.value)}
+ className="h-8 text-xs"
+ >
+
+ {addRowButtonText}
+
+ )}
-
+
)}
-
+
{/* 조건이 선택되지 않은 경우 안내 메시지 (checkbox/dropdown 모드에서만) */}
{selectedConditions.length === 0 && triggerType !== "tabs" && (
-
- {triggerType === "checkbox"
- ? "위에서 유형을 선택하여 검사항목을 추가하세요."
- : "유형을 선택하세요."}
+
+ {triggerType === "checkbox" ? "위에서 유형을 선택하여 검사항목을 추가하세요." : "유형을 선택하세요."}
)}
-
+
{/* 옵션이 없는 경우 안내 메시지 */}
{effectiveOptions.length === 0 && (
-
- 조건 옵션이 설정되지 않았습니다.
-
+
조건 옵션이 설정되지 않았습니다.
)}
-
+
{/* 항목 선택 모달 (조건부 테이블용) */}
{/* 추가 버튼 영역 */}
-
+
-
+
{tableData.length > 0 && `${tableData.length}개 항목`}
{selectedRows.size > 0 && ` (${selectedRows.size}개 선택됨)`}
@@ -1822,17 +1877,17 @@ export function TableSectionRenderer({
variant="outline"
size="sm"
onClick={() => setWidthTrigger((prev) => prev + 1)}
- className="h-7 text-xs px-2"
+ className="h-7 px-2 text-xs"
title={widthTrigger % 2 === 0 ? "내용에 맞게 자동 조정" : "균등 분배"}
>
{widthTrigger % 2 === 0 ? (
<>
-
+
자동 맞춤
>
) : (
<>
-
+
균등 분배
>
)}
@@ -1841,17 +1896,20 @@ export function TableSectionRenderer({
{selectedRows.size > 0 && (
-
+
선택 삭제 ({selectedRows.size})
)}
- {
- if (addButtonType === "addRow") {
+ {showSearchButton && (
+ setModalOpen(true)} className="h-8 text-xs sm:h-10 sm:text-sm">
+
+ {searchButtonText}
+
+ )}
+ {showAddRowButton && (
+ {
// 빈 행 추가
const newRow: Record = {};
for (const col of columns) {
@@ -1866,20 +1924,13 @@ export function TableSectionRenderer({
}
}
handleDataChange([...tableData, newRow]);
- } else {
- // 검색 모달 열기
- setModalOpen(true);
- }
- }}
- className="h-8 text-xs sm:h-10 sm:text-sm"
- >
- {addButtonType === "addRow" ? (
-
- ) : (
-
- )}
- {addButtonText}
-
+ }}
+ className="h-8 text-xs sm:h-10 sm:text-sm"
+ >
+
+ {addRowButtonText}
+
+ )}
diff --git a/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx
index ebd16c44..d82db59b 100644
--- a/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx
@@ -2928,54 +2928,74 @@ export function TableSectionSettingsModal({
{/* UI 설정 */}
UI 설정
-
-
-
추가 버튼 동작
-
updateUiConfig({ addButtonType: value as "search" | "addRow" })}
- >
-
-
-
-
-
-
- 검색 모달 열기
- 기존 데이터에서 선택
-
-
-
-
- 빈 행 추가
- 새 데이터 직접 입력
-
-
-
-
+
+ {/* 버튼 표시 설정 */}
+
+
표시할 버튼 선택
+
+ 두 버튼을 동시에 표시할 수 있습니다.
+
+
+
+
updateUiConfig({ showSearchButton: checked })}
+ className="scale-75"
+ />
+
+
+
+
updateUiConfig({ showAddRowButton: checked })}
+ className="scale-75"
+ />
+
+
+
+
+
+ {/* 검색 버튼 텍스트 */}
- 추가 버튼 텍스트
+ 검색 버튼 텍스트
updateUiConfig({ addButtonText: e.target.value })}
- placeholder={tableConfig.uiConfig?.addButtonType === "addRow" ? "항목 추가" : "항목 검색"}
+ value={tableConfig.uiConfig?.searchButtonText || ""}
+ onChange={(e) => updateUiConfig({ searchButtonText: e.target.value })}
+ placeholder="품목 검색"
className="h-8 text-xs mt-1"
+ disabled={!(tableConfig.uiConfig?.showSearchButton ?? true)}
/>
+ {/* 행 추가 버튼 텍스트 */}
- 모달 제목
+ 행 추가 버튼 텍스트
+ updateUiConfig({ addRowButtonText: e.target.value })}
+ placeholder="직접 입력"
+ className="h-8 text-xs mt-1"
+ disabled={!tableConfig.uiConfig?.showAddRowButton}
+ />
+
+ {/* 모달 제목 */}
+
+
검색 모달 제목
updateUiConfig({ modalTitle: e.target.value })}
placeholder="항목 검색 및 선택"
className="h-8 text-xs mt-1"
- disabled={tableConfig.uiConfig?.addButtonType === "addRow"}
+ disabled={!(tableConfig.uiConfig?.showSearchButton ?? true)}
/>
- {tableConfig.uiConfig?.addButtonType === "addRow" && (
-
빈 행 추가 모드에서는 모달이 열리지 않습니다
- )}
+ {/* 테이블 최대 높이 */}
테이블 최대 높이
+ {/* 다중 선택 허용 */}
updateUiConfig({ multiSelect: checked })}
className="scale-75"
- disabled={tableConfig.uiConfig?.addButtonType === "addRow"}
+ disabled={!(tableConfig.uiConfig?.showSearchButton ?? true)}
/>
다중 선택 허용
diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts
index 1f2015eb..a07feed6 100644
--- a/frontend/lib/registry/components/universal-form-modal/types.ts
+++ b/frontend/lib/registry/components/universal-form-modal/types.ts
@@ -253,15 +253,19 @@ export interface TableSectionConfig {
// 6. UI 설정
uiConfig?: {
- addButtonText?: string; // 추가 버튼 텍스트 (기본: "품목 검색")
modalTitle?: string; // 모달 제목 (기본: "항목 검색 및 선택")
multiSelect?: boolean; // 다중 선택 허용 (기본: true)
maxHeight?: string; // 테이블 최대 높이 (기본: "400px")
- // 추가 버튼 타입
- // - search: 검색 모달 열기 (기본값) - 기존 데이터에서 선택
- // - addRow: 빈 행 직접 추가 - 새 데이터 직접 입력
+ // 버튼 표시 설정 (동시 표시 가능)
+ showSearchButton?: boolean; // 검색 버튼 표시 (기본: true)
+ showAddRowButton?: boolean; // 행 추가 버튼 표시 (기본: false)
+ searchButtonText?: string; // 검색 버튼 텍스트 (기본: "품목 검색")
+ addRowButtonText?: string; // 행 추가 버튼 텍스트 (기본: "직접 입력")
+
+ // 레거시 호환용 (deprecated)
addButtonType?: "search" | "addRow";
+ addButtonText?: string;
};
// 7. 조건부 테이블 설정 (고급)
From b45f4870e88efa75364435fce4fe2a8e60e0d6d6 Mon Sep 17 00:00:00 2001
From: kjs
Date: Tue, 30 Dec 2025 14:03:29 +0900
Subject: [PATCH 19/28] =?UTF-8?q?=EC=8B=A0=EA=B7=9C=EB=93=B1=EB=A1=9D=20?=
=?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../button-primary/ButtonPrimaryComponent.tsx | 14 +++++++++++---
1 file changed, 11 insertions(+), 3 deletions(-)
diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx
index 1f067865..ade1c5cc 100644
--- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx
+++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx
@@ -832,7 +832,14 @@ export const ButtonPrimaryComponent: React.FC = ({
}
// modalDataStore에서 선택된 데이터 가져오기 (분할 패널 등에서 선택한 데이터)
- if ((!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) && effectiveTableName) {
+ // 단, 모달(modal) 액션은 신규 등록이므로 modalDataStore 데이터를 가져오지 않음
+ // (다른 화면에서 선택한 데이터가 남아있을 수 있으므로)
+ const shouldFetchFromModalDataStore =
+ processedConfig.action.type !== "modal" &&
+ (!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) &&
+ effectiveTableName;
+
+ if (shouldFetchFromModalDataStore) {
try {
const { useModalDataStore } = await import("@/stores/modalDataStore");
const dataRegistry = useModalDataStore.getState().dataRegistry;
@@ -860,9 +867,10 @@ export const ButtonPrimaryComponent: React.FC = ({
return;
}
- // 모달 액션인데 선택된 데이터가 있으면 경고 메시지 표시하고 중단
+ // 모달 액션인데 현재 화면(테이블)에서 직접 선택된 데이터가 있으면 경고 메시지 표시하고 중단
// (신규 등록 모달에서 선택된 데이터가 초기값으로 전달되는 것을 방지)
- if (processedConfig.action.type === "modal" && effectiveSelectedRowsData && effectiveSelectedRowsData.length > 0) {
+ // 주의: selectedRowsData만 체크 (modalDataStore의 데이터는 이미 제외했으므로)
+ if (processedConfig.action.type === "modal" && selectedRowsData && selectedRowsData.length > 0) {
toast.warning("신규 등록 시에는 테이블에서 선택된 항목을 해제해주세요.");
return;
}
From c78326bae1bbfe1e08d1a79b600aec08f6835493 Mon Sep 17 00:00:00 2001
From: kjs
Date: Tue, 30 Dec 2025 14:11:42 +0900
Subject: [PATCH 20/28] =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EB=B9=84=ED=99=9C?=
=?UTF-8?q?=EC=84=B1=ED=99=94=20=EB=A1=9C=EC=A7=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../config-panels/ButtonConfigPanel.tsx | 73 ++++++++++++++
.../button-primary/ButtonPrimaryComponent.tsx | 99 +++++++++++++++++--
2 files changed, 165 insertions(+), 7 deletions(-)
diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx
index 3a126c29..417ea4ff 100644
--- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx
+++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx
@@ -3081,6 +3081,79 @@ export const ButtonConfigPanel: React.FC = ({
/>
)}
+ {/* 🆕 행 선택 시에만 활성화 설정 */}
+
+
행 선택 활성화 조건
+
+ 테이블 리스트나 분할 패널에서 데이터가 선택되었을 때만 버튼을 활성화합니다.
+
+
+
+
+
행 선택 시에만 활성화
+
+ 체크하면 테이블에서 행을 선택해야만 버튼이 활성화됩니다.
+
+
+
{
+ onUpdateProperty("componentConfig.action.requireRowSelection", checked);
+ }}
+ />
+
+
+ {component.componentConfig?.action?.requireRowSelection && (
+
+
+
선택 데이터 소스
+
{
+ onUpdateProperty("componentConfig.action.rowSelectionSource", value);
+ }}
+ >
+
+
+
+
+ 자동 감지 (권장)
+ 테이블 리스트 선택
+ 분할 패널 좌측 선택
+ 플로우 위젯 선택
+
+
+
+ 자동 감지: 테이블, 분할 패널, 플로우 위젯 중 선택된 항목이 있으면 활성화
+
+
+
+
+
+
다중 선택 허용
+
+ 여러 행이 선택되어도 활성화 (기본: 1개 이상 선택 시 활성화)
+
+
+
{
+ onUpdateProperty("componentConfig.action.allowMultiRowSelection", checked);
+ }}
+ />
+
+
+ {!(component.componentConfig?.action?.allowMultiRowSelection ?? true) && (
+
+
+ 정확히 1개의 행만 선택되어야 버튼이 활성화됩니다.
+
+
+ )}
+
+ )}
+
+
{/* 제어 기능 섹션 */}
diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx
index ade1c5cc..8530d8e1 100644
--- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx
+++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx
@@ -296,6 +296,84 @@ export const ButtonPrimaryComponent: React.FC
= ({
return false;
}, [component.componentConfig?.action, formData, vehicleStatus, statusLoading, component.label]);
+ // 🆕 행 선택 기반 비활성화 조건 계산
+ const isRowSelectionDisabled = useMemo(() => {
+ const actionConfig = component.componentConfig?.action;
+
+ // requireRowSelection이 활성화되어 있지 않으면 비활성화하지 않음
+ if (!actionConfig?.requireRowSelection) {
+ return false;
+ }
+
+ const rowSelectionSource = actionConfig.rowSelectionSource || "auto";
+ const allowMultiRowSelection = actionConfig.allowMultiRowSelection ?? true;
+
+ // 선택된 데이터 확인
+ let hasSelection = false;
+ let selectionCount = 0;
+
+ // 1. 자동 감지 모드 또는 특정 소스 모드
+ if (rowSelectionSource === "auto" || rowSelectionSource === "tableList") {
+ // TableList에서 선택된 행 확인 (props로 전달됨)
+ if (selectedRowsData && selectedRowsData.length > 0) {
+ hasSelection = true;
+ selectionCount = selectedRowsData.length;
+ }
+ // 또는 selectedRows prop 확인
+ else if (selectedRows && selectedRows.length > 0) {
+ hasSelection = true;
+ selectionCount = selectedRows.length;
+ }
+ }
+
+ if (rowSelectionSource === "auto" || rowSelectionSource === "splitPanelLeft") {
+ // 분할 패널 좌측 선택 데이터 확인
+ if (!hasSelection && splitPanelContext?.selectedLeftData) {
+ hasSelection = true;
+ selectionCount = 1;
+ }
+ }
+
+ if (rowSelectionSource === "auto" || rowSelectionSource === "flowWidget") {
+ // 플로우 위젯 선택 데이터 확인
+ if (!hasSelection && flowSelectedData && flowSelectedData.length > 0) {
+ hasSelection = true;
+ selectionCount = flowSelectedData.length;
+ }
+ }
+
+ // 선택된 데이터가 없으면 비활성화
+ if (!hasSelection) {
+ console.log("🚫 [ButtonPrimary] 행 선택 필요 → 비활성화:", component.label, {
+ rowSelectionSource,
+ hasSelection,
+ });
+ return true;
+ }
+
+ // 다중 선택 허용하지 않는 경우, 정확히 1개만 선택되어야 함
+ if (!allowMultiRowSelection && selectionCount !== 1) {
+ console.log("🚫 [ButtonPrimary] 정확히 1개 행 선택 필요 → 비활성화:", component.label, {
+ selectionCount,
+ allowMultiRowSelection,
+ });
+ return true;
+ }
+
+ console.log("✅ [ButtonPrimary] 행 선택 조건 충족:", component.label, {
+ selectionCount,
+ rowSelectionSource,
+ });
+ return false;
+ }, [
+ component.componentConfig?.action,
+ component.label,
+ selectedRows,
+ selectedRowsData,
+ splitPanelContext?.selectedLeftData,
+ flowSelectedData,
+ ]);
+
// 확인 다이얼로그 상태
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [pendingAction, setPendingAction] = useState<{
@@ -1096,17 +1174,26 @@ export const ButtonPrimaryComponent: React.FC = ({
}
}
- // 🆕 최종 비활성화 상태 (설정 + 조건부 비활성화)
- const finalDisabled = componentConfig.disabled || isOperationButtonDisabled || statusLoading;
+ // 🆕 최종 비활성화 상태 (설정 + 조건부 비활성화 + 행 선택 필수)
+ const finalDisabled = componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading;
// 공통 버튼 스타일
+ // 🔧 component.style에서 background/backgroundColor 충돌 방지
+ const userStyle = component.style
+ ? Object.fromEntries(
+ Object.entries(component.style).filter(
+ ([key]) => !["width", "height", "background", "backgroundColor"].includes(key)
+ )
+ )
+ : {};
+
const buttonElementStyle: React.CSSProperties = {
width: "100%",
height: "100%",
minHeight: "40px",
border: "none",
borderRadius: "0.5rem",
- background: finalDisabled ? "#e5e7eb" : buttonColor,
+ backgroundColor: finalDisabled ? "#e5e7eb" : buttonColor, // 🔧 background → backgroundColor로 변경
color: finalDisabled ? "#9ca3af" : "white",
// 🔧 크기 설정 적용 (sm/md/lg)
fontSize: componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem",
@@ -1122,10 +1209,8 @@ export const ButtonPrimaryComponent: React.FC = ({
margin: "0",
lineHeight: "1.25",
boxShadow: finalDisabled ? "none" : "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
- // 디자인 모드와 인터랙티브 모드 모두에서 사용자 스타일 적용 (width/height 제외)
- ...(component.style
- ? Object.fromEntries(Object.entries(component.style).filter(([key]) => key !== "width" && key !== "height"))
- : {}),
+ // 디자인 모드와 인터랙티브 모드 모두에서 사용자 스타일 적용 (width/height/background 제외)
+ ...userStyle,
};
const buttonContent = processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼";
From 84a3956b02f2f7b4a715b803c764ca041b811458 Mon Sep 17 00:00:00 2001
From: kjs
Date: Tue, 30 Dec 2025 14:13:26 +0900
Subject: [PATCH 21/28] =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EB=B2=84=EA=B7=B8?=
=?UTF-8?q?=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../button-primary/ButtonPrimaryComponent.tsx | 11 ++++-------
1 file changed, 4 insertions(+), 7 deletions(-)
diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx
index 8530d8e1..d79d926f 100644
--- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx
+++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx
@@ -945,13 +945,10 @@ export const ButtonPrimaryComponent: React.FC = ({
return;
}
- // 모달 액션인데 현재 화면(테이블)에서 직접 선택된 데이터가 있으면 경고 메시지 표시하고 중단
- // (신규 등록 모달에서 선택된 데이터가 초기값으로 전달되는 것을 방지)
- // 주의: selectedRowsData만 체크 (modalDataStore의 데이터는 이미 제외했으므로)
- if (processedConfig.action.type === "modal" && selectedRowsData && selectedRowsData.length > 0) {
- toast.warning("신규 등록 시에는 테이블에서 선택된 항목을 해제해주세요.");
- return;
- }
+ // 🔧 모달 액션 시 선택 데이터 경고 제거
+ // 이전에는 "신규 등록 시에는 테이블에서 선택된 항목을 해제해주세요" 경고를 표시했으나,
+ // 다른 화면에서 선택한 데이터가 남아있는 경우 오탐이 발생하여 제거함.
+ // 모달 화면 내부에서 필요 시 자체적으로 선택 데이터를 무시하도록 처리하면 됨.
// 수정(edit) 액션 검증
if (processedConfig.action.type === "edit") {
From fb82d2f5a1d10bc7c0fa0c661c5ee687f564e58f Mon Sep 17 00:00:00 2001
From: kjs
Date: Tue, 30 Dec 2025 14:19:15 +0900
Subject: [PATCH 22/28] =?UTF-8?q?=EB=B6=84=ED=95=A0=ED=8C=A8=EB=84=90?=
=?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B2=84=ED=8A=BC=20=EB=B9=84=ED=99=9C?=
=?UTF-8?q?=EC=84=B1=ED=99=94=EA=B8=B0=EB=8A=A5=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../button-primary/ButtonPrimaryComponent.tsx | 81 ++++++++++++++++---
.../SplitPanelLayoutComponent.tsx | 20 ++---
2 files changed, 81 insertions(+), 20 deletions(-)
diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx
index d79d926f..f311c035 100644
--- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx
+++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx
@@ -296,6 +296,32 @@ export const ButtonPrimaryComponent: React.FC = ({
return false;
}, [component.componentConfig?.action, formData, vehicleStatus, statusLoading, component.label]);
+ // 🆕 modalDataStore에서 선택된 데이터 확인 (분할 패널 등에서 저장됨)
+ const [modalStoreData, setModalStoreData] = useState>({});
+
+ // modalDataStore 상태 구독 (실시간 업데이트)
+ useEffect(() => {
+ const actionConfig = component.componentConfig?.action;
+ if (!actionConfig?.requireRowSelection) return;
+
+ // 동적 import로 modalDataStore 구독
+ let unsubscribe: (() => void) | undefined;
+
+ import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
+ // 초기값 설정
+ setModalStoreData(useModalDataStore.getState().dataRegistry);
+
+ // 상태 변경 구독
+ unsubscribe = useModalDataStore.subscribe((state) => {
+ setModalStoreData(state.dataRegistry);
+ });
+ });
+
+ return () => {
+ unsubscribe?.();
+ };
+ }, [component.componentConfig?.action?.requireRowSelection]);
+
// 🆕 행 선택 기반 비활성화 조건 계산
const isRowSelectionDisabled = useMemo(() => {
const actionConfig = component.componentConfig?.action;
@@ -311,43 +337,76 @@ export const ButtonPrimaryComponent: React.FC = ({
// 선택된 데이터 확인
let hasSelection = false;
let selectionCount = 0;
+ let selectionSource = "";
- // 1. 자동 감지 모드 또는 특정 소스 모드
+ // 1. 자동 감지 모드 또는 테이블 리스트 모드
if (rowSelectionSource === "auto" || rowSelectionSource === "tableList") {
// TableList에서 선택된 행 확인 (props로 전달됨)
if (selectedRowsData && selectedRowsData.length > 0) {
hasSelection = true;
selectionCount = selectedRowsData.length;
+ selectionSource = "tableList (selectedRowsData)";
}
// 또는 selectedRows prop 확인
else if (selectedRows && selectedRows.length > 0) {
hasSelection = true;
selectionCount = selectedRows.length;
+ selectionSource = "tableList (selectedRows)";
}
}
+ // 2. 분할 패널 좌측 선택 데이터 확인
if (rowSelectionSource === "auto" || rowSelectionSource === "splitPanelLeft") {
- // 분할 패널 좌측 선택 데이터 확인
- if (!hasSelection && splitPanelContext?.selectedLeftData) {
- hasSelection = true;
- selectionCount = 1;
+ // SplitPanelContext에서 확인
+ if (splitPanelContext?.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0) {
+ if (!hasSelection) {
+ hasSelection = true;
+ selectionCount = 1;
+ selectionSource = "splitPanelLeft (context)";
+ }
+ }
+
+ // 🆕 modalDataStore에서도 확인 (SplitPanelLayoutComponent에서 저장)
+ if (!hasSelection && Object.keys(modalStoreData).length > 0) {
+ // modalDataStore에서 데이터가 있는지 확인
+ for (const [sourceId, items] of Object.entries(modalStoreData)) {
+ if (items && items.length > 0) {
+ hasSelection = true;
+ selectionCount = items.length;
+ selectionSource = `modalDataStore (${sourceId})`;
+ break;
+ }
+ }
}
}
+ // 3. 플로우 위젯 선택 데이터 확인
if (rowSelectionSource === "auto" || rowSelectionSource === "flowWidget") {
// 플로우 위젯 선택 데이터 확인
if (!hasSelection && flowSelectedData && flowSelectedData.length > 0) {
hasSelection = true;
selectionCount = flowSelectedData.length;
+ selectionSource = "flowWidget";
}
}
+ // 디버깅 로그
+ console.log("🔍 [ButtonPrimary] 행 선택 체크:", component.label, {
+ rowSelectionSource,
+ hasSelection,
+ selectionCount,
+ selectionSource,
+ hasSplitPanelContext: !!splitPanelContext,
+ selectedLeftData: splitPanelContext?.selectedLeftData,
+ selectedRowsData: selectedRowsData?.length,
+ selectedRows: selectedRows?.length,
+ flowSelectedData: flowSelectedData?.length,
+ modalStoreDataKeys: Object.keys(modalStoreData),
+ });
+
// 선택된 데이터가 없으면 비활성화
if (!hasSelection) {
- console.log("🚫 [ButtonPrimary] 행 선택 필요 → 비활성화:", component.label, {
- rowSelectionSource,
- hasSelection,
- });
+ console.log("🚫 [ButtonPrimary] 행 선택 필요 → 비활성화:", component.label);
return true;
}
@@ -362,7 +421,7 @@ export const ButtonPrimaryComponent: React.FC = ({
console.log("✅ [ButtonPrimary] 행 선택 조건 충족:", component.label, {
selectionCount,
- rowSelectionSource,
+ selectionSource,
});
return false;
}, [
@@ -372,6 +431,8 @@ export const ButtonPrimaryComponent: React.FC = ({
selectedRowsData,
splitPanelContext?.selectedLeftData,
flowSelectedData,
+ splitPanelContext,
+ modalStoreData,
]);
// 확인 다이얼로그 상태
diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx
index ef91a23d..bfb26c90 100644
--- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx
+++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx
@@ -2030,14 +2030,14 @@ export const SplitPanelLayoutComponent: React.FC
className="border-border flex flex-shrink-0 flex-col border-r"
>
-
@@ -2521,14 +2521,14 @@ export const SplitPanelLayoutComponent: React.FC
className="flex flex-shrink-0 flex-col"
>
-
From 58233e51de41f3aaa7bbd5ee38eb5f1f5d5d22a3 Mon Sep 17 00:00:00 2001
From: DDD1542
Date: Tue, 30 Dec 2025 15:28:05 +0900
Subject: [PATCH 23/28] =?UTF-8?q?=EA=B0=81=EA=B0=81=20=EB=B3=84=EB=8F=84?=
=?UTF-8?q?=20TSX=20=EB=B3=91=ED=95=A9=20=EB=B0=8F=20=ED=9A=8C=EC=82=AC?=
=?UTF-8?q?=EC=84=A0=ED=83=9D=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/controllers/authController.ts | 111 +-
backend-node/src/routes/authRoutes.ts | 6 +
backend-node/src/services/adminService.ts | 6 +-
frontend/app/(main)/admin/menu/page.tsx | 1131 +++++++++++++++-
frontend/app/(main)/admin/monitoring/page.tsx | 284 ++++-
frontend/app/(main)/admin/page.tsx | 3 +-
.../dashboardList/DashboardListClient.tsx | 449 -------
.../screenMng/dashboardList/[id]/page.tsx} | 35 +-
.../dashboardList/edit/[id]/page.tsx | 23 -
.../screenMng/dashboardList/new/page.tsx | 12 -
.../admin/screenMng/dashboardList/page.tsx | 445 ++++++-
.../(main)/admin/systemMng/i18nList/page.tsx | 815 +++++++++++-
.../[companyCode]/departments/page.tsx | 111 +-
.../(main)/admin/userMng/companyList/page.tsx | 86 +-
.../admin/userMng/rolesList/[id]/page.tsx | 329 ++++-
.../(main)/admin/userMng/rolesList/page.tsx | 337 ++++-
.../admin/userMng/userAuthList/page.tsx | 156 ++-
.../(main)/admin/userMng/userMngList/page.tsx | 166 ++-
frontend/app/(main)/main/page.tsx | 1 -
.../components/admin/CompanyManagement.tsx | 93 --
frontend/components/admin/CompanySwitcher.tsx | 195 +++
frontend/components/admin/MenuManagement.tsx | 1136 -----------------
.../components/admin/MonitoringDashboard.tsx | 288 -----
frontend/components/admin/MultiLang.tsx | 859 -------------
.../components/admin/RoleDetailManagement.tsx | 345 -----
frontend/components/admin/RoleManagement.tsx | 335 -----
.../components/admin/UserAuthManagement.tsx | 157 ---
frontend/components/admin/UserManagement.tsx | 176 ---
.../admin/department/DepartmentManagement.tsx | 117 --
frontend/components/layout/AppLayout.tsx | 108 +-
frontend/contexts/MenuContext.tsx | 6 +-
frontend/hooks/useAuth.ts | 56 +
frontend/lib/api/menu.ts | 4 +-
33 files changed, 4326 insertions(+), 4055 deletions(-)
delete mode 100644 frontend/app/(main)/admin/screenMng/dashboardList/DashboardListClient.tsx
rename frontend/{components/admin/dashboard/DashboardDesigner.tsx => app/(main)/admin/screenMng/dashboardList/[id]/page.tsx} (95%)
delete mode 100644 frontend/app/(main)/admin/screenMng/dashboardList/edit/[id]/page.tsx
delete mode 100644 frontend/app/(main)/admin/screenMng/dashboardList/new/page.tsx
delete mode 100644 frontend/components/admin/CompanyManagement.tsx
create mode 100644 frontend/components/admin/CompanySwitcher.tsx
delete mode 100644 frontend/components/admin/MenuManagement.tsx
delete mode 100644 frontend/components/admin/MonitoringDashboard.tsx
delete mode 100644 frontend/components/admin/MultiLang.tsx
delete mode 100644 frontend/components/admin/RoleDetailManagement.tsx
delete mode 100644 frontend/components/admin/RoleManagement.tsx
delete mode 100644 frontend/components/admin/UserAuthManagement.tsx
delete mode 100644 frontend/components/admin/UserManagement.tsx
delete mode 100644 frontend/components/admin/department/DepartmentManagement.tsx
diff --git a/backend-node/src/controllers/authController.ts b/backend-node/src/controllers/authController.ts
index 6f72eb10..1903d397 100644
--- a/backend-node/src/controllers/authController.ts
+++ b/backend-node/src/controllers/authController.ts
@@ -141,6 +141,110 @@ export class AuthController {
}
}
+ /**
+ * POST /api/auth/switch-company
+ * WACE 관리자 전용: 다른 회사로 전환
+ */
+ static async switchCompany(req: Request, res: Response): Promise {
+ try {
+ const { companyCode } = req.body;
+ const authHeader = req.get("Authorization");
+ const token = authHeader && authHeader.split(" ")[1];
+
+ if (!token) {
+ res.status(401).json({
+ success: false,
+ message: "인증 토큰이 필요합니다.",
+ error: { code: "TOKEN_MISSING" },
+ });
+ return;
+ }
+
+ // 현재 사용자 정보 확인
+ const currentUser = JwtUtils.verifyToken(token);
+
+ // WACE 관리자 권한 체크 (userType = "SUPER_ADMIN"만 확인)
+ // 이미 다른 회사로 전환한 상태(companyCode != "*")에서도 다시 전환 가능해야 함
+ if (currentUser.userType !== "SUPER_ADMIN") {
+ logger.warn(`회사 전환 권한 없음: userId=${currentUser.userId}, userType=${currentUser.userType}, companyCode=${currentUser.companyCode}`);
+ res.status(403).json({
+ success: false,
+ message: "회사 전환은 최고 관리자(SUPER_ADMIN)만 가능합니다.",
+ error: { code: "FORBIDDEN" },
+ });
+ return;
+ }
+
+ // 전환할 회사 코드 검증
+ if (!companyCode || companyCode.trim() === "") {
+ res.status(400).json({
+ success: false,
+ message: "전환할 회사 코드가 필요합니다.",
+ error: { code: "INVALID_INPUT" },
+ });
+ return;
+ }
+
+ logger.info(`=== WACE 관리자 회사 전환 ===`, {
+ userId: currentUser.userId,
+ originalCompanyCode: currentUser.companyCode,
+ targetCompanyCode: companyCode,
+ });
+
+ // 회사 코드 존재 여부 확인 (company_code가 "*"가 아닌 경우만)
+ if (companyCode !== "*") {
+ const { query } = await import("../database/db");
+ const companies = await query(
+ "SELECT company_code, company_name FROM company_mng WHERE company_code = $1",
+ [companyCode]
+ );
+
+ if (companies.length === 0) {
+ res.status(404).json({
+ success: false,
+ message: "존재하지 않는 회사 코드입니다.",
+ error: { code: "COMPANY_NOT_FOUND" },
+ });
+ return;
+ }
+ }
+
+ // 새로운 JWT 토큰 발급 (company_code만 변경)
+ const newPersonBean: PersonBean = {
+ ...currentUser,
+ companyCode: companyCode.trim(), // 전환할 회사 코드로 변경
+ };
+
+ const newToken = JwtUtils.generateToken(newPersonBean);
+
+ logger.info(`✅ 회사 전환 성공: ${currentUser.userId} → ${companyCode}`);
+
+ res.status(200).json({
+ success: true,
+ message: "회사 전환 완료",
+ data: {
+ token: newToken,
+ companyCode: companyCode.trim(),
+ },
+ });
+ } catch (error) {
+ logger.error(
+ `회사 전환 API 오류: ${error instanceof Error ? error.message : error}`
+ );
+ res.status(500).json({
+ success: false,
+ message: "회사 전환 중 오류가 발생했습니다.",
+ error: {
+ code: "SERVER_ERROR",
+ details:
+ error instanceof Error
+ ? error.message
+ : "알 수 없는 오류가 발생했습니다.",
+ },
+ });
+ }
+ }
+
/**
* POST /api/auth/logout
* 기존 Java ApiLoginController.logout() 메서드 포팅
@@ -226,13 +330,14 @@ export class AuthController {
}
// 프론트엔드 호환성을 위해 더 많은 사용자 정보 반환
+ // ⚠️ JWT 토큰의 companyCode를 우선 사용 (회사 전환 기능 지원)
const userInfoResponse: any = {
userId: dbUserInfo.userId,
userName: dbUserInfo.userName || "",
deptName: dbUserInfo.deptName || "",
- companyCode: dbUserInfo.companyCode || "ILSHIN",
- company_code: dbUserInfo.companyCode || "ILSHIN", // 프론트엔드 호환성
- userType: dbUserInfo.userType || "USER",
+ companyCode: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선
+ company_code: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선
+ userType: userInfo.userType || dbUserInfo.userType || "USER", // JWT 토큰 우선
userTypeName: dbUserInfo.userTypeName || "일반사용자",
email: dbUserInfo.email || "",
photo: dbUserInfo.photo,
diff --git a/backend-node/src/routes/authRoutes.ts b/backend-node/src/routes/authRoutes.ts
index adba86e6..7ed87a06 100644
--- a/backend-node/src/routes/authRoutes.ts
+++ b/backend-node/src/routes/authRoutes.ts
@@ -47,4 +47,10 @@ router.post("/refresh", AuthController.refreshToken);
*/
router.post("/signup", AuthController.signup);
+/**
+ * POST /api/auth/switch-company
+ * WACE 관리자 전용: 다른 회사로 전환
+ */
+router.post("/switch-company", AuthController.switchCompany);
+
export default router;
diff --git a/backend-node/src/services/adminService.ts b/backend-node/src/services/adminService.ts
index 5ca6b392..1b9280db 100644
--- a/backend-node/src/services/adminService.ts
+++ b/backend-node/src/services/adminService.ts
@@ -412,9 +412,9 @@ export class AdminService {
let queryParams: any[] = [userLang];
let paramIndex = 2;
- if (userType === "SUPER_ADMIN" && userCompanyCode === "*") {
- // SUPER_ADMIN: 권한 그룹 체크 없이 공통 메뉴만 표시
- logger.info("✅ 좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시");
+ if (userType === "SUPER_ADMIN") {
+ // SUPER_ADMIN: 권한 그룹 체크 없이 해당 회사의 모든 메뉴 표시
+ logger.info(`✅ 좌측 사이드바 (SUPER_ADMIN): 회사 ${userCompanyCode}의 모든 메뉴 표시`);
authFilter = "";
unionFilter = "";
} else {
diff --git a/frontend/app/(main)/admin/menu/page.tsx b/frontend/app/(main)/admin/menu/page.tsx
index 4e9ff1d4..85d5b346 100644
--- a/frontend/app/(main)/admin/menu/page.tsx
+++ b/frontend/app/(main)/admin/menu/page.tsx
@@ -1,9 +1,880 @@
"use client";
-import { MenuManagement } from "@/components/admin/MenuManagement";
+import React, { useState, useEffect, useMemo } from "react";
+import { menuApi } from "@/lib/api/menu";
+import type { MenuItem } from "@/lib/api/menu";
+import { MenuTable } from "@/components/admin/MenuTable";
+import { MenuFormModal } from "@/components/admin/MenuFormModal";
+import { MenuCopyDialog } from "@/components/admin/MenuCopyDialog";
+import { Button } from "@/components/ui/button";
+import { LoadingSpinner, LoadingOverlay } from "@/components/common/LoadingSpinner";
+import { toast } from "sonner";
+import { Badge } from "@/components/ui/badge";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { useMenu } from "@/contexts/MenuContext";
+import { useMenuManagementText, setTranslationCache, getMenuTextSync } from "@/lib/utils/multilang";
+import { useMultiLang } from "@/hooks/useMultiLang";
+import { apiClient } from "@/lib/api/client";
+import { useAuth } from "@/hooks/useAuth"; // useAuth 추가
import { ScrollToTop } from "@/components/common/ScrollToTop";
+type MenuType = "admin" | "user";
+
export default function MenuPage() {
+ const { adminMenus, userMenus, refreshMenus } = useMenu();
+ const { user } = useAuth(); // 현재 사용자 정보 가져오기
+ const [selectedMenuType, setSelectedMenuType] = useState("admin");
+ const [loading, setLoading] = useState(false);
+ const [deleting, setDeleting] = useState(false);
+ const [formModalOpen, setFormModalOpen] = useState(false);
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [copyDialogOpen, setCopyDialogOpen] = useState(false);
+ const [selectedMenuId, setSelectedMenuId] = useState("");
+ const [selectedMenuName, setSelectedMenuName] = useState("");
+ const [selectedMenus, setSelectedMenus] = useState>(new Set());
+
+ // 메뉴 관리 화면용 로컬 상태 (모든 상태의 메뉴 표시)
+ const [localAdminMenus, setLocalAdminMenus] = useState([]);
+ const [localUserMenus, setLocalUserMenus] = useState([]);
+
+ // 다국어 텍스트 훅 사용
+ // getMenuText는 더 이상 사용하지 않음 - getUITextSync만 사용
+ const { userLang } = useMultiLang({ companyCode: "*" });
+
+ // SUPER_ADMIN 여부 확인
+ const isSuperAdmin = user?.userType === "SUPER_ADMIN";
+
+ // 다국어 텍스트 상태
+ const [uiTexts, setUiTexts] = useState>({});
+ const [uiTextsLoading, setUiTextsLoading] = useState(false);
+
+ // 회사 목록 상태
+ const [companies, setCompanies] = useState>([]);
+ const [selectedCompany, setSelectedCompany] = useState("all");
+ const [searchText, setSearchText] = useState("");
+ const [expandedMenus, setExpandedMenus] = useState>(new Set());
+ const [companySearchText, setCompanySearchText] = useState("");
+ const [isCompanyDropdownOpen, setIsCompanyDropdownOpen] = useState(false);
+ const [formData, setFormData] = useState({
+ menuId: "",
+ parentId: "",
+ menuType: "",
+ level: 0,
+ parentCompanyCode: "",
+ });
+
+ // 언어별 텍스트 매핑 테이블 제거 - DB에서 직접 가져옴
+
+ // 메뉴관리 페이지에서 사용할 다국어 키들 (실제 DB에 등록된 키들)
+ const MENU_MANAGEMENT_LANG_KEYS = [
+ // 페이지 제목 및 설명
+ "menu.management.title",
+ "menu.management.description",
+ "menu.type.title",
+ "menu.type.admin",
+ "menu.type.user",
+ "menu.management.admin",
+ "menu.management.user",
+ "menu.management.admin.description",
+ "menu.management.user.description",
+
+ // 버튼
+ "button.add",
+ "button.add.top.level",
+ "button.add.sub",
+ "button.edit",
+ "button.delete",
+ "button.delete.selected",
+ "button.delete.selected.count",
+ "button.delete.processing",
+ "button.cancel",
+ "button.save",
+ "button.register",
+ "button.modify",
+
+ // 필터 및 검색
+ "filter.company",
+ "filter.company.all",
+ "filter.company.common",
+ "filter.company.search",
+ "filter.search",
+ "filter.search.placeholder",
+ "filter.reset",
+
+ // 테이블 헤더
+ "table.header.select",
+ "table.header.menu.name",
+ "table.header.menu.url",
+ "table.header.menu.type",
+ "table.header.status",
+ "table.header.company",
+ "table.header.sequence",
+ "table.header.actions",
+
+ // 상태
+ "status.active",
+ "status.inactive",
+ "status.unspecified",
+
+ // 폼
+ "form.menu.type",
+ "form.menu.type.admin",
+ "form.menu.type.user",
+ "form.company",
+ "form.company.select",
+ "form.company.common",
+ "form.company.submenu.note",
+ "form.lang.key",
+ "form.lang.key.select",
+ "form.lang.key.none",
+ "form.lang.key.search",
+ "form.lang.key.selected",
+ "form.menu.name",
+ "form.menu.name.placeholder",
+ "form.menu.url",
+ "form.menu.url.placeholder",
+ "form.menu.description",
+ "form.menu.description.placeholder",
+ "form.menu.sequence",
+
+ // 모달
+ "modal.menu.register.title",
+ "modal.menu.modify.title",
+ "modal.delete.title",
+ "modal.delete.description",
+ "modal.delete.batch.description",
+
+ // 메시지
+ "message.loading",
+ "message.menu.delete.processing",
+ "message.menu.save.success",
+ "message.menu.save.failed",
+ "message.menu.delete.success",
+ "message.menu.delete.failed",
+ "message.menu.delete.batch.success",
+ "message.menu.delete.batch.partial",
+ "message.menu.status.toggle.success",
+ "message.menu.status.toggle.failed",
+ "message.validation.menu.name.required",
+ "message.validation.company.required",
+ "message.validation.select.menu.delete",
+ "message.error.load.menu.list",
+ "message.error.load.menu.info",
+ "message.error.load.company.list",
+ "message.error.load.lang.key.list",
+
+ // 리스트 정보
+ "menu.list.title",
+ "menu.list.total",
+ "menu.list.search.result",
+
+ // UI
+ "ui.expand",
+ "ui.collapse",
+ "ui.menu.collapse",
+ "ui.language",
+ ];
+
+ // 초기 로딩
+ useEffect(() => {
+ loadCompanies();
+ loadMenus(false); // 메뉴 목록 로드 (메뉴 관리 화면용 - 모든 상태 표시)
+ // 사용자 언어가 설정되지 않았을 때만 기본 텍스트 설정
+ if (!userLang) {
+ initializeDefaultTexts();
+ }
+ }, [userLang]); // userLang 변경 시마다 실행
+
+ // 초기 기본 텍스트 설정 함수
+ const initializeDefaultTexts = () => {
+ const defaultTexts: Record = {};
+ MENU_MANAGEMENT_LANG_KEYS.forEach((key) => {
+ // 기본 한국어 텍스트 제공
+ const defaultText = getDefaultText(key);
+ defaultTexts[key] = defaultText;
+ });
+ setUiTexts(defaultTexts);
+ // console.log("🌐 초기 기본 텍스트 설정 완료:", Object.keys(defaultTexts).length);
+ };
+
+ // 기본 텍스트 반환 함수
+ const getDefaultText = (key: string): string => {
+ const defaultTexts: Record = {
+ "menu.management.title": "메뉴 관리",
+ "menu.management.description": "시스템의 메뉴 구조와 권한을 관리합니다.",
+ "menu.type.title": "메뉴 타입",
+ "menu.type.admin": "관리자",
+ "menu.type.user": "사용자",
+ "menu.management.admin": "관리자 메뉴",
+ "menu.management.user": "사용자 메뉴",
+ "menu.management.admin.description": "시스템 관리 및 설정 메뉴",
+ "menu.management.user.description": "일반 사용자 업무 메뉴",
+ "button.add": "추가",
+ "button.add.top.level": "최상위 메뉴 추가",
+ "button.add.sub": "하위 메뉴 추가",
+ "button.edit": "수정",
+ "button.delete": "삭제",
+ "button.delete.selected": "선택 삭제",
+ "button.delete.selected.count": "선택 삭제 ({count})",
+ "button.delete.processing": "삭제 중...",
+ "button.cancel": "취소",
+ "button.save": "저장",
+ "button.register": "등록",
+ "button.modify": "수정",
+ "filter.company": "회사",
+ "filter.company.all": "전체",
+ "filter.company.common": "공통",
+ "filter.company.search": "회사 검색",
+ "filter.search": "검색",
+ "filter.search.placeholder": "메뉴명 또는 URL로 검색...",
+ "filter.reset": "초기화",
+ "table.header.select": "선택",
+ "table.header.menu.name": "메뉴명",
+ "table.header.menu.url": "URL",
+ "table.header.menu.type": "메뉴 타입",
+ "table.header.status": "상태",
+ "table.header.company": "회사",
+ "table.header.sequence": "순서",
+ "table.header.actions": "작업",
+ "status.active": "활성화",
+ "status.inactive": "비활성화",
+ "status.unspecified": "미지정",
+ "form.menu.type": "메뉴 타입",
+ "form.menu.type.admin": "관리자",
+ "form.menu.type.user": "사용자",
+ "form.company": "회사",
+ "form.company.select": "회사를 선택하세요",
+ "form.company.common": "공통",
+ "form.company.submenu.note": "하위 메뉴는 상위 메뉴와 동일한 회사를 가져야 합니다.",
+ "form.lang.key": "다국어 키",
+ "form.lang.key.select": "다국어 키를 선택하세요",
+ "form.lang.key.none": "다국어 키 없음",
+ "form.lang.key.search": "다국어 키 검색...",
+ "form.lang.key.selected": "선택된 키: {key} - {description}",
+ "form.menu.name": "메뉴명",
+ "form.menu.name.placeholder": "메뉴명을 입력하세요",
+ "form.menu.url": "URL",
+ "form.menu.url.placeholder": "메뉴 URL을 입력하세요",
+ "form.menu.description": "설명",
+ "form.menu.description.placeholder": "메뉴 설명을 입력하세요",
+ "form.menu.sequence": "순서",
+ "modal.menu.register.title": "메뉴 등록",
+ "modal.menu.modify.title": "메뉴 수정",
+ "modal.delete.title": "메뉴 삭제",
+ "modal.delete.description": "해당 메뉴를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
+ "modal.delete.batch.description":
+ "선택된 {count}개의 메뉴를 영구적으로 삭제하시겠습니까?\n\n⚠️ 주의: 상위 메뉴를 삭제하면 하위 메뉴들도 함께 삭제됩니다.\n이 작업은 되돌릴 수 없습니다.",
+ "message.loading": "로딩 중...",
+ "message.menu.delete.processing": "메뉴 삭제 중...",
+ "message.menu.save.success": "메뉴가 성공적으로 저장되었습니다.",
+ "message.menu.save.failed": "메뉴 저장에 실패했습니다.",
+ "message.menu.delete.success": "메뉴가 성공적으로 삭제되었습니다.",
+ "message.menu.delete.failed": "메뉴 삭제에 실패했습니다.",
+ "message.menu.delete.batch.success": "선택된 메뉴들이 성공적으로 삭제되었습니다.",
+ "message.menu.delete.batch.partial": "일부 메뉴 삭제에 실패했습니다.",
+ "message.menu.status.toggle.success": "메뉴 상태가 변경되었습니다.",
+ "message.menu.status.toggle.failed": "메뉴 상태 변경에 실패했습니다.",
+ "message.validation.menu.name.required": "메뉴명을 입력해주세요.",
+ "message.validation.company.required": "회사를 선택해주세요.",
+ "message.validation.select.menu.delete": "삭제할 메뉴를 선택해주세요.",
+ "message.error.load.menu.list": "메뉴 목록을 불러오는데 실패했습니다.",
+ "message.error.load.menu.info": "메뉴 정보를 불러오는데 실패했습니다.",
+ "message.error.load.company.list": "회사 목록을 불러오는데 실패했습니다.",
+ "message.error.load.lang.key.list": "다국어 키 목록을 불러오는데 실패했습니다.",
+ "menu.list.title": "메뉴 목록",
+ "menu.list.total": "총 {count}개",
+ "menu.list.search.result": "검색 결과: {count}개",
+ "ui.expand": "펼치기",
+ "ui.collapse": "접기",
+ "ui.menu.collapse": "메뉴 접기",
+ "ui.language": "언어",
+ };
+
+ return defaultTexts[key] || key;
+ };
+
+ // 컴포넌트 마운트 시 및 userLang 변경 시 다국어 텍스트 로드
+ useEffect(() => {
+ if (userLang && !uiTextsLoading) {
+ loadUITexts();
+ }
+ }, [userLang]); // userLang 변경 시마다 실행
+
+ // uiTexts 상태 변경 감지
+ useEffect(() => {
+ // console.log("🔄 uiTexts 상태 변경됨:", {
+ // count: Object.keys(uiTexts).length,
+ // sampleKeys: Object.keys(uiTexts).slice(0, 5),
+ // sampleValues: Object.entries(uiTexts)
+ // .slice(0, 3)
+ // .map(([k, v]) => `${k}: ${v}`),
+ // });
+ }, [uiTexts]);
+
+ // 컴포넌트 마운트 후 다국어 텍스트 강제 로드 (userLang이 아직 설정되지 않았을 수 있음)
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ if (userLang && !uiTextsLoading) {
+ // console.log("🔄 컴포넌트 마운트 후 다국어 텍스트 강제 로드");
+ loadUITexts();
+ }
+ }, 300); // 300ms 후 실행
+
+ return () => clearTimeout(timer);
+ }, [userLang]); // userLang이 설정된 후 실행
+
+ // 추가 안전장치: 컴포넌트 마운트 후 일정 시간이 지나면 강제로 다국어 텍스트 로드
+ useEffect(() => {
+ const fallbackTimer = setTimeout(() => {
+ if (!uiTextsLoading && Object.keys(uiTexts).length === 0) {
+ // console.log("🔄 안전장치: 컴포넌트 마운트 후 강제 다국어 텍스트 로드");
+ // 사용자 언어가 설정되지 않았을 때만 기본 텍스트 설정
+ if (!userLang) {
+ initializeDefaultTexts();
+ } else {
+ // 사용자 언어가 설정된 경우 다국어 텍스트 로드
+ loadUITexts();
+ }
+ }
+ }, 1000); // 1초 후 실행
+
+ return () => clearTimeout(fallbackTimer);
+ }, [userLang]); // userLang 변경 시마다 실행
+
+ // 번역 로드 이벤트 감지
+ useEffect(() => {
+ const handleTranslationLoaded = (event: CustomEvent) => {
+ const { key, text, userLang: loadedLang } = event.detail;
+ if (loadedLang === userLang) {
+ setUiTexts((prev) => ({ ...prev, [key]: text }));
+ }
+ };
+
+ window.addEventListener("translation-loaded", handleTranslationLoaded as EventListener);
+
+ return () => {
+ window.removeEventListener("translation-loaded", handleTranslationLoaded as EventListener);
+ };
+ }, [userLang]);
+
+ // 드롭다운 외부 클릭 시 닫기
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ const target = event.target as Element;
+ if (!target.closest(".company-dropdown")) {
+ setIsCompanyDropdownOpen(false);
+ setCompanySearchText("");
+ }
+ };
+
+ if (isCompanyDropdownOpen) {
+ document.addEventListener("mousedown", handleClickOutside);
+ }
+
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ };
+ }, [isCompanyDropdownOpen]);
+
+ // 특정 메뉴 타입만 로드하는 함수
+ const loadMenusForType = async (type: MenuType, showLoading = true) => {
+ try {
+ if (showLoading) {
+ setLoading(true);
+ }
+
+ if (type === "admin") {
+ const adminResponse = await menuApi.getAdminMenusForManagement();
+ if (adminResponse.success && adminResponse.data) {
+ setLocalAdminMenus(adminResponse.data);
+ }
+ } else {
+ const userResponse = await menuApi.getUserMenusForManagement();
+ if (userResponse.success && userResponse.data) {
+ setLocalUserMenus(userResponse.data);
+ }
+ }
+ } catch (error) {
+ toast.error(getUITextSync("message.error.load.menu.list"));
+ } finally {
+ if (showLoading) {
+ setLoading(false);
+ }
+ }
+ };
+
+ const loadMenus = async (showLoading = true) => {
+ // console.log(`📋 메뉴 목록 조회 시작 (showLoading: ${showLoading})`);
+ try {
+ if (showLoading) {
+ setLoading(true);
+ }
+
+ // 선택된 메뉴 타입에 해당하는 메뉴만 로드
+ if (selectedMenuType === "admin") {
+ const adminResponse = await menuApi.getAdminMenusForManagement();
+ if (adminResponse.success && adminResponse.data) {
+ setLocalAdminMenus(adminResponse.data);
+ }
+ } else {
+ const userResponse = await menuApi.getUserMenusForManagement();
+ if (userResponse.success && userResponse.data) {
+ setLocalUserMenus(userResponse.data);
+ }
+ }
+
+ // 전역 메뉴 상태도 업데이트 (좌측 사이드바용)
+ await refreshMenus();
+ // console.log("📋 메뉴 목록 조회 성공");
+ } catch (error) {
+ // console.error("❌ 메뉴 목록 조회 실패:", error);
+ toast.error(getUITextSync("message.error.load.menu.list"));
+ } finally {
+ if (showLoading) {
+ setLoading(false);
+ }
+ }
+ };
+
+ // 회사 목록 조회
+ const loadCompanies = async () => {
+ // console.log("🏢 회사 목록 조회 시작");
+ try {
+ const response = await apiClient.get("/admin/companies");
+
+ if (response.data.success) {
+ // console.log("🏢 회사 목록 응답:", response.data);
+ const companyList = response.data.data.map((company: any) => ({
+ code: company.company_code || company.companyCode,
+ name: company.company_name || company.companyName,
+ }));
+ // console.log("🏢 변환된 회사 목록:", companyList);
+ setCompanies(companyList);
+ }
+ } catch (error) {
+ // console.error("❌ 회사 목록 조회 실패:", error);
+ }
+ };
+
+ // 다국어 텍스트 로드 함수 - 배치 API 사용
+ const loadUITexts = async () => {
+ if (uiTextsLoading) return; // 이미 로딩 중이면 중단
+
+ // userLang이 설정되지 않았으면 기본값 설정
+ if (!userLang) {
+ // console.log("🌐 사용자 언어가 설정되지 않음, 기본값 설정");
+ const defaultTexts: Record = {};
+ MENU_MANAGEMENT_LANG_KEYS.forEach((key) => {
+ defaultTexts[key] = getDefaultText(key); // 기본 한국어 텍스트 사용
+ });
+ setUiTexts(defaultTexts);
+ return;
+ }
+
+ // 사용자 언어가 설정된 경우, 기존 uiTexts가 비어있으면 기본 텍스트로 초기화
+ if (Object.keys(uiTexts).length === 0) {
+ // console.log("🌐 기존 uiTexts가 비어있음, 기본 텍스트로 초기화");
+ const defaultTexts: Record = {};
+ MENU_MANAGEMENT_LANG_KEYS.forEach((key) => {
+ defaultTexts[key] = getDefaultText(key);
+ });
+ setUiTexts(defaultTexts);
+ }
+
+ // console.log("🌐 UI 다국어 텍스트 로드 시작", {
+ // userLang,
+ // apiParams: {
+ // companyCode: "*",
+ // menuCode: "menu.management",
+ // userLang: userLang,
+ // },
+ // });
+ setUiTextsLoading(true);
+
+ try {
+ // 배치 API를 사용하여 모든 다국어 키를 한 번에 조회
+ const response = await apiClient.post(
+ "/multilang/batch",
+ {
+ langKeys: MENU_MANAGEMENT_LANG_KEYS,
+ companyCode: "*", // 모든 회사
+ menuCode: "menu.management", // 메뉴관리 메뉴
+ userLang: userLang, // body에 포함
+ },
+ {
+ params: {}, // query params는 비움
+ },
+ );
+
+ if (response.data.success) {
+ const translations = response.data.data;
+ // console.log("🌐 배치 다국어 텍스트 응답:", translations);
+
+ // 번역 결과를 상태에 저장 (기존 uiTexts와 병합)
+ const mergedTranslations = { ...uiTexts, ...translations };
+ // console.log("🔧 setUiTexts 호출 전:", {
+ // translationsCount: Object.keys(translations).length,
+ // mergedCount: Object.keys(mergedTranslations).length,
+ // });
+ setUiTexts(mergedTranslations);
+ // console.log("🔧 setUiTexts 호출 후 - mergedTranslations:", mergedTranslations);
+
+ // 번역 캐시에 저장 (다른 컴포넌트에서도 사용할 수 있도록)
+ setTranslationCache(userLang, mergedTranslations);
+ } else {
+ // console.error("❌ 다국어 텍스트 배치 조회 실패:", response.data.message);
+ // API 실패 시에도 기존 uiTexts는 유지
+ // console.log("🔄 API 실패로 인해 기존 uiTexts 유지");
+ }
+ } catch (error) {
+ // console.error("❌ UI 다국어 텍스트 로드 실패:", error);
+ // API 실패 시에도 기존 uiTexts는 유지
+ // console.log("🔄 API 실패로 인해 기존 uiTexts 유지");
+ } finally {
+ setUiTextsLoading(false);
+ }
+ };
+
+ // UI 텍스트 가져오기 함수 (동기 버전만 사용)
+ // getUIText 함수는 제거 - getUITextSync만 사용
+
+ // 동기 버전 (DB에서 가져온 번역 텍스트 사용)
+ const getUITextSync = (key: string, params?: Record, fallback?: string): string => {
+ // uiTexts에서 번역 텍스트 찾기
+ let text = uiTexts[key];
+
+ // uiTexts에 없으면 getMenuTextSync로 기본 한글 텍스트 가져오기
+ if (!text) {
+ text = getMenuTextSync(key, userLang) || fallback || key;
+ }
+
+ // 파라미터 치환
+ if (params && text) {
+ Object.entries(params).forEach(([paramKey, paramValue]) => {
+ text = text!.replace(`{${paramKey}}`, String(paramValue));
+ });
+ }
+
+ return text || key;
+ };
+
+ // 다국어 API 테스트 함수 (getUITextSync 사용)
+ const testMultiLangAPI = async () => {
+ // console.log("🧪 다국어 API 테스트 시작");
+ try {
+ const text = getUITextSync("menu.management.admin");
+ // console.log("🧪 다국어 API 테스트 결과:", text);
+ } catch (error) {
+ // console.error("❌ 다국어 API 테스트 실패:", error);
+ }
+ };
+
+ // 대문자 키를 소문자 키로 변환하는 함수
+ const convertMenuData = (data: any[]): MenuItem[] => {
+ return data.map((item) => ({
+ objid: item.OBJID || item.objid,
+ parent_obj_id: item.PARENT_OBJ_ID || item.parent_obj_id,
+ menu_name_kor: item.MENU_NAME_KOR || item.menu_name_kor,
+ menu_url: item.MENU_URL || item.menu_url,
+ menu_desc: item.MENU_DESC || item.menu_desc,
+ seq: item.SEQ || item.seq,
+ menu_type: item.MENU_TYPE || item.menu_type,
+ status: item.STATUS || item.status,
+ lev: item.LEV || item.lev,
+ lpad_menu_name_kor: item.LPAD_MENU_NAME_KOR || item.lpad_menu_name_kor,
+ status_title: item.STATUS_TITLE || item.status_title,
+ writer: item.WRITER || item.writer,
+ regdate: item.REGDATE || item.regdate,
+ company_code: item.COMPANY_CODE || item.company_code,
+ company_name: item.COMPANY_NAME || item.company_name,
+ }));
+ };
+
+ const handleAddTopLevelMenu = () => {
+ setFormData({
+ menuId: "",
+ parentId: "0", // 최상위 메뉴는 parentId가 0
+ menuType: getMenuTypeValue(),
+ level: 1, // 최상위 메뉴는 level 1
+ parentCompanyCode: "", // 최상위 메뉴는 상위 회사 정보 없음
+ });
+ setFormModalOpen(true);
+ };
+
+ const handleAddMenu = (parentId: string, menuType: string, level: number) => {
+ // 상위 메뉴의 회사 정보 찾기
+ const currentMenus = selectedMenuType === "admin" ? localAdminMenus : localUserMenus;
+ const parentMenu = currentMenus.find((menu) => menu.objid === parentId);
+
+ setFormData({
+ menuId: "",
+ parentId,
+ menuType,
+ level: level + 1,
+ parentCompanyCode: parentMenu?.company_code || "",
+ });
+ setFormModalOpen(true);
+ };
+
+ const handleEditMenu = (menuId: string) => {
+ // console.log("🔧 메뉴 수정 시작 - menuId:", menuId);
+
+ // 현재 메뉴 정보 찾기
+ const currentMenus = selectedMenuType === "admin" ? localAdminMenus : localUserMenus;
+ const menuToEdit = currentMenus.find((menu) => (menu.objid || menu.OBJID) === menuId);
+
+ if (menuToEdit) {
+ // console.log("수정할 메뉴 정보:", menuToEdit);
+
+ setFormData({
+ menuId: menuId,
+ parentId: menuToEdit.parent_obj_id || menuToEdit.PARENT_OBJ_ID || "",
+ menuType: selectedMenuType, // 현재 선택된 메뉴 타입
+ level: 0, // 기본값
+ parentCompanyCode: menuToEdit.company_code || menuToEdit.COMPANY_CODE || "",
+ });
+
+ // console.log("설정된 formData:", {
+ // menuId: menuId,
+ // parentId: menuToEdit.parent_obj_id || menuToEdit.PARENT_OBJ_ID || "",
+ // menuType: selectedMenuType,
+ // level: 0,
+ // parentCompanyCode: menuToEdit.company_code || menuToEdit.COMPANY_CODE || "",
+ // });
+ } else {
+ // console.error("수정할 메뉴를 찾을 수 없음:", menuId);
+ }
+
+ setFormModalOpen(true);
+ };
+
+ const handleMenuSelectionChange = (menuId: string, checked: boolean) => {
+ const newSelected = new Set(selectedMenus);
+ if (checked) {
+ newSelected.add(menuId);
+ } else {
+ newSelected.delete(menuId);
+ }
+ setSelectedMenus(newSelected);
+ };
+
+ const handleSelectAllMenus = (checked: boolean) => {
+ const currentMenus = selectedMenuType === "admin" ? localAdminMenus : localUserMenus;
+ if (checked) {
+ // 모든 메뉴 선택 (최상위 메뉴 포함)
+ setSelectedMenus(new Set(currentMenus.map((menu) => menu.objid || menu.OBJID || "")));
+ } else {
+ setSelectedMenus(new Set());
+ }
+ };
+
+ const handleDeleteSelectedMenus = async () => {
+ if (selectedMenus.size === 0) {
+ toast.error(getUITextSync("message.validation.select.menu.delete"));
+ return;
+ }
+
+ if (!confirm(getUITextSync("modal.delete.batch.description", { count: selectedMenus.size }))) {
+ return;
+ }
+
+ setDeleting(true);
+ try {
+ const menuIds = Array.from(selectedMenus);
+ // console.log("삭제할 메뉴 IDs:", menuIds);
+
+ toast.info(getUITextSync("message.menu.delete.processing"));
+
+ const response = await menuApi.deleteMenusBatch(menuIds);
+ // console.log("삭제 API 응답:", response);
+ // console.log("응답 구조:", {
+ // success: response.success,
+ // data: response.data,
+ // message: response.message,
+ // });
+
+ if (response.success && response.data) {
+ const { deletedCount, failedCount } = response.data;
+ // console.log("삭제 결과:", { deletedCount, failedCount });
+
+ // 선택된 메뉴 초기화
+ setSelectedMenus(new Set());
+
+ // 메뉴 목록 즉시 새로고침 (로딩 상태 없이)
+ // console.log("메뉴 목록 새로고침 시작");
+ await loadMenus(false);
+ // 전역 메뉴 상태도 업데이트
+ await refreshMenus();
+ // console.log("메뉴 목록 새로고침 완료");
+
+ // 삭제 결과 메시지
+ if (failedCount === 0) {
+ toast.success(getUITextSync("message.menu.delete.batch.success", { count: deletedCount }));
+ } else {
+ toast.success(
+ getUITextSync("message.menu.delete.batch.partial", {
+ success: deletedCount,
+ failed: failedCount,
+ }),
+ );
+ }
+ } else {
+ // console.error("삭제 실패:", response);
+ toast.error(response.message || "메뉴 삭제에 실패했습니다.");
+ }
+ } catch (error) {
+ // console.error("메뉴 삭제 중 오류:", error);
+ toast.error(getUITextSync("message.menu.delete.failed"));
+ } finally {
+ setDeleting(false);
+ }
+ };
+
+ const confirmDelete = async () => {
+ try {
+ const response = await menuApi.deleteMenu(selectedMenuId);
+ if (response.success) {
+ toast.success(response.message);
+ await loadMenus(false);
+ } else {
+ toast.error(response.message);
+ }
+ } catch (error) {
+ toast.error("메뉴 삭제에 실패했습니다.");
+ } finally {
+ setDeleteDialogOpen(false);
+ setSelectedMenuId("");
+ }
+ };
+
+ const handleCopyMenu = (menuId: string, menuName: string) => {
+ setSelectedMenuId(menuId);
+ setSelectedMenuName(menuName);
+ setCopyDialogOpen(true);
+ };
+
+ const handleCopyComplete = async () => {
+ // 복사 완료 후 메뉴 목록 새로고침
+ await loadMenus(false);
+ toast.success("메뉴 복사가 완료되었습니다");
+ };
+
+ const handleToggleStatus = async (menuId: string) => {
+ try {
+ const response = await menuApi.toggleMenuStatus(menuId);
+ if (response.success) {
+ toast.success(response.message);
+ await loadMenus(false); // 메뉴 목록 새로고침
+ // 전역 메뉴 상태도 업데이트
+ await refreshMenus();
+ } else {
+ toast.error(response.message);
+ }
+ } catch (error) {
+ // console.error("메뉴 상태 토글 오류:", error);
+ toast.error(getUITextSync("message.menu.status.toggle.failed"));
+ }
+ };
+
+ const handleFormSuccess = () => {
+ loadMenus(false);
+ // 전역 메뉴 상태도 업데이트
+ refreshMenus();
+ };
+
+ const getCurrentMenus = () => {
+ // 메뉴 관리 화면용: 모든 상태의 메뉴 표시 (localAdminMenus/localUserMenus 사용)
+ const currentMenus = selectedMenuType === "admin" ? localAdminMenus : localUserMenus;
+
+ // 검색어 필터링
+ let filteredMenus = currentMenus;
+ if (searchText.trim()) {
+ const searchLower = searchText.toLowerCase();
+ filteredMenus = currentMenus.filter((menu) => {
+ const menuName = (menu.menu_name_kor || menu.MENU_NAME_KOR || "").toLowerCase();
+ const menuUrl = (menu.menu_url || menu.MENU_URL || "").toLowerCase();
+ return menuName.includes(searchLower) || menuUrl.includes(searchLower);
+ });
+ }
+
+ // 회사 필터링
+ if (selectedCompany !== "all") {
+ filteredMenus = filteredMenus.filter((menu) => {
+ const menuCompanyCode = menu.company_code || menu.COMPANY_CODE || "";
+ return menuCompanyCode === selectedCompany;
+ });
+ }
+
+ return filteredMenus;
+ };
+
+ // 메뉴 타입 변경 시 선택된 메뉴 초기화
+ const handleMenuTypeChange = (type: MenuType) => {
+ setSelectedMenuType(type);
+ setSelectedMenus(new Set()); // 선택된 메뉴 초기화
+ setExpandedMenus(new Set()); // 메뉴 타입 변경 시 확장 상태 초기화
+
+ // 선택한 메뉴 타입에 해당하는 메뉴만 로드
+ if (type === "admin" && localAdminMenus.length === 0) {
+ loadMenusForType("admin", false);
+ } else if (type === "user" && localUserMenus.length === 0) {
+ loadMenusForType("user", false);
+ }
+ };
+
+ const handleToggleExpand = (menuId: string) => {
+ const newExpandedMenus = new Set(expandedMenus);
+ if (newExpandedMenus.has(menuId)) {
+ newExpandedMenus.delete(menuId);
+ } else {
+ newExpandedMenus.add(menuId);
+ }
+ setExpandedMenus(newExpandedMenus);
+ };
+
+ const getMenuTypeString = () => {
+ return selectedMenuType === "admin" ? getUITextSync("menu.type.admin") : getUITextSync("menu.type.user");
+ };
+
+ const getMenuTypeValue = () => {
+ return selectedMenuType === "admin" ? "0" : "1";
+ };
+
+ // uiTextsCount를 useMemo로 계산하여 상태 변경 시에만 재계산
+ const uiTextsCount = useMemo(() => Object.keys(uiTexts).length, [uiTexts]);
+ const adminMenusCount = useMemo(() => localAdminMenus?.length || 0, [localAdminMenus]);
+ const userMenusCount = useMemo(() => localUserMenus?.length || 0, [localUserMenus]);
+
+ // 디버깅을 위한 간단한 상태 표시
+ // console.log("🔍 MenuManagement 렌더링 상태:", {
+ // loading,
+ // uiTextsLoading,
+ // uiTextsCount,
+ // adminMenusCount,
+ // userMenusCount,
+ // selectedMenuType,
+ // userLang,
+ // });
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
return (
@@ -14,7 +885,263 @@ export default function MenuPage() {
{/* 메인 컨텐츠 */}
-
+
+
+ {/* 좌측 사이드바 - 메뉴 타입 선택 (20%) */}
+
+
+
{getUITextSync("menu.type.title")}
+
+ {/* 메뉴 타입 선택 카드들 */}
+
+
handleMenuTypeChange("admin")}
+ >
+
+
+
{getUITextSync("menu.management.admin")}
+
+ {getUITextSync("menu.management.admin.description")}
+
+
+
+ {localAdminMenus.length}
+
+
+
+
+
handleMenuTypeChange("user")}
+ >
+
+
+
{getUITextSync("menu.management.user")}
+
+ {getUITextSync("menu.management.user.description")}
+
+
+
+ {localUserMenus.length}
+
+
+
+
+
+
+
+ {/* 우측 메인 영역 - 메뉴 목록 (80%) */}
+
+
+ {/* 상단 헤더: 제목 + 검색 + 버튼 */}
+
+ {/* 왼쪽: 제목 */}
+
+ {getMenuTypeString()} {getUITextSync("menu.list.title")}
+
+
+ {/* 오른쪽: 검색 + 버튼 */}
+
+ {/* 회사 선택 */}
+
+
+
setIsCompanyDropdownOpen(!isCompanyDropdownOpen)}
+ className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
+ >
+
+ {selectedCompany === "all"
+ ? getUITextSync("filter.company.all")
+ : selectedCompany === "*"
+ ? getUITextSync("filter.company.common")
+ : companies.find((c) => c.code === selectedCompany)?.name ||
+ getUITextSync("filter.company.all")}
+
+
+
+
+
+
+ {isCompanyDropdownOpen && (
+
+
+ setCompanySearchText(e.target.value)}
+ className="h-8 text-sm"
+ onClick={(e) => e.stopPropagation()}
+ />
+
+
+
+
{
+ setSelectedCompany("all");
+ setIsCompanyDropdownOpen(false);
+ setCompanySearchText("");
+ }}
+ >
+ {getUITextSync("filter.company.all")}
+
+
{
+ setSelectedCompany("*");
+ setIsCompanyDropdownOpen(false);
+ setCompanySearchText("");
+ }}
+ >
+ {getUITextSync("filter.company.common")}
+
+
+ {companies
+ .filter((company) => company.code && company.code.trim() !== "")
+ .filter(
+ (company) =>
+ company.name.toLowerCase().includes(companySearchText.toLowerCase()) ||
+ company.code.toLowerCase().includes(companySearchText.toLowerCase()),
+ )
+ .map((company, index) => (
+
{
+ setSelectedCompany(company.code);
+ setIsCompanyDropdownOpen(false);
+ setCompanySearchText("");
+ }}
+ >
+ {company.code === "*" ? getUITextSync("filter.company.common") : company.name}
+
+ ))}
+
+
+ )}
+
+
+
+ {/* 검색 입력 */}
+
+ setSearchText(e.target.value)}
+ className="h-10 text-sm"
+ />
+
+
+ {/* 초기화 버튼 */}
+
{
+ setSearchText("");
+ setSelectedCompany("all");
+ setCompanySearchText("");
+ }}
+ variant="outline"
+ className="h-10 text-sm font-medium"
+ >
+ {getUITextSync("filter.reset")}
+
+
+ {/* 최상위 메뉴 추가 */}
+
handleAddTopLevelMenu()} className="h-10 gap-2 text-sm font-medium">
+ {getUITextSync("button.add.top.level")}
+
+
+ {/* 선택 삭제 */}
+ {selectedMenus.size > 0 && (
+
+ {deleting ? (
+ <>
+
+ {getUITextSync("button.delete.processing")}
+ >
+ ) : (
+ getUITextSync("button.delete.selected.count", {
+ count: selectedMenus.size,
+ })
+ )}
+
+ )}
+
+
+
+ {/* 테이블 영역 */}
+
+
+
+
+
+
+
+
+
setFormModalOpen(false)}
+ onSuccess={handleFormSuccess}
+ menuId={formData.menuId}
+ parentId={formData.parentId}
+ menuType={formData.menuType}
+ level={formData.level}
+ parentCompanyCode={formData.parentCompanyCode}
+ uiTexts={uiTexts}
+ />
+
+
+
+
+ 메뉴 삭제
+
+ 해당 메뉴를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
+
+
+
+ 취소
+ 삭제
+
+
+
+
+
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
diff --git a/frontend/app/(main)/admin/monitoring/page.tsx b/frontend/app/(main)/admin/monitoring/page.tsx
index ac70e9a4..7be9d405 100644
--- a/frontend/app/(main)/admin/monitoring/page.tsx
+++ b/frontend/app/(main)/admin/monitoring/page.tsx
@@ -1,9 +1,124 @@
"use client";
-import React from "react";
-import MonitoringDashboard from "@/components/admin/MonitoringDashboard";
+import React, { useState, useEffect } from "react";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { Progress } from "@/components/ui/progress";
+import { RefreshCw, Play, Pause, AlertCircle, CheckCircle, Clock } from "lucide-react";
+import { toast } from "sonner";
+import { BatchAPI, BatchMonitoring } from "@/lib/api/batch";
export default function MonitoringPage() {
+ const [monitoring, setMonitoring] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [autoRefresh, setAutoRefresh] = useState(false);
+
+ useEffect(() => {
+ loadMonitoringData();
+
+ let interval: NodeJS.Timeout;
+ if (autoRefresh) {
+ interval = setInterval(loadMonitoringData, 30000); // 30초마다 자동 새로고침
+ }
+
+ return () => {
+ if (interval) clearInterval(interval);
+ };
+ }, [autoRefresh]);
+
+ const loadMonitoringData = async () => {
+ setIsLoading(true);
+ try {
+ const data = await BatchAPI.getBatchMonitoring();
+ setMonitoring(data);
+ } catch (error) {
+ console.error("모니터링 데이터 조회 오류:", error);
+ toast.error("모니터링 데이터를 불러오는데 실패했습니다.");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleRefresh = () => {
+ loadMonitoringData();
+ };
+
+ const toggleAutoRefresh = () => {
+ setAutoRefresh(!autoRefresh);
+ };
+
+ const getStatusIcon = (status: string) => {
+ switch (status) {
+ case 'completed':
+ return ;
+ case 'failed':
+ return ;
+ case 'running':
+ return ;
+ case 'pending':
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ const getStatusBadge = (status: string) => {
+ const variants = {
+ completed: "bg-green-100 text-green-800",
+ failed: "bg-destructive/20 text-red-800",
+ running: "bg-primary/20 text-blue-800",
+ pending: "bg-yellow-100 text-yellow-800",
+ cancelled: "bg-gray-100 text-gray-800",
+ };
+
+ const labels = {
+ completed: "완료",
+ failed: "실패",
+ running: "실행 중",
+ pending: "대기 중",
+ cancelled: "취소됨",
+ };
+
+ return (
+
+ {labels[status as keyof typeof labels] || status}
+
+ );
+ };
+
+ const formatDuration = (ms: number) => {
+ if (ms < 1000) return `${ms}ms`;
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
+ return `${(ms / 60000).toFixed(1)}m`;
+ };
+
+ const getSuccessRate = () => {
+ if (!monitoring) return 0;
+ const total = monitoring.successful_jobs_today + monitoring.failed_jobs_today;
+ if (total === 0) return 100;
+ return Math.round((monitoring.successful_jobs_today / total) * 100);
+ };
+
+ if (!monitoring) {
+ return (
+
+
+
+
모니터링 데이터를 불러오는 중...
+
+
+ );
+ }
+
return (
@@ -16,7 +131,170 @@ export default function MonitoringPage() {
{/* 모니터링 대시보드 */}
-
+
+ {/* 헤더 */}
+
+
배치 모니터링
+
+
+ {autoRefresh ? : }
+ 자동 새로고침
+
+
+
+ 새로고침
+
+
+
+
+ {/* 통계 카드 */}
+
+
+
+ 총 작업 수
+ 📋
+
+
+ {monitoring.total_jobs}
+
+ 활성: {monitoring.active_jobs}개
+
+
+
+
+
+
+ 실행 중
+ 🔄
+
+
+ {monitoring.running_jobs}
+
+ 현재 실행 중인 작업
+
+
+
+
+
+
+ 오늘 성공
+ ✅
+
+
+ {monitoring.successful_jobs_today}
+
+ 성공률: {getSuccessRate()}%
+
+
+
+
+
+
+ 오늘 실패
+ ❌
+
+
+ {monitoring.failed_jobs_today}
+
+ 주의가 필요한 작업
+
+
+
+
+
+ {/* 성공률 진행바 */}
+
+
+ 오늘 실행 성공률
+
+
+
+
+ 성공: {monitoring.successful_jobs_today}건
+ 실패: {monitoring.failed_jobs_today}건
+
+
+
+ {getSuccessRate()}% 성공률
+
+
+
+
+
+ {/* 최근 실행 이력 */}
+
+
+ 최근 실행 이력
+
+
+ {monitoring.recent_executions.length === 0 ? (
+
+ 최근 실행 이력이 없습니다.
+
+ ) : (
+
+
+
+ 상태
+ 작업 ID
+ 시작 시간
+ 완료 시간
+ 실행 시간
+ 오류 메시지
+
+
+
+ {monitoring.recent_executions.map((execution) => (
+
+
+
+ {getStatusIcon(execution.execution_status)}
+ {getStatusBadge(execution.execution_status)}
+
+
+ #{execution.job_id}
+
+ {execution.started_at
+ ? new Date(execution.started_at).toLocaleString()
+ : "-"}
+
+
+ {execution.completed_at
+ ? new Date(execution.completed_at).toLocaleString()
+ : "-"}
+
+
+ {execution.execution_time_ms
+ ? formatDuration(execution.execution_time_ms)
+ : "-"}
+
+
+ {execution.error_message ? (
+
+ {execution.error_message}
+
+ ) : (
+ "-"
+ )}
+
+
+ ))}
+
+
+ )}
+
+
+
);
diff --git a/frontend/app/(main)/admin/page.tsx b/frontend/app/(main)/admin/page.tsx
index 3060e0fc..8658d7c6 100644
--- a/frontend/app/(main)/admin/page.tsx
+++ b/frontend/app/(main)/admin/page.tsx
@@ -1,4 +1,4 @@
-import { Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package } from "lucide-react";
+import { Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package, Building2 } from "lucide-react";
import Link from "next/link";
import { GlobalFileViewer } from "@/components/GlobalFileViewer";
@@ -9,6 +9,7 @@ export default function AdminPage() {
return (
+
{/* 주요 관리 기능 */}
diff --git a/frontend/app/(main)/admin/screenMng/dashboardList/DashboardListClient.tsx b/frontend/app/(main)/admin/screenMng/dashboardList/DashboardListClient.tsx
deleted file mode 100644
index c50aaa51..00000000
--- a/frontend/app/(main)/admin/screenMng/dashboardList/DashboardListClient.tsx
+++ /dev/null
@@ -1,449 +0,0 @@
-"use client";
-
-import { useState, useEffect } from "react";
-import { useRouter } from "next/navigation";
-import { dashboardApi } from "@/lib/api/dashboard";
-import { Dashboard } from "@/lib/api/dashboard";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { useToast } from "@/hooks/use-toast";
-import { Pagination, PaginationInfo } from "@/components/common/Pagination";
-import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal";
-import { Plus, Search, Edit, Trash2, Copy, MoreVertical, AlertCircle, RefreshCw } from "lucide-react";
-
-/**
- * 대시보드 목록 클라이언트 컴포넌트
- * - CSR 방식으로 초기 데이터 로드
- * - 대시보드 목록 조회
- * - 대시보드 생성/수정/삭제/복사
- */
-export default function DashboardListClient() {
- const router = useRouter();
- const { toast } = useToast();
-
- // 상태 관리
- const [dashboards, setDashboards] = useState
([]);
- const [loading, setLoading] = useState(true); // CSR이므로 초기 로딩 true
- const [error, setError] = useState(null);
- const [searchTerm, setSearchTerm] = useState("");
-
- // 페이지네이션 상태
- const [currentPage, setCurrentPage] = useState(1);
- const [pageSize, setPageSize] = useState(10);
- const [totalCount, setTotalCount] = useState(0);
-
- // 모달 상태
- const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
- const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null);
-
- // 대시보드 목록 로드
- const loadDashboards = async () => {
- try {
- setLoading(true);
- setError(null);
- const result = await dashboardApi.getMyDashboards({
- search: searchTerm,
- page: currentPage,
- limit: pageSize,
- });
- setDashboards(result.dashboards);
- setTotalCount(result.pagination.total);
- } catch (err) {
- console.error("Failed to load dashboards:", err);
- setError(
- err instanceof Error
- ? err.message
- : "대시보드 목록을 불러오는데 실패했습니다. 네트워크 연결을 확인하거나 잠시 후 다시 시도해주세요.",
- );
- } finally {
- setLoading(false);
- }
- };
-
- // 검색어/페이지 변경 시 fetch (초기 로딩 포함)
- useEffect(() => {
- loadDashboards();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [searchTerm, currentPage, pageSize]);
-
- // 페이지네이션 정보 계산
- const paginationInfo: PaginationInfo = {
- currentPage,
- totalPages: Math.ceil(totalCount / pageSize) || 1,
- totalItems: totalCount,
- itemsPerPage: pageSize,
- startItem: totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1,
- endItem: Math.min(currentPage * pageSize, totalCount),
- };
-
- // 페이지 변경 핸들러
- const handlePageChange = (page: number) => {
- setCurrentPage(page);
- };
-
- // 페이지 크기 변경 핸들러
- const handlePageSizeChange = (size: number) => {
- setPageSize(size);
- setCurrentPage(1); // 페이지 크기 변경 시 첫 페이지로
- };
-
- // 대시보드 삭제 확인 모달 열기
- const handleDeleteClick = (id: string, title: string) => {
- setDeleteTarget({ id, title });
- setDeleteDialogOpen(true);
- };
-
- // 대시보드 삭제 실행
- const handleDeleteConfirm = async () => {
- if (!deleteTarget) return;
-
- try {
- await dashboardApi.deleteDashboard(deleteTarget.id);
- setDeleteDialogOpen(false);
- setDeleteTarget(null);
- toast({
- title: "성공",
- description: "대시보드가 삭제되었습니다.",
- });
- loadDashboards();
- } catch (err) {
- console.error("Failed to delete dashboard:", err);
- setDeleteDialogOpen(false);
- toast({
- title: "오류",
- description: "대시보드 삭제에 실패했습니다.",
- variant: "destructive",
- });
- }
- };
-
- // 대시보드 복사
- const handleCopy = async (dashboard: Dashboard) => {
- try {
- const fullDashboard = await dashboardApi.getDashboard(dashboard.id);
-
- await dashboardApi.createDashboard({
- title: `${fullDashboard.title} (복사본)`,
- description: fullDashboard.description,
- elements: fullDashboard.elements || [],
- isPublic: false,
- tags: fullDashboard.tags,
- category: fullDashboard.category,
- settings: fullDashboard.settings as { resolution?: string; backgroundColor?: string },
- });
- toast({
- title: "성공",
- description: "대시보드가 복사되었습니다.",
- });
- loadDashboards();
- } catch (err) {
- console.error("Failed to copy dashboard:", err);
- toast({
- title: "오류",
- description: "대시보드 복사에 실패했습니다.",
- variant: "destructive",
- });
- }
- };
-
- // 포맷팅 헬퍼
- const formatDate = (dateString: string) => {
- return new Date(dateString).toLocaleDateString("ko-KR", {
- year: "numeric",
- month: "2-digit",
- day: "2-digit",
- });
- };
-
- return (
- <>
- {/* 검색 및 액션 */}
-
-
-
-
- setSearchTerm(e.target.value)}
- className="h-10 pl-10 text-sm"
- />
-
-
- 총 {totalCount.toLocaleString()} 건
-
-
-
router.push("/admin/screenMng/dashboardList/new")} className="h-10 gap-2 text-sm font-medium">
- 새 대시보드 생성
-
-
-
- {/* 대시보드 목록 */}
- {loading ? (
- <>
- {/* 데스크톱 테이블 스켈레톤 */}
-
-
-
-
- 제목
- 설명
- 생성자
- 생성일
- 수정일
- 작업
-
-
-
- {Array.from({ length: 10 }).map((_, index) => (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ))}
-
-
-
-
- {/* 모바일/태블릿 카드 스켈레톤 */}
-
- {Array.from({ length: 6 }).map((_, index) => (
-
-
-
- {Array.from({ length: 3 }).map((_, i) => (
-
- ))}
-
-
- ))}
-
- >
- ) : error ? (
-
-
-
-
-
데이터를 불러올 수 없습니다
-
{error}
-
-
-
- 다시 시도
-
-
-
- ) : dashboards.length === 0 ? (
-
- ) : (
- <>
- {/* 데스크톱 테이블 뷰 (lg 이상) */}
-
-
-
-
- 제목
- 설명
- 생성자
- 생성일
- 수정일
- 작업
-
-
-
- {dashboards.map((dashboard) => (
-
-
- router.push(`/admin/screenMng/dashboardList/edit/${dashboard.id}`)}
- className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
- >
- {dashboard.title}
-
-
-
- {dashboard.description || "-"}
-
-
- {dashboard.createdByName || dashboard.createdBy || "-"}
-
-
- {formatDate(dashboard.createdAt)}
-
-
- {formatDate(dashboard.updatedAt)}
-
-
-
-
-
-
-
-
-
- router.push(`/admin/screenMng/dashboardList/edit/${dashboard.id}`)}
- className="gap-2 text-sm"
- >
-
- 편집
-
- handleCopy(dashboard)} className="gap-2 text-sm">
-
- 복사
-
- handleDeleteClick(dashboard.id, dashboard.title)}
- className="text-destructive focus:text-destructive gap-2 text-sm"
- >
-
- 삭제
-
-
-
-
-
- ))}
-
-
-
-
- {/* 모바일/태블릿 카드 뷰 (lg 미만) */}
-
- {dashboards.map((dashboard) => (
-
- {/* 헤더 */}
-
-
-
router.push(`/admin/screenMng/dashboardList/edit/${dashboard.id}`)}
- className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
- >
- {dashboard.title}
-
-
{dashboard.id}
-
-
-
- {/* 정보 */}
-
-
- 설명
- {dashboard.description || "-"}
-
-
- 생성자
- {dashboard.createdByName || dashboard.createdBy || "-"}
-
-
- 생성일
- {formatDate(dashboard.createdAt)}
-
-
- 수정일
- {formatDate(dashboard.updatedAt)}
-
-
-
- {/* 액션 */}
-
- router.push(`/admin/screenMng/dashboardList/edit/${dashboard.id}`)}
- >
-
- 편집
-
- handleCopy(dashboard)}
- >
-
- 복사
-
- handleDeleteClick(dashboard.id, dashboard.title)}
- >
-
-
-
-
- ))}
-
- >
- )}
-
- {/* 페이지네이션 */}
- {!loading && dashboards.length > 0 && (
-
- )}
-
- {/* 삭제 확인 모달 */}
-
- "{deleteTarget?.title}" 대시보드를 삭제하시겠습니까?
- 이 작업은 되돌릴 수 없습니다.
- >
- }
- onConfirm={handleDeleteConfirm}
- />
- >
- );
-}
diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/app/(main)/admin/screenMng/dashboardList/[id]/page.tsx
similarity index 95%
rename from frontend/components/admin/dashboard/DashboardDesigner.tsx
rename to frontend/app/(main)/admin/screenMng/dashboardList/[id]/page.tsx
index b945cb3d..63900c78 100644
--- a/frontend/components/admin/dashboard/DashboardDesigner.tsx
+++ b/frontend/app/(main)/admin/screenMng/dashboardList/[id]/page.tsx
@@ -1,17 +1,18 @@
"use client";
import React, { useState, useRef, useCallback } from "react";
+import { use } from "react";
import { useRouter } from "next/navigation";
-import { DashboardCanvas } from "./DashboardCanvas";
-import { DashboardTopMenu } from "./DashboardTopMenu";
-import { WidgetConfigSidebar } from "./WidgetConfigSidebar";
-import { DashboardSaveModal } from "./DashboardSaveModal";
-import { DashboardElement, ElementType, ElementSubtype } from "./types";
-import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateBoxSize } from "./gridUtils";
-import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector";
+import { DashboardCanvas } from "@/components/admin/dashboard/DashboardCanvas";
+import { DashboardTopMenu } from "@/components/admin/dashboard/DashboardTopMenu";
+import { WidgetConfigSidebar } from "@/components/admin/dashboard/WidgetConfigSidebar";
+import { DashboardSaveModal } from "@/components/admin/dashboard/DashboardSaveModal";
+import { DashboardElement, ElementType, ElementSubtype } from "@/components/admin/dashboard/types";
+import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateBoxSize } from "@/components/admin/dashboard/gridUtils";
+import { Resolution, RESOLUTIONS, detectScreenResolution } from "@/components/admin/dashboard/ResolutionSelector";
import { DashboardProvider } from "@/contexts/DashboardContext";
import { useMenu } from "@/contexts/MenuContext";
-import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
+import { useKeyboardShortcuts } from "@/components/admin/dashboard/hooks/useKeyboardShortcuts";
import {
Dialog,
DialogContent,
@@ -32,18 +33,24 @@ import {
import { Button } from "@/components/ui/button";
import { CheckCircle2 } from "lucide-react";
-interface DashboardDesignerProps {
- dashboardId?: string;
-}
-
/**
- * 대시보드 설계 도구 메인 컴포넌트
+ * 대시보드 생성/편집 페이지
+ * URL: /admin/screenMng/dashboardList/[id]
+ * - id가 "new"면 새 대시보드 생성
+ * - id가 숫자면 기존 대시보드 편집
+ *
+ * 기능:
* - 드래그 앤 드롭으로 차트/위젯 배치
* - 그리드 기반 레이아웃 (12 컬럼)
* - 요소 이동, 크기 조절, 삭제 기능
* - 레이아웃 저장/불러오기 기능
*/
-export default function DashboardDesigner({ dashboardId: initialDashboardId }: DashboardDesignerProps = {}) {
+export default function DashboardDesignerPage({ params }: { params: Promise<{ id: string }> }) {
+ const { id: paramId } = use(params);
+
+ // "new"면 생성 모드, 아니면 편집 모드
+ const initialDashboardId = paramId === "new" ? undefined : paramId;
+
const router = useRouter();
const { refreshMenus } = useMenu();
const [elements, setElements] = useState([]);
diff --git a/frontend/app/(main)/admin/screenMng/dashboardList/edit/[id]/page.tsx b/frontend/app/(main)/admin/screenMng/dashboardList/edit/[id]/page.tsx
deleted file mode 100644
index 92220b6c..00000000
--- a/frontend/app/(main)/admin/screenMng/dashboardList/edit/[id]/page.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-"use client";
-
-import React from "react";
-import { use } from "react";
-import DashboardDesigner from "@/components/admin/dashboard/DashboardDesigner";
-
-interface PageProps {
- params: Promise<{ id: string }>;
-}
-
-/**
- * 대시보드 편집 페이지
- * - 기존 대시보드 편집
- */
-export default function DashboardEditPage({ params }: PageProps) {
- const { id } = use(params);
-
- return (
-
-
-
- );
-}
diff --git a/frontend/app/(main)/admin/screenMng/dashboardList/new/page.tsx b/frontend/app/(main)/admin/screenMng/dashboardList/new/page.tsx
deleted file mode 100644
index 56d28f46..00000000
--- a/frontend/app/(main)/admin/screenMng/dashboardList/new/page.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import DashboardDesigner from "@/components/admin/dashboard/DashboardDesigner";
-
-/**
- * 새 대시보드 생성 페이지
- */
-export default function DashboardNewPage() {
- return (
-
-
-
- );
-}
diff --git a/frontend/app/(main)/admin/screenMng/dashboardList/page.tsx b/frontend/app/(main)/admin/screenMng/dashboardList/page.tsx
index 62587c54..c346dc54 100644
--- a/frontend/app/(main)/admin/screenMng/dashboardList/page.tsx
+++ b/frontend/app/(main)/admin/screenMng/dashboardList/page.tsx
@@ -1,11 +1,167 @@
-import DashboardListClient from "@/app/(main)/admin/screenMng/dashboardList/DashboardListClient";
+"use client";
+
+import { useState, useEffect } from "react";
+import { useRouter } from "next/navigation";
+import { dashboardApi } from "@/lib/api/dashboard";
+import { Dashboard } from "@/lib/api/dashboard";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { useToast } from "@/hooks/use-toast";
+import { Pagination, PaginationInfo } from "@/components/common/Pagination";
+import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal";
+import { Plus, Search, Edit, Trash2, Copy, MoreVertical, AlertCircle, RefreshCw } from "lucide-react";
/**
* 대시보드 관리 페이지
- * - 클라이언트 컴포넌트를 렌더링하는 래퍼
- * - 초기 로딩부터 CSR로 처리
+ * - CSR 방식으로 초기 데이터 로드
+ * - 대시보드 목록 조회
+ * - 대시보드 생성/수정/삭제/복사
*/
export default function DashboardListPage() {
+ const router = useRouter();
+ const { toast } = useToast();
+
+ // 상태 관리
+ const [dashboards, setDashboards] = useState([]);
+ const [loading, setLoading] = useState(true); // CSR이므로 초기 로딩 true
+ const [error, setError] = useState(null);
+ const [searchTerm, setSearchTerm] = useState("");
+
+ // 페이지네이션 상태
+ const [currentPage, setCurrentPage] = useState(1);
+ const [pageSize, setPageSize] = useState(10);
+ const [totalCount, setTotalCount] = useState(0);
+
+ // 모달 상태
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null);
+
+ // 대시보드 목록 로드
+ const loadDashboards = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const result = await dashboardApi.getMyDashboards({
+ search: searchTerm,
+ page: currentPage,
+ limit: pageSize,
+ });
+ setDashboards(result.dashboards);
+ setTotalCount(result.pagination.total);
+ } catch (err) {
+ console.error("Failed to load dashboards:", err);
+ setError(
+ err instanceof Error
+ ? err.message
+ : "대시보드 목록을 불러오는데 실패했습니다. 네트워크 연결을 확인하거나 잠시 후 다시 시도해주세요.",
+ );
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // 검색어/페이지 변경 시 fetch (초기 로딩 포함)
+ useEffect(() => {
+ loadDashboards();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [searchTerm, currentPage, pageSize]);
+
+ // 페이지네이션 정보 계산
+ const paginationInfo: PaginationInfo = {
+ currentPage,
+ totalPages: Math.ceil(totalCount / pageSize) || 1,
+ totalItems: totalCount,
+ itemsPerPage: pageSize,
+ startItem: totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1,
+ endItem: Math.min(currentPage * pageSize, totalCount),
+ };
+
+ // 페이지 변경 핸들러
+ const handlePageChange = (page: number) => {
+ setCurrentPage(page);
+ };
+
+ // 페이지 크기 변경 핸들러
+ const handlePageSizeChange = (size: number) => {
+ setPageSize(size);
+ setCurrentPage(1); // 페이지 크기 변경 시 첫 페이지로
+ };
+
+ // 대시보드 삭제 확인 모달 열기
+ const handleDeleteClick = (id: string, title: string) => {
+ setDeleteTarget({ id, title });
+ setDeleteDialogOpen(true);
+ };
+
+ // 대시보드 삭제 실행
+ const handleDeleteConfirm = async () => {
+ if (!deleteTarget) return;
+
+ try {
+ await dashboardApi.deleteDashboard(deleteTarget.id);
+ setDeleteDialogOpen(false);
+ setDeleteTarget(null);
+ toast({
+ title: "성공",
+ description: "대시보드가 삭제되었습니다.",
+ });
+ loadDashboards();
+ } catch (err) {
+ console.error("Failed to delete dashboard:", err);
+ setDeleteDialogOpen(false);
+ toast({
+ title: "오류",
+ description: "대시보드 삭제에 실패했습니다.",
+ variant: "destructive",
+ });
+ }
+ };
+
+ // 대시보드 복사
+ const handleCopy = async (dashboard: Dashboard) => {
+ try {
+ const fullDashboard = await dashboardApi.getDashboard(dashboard.id);
+
+ await dashboardApi.createDashboard({
+ title: `${fullDashboard.title} (복사본)`,
+ description: fullDashboard.description,
+ elements: fullDashboard.elements || [],
+ isPublic: false,
+ tags: fullDashboard.tags,
+ category: fullDashboard.category,
+ settings: fullDashboard.settings as { resolution?: string; backgroundColor?: string },
+ });
+ toast({
+ title: "성공",
+ description: "대시보드가 복사되었습니다.",
+ });
+ loadDashboards();
+ } catch (err) {
+ console.error("Failed to copy dashboard:", err);
+ toast({
+ title: "오류",
+ description: "대시보드 복사에 실패했습니다.",
+ variant: "destructive",
+ });
+ }
+ };
+
+ // 포맷팅 헬퍼
+ const formatDate = (dateString: string) => {
+ return new Date(dateString).toLocaleDateString("ko-KR", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ });
+ };
+
return (
@@ -15,8 +171,287 @@ export default function DashboardListPage() {
대시보드를 생성하고 관리할 수 있습니다
- {/* 클라이언트 컴포넌트 */}
-
+ {/* 검색 및 액션 */}
+
+
+
+
+ setSearchTerm(e.target.value)}
+ className="h-10 pl-10 text-sm"
+ />
+
+
+ 총 {totalCount.toLocaleString()} 건
+
+
+
router.push("/admin/screenMng/dashboardList/new")} className="h-10 gap-2 text-sm font-medium">
+
+ 새 대시보드 생성
+
+
+
+ {/* 대시보드 목록 */}
+ {loading ? (
+ <>
+ {/* 데스크톱 테이블 스켈레톤 */}
+
+
+
+
+ 제목
+ 설명
+ 생성자
+ 생성일
+ 수정일
+ 작업
+
+
+
+ {Array.from({ length: 10 }).map((_, index) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+ {/* 모바일/태블릿 카드 스켈레톤 */}
+
+ {Array.from({ length: 6 }).map((_, index) => (
+
+
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+
+ ))}
+
+ >
+ ) : error ? (
+
+
+
+
+
데이터를 불러올 수 없습니다
+
{error}
+
+
+
+ 다시 시도
+
+
+
+ ) : dashboards.length === 0 ? (
+
+ ) : (
+ <>
+ {/* 데스크톱 테이블 뷰 (lg 이상) */}
+
+
+
+
+ 제목
+ 설명
+ 생성자
+ 생성일
+ 수정일
+ 작업
+
+
+
+ {dashboards.map((dashboard) => (
+
+
+ router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
+ className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
+ >
+ {dashboard.title}
+
+
+
+ {dashboard.description || "-"}
+
+
+ {dashboard.createdByName || dashboard.createdBy || "-"}
+
+
+ {formatDate(dashboard.createdAt)}
+
+
+ {formatDate(dashboard.updatedAt)}
+
+
+
+
+
+
+
+
+
+ router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
+ className="gap-2 text-sm"
+ >
+
+ 편집
+
+ handleCopy(dashboard)} className="gap-2 text-sm">
+
+ 복사
+
+ handleDeleteClick(dashboard.id, dashboard.title)}
+ className="text-destructive focus:text-destructive gap-2 text-sm"
+ >
+
+ 삭제
+
+
+
+
+
+ ))}
+
+
+
+
+ {/* 모바일/태블릿 카드 뷰 (lg 미만) */}
+
+ {dashboards.map((dashboard) => (
+
+ {/* 헤더 */}
+
+
+
router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
+ className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
+ >
+ {dashboard.title}
+
+
{dashboard.id}
+
+
+
+ {/* 정보 */}
+
+
+ 설명
+ {dashboard.description || "-"}
+
+
+ 생성자
+ {dashboard.createdByName || dashboard.createdBy || "-"}
+
+
+ 생성일
+ {formatDate(dashboard.createdAt)}
+
+
+ 수정일
+ {formatDate(dashboard.updatedAt)}
+
+
+
+ {/* 액션 */}
+
+ router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
+ >
+
+ 편집
+
+ handleCopy(dashboard)}
+ >
+
+ 복사
+
+ handleDeleteClick(dashboard.id, dashboard.title)}
+ >
+
+
+
+
+ ))}
+
+ >
+ )}
+
+ {/* 페이지네이션 */}
+ {!loading && dashboards.length > 0 && (
+
+ )}
+
+ {/* 삭제 확인 모달 */}
+
+ "{deleteTarget?.title}" 대시보드를 삭제하시겠습니까?
+ 이 작업은 되돌릴 수 없습니다.
+ >
+ }
+ onConfirm={handleDeleteConfirm}
+ />
);
diff --git a/frontend/app/(main)/admin/systemMng/i18nList/page.tsx b/frontend/app/(main)/admin/systemMng/i18nList/page.tsx
index 48655de7..3acce6fb 100644
--- a/frontend/app/(main)/admin/systemMng/i18nList/page.tsx
+++ b/frontend/app/(main)/admin/systemMng/i18nList/page.tsx
@@ -1,12 +1,823 @@
"use client";
-import MultiLang from "@/components/admin/MultiLang";
+import { useState, useEffect } from "react";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Badge } from "@/components/ui/badge";
+
+import { DataTable } from "@/components/common/DataTable";
+import { LoadingSpinner } from "@/components/common/LoadingSpinner";
+import { useAuth } from "@/hooks/useAuth";
+import LangKeyModal from "@/components/admin/LangKeyModal";
+import LanguageModal from "@/components/admin/LanguageModal";
+import { apiClient } from "@/lib/api/client";
+
+interface Language {
+ langCode: string;
+ langName: string;
+ langNative: string;
+ isActive: string;
+}
+
+interface LangKey {
+ keyId: number;
+ companyCode: string;
+ menuName: string;
+ langKey: string;
+ description: string;
+ isActive: string;
+}
+
+interface LangText {
+ textId: number;
+ keyId: number;
+ langCode: string;
+ langText: string;
+ isActive: string;
+}
export default function I18nPage() {
+ const { user } = useAuth();
+ const [loading, setLoading] = useState(true);
+ const [languages, setLanguages] = useState
([]);
+ const [langKeys, setLangKeys] = useState([]);
+ const [selectedKey, setSelectedKey] = useState(null);
+ const [langTexts, setLangTexts] = useState([]);
+ const [editingTexts, setEditingTexts] = useState([]);
+ const [selectedCompany, setSelectedCompany] = useState("all");
+ const [searchText, setSearchText] = useState("");
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [editingKey, setEditingKey] = useState(null);
+ const [selectedKeys, setSelectedKeys] = useState>(new Set());
+
+ // 언어 관리 관련 상태
+ const [isLanguageModalOpen, setIsLanguageModalOpen] = useState(false);
+ const [editingLanguage, setEditingLanguage] = useState(null);
+ const [selectedLanguages, setSelectedLanguages] = useState>(new Set());
+ const [activeTab, setActiveTab] = useState<"keys" | "languages">("keys");
+
+ const [companies, setCompanies] = useState>([]);
+
+ // 회사 목록 조회
+ const fetchCompanies = async () => {
+ try {
+ const response = await apiClient.get("/admin/companies");
+ const data = response.data;
+ if (data.success) {
+ const companyList = data.data.map((company: any) => ({
+ code: company.company_code,
+ name: company.company_name,
+ }));
+ setCompanies(companyList);
+ }
+ } catch (error) {
+ // console.error("회사 목록 조회 실패:", error);
+ }
+ };
+
+ // 언어 목록 조회
+ const fetchLanguages = async () => {
+ try {
+ const response = await apiClient.get("/multilang/languages");
+ const data = response.data;
+ if (data.success) {
+ setLanguages(data.data);
+ }
+ } catch (error) {
+ // console.error("언어 목록 조회 실패:", error);
+ }
+ };
+
+ // 다국어 키 목록 조회
+ const fetchLangKeys = async () => {
+ try {
+ const response = await apiClient.get("/multilang/keys");
+ const data = response.data;
+ if (data.success) {
+ setLangKeys(data.data);
+ }
+ } catch (error) {
+ // console.error("다국어 키 목록 조회 실패:", error);
+ }
+ };
+
+ // 필터링된 데이터 계산
+ const getFilteredLangKeys = () => {
+ let filteredKeys = langKeys;
+
+ // 회사 필터링
+ if (selectedCompany && selectedCompany !== "all") {
+ filteredKeys = filteredKeys.filter((key) => key.companyCode === selectedCompany);
+ }
+
+ // 텍스트 검색 필터링
+ if (searchText.trim()) {
+ const searchLower = searchText.toLowerCase();
+ filteredKeys = filteredKeys.filter((key) => {
+ const langKey = (key.langKey || "").toLowerCase();
+ const description = (key.description || "").toLowerCase();
+ const menuName = (key.menuName || "").toLowerCase();
+ const companyName = companies.find((c) => c.code === key.companyCode)?.name?.toLowerCase() || "";
+
+ return (
+ langKey.includes(searchLower) ||
+ description.includes(searchLower) ||
+ menuName.includes(searchLower) ||
+ companyName.includes(searchLower)
+ );
+ });
+ }
+
+ return filteredKeys;
+ };
+
+ // 선택된 키의 다국어 텍스트 조회
+ const fetchLangTexts = async (keyId: number) => {
+ try {
+ const response = await apiClient.get(`/multilang/keys/${keyId}/texts`);
+ const data = response.data;
+ if (data.success) {
+ setLangTexts(data.data);
+ const editingData = data.data.map((text: LangText) => ({ ...text }));
+ setEditingTexts(editingData);
+ }
+ } catch (error) {
+ // console.error("다국어 텍스트 조회 실패:", error);
+ }
+ };
+
+ // 언어 키 선택 처리
+ const handleKeySelect = (key: LangKey) => {
+ setSelectedKey(key);
+ fetchLangTexts(key.keyId);
+ };
+
+ // 텍스트 변경 처리
+ const handleTextChange = (langCode: string, value: string) => {
+ const newEditingTexts = [...editingTexts];
+ const existingIndex = newEditingTexts.findIndex((t) => t.langCode === langCode);
+
+ if (existingIndex >= 0) {
+ newEditingTexts[existingIndex].langText = value;
+ } else {
+ newEditingTexts.push({
+ textId: 0,
+ keyId: selectedKey!.keyId,
+ langCode: langCode,
+ langText: value,
+ isActive: "Y",
+ });
+ }
+
+ setEditingTexts(newEditingTexts);
+ };
+
+ // 텍스트 저장
+ const handleSave = async () => {
+ if (!selectedKey) return;
+
+ try {
+ const requestData = {
+ texts: editingTexts.map((text) => ({
+ langCode: text.langCode,
+ langText: text.langText,
+ isActive: text.isActive || "Y",
+ createdBy: user?.userId || "system",
+ updatedBy: user?.userId || "system",
+ })),
+ };
+
+ const response = await apiClient.post(`/multilang/keys/${selectedKey.keyId}/texts`, requestData);
+ const data = response.data;
+ if (data.success) {
+ alert("저장되었습니다.");
+ fetchLangTexts(selectedKey.keyId);
+ }
+ } catch (error) {
+ alert("저장에 실패했습니다.");
+ }
+ };
+
+ // 언어 키 추가/수정 모달 열기
+ const handleAddKey = () => {
+ setEditingKey(null);
+ setIsModalOpen(true);
+ };
+
+ // 언어 추가/수정 모달 열기
+ const handleAddLanguage = () => {
+ setEditingLanguage(null);
+ setIsLanguageModalOpen(true);
+ };
+
+ // 언어 수정
+ const handleEditLanguage = (language: Language) => {
+ setEditingLanguage(language);
+ setIsLanguageModalOpen(true);
+ };
+
+ // 언어 저장 (추가/수정)
+ const handleSaveLanguage = async (languageData: any) => {
+ try {
+ const requestData = {
+ ...languageData,
+ createdBy: user?.userId || "admin",
+ updatedBy: user?.userId || "admin",
+ };
+
+ let response;
+ if (editingLanguage) {
+ response = await apiClient.put(`/multilang/languages/${editingLanguage.langCode}`, requestData);
+ } else {
+ response = await apiClient.post("/multilang/languages", requestData);
+ }
+
+ const result = response.data;
+
+ if (result.success) {
+ alert(editingLanguage ? "언어가 수정되었습니다." : "언어가 추가되었습니다.");
+ setIsLanguageModalOpen(false);
+ fetchLanguages();
+ } else {
+ alert(`오류: ${result.message}`);
+ }
+ } catch (error) {
+ alert("언어 저장 중 오류가 발생했습니다.");
+ }
+ };
+
+ // 언어 삭제
+ const handleDeleteLanguages = async () => {
+ if (selectedLanguages.size === 0) {
+ alert("삭제할 언어를 선택해주세요.");
+ return;
+ }
+
+ if (
+ !confirm(
+ `선택된 ${selectedLanguages.size}개의 언어를 영구적으로 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.`,
+ )
+ ) {
+ return;
+ }
+
+ try {
+ const deletePromises = Array.from(selectedLanguages).map((langCode) =>
+ apiClient.delete(`/multilang/languages/${langCode}`),
+ );
+
+ const responses = await Promise.all(deletePromises);
+ const failedDeletes = responses.filter((response) => !response.data.success);
+
+ if (failedDeletes.length === 0) {
+ alert("선택된 언어가 삭제되었습니다.");
+ setSelectedLanguages(new Set());
+ fetchLanguages();
+ } else {
+ alert(`${failedDeletes.length}개의 언어 삭제에 실패했습니다.`);
+ }
+ } catch (error) {
+ alert("언어 삭제 중 오류가 발생했습니다.");
+ }
+ };
+
+ // 언어 선택 체크박스 처리
+ const handleLanguageCheckboxChange = (langCode: string, checked: boolean) => {
+ const newSelected = new Set(selectedLanguages);
+ if (checked) {
+ newSelected.add(langCode);
+ } else {
+ newSelected.delete(langCode);
+ }
+ setSelectedLanguages(newSelected);
+ };
+
+ // 언어 전체 선택/해제
+ const handleSelectAllLanguages = (checked: boolean) => {
+ if (checked) {
+ setSelectedLanguages(new Set(languages.map((lang) => lang.langCode)));
+ } else {
+ setSelectedLanguages(new Set());
+ }
+ };
+
+ // 언어 키 수정 모달 열기
+ const handleEditKey = (key: LangKey) => {
+ setEditingKey(key);
+ setIsModalOpen(true);
+ };
+
+ // 언어 키 저장 (추가/수정)
+ const handleSaveKey = async (keyData: any) => {
+ try {
+ const requestData = {
+ ...keyData,
+ createdBy: user?.userId || "admin",
+ updatedBy: user?.userId || "admin",
+ };
+
+ let response;
+ if (editingKey) {
+ response = await apiClient.put(`/multilang/keys/${editingKey.keyId}`, requestData);
+ } else {
+ response = await apiClient.post("/multilang/keys", requestData);
+ }
+
+ const data = response.data;
+
+ if (data.success) {
+ alert(editingKey ? "언어 키가 수정되었습니다." : "언어 키가 추가되었습니다.");
+ fetchLangKeys();
+ setIsModalOpen(false);
+ } else {
+ if (data.message && data.message.includes("이미 존재하는 언어키")) {
+ alert(data.message);
+ } else {
+ alert(data.message || "언어 키 저장에 실패했습니다.");
+ }
+ }
+ } catch (error) {
+ alert("언어 키 저장에 실패했습니다.");
+ }
+ };
+
+ // 체크박스 선택/해제
+ const handleCheckboxChange = (keyId: number, checked: boolean) => {
+ const newSelectedKeys = new Set(selectedKeys);
+ if (checked) {
+ newSelectedKeys.add(keyId);
+ } else {
+ newSelectedKeys.delete(keyId);
+ }
+ setSelectedKeys(newSelectedKeys);
+ };
+
+ // 키 상태 토글
+ const handleToggleStatus = async (keyId: number) => {
+ try {
+ const response = await apiClient.put(`/multilang/keys/${keyId}/toggle`);
+ const data = response.data;
+ if (data.success) {
+ alert(`키가 ${data.data}되었습니다.`);
+ fetchLangKeys();
+ } else {
+ alert("상태 변경 중 오류가 발생했습니다.");
+ }
+ } catch (error) {
+ alert("키 상태 변경 중 오류가 발생했습니다.");
+ }
+ };
+
+ // 언어 상태 토글
+ const handleToggleLanguageStatus = async (langCode: string) => {
+ try {
+ const response = await apiClient.put(`/multilang/languages/${langCode}/toggle`);
+ const data = response.data;
+ if (data.success) {
+ alert(`언어가 ${data.data}되었습니다.`);
+ fetchLanguages();
+ } else {
+ alert("언어 상태 변경 중 오류가 발생했습니다.");
+ }
+ } catch (error) {
+ alert("언어 상태 변경 중 오류가 발생했습니다.");
+ }
+ };
+
+ // 전체 선택/해제
+ const handleSelectAll = (checked: boolean) => {
+ if (checked) {
+ const allKeyIds = getFilteredLangKeys().map((key) => key.keyId);
+ setSelectedKeys(new Set(allKeyIds));
+ } else {
+ setSelectedKeys(new Set());
+ }
+ };
+
+ // 선택된 키들 일괄 삭제
+ const handleDeleteSelectedKeys = async () => {
+ if (selectedKeys.size === 0) {
+ alert("삭제할 키를 선택해주세요.");
+ return;
+ }
+
+ if (
+ !confirm(
+ `선택된 ${selectedKeys.size}개의 언어 키를 영구적으로 삭제하시겠습니까?\n\n⚠️ 이 작업은 되돌릴 수 없습니다.`,
+ )
+ ) {
+ return;
+ }
+
+ try {
+ const deletePromises = Array.from(selectedKeys).map((keyId) => apiClient.delete(`/multilang/keys/${keyId}`));
+
+ const responses = await Promise.all(deletePromises);
+ const allSuccess = responses.every((response) => response.data.success);
+
+ if (allSuccess) {
+ alert(`${selectedKeys.size}개의 언어 키가 영구적으로 삭제되었습니다.`);
+ setSelectedKeys(new Set());
+ fetchLangKeys();
+
+ if (selectedKey && selectedKeys.has(selectedKey.keyId)) {
+ handleCancel();
+ }
+ } else {
+ alert("일부 키 삭제에 실패했습니다.");
+ }
+ } catch (error) {
+ alert("선택된 키 삭제에 실패했습니다.");
+ }
+ };
+
+ // 개별 키 삭제
+ const handleDeleteKey = async (keyId: number) => {
+ if (!confirm("정말로 이 언어 키를 영구적으로 삭제하시겠습니까?\n\n⚠️ 이 작업은 되돌릴 수 없습니다.")) {
+ return;
+ }
+
+ try {
+ const response = await apiClient.delete(`/multilang/keys/${keyId}`);
+ const data = response.data;
+ if (data.success) {
+ alert("언어 키가 영구적으로 삭제되었습니다.");
+ fetchLangKeys();
+ if (selectedKey && selectedKey.keyId === keyId) {
+ handleCancel();
+ }
+ }
+ } catch (error) {
+ alert("언어 키 삭제에 실패했습니다.");
+ }
+ };
+
+ // 취소 처리
+ const handleCancel = () => {
+ setSelectedKey(null);
+ setLangTexts([]);
+ setEditingTexts([]);
+ };
+
+ useEffect(() => {
+ const initializeData = async () => {
+ setLoading(true);
+ await Promise.all([fetchCompanies(), fetchLanguages(), fetchLangKeys()]);
+ setLoading(false);
+ };
+ initializeData();
+ }, []);
+
+ const columns = [
+ {
+ id: "select",
+ header: () => {
+ const filteredKeys = getFilteredLangKeys();
+ return (
+ 0}
+ onChange={(e) => handleSelectAll(e.target.checked)}
+ className="h-4 w-4"
+ />
+ );
+ },
+ cell: ({ row }: any) => (
+ handleCheckboxChange(row.original.keyId, e.target.checked)}
+ onClick={(e) => e.stopPropagation()}
+ className="h-4 w-4"
+ disabled={row.original.isActive === "N"}
+ />
+ ),
+ },
+ {
+ accessorKey: "companyCode",
+ header: "회사",
+ cell: ({ row }: any) => {
+ const companyName =
+ row.original.companyCode === "*"
+ ? "공통"
+ : companies.find((c) => c.code === row.original.companyCode)?.name || row.original.companyCode;
+
+ return {companyName} ;
+ },
+ },
+ {
+ accessorKey: "menuName",
+ header: "메뉴명",
+ cell: ({ row }: any) => (
+ {row.original.menuName}
+ ),
+ },
+ {
+ accessorKey: "langKey",
+ header: "언어 키",
+ cell: ({ row }: any) => (
+ handleEditKey(row.original)}
+ >
+ {row.original.langKey}
+
+ ),
+ },
+ {
+ accessorKey: "description",
+ header: "설명",
+ cell: ({ row }: any) => (
+ {row.original.description}
+ ),
+ },
+ {
+ accessorKey: "isActive",
+ header: "상태",
+ cell: ({ row }: any) => (
+ handleToggleStatus(row.original.keyId)}
+ className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
+ row.original.isActive === "Y"
+ ? "bg-green-100 text-green-800 hover:bg-green-200"
+ : "bg-gray-100 text-gray-800 hover:bg-gray-200"
+ }`}
+ >
+ {row.original.isActive === "Y" ? "활성" : "비활성"}
+
+ ),
+ },
+ ];
+
+ // 언어 테이블 컬럼 정의
+ const languageColumns = [
+ {
+ id: "select",
+ header: () => (
+ 0}
+ onChange={(e) => handleSelectAllLanguages(e.target.checked)}
+ className="h-4 w-4"
+ />
+ ),
+ cell: ({ row }: any) => (
+ handleLanguageCheckboxChange(row.original.langCode, e.target.checked)}
+ onClick={(e) => e.stopPropagation()}
+ className="h-4 w-4"
+ disabled={row.original.isActive === "N"}
+ />
+ ),
+ },
+ {
+ accessorKey: "langCode",
+ header: "언어 코드",
+ cell: ({ row }: any) => (
+ handleEditLanguage(row.original)}
+ >
+ {row.original.langCode}
+
+ ),
+ },
+ {
+ accessorKey: "langName",
+ header: "언어명 (영문)",
+ cell: ({ row }: any) => (
+ {row.original.langName}
+ ),
+ },
+ {
+ accessorKey: "langNative",
+ header: "언어명 (원어)",
+ cell: ({ row }: any) => (
+ {row.original.langNative}
+ ),
+ },
+ {
+ accessorKey: "isActive",
+ header: "상태",
+ cell: ({ row }: any) => (
+ handleToggleLanguageStatus(row.original.langCode)}
+ className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
+ row.original.isActive === "Y"
+ ? "bg-green-100 text-green-800 hover:bg-green-200"
+ : "bg-gray-100 text-gray-800 hover:bg-gray-200"
+ }`}
+ >
+ {row.original.isActive === "Y" ? "활성" : "비활성"}
+
+ ),
+ },
+ ];
+
+ if (loading) {
+ return ;
+ }
+
return (
-
+
+ {/* 탭 네비게이션 */}
+
+ setActiveTab("keys")}
+ className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${
+ activeTab === "keys" ? "bg-accent0 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
+ }`}
+ >
+ 다국어 키 관리
+
+ setActiveTab("languages")}
+ className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${
+ activeTab === "languages" ? "bg-accent0 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
+ }`}
+ >
+ 언어 관리
+
+
+
+ {/* 메인 콘텐츠 영역 */}
+
+ {/* 언어 관리 탭 */}
+ {activeTab === "languages" && (
+
+
+ 언어 관리
+
+
+
+
총 {languages.length}개의 언어가 등록되어 있습니다.
+
+ {selectedLanguages.size > 0 && (
+
+ 선택 삭제 ({selectedLanguages.size})
+
+ )}
+ 새 언어 추가
+
+
+
+
+
+ )}
+
+ {/* 다국어 키 관리 탭 */}
+ {activeTab === "keys" && (
+
+ {/* 좌측: 언어 키 목록 (7/10) */}
+
+
+
+
언어 키 목록
+
+
+ 선택 삭제 ({selectedKeys.size})
+
+ 새 키 추가
+
+
+
+
+ {/* 검색 필터 영역 */}
+
+
+ 회사
+
+
+
+
+
+ 전체 회사
+ {companies.map((company) => (
+
+ {company.name}
+
+ ))}
+
+
+
+
+
+ 검색
+ setSearchText(e.target.value)}
+ />
+
+
+
+
검색 결과: {getFilteredLangKeys().length}건
+
+
+
+ {/* 테이블 영역 */}
+
+
전체: {getFilteredLangKeys().length}건
+
+
+
+
+
+ {/* 우측: 선택된 키의 다국어 관리 (3/10) */}
+
+
+
+ {selectedKey ? (
+ <>
+ 선택된 키:{" "}
+
+ {selectedKey.companyCode}.{selectedKey.menuName}.{selectedKey.langKey}
+
+ >
+ ) : (
+ "다국어 편집"
+ )}
+
+
+
+ {selectedKey ? (
+
+ {/* 스크롤 가능한 텍스트 영역 */}
+
+ {languages
+ .filter((lang) => lang.isActive === "Y")
+ .map((lang) => {
+ const text = editingTexts.find((t) => t.langCode === lang.langCode);
+ return (
+
+
+ {lang.langName}
+
+ handleTextChange(lang.langCode, e.target.value)}
+ className="flex-1"
+ />
+
+ );
+ })}
+
+ {/* 저장 버튼 - 고정 위치 */}
+
+ 저장
+
+ 취소
+
+
+
+ ) : (
+
+
+
언어 키를 선택하세요
+
좌측 목록에서 편집할 언어 키를 클릭하세요
+
+
+ )}
+
+
+
+ )}
+
+
+ {/* 언어 키 추가/수정 모달 */}
+
setIsModalOpen(false)}
+ onSave={handleSaveKey}
+ keyData={editingKey}
+ companies={companies}
+ />
+
+ {/* 언어 추가/수정 모달 */}
+ setIsLanguageModalOpen(false)}
+ onSave={handleSaveLanguage}
+ languageData={editingLanguage}
+ />
+
);
diff --git a/frontend/app/(main)/admin/userMng/companyList/[companyCode]/departments/page.tsx b/frontend/app/(main)/admin/userMng/companyList/[companyCode]/departments/page.tsx
index 7854e6ee..a9cd747c 100644
--- a/frontend/app/(main)/admin/userMng/companyList/[companyCode]/departments/page.tsx
+++ b/frontend/app/(main)/admin/userMng/companyList/[companyCode]/departments/page.tsx
@@ -1,12 +1,115 @@
"use client";
-import { useParams } from "next/navigation";
-import { DepartmentManagement } from "@/components/admin/department/DepartmentManagement";
+import { useState, useEffect } from "react";
+import { useParams, useRouter } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { ArrowLeft } from "lucide-react";
+import { DepartmentStructure } from "@/components/admin/department/DepartmentStructure";
+import { DepartmentMembers } from "@/components/admin/department/DepartmentMembers";
+import type { Department } from "@/types/department";
+import { getCompanyList } from "@/lib/api/company";
+/**
+ * 부서 관리 메인 페이지
+ * 좌측: 부서 구조, 우측: 부서 인원
+ */
export default function DepartmentManagementPage() {
const params = useParams();
+ const router = useRouter();
const companyCode = params.companyCode as string;
+ const [selectedDepartment, setSelectedDepartment] = useState(null);
+ const [activeTab, setActiveTab] = useState("structure");
+ const [companyName, setCompanyName] = useState("");
+ const [refreshTrigger, setRefreshTrigger] = useState(0);
- return ;
+ // 부서원 변경 시 부서 구조 새로고침
+ const handleMemberChange = () => {
+ setRefreshTrigger((prev) => prev + 1);
+ };
+
+ // 회사 정보 로드
+ useEffect(() => {
+ const loadCompanyInfo = async () => {
+ const response = await getCompanyList();
+ if (response.success && response.data) {
+ const company = response.data.find((c) => c.company_code === companyCode);
+ if (company) {
+ setCompanyName(company.company_name);
+ }
+ }
+ };
+ loadCompanyInfo();
+ }, [companyCode]);
+
+ const handleBackToList = () => {
+ router.push("/admin/userMng/companyList");
+ };
+
+ return (
+
+ {/* 상단 헤더: 회사 정보 + 뒤로가기 */}
+
+
+
+
+ 회사 목록
+
+
+
+
{companyName || companyCode}
+
부서 관리
+
+
+
+ {/* 탭 네비게이션 (모바일용) */}
+
+
+
+ 부서 구조
+ 부서 인원
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* 좌우 레이아웃 (데스크톱) */}
+
+ {/* 좌측: 부서 구조 (20%) */}
+
+
+
+
+ {/* 우측: 부서 인원 (80%) */}
+
+
+
+
+
+ );
}
-
diff --git a/frontend/app/(main)/admin/userMng/companyList/page.tsx b/frontend/app/(main)/admin/userMng/companyList/page.tsx
index c24afc7a..a36cd9c3 100644
--- a/frontend/app/(main)/admin/userMng/companyList/page.tsx
+++ b/frontend/app/(main)/admin/userMng/companyList/page.tsx
@@ -1,10 +1,56 @@
-import { CompanyManagement } from "@/components/admin/CompanyManagement";
+"use client";
+
+import { useCompanyManagement } from "@/hooks/useCompanyManagement";
+import { CompanyToolbar } from "@/components/admin/CompanyToolbar";
+import { CompanyTable } from "@/components/admin/CompanyTable";
+import { CompanyFormModal } from "@/components/admin/CompanyFormModal";
+import { CompanyDeleteDialog } from "@/components/admin/CompanyDeleteDialog";
+import { DiskUsageSummary } from "@/components/admin/DiskUsageSummary";
import { ScrollToTop } from "@/components/common/ScrollToTop";
/**
* 회사 관리 페이지
+ * 모든 회사 관리 기능을 통합하여 제공
*/
export default function CompanyPage() {
+ const {
+ // 데이터
+ companies,
+ searchFilter,
+ isLoading,
+ error,
+
+ // 디스크 사용량 관련
+ diskUsageInfo,
+ isDiskUsageLoading,
+ loadDiskUsage,
+
+ // 모달 상태
+ modalState,
+ deleteState,
+
+ // 검색 기능
+ updateSearchFilter,
+ clearSearchFilter,
+
+ // 모달 제어
+ openCreateModal,
+ openEditModal,
+ closeModal,
+ updateFormData,
+
+ // 삭제 다이얼로그 제어
+ openDeleteDialog,
+ closeDeleteDialog,
+
+ // CRUD 작업
+ saveCompany,
+ deleteCompany,
+
+ // 에러 처리
+ clearError,
+ } = useCompanyManagement();
+
return (
@@ -14,8 +60,42 @@ export default function CompanyPage() {
시스템에서 사용하는 회사 정보를 관리합니다
- {/* 메인 컨텐츠 */}
-
+ {/* 디스크 사용량 요약 */}
+
+
+ {/* 툴바 - 검색, 필터, 등록 버튼 */}
+
+
+ {/* 회사 목록 테이블 */}
+
+
+ {/* 회사 등록/수정 모달 */}
+
+
+ {/* 회사 삭제 확인 다이얼로그 */}
+
{/* Scroll to Top 버튼 */}
diff --git a/frontend/app/(main)/admin/userMng/rolesList/[id]/page.tsx b/frontend/app/(main)/admin/userMng/rolesList/[id]/page.tsx
index a1579bf2..30552af4 100644
--- a/frontend/app/(main)/admin/userMng/rolesList/[id]/page.tsx
+++ b/frontend/app/(main)/admin/userMng/rolesList/[id]/page.tsx
@@ -1,12 +1,20 @@
"use client";
+import React, { useState, useCallback, useEffect } from "react";
import { use } from "react";
-import { RoleDetailManagement } from "@/components/admin/RoleDetailManagement";
+import { Button } from "@/components/ui/button";
+import { ArrowLeft, Users, Menu as MenuIcon, Save, AlertCircle } from "lucide-react";
+import { roleAPI, RoleGroup } from "@/lib/api/role";
+import { useAuth } from "@/hooks/useAuth";
+import { useRouter } from "next/navigation";
+import { DualListBox } from "@/components/common/DualListBox";
+import { MenuPermissionsTable } from "@/components/admin/MenuPermissionsTable";
+import { useMenu } from "@/contexts/MenuContext";
import { ScrollToTop } from "@/components/common/ScrollToTop";
/**
* 권한 그룹 상세 페이지
- * URL: /admin/roles/[id]
+ * URL: /admin/userMng/rolesList/[id]
*
* 기능:
* - 권한 그룹 멤버 관리 (Dual List Box)
@@ -14,13 +22,324 @@ import { ScrollToTop } from "@/components/common/ScrollToTop";
*/
export default function RoleDetailPage({ params }: { params: Promise<{ id: string }> }) {
// Next.js 15: params는 Promise이므로 React.use()로 unwrap
- const { id } = use(params);
+ const { id: roleId } = use(params);
+ const { user: currentUser } = useAuth();
+ const router = useRouter();
+ const { refreshMenus } = useMenu();
+
+ const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
+
+ // 상태 관리
+ const [roleGroup, setRoleGroup] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ // 탭 상태
+ const [activeTab, setActiveTab] = useState<"members" | "permissions">("members");
+
+ // 멤버 관리 상태
+ const [availableUsers, setAvailableUsers] = useState>([]);
+ const [selectedUsers, setSelectedUsers] = useState>([]);
+ const [isSavingMembers, setIsSavingMembers] = useState(false);
+
+ // 메뉴 권한 상태
+ const [menuPermissions, setMenuPermissions] = useState([]);
+ const [isSavingPermissions, setIsSavingPermissions] = useState(false);
+
+ // 데이터 로드
+ const loadRoleGroup = useCallback(async () => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const response = await roleAPI.getById(parseInt(roleId, 10));
+
+ if (response.success && response.data) {
+ setRoleGroup(response.data);
+ } else {
+ setError(response.message || "권한 그룹 정보를 불러오는데 실패했습니다.");
+ }
+ } catch (err) {
+ console.error("권한 그룹 정보 로드 오류:", err);
+ setError("권한 그룹 정보를 불러오는 중 오류가 발생했습니다.");
+ } finally {
+ setIsLoading(false);
+ }
+ }, [roleId]);
+
+ // 멤버 목록 로드
+ const loadMembers = useCallback(async () => {
+ if (!roleGroup) return;
+
+ try {
+ // 1. 권한 그룹 멤버 조회
+ const membersResponse = await roleAPI.getMembers(roleGroup.objid);
+ if (membersResponse.success && membersResponse.data) {
+ setSelectedUsers(
+ membersResponse.data.map((member: any) => ({
+ id: member.userId,
+ label: member.userName || member.userId,
+ description: member.deptName,
+ })),
+ );
+ }
+
+ // 2. 전체 사용자 목록 조회 (같은 회사)
+ const userAPI = await import("@/lib/api/user");
+
+ console.log("🔍 사용자 목록 조회 요청:", {
+ companyCode: roleGroup.companyCode,
+ size: 1000,
+ });
+
+ const usersResponse = await userAPI.userAPI.getList({
+ companyCode: roleGroup.companyCode,
+ size: 1000, // 대량 조회
+ });
+
+ console.log("✅ 사용자 목록 응답:", {
+ success: usersResponse.success,
+ count: usersResponse.data?.length,
+ total: usersResponse.total,
+ });
+
+ if (usersResponse.success && usersResponse.data) {
+ setAvailableUsers(
+ usersResponse.data.map((user: any) => ({
+ id: user.userId,
+ label: user.userName || user.userId,
+ description: user.deptName,
+ })),
+ );
+ console.log("📋 설정된 전체 사용자 수:", usersResponse.data.length);
+ }
+ } catch (err) {
+ console.error("멤버 목록 로드 오류:", err);
+ }
+ }, [roleGroup]);
+
+ // 메뉴 권한 로드
+ const loadMenuPermissions = useCallback(async () => {
+ if (!roleGroup) return;
+
+ console.log("🔍 [loadMenuPermissions] 메뉴 권한 로드 시작", {
+ roleGroupId: roleGroup.objid,
+ roleGroupName: roleGroup.authName,
+ companyCode: roleGroup.companyCode,
+ });
+
+ try {
+ const response = await roleAPI.getMenuPermissions(roleGroup.objid);
+
+ console.log("✅ [loadMenuPermissions] API 응답", {
+ success: response.success,
+ dataCount: response.data?.length,
+ data: response.data,
+ });
+
+ if (response.success && response.data) {
+ setMenuPermissions(response.data);
+ console.log("✅ [loadMenuPermissions] 상태 업데이트 완료", {
+ count: response.data.length,
+ });
+ } else {
+ console.warn("⚠️ [loadMenuPermissions] 응답 실패", {
+ message: response.message,
+ });
+ }
+ } catch (err) {
+ console.error("❌ [loadMenuPermissions] 메뉴 권한 로드 오류:", err);
+ }
+ }, [roleGroup]);
+
+ useEffect(() => {
+ loadRoleGroup();
+ }, [loadRoleGroup]);
+
+ useEffect(() => {
+ if (roleGroup && activeTab === "members") {
+ loadMembers();
+ } else if (roleGroup && activeTab === "permissions") {
+ loadMenuPermissions();
+ }
+ }, [roleGroup, activeTab, loadMembers, loadMenuPermissions]);
+
+ // 멤버 저장 핸들러
+ const handleSaveMembers = useCallback(async () => {
+ if (!roleGroup) return;
+
+ setIsSavingMembers(true);
+ try {
+ // 현재 선택된 사용자 ID 목록
+ const selectedUserIds = selectedUsers.map((user) => user.id);
+
+ // 멤버 업데이트 API 호출
+ const response = await roleAPI.updateMembers(roleGroup.objid, selectedUserIds);
+
+ if (response.success) {
+ alert("멤버가 성공적으로 저장되었습니다.");
+ loadMembers(); // 새로고침
+
+ // 사이드바 메뉴 새로고침 (현재 사용자가 영향받을 수 있음)
+ await refreshMenus();
+ } else {
+ alert(response.message || "멤버 저장에 실패했습니다.");
+ }
+ } catch (err) {
+ console.error("멤버 저장 오류:", err);
+ alert("멤버 저장 중 오류가 발생했습니다.");
+ } finally {
+ setIsSavingMembers(false);
+ }
+ }, [roleGroup, selectedUsers, loadMembers, refreshMenus]);
+
+ // 메뉴 권한 저장 핸들러
+ const handleSavePermissions = useCallback(async () => {
+ if (!roleGroup) return;
+
+ setIsSavingPermissions(true);
+ try {
+ const response = await roleAPI.setMenuPermissions(roleGroup.objid, menuPermissions);
+
+ if (response.success) {
+ alert("메뉴 권한이 성공적으로 저장되었습니다.");
+ loadMenuPermissions(); // 새로고침
+
+ // 사이드바 메뉴 새로고침 (권한 변경 즉시 반영)
+ await refreshMenus();
+ } else {
+ alert(response.message || "메뉴 권한 저장에 실패했습니다.");
+ }
+ } catch (err) {
+ console.error("메뉴 권한 저장 오류:", err);
+ alert("메뉴 권한 저장 중 오류가 발생했습니다.");
+ } finally {
+ setIsSavingPermissions(false);
+ }
+ }, [roleGroup, menuPermissions, loadMenuPermissions, refreshMenus]);
+
+ if (isLoading) {
+ return (
+
+
+
+
권한 그룹 정보를 불러오는 중...
+
+
+ );
+ }
+
+ if (error || !roleGroup) {
+ return (
+
+
+
오류 발생
+
{error || "권한 그룹을 찾을 수 없습니다."}
+
router.push("/admin/userMng/rolesList")}>
+ 목록으로 돌아가기
+
+
+ );
+ }
return (
- {/* 메인 컨텐츠 */}
-
+ {/* 페이지 헤더 */}
+
+
+
router.push("/admin/userMng/rolesList")} className="h-10 w-10">
+
+
+
+
{roleGroup.authName}
+
+ {roleGroup.authCode} • {roleGroup.companyCode}
+
+
+
+ {roleGroup.status === "active" ? "활성" : "비활성"}
+
+
+
+
+ {/* 탭 네비게이션 */}
+
+ setActiveTab("members")}
+ className={`flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors ${
+ activeTab === "members"
+ ? "border-primary text-primary"
+ : "text-muted-foreground hover:text-foreground border-transparent"
+ }`}
+ >
+
+ 멤버 관리 ({selectedUsers.length})
+
+ setActiveTab("permissions")}
+ className={`flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors ${
+ activeTab === "permissions"
+ ? "border-primary text-primary"
+ : "text-muted-foreground hover:text-foreground border-transparent"
+ }`}
+ >
+
+ 메뉴 권한 ({menuPermissions.length})
+
+
+
+ {/* 탭 컨텐츠 */}
+
+ {activeTab === "members" && (
+ <>
+
+
+
멤버 관리
+
이 권한 그룹에 속한 사용자를 관리합니다
+
+
+
+ {isSavingMembers ? "저장 중..." : "멤버 저장"}
+
+
+
+
+ >
+ )}
+
+ {activeTab === "permissions" && (
+ <>
+
+
+
메뉴 권한 설정
+
이 권한 그룹에서 접근 가능한 메뉴와 권한을 설정합니다
+
+
+
+ {isSavingPermissions ? "저장 중..." : "권한 저장"}
+
+
+
+
+ >
+ )}
+
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
diff --git a/frontend/app/(main)/admin/userMng/rolesList/page.tsx b/frontend/app/(main)/admin/userMng/rolesList/page.tsx
index 2b973ad5..eeac1dec 100644
--- a/frontend/app/(main)/admin/userMng/rolesList/page.tsx
+++ b/frontend/app/(main)/admin/userMng/rolesList/page.tsx
@@ -1,6 +1,16 @@
"use client";
-import { RoleManagement } from "@/components/admin/RoleManagement";
+import React, { useState, useCallback, useEffect } from "react";
+import { Button } from "@/components/ui/button";
+import { Plus, Edit, Trash2, Users, Menu, Filter, X } from "lucide-react";
+import { roleAPI, RoleGroup } from "@/lib/api/role";
+import { useAuth } from "@/hooks/useAuth";
+import { AlertCircle } from "lucide-react";
+import { RoleFormModal } from "@/components/admin/RoleFormModal";
+import { RoleDeleteModal } from "@/components/admin/RoleDeleteModal";
+import { useRouter } from "next/navigation";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { companyAPI } from "@/lib/api/company";
import { ScrollToTop } from "@/components/common/ScrollToTop";
/**
@@ -14,21 +24,336 @@ import { ScrollToTop } from "@/components/common/ScrollToTop";
* - 권한 그룹 생성/수정/삭제
* - 멤버 관리 (Dual List Box)
* - 메뉴 권한 설정 (CRUD 권한)
+ * - 상세 페이지로 이동 (멤버 관리 + 메뉴 권한 설정)
*/
export default function RolesPage() {
+ const { user: currentUser } = useAuth();
+ const router = useRouter();
+
+ // 회사 관리자 또는 최고 관리자 여부
+ const isAdmin =
+ (currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN") ||
+ currentUser?.userType === "COMPANY_ADMIN";
+ const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
+
+ // 상태 관리
+ const [roleGroups, setRoleGroups] = useState
([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ // 회사 필터 (최고 관리자 전용)
+ const [companies, setCompanies] = useState>([]);
+ const [selectedCompany, setSelectedCompany] = useState("all");
+
+ // 모달 상태
+ const [formModal, setFormModal] = useState({
+ isOpen: false,
+ editingRole: null as RoleGroup | null,
+ });
+
+ const [deleteModal, setDeleteModal] = useState({
+ isOpen: false,
+ role: null as RoleGroup | null,
+ });
+
+ // 회사 목록 로드 (최고 관리자만)
+ const loadCompanies = useCallback(async () => {
+ if (!isSuperAdmin) return;
+
+ try {
+ const companies = await companyAPI.getList();
+ setCompanies(companies);
+ } catch (error) {
+ console.error("회사 목록 로드 오류:", error);
+ }
+ }, [isSuperAdmin]);
+
+ // 데이터 로드
+ const loadRoleGroups = useCallback(async () => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ // 최고 관리자: selectedCompany에 따라 필터링 (all이면 전체 조회)
+ // 회사 관리자: 자기 회사만 조회
+ const companyFilter =
+ isSuperAdmin && selectedCompany !== "all"
+ ? selectedCompany
+ : isSuperAdmin
+ ? undefined
+ : currentUser?.companyCode;
+
+ console.log("권한 그룹 목록 조회:", { isSuperAdmin, selectedCompany, companyFilter });
+
+ const response = await roleAPI.getList({
+ companyCode: companyFilter,
+ });
+
+ if (response.success && response.data) {
+ setRoleGroups(response.data);
+ console.log("권한 그룹 조회 성공:", response.data.length, "개");
+ } else {
+ setError(response.message || "권한 그룹 목록을 불러오는데 실패했습니다.");
+ }
+ } catch (err) {
+ console.error("권한 그룹 목록 로드 오류:", err);
+ setError("권한 그룹 목록을 불러오는 중 오류가 발생했습니다.");
+ } finally {
+ setIsLoading(false);
+ }
+ }, [isSuperAdmin, selectedCompany, currentUser?.companyCode]);
+
+ useEffect(() => {
+ if (isAdmin) {
+ if (isSuperAdmin) {
+ loadCompanies(); // 최고 관리자는 회사 목록 먼저 로드
+ }
+ loadRoleGroups();
+ } else {
+ setIsLoading(false);
+ }
+ }, [isAdmin, isSuperAdmin, loadRoleGroups, loadCompanies]);
+
+ // 권한 그룹 생성 핸들러
+ const handleCreateRole = useCallback(() => {
+ setFormModal({ isOpen: true, editingRole: null });
+ }, []);
+
+ // 권한 그룹 수정 핸들러
+ const handleEditRole = useCallback((role: RoleGroup) => {
+ setFormModal({ isOpen: true, editingRole: role });
+ }, []);
+
+ // 권한 그룹 삭제 핸들러
+ const handleDeleteRole = useCallback((role: RoleGroup) => {
+ setDeleteModal({ isOpen: true, role });
+ }, []);
+
+ // 폼 모달 닫기
+ const handleFormModalClose = useCallback(() => {
+ setFormModal({ isOpen: false, editingRole: null });
+ }, []);
+
+ // 삭제 모달 닫기
+ const handleDeleteModalClose = useCallback(() => {
+ setDeleteModal({ isOpen: false, role: null });
+ }, []);
+
+ // 모달 성공 후 새로고침
+ const handleModalSuccess = useCallback(() => {
+ loadRoleGroups();
+ }, [loadRoleGroups]);
+
+ // 상세 페이지로 이동
+ const handleViewDetail = useCallback(
+ (role: RoleGroup) => {
+ router.push(`/admin/userMng/rolesList/${role.objid}`);
+ },
+ [router],
+ );
+
+ // 관리자가 아니면 접근 제한
+ if (!isAdmin) {
+ return (
+
+
+
+
권한 그룹 관리
+
회사 내 권한 그룹을 생성하고 멤버를 관리합니다 (회사 관리자 이상)
+
+
+
+
+
접근 권한 없음
+
+ 권한 그룹 관리는 회사 관리자 이상만 접근할 수 있습니다.
+
+
window.history.back()}>
+ 뒤로 가기
+
+
+
+
+
+
+ );
+ }
+
return (
{/* 페이지 헤더 */}
권한 그룹 관리
-
- 회사 내 권한 그룹을 생성하고 멤버를 관리합니다 (회사 관리자 이상)
-
+
회사 내 권한 그룹을 생성하고 멤버를 관리합니다 (회사 관리자 이상)
- {/* 메인 컨텐츠 */}
-
+ {/* 에러 메시지 */}
+ {error && (
+
+
+
오류가 발생했습니다
+
setError(null)}
+ className="text-destructive hover:text-destructive/80 transition-colors"
+ aria-label="에러 메시지 닫기"
+ >
+ ✕
+
+
+
{error}
+
+ )}
+
+ {/* 액션 버튼 영역 */}
+
+
+
권한 그룹 목록 ({roleGroups.length})
+
+ {/* 최고 관리자 전용: 회사 필터 */}
+ {isSuperAdmin && (
+
+
+ setSelectedCompany(value)}>
+
+
+
+
+ 전체 회사
+ {companies.map((company) => (
+
+ {company.company_name}
+
+ ))}
+
+
+ {selectedCompany !== "all" && (
+ setSelectedCompany("all")} className="h-8 w-8 p-0">
+
+
+ )}
+
+ )}
+
+
+
+
+ 권한 그룹 생성
+
+
+
+ {/* 권한 그룹 목록 */}
+ {isLoading ? (
+
+
+
+
권한 그룹 목록을 불러오는 중...
+
+
+ ) : roleGroups.length === 0 ? (
+
+
+
등록된 권한 그룹이 없습니다.
+
권한 그룹을 생성하여 멤버를 관리해보세요.
+
+
+ ) : (
+
+ {roleGroups.map((role) => (
+
+ {/* 헤더 (클릭 시 상세 페이지) */}
+
handleViewDetail(role)}
+ >
+
+
+
{role.authName}
+
{role.authCode}
+
+
+ {role.status === "active" ? "활성" : "비활성"}
+
+
+
+ {/* 정보 */}
+
+ {/* 최고 관리자는 회사명 표시 */}
+ {isSuperAdmin && (
+
+ 회사
+
+ {companies.find((c) => c.company_code === role.companyCode)?.company_name || role.companyCode}
+
+
+ )}
+
+
+
+ 멤버 수
+
+ {role.memberCount || 0}명
+
+
+
+
+ 메뉴 권한
+
+ {role.menuCount || 0}개
+
+
+
+
+ {/* 액션 버튼 */}
+
+ {
+ e.stopPropagation();
+ handleEditRole(role);
+ }}
+ className="flex-1 gap-1 text-xs"
+ >
+
+ 수정
+
+ {
+ e.stopPropagation();
+ handleDeleteRole(role);
+ }}
+ className="text-destructive hover:bg-destructive hover:text-destructive-foreground gap-1 text-xs"
+ >
+
+ 삭제
+
+
+
+ ))}
+
+ )}
+
+ {/* 모달들 */}
+
+
+
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
diff --git a/frontend/app/(main)/admin/userMng/userAuthList/page.tsx b/frontend/app/(main)/admin/userMng/userAuthList/page.tsx
index 322bba64..4ad69183 100644
--- a/frontend/app/(main)/admin/userMng/userAuthList/page.tsx
+++ b/frontend/app/(main)/admin/userMng/userAuthList/page.tsx
@@ -1,6 +1,12 @@
"use client";
-import { UserAuthManagement } from "@/components/admin/UserAuthManagement";
+import React, { useState, useCallback, useEffect } from "react";
+import { UserAuthTable } from "@/components/admin/UserAuthTable";
+import { UserAuthEditModal } from "@/components/admin/UserAuthEditModal";
+import { userAPI } from "@/lib/api/user";
+import { useAuth } from "@/hooks/useAuth";
+import { AlertCircle } from "lucide-react";
+import { Button } from "@/components/ui/button";
import { ScrollToTop } from "@/components/common/ScrollToTop";
/**
@@ -11,6 +17,119 @@ import { ScrollToTop } from "@/components/common/ScrollToTop";
* 사용자별 권한 레벨(SUPER_ADMIN, COMPANY_ADMIN, USER 등) 관리
*/
export default function UserAuthPage() {
+ const { user: currentUser } = useAuth();
+
+ // 최고 관리자 여부
+ const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
+
+ // 상태 관리
+ const [users, setUsers] = useState
([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [paginationInfo, setPaginationInfo] = useState({
+ currentPage: 1,
+ pageSize: 20,
+ totalItems: 0,
+ totalPages: 0,
+ });
+
+ // 권한 변경 모달
+ const [authEditModal, setAuthEditModal] = useState({
+ isOpen: false,
+ user: null as any | null,
+ });
+
+ // 데이터 로드
+ const loadUsers = useCallback(
+ async (page: number = 1) => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const response = await userAPI.getList({
+ page,
+ size: paginationInfo.pageSize,
+ });
+
+ if (response.success && response.data) {
+ setUsers(response.data);
+ setPaginationInfo({
+ currentPage: response.currentPage || page,
+ pageSize: response.pageSize || paginationInfo.pageSize,
+ totalItems: response.total || 0,
+ totalPages: Math.ceil((response.total || 0) / (response.pageSize || paginationInfo.pageSize)),
+ });
+ } else {
+ setError(response.message || "사용자 목록을 불러오는데 실패했습니다.");
+ }
+ } catch (err) {
+ console.error("사용자 목록 로드 오류:", err);
+ setError("사용자 목록을 불러오는 중 오류가 발생했습니다.");
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [paginationInfo.pageSize],
+ );
+
+ useEffect(() => {
+ loadUsers(1);
+ }, []);
+
+ // 권한 변경 핸들러
+ const handleEditAuth = (user: any) => {
+ setAuthEditModal({
+ isOpen: true,
+ user,
+ });
+ };
+
+ // 권한 변경 모달 닫기
+ const handleAuthEditClose = () => {
+ setAuthEditModal({
+ isOpen: false,
+ user: null,
+ });
+ };
+
+ // 권한 변경 성공
+ const handleAuthEditSuccess = () => {
+ loadUsers(paginationInfo.currentPage);
+ handleAuthEditClose();
+ };
+
+ // 페이지 변경
+ const handlePageChange = (page: number) => {
+ loadUsers(page);
+ };
+
+ // 최고 관리자가 아닌 경우
+ if (!isSuperAdmin) {
+ return (
+
+
+
+
사용자 권한 관리
+
사용자별 권한 레벨을 관리합니다. (최고 관리자 전용)
+
+
+
+
+
접근 권한 없음
+
+ 권한 관리는 최고 관리자만 접근할 수 있습니다.
+
+
window.history.back()}>
+ 뒤로 가기
+
+
+
+
+
+
+ );
+ }
+
return (
@@ -20,8 +139,39 @@ export default function UserAuthPage() {
사용자별 권한 레벨을 관리합니다. (최고 관리자 전용)
- {/* 메인 컨텐츠 */}
-
+ {/* 에러 메시지 */}
+ {error && (
+
+
+
오류가 발생했습니다
+
setError(null)}
+ className="text-destructive hover:text-destructive/80 transition-colors"
+ aria-label="에러 메시지 닫기"
+ >
+ ✕
+
+
+
{error}
+
+ )}
+
+ {/* 사용자 권한 테이블 */}
+
+
+ {/* 권한 변경 모달 */}
+
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
diff --git a/frontend/app/(main)/admin/userMng/userMngList/page.tsx b/frontend/app/(main)/admin/userMng/userMngList/page.tsx
index 428e8986..828390d9 100644
--- a/frontend/app/(main)/admin/userMng/userMngList/page.tsx
+++ b/frontend/app/(main)/admin/userMng/userMngList/page.tsx
@@ -1,6 +1,12 @@
"use client";
-import { UserManagement } from "@/components/admin/UserManagement";
+import { useState } from "react";
+import { useUserManagement } from "@/hooks/useUserManagement";
+import { UserToolbar } from "@/components/admin/UserToolbar";
+import { UserTable } from "@/components/admin/UserTable";
+import { Pagination } from "@/components/common/Pagination";
+import { UserPasswordResetModal } from "@/components/admin/UserPasswordResetModal";
+import { UserFormModal } from "@/components/admin/UserFormModal";
import { ScrollToTop } from "@/components/common/ScrollToTop";
/**
@@ -8,8 +14,100 @@ import { ScrollToTop } from "@/components/common/ScrollToTop";
* URL: /admin/userMng
*
* shadcn/ui 스타일 가이드 적용
+ * - 원본 Spring + JSP 코드 패턴 기반 REST API 연동
+ * - 실제 데이터베이스와 연동되어 작동
*/
export default function UserMngPage() {
+ const {
+ // 데이터
+ users,
+ searchFilter,
+ isLoading,
+ isSearching,
+ error,
+ paginationInfo,
+
+ // 검색 기능
+ updateSearchFilter,
+
+ // 페이지네이션
+ handlePageChange,
+ handlePageSizeChange,
+
+ // 액션 핸들러
+ handleStatusToggle,
+
+ // 유틸리티
+ clearError,
+ refreshData,
+ } = useUserManagement();
+
+ // 비밀번호 초기화 모달 상태
+ const [passwordResetModal, setPasswordResetModal] = useState({
+ isOpen: false,
+ userId: null as string | null,
+ userName: null as string | null,
+ });
+
+ // 사용자 등록/수정 모달 상태
+ const [userFormModal, setUserFormModal] = useState({
+ isOpen: false,
+ editingUser: null as any | null,
+ });
+
+ // 사용자 등록 핸들러
+ const handleCreateUser = () => {
+ setUserFormModal({
+ isOpen: true,
+ editingUser: null,
+ });
+ };
+
+ // 사용자 수정 핸들러
+ const handleEditUser = (user: any) => {
+ setUserFormModal({
+ isOpen: true,
+ editingUser: user,
+ });
+ };
+
+ // 사용자 등록/수정 모달 닫기
+ const handleUserFormClose = () => {
+ setUserFormModal({
+ isOpen: false,
+ editingUser: null,
+ });
+ };
+
+ // 사용자 등록/수정 성공 핸들러
+ const handleUserFormSuccess = () => {
+ refreshData();
+ handleUserFormClose();
+ };
+
+ // 비밀번호 초기화 핸들러
+ const handlePasswordReset = (userId: string, userName: string) => {
+ setPasswordResetModal({
+ isOpen: true,
+ userId,
+ userName,
+ });
+ };
+
+ // 비밀번호 초기화 모달 닫기
+ const handlePasswordResetClose = () => {
+ setPasswordResetModal({
+ isOpen: false,
+ userId: null,
+ userName: null,
+ });
+ };
+
+ // 비밀번호 초기화 성공 핸들러
+ const handlePasswordResetSuccess = () => {
+ handlePasswordResetClose();
+ };
+
return (
@@ -19,8 +117,70 @@ export default function UserMngPage() {
시스템 사용자 계정 및 권한을 관리합니다
- {/* 메인 컨텐츠 */}
-
+ {/* 툴바 - 검색, 필터, 등록 버튼 */}
+
+
+ {/* 에러 메시지 */}
+ {error && (
+
+ )}
+
+ {/* 사용자 목록 테이블 */}
+
+
+ {/* 페이지네이션 */}
+ {!isLoading && users.length > 0 && (
+
+ )}
+
+ {/* 사용자 등록/수정 모달 */}
+
+
+ {/* 비밀번호 초기화 모달 */}
+
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
diff --git a/frontend/app/(main)/main/page.tsx b/frontend/app/(main)/main/page.tsx
index 00ef509b..56558f7e 100644
--- a/frontend/app/(main)/main/page.tsx
+++ b/frontend/app/(main)/main/page.tsx
@@ -10,7 +10,6 @@ import { Badge } from "@/components/ui/badge";
export default function MainPage() {
return (
- {/* 메인 컨텐츠 */}
{/* Welcome Message */}
diff --git a/frontend/components/admin/CompanyManagement.tsx b/frontend/components/admin/CompanyManagement.tsx
deleted file mode 100644
index 4e88e35a..00000000
--- a/frontend/components/admin/CompanyManagement.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-"use client";
-
-import { useCompanyManagement } from "@/hooks/useCompanyManagement";
-import { CompanyToolbar } from "./CompanyToolbar";
-import { CompanyTable } from "./CompanyTable";
-import { CompanyFormModal } from "./CompanyFormModal";
-import { CompanyDeleteDialog } from "./CompanyDeleteDialog";
-import { DiskUsageSummary } from "./DiskUsageSummary";
-
-/**
- * 회사 관리 메인 컴포넌트
- * 모든 회사 관리 기능을 통합하여 제공
- */
-export function CompanyManagement() {
- const {
- // 데이터
- companies,
- searchFilter,
- isLoading,
- error,
-
- // 디스크 사용량 관련
- diskUsageInfo,
- isDiskUsageLoading,
- loadDiskUsage,
-
- // 모달 상태
- modalState,
- deleteState,
-
- // 검색 기능
- updateSearchFilter,
- clearSearchFilter,
-
- // 모달 제어
- openCreateModal,
- openEditModal,
- closeModal,
- updateFormData,
-
- // 삭제 다이얼로그 제어
- openDeleteDialog,
- closeDeleteDialog,
-
- // CRUD 작업
- saveCompany,
- deleteCompany,
-
- // 에러 처리
- clearError,
- } = useCompanyManagement();
-
- return (
-
- {/* 디스크 사용량 요약 */}
-
-
- {/* 툴바 - 검색, 필터, 등록 버튼 */}
-
-
- {/* 회사 목록 테이블 */}
-
-
- {/* 회사 등록/수정 모달 */}
-
-
- {/* 회사 삭제 확인 다이얼로그 */}
-
-
- );
-}
diff --git a/frontend/components/admin/CompanySwitcher.tsx b/frontend/components/admin/CompanySwitcher.tsx
new file mode 100644
index 00000000..3d53accc
--- /dev/null
+++ b/frontend/components/admin/CompanySwitcher.tsx
@@ -0,0 +1,195 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Building2, Search } from "lucide-react";
+import { useAuth } from "@/hooks/useAuth";
+import { apiClient } from "@/lib/api/client";
+import { logger } from "@/lib/utils/logger";
+
+interface Company {
+ company_code: string;
+ company_name: string;
+ status: string;
+}
+
+interface CompanySwitcherProps {
+ onClose?: () => void;
+ isOpen?: boolean; // Dialog 열림 상태 (AppLayout에서 전달)
+}
+
+/**
+ * WACE 관리자 전용: 회사 선택 및 전환 컴포넌트
+ *
+ * - WACE 관리자(company_code = "*", userType = "SUPER_ADMIN")만 표시
+ * - 회사 선택 시 해당 회사로 전환하여 시스템 사용
+ * - JWT 토큰 재발급으로 company_code 변경
+ */
+export function CompanySwitcher({ onClose, isOpen = false }: CompanySwitcherProps = {}) {
+ const { user, switchCompany } = useAuth();
+ const [companies, setCompanies] = useState([]);
+ const [filteredCompanies, setFilteredCompanies] = useState([]);
+ const [searchText, setSearchText] = useState("");
+ const [loading, setLoading] = useState(false);
+
+ // WACE 관리자 권한 체크 (userType만 확인)
+ const isWaceAdmin = user?.userType === "SUPER_ADMIN";
+
+ // 현재 선택된 회사명 표시
+ const currentCompanyName = React.useMemo(() => {
+ if (!user?.companyCode) return "로딩 중...";
+
+ if (user.companyCode === "*") {
+ return "WACE (최고 관리자)";
+ }
+
+ // companies 배열에서 현재 회사 찾기
+ const currentCompany = companies.find(c => c.company_code === user.companyCode);
+ return currentCompany?.company_name || user.companyCode;
+ }, [user?.companyCode, companies]);
+
+ // 회사 목록 조회
+ useEffect(() => {
+ if (isWaceAdmin && isOpen) {
+ fetchCompanies();
+ }
+ }, [isWaceAdmin, isOpen]);
+
+ // 검색 필터링
+ useEffect(() => {
+ if (searchText.trim() === "") {
+ setFilteredCompanies(companies);
+ } else {
+ const filtered = companies.filter(company =>
+ company.company_name.toLowerCase().includes(searchText.toLowerCase()) ||
+ company.company_code.toLowerCase().includes(searchText.toLowerCase())
+ );
+ setFilteredCompanies(filtered);
+ }
+ }, [searchText, companies]);
+
+ const fetchCompanies = async () => {
+ try {
+ setLoading(true);
+ const response = await apiClient.get("/admin/companies/db");
+
+ if (response.data.success) {
+ // 활성 상태의 회사만 필터링 + company_code="*" 제외 (WACE는 별도 추가)
+ const activeCompanies = response.data.data
+ .filter((c: Company) => c.company_code !== "*") // DB의 "*" 제외
+ .filter((c: Company) => c.status === "active" || !c.status)
+ .sort((a: Company, b: Company) => a.company_name.localeCompare(b.company_name));
+
+ // WACE 복귀 옵션 추가
+ const companiesWithWace: Company[] = [
+ {
+ company_code: "*",
+ company_name: "WACE (최고 관리자)",
+ status: "active",
+ },
+ ...activeCompanies,
+ ];
+
+ setCompanies(companiesWithWace);
+ setFilteredCompanies(companiesWithWace);
+ }
+ } catch (error) {
+ logger.error("회사 목록 조회 실패", error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleCompanySwitch = async (companyCode: string) => {
+ try {
+ setLoading(true);
+
+ const result = await switchCompany(companyCode);
+
+ if (!result.success) {
+ alert(result.message || "회사 전환에 실패했습니다.");
+ setLoading(false);
+ return;
+ }
+
+ logger.info("회사 전환 성공", { companyCode });
+
+ // 즉시 페이지 새로고침 (토큰이 이미 저장됨)
+ window.location.reload();
+ } catch (error: any) {
+ logger.error("회사 전환 실패", error);
+ alert(error.message || "회사 전환 중 오류가 발생했습니다.");
+ setLoading(false);
+ }
+ };
+
+ // WACE 관리자가 아니면 렌더링하지 않음
+ if (!isWaceAdmin) {
+ return null;
+ }
+
+ return (
+
+ {/* 현재 회사 정보 */}
+
+
+
+
+
+
+
현재 관리 회사
+
{currentCompanyName}
+
+
+
+
+ {/* 회사 검색 */}
+
+
+ setSearchText(e.target.value)}
+ className="h-10 pl-10 text-sm"
+ />
+
+
+ {/* 회사 목록 */}
+
+ {loading ? (
+
+ 로딩 중...
+
+ ) : filteredCompanies.length === 0 ? (
+
+ 검색 결과가 없습니다.
+
+ ) : (
+ filteredCompanies.map((company) => (
+
handleCompanySwitch(company.company_code)}
+ >
+
+ {company.company_name}
+
+ {company.company_code}
+
+
+ {company.company_code === user?.companyCode && (
+
현재
+ )}
+
+ ))
+ )}
+
+
+ );
+}
+
diff --git a/frontend/components/admin/MenuManagement.tsx b/frontend/components/admin/MenuManagement.tsx
deleted file mode 100644
index 67e8bab6..00000000
--- a/frontend/components/admin/MenuManagement.tsx
+++ /dev/null
@@ -1,1136 +0,0 @@
-"use client";
-
-import React, { useState, useEffect, useMemo } from "react";
-import { menuApi } from "@/lib/api/menu";
-import type { MenuItem } from "@/lib/api/menu";
-import { MenuTable } from "./MenuTable";
-import { MenuFormModal } from "./MenuFormModal";
-import { MenuCopyDialog } from "./MenuCopyDialog";
-import { Button } from "@/components/ui/button";
-import { LoadingSpinner, LoadingOverlay } from "@/components/common/LoadingSpinner";
-import { toast } from "sonner";
-import { Badge } from "@/components/ui/badge";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from "@/components/ui/alert-dialog";
-import { useMenu } from "@/contexts/MenuContext";
-import { useMenuManagementText, setTranslationCache, getMenuTextSync } from "@/lib/utils/multilang";
-import { useMultiLang } from "@/hooks/useMultiLang";
-import { apiClient } from "@/lib/api/client";
-import { useAuth } from "@/hooks/useAuth"; // useAuth 추가
-
-type MenuType = "admin" | "user";
-
-export const MenuManagement: React.FC = () => {
- const { adminMenus, userMenus, refreshMenus } = useMenu();
- const { user } = useAuth(); // 현재 사용자 정보 가져오기
- const [selectedMenuType, setSelectedMenuType] = useState("admin");
- const [loading, setLoading] = useState(false);
- const [deleting, setDeleting] = useState(false);
- const [formModalOpen, setFormModalOpen] = useState(false);
- const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
- const [copyDialogOpen, setCopyDialogOpen] = useState(false);
- const [selectedMenuId, setSelectedMenuId] = useState("");
- const [selectedMenuName, setSelectedMenuName] = useState("");
- const [selectedMenus, setSelectedMenus] = useState>(new Set());
-
- // 메뉴 관리 화면용 로컬 상태 (모든 상태의 메뉴 표시)
- const [localAdminMenus, setLocalAdminMenus] = useState([]);
- const [localUserMenus, setLocalUserMenus] = useState([]);
-
- // 다국어 텍스트 훅 사용
- // getMenuText는 더 이상 사용하지 않음 - getUITextSync만 사용
- const { userLang } = useMultiLang({ companyCode: "*" });
-
- // SUPER_ADMIN 여부 확인
- const isSuperAdmin = user?.userType === "SUPER_ADMIN";
-
- // 다국어 텍스트 상태
- const [uiTexts, setUiTexts] = useState>({});
- const [uiTextsLoading, setUiTextsLoading] = useState(false);
-
- // 회사 목록 상태
- const [companies, setCompanies] = useState>([]);
- const [selectedCompany, setSelectedCompany] = useState("all");
- const [searchText, setSearchText] = useState("");
- const [expandedMenus, setExpandedMenus] = useState>(new Set());
- const [companySearchText, setCompanySearchText] = useState("");
- const [isCompanyDropdownOpen, setIsCompanyDropdownOpen] = useState(false);
- const [formData, setFormData] = useState({
- menuId: "",
- parentId: "",
- menuType: "",
- level: 0,
- parentCompanyCode: "",
- });
-
- // 언어별 텍스트 매핑 테이블 제거 - DB에서 직접 가져옴
-
- // 메뉴관리 페이지에서 사용할 다국어 키들 (실제 DB에 등록된 키들)
- const MENU_MANAGEMENT_LANG_KEYS = [
- // 페이지 제목 및 설명
- "menu.management.title",
- "menu.management.description",
- "menu.type.title",
- "menu.type.admin",
- "menu.type.user",
- "menu.management.admin",
- "menu.management.user",
- "menu.management.admin.description",
- "menu.management.user.description",
-
- // 버튼
- "button.add",
- "button.add.top.level",
- "button.add.sub",
- "button.edit",
- "button.delete",
- "button.delete.selected",
- "button.delete.selected.count",
- "button.delete.processing",
- "button.cancel",
- "button.save",
- "button.register",
- "button.modify",
-
- // 필터 및 검색
- "filter.company",
- "filter.company.all",
- "filter.company.common",
- "filter.company.search",
- "filter.search",
- "filter.search.placeholder",
- "filter.reset",
-
- // 테이블 헤더
- "table.header.select",
- "table.header.menu.name",
- "table.header.menu.url",
- "table.header.menu.type",
- "table.header.status",
- "table.header.company",
- "table.header.sequence",
- "table.header.actions",
-
- // 상태
- "status.active",
- "status.inactive",
- "status.unspecified",
-
- // 폼
- "form.menu.type",
- "form.menu.type.admin",
- "form.menu.type.user",
- "form.company",
- "form.company.select",
- "form.company.common",
- "form.company.submenu.note",
- "form.lang.key",
- "form.lang.key.select",
- "form.lang.key.none",
- "form.lang.key.search",
- "form.lang.key.selected",
- "form.menu.name",
- "form.menu.name.placeholder",
- "form.menu.url",
- "form.menu.url.placeholder",
- "form.menu.description",
- "form.menu.description.placeholder",
- "form.menu.sequence",
-
- // 모달
- "modal.menu.register.title",
- "modal.menu.modify.title",
- "modal.delete.title",
- "modal.delete.description",
- "modal.delete.batch.description",
-
- // 메시지
- "message.loading",
- "message.menu.delete.processing",
- "message.menu.save.success",
- "message.menu.save.failed",
- "message.menu.delete.success",
- "message.menu.delete.failed",
- "message.menu.delete.batch.success",
- "message.menu.delete.batch.partial",
- "message.menu.status.toggle.success",
- "message.menu.status.toggle.failed",
- "message.validation.menu.name.required",
- "message.validation.company.required",
- "message.validation.select.menu.delete",
- "message.error.load.menu.list",
- "message.error.load.menu.info",
- "message.error.load.company.list",
- "message.error.load.lang.key.list",
-
- // 리스트 정보
- "menu.list.title",
- "menu.list.total",
- "menu.list.search.result",
-
- // UI
- "ui.expand",
- "ui.collapse",
- "ui.menu.collapse",
- "ui.language",
- ];
-
- // 초기 로딩
- useEffect(() => {
- loadCompanies();
- loadMenus(false); // 메뉴 목록 로드 (메뉴 관리 화면용 - 모든 상태 표시)
- // 사용자 언어가 설정되지 않았을 때만 기본 텍스트 설정
- if (!userLang) {
- initializeDefaultTexts();
- }
- }, [userLang]); // userLang 변경 시마다 실행
-
- // 초기 기본 텍스트 설정 함수
- const initializeDefaultTexts = () => {
- const defaultTexts: Record = {};
- MENU_MANAGEMENT_LANG_KEYS.forEach((key) => {
- // 기본 한국어 텍스트 제공
- const defaultText = getDefaultText(key);
- defaultTexts[key] = defaultText;
- });
- setUiTexts(defaultTexts);
- // console.log("🌐 초기 기본 텍스트 설정 완료:", Object.keys(defaultTexts).length);
- };
-
- // 기본 텍스트 반환 함수
- const getDefaultText = (key: string): string => {
- const defaultTexts: Record = {
- "menu.management.title": "메뉴 관리",
- "menu.management.description": "시스템의 메뉴 구조와 권한을 관리합니다.",
- "menu.type.title": "메뉴 타입",
- "menu.type.admin": "관리자",
- "menu.type.user": "사용자",
- "menu.management.admin": "관리자 메뉴",
- "menu.management.user": "사용자 메뉴",
- "menu.management.admin.description": "시스템 관리 및 설정 메뉴",
- "menu.management.user.description": "일반 사용자 업무 메뉴",
- "button.add": "추가",
- "button.add.top.level": "최상위 메뉴 추가",
- "button.add.sub": "하위 메뉴 추가",
- "button.edit": "수정",
- "button.delete": "삭제",
- "button.delete.selected": "선택 삭제",
- "button.delete.selected.count": "선택 삭제 ({count})",
- "button.delete.processing": "삭제 중...",
- "button.cancel": "취소",
- "button.save": "저장",
- "button.register": "등록",
- "button.modify": "수정",
- "filter.company": "회사",
- "filter.company.all": "전체",
- "filter.company.common": "공통",
- "filter.company.search": "회사 검색",
- "filter.search": "검색",
- "filter.search.placeholder": "메뉴명 또는 URL로 검색...",
- "filter.reset": "초기화",
- "table.header.select": "선택",
- "table.header.menu.name": "메뉴명",
- "table.header.menu.url": "URL",
- "table.header.menu.type": "메뉴 타입",
- "table.header.status": "상태",
- "table.header.company": "회사",
- "table.header.sequence": "순서",
- "table.header.actions": "작업",
- "status.active": "활성화",
- "status.inactive": "비활성화",
- "status.unspecified": "미지정",
- "form.menu.type": "메뉴 타입",
- "form.menu.type.admin": "관리자",
- "form.menu.type.user": "사용자",
- "form.company": "회사",
- "form.company.select": "회사를 선택하세요",
- "form.company.common": "공통",
- "form.company.submenu.note": "하위 메뉴는 상위 메뉴와 동일한 회사를 가져야 합니다.",
- "form.lang.key": "다국어 키",
- "form.lang.key.select": "다국어 키를 선택하세요",
- "form.lang.key.none": "다국어 키 없음",
- "form.lang.key.search": "다국어 키 검색...",
- "form.lang.key.selected": "선택된 키: {key} - {description}",
- "form.menu.name": "메뉴명",
- "form.menu.name.placeholder": "메뉴명을 입력하세요",
- "form.menu.url": "URL",
- "form.menu.url.placeholder": "메뉴 URL을 입력하세요",
- "form.menu.description": "설명",
- "form.menu.description.placeholder": "메뉴 설명을 입력하세요",
- "form.menu.sequence": "순서",
- "modal.menu.register.title": "메뉴 등록",
- "modal.menu.modify.title": "메뉴 수정",
- "modal.delete.title": "메뉴 삭제",
- "modal.delete.description": "해당 메뉴를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
- "modal.delete.batch.description":
- "선택된 {count}개의 메뉴를 영구적으로 삭제하시겠습니까?\n\n⚠️ 주의: 상위 메뉴를 삭제하면 하위 메뉴들도 함께 삭제됩니다.\n이 작업은 되돌릴 수 없습니다.",
- "message.loading": "로딩 중...",
- "message.menu.delete.processing": "메뉴 삭제 중...",
- "message.menu.save.success": "메뉴가 성공적으로 저장되었습니다.",
- "message.menu.save.failed": "메뉴 저장에 실패했습니다.",
- "message.menu.delete.success": "메뉴가 성공적으로 삭제되었습니다.",
- "message.menu.delete.failed": "메뉴 삭제에 실패했습니다.",
- "message.menu.delete.batch.success": "선택된 메뉴들이 성공적으로 삭제되었습니다.",
- "message.menu.delete.batch.partial": "일부 메뉴 삭제에 실패했습니다.",
- "message.menu.status.toggle.success": "메뉴 상태가 변경되었습니다.",
- "message.menu.status.toggle.failed": "메뉴 상태 변경에 실패했습니다.",
- "message.validation.menu.name.required": "메뉴명을 입력해주세요.",
- "message.validation.company.required": "회사를 선택해주세요.",
- "message.validation.select.menu.delete": "삭제할 메뉴를 선택해주세요.",
- "message.error.load.menu.list": "메뉴 목록을 불러오는데 실패했습니다.",
- "message.error.load.menu.info": "메뉴 정보를 불러오는데 실패했습니다.",
- "message.error.load.company.list": "회사 목록을 불러오는데 실패했습니다.",
- "message.error.load.lang.key.list": "다국어 키 목록을 불러오는데 실패했습니다.",
- "menu.list.title": "메뉴 목록",
- "menu.list.total": "총 {count}개",
- "menu.list.search.result": "검색 결과: {count}개",
- "ui.expand": "펼치기",
- "ui.collapse": "접기",
- "ui.menu.collapse": "메뉴 접기",
- "ui.language": "언어",
- };
-
- return defaultTexts[key] || key;
- };
-
- // 컴포넌트 마운트 시 및 userLang 변경 시 다국어 텍스트 로드
- useEffect(() => {
- if (userLang && !uiTextsLoading) {
- loadUITexts();
- }
- }, [userLang]); // userLang 변경 시마다 실행
-
- // uiTexts 상태 변경 감지
- useEffect(() => {
- // console.log("🔄 uiTexts 상태 변경됨:", {
- // count: Object.keys(uiTexts).length,
- // sampleKeys: Object.keys(uiTexts).slice(0, 5),
- // sampleValues: Object.entries(uiTexts)
- // .slice(0, 3)
- // .map(([k, v]) => `${k}: ${v}`),
- // });
- }, [uiTexts]);
-
- // 컴포넌트 마운트 후 다국어 텍스트 강제 로드 (userLang이 아직 설정되지 않았을 수 있음)
- useEffect(() => {
- const timer = setTimeout(() => {
- if (userLang && !uiTextsLoading) {
- // console.log("🔄 컴포넌트 마운트 후 다국어 텍스트 강제 로드");
- loadUITexts();
- }
- }, 300); // 300ms 후 실행
-
- return () => clearTimeout(timer);
- }, [userLang]); // userLang이 설정된 후 실행
-
- // 추가 안전장치: 컴포넌트 마운트 후 일정 시간이 지나면 강제로 다국어 텍스트 로드
- useEffect(() => {
- const fallbackTimer = setTimeout(() => {
- if (!uiTextsLoading && Object.keys(uiTexts).length === 0) {
- // console.log("🔄 안전장치: 컴포넌트 마운트 후 강제 다국어 텍스트 로드");
- // 사용자 언어가 설정되지 않았을 때만 기본 텍스트 설정
- if (!userLang) {
- initializeDefaultTexts();
- } else {
- // 사용자 언어가 설정된 경우 다국어 텍스트 로드
- loadUITexts();
- }
- }
- }, 1000); // 1초 후 실행
-
- return () => clearTimeout(fallbackTimer);
- }, [userLang]); // userLang 변경 시마다 실행
-
- // 번역 로드 이벤트 감지
- useEffect(() => {
- const handleTranslationLoaded = (event: CustomEvent) => {
- const { key, text, userLang: loadedLang } = event.detail;
- if (loadedLang === userLang) {
- setUiTexts((prev) => ({ ...prev, [key]: text }));
- }
- };
-
- window.addEventListener("translation-loaded", handleTranslationLoaded as EventListener);
-
- return () => {
- window.removeEventListener("translation-loaded", handleTranslationLoaded as EventListener);
- };
- }, [userLang]);
-
- // 드롭다운 외부 클릭 시 닫기
- useEffect(() => {
- const handleClickOutside = (event: MouseEvent) => {
- const target = event.target as Element;
- if (!target.closest(".company-dropdown")) {
- setIsCompanyDropdownOpen(false);
- setCompanySearchText("");
- }
- };
-
- if (isCompanyDropdownOpen) {
- document.addEventListener("mousedown", handleClickOutside);
- }
-
- return () => {
- document.removeEventListener("mousedown", handleClickOutside);
- };
- }, [isCompanyDropdownOpen]);
-
- // 특정 메뉴 타입만 로드하는 함수
- const loadMenusForType = async (type: MenuType, showLoading = true) => {
- try {
- if (showLoading) {
- setLoading(true);
- }
-
- if (type === "admin") {
- const adminResponse = await menuApi.getAdminMenusForManagement();
- if (adminResponse.success && adminResponse.data) {
- setLocalAdminMenus(adminResponse.data);
- }
- } else {
- const userResponse = await menuApi.getUserMenusForManagement();
- if (userResponse.success && userResponse.data) {
- setLocalUserMenus(userResponse.data);
- }
- }
- } catch (error) {
- toast.error(getUITextSync("message.error.load.menu.list"));
- } finally {
- if (showLoading) {
- setLoading(false);
- }
- }
- };
-
- const loadMenus = async (showLoading = true) => {
- // console.log(`📋 메뉴 목록 조회 시작 (showLoading: ${showLoading})`);
- try {
- if (showLoading) {
- setLoading(true);
- }
-
- // 선택된 메뉴 타입에 해당하는 메뉴만 로드
- if (selectedMenuType === "admin") {
- const adminResponse = await menuApi.getAdminMenusForManagement();
- if (adminResponse.success && adminResponse.data) {
- setLocalAdminMenus(adminResponse.data);
- }
- } else {
- const userResponse = await menuApi.getUserMenusForManagement();
- if (userResponse.success && userResponse.data) {
- setLocalUserMenus(userResponse.data);
- }
- }
-
- // 전역 메뉴 상태도 업데이트 (좌측 사이드바용)
- await refreshMenus();
- // console.log("📋 메뉴 목록 조회 성공");
- } catch (error) {
- // console.error("❌ 메뉴 목록 조회 실패:", error);
- toast.error(getUITextSync("message.error.load.menu.list"));
- } finally {
- if (showLoading) {
- setLoading(false);
- }
- }
- };
-
- // 회사 목록 조회
- const loadCompanies = async () => {
- // console.log("🏢 회사 목록 조회 시작");
- try {
- const response = await apiClient.get("/admin/companies");
-
- if (response.data.success) {
- // console.log("🏢 회사 목록 응답:", response.data);
- const companyList = response.data.data.map((company: any) => ({
- code: company.company_code || company.companyCode,
- name: company.company_name || company.companyName,
- }));
- // console.log("🏢 변환된 회사 목록:", companyList);
- setCompanies(companyList);
- }
- } catch (error) {
- // console.error("❌ 회사 목록 조회 실패:", error);
- }
- };
-
- // 다국어 텍스트 로드 함수 - 배치 API 사용
- const loadUITexts = async () => {
- if (uiTextsLoading) return; // 이미 로딩 중이면 중단
-
- // userLang이 설정되지 않았으면 기본값 설정
- if (!userLang) {
- // console.log("🌐 사용자 언어가 설정되지 않음, 기본값 설정");
- const defaultTexts: Record = {};
- MENU_MANAGEMENT_LANG_KEYS.forEach((key) => {
- defaultTexts[key] = getDefaultText(key); // 기본 한국어 텍스트 사용
- });
- setUiTexts(defaultTexts);
- return;
- }
-
- // 사용자 언어가 설정된 경우, 기존 uiTexts가 비어있으면 기본 텍스트로 초기화
- if (Object.keys(uiTexts).length === 0) {
- // console.log("🌐 기존 uiTexts가 비어있음, 기본 텍스트로 초기화");
- const defaultTexts: Record = {};
- MENU_MANAGEMENT_LANG_KEYS.forEach((key) => {
- defaultTexts[key] = getDefaultText(key);
- });
- setUiTexts(defaultTexts);
- }
-
- // console.log("🌐 UI 다국어 텍스트 로드 시작", {
- // userLang,
- // apiParams: {
- // companyCode: "*",
- // menuCode: "menu.management",
- // userLang: userLang,
- // },
- // });
- setUiTextsLoading(true);
-
- try {
- // 배치 API를 사용하여 모든 다국어 키를 한 번에 조회
- const response = await apiClient.post(
- "/multilang/batch",
- {
- langKeys: MENU_MANAGEMENT_LANG_KEYS,
- companyCode: "*", // 모든 회사
- menuCode: "menu.management", // 메뉴관리 메뉴
- userLang: userLang, // body에 포함
- },
- {
- params: {}, // query params는 비움
- },
- );
-
- if (response.data.success) {
- const translations = response.data.data;
- // console.log("🌐 배치 다국어 텍스트 응답:", translations);
-
- // 번역 결과를 상태에 저장 (기존 uiTexts와 병합)
- const mergedTranslations = { ...uiTexts, ...translations };
- // console.log("🔧 setUiTexts 호출 전:", {
- // translationsCount: Object.keys(translations).length,
- // mergedCount: Object.keys(mergedTranslations).length,
- // });
- setUiTexts(mergedTranslations);
- // console.log("🔧 setUiTexts 호출 후 - mergedTranslations:", mergedTranslations);
-
- // 번역 캐시에 저장 (다른 컴포넌트에서도 사용할 수 있도록)
- setTranslationCache(userLang, mergedTranslations);
- } else {
- // console.error("❌ 다국어 텍스트 배치 조회 실패:", response.data.message);
- // API 실패 시에도 기존 uiTexts는 유지
- // console.log("🔄 API 실패로 인해 기존 uiTexts 유지");
- }
- } catch (error) {
- // console.error("❌ UI 다국어 텍스트 로드 실패:", error);
- // API 실패 시에도 기존 uiTexts는 유지
- // console.log("🔄 API 실패로 인해 기존 uiTexts 유지");
- } finally {
- setUiTextsLoading(false);
- }
- };
-
- // UI 텍스트 가져오기 함수 (동기 버전만 사용)
- // getUIText 함수는 제거 - getUITextSync만 사용
-
- // 동기 버전 (DB에서 가져온 번역 텍스트 사용)
- const getUITextSync = (key: string, params?: Record, fallback?: string): string => {
- // uiTexts에서 번역 텍스트 찾기
- let text = uiTexts[key];
-
- // uiTexts에 없으면 getMenuTextSync로 기본 한글 텍스트 가져오기
- if (!text) {
- text = getMenuTextSync(key, userLang) || fallback || key;
- }
-
- // 파라미터 치환
- if (params && text) {
- Object.entries(params).forEach(([paramKey, paramValue]) => {
- text = text!.replace(`{${paramKey}}`, String(paramValue));
- });
- }
-
- return text || key;
- };
-
- // 다국어 API 테스트 함수 (getUITextSync 사용)
- const testMultiLangAPI = async () => {
- // console.log("🧪 다국어 API 테스트 시작");
- try {
- const text = getUITextSync("menu.management.admin");
- // console.log("🧪 다국어 API 테스트 결과:", text);
- } catch (error) {
- // console.error("❌ 다국어 API 테스트 실패:", error);
- }
- };
-
- // 대문자 키를 소문자 키로 변환하는 함수
- const convertMenuData = (data: any[]): MenuItem[] => {
- return data.map((item) => ({
- objid: item.OBJID || item.objid,
- parent_obj_id: item.PARENT_OBJ_ID || item.parent_obj_id,
- menu_name_kor: item.MENU_NAME_KOR || item.menu_name_kor,
- menu_url: item.MENU_URL || item.menu_url,
- menu_desc: item.MENU_DESC || item.menu_desc,
- seq: item.SEQ || item.seq,
- menu_type: item.MENU_TYPE || item.menu_type,
- status: item.STATUS || item.status,
- lev: item.LEV || item.lev,
- lpad_menu_name_kor: item.LPAD_MENU_NAME_KOR || item.lpad_menu_name_kor,
- status_title: item.STATUS_TITLE || item.status_title,
- writer: item.WRITER || item.writer,
- regdate: item.REGDATE || item.regdate,
- company_code: item.COMPANY_CODE || item.company_code,
- company_name: item.COMPANY_NAME || item.company_name,
- }));
- };
-
- const handleAddTopLevelMenu = () => {
- setFormData({
- menuId: "",
- parentId: "0", // 최상위 메뉴는 parentId가 0
- menuType: getMenuTypeValue(),
- level: 1, // 최상위 메뉴는 level 1
- parentCompanyCode: "", // 최상위 메뉴는 상위 회사 정보 없음
- });
- setFormModalOpen(true);
- };
-
- const handleAddMenu = (parentId: string, menuType: string, level: number) => {
- // 상위 메뉴의 회사 정보 찾기
- const currentMenus = selectedMenuType === "admin" ? localAdminMenus : localUserMenus;
- const parentMenu = currentMenus.find((menu) => menu.objid === parentId);
-
- setFormData({
- menuId: "",
- parentId,
- menuType,
- level: level + 1,
- parentCompanyCode: parentMenu?.company_code || "",
- });
- setFormModalOpen(true);
- };
-
- const handleEditMenu = (menuId: string) => {
- // console.log("🔧 메뉴 수정 시작 - menuId:", menuId);
-
- // 현재 메뉴 정보 찾기
- const currentMenus = selectedMenuType === "admin" ? localAdminMenus : localUserMenus;
- const menuToEdit = currentMenus.find((menu) => (menu.objid || menu.OBJID) === menuId);
-
- if (menuToEdit) {
- // console.log("수정할 메뉴 정보:", menuToEdit);
-
- setFormData({
- menuId: menuId,
- parentId: menuToEdit.parent_obj_id || menuToEdit.PARENT_OBJ_ID || "",
- menuType: selectedMenuType, // 현재 선택된 메뉴 타입
- level: 0, // 기본값
- parentCompanyCode: menuToEdit.company_code || menuToEdit.COMPANY_CODE || "",
- });
-
- // console.log("설정된 formData:", {
- // menuId: menuId,
- // parentId: menuToEdit.parent_obj_id || menuToEdit.PARENT_OBJ_ID || "",
- // menuType: selectedMenuType,
- // level: 0,
- // parentCompanyCode: menuToEdit.company_code || menuToEdit.COMPANY_CODE || "",
- // });
- } else {
- // console.error("수정할 메뉴를 찾을 수 없음:", menuId);
- }
-
- setFormModalOpen(true);
- };
-
- const handleMenuSelectionChange = (menuId: string, checked: boolean) => {
- const newSelected = new Set(selectedMenus);
- if (checked) {
- newSelected.add(menuId);
- } else {
- newSelected.delete(menuId);
- }
- setSelectedMenus(newSelected);
- };
-
- const handleSelectAllMenus = (checked: boolean) => {
- const currentMenus = selectedMenuType === "admin" ? localAdminMenus : localUserMenus;
- if (checked) {
- // 모든 메뉴 선택 (최상위 메뉴 포함)
- setSelectedMenus(new Set(currentMenus.map((menu) => menu.objid || menu.OBJID || "")));
- } else {
- setSelectedMenus(new Set());
- }
- };
-
- const handleDeleteSelectedMenus = async () => {
- if (selectedMenus.size === 0) {
- toast.error(getUITextSync("message.validation.select.menu.delete"));
- return;
- }
-
- if (!confirm(getUITextSync("modal.delete.batch.description", { count: selectedMenus.size }))) {
- return;
- }
-
- setDeleting(true);
- try {
- const menuIds = Array.from(selectedMenus);
- // console.log("삭제할 메뉴 IDs:", menuIds);
-
- toast.info(getUITextSync("message.menu.delete.processing"));
-
- const response = await menuApi.deleteMenusBatch(menuIds);
- // console.log("삭제 API 응답:", response);
- // console.log("응답 구조:", {
- // success: response.success,
- // data: response.data,
- // message: response.message,
- // });
-
- if (response.success && response.data) {
- const { deletedCount, failedCount } = response.data;
- // console.log("삭제 결과:", { deletedCount, failedCount });
-
- // 선택된 메뉴 초기화
- setSelectedMenus(new Set());
-
- // 메뉴 목록 즉시 새로고침 (로딩 상태 없이)
- // console.log("메뉴 목록 새로고침 시작");
- await loadMenus(false);
- // 전역 메뉴 상태도 업데이트
- await refreshMenus();
- // console.log("메뉴 목록 새로고침 완료");
-
- // 삭제 결과 메시지
- if (failedCount === 0) {
- toast.success(getUITextSync("message.menu.delete.batch.success", { count: deletedCount }));
- } else {
- toast.success(
- getUITextSync("message.menu.delete.batch.partial", {
- success: deletedCount,
- failed: failedCount,
- }),
- );
- }
- } else {
- // console.error("삭제 실패:", response);
- toast.error(response.message || "메뉴 삭제에 실패했습니다.");
- }
- } catch (error) {
- // console.error("메뉴 삭제 중 오류:", error);
- toast.error(getUITextSync("message.menu.delete.failed"));
- } finally {
- setDeleting(false);
- }
- };
-
- const confirmDelete = async () => {
- try {
- const response = await menuApi.deleteMenu(selectedMenuId);
- if (response.success) {
- toast.success(response.message);
- await loadMenus(false);
- } else {
- toast.error(response.message);
- }
- } catch (error) {
- toast.error("메뉴 삭제에 실패했습니다.");
- } finally {
- setDeleteDialogOpen(false);
- setSelectedMenuId("");
- }
- };
-
- const handleCopyMenu = (menuId: string, menuName: string) => {
- setSelectedMenuId(menuId);
- setSelectedMenuName(menuName);
- setCopyDialogOpen(true);
- };
-
- const handleCopyComplete = async () => {
- // 복사 완료 후 메뉴 목록 새로고침
- await loadMenus(false);
- toast.success("메뉴 복사가 완료되었습니다");
- };
-
- const handleToggleStatus = async (menuId: string) => {
- try {
- const response = await menuApi.toggleMenuStatus(menuId);
- if (response.success) {
- toast.success(response.message);
- await loadMenus(false); // 메뉴 목록 새로고침
- // 전역 메뉴 상태도 업데이트
- await refreshMenus();
- } else {
- toast.error(response.message);
- }
- } catch (error) {
- // console.error("메뉴 상태 토글 오류:", error);
- toast.error(getUITextSync("message.menu.status.toggle.failed"));
- }
- };
-
- const handleFormSuccess = () => {
- loadMenus(false);
- // 전역 메뉴 상태도 업데이트
- refreshMenus();
- };
-
- const getCurrentMenus = () => {
- // 메뉴 관리 화면용: 모든 상태의 메뉴 표시 (localAdminMenus/localUserMenus 사용)
- const currentMenus = selectedMenuType === "admin" ? localAdminMenus : localUserMenus;
-
- // 검색어 필터링
- let filteredMenus = currentMenus;
- if (searchText.trim()) {
- const searchLower = searchText.toLowerCase();
- filteredMenus = currentMenus.filter((menu) => {
- const menuName = (menu.menu_name_kor || menu.MENU_NAME_KOR || "").toLowerCase();
- const menuUrl = (menu.menu_url || menu.MENU_URL || "").toLowerCase();
- return menuName.includes(searchLower) || menuUrl.includes(searchLower);
- });
- }
-
- // 회사 필터링
- if (selectedCompany !== "all") {
- filteredMenus = filteredMenus.filter((menu) => {
- const menuCompanyCode = menu.company_code || menu.COMPANY_CODE || "";
- return menuCompanyCode === selectedCompany;
- });
- }
-
- return filteredMenus;
- };
-
- // 메뉴 타입 변경 시 선택된 메뉴 초기화
- const handleMenuTypeChange = (type: MenuType) => {
- setSelectedMenuType(type);
- setSelectedMenus(new Set()); // 선택된 메뉴 초기화
- setExpandedMenus(new Set()); // 메뉴 타입 변경 시 확장 상태 초기화
-
- // 선택한 메뉴 타입에 해당하는 메뉴만 로드
- if (type === "admin" && localAdminMenus.length === 0) {
- loadMenusForType("admin", false);
- } else if (type === "user" && localUserMenus.length === 0) {
- loadMenusForType("user", false);
- }
- };
-
- const handleToggleExpand = (menuId: string) => {
- const newExpandedMenus = new Set(expandedMenus);
- if (newExpandedMenus.has(menuId)) {
- newExpandedMenus.delete(menuId);
- } else {
- newExpandedMenus.add(menuId);
- }
- setExpandedMenus(newExpandedMenus);
- };
-
- const getMenuTypeString = () => {
- return selectedMenuType === "admin" ? getUITextSync("menu.type.admin") : getUITextSync("menu.type.user");
- };
-
- const getMenuTypeValue = () => {
- return selectedMenuType === "admin" ? "0" : "1";
- };
-
- // uiTextsCount를 useMemo로 계산하여 상태 변경 시에만 재계산
- const uiTextsCount = useMemo(() => Object.keys(uiTexts).length, [uiTexts]);
- const adminMenusCount = useMemo(() => localAdminMenus?.length || 0, [localAdminMenus]);
- const userMenusCount = useMemo(() => localUserMenus?.length || 0, [localUserMenus]);
-
- // 디버깅을 위한 간단한 상태 표시
- // console.log("🔍 MenuManagement 렌더링 상태:", {
- // loading,
- // uiTextsLoading,
- // uiTextsCount,
- // adminMenusCount,
- // userMenusCount,
- // selectedMenuType,
- // userLang,
- // });
-
- if (loading) {
- return (
-
-
-
- );
- }
-
- return (
-
-
- {/* 좌측 사이드바 - 메뉴 타입 선택 (20%) */}
-
-
-
{getUITextSync("menu.type.title")}
-
- {/* 메뉴 타입 선택 카드들 */}
-
-
handleMenuTypeChange("admin")}
- >
-
-
-
{getUITextSync("menu.management.admin")}
-
- {getUITextSync("menu.management.admin.description")}
-
-
-
- {localAdminMenus.length}
-
-
-
-
-
handleMenuTypeChange("user")}
- >
-
-
-
{getUITextSync("menu.management.user")}
-
- {getUITextSync("menu.management.user.description")}
-
-
-
- {localUserMenus.length}
-
-
-
-
-
-
-
- {/* 우측 메인 영역 - 메뉴 목록 (80%) */}
-
-
- {/* 상단 헤더: 제목 + 검색 + 버튼 */}
-
- {/* 왼쪽: 제목 */}
-
- {getMenuTypeString()} {getUITextSync("menu.list.title")}
-
-
- {/* 오른쪽: 검색 + 버튼 */}
-
- {/* 회사 선택 */}
-
-
-
setIsCompanyDropdownOpen(!isCompanyDropdownOpen)}
- className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
- >
-
- {selectedCompany === "all"
- ? getUITextSync("filter.company.all")
- : selectedCompany === "*"
- ? getUITextSync("filter.company.common")
- : companies.find((c) => c.code === selectedCompany)?.name ||
- getUITextSync("filter.company.all")}
-
-
-
-
-
-
- {isCompanyDropdownOpen && (
-
-
- setCompanySearchText(e.target.value)}
- className="h-8 text-sm"
- onClick={(e) => e.stopPropagation()}
- />
-
-
-
-
{
- setSelectedCompany("all");
- setIsCompanyDropdownOpen(false);
- setCompanySearchText("");
- }}
- >
- {getUITextSync("filter.company.all")}
-
-
{
- setSelectedCompany("*");
- setIsCompanyDropdownOpen(false);
- setCompanySearchText("");
- }}
- >
- {getUITextSync("filter.company.common")}
-
-
- {companies
- .filter((company) => company.code && company.code.trim() !== "")
- .filter(
- (company) =>
- company.name.toLowerCase().includes(companySearchText.toLowerCase()) ||
- company.code.toLowerCase().includes(companySearchText.toLowerCase()),
- )
- .map((company, index) => (
-
{
- setSelectedCompany(company.code);
- setIsCompanyDropdownOpen(false);
- setCompanySearchText("");
- }}
- >
- {company.code === "*" ? getUITextSync("filter.company.common") : company.name}
-
- ))}
-
-
- )}
-
-
-
- {/* 검색 입력 */}
-
- setSearchText(e.target.value)}
- className="h-10 text-sm"
- />
-
-
- {/* 초기화 버튼 */}
-
{
- setSearchText("");
- setSelectedCompany("all");
- setCompanySearchText("");
- }}
- variant="outline"
- className="h-10 text-sm font-medium"
- >
- {getUITextSync("filter.reset")}
-
-
- {/* 최상위 메뉴 추가 */}
-
handleAddTopLevelMenu()} className="h-10 gap-2 text-sm font-medium">
- {getUITextSync("button.add.top.level")}
-
-
- {/* 선택 삭제 */}
- {selectedMenus.size > 0 && (
-
- {deleting ? (
- <>
-
- {getUITextSync("button.delete.processing")}
- >
- ) : (
- getUITextSync("button.delete.selected.count", {
- count: selectedMenus.size,
- })
- )}
-
- )}
-
-
-
- {/* 테이블 영역 */}
-
-
-
-
-
-
-
- setFormModalOpen(false)}
- onSuccess={handleFormSuccess}
- menuId={formData.menuId}
- parentId={formData.parentId}
- menuType={formData.menuType}
- level={formData.level}
- parentCompanyCode={formData.parentCompanyCode}
- uiTexts={uiTexts}
- />
-
-
-
-
- 메뉴 삭제
-
- 해당 메뉴를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
-
-
-
- 취소
- 삭제
-
-
-
-
-
-
- );
-};
diff --git a/frontend/components/admin/MonitoringDashboard.tsx b/frontend/components/admin/MonitoringDashboard.tsx
deleted file mode 100644
index 500dd4fb..00000000
--- a/frontend/components/admin/MonitoringDashboard.tsx
+++ /dev/null
@@ -1,288 +0,0 @@
-"use client";
-
-import React, { useState, useEffect } from "react";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { Button } from "@/components/ui/button";
-import { Badge } from "@/components/ui/badge";
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table";
-import { Progress } from "@/components/ui/progress";
-import { RefreshCw, Play, Pause, AlertCircle, CheckCircle, Clock } from "lucide-react";
-import { toast } from "sonner";
-import { BatchAPI, BatchMonitoring, BatchExecution } from "@/lib/api/batch";
-
-export default function MonitoringDashboard() {
- const [monitoring, setMonitoring] = useState(null);
- const [isLoading, setIsLoading] = useState(false);
- const [autoRefresh, setAutoRefresh] = useState(false);
-
- useEffect(() => {
- loadMonitoringData();
-
- let interval: NodeJS.Timeout;
- if (autoRefresh) {
- interval = setInterval(loadMonitoringData, 30000); // 30초마다 자동 새로고침
- }
-
- return () => {
- if (interval) clearInterval(interval);
- };
- }, [autoRefresh]);
-
- const loadMonitoringData = async () => {
- setIsLoading(true);
- try {
- const data = await BatchAPI.getBatchMonitoring();
- setMonitoring(data);
- } catch (error) {
- console.error("모니터링 데이터 조회 오류:", error);
- toast.error("모니터링 데이터를 불러오는데 실패했습니다.");
- } finally {
- setIsLoading(false);
- }
- };
-
- const handleRefresh = () => {
- loadMonitoringData();
- };
-
- const toggleAutoRefresh = () => {
- setAutoRefresh(!autoRefresh);
- };
-
- const getStatusIcon = (status: string) => {
- switch (status) {
- case 'completed':
- return ;
- case 'failed':
- return ;
- case 'running':
- return ;
- case 'pending':
- return ;
- default:
- return ;
- }
- };
-
- const getStatusBadge = (status: string) => {
- const variants = {
- completed: "bg-green-100 text-green-800",
- failed: "bg-destructive/20 text-red-800",
- running: "bg-primary/20 text-blue-800",
- pending: "bg-yellow-100 text-yellow-800",
- cancelled: "bg-gray-100 text-gray-800",
- };
-
- const labels = {
- completed: "완료",
- failed: "실패",
- running: "실행 중",
- pending: "대기 중",
- cancelled: "취소됨",
- };
-
- return (
-
- {labels[status as keyof typeof labels] || status}
-
- );
- };
-
- const formatDuration = (ms: number) => {
- if (ms < 1000) return `${ms}ms`;
- if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
- return `${(ms / 60000).toFixed(1)}m`;
- };
-
- const getSuccessRate = () => {
- if (!monitoring) return 0;
- const total = monitoring.successful_jobs_today + monitoring.failed_jobs_today;
- if (total === 0) return 100;
- return Math.round((monitoring.successful_jobs_today / total) * 100);
- };
-
- if (!monitoring) {
- return (
-
-
-
-
모니터링 데이터를 불러오는 중...
-
-
- );
- }
-
- return (
-
- {/* 헤더 */}
-
-
배치 모니터링
-
-
- {autoRefresh ? : }
- 자동 새로고침
-
-
-
- 새로고침
-
-
-
-
- {/* 통계 카드 */}
-
-
-
- 총 작업 수
- 📋
-
-
- {monitoring.total_jobs}
-
- 활성: {monitoring.active_jobs}개
-
-
-
-
-
-
- 실행 중
- 🔄
-
-
- {monitoring.running_jobs}
-
- 현재 실행 중인 작업
-
-
-
-
-
-
- 오늘 성공
- ✅
-
-
- {monitoring.successful_jobs_today}
-
- 성공률: {getSuccessRate()}%
-
-
-
-
-
-
- 오늘 실패
- ❌
-
-
- {monitoring.failed_jobs_today}
-
- 주의가 필요한 작업
-
-
-
-
-
- {/* 성공률 진행바 */}
-
-
- 오늘 실행 성공률
-
-
-
-
- 성공: {monitoring.successful_jobs_today}건
- 실패: {monitoring.failed_jobs_today}건
-
-
-
- {getSuccessRate()}% 성공률
-
-
-
-
-
- {/* 최근 실행 이력 */}
-
-
- 최근 실행 이력
-
-
- {monitoring.recent_executions.length === 0 ? (
-
- 최근 실행 이력이 없습니다.
-
- ) : (
-
-
-
- 상태
- 작업 ID
- 시작 시간
- 완료 시간
- 실행 시간
- 오류 메시지
-
-
-
- {monitoring.recent_executions.map((execution) => (
-
-
-
- {getStatusIcon(execution.execution_status)}
- {getStatusBadge(execution.execution_status)}
-
-
- #{execution.job_id}
-
- {execution.started_at
- ? new Date(execution.started_at).toLocaleString()
- : "-"}
-
-
- {execution.completed_at
- ? new Date(execution.completed_at).toLocaleString()
- : "-"}
-
-
- {execution.execution_time_ms
- ? formatDuration(execution.execution_time_ms)
- : "-"}
-
-
- {execution.error_message ? (
-
- {execution.error_message}
-
- ) : (
- "-"
- )}
-
-
- ))}
-
-
- )}
-
-
-
- );
-}
diff --git a/frontend/components/admin/MultiLang.tsx b/frontend/components/admin/MultiLang.tsx
deleted file mode 100644
index abdadcdb..00000000
--- a/frontend/components/admin/MultiLang.tsx
+++ /dev/null
@@ -1,859 +0,0 @@
-"use client";
-
-import { useState, useEffect } from "react";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
-import { Badge } from "@/components/ui/badge";
-
-import { DataTable } from "@/components/common/DataTable";
-import { LoadingSpinner } from "@/components/common/LoadingSpinner";
-import { useAuth } from "@/hooks/useAuth";
-import LangKeyModal from "./LangKeyModal";
-import LanguageModal from "./LanguageModal";
-import { apiClient } from "@/lib/api/client";
-
-interface Language {
- langCode: string;
- langName: string;
- langNative: string;
- isActive: string;
-}
-
-interface LangKey {
- keyId: number;
- companyCode: string;
- menuName: string;
- langKey: string;
- description: string;
- isActive: string;
-}
-
-interface LangText {
- textId: number;
- keyId: number;
- langCode: string;
- langText: string;
- isActive: string;
-}
-
-export default function MultiLangPage() {
- const { user } = useAuth();
- const [loading, setLoading] = useState(true);
- const [languages, setLanguages] = useState([]);
- const [langKeys, setLangKeys] = useState([]);
- const [selectedKey, setSelectedKey] = useState(null);
- const [langTexts, setLangTexts] = useState([]);
- const [editingTexts, setEditingTexts] = useState([]);
- const [selectedCompany, setSelectedCompany] = useState("all");
- const [searchText, setSearchText] = useState("");
- const [isModalOpen, setIsModalOpen] = useState(false);
- const [editingKey, setEditingKey] = useState(null);
- const [selectedKeys, setSelectedKeys] = useState>(new Set());
-
- // 언어 관리 관련 상태
- const [isLanguageModalOpen, setIsLanguageModalOpen] = useState(false);
- const [editingLanguage, setEditingLanguage] = useState(null);
- const [selectedLanguages, setSelectedLanguages] = useState>(new Set());
- const [activeTab, setActiveTab] = useState<"keys" | "languages">("keys");
-
- const [companies, setCompanies] = useState>([]);
-
- // 회사 목록 조회
- const fetchCompanies = async () => {
- try {
- // console.log("회사 목록 조회 시작");
- const response = await apiClient.get("/admin/companies");
- // console.log("회사 목록 응답 데이터:", response.data);
-
- const data = response.data;
- if (data.success) {
- const companyList = data.data.map((company: any) => ({
- code: company.company_code,
- name: company.company_name,
- }));
- // console.log("변환된 회사 목록:", companyList);
- setCompanies(companyList);
- } else {
- // console.error("회사 목록 조회 실패:", data.message);
- }
- } catch (error) {
- // console.error("회사 목록 조회 실패:", error);
- }
- };
-
- // 언어 목록 조회
- const fetchLanguages = async () => {
- try {
- const response = await apiClient.get("/multilang/languages");
- const data = response.data;
- if (data.success) {
- setLanguages(data.data);
- }
- } catch (error) {
- // console.error("언어 목록 조회 실패:", error);
- }
- };
-
- // 다국어 키 목록 조회
- const fetchLangKeys = async () => {
- try {
- const response = await apiClient.get("/multilang/keys");
- const data = response.data;
- if (data.success) {
- // console.log("✅ 전체 키 목록 로드:", data.data.length, "개");
- setLangKeys(data.data);
- } else {
- // console.error("❌ 키 목록 로드 실패:", data.message);
- }
- } catch (error) {
- // console.error("다국어 키 목록 조회 실패:", error);
- }
- };
-
- // 필터링된 데이터 계산 - 메뉴관리와 동일한 방식
- const getFilteredLangKeys = () => {
- let filteredKeys = langKeys;
-
- // 회사 필터링
- if (selectedCompany && selectedCompany !== "all") {
- filteredKeys = filteredKeys.filter((key) => key.companyCode === selectedCompany);
- }
-
- // 텍스트 검색 필터링
- if (searchText.trim()) {
- const searchLower = searchText.toLowerCase();
- filteredKeys = filteredKeys.filter((key) => {
- const langKey = (key.langKey || "").toLowerCase();
- const description = (key.description || "").toLowerCase();
- const menuName = (key.menuName || "").toLowerCase();
- const companyName = companies.find((c) => c.code === key.companyCode)?.name?.toLowerCase() || "";
-
- return (
- langKey.includes(searchLower) ||
- description.includes(searchLower) ||
- menuName.includes(searchLower) ||
- companyName.includes(searchLower)
- );
- });
- }
-
- return filteredKeys;
- };
-
- // 선택된 키의 다국어 텍스트 조회
- const fetchLangTexts = async (keyId: number) => {
- try {
- // console.log("다국어 텍스트 조회 시작: keyId =", keyId);
- const response = await apiClient.get(`/multilang/keys/${keyId}/texts`);
- const data = response.data;
- // console.log("다국어 텍스트 조회 응답:", data);
- if (data.success) {
- setLangTexts(data.data);
- // 편집용 텍스트 초기화
- const editingData = data.data.map((text: LangText) => ({ ...text }));
- setEditingTexts(editingData);
- // console.log("편집용 텍스트 설정:", editingData);
- }
- } catch (error) {
- // console.error("다국어 텍스트 조회 실패:", error);
- }
- };
-
- // 언어 키 선택 처리
- const handleKeySelect = (key: LangKey) => {
- // console.log("언어 키 선택:", key);
- setSelectedKey(key);
- fetchLangTexts(key.keyId);
- };
-
- // 디버깅용 useEffect
- useEffect(() => {
- if (selectedKey) {
- // console.log("선택된 키 변경:", selectedKey);
- // console.log("언어 목록:", languages);
- // console.log("편집 텍스트:", editingTexts);
- }
- }, [selectedKey, languages, editingTexts]);
-
- // 텍스트 변경 처리
- const handleTextChange = (langCode: string, value: string) => {
- const newEditingTexts = [...editingTexts];
- const existingIndex = newEditingTexts.findIndex((t) => t.langCode === langCode);
-
- if (existingIndex >= 0) {
- newEditingTexts[existingIndex].langText = value;
- } else {
- newEditingTexts.push({
- textId: 0,
- keyId: selectedKey!.keyId,
- langCode: langCode,
- langText: value,
- isActive: "Y",
- });
- }
-
- setEditingTexts(newEditingTexts);
- };
-
- // 텍스트 저장
- const handleSave = async () => {
- if (!selectedKey) return;
-
- try {
- // 백엔드가 기대하는 형식으로 데이터 변환
- const requestData = {
- texts: editingTexts.map((text) => ({
- langCode: text.langCode,
- langText: text.langText,
- isActive: text.isActive || "Y",
- createdBy: user?.userId || "system",
- updatedBy: user?.userId || "system",
- })),
- };
-
- const response = await apiClient.post(`/multilang/keys/${selectedKey.keyId}/texts`, requestData);
- const data = response.data;
- if (data.success) {
- alert("저장되었습니다.");
- // 저장 후 다시 조회
- fetchLangTexts(selectedKey.keyId);
- }
- } catch (error) {
- // console.error("텍스트 저장 실패:", error);
- alert("저장에 실패했습니다.");
- }
- };
-
- // 언어 키 추가/수정 모달 열기
- const handleAddKey = () => {
- setEditingKey(null); // 새 키 추가는 null로 설정
- setIsModalOpen(true);
- };
-
- // 언어 추가/수정 모달 열기
- const handleAddLanguage = () => {
- setEditingLanguage(null);
- setIsLanguageModalOpen(true);
- };
-
- // 언어 수정
- const handleEditLanguage = (language: Language) => {
- setEditingLanguage(language);
- setIsLanguageModalOpen(true);
- };
-
- // 언어 저장 (추가/수정)
- const handleSaveLanguage = async (languageData: any) => {
- try {
- const requestData = {
- ...languageData,
- createdBy: user?.userId || "admin",
- updatedBy: user?.userId || "admin",
- };
-
- let response;
- if (editingLanguage) {
- response = await apiClient.put(`/multilang/languages/${editingLanguage.langCode}`, requestData);
- } else {
- response = await apiClient.post("/multilang/languages", requestData);
- }
-
- const result = response.data;
-
- if (result.success) {
- alert(editingLanguage ? "언어가 수정되었습니다." : "언어가 추가되었습니다.");
- setIsLanguageModalOpen(false);
- fetchLanguages(); // 언어 목록 새로고침
- } else {
- alert(`오류: ${result.message}`);
- }
- } catch (error) {
- // console.error("언어 저장 중 오류:", error);
- alert("언어 저장 중 오류가 발생했습니다.");
- }
- };
-
- // 언어 삭제
- const handleDeleteLanguages = async () => {
- if (selectedLanguages.size === 0) {
- alert("삭제할 언어를 선택해주세요.");
- return;
- }
-
- if (
- !confirm(
- `선택된 ${selectedLanguages.size}개의 언어를 영구적으로 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.`,
- )
- ) {
- return;
- }
-
- try {
- const deletePromises = Array.from(selectedLanguages).map((langCode) =>
- apiClient.delete(`/multilang/languages/${langCode}`),
- );
-
- const responses = await Promise.all(deletePromises);
- const failedDeletes = responses.filter((response) => !response.data.success);
-
- if (failedDeletes.length === 0) {
- alert("선택된 언어가 삭제되었습니다.");
- setSelectedLanguages(new Set());
- fetchLanguages(); // 언어 목록 새로고침
- } else {
- alert(`${failedDeletes.length}개의 언어 삭제에 실패했습니다.`);
- }
- } catch (error) {
- // console.error("언어 삭제 중 오류:", error);
- alert("언어 삭제 중 오류가 발생했습니다.");
- }
- };
-
- // 언어 선택 체크박스 처리
- const handleLanguageCheckboxChange = (langCode: string, checked: boolean) => {
- const newSelected = new Set(selectedLanguages);
- if (checked) {
- newSelected.add(langCode);
- } else {
- newSelected.delete(langCode);
- }
- setSelectedLanguages(newSelected);
- };
-
- // 언어 전체 선택/해제
- const handleSelectAllLanguages = (checked: boolean) => {
- if (checked) {
- setSelectedLanguages(new Set(languages.map((lang) => lang.langCode)));
- } else {
- setSelectedLanguages(new Set());
- }
- };
-
- // 언어 키 수정 모달 열기
- const handleEditKey = (key: LangKey) => {
- setEditingKey(key);
- setIsModalOpen(true);
- };
-
- // 언어 키 저장 (추가/수정)
- const handleSaveKey = async (keyData: any) => {
- try {
- const requestData = {
- ...keyData,
- createdBy: user?.userId || "admin",
- updatedBy: user?.userId || "admin",
- };
-
- let response;
- if (editingKey) {
- response = await apiClient.put(`/multilang/keys/${editingKey.keyId}`, requestData);
- } else {
- response = await apiClient.post("/multilang/keys", requestData);
- }
-
- const data = response.data;
-
- if (data.success) {
- alert(editingKey ? "언어 키가 수정되었습니다." : "언어 키가 추가되었습니다.");
- fetchLangKeys(); // 목록 새로고침
- setIsModalOpen(false);
- } else {
- // 중복 체크 오류 메시지 처리
- if (data.message && data.message.includes("이미 존재하는 언어키")) {
- alert(data.message);
- } else {
- alert(data.message || "언어 키 저장에 실패했습니다.");
- }
- }
- } catch (error) {
- // console.error("언어 키 저장 실패:", error);
- alert("언어 키 저장에 실패했습니다.");
- }
- };
-
- // 체크박스 선택/해제
- const handleCheckboxChange = (keyId: number, checked: boolean) => {
- const newSelectedKeys = new Set(selectedKeys);
- if (checked) {
- newSelectedKeys.add(keyId);
- } else {
- newSelectedKeys.delete(keyId);
- }
- setSelectedKeys(newSelectedKeys);
- };
-
- // 키 상태 토글
- const handleToggleStatus = async (keyId: number) => {
- try {
- const response = await apiClient.put(`/multilang/keys/${keyId}/toggle`);
- const data = response.data;
- if (data.success) {
- alert(`키가 ${data.data}되었습니다.`);
- fetchLangKeys();
- } else {
- alert("상태 변경 중 오류가 발생했습니다.");
- }
- } catch (error) {
- // console.error("키 상태 토글 실패:", error);
- alert("키 상태 변경 중 오류가 발생했습니다.");
- }
- };
-
- // 언어 상태 토글
- const handleToggleLanguageStatus = async (langCode: string) => {
- try {
- const response = await apiClient.put(`/multilang/languages/${langCode}/toggle`);
- const data = response.data;
- if (data.success) {
- alert(`언어가 ${data.data}되었습니다.`);
- fetchLanguages();
- } else {
- alert("언어 상태 변경 중 오류가 발생했습니다.");
- }
- } catch (error) {
- // console.error("언어 상태 토글 실패:", error);
- alert("언어 상태 변경 중 오류가 발생했습니다.");
- }
- };
-
- // 전체 선택/해제
- const handleSelectAll = (checked: boolean) => {
- if (checked) {
- const allKeyIds = getFilteredLangKeys().map((key) => key.keyId);
- setSelectedKeys(new Set(allKeyIds));
- } else {
- setSelectedKeys(new Set());
- }
- };
-
- // 선택된 키들 일괄 삭제
- const handleDeleteSelectedKeys = async () => {
- if (selectedKeys.size === 0) {
- alert("삭제할 키를 선택해주세요.");
- return;
- }
-
- if (
- !confirm(
- `선택된 ${selectedKeys.size}개의 언어 키를 영구적으로 삭제하시겠습니까?\n\n⚠️ 이 작업은 되돌릴 수 없습니다.`,
- )
- ) {
- return;
- }
-
- try {
- const deletePromises = Array.from(selectedKeys).map((keyId) => apiClient.delete(`/multilang/keys/${keyId}`));
-
- const responses = await Promise.all(deletePromises);
- const allSuccess = responses.every((response) => response.data.success);
-
- if (allSuccess) {
- alert(`${selectedKeys.size}개의 언어 키가 영구적으로 삭제되었습니다.`);
- setSelectedKeys(new Set());
- fetchLangKeys(); // 목록 새로고침
-
- // 선택된 키가 삭제된 경우 편집 영역 닫기
- if (selectedKey && selectedKeys.has(selectedKey.keyId)) {
- handleCancel();
- }
- } else {
- alert("일부 키 삭제에 실패했습니다.");
- }
- } catch (error) {
- // console.error("선택된 키 삭제 실패:", error);
- alert("선택된 키 삭제에 실패했습니다.");
- }
- };
-
- // 개별 키 삭제 (기존 함수 유지)
- const handleDeleteKey = async (keyId: number) => {
- if (!confirm("정말로 이 언어 키를 영구적으로 삭제하시겠습니까?\n\n⚠️ 이 작업은 되돌릴 수 없습니다.")) {
- return;
- }
-
- try {
- const response = await apiClient.delete(`/multilang/keys/${keyId}`);
- const data = response.data;
- if (data.success) {
- alert("언어 키가 영구적으로 삭제되었습니다.");
- fetchLangKeys(); // 목록 새로고침
- if (selectedKey && selectedKey.keyId === keyId) {
- handleCancel(); // 선택된 키가 삭제된 경우 편집 영역 닫기
- }
- }
- } catch (error) {
- // console.error("언어 키 삭제 실패:", error);
- alert("언어 키 삭제에 실패했습니다.");
- }
- };
-
- // 취소 처리
- const handleCancel = () => {
- setSelectedKey(null);
- setLangTexts([]);
- setEditingTexts([]);
- };
-
- useEffect(() => {
- const initializeData = async () => {
- setLoading(true);
- await Promise.all([fetchCompanies(), fetchLanguages(), fetchLangKeys()]);
- setLoading(false);
- };
- initializeData();
- }, []);
-
- // 검색 관련 useEffect 제거 - 실시간 필터링만 사용
-
- const columns = [
- {
- id: "select",
- header: () => {
- const filteredKeys = getFilteredLangKeys();
- return (
- 0}
- onChange={(e) => handleSelectAll(e.target.checked)}
- className="h-4 w-4"
- />
- );
- },
- cell: ({ row }: any) => (
- handleCheckboxChange(row.original.keyId, e.target.checked)}
- onClick={(e) => e.stopPropagation()}
- className="h-4 w-4"
- disabled={row.original.isActive === "N"}
- />
- ),
- },
- {
- accessorKey: "companyCode",
- header: "회사",
- cell: ({ row }: any) => {
- const companyName =
- row.original.companyCode === "*"
- ? "공통"
- : companies.find((c) => c.code === row.original.companyCode)?.name || row.original.companyCode;
-
- return {companyName} ;
- },
- },
- {
- accessorKey: "menuName",
- header: "메뉴명",
- cell: ({ row }: any) => (
- {row.original.menuName}
- ),
- },
-
- {
- accessorKey: "langKey",
- header: "언어 키",
- cell: ({ row }: any) => (
- handleEditKey(row.original)}
- >
- {row.original.langKey}
-
- ),
- },
-
- {
- accessorKey: "description",
- header: "설명",
- cell: ({ row }: any) => (
- {row.original.description}
- ),
- },
- {
- accessorKey: "isActive",
- header: "상태",
- cell: ({ row }: any) => (
- handleToggleStatus(row.original.keyId)}
- className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
- row.original.isActive === "Y"
- ? "bg-green-100 text-green-800 hover:bg-green-200"
- : "bg-gray-100 text-gray-800 hover:bg-gray-200"
- }`}
- >
- {row.original.isActive === "Y" ? "활성" : "비활성"}
-
- ),
- },
- ];
-
- // 언어 테이블 컬럼 정의
- const languageColumns = [
- {
- id: "select",
- header: () => (
- 0}
- onChange={(e) => handleSelectAllLanguages(e.target.checked)}
- className="h-4 w-4"
- />
- ),
- cell: ({ row }: any) => (
- handleLanguageCheckboxChange(row.original.langCode, e.target.checked)}
- onClick={(e) => e.stopPropagation()}
- className="h-4 w-4"
- disabled={row.original.isActive === "N"}
- />
- ),
- },
- {
- accessorKey: "langCode",
- header: "언어 코드",
- cell: ({ row }: any) => (
- handleEditLanguage(row.original)}
- >
- {row.original.langCode}
-
- ),
- },
- {
- accessorKey: "langName",
- header: "언어명 (영문)",
- cell: ({ row }: any) => (
- {row.original.langName}
- ),
- },
- {
- accessorKey: "langNative",
- header: "언어명 (원어)",
- cell: ({ row }: any) => (
- {row.original.langNative}
- ),
- },
- {
- accessorKey: "isActive",
- header: "상태",
- cell: ({ row }: any) => (
- handleToggleLanguageStatus(row.original.langCode)}
- className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
- row.original.isActive === "Y"
- ? "bg-green-100 text-green-800 hover:bg-green-200"
- : "bg-gray-100 text-gray-800 hover:bg-gray-200"
- }`}
- >
- {row.original.isActive === "Y" ? "활성" : "비활성"}
-
- ),
- },
- ];
-
- if (loading) {
- return ;
- }
-
- return (
-
- {/* 탭 네비게이션 */}
-
- setActiveTab("keys")}
- className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${
- activeTab === "keys" ? "bg-accent0 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
- }`}
- >
- 다국어 키 관리
-
- setActiveTab("languages")}
- className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${
- activeTab === "languages" ? "bg-accent0 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
- }`}
- >
- 언어 관리
-
-
-
- {/* 메인 콘텐츠 영역 */}
-
- {/* 언어 관리 탭 */}
- {activeTab === "languages" && (
-
-
- 언어 관리
-
-
-
-
총 {languages.length}개의 언어가 등록되어 있습니다.
-
- {selectedLanguages.size > 0 && (
-
- 선택 삭제 ({selectedLanguages.size})
-
- )}
- 새 언어 추가
-
-
-
-
-
- )}
-
- {/* 다국어 키 관리 탭의 메인 영역 */}
- {activeTab === "keys" && (
-
- {/* 좌측: 언어 키 목록 (7/10) */}
-
-
-
-
언어 키 목록
-
-
- 선택 삭제 ({selectedKeys.size})
-
- 새 키 추가
-
-
-
-
- {/* 검색 필터 영역 */}
-
-
- 회사
-
-
-
-
-
- 전체 회사
- {companies.map((company) => (
-
- {company.name}
-
- ))}
-
-
-
-
-
- 검색
- setSearchText(e.target.value)}
- />
-
-
-
-
검색 결과: {getFilteredLangKeys().length}건
-
-
-
- {/* 테이블 영역 */}
-
-
전체: {getFilteredLangKeys().length}건
-
-
-
-
-
- {/* 우측: 선택된 키의 다국어 관리 (3/10) */}
-
-
-
- {selectedKey ? (
- <>
- 선택된 키:{" "}
-
- {selectedKey.companyCode}.{selectedKey.menuName}.{selectedKey.langKey}
-
- >
- ) : (
- "다국어 편집"
- )}
-
-
-
- {selectedKey ? (
-
- {/* 스크롤 가능한 텍스트 영역 */}
-
- {languages
- .filter((lang) => lang.isActive === "Y")
- .map((lang) => {
- const text = editingTexts.find((t) => t.langCode === lang.langCode);
- return (
-
-
- {lang.langName}
-
- handleTextChange(lang.langCode, e.target.value)}
- className="flex-1"
- />
-
- );
- })}
-
- {/* 저장 버튼 - 고정 위치 */}
-
- 저장
-
- 취소
-
-
-
- ) : (
-
-
-
언어 키를 선택하세요
-
좌측 목록에서 편집할 언어 키를 클릭하세요
-
-
- )}
-
-
-
- )}
-
-
- {/* 언어 키 추가/수정 모달 */}
-
setIsModalOpen(false)}
- onSave={handleSaveKey}
- keyData={editingKey}
- companies={companies}
- />
-
- {/* 언어 추가/수정 모달 */}
- setIsLanguageModalOpen(false)}
- onSave={handleSaveLanguage}
- languageData={editingLanguage}
- />
-
- );
-}
diff --git a/frontend/components/admin/RoleDetailManagement.tsx b/frontend/components/admin/RoleDetailManagement.tsx
deleted file mode 100644
index 92d03143..00000000
--- a/frontend/components/admin/RoleDetailManagement.tsx
+++ /dev/null
@@ -1,345 +0,0 @@
-"use client";
-
-import React, { useState, useCallback, useEffect } from "react";
-import { Button } from "@/components/ui/button";
-import { ArrowLeft, Users, Menu as MenuIcon, Save } from "lucide-react";
-import { roleAPI, RoleGroup } from "@/lib/api/role";
-import { useAuth } from "@/hooks/useAuth";
-import { useRouter } from "next/navigation";
-import { AlertCircle } from "lucide-react";
-import { DualListBox } from "@/components/common/DualListBox";
-import { MenuPermissionsTable } from "./MenuPermissionsTable";
-import { useMenu } from "@/contexts/MenuContext";
-
-interface RoleDetailManagementProps {
- roleId: string;
-}
-
-/**
- * 권한 그룹 상세 관리 컴포넌트
- *
- * 기능:
- * - 권한 그룹 정보 표시
- * - 멤버 관리 (Dual List Box)
- * - 메뉴 권한 설정 (CRUD 체크박스)
- */
-export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
- const { user: currentUser } = useAuth();
- const router = useRouter();
- const { refreshMenus } = useMenu();
-
- const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
-
- // 상태 관리
- const [roleGroup, setRoleGroup] = useState(null);
- const [isLoading, setIsLoading] = useState(true);
- const [error, setError] = useState(null);
-
- // 탭 상태
- const [activeTab, setActiveTab] = useState<"members" | "permissions">("members");
-
- // 멤버 관리 상태
- const [availableUsers, setAvailableUsers] = useState>([]);
- const [selectedUsers, setSelectedUsers] = useState>([]);
- const [isSavingMembers, setIsSavingMembers] = useState(false);
-
- // 메뉴 권한 상태
- const [menuPermissions, setMenuPermissions] = useState([]);
- const [isSavingPermissions, setIsSavingPermissions] = useState(false);
-
- // 데이터 로드
- const loadRoleGroup = useCallback(async () => {
- setIsLoading(true);
- setError(null);
-
- try {
- const response = await roleAPI.getById(parseInt(roleId, 10));
-
- if (response.success && response.data) {
- setRoleGroup(response.data);
- } else {
- setError(response.message || "권한 그룹 정보를 불러오는데 실패했습니다.");
- }
- } catch (err) {
- console.error("권한 그룹 정보 로드 오류:", err);
- setError("권한 그룹 정보를 불러오는 중 오류가 발생했습니다.");
- } finally {
- setIsLoading(false);
- }
- }, [roleId]);
-
- // 멤버 목록 로드
- const loadMembers = useCallback(async () => {
- if (!roleGroup) return;
-
- try {
- // 1. 권한 그룹 멤버 조회
- const membersResponse = await roleAPI.getMembers(roleGroup.objid);
- if (membersResponse.success && membersResponse.data) {
- setSelectedUsers(
- membersResponse.data.map((member: any) => ({
- id: member.userId,
- label: member.userName || member.userId,
- description: member.deptName,
- })),
- );
- }
-
- // 2. 전체 사용자 목록 조회 (같은 회사)
- const userAPI = await import("@/lib/api/user");
-
- console.log("🔍 사용자 목록 조회 요청:", {
- companyCode: roleGroup.companyCode,
- size: 1000,
- });
-
- const usersResponse = await userAPI.userAPI.getList({
- companyCode: roleGroup.companyCode,
- size: 1000, // 대량 조회
- });
-
- console.log("✅ 사용자 목록 응답:", {
- success: usersResponse.success,
- count: usersResponse.data?.length,
- total: usersResponse.total,
- });
-
- if (usersResponse.success && usersResponse.data) {
- setAvailableUsers(
- usersResponse.data.map((user: any) => ({
- id: user.userId,
- label: user.userName || user.userId,
- description: user.deptName,
- })),
- );
- console.log("📋 설정된 전체 사용자 수:", usersResponse.data.length);
- }
- } catch (err) {
- console.error("멤버 목록 로드 오류:", err);
- }
- }, [roleGroup]);
-
- // 메뉴 권한 로드
- const loadMenuPermissions = useCallback(async () => {
- if (!roleGroup) return;
-
- console.log("🔍 [loadMenuPermissions] 메뉴 권한 로드 시작", {
- roleGroupId: roleGroup.objid,
- roleGroupName: roleGroup.authName,
- companyCode: roleGroup.companyCode,
- });
-
- try {
- const response = await roleAPI.getMenuPermissions(roleGroup.objid);
-
- console.log("✅ [loadMenuPermissions] API 응답", {
- success: response.success,
- dataCount: response.data?.length,
- data: response.data,
- });
-
- if (response.success && response.data) {
- setMenuPermissions(response.data);
- console.log("✅ [loadMenuPermissions] 상태 업데이트 완료", {
- count: response.data.length,
- });
- } else {
- console.warn("⚠️ [loadMenuPermissions] 응답 실패", {
- message: response.message,
- });
- }
- } catch (err) {
- console.error("❌ [loadMenuPermissions] 메뉴 권한 로드 오류:", err);
- }
- }, [roleGroup]);
-
- useEffect(() => {
- loadRoleGroup();
- }, [loadRoleGroup]);
-
- useEffect(() => {
- if (roleGroup && activeTab === "members") {
- loadMembers();
- } else if (roleGroup && activeTab === "permissions") {
- loadMenuPermissions();
- }
- }, [roleGroup, activeTab, loadMembers, loadMenuPermissions]);
-
- // 멤버 저장 핸들러
- const handleSaveMembers = useCallback(async () => {
- if (!roleGroup) return;
-
- setIsSavingMembers(true);
- try {
- // 현재 선택된 사용자 ID 목록
- const selectedUserIds = selectedUsers.map((user) => user.id);
-
- // 멤버 업데이트 API 호출
- const response = await roleAPI.updateMembers(roleGroup.objid, selectedUserIds);
-
- if (response.success) {
- alert("멤버가 성공적으로 저장되었습니다.");
- loadMembers(); // 새로고침
-
- // 사이드바 메뉴 새로고침 (현재 사용자가 영향받을 수 있음)
- await refreshMenus();
- } else {
- alert(response.message || "멤버 저장에 실패했습니다.");
- }
- } catch (err) {
- console.error("멤버 저장 오류:", err);
- alert("멤버 저장 중 오류가 발생했습니다.");
- } finally {
- setIsSavingMembers(false);
- }
- }, [roleGroup, selectedUsers, loadMembers, refreshMenus]);
-
- // 메뉴 권한 저장 핸들러
- const handleSavePermissions = useCallback(async () => {
- if (!roleGroup) return;
-
- setIsSavingPermissions(true);
- try {
- const response = await roleAPI.setMenuPermissions(roleGroup.objid, menuPermissions);
-
- if (response.success) {
- alert("메뉴 권한이 성공적으로 저장되었습니다.");
- loadMenuPermissions(); // 새로고침
-
- // 사이드바 메뉴 새로고침 (권한 변경 즉시 반영)
- await refreshMenus();
- } else {
- alert(response.message || "메뉴 권한 저장에 실패했습니다.");
- }
- } catch (err) {
- console.error("메뉴 권한 저장 오류:", err);
- alert("메뉴 권한 저장 중 오류가 발생했습니다.");
- } finally {
- setIsSavingPermissions(false);
- }
- }, [roleGroup, menuPermissions, loadMenuPermissions, refreshMenus]);
-
- if (isLoading) {
- return (
-
-
-
-
권한 그룹 정보를 불러오는 중...
-
-
- );
- }
-
- if (error || !roleGroup) {
- return (
-
-
-
오류 발생
-
{error || "권한 그룹을 찾을 수 없습니다."}
-
router.push("/admin/userMng/rolesList")}>
- 목록으로 돌아가기
-
-
- );
- }
-
- return (
- <>
- {/* 페이지 헤더 */}
-
-
-
router.push("/admin/userMng/rolesList")} className="h-10 w-10">
-
-
-
-
{roleGroup.authName}
-
- {roleGroup.authCode} • {roleGroup.companyCode}
-
-
-
- {roleGroup.status === "active" ? "활성" : "비활성"}
-
-
-
-
- {/* 탭 네비게이션 */}
-
- setActiveTab("members")}
- className={`flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors ${
- activeTab === "members"
- ? "border-primary text-primary"
- : "text-muted-foreground hover:text-foreground border-transparent"
- }`}
- >
-
- 멤버 관리 ({selectedUsers.length})
-
- setActiveTab("permissions")}
- className={`flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors ${
- activeTab === "permissions"
- ? "border-primary text-primary"
- : "text-muted-foreground hover:text-foreground border-transparent"
- }`}
- >
-
- 메뉴 권한 ({menuPermissions.length})
-
-
-
- {/* 탭 컨텐츠 */}
-
- {activeTab === "members" && (
- <>
-
-
-
멤버 관리
-
이 권한 그룹에 속한 사용자를 관리합니다
-
-
-
- {isSavingMembers ? "저장 중..." : "멤버 저장"}
-
-
-
-
- >
- )}
-
- {activeTab === "permissions" && (
- <>
-
-
-
메뉴 권한 설정
-
이 권한 그룹에서 접근 가능한 메뉴와 권한을 설정합니다
-
-
-
- {isSavingPermissions ? "저장 중..." : "권한 저장"}
-
-
-
-
- >
- )}
-
- >
- );
-}
diff --git a/frontend/components/admin/RoleManagement.tsx b/frontend/components/admin/RoleManagement.tsx
deleted file mode 100644
index 3834b2a4..00000000
--- a/frontend/components/admin/RoleManagement.tsx
+++ /dev/null
@@ -1,335 +0,0 @@
-"use client";
-
-import React, { useState, useCallback, useEffect } from "react";
-import { Button } from "@/components/ui/button";
-import { Plus, Edit, Trash2, Users, Menu, Filter, X } from "lucide-react";
-import { roleAPI, RoleGroup } from "@/lib/api/role";
-import { useAuth } from "@/hooks/useAuth";
-import { AlertCircle } from "lucide-react";
-import { RoleFormModal } from "./RoleFormModal";
-import { RoleDeleteModal } from "./RoleDeleteModal";
-import { useRouter } from "next/navigation";
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
-import { companyAPI } from "@/lib/api/company";
-
-/**
- * 권한 그룹 관리 메인 컴포넌트
- *
- * 기능:
- * - 권한 그룹 목록 조회 (회사별)
- * - 권한 그룹 생성/수정/삭제
- * - 상세 페이지로 이동 (멤버 관리 + 메뉴 권한 설정)
- */
-export function RoleManagement() {
- const { user: currentUser } = useAuth();
- const router = useRouter();
-
- // 회사 관리자 또는 최고 관리자 여부
- const isAdmin =
- (currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN") ||
- currentUser?.userType === "COMPANY_ADMIN";
- const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
-
- // 상태 관리
- const [roleGroups, setRoleGroups] = useState([]);
- const [isLoading, setIsLoading] = useState(true);
- const [error, setError] = useState(null);
-
- // 회사 필터 (최고 관리자 전용)
- const [companies, setCompanies] = useState>([]);
- const [selectedCompany, setSelectedCompany] = useState("all");
-
- // 모달 상태
- const [formModal, setFormModal] = useState({
- isOpen: false,
- editingRole: null as RoleGroup | null,
- });
-
- const [deleteModal, setDeleteModal] = useState({
- isOpen: false,
- role: null as RoleGroup | null,
- });
-
- // 회사 목록 로드 (최고 관리자만)
- const loadCompanies = useCallback(async () => {
- if (!isSuperAdmin) return;
-
- try {
- const companies = await companyAPI.getList();
- setCompanies(companies);
- } catch (error) {
- console.error("회사 목록 로드 오류:", error);
- }
- }, [isSuperAdmin]);
-
- // 데이터 로드
- const loadRoleGroups = useCallback(async () => {
- setIsLoading(true);
- setError(null);
-
- try {
- // 최고 관리자: selectedCompany에 따라 필터링 (all이면 전체 조회)
- // 회사 관리자: 자기 회사만 조회
- const companyFilter =
- isSuperAdmin && selectedCompany !== "all"
- ? selectedCompany
- : isSuperAdmin
- ? undefined
- : currentUser?.companyCode;
-
- console.log("권한 그룹 목록 조회:", { isSuperAdmin, selectedCompany, companyFilter });
-
- const response = await roleAPI.getList({
- companyCode: companyFilter,
- });
-
- if (response.success && response.data) {
- setRoleGroups(response.data);
- console.log("권한 그룹 조회 성공:", response.data.length, "개");
- } else {
- setError(response.message || "권한 그룹 목록을 불러오는데 실패했습니다.");
- }
- } catch (err) {
- console.error("권한 그룹 목록 로드 오류:", err);
- setError("권한 그룹 목록을 불러오는 중 오류가 발생했습니다.");
- } finally {
- setIsLoading(false);
- }
- }, [isSuperAdmin, selectedCompany, currentUser?.companyCode]);
-
- useEffect(() => {
- if (isAdmin) {
- if (isSuperAdmin) {
- loadCompanies(); // 최고 관리자는 회사 목록 먼저 로드
- }
- loadRoleGroups();
- } else {
- setIsLoading(false);
- }
- }, [isAdmin, isSuperAdmin, loadRoleGroups, loadCompanies]);
-
- // 권한 그룹 생성 핸들러
- const handleCreateRole = useCallback(() => {
- setFormModal({ isOpen: true, editingRole: null });
- }, []);
-
- // 권한 그룹 수정 핸들러
- const handleEditRole = useCallback((role: RoleGroup) => {
- setFormModal({ isOpen: true, editingRole: role });
- }, []);
-
- // 권한 그룹 삭제 핸들러
- const handleDeleteRole = useCallback((role: RoleGroup) => {
- setDeleteModal({ isOpen: true, role });
- }, []);
-
- // 폼 모달 닫기
- const handleFormModalClose = useCallback(() => {
- setFormModal({ isOpen: false, editingRole: null });
- }, []);
-
- // 삭제 모달 닫기
- const handleDeleteModalClose = useCallback(() => {
- setDeleteModal({ isOpen: false, role: null });
- }, []);
-
- // 모달 성공 후 새로고침
- const handleModalSuccess = useCallback(() => {
- loadRoleGroups();
- }, [loadRoleGroups]);
-
- // 상세 페이지로 이동
- const handleViewDetail = useCallback(
- (role: RoleGroup) => {
- router.push(`/admin/userMng/rolesList/${role.objid}`);
- },
- [router],
- );
-
- // 관리자가 아니면 접근 제한
- if (!isAdmin) {
- return (
-
-
-
접근 권한 없음
-
- 권한 그룹 관리는 회사 관리자 이상만 접근할 수 있습니다.
-
-
window.history.back()}>
- 뒤로 가기
-
-
- );
- }
-
- return (
- <>
- {/* 에러 메시지 */}
- {error && (
-
-
-
오류가 발생했습니다
-
setError(null)}
- className="text-destructive hover:text-destructive/80 transition-colors"
- aria-label="에러 메시지 닫기"
- >
- ✕
-
-
-
{error}
-
- )}
-
- {/* 액션 버튼 영역 */}
-
-
-
권한 그룹 목록 ({roleGroups.length})
-
- {/* 최고 관리자 전용: 회사 필터 */}
- {isSuperAdmin && (
-
-
- setSelectedCompany(value)}>
-
-
-
-
- 전체 회사
- {companies.map((company) => (
-
- {company.company_name}
-
- ))}
-
-
- {selectedCompany !== "all" && (
- setSelectedCompany("all")} className="h-8 w-8 p-0">
-
-
- )}
-
- )}
-
-
-
-
- 권한 그룹 생성
-
-
-
- {/* 권한 그룹 목록 */}
- {isLoading ? (
-
-
-
-
권한 그룹 목록을 불러오는 중...
-
-
- ) : roleGroups.length === 0 ? (
-
-
-
등록된 권한 그룹이 없습니다.
-
권한 그룹을 생성하여 멤버를 관리해보세요.
-
-
- ) : (
-
- {roleGroups.map((role) => (
-
- {/* 헤더 (클릭 시 상세 페이지) */}
-
handleViewDetail(role)}
- >
-
-
-
{role.authName}
-
{role.authCode}
-
-
- {role.status === "active" ? "활성" : "비활성"}
-
-
-
- {/* 정보 */}
-
- {/* 최고 관리자는 회사명 표시 */}
- {isSuperAdmin && (
-
- 회사
-
- {companies.find((c) => c.company_code === role.companyCode)?.company_name || role.companyCode}
-
-
- )}
-
-
-
- 멤버 수
-
- {role.memberCount || 0}명
-
-
-
-
- 메뉴 권한
-
- {role.menuCount || 0}개
-
-
-
-
- {/* 액션 버튼 */}
-
- {
- e.stopPropagation();
- handleEditRole(role);
- }}
- className="flex-1 gap-1 text-xs"
- >
-
- 수정
-
- {
- e.stopPropagation();
- handleDeleteRole(role);
- }}
- className="text-destructive hover:bg-destructive hover:text-destructive-foreground gap-1 text-xs"
- >
-
- 삭제
-
-
-
- ))}
-
- )}
-
- {/* 모달들 */}
-
-
-
- >
- );
-}
diff --git a/frontend/components/admin/UserAuthManagement.tsx b/frontend/components/admin/UserAuthManagement.tsx
deleted file mode 100644
index 27163ba5..00000000
--- a/frontend/components/admin/UserAuthManagement.tsx
+++ /dev/null
@@ -1,157 +0,0 @@
-"use client";
-
-import React, { useState, useCallback, useEffect } from "react";
-import { UserAuthTable } from "./UserAuthTable";
-import { UserAuthEditModal } from "./UserAuthEditModal";
-import { userAPI } from "@/lib/api/user";
-import { useAuth } from "@/hooks/useAuth";
-import { AlertCircle } from "lucide-react";
-import { Button } from "@/components/ui/button";
-
-/**
- * 사용자 권한 관리 메인 컴포넌트
- *
- * 기능:
- * - 사용자 목록 조회 (권한 정보 포함)
- * - 권한 변경 모달
- * - 최고 관리자 권한 체크
- */
-export function UserAuthManagement() {
- const { user: currentUser } = useAuth();
-
- // 최고 관리자 여부
- const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
-
- // 상태 관리
- const [users, setUsers] = useState([]);
- const [isLoading, setIsLoading] = useState(true);
- const [error, setError] = useState(null);
- const [paginationInfo, setPaginationInfo] = useState({
- currentPage: 1,
- pageSize: 20,
- totalItems: 0,
- totalPages: 0,
- });
-
- // 권한 변경 모달
- const [authEditModal, setAuthEditModal] = useState({
- isOpen: false,
- user: null as any | null,
- });
-
- // 데이터 로드
- const loadUsers = useCallback(
- async (page: number = 1) => {
- setIsLoading(true);
- setError(null);
-
- try {
- const response = await userAPI.getList({
- page,
- size: paginationInfo.pageSize,
- });
-
- if (response.success && response.data) {
- setUsers(response.data);
- setPaginationInfo({
- currentPage: response.currentPage || page,
- pageSize: response.pageSize || paginationInfo.pageSize,
- totalItems: response.total || 0,
- totalPages: Math.ceil((response.total || 0) / (response.pageSize || paginationInfo.pageSize)),
- });
- } else {
- setError(response.message || "사용자 목록을 불러오는데 실패했습니다.");
- }
- } catch (err) {
- console.error("사용자 목록 로드 오류:", err);
- setError("사용자 목록을 불러오는 중 오류가 발생했습니다.");
- } finally {
- setIsLoading(false);
- }
- },
- [paginationInfo.pageSize],
- );
-
- useEffect(() => {
- loadUsers(1);
- }, []);
-
- // 권한 변경 핸들러
- const handleEditAuth = (user: any) => {
- setAuthEditModal({
- isOpen: true,
- user,
- });
- };
-
- // 권한 변경 모달 닫기
- const handleAuthEditClose = () => {
- setAuthEditModal({
- isOpen: false,
- user: null,
- });
- };
-
- // 권한 변경 성공
- const handleAuthEditSuccess = () => {
- loadUsers(paginationInfo.currentPage);
- handleAuthEditClose();
- };
-
- // 페이지 변경
- const handlePageChange = (page: number) => {
- loadUsers(page);
- };
-
- // 최고 관리자가 아닌 경우
- if (!isSuperAdmin) {
- return (
-
-
-
접근 권한 없음
-
권한 관리는 최고 관리자만 접근할 수 있습니다.
-
window.history.back()}>
- 뒤로 가기
-
-
- );
- }
-
- return (
-
- {/* 에러 메시지 */}
- {error && (
-
-
-
오류가 발생했습니다
-
setError(null)}
- className="text-destructive hover:text-destructive/80 transition-colors"
- aria-label="에러 메시지 닫기"
- >
- ✕
-
-
-
{error}
-
- )}
-
- {/* 사용자 권한 테이블 */}
-
-
- {/* 권한 변경 모달 */}
-
-
- );
-}
diff --git a/frontend/components/admin/UserManagement.tsx b/frontend/components/admin/UserManagement.tsx
deleted file mode 100644
index 987b986e..00000000
--- a/frontend/components/admin/UserManagement.tsx
+++ /dev/null
@@ -1,176 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { useUserManagement } from "@/hooks/useUserManagement";
-import { UserToolbar } from "./UserToolbar";
-import { UserTable } from "./UserTable";
-import { Pagination } from "@/components/common/Pagination";
-import { UserPasswordResetModal } from "./UserPasswordResetModal";
-import { UserFormModal } from "./UserFormModal";
-
-/**
- * 사용자 관리 메인 컴포넌트
- * - 원본 Spring + JSP 코드 패턴 기반 REST API 연동
- * - 실제 데이터베이스와 연동되어 작동
- */
-export function UserManagement() {
- const {
- // 데이터
- users,
- searchFilter,
- isLoading,
- isSearching,
- error,
- paginationInfo,
-
- // 검색 기능
- updateSearchFilter,
-
- // 페이지네이션
- handlePageChange,
- handlePageSizeChange,
-
- // 액션 핸들러
- handleStatusToggle,
-
- // 유틸리티
- clearError,
- refreshData,
- } = useUserManagement();
-
- // 비밀번호 초기화 모달 상태
- const [passwordResetModal, setPasswordResetModal] = useState({
- isOpen: false,
- userId: null as string | null,
- userName: null as string | null,
- });
-
- // 사용자 등록/수정 모달 상태
- const [userFormModal, setUserFormModal] = useState({
- isOpen: false,
- editingUser: null as any | null,
- });
-
- // 사용자 등록 핸들러
- const handleCreateUser = () => {
- setUserFormModal({
- isOpen: true,
- editingUser: null,
- });
- };
-
- // 사용자 수정 핸들러
- const handleEditUser = (user: any) => {
- setUserFormModal({
- isOpen: true,
- editingUser: user,
- });
- };
-
- // 사용자 등록/수정 모달 닫기
- const handleUserFormClose = () => {
- setUserFormModal({
- isOpen: false,
- editingUser: null,
- });
- };
-
- // 사용자 등록/수정 성공 핸들러
- const handleUserFormSuccess = () => {
- refreshData(); // 목록 새로고침
- handleUserFormClose();
- };
-
- // 비밀번호 초기화 핸들러
- const handlePasswordReset = (userId: string, userName: string) => {
- setPasswordResetModal({
- isOpen: true,
- userId,
- userName,
- });
- };
-
- // 비밀번호 초기화 모달 닫기
- const handlePasswordResetClose = () => {
- setPasswordResetModal({
- isOpen: false,
- userId: null,
- userName: null,
- });
- };
-
- // 비밀번호 초기화 성공 핸들러
- const handlePasswordResetSuccess = () => {
- // refreshData(); // 비밀번호 변경은 목록에 영향을 주지 않으므로 새로고침 불필요
- handlePasswordResetClose();
- };
-
- return (
-
- {/* 툴바 - 검색, 필터, 등록 버튼 */}
-
-
- {/* 에러 메시지 */}
- {error && (
-
- )}
-
- {/* 사용자 목록 테이블 */}
-
-
- {/* 페이지네이션 */}
- {!isLoading && users.length > 0 && (
-
- )}
-
- {/* 사용자 등록/수정 모달 */}
-
-
- {/* 비밀번호 초기화 모달 */}
-
-
- );
-}
diff --git a/frontend/components/admin/department/DepartmentManagement.tsx b/frontend/components/admin/department/DepartmentManagement.tsx
deleted file mode 100644
index 4939e24e..00000000
--- a/frontend/components/admin/department/DepartmentManagement.tsx
+++ /dev/null
@@ -1,117 +0,0 @@
-"use client";
-
-import { useState, useEffect } from "react";
-import { useRouter } from "next/navigation";
-import { Button } from "@/components/ui/button";
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { ArrowLeft } from "lucide-react";
-import { DepartmentStructure } from "./DepartmentStructure";
-import { DepartmentMembers } from "./DepartmentMembers";
-import type { Department } from "@/types/department";
-import { getCompanyList } from "@/lib/api/company";
-
-interface DepartmentManagementProps {
- companyCode: string;
-}
-
-/**
- * 부서 관리 메인 컴포넌트
- * 좌측: 부서 구조, 우측: 부서 인원
- */
-export function DepartmentManagement({ companyCode }: DepartmentManagementProps) {
- const router = useRouter();
- const [selectedDepartment, setSelectedDepartment] = useState(null);
- const [activeTab, setActiveTab] = useState("structure");
- const [companyName, setCompanyName] = useState("");
- const [refreshTrigger, setRefreshTrigger] = useState(0);
-
- // 부서원 변경 시 부서 구조 새로고침
- const handleMemberChange = () => {
- setRefreshTrigger((prev) => prev + 1);
- };
-
- // 회사 정보 로드
- useEffect(() => {
- const loadCompanyInfo = async () => {
- const response = await getCompanyList();
- if (response.success && response.data) {
- const company = response.data.find((c) => c.company_code === companyCode);
- if (company) {
- setCompanyName(company.company_name);
- }
- }
- };
- loadCompanyInfo();
- }, [companyCode]);
-
- const handleBackToList = () => {
- router.push("/admin/userMng/companyList");
- };
-
- return (
-
- {/* 상단 헤더: 회사 정보 + 뒤로가기 */}
-
-
-
-
- 회사 목록
-
-
-
-
{companyName || companyCode}
-
부서 관리
-
-
-
- {/* 탭 네비게이션 (모바일용) */}
-
-
-
- 부서 구조
- 부서 인원
-
-
-
-
-
-
-
-
-
-
-
-
- {/* 좌우 레이아웃 (데스크톱) */}
-
- {/* 좌측: 부서 구조 (20%) */}
-
-
-
-
- {/* 우측: 부서 인원 (80%) */}
-
-
-
-
-
- );
-}
diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx
index 449a9c49..b28e4d01 100644
--- a/frontend/components/layout/AppLayout.tsx
+++ b/frontend/components/layout/AppLayout.tsx
@@ -17,12 +17,14 @@ import {
UserCheck,
LogOut,
User,
+ Building2,
} from "lucide-react";
import { useMenu } from "@/contexts/MenuContext";
import { useAuth } from "@/hooks/useAuth";
import { useProfile } from "@/hooks/useProfile";
import { MenuItem } from "@/lib/api/menu";
import { menuScreenApi } from "@/lib/api/screen";
+import { apiClient } from "@/lib/api/client";
import { toast } from "sonner";
import { ProfileModal } from "./ProfileModal";
import { Logo } from "./Logo";
@@ -35,6 +37,14 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+} from "@/components/ui/dialog";
+import { CompanySwitcher } from "@/components/admin/CompanySwitcher";
// useAuth의 UserInfo 타입을 확장
interface ExtendedUserInfo {
@@ -206,11 +216,38 @@ function AppLayoutInner({ children }: AppLayoutProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
- const { user, logout, refreshUserData } = useAuth();
+ const { user, logout, refreshUserData, switchCompany } = useAuth();
const { userMenus, adminMenus, loading, refreshMenus } = useMenu();
const [sidebarOpen, setSidebarOpen] = useState(true);
const [expandedMenus, setExpandedMenus] = useState>(new Set());
const [isMobile, setIsMobile] = useState(false);
+ const [showCompanySwitcher, setShowCompanySwitcher] = useState(false);
+ const [currentCompanyName, setCurrentCompanyName] = useState("");
+
+ // 현재 회사명 조회 (SUPER_ADMIN 전용)
+ useEffect(() => {
+ const fetchCurrentCompanyName = async () => {
+ if ((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN") {
+ const companyCode = (user as ExtendedUserInfo)?.companyCode;
+
+ if (companyCode === "*") {
+ setCurrentCompanyName("WACE (최고 관리자)");
+ } else if (companyCode) {
+ try {
+ const response = await apiClient.get("/admin/companies/db");
+ if (response.data.success) {
+ const company = response.data.data.find((c: any) => c.company_code === companyCode);
+ setCurrentCompanyName(company?.company_name || companyCode);
+ }
+ } catch (error) {
+ setCurrentCompanyName(companyCode);
+ }
+ }
+ }
+ };
+
+ fetchCurrentCompanyName();
+ }, [(user as ExtendedUserInfo)?.companyCode, (user as ExtendedUserInfo)?.userType]);
// 화면 크기 감지 및 사이드바 초기 상태 설정
useEffect(() => {
@@ -333,11 +370,32 @@ function AppLayoutInner({ children }: AppLayoutProps) {
};
// 모드 전환 핸들러
- const handleModeSwitch = () => {
+ const handleModeSwitch = async () => {
if (isAdminMode) {
+ // 관리자 → 사용자 모드: 선택한 회사 유지
router.push("/main");
} else {
- router.push("/admin");
+ // 사용자 → 관리자 모드: WACE로 복귀 필요 (SUPER_ADMIN만)
+ if ((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN") {
+ const currentCompanyCode = (user as ExtendedUserInfo)?.companyCode;
+
+ // 이미 WACE("*")가 아니면 WACE로 전환 후 관리자 페이지로 이동
+ if (currentCompanyCode !== "*") {
+ const result = await switchCompany("*");
+ if (result.success) {
+ // 페이지 새로고침 (관리자 페이지로 이동)
+ window.location.href = "/admin";
+ } else {
+ toast.error("WACE로 전환 실패");
+ }
+ } else {
+ // 이미 WACE면 바로 관리자 페이지로 이동
+ router.push("/admin");
+ }
+ } else {
+ // 일반 관리자는 바로 관리자 페이지로 이동
+ router.push("/admin");
+ }
}
};
@@ -498,11 +556,27 @@ function AppLayoutInner({ children }: AppLayoutProps) {
)}
+ {/* WACE 관리자: 현재 관리 회사 표시 */}
+ {(user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" && (
+
+
+
+
+
현재 관리 회사
+
+ {currentCompanyName || "로딩 중..."}
+
+
+
+
+ )}
+
{/* Admin/User 모드 전환 버튼 (관리자만) */}
{((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" ||
(user as ExtendedUserInfo)?.userType === "COMPANY_ADMIN" ||
(user as ExtendedUserInfo)?.userType === "admin") && (
-
+
+ {/* 관리자/사용자 메뉴 전환 */}
)}
+
+ {/* WACE 관리자 전용: 회사 선택 버튼 */}
+ {(user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" && (
+ { console.log("🔴 회사 선택 버튼 클릭!"); setShowCompanySwitcher(true); }}
+ className="flex w-full items-center justify-center gap-2 rounded-lg border border-purple-200 bg-purple-50 px-3 py-2 text-sm font-medium text-purple-700 transition-colors duration-200 hover:cursor-pointer hover:bg-purple-100"
+ >
+
+ 회사 선택
+
+ )}
)}
@@ -653,6 +738,21 @@ function AppLayoutInner({ children }: AppLayoutProps) {
onSave={saveProfile}
onAlertClose={closeAlert}
/>
+
+ {/* 회사 전환 모달 (WACE 관리자 전용) */}
+
+
+
+ 회사 선택
+
+ 관리할 회사를 선택하면 해당 회사의 관점에서 시스템을 사용할 수 있습니다.
+
+
+
+ setShowCompanySwitcher(false)} isOpen={showCompanySwitcher} />
+
+
+
);
}
diff --git a/frontend/contexts/MenuContext.tsx b/frontend/contexts/MenuContext.tsx
index 1ced8546..88b15542 100644
--- a/frontend/contexts/MenuContext.tsx
+++ b/frontend/contexts/MenuContext.tsx
@@ -4,6 +4,7 @@ import React, { createContext, useContext, useState, useEffect, ReactNode } from
import type { MenuItem } from "@/lib/api/menu";
import { menuApi } from "@/lib/api/menu"; // API 호출 활성화
import { toast } from "sonner";
+import { useAuth } from "@/hooks/useAuth"; // user 정보 가져오기
interface MenuContextType {
adminMenus: MenuItem[];
@@ -18,6 +19,7 @@ export function MenuProvider({ children }: { children: ReactNode }) {
const [adminMenus, setAdminMenus] = useState([]);
const [userMenus, setUserMenus] = useState([]);
const [loading, setLoading] = useState(true);
+ const { user } = useAuth(); // user 정보 가져오기
const convertMenuData = (data: any[]): MenuItem[] => {
return data.map((item) => ({
@@ -96,8 +98,10 @@ export function MenuProvider({ children }: { children: ReactNode }) {
};
useEffect(() => {
+ // user.companyCode가 변경되면 메뉴 다시 로드
+ // console.log("🔄 MenuContext: user.companyCode 변경 감지, 메뉴 재로드", user?.companyCode);
loadMenus();
- }, []); // 초기 로드만
+ }, [user?.companyCode]); // companyCode 변경 시 재로드
return (
{children}
diff --git a/frontend/hooks/useAuth.ts b/frontend/hooks/useAuth.ts
index 854a9196..29752559 100644
--- a/frontend/hooks/useAuth.ts
+++ b/frontend/hooks/useAuth.ts
@@ -331,6 +331,61 @@ export const useAuth = () => {
[apiCall, refreshUserData],
);
+ /**
+ * 회사 전환 처리 (WACE 관리자 전용)
+ */
+ const switchCompany = useCallback(
+ async (companyCode: string): Promise<{ success: boolean; message: string }> => {
+ try {
+ // console.log("🔵 useAuth.switchCompany 시작:", companyCode);
+ setLoading(true);
+ setError(null);
+
+ // console.log("🔵 API 호출: POST /auth/switch-company");
+ const response = await apiCall("POST", "/auth/switch-company", {
+ companyCode,
+ });
+ // console.log("🔵 API 응답:", response);
+
+ if (response.success && response.data?.token) {
+ // console.log("🔵 새 토큰 받음:", response.data.token.substring(0, 20) + "...");
+
+ // 새로운 JWT 토큰 저장
+ TokenManager.setToken(response.data.token);
+ // console.log("🔵 토큰 저장 완료");
+
+ // refreshUserData 호출하지 않고 바로 성공 반환
+ // (페이지 새로고침 시 자동으로 갱신됨)
+ // console.log("🔵 회사 전환 완료 (페이지 새로고침 필요)");
+
+ return {
+ success: true,
+ message: response.message || "회사 전환에 성공했습니다.",
+ };
+ } else {
+ // console.error("🔵 API 응답 실패:", response);
+ return {
+ success: false,
+ message: response.message || "회사 전환에 실패했습니다.",
+ };
+ }
+ } catch (error: any) {
+ // console.error("🔵 switchCompany 에러:", error);
+ const errorMessage = error.message || "회사 전환 중 오류가 발생했습니다.";
+ setError(errorMessage);
+
+ return {
+ success: false,
+ message: errorMessage,
+ };
+ } finally {
+ setLoading(false);
+ // console.log("🔵 switchCompany 완료");
+ }
+ },
+ [apiCall]
+ );
+
/**
* 로그아웃 처리
*/
@@ -493,6 +548,7 @@ export const useAuth = () => {
// 함수
login,
logout,
+ switchCompany, // 🆕 회사 전환 함수
checkMenuAuth,
refreshUserData,
diff --git a/frontend/lib/api/menu.ts b/frontend/lib/api/menu.ts
index 82ab39ac..67de76ae 100644
--- a/frontend/lib/api/menu.ts
+++ b/frontend/lib/api/menu.ts
@@ -85,9 +85,9 @@ export const menuApi = {
return response.data;
},
- // 사용자 메뉴 목록 조회 (좌측 사이드바용 - active만 표시)
+ // 사용자 메뉴 목록 조회 (좌측 사이드바용 - active만 표시, 회사별 필터링)
getUserMenus: async (): Promise> => {
- const response = await apiClient.get("/admin/menus", { params: { menuType: "1" } });
+ const response = await apiClient.get("/admin/user-menus");
return response.data;
},
From 7d6bff49aa7d737fb6bb6fa78490b99b2d595a0f Mon Sep 17 00:00:00 2001
From: kjs
Date: Tue, 30 Dec 2025 15:36:28 +0900
Subject: [PATCH 24/28] =?UTF-8?q?=ED=8F=BC=20=EC=B1=84=EB=B2=88=20?=
=?UTF-8?q?=EC=98=A4=EC=9E=91=EB=8F=99=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?=
=?UTF-8?q?=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/lib/utils/buttonActions.ts | 60 +++++++++++++++++++++++++++++
1 file changed, 60 insertions(+)
diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts
index 9a6a606e..944f7126 100644
--- a/frontend/lib/utils/buttonActions.ts
+++ b/frontend/lib/utils/buttonActions.ts
@@ -1608,6 +1608,66 @@ export class ButtonActionExecutor {
return { handled: false, success: false };
}
+ // 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가)
+ console.log("🔍 [handleUniversalFormModalTableSectionSave] 채번 규칙 할당 체크 시작");
+
+ const fieldsWithNumbering: Record = {};
+
+ // commonFieldsData와 modalData에서 채번 규칙이 설정된 필드 찾기
+ for (const [key, value] of Object.entries(modalData)) {
+ if (key.endsWith("_numberingRuleId") && value) {
+ const fieldName = key.replace("_numberingRuleId", "");
+ fieldsWithNumbering[fieldName] = value as string;
+ console.log(`🎯 [handleUniversalFormModalTableSectionSave] 채번 필드 발견: ${fieldName} → 규칙 ${value}`);
+ }
+ }
+
+ // formData에서도 확인 (모달 외부에 있을 수 있음)
+ for (const [key, value] of Object.entries(formData)) {
+ if (key.endsWith("_numberingRuleId") && value && !fieldsWithNumbering[key.replace("_numberingRuleId", "")]) {
+ const fieldName = key.replace("_numberingRuleId", "");
+ fieldsWithNumbering[fieldName] = value as string;
+ console.log(
+ `🎯 [handleUniversalFormModalTableSectionSave] 채번 필드 발견 (formData): ${fieldName} → 규칙 ${value}`,
+ );
+ }
+ }
+
+ console.log("📋 [handleUniversalFormModalTableSectionSave] 채번 규칙이 설정된 필드:", fieldsWithNumbering);
+
+ // 🔥 저장 시점에 allocateCode 호출하여 실제 순번 증가
+ if (Object.keys(fieldsWithNumbering).length > 0) {
+ console.log("🎯 [handleUniversalFormModalTableSectionSave] 채번 규칙 할당 시작 (allocateCode 호출)");
+ const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
+
+ for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) {
+ try {
+ console.log(
+ `🔄 [handleUniversalFormModalTableSectionSave] ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`,
+ );
+ const allocateResult = await allocateNumberingCode(ruleId);
+
+ if (allocateResult.success && allocateResult.data?.generatedCode) {
+ const newCode = allocateResult.data.generatedCode;
+ console.log(
+ `✅ [handleUniversalFormModalTableSectionSave] ${fieldName} 새 코드 할당: ${commonFieldsData[fieldName]} → ${newCode}`,
+ );
+ commonFieldsData[fieldName] = newCode;
+ } else {
+ console.warn(
+ `⚠️ [handleUniversalFormModalTableSectionSave] ${fieldName} 코드 할당 실패, 기존 값 유지:`,
+ allocateResult.error,
+ );
+ }
+ } catch (allocateError) {
+ console.error(`❌ [handleUniversalFormModalTableSectionSave] ${fieldName} 코드 할당 오류:`, allocateError);
+ // 오류 시 기존 값 유지
+ }
+ }
+ }
+
+ console.log("✅ [handleUniversalFormModalTableSectionSave] 채번 규칙 할당 완료");
+
try {
// 사용자 정보 추가
if (!context.userId) {
From bd49db16c6c55c226a28151491864797ae777203 Mon Sep 17 00:00:00 2001
From: kjs
Date: Tue, 30 Dec 2025 17:45:38 +0900
Subject: [PATCH 25/28] 1
---
frontend/app/(main)/admin/systemMng/dataflow/page.tsx | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/frontend/app/(main)/admin/systemMng/dataflow/page.tsx b/frontend/app/(main)/admin/systemMng/dataflow/page.tsx
index 87d937ec..d55a6cf1 100644
--- a/frontend/app/(main)/admin/systemMng/dataflow/page.tsx
+++ b/frontend/app/(main)/admin/systemMng/dataflow/page.tsx
@@ -51,17 +51,17 @@ export default function DataFlowPage() {
// 에디터 모드일 때는 레이아웃 없이 전체 화면 사용
if (isEditorMode) {
return (
-
+
{/* 에디터 헤더 */}
-
+
목록으로
노드 플로우 에디터
-
+
드래그 앤 드롭으로 데이터 제어 플로우를 시각적으로 설계합니다
@@ -77,12 +77,12 @@ export default function DataFlowPage() {
}
return (
-
+
{/* 페이지 헤더 */}
제어 관리
-
노드 기반 데이터 플로우를 시각적으로 설계하고 관리합니다
+
노드 기반 데이터 플로우를 시각적으로 설계하고 관리합니다
{/* 플로우 목록 */}
From 5bdc903b0dee0382f21de3792b916937cf36fe8c Mon Sep 17 00:00:00 2001
From: kjs
Date: Wed, 31 Dec 2025 10:54:07 +0900
Subject: [PATCH 26/28] =?UTF-8?q?=EB=B2=94=EC=9A=A9=20=ED=8F=BC=EB=AA=A8?=
=?UTF-8?q?=EB=8B=AC=20=EC=A0=9C=EC=96=B4=EB=A1=9C=EC=A7=81=20=EC=97=B0?=
=?UTF-8?q?=EB=8F=99?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/routes/dataflow/node-flows.ts | 67 ++++++++++++++++
frontend/lib/api/nodeFlows.ts | 38 +++++++++
frontend/lib/utils/buttonActions.ts | 78 +++++++++++++++++++
3 files changed, 183 insertions(+)
diff --git a/backend-node/src/routes/dataflow/node-flows.ts b/backend-node/src/routes/dataflow/node-flows.ts
index 6de84866..177b4304 100644
--- a/backend-node/src/routes/dataflow/node-flows.ts
+++ b/backend-node/src/routes/dataflow/node-flows.ts
@@ -214,6 +214,73 @@ router.delete("/:flowId", async (req: Request, res: Response) => {
}
});
+/**
+ * 플로우 소스 테이블 조회
+ * GET /api/dataflow/node-flows/:flowId/source-table
+ * 플로우의 첫 번째 소스 노드(tableSource, externalDBSource)에서 테이블명 추출
+ */
+router.get("/:flowId/source-table", async (req: Request, res: Response) => {
+ try {
+ const { flowId } = req.params;
+
+ const flow = await queryOne<{ flow_data: any }>(
+ `SELECT flow_data FROM node_flows WHERE flow_id = $1`,
+ [flowId]
+ );
+
+ if (!flow) {
+ return res.status(404).json({
+ success: false,
+ message: "플로우를 찾을 수 없습니다.",
+ });
+ }
+
+ const flowData =
+ typeof flow.flow_data === "string"
+ ? JSON.parse(flow.flow_data)
+ : flow.flow_data;
+
+ const nodes = flowData.nodes || [];
+
+ // 소스 노드 찾기 (tableSource, externalDBSource 타입)
+ const sourceNode = nodes.find(
+ (node: any) =>
+ node.type === "tableSource" || node.type === "externalDBSource"
+ );
+
+ if (!sourceNode || !sourceNode.data?.tableName) {
+ return res.json({
+ success: true,
+ data: {
+ sourceTable: null,
+ sourceNodeType: null,
+ message: "소스 노드가 없거나 테이블명이 설정되지 않았습니다.",
+ },
+ });
+ }
+
+ logger.info(
+ `플로우 소스 테이블 조회: flowId=${flowId}, table=${sourceNode.data.tableName}`
+ );
+
+ return res.json({
+ success: true,
+ data: {
+ sourceTable: sourceNode.data.tableName,
+ sourceNodeType: sourceNode.type,
+ sourceNodeId: sourceNode.id,
+ displayName: sourceNode.data.displayName,
+ },
+ });
+ } catch (error) {
+ logger.error("플로우 소스 테이블 조회 실패:", error);
+ return res.status(500).json({
+ success: false,
+ message: "플로우 소스 테이블을 조회하지 못했습니다.",
+ });
+ }
+});
+
/**
* 플로우 실행
* POST /api/dataflow/node-flows/:flowId/execute
diff --git a/frontend/lib/api/nodeFlows.ts b/frontend/lib/api/nodeFlows.ts
index b42340d7..27bb1b96 100644
--- a/frontend/lib/api/nodeFlows.ts
+++ b/frontend/lib/api/nodeFlows.ts
@@ -120,3 +120,41 @@ export interface NodeExecutionSummary {
duration?: number;
error?: string;
}
+
+/**
+ * 플로우 소스 테이블 정보 인터페이스
+ */
+export interface FlowSourceTableInfo {
+ sourceTable: string | null;
+ sourceNodeType: string | null;
+ sourceNodeId?: string;
+ displayName?: string;
+ message?: string;
+}
+
+/**
+ * 플로우 소스 테이블 조회
+ * 플로우의 첫 번째 소스 노드(tableSource, externalDBSource)에서 테이블명 추출
+ */
+export async function getFlowSourceTable(flowId: number): Promise {
+ try {
+ const response = await apiClient.get>(
+ `/dataflow/node-flows/${flowId}/source-table`,
+ );
+ if (response.data.success && response.data.data) {
+ return response.data.data;
+ }
+ return {
+ sourceTable: null,
+ sourceNodeType: null,
+ message: response.data.message || "소스 테이블 정보를 가져올 수 없습니다.",
+ };
+ } catch (error) {
+ console.error("플로우 소스 테이블 조회 실패:", error);
+ return {
+ sourceTable: null,
+ sourceNodeType: null,
+ message: "API 호출 중 오류가 발생했습니다.",
+ };
+ }
+}
diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts
index 944f7126..327cb87f 100644
--- a/frontend/lib/utils/buttonActions.ts
+++ b/frontend/lib/utils/buttonActions.ts
@@ -1864,6 +1864,84 @@ export class ButtonActionExecutor {
console.log(`✅ [handleUniversalFormModalTableSectionSave] 완료: ${resultMessage}`);
toast.success(`저장 완료: ${resultMessage}`);
+ // 🆕 저장 성공 후 제어 관리 실행 (다중 테이블 저장 시 소스 테이블과 일치하는 섹션만 실행)
+ if (config.enableDataflowControl && config.dataflowConfig?.flowConfig?.flowId) {
+ const flowId = config.dataflowConfig.flowConfig.flowId;
+ console.log("🎯 [handleUniversalFormModalTableSectionSave] 제어 관리 실행 시작:", { flowId });
+
+ try {
+ // 플로우 소스 테이블 조회
+ const { getFlowSourceTable } = await import("@/lib/api/nodeFlows");
+ const flowSourceInfo = await getFlowSourceTable(flowId);
+
+ console.log("📊 [handleUniversalFormModalTableSectionSave] 플로우 소스 테이블:", flowSourceInfo);
+
+ if (flowSourceInfo.sourceTable) {
+ // 각 섹션 확인하여 소스 테이블과 일치하는 섹션 찾기
+ let controlExecuted = false;
+
+ for (const [sectionId, sectionItems] of Object.entries(tableSectionData)) {
+ const sectionConfig = sections.find((s: any) => s.id === sectionId);
+ const sectionTargetTable = sectionConfig?.tableConfig?.saveConfig?.targetTable || tableName;
+
+ console.log(`🔍 [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} 테이블 비교:`, {
+ sectionTargetTable,
+ flowSourceTable: flowSourceInfo.sourceTable,
+ isMatch: sectionTargetTable === flowSourceInfo.sourceTable,
+ });
+
+ // 소스 테이블과 일치하는 섹션만 제어 실행
+ if (sectionTargetTable === flowSourceInfo.sourceTable && sectionItems.length > 0) {
+ console.log(
+ `✅ [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} → 플로우 소스 테이블 일치! 제어 실행`,
+ );
+
+ // 공통 필드 + 해당 섹션 데이터 병합하여 sourceData 생성
+ const sourceData = sectionItems.map((item: any) => ({
+ ...commonFieldsData,
+ ...item,
+ }));
+
+ console.log(
+ `📦 [handleUniversalFormModalTableSectionSave] 제어 전달 데이터: ${sourceData.length}건`,
+ sourceData[0],
+ );
+
+ // 제어 관리용 컨텍스트 생성
+ const controlContext: ButtonActionContext = {
+ ...context,
+ selectedRowsData: sourceData,
+ formData: commonFieldsData,
+ };
+
+ // 제어 관리 실행
+ await this.executeAfterSaveControl(config, controlContext);
+ controlExecuted = true;
+ break; // 첫 번째 매칭 섹션만 실행
+ }
+ }
+
+ // 매칭되는 섹션이 없으면 메인 테이블 확인
+ if (!controlExecuted && tableName === flowSourceInfo.sourceTable) {
+ console.log("✅ [handleUniversalFormModalTableSectionSave] 메인 테이블 일치! 공통 필드로 제어 실행");
+
+ const controlContext: ButtonActionContext = {
+ ...context,
+ selectedRowsData: [commonFieldsData],
+ formData: commonFieldsData,
+ };
+
+ await this.executeAfterSaveControl(config, controlContext);
+ }
+ } else {
+ console.log("⚠️ [handleUniversalFormModalTableSectionSave] 플로우 소스 테이블 없음 - 제어 스킵");
+ }
+ } catch (controlError) {
+ console.error("❌ [handleUniversalFormModalTableSectionSave] 제어 관리 실행 오류:", controlError);
+ // 제어 관리 실패는 저장 성공에 영향주지 않음
+ }
+ }
+
// 저장 성공 이벤트 발생
window.dispatchEvent(new CustomEvent("saveSuccess"));
window.dispatchEvent(new CustomEvent("refreshTable"));
From 417e1d297b52176aa7060af6bb22194deef71a05 Mon Sep 17 00:00:00 2001
From: kjs
Date: Wed, 31 Dec 2025 13:53:30 +0900
Subject: [PATCH 27/28] =?UTF-8?q?=ED=8F=BC=20=EC=A1=B0=EA=B1=B4=EB=B3=84?=
=?UTF-8?q?=20=EA=B3=84=EC=82=B0=EC=8B=9D=20=EC=84=A4=EC=A0=95=EA=B8=B0?=
=?UTF-8?q?=EB=8A=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../TableSectionRenderer.tsx | 58 ++-
.../modals/TableSectionSettingsModal.tsx | 474 ++++++++++++++++--
.../components/universal-form-modal/types.ts | 24 +
3 files changed, 505 insertions(+), 51 deletions(-)
diff --git a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx
index a1c0bd76..1242e1d2 100644
--- a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx
@@ -16,7 +16,13 @@ import { ItemSelectionModal } from "../modal-repeater-table/ItemSelectionModal";
import { RepeaterColumnConfig, CalculationRule } from "../modal-repeater-table/types";
// 타입 정의
-import { TableSectionConfig, TableColumnConfig, TableJoinCondition, FormDataState } from "./types";
+import {
+ TableSectionConfig,
+ TableColumnConfig,
+ TableJoinCondition,
+ FormDataState,
+ TableCalculationRule,
+} from "./types";
interface TableSectionRendererProps {
sectionId: string;
@@ -811,39 +817,69 @@ export function TableSectionRenderer({
});
}, [tableConfig.columns, dynamicSelectOptionsMap]);
- // 계산 규칙 변환
- const calculationRules: CalculationRule[] = (tableConfig.calculations || []).map(convertToCalculationRule);
+ // 원본 계산 규칙 (조건부 계산 포함)
+ const originalCalculationRules: TableCalculationRule[] = useMemo(
+ () => tableConfig.calculations || [],
+ [tableConfig.calculations],
+ );
- // 계산 로직
+ // 기본 계산 규칙 변환 (RepeaterTable용 - 조건부 계산이 없는 경우에 사용)
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const calculationRules: CalculationRule[] = originalCalculationRules.map(convertToCalculationRule);
+
+ // 조건부 계산 로직: 행의 조건 필드 값에 따라 적절한 계산식 선택
+ const getFormulaForRow = useCallback((rule: TableCalculationRule, row: Record): string => {
+ // 조건부 계산이 활성화된 경우
+ if (rule.conditionalCalculation?.enabled && rule.conditionalCalculation.conditionField) {
+ const conditionValue = row[rule.conditionalCalculation.conditionField];
+ // 조건값과 일치하는 규칙 찾기
+ const matchedRule = rule.conditionalCalculation.rules?.find((r) => r.conditionValue === conditionValue);
+ if (matchedRule) {
+ return matchedRule.formula;
+ }
+ // 일치하는 규칙이 없으면 기본 계산식 사용
+ if (rule.conditionalCalculation.defaultFormula) {
+ return rule.conditionalCalculation.defaultFormula;
+ }
+ }
+ // 조건부 계산이 비활성화되었거나 기본값이 없으면 원래 계산식 사용
+ return rule.formula;
+ }, []);
+
+ // 계산 로직 (조건부 계산 지원)
const calculateRow = useCallback(
(row: any): any => {
- if (calculationRules.length === 0) return row;
+ if (originalCalculationRules.length === 0) return row;
const updatedRow = { ...row };
- for (const rule of calculationRules) {
+ for (const rule of originalCalculationRules) {
try {
- let formula = rule.formula;
+ // 조건부 계산에 따라 적절한 계산식 선택
+ let formula = getFormulaForRow(rule, row);
+
+ if (!formula) continue;
+
const fieldMatches = formula.match(/[a-zA-Z_][a-zA-Z0-9_]*/g) || [];
const dependencies = rule.dependencies.length > 0 ? rule.dependencies : fieldMatches;
for (const dep of dependencies) {
- if (dep === rule.result) continue;
+ if (dep === rule.resultField) continue;
const value = parseFloat(row[dep]) || 0;
formula = formula.replace(new RegExp(`\\b${dep}\\b`, "g"), value.toString());
}
const result = new Function(`return ${formula}`)();
- updatedRow[rule.result] = result;
+ updatedRow[rule.resultField] = result;
} catch (error) {
console.error(`계산 오류 (${rule.formula}):`, error);
- updatedRow[rule.result] = 0;
+ updatedRow[rule.resultField] = 0;
}
}
return updatedRow;
},
- [calculationRules],
+ [originalCalculationRules, getFormulaForRow],
);
const calculateAll = useCallback(
diff --git a/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx
index d82db59b..037707ca 100644
--- a/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx
@@ -24,6 +24,8 @@ import {
TablePreFilter,
TableModalFilter,
TableCalculationRule,
+ ConditionalCalculationRule,
+ ConditionalCalculationConfig,
LookupOption,
LookupCondition,
ConditionalTableOption,
@@ -52,6 +54,429 @@ const HelpText = ({ children }: { children: React.ReactNode }) => (
{children}
);
+// 계산 규칙 편집 컴포넌트 (조건부 계산 지원)
+interface CalculationRuleEditorProps {
+ calc: TableCalculationRule;
+ index: number;
+ columns: TableColumnConfig[];
+ sourceTableName?: string; // 소스 테이블명 추가
+ onUpdate: (updates: Partial) => void;
+ onRemove: () => void;
+}
+
+const CalculationRuleEditor: React.FC = ({
+ calc,
+ index,
+ columns,
+ sourceTableName,
+ onUpdate,
+ onRemove,
+}) => {
+ const [categoryOptions, setCategoryOptions] = useState<{ value: string; label: string }[]>([]);
+ const [loadingOptions, setLoadingOptions] = useState(false);
+ const [categoryColumns, setCategoryColumns] = useState>({});
+
+ // 조건부 계산 활성화 여부
+ const isConditionalEnabled = calc.conditionalCalculation?.enabled ?? false;
+
+ // 소스 테이블의 카테고리 컬럼 정보 로드
+ useEffect(() => {
+ const loadCategoryColumns = async () => {
+ console.log("[CalculationRuleEditor] sourceTableName:", sourceTableName);
+
+ if (!sourceTableName) {
+ setCategoryColumns({});
+ return;
+ }
+
+ try {
+ const { getCategoryColumns } = await import("@/lib/api/tableCategoryValue");
+ const result = await getCategoryColumns(sourceTableName);
+ console.log("[CalculationRuleEditor] getCategoryColumns 결과:", result);
+
+ if (result && result.success && Array.isArray(result.data)) {
+ const categoryMap: Record = {};
+ result.data.forEach((col: any) => {
+ // API 응답은 camelCase (columnName)
+ const colName = col.columnName || col.column_name;
+ if (colName) {
+ categoryMap[colName] = true;
+ }
+ });
+ console.log("[CalculationRuleEditor] categoryMap:", categoryMap);
+ setCategoryColumns(categoryMap);
+ }
+ } catch (error) {
+ console.error("카테고리 컬럼 조회 실패:", error);
+ }
+ };
+
+ loadCategoryColumns();
+ }, [sourceTableName]);
+
+ // 조건 필드가 선택되었을 때 옵션 로드 (테이블 타입 관리의 카테고리 기준)
+ useEffect(() => {
+ const loadConditionOptions = async () => {
+ if (!isConditionalEnabled || !calc.conditionalCalculation?.conditionField) {
+ setCategoryOptions([]);
+ return;
+ }
+
+ const conditionField = calc.conditionalCalculation.conditionField;
+
+ // 소스 필드(sourceField)가 있으면 해당 필드명 사용, 없으면 field명 사용
+ const selectedColumn = columns.find((col) => col.field === conditionField);
+ const actualFieldName = selectedColumn?.sourceField || conditionField;
+
+ console.log("[loadConditionOptions] 조건 필드:", {
+ conditionField,
+ actualFieldName,
+ sourceTableName,
+ categoryColumnsKeys: Object.keys(categoryColumns),
+ isCategoryColumn: categoryColumns[actualFieldName],
+ });
+
+ // 소스 테이블에서 해당 컬럼이 카테고리 타입인지 확인
+ if (sourceTableName && categoryColumns[actualFieldName]) {
+ try {
+ setLoadingOptions(true);
+ const { getCategoryValues } = await import("@/lib/api/tableCategoryValue");
+ console.log("[loadConditionOptions] getCategoryValues 호출:", sourceTableName, actualFieldName);
+ const result = await getCategoryValues(sourceTableName, actualFieldName, false);
+ console.log("[loadConditionOptions] getCategoryValues 결과:", result);
+ if (result && result.success && Array.isArray(result.data)) {
+ const options = result.data.map((item: any) => ({
+ // API 응답은 camelCase (valueCode, valueLabel)
+ value: item.valueCode || item.value_code || item.value,
+ label: item.valueLabel || item.displayLabel || item.display_label || item.label || item.valueCode || item.value_code || item.value,
+ }));
+ console.log("[loadConditionOptions] 매핑된 옵션:", options);
+ setCategoryOptions(options);
+ } else {
+ setCategoryOptions([]);
+ }
+ } catch (error) {
+ console.error("카테고리 값 로드 실패:", error);
+ setCategoryOptions([]);
+ } finally {
+ setLoadingOptions(false);
+ }
+ return;
+ }
+
+ // 카테고리 키가 직접 설정된 경우 (저장된 값)
+ const categoryKey = calc.conditionalCalculation?.conditionFieldCategoryKey;
+ if (categoryKey) {
+ try {
+ setLoadingOptions(true);
+ const [tableName, columnName] = categoryKey.split(".");
+ if (tableName && columnName) {
+ const { getCategoryValues } = await import("@/lib/api/tableCategoryValue");
+ const result = await getCategoryValues(tableName, columnName, false);
+ if (result && result.success && Array.isArray(result.data)) {
+ setCategoryOptions(
+ result.data.map((item: any) => ({
+ // API 응답은 camelCase (valueCode, valueLabel)
+ value: item.valueCode || item.value_code || item.value,
+ label: item.valueLabel || item.displayLabel || item.display_label || item.label || item.valueCode || item.value_code || item.value,
+ }))
+ );
+ }
+ }
+ } catch (error) {
+ console.error("카테고리 옵션 로드 실패:", error);
+ } finally {
+ setLoadingOptions(false);
+ }
+ return;
+ }
+
+ // 그 외 타입은 옵션 없음 (직접 입력)
+ setCategoryOptions([]);
+ };
+
+ loadConditionOptions();
+ }, [isConditionalEnabled, calc.conditionalCalculation?.conditionField, calc.conditionalCalculation?.conditionFieldCategoryKey, columns, sourceTableName, categoryColumns]);
+
+ // 조건부 계산 토글
+ const toggleConditionalCalculation = (enabled: boolean) => {
+ onUpdate({
+ conditionalCalculation: enabled
+ ? {
+ enabled: true,
+ conditionField: "",
+ conditionFieldType: "static",
+ rules: [],
+ defaultFormula: calc.formula || "",
+ }
+ : undefined,
+ });
+ };
+
+ // 조건 필드 변경
+ const updateConditionField = (field: string) => {
+ const selectedColumn = columns.find((col) => col.field === field);
+ const actualFieldName = selectedColumn?.sourceField || field;
+
+ // 컬럼의 타입과 옵션 확인 (테이블 타입 관리의 카테고리 기준)
+ let conditionFieldType: "static" | "code" | "table" = "static";
+ let conditionFieldCategoryKey = "";
+
+ // 소스 테이블에서 해당 컬럼이 카테고리 타입인지 확인
+ if (sourceTableName && categoryColumns[actualFieldName]) {
+ conditionFieldType = "code";
+ conditionFieldCategoryKey = `${sourceTableName}.${actualFieldName}`;
+ }
+
+ onUpdate({
+ conditionalCalculation: {
+ ...calc.conditionalCalculation!,
+ conditionField: field,
+ conditionFieldType,
+ conditionFieldCategoryKey,
+ rules: [], // 필드 변경 시 규칙 초기화
+ },
+ });
+ };
+
+ // 조건 규칙 추가
+ const addConditionRule = () => {
+ const newRule: ConditionalCalculationRule = {
+ conditionValue: "",
+ formula: calc.formula || "",
+ };
+ onUpdate({
+ conditionalCalculation: {
+ ...calc.conditionalCalculation!,
+ rules: [...(calc.conditionalCalculation?.rules || []), newRule],
+ },
+ });
+ };
+
+ // 조건 규칙 업데이트
+ const updateConditionRule = (ruleIndex: number, updates: Partial) => {
+ const newRules = [...(calc.conditionalCalculation?.rules || [])];
+ newRules[ruleIndex] = { ...newRules[ruleIndex], ...updates };
+ onUpdate({
+ conditionalCalculation: {
+ ...calc.conditionalCalculation!,
+ rules: newRules,
+ },
+ });
+ };
+
+ // 조건 규칙 삭제
+ const removeConditionRule = (ruleIndex: number) => {
+ onUpdate({
+ conditionalCalculation: {
+ ...calc.conditionalCalculation!,
+ rules: (calc.conditionalCalculation?.rules || []).filter((_, i) => i !== ruleIndex),
+ },
+ });
+ };
+
+ // 기본 계산식 업데이트
+ const updateDefaultFormula = (formula: string) => {
+ onUpdate({
+ conditionalCalculation: {
+ ...calc.conditionalCalculation!,
+ defaultFormula: formula,
+ },
+ });
+ };
+
+ // 조건 필드로 사용 가능한 컬럼 (모든 컬럼)
+ const availableColumns = columns.filter((col) => col.field);
+
+ return (
+
+ {/* 기본 계산 규칙 */}
+
+ onUpdate({ resultField: value })}
+ >
+
+
+
+
+ {columns.length === 0 ? (
+
+ 컬럼 설정에서 먼저 컬럼을 추가하세요
+
+ ) : (
+ columns
+ .filter((col) => col.field)
+ .map((col, idx) => (
+
+ {col.label || col.field}
+
+ ))
+ )}
+
+
+ =
+ onUpdate({ formula: e.target.value })}
+ placeholder="수식 (예: qty * unit_price)"
+ className="h-8 text-xs flex-1"
+ disabled={isConditionalEnabled}
+ />
+
+
+
+
+
+ {/* 조건부 계산 토글 */}
+
+
+
+ 조건부 계산 활성화
+
+ {availableColumns.length === 0 && !isConditionalEnabled && (
+
+ (컬럼 설정에서 먼저 컬럼을 추가하세요)
+
+ )}
+
+
+ {/* 조건부 계산 설정 */}
+ {isConditionalEnabled && (
+
+ {/* 조건 필드 선택 */}
+
+ 조건 필드:
+
+
+
+
+
+ {availableColumns.length === 0 ? (
+
+ 컬럼이 없습니다
+
+ ) : (
+ availableColumns.map((col, idx) => {
+ // 소스 필드명으로 카테고리 여부 확인
+ const actualFieldName = col.sourceField || col.field;
+ const isCategoryColumn = categoryColumns[actualFieldName];
+ return (
+
+ {col.label || col.field} {isCategoryColumn ? "(카테고리)" : `(${col.type})`}
+
+ );
+ })
+ )}
+
+
+
+
+ {/* 조건별 계산식 목록 */}
+ {calc.conditionalCalculation?.conditionField && (
+
+
+
조건별 계산식:
+
+
+ 조건 추가
+
+
+
+ {(calc.conditionalCalculation?.rules || []).map((rule, ruleIndex) => (
+
+ {/* 조건값 선택 */}
+ {categoryOptions.length > 0 ? (
+
+ updateConditionRule(ruleIndex, { conditionValue: value })
+ }
+ >
+
+
+
+
+ {categoryOptions.map((opt, optIdx) => (
+
+ {opt.label}
+
+ ))}
+
+
+ ) : (
+
+ updateConditionRule(ruleIndex, { conditionValue: e.target.value })
+ }
+ placeholder="조건값"
+ className="h-7 text-xs w-[120px]"
+ />
+ )}
+ →
+
+ updateConditionRule(ruleIndex, { formula: e.target.value })
+ }
+ placeholder="계산식"
+ className="h-7 text-xs flex-1"
+ />
+ removeConditionRule(ruleIndex)}
+ className="h-7 w-7 p-0 text-destructive hover:text-destructive"
+ >
+
+
+
+ ))}
+
+ {/* 기본 계산식 */}
+
+
+ (기본값)
+
+ →
+ updateDefaultFormula(e.target.value)}
+ placeholder="기본 계산식 (조건 미해당 시)"
+ className="h-7 text-xs flex-1"
+ />
+
+
+ )}
+
+ {loadingOptions && (
+
옵션 로딩 중...
+ )}
+
+ )}
+
+ );
+};
+
// 옵션 소스 설정 컴포넌트 (검색 가능한 Combobox)
interface OptionSourceConfigProps {
optionSource: {
@@ -3034,46 +3459,15 @@ export function TableSectionSettingsModal({
{(tableConfig.calculations || []).map((calc, index) => (
-
- updateCalculation(index, { resultField: value })}
- >
-
-
-
-
- {(tableConfig.columns || []).length === 0 ? (
-
- 컬럼 설정에서 먼저 컬럼을 추가하세요
-
- ) : (
- (tableConfig.columns || [])
- .filter((col) => col.field) // 빈 필드명 제외
- .map((col, idx) => (
-
- {col.label || col.field}
-
- ))
- )}
-
-
- =
- updateCalculation(index, { formula: e.target.value })}
- placeholder="수식 (예: quantity * unit_price)"
- className="h-8 text-xs flex-1"
- />
- removeCalculation(index)}
- className="h-8 w-8 p-0 text-destructive hover:text-destructive"
- >
-
-
-
+
updateCalculation(index, updates)}
+ onRemove={() => removeCalculation(index)}
+ />
))}
diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts
index a07feed6..3d8cac6c 100644
--- a/frontend/lib/registry/components/universal-form-modal/types.ts
+++ b/frontend/lib/registry/components/universal-form-modal/types.ts
@@ -604,6 +604,27 @@ export interface ColumnModeConfig {
valueMapping: ValueMappingConfig; // 이 모드의 값 매핑
}
+/**
+ * 조건부 계산 규칙
+ * 특정 필드 값에 따라 다른 계산식 적용
+ */
+export interface ConditionalCalculationRule {
+ conditionValue: string; // 조건 값 (예: "국내", "해외")
+ formula: string; // 해당 조건일 때 사용할 계산식
+}
+
+/**
+ * 조건부 계산 설정
+ */
+export interface ConditionalCalculationConfig {
+ enabled: boolean; // 조건부 계산 활성화 여부
+ conditionField: string; // 조건 기준 필드 (예: "sales_type")
+ conditionFieldType?: "static" | "code" | "table"; // 조건 필드의 옵션 타입
+ conditionFieldCategoryKey?: string; // 카테고리 키 (예: "sales_order_mng.sales_type")
+ rules: ConditionalCalculationRule[]; // 조건별 계산 규칙
+ defaultFormula?: string; // 조건에 해당하지 않을 때 기본 계산식
+}
+
/**
* 테이블 계산 규칙
* 다른 컬럼 값을 기반으로 자동 계산
@@ -612,6 +633,9 @@ export interface TableCalculationRule {
resultField: string; // 결과를 저장할 필드
formula: string; // 계산 공식 (예: "quantity * unit_price")
dependencies: string[]; // 의존하는 필드들
+
+ // 조건부 계산 (선택사항)
+ conditionalCalculation?: ConditionalCalculationConfig;
}
// 다중 행 저장 설정
From eb868965df4372656276edcf5dfdc7d18966b6b4 Mon Sep 17 00:00:00 2001
From: kjs
Date: Wed, 31 Dec 2025 14:17:39 +0900
Subject: [PATCH 28/28] =?UTF-8?q?=EC=A1=B0=EA=B1=B4=EB=B6=80=20=EA=B3=84?=
=?UTF-8?q?=EC=82=B0=EC=8B=9D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../modal-repeater-table/RepeaterTable.tsx | 41 ++++++++++---------
.../components/modal-repeater-table/types.ts | 1 +
.../TableSectionRenderer.tsx | 1 +
.../modals/TableSectionSettingsModal.tsx | 23 ++++-------
.../components/universal-form-modal/types.ts | 1 +
5 files changed, 33 insertions(+), 34 deletions(-)
diff --git a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx
index 70b15e7d..995ebccb 100644
--- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx
+++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx
@@ -1,6 +1,6 @@
"use client";
-import React, { useState, useEffect, useRef } from "react";
+import React, { useState, useEffect, useRef, useMemo } from "react";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -84,6 +84,9 @@ export function RepeaterTable({
onSelectionChange,
equalizeWidthsTrigger,
}: RepeaterTableProps) {
+ // 히든 컬럼을 제외한 표시 가능한 컬럼만 필터링
+ const visibleColumns = useMemo(() => columns.filter((col) => !col.hidden), [columns]);
+
// 컨테이너 ref - 실제 너비 측정용
const containerRef = useRef(null);
@@ -145,7 +148,7 @@ export function RepeaterTable({
// 컬럼 너비 상태 관리
const [columnWidths, setColumnWidths] = useState>(() => {
const widths: Record = {};
- columns.forEach((col) => {
+ columns.filter((col) => !col.hidden).forEach((col) => {
widths[col.field] = col.width ? parseInt(col.width) : 120;
});
return widths;
@@ -154,11 +157,11 @@ export function RepeaterTable({
// 기본 너비 저장 (리셋용)
const defaultWidths = React.useMemo(() => {
const widths: Record = {};
- columns.forEach((col) => {
+ visibleColumns.forEach((col) => {
widths[col.field] = col.width ? parseInt(col.width) : 120;
});
return widths;
- }, [columns]);
+ }, [visibleColumns]);
// 리사이즈 상태
const [resizing, setResizing] = useState<{ field: string; startX: number; startWidth: number } | null>(null);
@@ -206,7 +209,7 @@ export function RepeaterTable({
// 해당 컬럼의 가장 긴 글자 너비 계산
// equalWidth: 균등 분배 시 너비 (값이 없는 컬럼의 최소값으로 사용)
const calculateColumnContentWidth = (field: string, equalWidth: number): number => {
- const column = columns.find((col) => col.field === field);
+ const column = visibleColumns.find((col) => col.field === field);
if (!column) return equalWidth;
// 날짜 필드는 110px (yyyy-MM-dd)
@@ -257,7 +260,7 @@ export function RepeaterTable({
// 헤더 더블클릭: 해당 컬럼만 글자 너비에 맞춤
const handleDoubleClick = (field: string) => {
const availableWidth = getAvailableWidth();
- const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length));
+ const equalWidth = Math.max(60, Math.floor(availableWidth / visibleColumns.length));
const contentWidth = calculateColumnContentWidth(field, equalWidth);
setColumnWidths((prev) => ({
...prev,
@@ -268,10 +271,10 @@ export function RepeaterTable({
// 균등 분배: 컬럼 수로 테이블 너비를 균등 분배
const applyEqualizeWidths = () => {
const availableWidth = getAvailableWidth();
- const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length));
+ const equalWidth = Math.max(60, Math.floor(availableWidth / visibleColumns.length));
const newWidths: Record = {};
- columns.forEach((col) => {
+ visibleColumns.forEach((col) => {
newWidths[col.field] = equalWidth;
});
@@ -280,15 +283,15 @@ export function RepeaterTable({
// 자동 맞춤: 각 컬럼을 글자 너비에 맞추고, 컨테이너보다 작으면 남는 공간 분배
const applyAutoFitWidths = () => {
- if (columns.length === 0) return;
+ if (visibleColumns.length === 0) return;
// 균등 분배 너비 계산 (값이 없는 컬럼의 최소값)
const availableWidth = getAvailableWidth();
- const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length));
+ const equalWidth = Math.max(60, Math.floor(availableWidth / visibleColumns.length));
// 1. 각 컬럼의 글자 너비 계산 (값이 없으면 균등 분배 너비 사용)
const newWidths: Record = {};
- columns.forEach((col) => {
+ visibleColumns.forEach((col) => {
newWidths[col.field] = calculateColumnContentWidth(col.field, equalWidth);
});
@@ -298,8 +301,8 @@ export function RepeaterTable({
// 3. 컨테이너보다 작으면 남는 공간을 균등 분배 (테이블 꽉 참 유지)
if (totalContentWidth < availableWidth) {
const extraSpace = availableWidth - totalContentWidth;
- const extraPerColumn = Math.floor(extraSpace / columns.length);
- columns.forEach((col) => {
+ const extraPerColumn = Math.floor(extraSpace / visibleColumns.length);
+ visibleColumns.forEach((col) => {
newWidths[col.field] += extraPerColumn;
});
}
@@ -311,7 +314,7 @@ export function RepeaterTable({
// 초기 마운트 시 균등 분배 적용
useEffect(() => {
if (initializedRef.current) return;
- if (!containerRef.current || columns.length === 0) return;
+ if (!containerRef.current || visibleColumns.length === 0) return;
const timer = setTimeout(() => {
applyEqualizeWidths();
@@ -319,7 +322,7 @@ export function RepeaterTable({
}, 100);
return () => clearTimeout(timer);
- }, [columns]);
+ }, [visibleColumns]);
// 트리거 감지: 1=균등분배, 2=자동맞춤
useEffect(() => {
@@ -357,7 +360,7 @@ export function RepeaterTable({
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
- }, [resizing, columns, data]);
+ }, [resizing, visibleColumns, data]);
// 데이터 변경 감지 (필요시 활성화)
// useEffect(() => {
@@ -531,7 +534,7 @@ export function RepeaterTable({
className={cn("border-gray-400", isIndeterminate && "data-[state=checked]:bg-primary")}
/>
- {columns.map((col) => {
+ {visibleColumns.map((col) => {
const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0;
const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId;
const activeOption = hasDynamicSource
@@ -631,7 +634,7 @@ export function RepeaterTable({
{data.length === 0 ? (
추가된 항목이 없습니다
@@ -672,7 +675,7 @@ export function RepeaterTable({
/>
{/* 데이터 컬럼들 */}
- {columns.map((col) => (
+ {visibleColumns.map((col) => (
= ({
// 소스 테이블의 카테고리 컬럼 정보 로드
useEffect(() => {
const loadCategoryColumns = async () => {
- console.log("[CalculationRuleEditor] sourceTableName:", sourceTableName);
-
if (!sourceTableName) {
setCategoryColumns({});
return;
@@ -92,7 +90,6 @@ const CalculationRuleEditor: React.FC = ({
try {
const { getCategoryColumns } = await import("@/lib/api/tableCategoryValue");
const result = await getCategoryColumns(sourceTableName);
- console.log("[CalculationRuleEditor] getCategoryColumns 결과:", result);
if (result && result.success && Array.isArray(result.data)) {
const categoryMap: Record = {};
@@ -103,7 +100,6 @@ const CalculationRuleEditor: React.FC = ({
categoryMap[colName] = true;
}
});
- console.log("[CalculationRuleEditor] categoryMap:", categoryMap);
setCategoryColumns(categoryMap);
}
} catch (error) {
@@ -128,29 +124,18 @@ const CalculationRuleEditor: React.FC = ({
const selectedColumn = columns.find((col) => col.field === conditionField);
const actualFieldName = selectedColumn?.sourceField || conditionField;
- console.log("[loadConditionOptions] 조건 필드:", {
- conditionField,
- actualFieldName,
- sourceTableName,
- categoryColumnsKeys: Object.keys(categoryColumns),
- isCategoryColumn: categoryColumns[actualFieldName],
- });
-
// 소스 테이블에서 해당 컬럼이 카테고리 타입인지 확인
if (sourceTableName && categoryColumns[actualFieldName]) {
try {
setLoadingOptions(true);
const { getCategoryValues } = await import("@/lib/api/tableCategoryValue");
- console.log("[loadConditionOptions] getCategoryValues 호출:", sourceTableName, actualFieldName);
const result = await getCategoryValues(sourceTableName, actualFieldName, false);
- console.log("[loadConditionOptions] getCategoryValues 결과:", result);
if (result && result.success && Array.isArray(result.data)) {
const options = result.data.map((item: any) => ({
// API 응답은 camelCase (valueCode, valueLabel)
value: item.valueCode || item.value_code || item.value,
label: item.valueLabel || item.displayLabel || item.display_label || item.label || item.valueCode || item.value_code || item.value,
}));
- console.log("[loadConditionOptions] 매핑된 옵션:", options);
setCategoryOptions(options);
} else {
setCategoryOptions([]);
@@ -1094,6 +1079,14 @@ function ColumnSettingItem({
/>
필수
+
+ onUpdate({ hidden: checked })}
+ className="scale-75"
+ />
+ 히든
+