From aa283d11dac7406aaac9e9c3021fa11fca3305ee Mon Sep 17 00:00:00 2001
From: dohyeons
Date: Wed, 24 Dec 2025 09:48:37 +0900
Subject: [PATCH 01/19] =?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/19] =?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/19] =?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/19] =?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/19] =?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/19] =?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 (
+
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 08/19] =?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 09/19] =?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 10/19] =?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 11/19] =?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 12/19] =?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 13/19] =?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 14/19] =?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 15/19] =?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 16/19] =?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 17/19] =?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 18/19] =?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 19/19] =?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"
>
-