From 109380b9e5ea1b19936c0cb2fce18b51518f9d59 Mon Sep 17 00:00:00 2001
From: leeheejin
Date: Mon, 15 Dec 2025 17:01:04 +0900
Subject: [PATCH 01/95] =?UTF-8?q?=EC=9D=B4=EC=A0=9C=20=EB=94=94=EB=B9=84?=
=?UTF-8?q?=EC=97=90=20=ED=95=9C=EA=B8=80=EB=A1=9C=20=EC=B6=9C=EB=B0=9C?=
=?UTF-8?q?=EC=A7=80=20=EB=AA=A9=EC=A0=81=EC=A7=80=20=EC=A0=80=EC=9E=A5?=
=?UTF-8?q?=EB=90=98=EB=8F=84=EB=A1=9D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../LocationSwapSelectorComponent.tsx | 6 +++---
.../lib/registry/components/location-swap-selector/index.ts | 6 +++---
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx
index 7a693ad5..88e9002a 100644
--- a/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx
+++ b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx
@@ -107,10 +107,10 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
const dbTableName = config.dbTableName || "vehicles";
const dbKeyField = config.dbKeyField || "user_id";
- // 기본 옵션 (포항/광양)
+ // 기본 옵션 (포항/광양) - 한글로 저장
const DEFAULT_OPTIONS: LocationOption[] = [
- { value: "pohang", label: "포항" },
- { value: "gwangyang", label: "광양" },
+ { value: "포항", label: "포항" },
+ { value: "광양", label: "광양" },
];
// 상태
diff --git a/frontend/lib/registry/components/location-swap-selector/index.ts b/frontend/lib/registry/components/location-swap-selector/index.ts
index c4c30418..7f7447cf 100644
--- a/frontend/lib/registry/components/location-swap-selector/index.ts
+++ b/frontend/lib/registry/components/location-swap-selector/index.ts
@@ -26,9 +26,9 @@ export const LocationSwapSelectorDefinition = createComponentDefinition({
labelField: "location_name", // 표시 필드
codeCategory: "", // 코드 관리 카테고리 (type이 "code"일 때)
staticOptions: [
- { value: "pohang", label: "포항" },
- { value: "gwangyang", label: "광양" },
- ], // 정적 옵션 (type이 "static"일 때)
+ { value: "포항", label: "포항" },
+ { value: "광양", label: "광양" },
+ ], // 정적 옵션 (type이 "static"일 때) - 한글로 저장
},
// 필드 매핑
departureField: "departure", // 출발지 저장 필드
From aa283d11dac7406aaac9e9c3021fa11fca3305ee Mon Sep 17 00:00:00 2001
From: dohyeons
Date: Wed, 24 Dec 2025 09:48:37 +0900
Subject: [PATCH 02/95] =?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 03/95] =?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 04/95] =?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 05/95] =?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 06/95] =?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 07/95] =?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 10/95] =?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 11/95] =?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 12/95] =?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 13/95] =?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 14/95] =?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 15/95] =?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 16/95] =?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 17/95] =?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 18/95] =?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 19/95] =?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 20/95] =?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 21/95] =?UTF-8?q?=EB=B6=84=ED=95=A0=ED=8C=A8=EB=84=90?=
=?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B2=84=ED=8A=BC=20=EB=B9=84=ED=99=9C?=
=?UTF-8?q?=EC=84=B1=ED=99=94=EA=B8=B0=EB=8A=A5=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../button-primary/ButtonPrimaryComponent.tsx | 81 ++++++++++++++++---
.../SplitPanelLayoutComponent.tsx | 20 ++---
2 files changed, 81 insertions(+), 20 deletions(-)
diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx
index d79d926f..f311c035 100644
--- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx
+++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx
@@ -296,6 +296,32 @@ export const ButtonPrimaryComponent: React.FC = ({
return false;
}, [component.componentConfig?.action, formData, vehicleStatus, statusLoading, component.label]);
+ // 🆕 modalDataStore에서 선택된 데이터 확인 (분할 패널 등에서 저장됨)
+ const [modalStoreData, setModalStoreData] = useState>({});
+
+ // modalDataStore 상태 구독 (실시간 업데이트)
+ useEffect(() => {
+ const actionConfig = component.componentConfig?.action;
+ if (!actionConfig?.requireRowSelection) return;
+
+ // 동적 import로 modalDataStore 구독
+ let unsubscribe: (() => void) | undefined;
+
+ import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
+ // 초기값 설정
+ setModalStoreData(useModalDataStore.getState().dataRegistry);
+
+ // 상태 변경 구독
+ unsubscribe = useModalDataStore.subscribe((state) => {
+ setModalStoreData(state.dataRegistry);
+ });
+ });
+
+ return () => {
+ unsubscribe?.();
+ };
+ }, [component.componentConfig?.action?.requireRowSelection]);
+
// 🆕 행 선택 기반 비활성화 조건 계산
const isRowSelectionDisabled = useMemo(() => {
const actionConfig = component.componentConfig?.action;
@@ -311,43 +337,76 @@ export const ButtonPrimaryComponent: React.FC = ({
// 선택된 데이터 확인
let hasSelection = false;
let selectionCount = 0;
+ let selectionSource = "";
- // 1. 자동 감지 모드 또는 특정 소스 모드
+ // 1. 자동 감지 모드 또는 테이블 리스트 모드
if (rowSelectionSource === "auto" || rowSelectionSource === "tableList") {
// TableList에서 선택된 행 확인 (props로 전달됨)
if (selectedRowsData && selectedRowsData.length > 0) {
hasSelection = true;
selectionCount = selectedRowsData.length;
+ selectionSource = "tableList (selectedRowsData)";
}
// 또는 selectedRows prop 확인
else if (selectedRows && selectedRows.length > 0) {
hasSelection = true;
selectionCount = selectedRows.length;
+ selectionSource = "tableList (selectedRows)";
}
}
+ // 2. 분할 패널 좌측 선택 데이터 확인
if (rowSelectionSource === "auto" || rowSelectionSource === "splitPanelLeft") {
- // 분할 패널 좌측 선택 데이터 확인
- if (!hasSelection && splitPanelContext?.selectedLeftData) {
- hasSelection = true;
- selectionCount = 1;
+ // SplitPanelContext에서 확인
+ if (splitPanelContext?.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0) {
+ if (!hasSelection) {
+ hasSelection = true;
+ selectionCount = 1;
+ selectionSource = "splitPanelLeft (context)";
+ }
+ }
+
+ // 🆕 modalDataStore에서도 확인 (SplitPanelLayoutComponent에서 저장)
+ if (!hasSelection && Object.keys(modalStoreData).length > 0) {
+ // modalDataStore에서 데이터가 있는지 확인
+ for (const [sourceId, items] of Object.entries(modalStoreData)) {
+ if (items && items.length > 0) {
+ hasSelection = true;
+ selectionCount = items.length;
+ selectionSource = `modalDataStore (${sourceId})`;
+ break;
+ }
+ }
}
}
+ // 3. 플로우 위젯 선택 데이터 확인
if (rowSelectionSource === "auto" || rowSelectionSource === "flowWidget") {
// 플로우 위젯 선택 데이터 확인
if (!hasSelection && flowSelectedData && flowSelectedData.length > 0) {
hasSelection = true;
selectionCount = flowSelectedData.length;
+ selectionSource = "flowWidget";
}
}
+ // 디버깅 로그
+ console.log("🔍 [ButtonPrimary] 행 선택 체크:", component.label, {
+ rowSelectionSource,
+ hasSelection,
+ selectionCount,
+ selectionSource,
+ hasSplitPanelContext: !!splitPanelContext,
+ selectedLeftData: splitPanelContext?.selectedLeftData,
+ selectedRowsData: selectedRowsData?.length,
+ selectedRows: selectedRows?.length,
+ flowSelectedData: flowSelectedData?.length,
+ modalStoreDataKeys: Object.keys(modalStoreData),
+ });
+
// 선택된 데이터가 없으면 비활성화
if (!hasSelection) {
- console.log("🚫 [ButtonPrimary] 행 선택 필요 → 비활성화:", component.label, {
- rowSelectionSource,
- hasSelection,
- });
+ console.log("🚫 [ButtonPrimary] 행 선택 필요 → 비활성화:", component.label);
return true;
}
@@ -362,7 +421,7 @@ export const ButtonPrimaryComponent: React.FC = ({
console.log("✅ [ButtonPrimary] 행 선택 조건 충족:", component.label, {
selectionCount,
- rowSelectionSource,
+ selectionSource,
});
return false;
}, [
@@ -372,6 +431,8 @@ export const ButtonPrimaryComponent: React.FC = ({
selectedRowsData,
splitPanelContext?.selectedLeftData,
flowSelectedData,
+ splitPanelContext,
+ modalStoreData,
]);
// 확인 다이얼로그 상태
diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx
index ef91a23d..bfb26c90 100644
--- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx
+++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx
@@ -2030,14 +2030,14 @@ export const SplitPanelLayoutComponent: React.FC
className="border-border flex flex-shrink-0 flex-col border-r"
>
-
@@ -2521,14 +2521,14 @@ export const SplitPanelLayoutComponent: React.FC
className="flex flex-shrink-0 flex-col"
>
-
From 58233e51de41f3aaa7bbd5ee38eb5f1f5d5d22a3 Mon Sep 17 00:00:00 2001
From: DDD1542
Date: Tue, 30 Dec 2025 15:28:05 +0900
Subject: [PATCH 22/95] =?UTF-8?q?=EA=B0=81=EA=B0=81=20=EB=B3=84=EB=8F=84?=
=?UTF-8?q?=20TSX=20=EB=B3=91=ED=95=A9=20=EB=B0=8F=20=ED=9A=8C=EC=82=AC?=
=?UTF-8?q?=EC=84=A0=ED=83=9D=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/controllers/authController.ts | 111 +-
backend-node/src/routes/authRoutes.ts | 6 +
backend-node/src/services/adminService.ts | 6 +-
frontend/app/(main)/admin/menu/page.tsx | 1131 +++++++++++++++-
frontend/app/(main)/admin/monitoring/page.tsx | 284 ++++-
frontend/app/(main)/admin/page.tsx | 3 +-
.../dashboardList/DashboardListClient.tsx | 449 -------
.../screenMng/dashboardList/[id]/page.tsx} | 35 +-
.../dashboardList/edit/[id]/page.tsx | 23 -
.../screenMng/dashboardList/new/page.tsx | 12 -
.../admin/screenMng/dashboardList/page.tsx | 445 ++++++-
.../(main)/admin/systemMng/i18nList/page.tsx | 815 +++++++++++-
.../[companyCode]/departments/page.tsx | 111 +-
.../(main)/admin/userMng/companyList/page.tsx | 86 +-
.../admin/userMng/rolesList/[id]/page.tsx | 329 ++++-
.../(main)/admin/userMng/rolesList/page.tsx | 337 ++++-
.../admin/userMng/userAuthList/page.tsx | 156 ++-
.../(main)/admin/userMng/userMngList/page.tsx | 166 ++-
frontend/app/(main)/main/page.tsx | 1 -
.../components/admin/CompanyManagement.tsx | 93 --
frontend/components/admin/CompanySwitcher.tsx | 195 +++
frontend/components/admin/MenuManagement.tsx | 1136 -----------------
.../components/admin/MonitoringDashboard.tsx | 288 -----
frontend/components/admin/MultiLang.tsx | 859 -------------
.../components/admin/RoleDetailManagement.tsx | 345 -----
frontend/components/admin/RoleManagement.tsx | 335 -----
.../components/admin/UserAuthManagement.tsx | 157 ---
frontend/components/admin/UserManagement.tsx | 176 ---
.../admin/department/DepartmentManagement.tsx | 117 --
frontend/components/layout/AppLayout.tsx | 108 +-
frontend/contexts/MenuContext.tsx | 6 +-
frontend/hooks/useAuth.ts | 56 +
frontend/lib/api/menu.ts | 4 +-
33 files changed, 4326 insertions(+), 4055 deletions(-)
delete mode 100644 frontend/app/(main)/admin/screenMng/dashboardList/DashboardListClient.tsx
rename frontend/{components/admin/dashboard/DashboardDesigner.tsx => app/(main)/admin/screenMng/dashboardList/[id]/page.tsx} (95%)
delete mode 100644 frontend/app/(main)/admin/screenMng/dashboardList/edit/[id]/page.tsx
delete mode 100644 frontend/app/(main)/admin/screenMng/dashboardList/new/page.tsx
delete mode 100644 frontend/components/admin/CompanyManagement.tsx
create mode 100644 frontend/components/admin/CompanySwitcher.tsx
delete mode 100644 frontend/components/admin/MenuManagement.tsx
delete mode 100644 frontend/components/admin/MonitoringDashboard.tsx
delete mode 100644 frontend/components/admin/MultiLang.tsx
delete mode 100644 frontend/components/admin/RoleDetailManagement.tsx
delete mode 100644 frontend/components/admin/RoleManagement.tsx
delete mode 100644 frontend/components/admin/UserAuthManagement.tsx
delete mode 100644 frontend/components/admin/UserManagement.tsx
delete mode 100644 frontend/components/admin/department/DepartmentManagement.tsx
diff --git a/backend-node/src/controllers/authController.ts b/backend-node/src/controllers/authController.ts
index 6f72eb10..1903d397 100644
--- a/backend-node/src/controllers/authController.ts
+++ b/backend-node/src/controllers/authController.ts
@@ -141,6 +141,110 @@ export class AuthController {
}
}
+ /**
+ * POST /api/auth/switch-company
+ * WACE 관리자 전용: 다른 회사로 전환
+ */
+ static async switchCompany(req: Request, res: Response): Promise {
+ try {
+ const { companyCode } = req.body;
+ const authHeader = req.get("Authorization");
+ const token = authHeader && authHeader.split(" ")[1];
+
+ if (!token) {
+ res.status(401).json({
+ success: false,
+ message: "인증 토큰이 필요합니다.",
+ error: { code: "TOKEN_MISSING" },
+ });
+ return;
+ }
+
+ // 현재 사용자 정보 확인
+ const currentUser = JwtUtils.verifyToken(token);
+
+ // WACE 관리자 권한 체크 (userType = "SUPER_ADMIN"만 확인)
+ // 이미 다른 회사로 전환한 상태(companyCode != "*")에서도 다시 전환 가능해야 함
+ if (currentUser.userType !== "SUPER_ADMIN") {
+ logger.warn(`회사 전환 권한 없음: userId=${currentUser.userId}, userType=${currentUser.userType}, companyCode=${currentUser.companyCode}`);
+ res.status(403).json({
+ success: false,
+ message: "회사 전환은 최고 관리자(SUPER_ADMIN)만 가능합니다.",
+ error: { code: "FORBIDDEN" },
+ });
+ return;
+ }
+
+ // 전환할 회사 코드 검증
+ if (!companyCode || companyCode.trim() === "") {
+ res.status(400).json({
+ success: false,
+ message: "전환할 회사 코드가 필요합니다.",
+ error: { code: "INVALID_INPUT" },
+ });
+ return;
+ }
+
+ logger.info(`=== WACE 관리자 회사 전환 ===`, {
+ userId: currentUser.userId,
+ originalCompanyCode: currentUser.companyCode,
+ targetCompanyCode: companyCode,
+ });
+
+ // 회사 코드 존재 여부 확인 (company_code가 "*"가 아닌 경우만)
+ if (companyCode !== "*") {
+ const { query } = await import("../database/db");
+ const companies = await query(
+ "SELECT company_code, company_name FROM company_mng WHERE company_code = $1",
+ [companyCode]
+ );
+
+ if (companies.length === 0) {
+ res.status(404).json({
+ success: false,
+ message: "존재하지 않는 회사 코드입니다.",
+ error: { code: "COMPANY_NOT_FOUND" },
+ });
+ return;
+ }
+ }
+
+ // 새로운 JWT 토큰 발급 (company_code만 변경)
+ const newPersonBean: PersonBean = {
+ ...currentUser,
+ companyCode: companyCode.trim(), // 전환할 회사 코드로 변경
+ };
+
+ const newToken = JwtUtils.generateToken(newPersonBean);
+
+ logger.info(`✅ 회사 전환 성공: ${currentUser.userId} → ${companyCode}`);
+
+ res.status(200).json({
+ success: true,
+ message: "회사 전환 완료",
+ data: {
+ token: newToken,
+ companyCode: companyCode.trim(),
+ },
+ });
+ } catch (error) {
+ logger.error(
+ `회사 전환 API 오류: ${error instanceof Error ? error.message : error}`
+ );
+ res.status(500).json({
+ success: false,
+ message: "회사 전환 중 오류가 발생했습니다.",
+ error: {
+ code: "SERVER_ERROR",
+ details:
+ error instanceof Error
+ ? error.message
+ : "알 수 없는 오류가 발생했습니다.",
+ },
+ });
+ }
+ }
+
/**
* POST /api/auth/logout
* 기존 Java ApiLoginController.logout() 메서드 포팅
@@ -226,13 +330,14 @@ export class AuthController {
}
// 프론트엔드 호환성을 위해 더 많은 사용자 정보 반환
+ // ⚠️ JWT 토큰의 companyCode를 우선 사용 (회사 전환 기능 지원)
const userInfoResponse: any = {
userId: dbUserInfo.userId,
userName: dbUserInfo.userName || "",
deptName: dbUserInfo.deptName || "",
- companyCode: dbUserInfo.companyCode || "ILSHIN",
- company_code: dbUserInfo.companyCode || "ILSHIN", // 프론트엔드 호환성
- userType: dbUserInfo.userType || "USER",
+ companyCode: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선
+ company_code: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선
+ userType: userInfo.userType || dbUserInfo.userType || "USER", // JWT 토큰 우선
userTypeName: dbUserInfo.userTypeName || "일반사용자",
email: dbUserInfo.email || "",
photo: dbUserInfo.photo,
diff --git a/backend-node/src/routes/authRoutes.ts b/backend-node/src/routes/authRoutes.ts
index adba86e6..7ed87a06 100644
--- a/backend-node/src/routes/authRoutes.ts
+++ b/backend-node/src/routes/authRoutes.ts
@@ -47,4 +47,10 @@ router.post("/refresh", AuthController.refreshToken);
*/
router.post("/signup", AuthController.signup);
+/**
+ * POST /api/auth/switch-company
+ * WACE 관리자 전용: 다른 회사로 전환
+ */
+router.post("/switch-company", AuthController.switchCompany);
+
export default router;
diff --git a/backend-node/src/services/adminService.ts b/backend-node/src/services/adminService.ts
index 5ca6b392..1b9280db 100644
--- a/backend-node/src/services/adminService.ts
+++ b/backend-node/src/services/adminService.ts
@@ -412,9 +412,9 @@ export class AdminService {
let queryParams: any[] = [userLang];
let paramIndex = 2;
- if (userType === "SUPER_ADMIN" && userCompanyCode === "*") {
- // SUPER_ADMIN: 권한 그룹 체크 없이 공통 메뉴만 표시
- logger.info("✅ 좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시");
+ if (userType === "SUPER_ADMIN") {
+ // SUPER_ADMIN: 권한 그룹 체크 없이 해당 회사의 모든 메뉴 표시
+ logger.info(`✅ 좌측 사이드바 (SUPER_ADMIN): 회사 ${userCompanyCode}의 모든 메뉴 표시`);
authFilter = "";
unionFilter = "";
} else {
diff --git a/frontend/app/(main)/admin/menu/page.tsx b/frontend/app/(main)/admin/menu/page.tsx
index 4e9ff1d4..85d5b346 100644
--- a/frontend/app/(main)/admin/menu/page.tsx
+++ b/frontend/app/(main)/admin/menu/page.tsx
@@ -1,9 +1,880 @@
"use client";
-import { MenuManagement } from "@/components/admin/MenuManagement";
+import React, { useState, useEffect, useMemo } from "react";
+import { menuApi } from "@/lib/api/menu";
+import type { MenuItem } from "@/lib/api/menu";
+import { MenuTable } from "@/components/admin/MenuTable";
+import { MenuFormModal } from "@/components/admin/MenuFormModal";
+import { MenuCopyDialog } from "@/components/admin/MenuCopyDialog";
+import { Button } from "@/components/ui/button";
+import { LoadingSpinner, LoadingOverlay } from "@/components/common/LoadingSpinner";
+import { toast } from "sonner";
+import { Badge } from "@/components/ui/badge";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { useMenu } from "@/contexts/MenuContext";
+import { useMenuManagementText, setTranslationCache, getMenuTextSync } from "@/lib/utils/multilang";
+import { useMultiLang } from "@/hooks/useMultiLang";
+import { apiClient } from "@/lib/api/client";
+import { useAuth } from "@/hooks/useAuth"; // useAuth 추가
import { ScrollToTop } from "@/components/common/ScrollToTop";
+type MenuType = "admin" | "user";
+
export default function MenuPage() {
+ const { adminMenus, userMenus, refreshMenus } = useMenu();
+ const { user } = useAuth(); // 현재 사용자 정보 가져오기
+ const [selectedMenuType, setSelectedMenuType] = useState("admin");
+ const [loading, setLoading] = useState(false);
+ const [deleting, setDeleting] = useState(false);
+ const [formModalOpen, setFormModalOpen] = useState(false);
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [copyDialogOpen, setCopyDialogOpen] = useState(false);
+ const [selectedMenuId, setSelectedMenuId] = useState("");
+ const [selectedMenuName, setSelectedMenuName] = useState("");
+ const [selectedMenus, setSelectedMenus] = useState>(new Set());
+
+ // 메뉴 관리 화면용 로컬 상태 (모든 상태의 메뉴 표시)
+ const [localAdminMenus, setLocalAdminMenus] = useState([]);
+ const [localUserMenus, setLocalUserMenus] = useState([]);
+
+ // 다국어 텍스트 훅 사용
+ // getMenuText는 더 이상 사용하지 않음 - getUITextSync만 사용
+ const { userLang } = useMultiLang({ companyCode: "*" });
+
+ // SUPER_ADMIN 여부 확인
+ const isSuperAdmin = user?.userType === "SUPER_ADMIN";
+
+ // 다국어 텍스트 상태
+ const [uiTexts, setUiTexts] = useState>({});
+ const [uiTextsLoading, setUiTextsLoading] = useState(false);
+
+ // 회사 목록 상태
+ const [companies, setCompanies] = useState>([]);
+ const [selectedCompany, setSelectedCompany] = useState("all");
+ const [searchText, setSearchText] = useState("");
+ const [expandedMenus, setExpandedMenus] = useState>(new Set());
+ const [companySearchText, setCompanySearchText] = useState("");
+ const [isCompanyDropdownOpen, setIsCompanyDropdownOpen] = useState(false);
+ const [formData, setFormData] = useState({
+ menuId: "",
+ parentId: "",
+ menuType: "",
+ level: 0,
+ parentCompanyCode: "",
+ });
+
+ // 언어별 텍스트 매핑 테이블 제거 - DB에서 직접 가져옴
+
+ // 메뉴관리 페이지에서 사용할 다국어 키들 (실제 DB에 등록된 키들)
+ const MENU_MANAGEMENT_LANG_KEYS = [
+ // 페이지 제목 및 설명
+ "menu.management.title",
+ "menu.management.description",
+ "menu.type.title",
+ "menu.type.admin",
+ "menu.type.user",
+ "menu.management.admin",
+ "menu.management.user",
+ "menu.management.admin.description",
+ "menu.management.user.description",
+
+ // 버튼
+ "button.add",
+ "button.add.top.level",
+ "button.add.sub",
+ "button.edit",
+ "button.delete",
+ "button.delete.selected",
+ "button.delete.selected.count",
+ "button.delete.processing",
+ "button.cancel",
+ "button.save",
+ "button.register",
+ "button.modify",
+
+ // 필터 및 검색
+ "filter.company",
+ "filter.company.all",
+ "filter.company.common",
+ "filter.company.search",
+ "filter.search",
+ "filter.search.placeholder",
+ "filter.reset",
+
+ // 테이블 헤더
+ "table.header.select",
+ "table.header.menu.name",
+ "table.header.menu.url",
+ "table.header.menu.type",
+ "table.header.status",
+ "table.header.company",
+ "table.header.sequence",
+ "table.header.actions",
+
+ // 상태
+ "status.active",
+ "status.inactive",
+ "status.unspecified",
+
+ // 폼
+ "form.menu.type",
+ "form.menu.type.admin",
+ "form.menu.type.user",
+ "form.company",
+ "form.company.select",
+ "form.company.common",
+ "form.company.submenu.note",
+ "form.lang.key",
+ "form.lang.key.select",
+ "form.lang.key.none",
+ "form.lang.key.search",
+ "form.lang.key.selected",
+ "form.menu.name",
+ "form.menu.name.placeholder",
+ "form.menu.url",
+ "form.menu.url.placeholder",
+ "form.menu.description",
+ "form.menu.description.placeholder",
+ "form.menu.sequence",
+
+ // 모달
+ "modal.menu.register.title",
+ "modal.menu.modify.title",
+ "modal.delete.title",
+ "modal.delete.description",
+ "modal.delete.batch.description",
+
+ // 메시지
+ "message.loading",
+ "message.menu.delete.processing",
+ "message.menu.save.success",
+ "message.menu.save.failed",
+ "message.menu.delete.success",
+ "message.menu.delete.failed",
+ "message.menu.delete.batch.success",
+ "message.menu.delete.batch.partial",
+ "message.menu.status.toggle.success",
+ "message.menu.status.toggle.failed",
+ "message.validation.menu.name.required",
+ "message.validation.company.required",
+ "message.validation.select.menu.delete",
+ "message.error.load.menu.list",
+ "message.error.load.menu.info",
+ "message.error.load.company.list",
+ "message.error.load.lang.key.list",
+
+ // 리스트 정보
+ "menu.list.title",
+ "menu.list.total",
+ "menu.list.search.result",
+
+ // UI
+ "ui.expand",
+ "ui.collapse",
+ "ui.menu.collapse",
+ "ui.language",
+ ];
+
+ // 초기 로딩
+ useEffect(() => {
+ loadCompanies();
+ loadMenus(false); // 메뉴 목록 로드 (메뉴 관리 화면용 - 모든 상태 표시)
+ // 사용자 언어가 설정되지 않았을 때만 기본 텍스트 설정
+ if (!userLang) {
+ initializeDefaultTexts();
+ }
+ }, [userLang]); // userLang 변경 시마다 실행
+
+ // 초기 기본 텍스트 설정 함수
+ const initializeDefaultTexts = () => {
+ const defaultTexts: Record = {};
+ MENU_MANAGEMENT_LANG_KEYS.forEach((key) => {
+ // 기본 한국어 텍스트 제공
+ const defaultText = getDefaultText(key);
+ defaultTexts[key] = defaultText;
+ });
+ setUiTexts(defaultTexts);
+ // console.log("🌐 초기 기본 텍스트 설정 완료:", Object.keys(defaultTexts).length);
+ };
+
+ // 기본 텍스트 반환 함수
+ const getDefaultText = (key: string): string => {
+ const defaultTexts: Record = {
+ "menu.management.title": "메뉴 관리",
+ "menu.management.description": "시스템의 메뉴 구조와 권한을 관리합니다.",
+ "menu.type.title": "메뉴 타입",
+ "menu.type.admin": "관리자",
+ "menu.type.user": "사용자",
+ "menu.management.admin": "관리자 메뉴",
+ "menu.management.user": "사용자 메뉴",
+ "menu.management.admin.description": "시스템 관리 및 설정 메뉴",
+ "menu.management.user.description": "일반 사용자 업무 메뉴",
+ "button.add": "추가",
+ "button.add.top.level": "최상위 메뉴 추가",
+ "button.add.sub": "하위 메뉴 추가",
+ "button.edit": "수정",
+ "button.delete": "삭제",
+ "button.delete.selected": "선택 삭제",
+ "button.delete.selected.count": "선택 삭제 ({count})",
+ "button.delete.processing": "삭제 중...",
+ "button.cancel": "취소",
+ "button.save": "저장",
+ "button.register": "등록",
+ "button.modify": "수정",
+ "filter.company": "회사",
+ "filter.company.all": "전체",
+ "filter.company.common": "공통",
+ "filter.company.search": "회사 검색",
+ "filter.search": "검색",
+ "filter.search.placeholder": "메뉴명 또는 URL로 검색...",
+ "filter.reset": "초기화",
+ "table.header.select": "선택",
+ "table.header.menu.name": "메뉴명",
+ "table.header.menu.url": "URL",
+ "table.header.menu.type": "메뉴 타입",
+ "table.header.status": "상태",
+ "table.header.company": "회사",
+ "table.header.sequence": "순서",
+ "table.header.actions": "작업",
+ "status.active": "활성화",
+ "status.inactive": "비활성화",
+ "status.unspecified": "미지정",
+ "form.menu.type": "메뉴 타입",
+ "form.menu.type.admin": "관리자",
+ "form.menu.type.user": "사용자",
+ "form.company": "회사",
+ "form.company.select": "회사를 선택하세요",
+ "form.company.common": "공통",
+ "form.company.submenu.note": "하위 메뉴는 상위 메뉴와 동일한 회사를 가져야 합니다.",
+ "form.lang.key": "다국어 키",
+ "form.lang.key.select": "다국어 키를 선택하세요",
+ "form.lang.key.none": "다국어 키 없음",
+ "form.lang.key.search": "다국어 키 검색...",
+ "form.lang.key.selected": "선택된 키: {key} - {description}",
+ "form.menu.name": "메뉴명",
+ "form.menu.name.placeholder": "메뉴명을 입력하세요",
+ "form.menu.url": "URL",
+ "form.menu.url.placeholder": "메뉴 URL을 입력하세요",
+ "form.menu.description": "설명",
+ "form.menu.description.placeholder": "메뉴 설명을 입력하세요",
+ "form.menu.sequence": "순서",
+ "modal.menu.register.title": "메뉴 등록",
+ "modal.menu.modify.title": "메뉴 수정",
+ "modal.delete.title": "메뉴 삭제",
+ "modal.delete.description": "해당 메뉴를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
+ "modal.delete.batch.description":
+ "선택된 {count}개의 메뉴를 영구적으로 삭제하시겠습니까?\n\n⚠️ 주의: 상위 메뉴를 삭제하면 하위 메뉴들도 함께 삭제됩니다.\n이 작업은 되돌릴 수 없습니다.",
+ "message.loading": "로딩 중...",
+ "message.menu.delete.processing": "메뉴 삭제 중...",
+ "message.menu.save.success": "메뉴가 성공적으로 저장되었습니다.",
+ "message.menu.save.failed": "메뉴 저장에 실패했습니다.",
+ "message.menu.delete.success": "메뉴가 성공적으로 삭제되었습니다.",
+ "message.menu.delete.failed": "메뉴 삭제에 실패했습니다.",
+ "message.menu.delete.batch.success": "선택된 메뉴들이 성공적으로 삭제되었습니다.",
+ "message.menu.delete.batch.partial": "일부 메뉴 삭제에 실패했습니다.",
+ "message.menu.status.toggle.success": "메뉴 상태가 변경되었습니다.",
+ "message.menu.status.toggle.failed": "메뉴 상태 변경에 실패했습니다.",
+ "message.validation.menu.name.required": "메뉴명을 입력해주세요.",
+ "message.validation.company.required": "회사를 선택해주세요.",
+ "message.validation.select.menu.delete": "삭제할 메뉴를 선택해주세요.",
+ "message.error.load.menu.list": "메뉴 목록을 불러오는데 실패했습니다.",
+ "message.error.load.menu.info": "메뉴 정보를 불러오는데 실패했습니다.",
+ "message.error.load.company.list": "회사 목록을 불러오는데 실패했습니다.",
+ "message.error.load.lang.key.list": "다국어 키 목록을 불러오는데 실패했습니다.",
+ "menu.list.title": "메뉴 목록",
+ "menu.list.total": "총 {count}개",
+ "menu.list.search.result": "검색 결과: {count}개",
+ "ui.expand": "펼치기",
+ "ui.collapse": "접기",
+ "ui.menu.collapse": "메뉴 접기",
+ "ui.language": "언어",
+ };
+
+ return defaultTexts[key] || key;
+ };
+
+ // 컴포넌트 마운트 시 및 userLang 변경 시 다국어 텍스트 로드
+ useEffect(() => {
+ if (userLang && !uiTextsLoading) {
+ loadUITexts();
+ }
+ }, [userLang]); // userLang 변경 시마다 실행
+
+ // uiTexts 상태 변경 감지
+ useEffect(() => {
+ // console.log("🔄 uiTexts 상태 변경됨:", {
+ // count: Object.keys(uiTexts).length,
+ // sampleKeys: Object.keys(uiTexts).slice(0, 5),
+ // sampleValues: Object.entries(uiTexts)
+ // .slice(0, 3)
+ // .map(([k, v]) => `${k}: ${v}`),
+ // });
+ }, [uiTexts]);
+
+ // 컴포넌트 마운트 후 다국어 텍스트 강제 로드 (userLang이 아직 설정되지 않았을 수 있음)
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ if (userLang && !uiTextsLoading) {
+ // console.log("🔄 컴포넌트 마운트 후 다국어 텍스트 강제 로드");
+ loadUITexts();
+ }
+ }, 300); // 300ms 후 실행
+
+ return () => clearTimeout(timer);
+ }, [userLang]); // userLang이 설정된 후 실행
+
+ // 추가 안전장치: 컴포넌트 마운트 후 일정 시간이 지나면 강제로 다국어 텍스트 로드
+ useEffect(() => {
+ const fallbackTimer = setTimeout(() => {
+ if (!uiTextsLoading && Object.keys(uiTexts).length === 0) {
+ // console.log("🔄 안전장치: 컴포넌트 마운트 후 강제 다국어 텍스트 로드");
+ // 사용자 언어가 설정되지 않았을 때만 기본 텍스트 설정
+ if (!userLang) {
+ initializeDefaultTexts();
+ } else {
+ // 사용자 언어가 설정된 경우 다국어 텍스트 로드
+ loadUITexts();
+ }
+ }
+ }, 1000); // 1초 후 실행
+
+ return () => clearTimeout(fallbackTimer);
+ }, [userLang]); // userLang 변경 시마다 실행
+
+ // 번역 로드 이벤트 감지
+ useEffect(() => {
+ const handleTranslationLoaded = (event: CustomEvent) => {
+ const { key, text, userLang: loadedLang } = event.detail;
+ if (loadedLang === userLang) {
+ setUiTexts((prev) => ({ ...prev, [key]: text }));
+ }
+ };
+
+ window.addEventListener("translation-loaded", handleTranslationLoaded as EventListener);
+
+ return () => {
+ window.removeEventListener("translation-loaded", handleTranslationLoaded as EventListener);
+ };
+ }, [userLang]);
+
+ // 드롭다운 외부 클릭 시 닫기
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ const target = event.target as Element;
+ if (!target.closest(".company-dropdown")) {
+ setIsCompanyDropdownOpen(false);
+ setCompanySearchText("");
+ }
+ };
+
+ if (isCompanyDropdownOpen) {
+ document.addEventListener("mousedown", handleClickOutside);
+ }
+
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ };
+ }, [isCompanyDropdownOpen]);
+
+ // 특정 메뉴 타입만 로드하는 함수
+ const loadMenusForType = async (type: MenuType, showLoading = true) => {
+ try {
+ if (showLoading) {
+ setLoading(true);
+ }
+
+ if (type === "admin") {
+ const adminResponse = await menuApi.getAdminMenusForManagement();
+ if (adminResponse.success && adminResponse.data) {
+ setLocalAdminMenus(adminResponse.data);
+ }
+ } else {
+ const userResponse = await menuApi.getUserMenusForManagement();
+ if (userResponse.success && userResponse.data) {
+ setLocalUserMenus(userResponse.data);
+ }
+ }
+ } catch (error) {
+ toast.error(getUITextSync("message.error.load.menu.list"));
+ } finally {
+ if (showLoading) {
+ setLoading(false);
+ }
+ }
+ };
+
+ const loadMenus = async (showLoading = true) => {
+ // console.log(`📋 메뉴 목록 조회 시작 (showLoading: ${showLoading})`);
+ try {
+ if (showLoading) {
+ setLoading(true);
+ }
+
+ // 선택된 메뉴 타입에 해당하는 메뉴만 로드
+ if (selectedMenuType === "admin") {
+ const adminResponse = await menuApi.getAdminMenusForManagement();
+ if (adminResponse.success && adminResponse.data) {
+ setLocalAdminMenus(adminResponse.data);
+ }
+ } else {
+ const userResponse = await menuApi.getUserMenusForManagement();
+ if (userResponse.success && userResponse.data) {
+ setLocalUserMenus(userResponse.data);
+ }
+ }
+
+ // 전역 메뉴 상태도 업데이트 (좌측 사이드바용)
+ await refreshMenus();
+ // console.log("📋 메뉴 목록 조회 성공");
+ } catch (error) {
+ // console.error("❌ 메뉴 목록 조회 실패:", error);
+ toast.error(getUITextSync("message.error.load.menu.list"));
+ } finally {
+ if (showLoading) {
+ setLoading(false);
+ }
+ }
+ };
+
+ // 회사 목록 조회
+ const loadCompanies = async () => {
+ // console.log("🏢 회사 목록 조회 시작");
+ try {
+ const response = await apiClient.get("/admin/companies");
+
+ if (response.data.success) {
+ // console.log("🏢 회사 목록 응답:", response.data);
+ const companyList = response.data.data.map((company: any) => ({
+ code: company.company_code || company.companyCode,
+ name: company.company_name || company.companyName,
+ }));
+ // console.log("🏢 변환된 회사 목록:", companyList);
+ setCompanies(companyList);
+ }
+ } catch (error) {
+ // console.error("❌ 회사 목록 조회 실패:", error);
+ }
+ };
+
+ // 다국어 텍스트 로드 함수 - 배치 API 사용
+ const loadUITexts = async () => {
+ if (uiTextsLoading) return; // 이미 로딩 중이면 중단
+
+ // userLang이 설정되지 않았으면 기본값 설정
+ if (!userLang) {
+ // console.log("🌐 사용자 언어가 설정되지 않음, 기본값 설정");
+ const defaultTexts: Record = {};
+ MENU_MANAGEMENT_LANG_KEYS.forEach((key) => {
+ defaultTexts[key] = getDefaultText(key); // 기본 한국어 텍스트 사용
+ });
+ setUiTexts(defaultTexts);
+ return;
+ }
+
+ // 사용자 언어가 설정된 경우, 기존 uiTexts가 비어있으면 기본 텍스트로 초기화
+ if (Object.keys(uiTexts).length === 0) {
+ // console.log("🌐 기존 uiTexts가 비어있음, 기본 텍스트로 초기화");
+ const defaultTexts: Record = {};
+ MENU_MANAGEMENT_LANG_KEYS.forEach((key) => {
+ defaultTexts[key] = getDefaultText(key);
+ });
+ setUiTexts(defaultTexts);
+ }
+
+ // console.log("🌐 UI 다국어 텍스트 로드 시작", {
+ // userLang,
+ // apiParams: {
+ // companyCode: "*",
+ // menuCode: "menu.management",
+ // userLang: userLang,
+ // },
+ // });
+ setUiTextsLoading(true);
+
+ try {
+ // 배치 API를 사용하여 모든 다국어 키를 한 번에 조회
+ const response = await apiClient.post(
+ "/multilang/batch",
+ {
+ langKeys: MENU_MANAGEMENT_LANG_KEYS,
+ companyCode: "*", // 모든 회사
+ menuCode: "menu.management", // 메뉴관리 메뉴
+ userLang: userLang, // body에 포함
+ },
+ {
+ params: {}, // query params는 비움
+ },
+ );
+
+ if (response.data.success) {
+ const translations = response.data.data;
+ // console.log("🌐 배치 다국어 텍스트 응답:", translations);
+
+ // 번역 결과를 상태에 저장 (기존 uiTexts와 병합)
+ const mergedTranslations = { ...uiTexts, ...translations };
+ // console.log("🔧 setUiTexts 호출 전:", {
+ // translationsCount: Object.keys(translations).length,
+ // mergedCount: Object.keys(mergedTranslations).length,
+ // });
+ setUiTexts(mergedTranslations);
+ // console.log("🔧 setUiTexts 호출 후 - mergedTranslations:", mergedTranslations);
+
+ // 번역 캐시에 저장 (다른 컴포넌트에서도 사용할 수 있도록)
+ setTranslationCache(userLang, mergedTranslations);
+ } else {
+ // console.error("❌ 다국어 텍스트 배치 조회 실패:", response.data.message);
+ // API 실패 시에도 기존 uiTexts는 유지
+ // console.log("🔄 API 실패로 인해 기존 uiTexts 유지");
+ }
+ } catch (error) {
+ // console.error("❌ UI 다국어 텍스트 로드 실패:", error);
+ // API 실패 시에도 기존 uiTexts는 유지
+ // console.log("🔄 API 실패로 인해 기존 uiTexts 유지");
+ } finally {
+ setUiTextsLoading(false);
+ }
+ };
+
+ // UI 텍스트 가져오기 함수 (동기 버전만 사용)
+ // getUIText 함수는 제거 - getUITextSync만 사용
+
+ // 동기 버전 (DB에서 가져온 번역 텍스트 사용)
+ const getUITextSync = (key: string, params?: Record, fallback?: string): string => {
+ // uiTexts에서 번역 텍스트 찾기
+ let text = uiTexts[key];
+
+ // uiTexts에 없으면 getMenuTextSync로 기본 한글 텍스트 가져오기
+ if (!text) {
+ text = getMenuTextSync(key, userLang) || fallback || key;
+ }
+
+ // 파라미터 치환
+ if (params && text) {
+ Object.entries(params).forEach(([paramKey, paramValue]) => {
+ text = text!.replace(`{${paramKey}}`, String(paramValue));
+ });
+ }
+
+ return text || key;
+ };
+
+ // 다국어 API 테스트 함수 (getUITextSync 사용)
+ const testMultiLangAPI = async () => {
+ // console.log("🧪 다국어 API 테스트 시작");
+ try {
+ const text = getUITextSync("menu.management.admin");
+ // console.log("🧪 다국어 API 테스트 결과:", text);
+ } catch (error) {
+ // console.error("❌ 다국어 API 테스트 실패:", error);
+ }
+ };
+
+ // 대문자 키를 소문자 키로 변환하는 함수
+ const convertMenuData = (data: any[]): MenuItem[] => {
+ return data.map((item) => ({
+ objid: item.OBJID || item.objid,
+ parent_obj_id: item.PARENT_OBJ_ID || item.parent_obj_id,
+ menu_name_kor: item.MENU_NAME_KOR || item.menu_name_kor,
+ menu_url: item.MENU_URL || item.menu_url,
+ menu_desc: item.MENU_DESC || item.menu_desc,
+ seq: item.SEQ || item.seq,
+ menu_type: item.MENU_TYPE || item.menu_type,
+ status: item.STATUS || item.status,
+ lev: item.LEV || item.lev,
+ lpad_menu_name_kor: item.LPAD_MENU_NAME_KOR || item.lpad_menu_name_kor,
+ status_title: item.STATUS_TITLE || item.status_title,
+ writer: item.WRITER || item.writer,
+ regdate: item.REGDATE || item.regdate,
+ company_code: item.COMPANY_CODE || item.company_code,
+ company_name: item.COMPANY_NAME || item.company_name,
+ }));
+ };
+
+ const handleAddTopLevelMenu = () => {
+ setFormData({
+ menuId: "",
+ parentId: "0", // 최상위 메뉴는 parentId가 0
+ menuType: getMenuTypeValue(),
+ level: 1, // 최상위 메뉴는 level 1
+ parentCompanyCode: "", // 최상위 메뉴는 상위 회사 정보 없음
+ });
+ setFormModalOpen(true);
+ };
+
+ const handleAddMenu = (parentId: string, menuType: string, level: number) => {
+ // 상위 메뉴의 회사 정보 찾기
+ const currentMenus = selectedMenuType === "admin" ? localAdminMenus : localUserMenus;
+ const parentMenu = currentMenus.find((menu) => menu.objid === parentId);
+
+ setFormData({
+ menuId: "",
+ parentId,
+ menuType,
+ level: level + 1,
+ parentCompanyCode: parentMenu?.company_code || "",
+ });
+ setFormModalOpen(true);
+ };
+
+ const handleEditMenu = (menuId: string) => {
+ // console.log("🔧 메뉴 수정 시작 - menuId:", menuId);
+
+ // 현재 메뉴 정보 찾기
+ const currentMenus = selectedMenuType === "admin" ? localAdminMenus : localUserMenus;
+ const menuToEdit = currentMenus.find((menu) => (menu.objid || menu.OBJID) === menuId);
+
+ if (menuToEdit) {
+ // console.log("수정할 메뉴 정보:", menuToEdit);
+
+ setFormData({
+ menuId: menuId,
+ parentId: menuToEdit.parent_obj_id || menuToEdit.PARENT_OBJ_ID || "",
+ menuType: selectedMenuType, // 현재 선택된 메뉴 타입
+ level: 0, // 기본값
+ parentCompanyCode: menuToEdit.company_code || menuToEdit.COMPANY_CODE || "",
+ });
+
+ // console.log("설정된 formData:", {
+ // menuId: menuId,
+ // parentId: menuToEdit.parent_obj_id || menuToEdit.PARENT_OBJ_ID || "",
+ // menuType: selectedMenuType,
+ // level: 0,
+ // parentCompanyCode: menuToEdit.company_code || menuToEdit.COMPANY_CODE || "",
+ // });
+ } else {
+ // console.error("수정할 메뉴를 찾을 수 없음:", menuId);
+ }
+
+ setFormModalOpen(true);
+ };
+
+ const handleMenuSelectionChange = (menuId: string, checked: boolean) => {
+ const newSelected = new Set(selectedMenus);
+ if (checked) {
+ newSelected.add(menuId);
+ } else {
+ newSelected.delete(menuId);
+ }
+ setSelectedMenus(newSelected);
+ };
+
+ const handleSelectAllMenus = (checked: boolean) => {
+ const currentMenus = selectedMenuType === "admin" ? localAdminMenus : localUserMenus;
+ if (checked) {
+ // 모든 메뉴 선택 (최상위 메뉴 포함)
+ setSelectedMenus(new Set(currentMenus.map((menu) => menu.objid || menu.OBJID || "")));
+ } else {
+ setSelectedMenus(new Set());
+ }
+ };
+
+ const handleDeleteSelectedMenus = async () => {
+ if (selectedMenus.size === 0) {
+ toast.error(getUITextSync("message.validation.select.menu.delete"));
+ return;
+ }
+
+ if (!confirm(getUITextSync("modal.delete.batch.description", { count: selectedMenus.size }))) {
+ return;
+ }
+
+ setDeleting(true);
+ try {
+ const menuIds = Array.from(selectedMenus);
+ // console.log("삭제할 메뉴 IDs:", menuIds);
+
+ toast.info(getUITextSync("message.menu.delete.processing"));
+
+ const response = await menuApi.deleteMenusBatch(menuIds);
+ // console.log("삭제 API 응답:", response);
+ // console.log("응답 구조:", {
+ // success: response.success,
+ // data: response.data,
+ // message: response.message,
+ // });
+
+ if (response.success && response.data) {
+ const { deletedCount, failedCount } = response.data;
+ // console.log("삭제 결과:", { deletedCount, failedCount });
+
+ // 선택된 메뉴 초기화
+ setSelectedMenus(new Set());
+
+ // 메뉴 목록 즉시 새로고침 (로딩 상태 없이)
+ // console.log("메뉴 목록 새로고침 시작");
+ await loadMenus(false);
+ // 전역 메뉴 상태도 업데이트
+ await refreshMenus();
+ // console.log("메뉴 목록 새로고침 완료");
+
+ // 삭제 결과 메시지
+ if (failedCount === 0) {
+ toast.success(getUITextSync("message.menu.delete.batch.success", { count: deletedCount }));
+ } else {
+ toast.success(
+ getUITextSync("message.menu.delete.batch.partial", {
+ success: deletedCount,
+ failed: failedCount,
+ }),
+ );
+ }
+ } else {
+ // console.error("삭제 실패:", response);
+ toast.error(response.message || "메뉴 삭제에 실패했습니다.");
+ }
+ } catch (error) {
+ // console.error("메뉴 삭제 중 오류:", error);
+ toast.error(getUITextSync("message.menu.delete.failed"));
+ } finally {
+ setDeleting(false);
+ }
+ };
+
+ const confirmDelete = async () => {
+ try {
+ const response = await menuApi.deleteMenu(selectedMenuId);
+ if (response.success) {
+ toast.success(response.message);
+ await loadMenus(false);
+ } else {
+ toast.error(response.message);
+ }
+ } catch (error) {
+ toast.error("메뉴 삭제에 실패했습니다.");
+ } finally {
+ setDeleteDialogOpen(false);
+ setSelectedMenuId("");
+ }
+ };
+
+ const handleCopyMenu = (menuId: string, menuName: string) => {
+ setSelectedMenuId(menuId);
+ setSelectedMenuName(menuName);
+ setCopyDialogOpen(true);
+ };
+
+ const handleCopyComplete = async () => {
+ // 복사 완료 후 메뉴 목록 새로고침
+ await loadMenus(false);
+ toast.success("메뉴 복사가 완료되었습니다");
+ };
+
+ const handleToggleStatus = async (menuId: string) => {
+ try {
+ const response = await menuApi.toggleMenuStatus(menuId);
+ if (response.success) {
+ toast.success(response.message);
+ await loadMenus(false); // 메뉴 목록 새로고침
+ // 전역 메뉴 상태도 업데이트
+ await refreshMenus();
+ } else {
+ toast.error(response.message);
+ }
+ } catch (error) {
+ // console.error("메뉴 상태 토글 오류:", error);
+ toast.error(getUITextSync("message.menu.status.toggle.failed"));
+ }
+ };
+
+ const handleFormSuccess = () => {
+ loadMenus(false);
+ // 전역 메뉴 상태도 업데이트
+ refreshMenus();
+ };
+
+ const getCurrentMenus = () => {
+ // 메뉴 관리 화면용: 모든 상태의 메뉴 표시 (localAdminMenus/localUserMenus 사용)
+ const currentMenus = selectedMenuType === "admin" ? localAdminMenus : localUserMenus;
+
+ // 검색어 필터링
+ let filteredMenus = currentMenus;
+ if (searchText.trim()) {
+ const searchLower = searchText.toLowerCase();
+ filteredMenus = currentMenus.filter((menu) => {
+ const menuName = (menu.menu_name_kor || menu.MENU_NAME_KOR || "").toLowerCase();
+ const menuUrl = (menu.menu_url || menu.MENU_URL || "").toLowerCase();
+ return menuName.includes(searchLower) || menuUrl.includes(searchLower);
+ });
+ }
+
+ // 회사 필터링
+ if (selectedCompany !== "all") {
+ filteredMenus = filteredMenus.filter((menu) => {
+ const menuCompanyCode = menu.company_code || menu.COMPANY_CODE || "";
+ return menuCompanyCode === selectedCompany;
+ });
+ }
+
+ return filteredMenus;
+ };
+
+ // 메뉴 타입 변경 시 선택된 메뉴 초기화
+ const handleMenuTypeChange = (type: MenuType) => {
+ setSelectedMenuType(type);
+ setSelectedMenus(new Set()); // 선택된 메뉴 초기화
+ setExpandedMenus(new Set()); // 메뉴 타입 변경 시 확장 상태 초기화
+
+ // 선택한 메뉴 타입에 해당하는 메뉴만 로드
+ if (type === "admin" && localAdminMenus.length === 0) {
+ loadMenusForType("admin", false);
+ } else if (type === "user" && localUserMenus.length === 0) {
+ loadMenusForType("user", false);
+ }
+ };
+
+ const handleToggleExpand = (menuId: string) => {
+ const newExpandedMenus = new Set(expandedMenus);
+ if (newExpandedMenus.has(menuId)) {
+ newExpandedMenus.delete(menuId);
+ } else {
+ newExpandedMenus.add(menuId);
+ }
+ setExpandedMenus(newExpandedMenus);
+ };
+
+ const getMenuTypeString = () => {
+ return selectedMenuType === "admin" ? getUITextSync("menu.type.admin") : getUITextSync("menu.type.user");
+ };
+
+ const getMenuTypeValue = () => {
+ return selectedMenuType === "admin" ? "0" : "1";
+ };
+
+ // uiTextsCount를 useMemo로 계산하여 상태 변경 시에만 재계산
+ const uiTextsCount = useMemo(() => Object.keys(uiTexts).length, [uiTexts]);
+ const adminMenusCount = useMemo(() => localAdminMenus?.length || 0, [localAdminMenus]);
+ const userMenusCount = useMemo(() => localUserMenus?.length || 0, [localUserMenus]);
+
+ // 디버깅을 위한 간단한 상태 표시
+ // console.log("🔍 MenuManagement 렌더링 상태:", {
+ // loading,
+ // uiTextsLoading,
+ // uiTextsCount,
+ // adminMenusCount,
+ // userMenusCount,
+ // selectedMenuType,
+ // userLang,
+ // });
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
return (
@@ -14,7 +885,263 @@ export default function MenuPage() {
{/* 메인 컨텐츠 */}
-
+
+
+ {/* 좌측 사이드바 - 메뉴 타입 선택 (20%) */}
+
+
+
{getUITextSync("menu.type.title")}
+
+ {/* 메뉴 타입 선택 카드들 */}
+
+
handleMenuTypeChange("admin")}
+ >
+
+
+
{getUITextSync("menu.management.admin")}
+
+ {getUITextSync("menu.management.admin.description")}
+
+
+
+ {localAdminMenus.length}
+
+
+
+
+
handleMenuTypeChange("user")}
+ >
+
+
+
{getUITextSync("menu.management.user")}
+
+ {getUITextSync("menu.management.user.description")}
+
+
+
+ {localUserMenus.length}
+
+
+
+
+
+
+
+ {/* 우측 메인 영역 - 메뉴 목록 (80%) */}
+
+
+ {/* 상단 헤더: 제목 + 검색 + 버튼 */}
+
+ {/* 왼쪽: 제목 */}
+
+ {getMenuTypeString()} {getUITextSync("menu.list.title")}
+
+
+ {/* 오른쪽: 검색 + 버튼 */}
+
+ {/* 회사 선택 */}
+
+
+
setIsCompanyDropdownOpen(!isCompanyDropdownOpen)}
+ className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
+ >
+
+ {selectedCompany === "all"
+ ? getUITextSync("filter.company.all")
+ : selectedCompany === "*"
+ ? getUITextSync("filter.company.common")
+ : companies.find((c) => c.code === selectedCompany)?.name ||
+ getUITextSync("filter.company.all")}
+
+
+
+
+
+
+ {isCompanyDropdownOpen && (
+
+
+ setCompanySearchText(e.target.value)}
+ className="h-8 text-sm"
+ onClick={(e) => e.stopPropagation()}
+ />
+
+
+
+
{
+ setSelectedCompany("all");
+ setIsCompanyDropdownOpen(false);
+ setCompanySearchText("");
+ }}
+ >
+ {getUITextSync("filter.company.all")}
+
+
{
+ setSelectedCompany("*");
+ setIsCompanyDropdownOpen(false);
+ setCompanySearchText("");
+ }}
+ >
+ {getUITextSync("filter.company.common")}
+
+
+ {companies
+ .filter((company) => company.code && company.code.trim() !== "")
+ .filter(
+ (company) =>
+ company.name.toLowerCase().includes(companySearchText.toLowerCase()) ||
+ company.code.toLowerCase().includes(companySearchText.toLowerCase()),
+ )
+ .map((company, index) => (
+
{
+ setSelectedCompany(company.code);
+ setIsCompanyDropdownOpen(false);
+ setCompanySearchText("");
+ }}
+ >
+ {company.code === "*" ? getUITextSync("filter.company.common") : company.name}
+
+ ))}
+
+
+ )}
+
+
+
+ {/* 검색 입력 */}
+
+ setSearchText(e.target.value)}
+ className="h-10 text-sm"
+ />
+
+
+ {/* 초기화 버튼 */}
+
{
+ setSearchText("");
+ setSelectedCompany("all");
+ setCompanySearchText("");
+ }}
+ variant="outline"
+ className="h-10 text-sm font-medium"
+ >
+ {getUITextSync("filter.reset")}
+
+
+ {/* 최상위 메뉴 추가 */}
+
handleAddTopLevelMenu()} className="h-10 gap-2 text-sm font-medium">
+ {getUITextSync("button.add.top.level")}
+
+
+ {/* 선택 삭제 */}
+ {selectedMenus.size > 0 && (
+
+ {deleting ? (
+ <>
+
+ {getUITextSync("button.delete.processing")}
+ >
+ ) : (
+ getUITextSync("button.delete.selected.count", {
+ count: selectedMenus.size,
+ })
+ )}
+
+ )}
+
+
+
+ {/* 테이블 영역 */}
+
+
+
+
+
+
+
+
+
setFormModalOpen(false)}
+ onSuccess={handleFormSuccess}
+ menuId={formData.menuId}
+ parentId={formData.parentId}
+ menuType={formData.menuType}
+ level={formData.level}
+ parentCompanyCode={formData.parentCompanyCode}
+ uiTexts={uiTexts}
+ />
+
+
+
+
+ 메뉴 삭제
+
+ 해당 메뉴를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
+
+
+
+ 취소
+ 삭제
+
+
+
+
+
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
diff --git a/frontend/app/(main)/admin/monitoring/page.tsx b/frontend/app/(main)/admin/monitoring/page.tsx
index ac70e9a4..7be9d405 100644
--- a/frontend/app/(main)/admin/monitoring/page.tsx
+++ b/frontend/app/(main)/admin/monitoring/page.tsx
@@ -1,9 +1,124 @@
"use client";
-import React from "react";
-import MonitoringDashboard from "@/components/admin/MonitoringDashboard";
+import React, { useState, useEffect } from "react";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { Progress } from "@/components/ui/progress";
+import { RefreshCw, Play, Pause, AlertCircle, CheckCircle, Clock } from "lucide-react";
+import { toast } from "sonner";
+import { BatchAPI, BatchMonitoring } from "@/lib/api/batch";
export default function MonitoringPage() {
+ const [monitoring, setMonitoring] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [autoRefresh, setAutoRefresh] = useState(false);
+
+ useEffect(() => {
+ loadMonitoringData();
+
+ let interval: NodeJS.Timeout;
+ if (autoRefresh) {
+ interval = setInterval(loadMonitoringData, 30000); // 30초마다 자동 새로고침
+ }
+
+ return () => {
+ if (interval) clearInterval(interval);
+ };
+ }, [autoRefresh]);
+
+ const loadMonitoringData = async () => {
+ setIsLoading(true);
+ try {
+ const data = await BatchAPI.getBatchMonitoring();
+ setMonitoring(data);
+ } catch (error) {
+ console.error("모니터링 데이터 조회 오류:", error);
+ toast.error("모니터링 데이터를 불러오는데 실패했습니다.");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleRefresh = () => {
+ loadMonitoringData();
+ };
+
+ const toggleAutoRefresh = () => {
+ setAutoRefresh(!autoRefresh);
+ };
+
+ const getStatusIcon = (status: string) => {
+ switch (status) {
+ case 'completed':
+ return ;
+ case 'failed':
+ return ;
+ case 'running':
+ return ;
+ case 'pending':
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ const getStatusBadge = (status: string) => {
+ const variants = {
+ completed: "bg-green-100 text-green-800",
+ failed: "bg-destructive/20 text-red-800",
+ running: "bg-primary/20 text-blue-800",
+ pending: "bg-yellow-100 text-yellow-800",
+ cancelled: "bg-gray-100 text-gray-800",
+ };
+
+ const labels = {
+ completed: "완료",
+ failed: "실패",
+ running: "실행 중",
+ pending: "대기 중",
+ cancelled: "취소됨",
+ };
+
+ return (
+
+ {labels[status as keyof typeof labels] || status}
+
+ );
+ };
+
+ const formatDuration = (ms: number) => {
+ if (ms < 1000) return `${ms}ms`;
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
+ return `${(ms / 60000).toFixed(1)}m`;
+ };
+
+ const getSuccessRate = () => {
+ if (!monitoring) return 0;
+ const total = monitoring.successful_jobs_today + monitoring.failed_jobs_today;
+ if (total === 0) return 100;
+ return Math.round((monitoring.successful_jobs_today / total) * 100);
+ };
+
+ if (!monitoring) {
+ return (
+
+
+
+
모니터링 데이터를 불러오는 중...
+
+
+ );
+ }
+
return (
@@ -16,7 +131,170 @@ export default function MonitoringPage() {
{/* 모니터링 대시보드 */}
-
+
+ {/* 헤더 */}
+
+
배치 모니터링
+
+
+ {autoRefresh ? : }
+ 자동 새로고침
+
+
+
+ 새로고침
+
+
+
+
+ {/* 통계 카드 */}
+
+
+
+ 총 작업 수
+ 📋
+
+
+ {monitoring.total_jobs}
+
+ 활성: {monitoring.active_jobs}개
+
+
+
+
+
+
+ 실행 중
+ 🔄
+
+
+ {monitoring.running_jobs}
+
+ 현재 실행 중인 작업
+
+
+
+
+
+
+ 오늘 성공
+ ✅
+
+
+ {monitoring.successful_jobs_today}
+
+ 성공률: {getSuccessRate()}%
+
+
+
+
+
+
+ 오늘 실패
+ ❌
+
+
+ {monitoring.failed_jobs_today}
+
+ 주의가 필요한 작업
+
+
+
+
+
+ {/* 성공률 진행바 */}
+
+
+ 오늘 실행 성공률
+
+
+
+
+ 성공: {monitoring.successful_jobs_today}건
+ 실패: {monitoring.failed_jobs_today}건
+
+
+
+ {getSuccessRate()}% 성공률
+
+
+
+
+
+ {/* 최근 실행 이력 */}
+
+
+ 최근 실행 이력
+
+
+ {monitoring.recent_executions.length === 0 ? (
+
+ 최근 실행 이력이 없습니다.
+
+ ) : (
+
+
+
+ 상태
+ 작업 ID
+ 시작 시간
+ 완료 시간
+ 실행 시간
+ 오류 메시지
+
+
+
+ {monitoring.recent_executions.map((execution) => (
+
+
+
+ {getStatusIcon(execution.execution_status)}
+ {getStatusBadge(execution.execution_status)}
+
+
+ #{execution.job_id}
+
+ {execution.started_at
+ ? new Date(execution.started_at).toLocaleString()
+ : "-"}
+
+
+ {execution.completed_at
+ ? new Date(execution.completed_at).toLocaleString()
+ : "-"}
+
+
+ {execution.execution_time_ms
+ ? formatDuration(execution.execution_time_ms)
+ : "-"}
+
+
+ {execution.error_message ? (
+
+ {execution.error_message}
+
+ ) : (
+ "-"
+ )}
+
+
+ ))}
+
+
+ )}
+
+
+
);
diff --git a/frontend/app/(main)/admin/page.tsx b/frontend/app/(main)/admin/page.tsx
index 3060e0fc..8658d7c6 100644
--- a/frontend/app/(main)/admin/page.tsx
+++ b/frontend/app/(main)/admin/page.tsx
@@ -1,4 +1,4 @@
-import { Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package } from "lucide-react";
+import { Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package, Building2 } from "lucide-react";
import Link from "next/link";
import { GlobalFileViewer } from "@/components/GlobalFileViewer";
@@ -9,6 +9,7 @@ export default function AdminPage() {
return (
+
{/* 주요 관리 기능 */}
diff --git a/frontend/app/(main)/admin/screenMng/dashboardList/DashboardListClient.tsx b/frontend/app/(main)/admin/screenMng/dashboardList/DashboardListClient.tsx
deleted file mode 100644
index c50aaa51..00000000
--- a/frontend/app/(main)/admin/screenMng/dashboardList/DashboardListClient.tsx
+++ /dev/null
@@ -1,449 +0,0 @@
-"use client";
-
-import { useState, useEffect } from "react";
-import { useRouter } from "next/navigation";
-import { dashboardApi } from "@/lib/api/dashboard";
-import { Dashboard } from "@/lib/api/dashboard";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { useToast } from "@/hooks/use-toast";
-import { Pagination, PaginationInfo } from "@/components/common/Pagination";
-import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal";
-import { Plus, Search, Edit, Trash2, Copy, MoreVertical, AlertCircle, RefreshCw } from "lucide-react";
-
-/**
- * 대시보드 목록 클라이언트 컴포넌트
- * - CSR 방식으로 초기 데이터 로드
- * - 대시보드 목록 조회
- * - 대시보드 생성/수정/삭제/복사
- */
-export default function DashboardListClient() {
- const router = useRouter();
- const { toast } = useToast();
-
- // 상태 관리
- const [dashboards, setDashboards] = useState
([]);
- const [loading, setLoading] = useState(true); // CSR이므로 초기 로딩 true
- const [error, setError] = useState(null);
- const [searchTerm, setSearchTerm] = useState("");
-
- // 페이지네이션 상태
- const [currentPage, setCurrentPage] = useState(1);
- const [pageSize, setPageSize] = useState(10);
- const [totalCount, setTotalCount] = useState(0);
-
- // 모달 상태
- const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
- const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null);
-
- // 대시보드 목록 로드
- const loadDashboards = async () => {
- try {
- setLoading(true);
- setError(null);
- const result = await dashboardApi.getMyDashboards({
- search: searchTerm,
- page: currentPage,
- limit: pageSize,
- });
- setDashboards(result.dashboards);
- setTotalCount(result.pagination.total);
- } catch (err) {
- console.error("Failed to load dashboards:", err);
- setError(
- err instanceof Error
- ? err.message
- : "대시보드 목록을 불러오는데 실패했습니다. 네트워크 연결을 확인하거나 잠시 후 다시 시도해주세요.",
- );
- } finally {
- setLoading(false);
- }
- };
-
- // 검색어/페이지 변경 시 fetch (초기 로딩 포함)
- useEffect(() => {
- loadDashboards();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [searchTerm, currentPage, pageSize]);
-
- // 페이지네이션 정보 계산
- const paginationInfo: PaginationInfo = {
- currentPage,
- totalPages: Math.ceil(totalCount / pageSize) || 1,
- totalItems: totalCount,
- itemsPerPage: pageSize,
- startItem: totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1,
- endItem: Math.min(currentPage * pageSize, totalCount),
- };
-
- // 페이지 변경 핸들러
- const handlePageChange = (page: number) => {
- setCurrentPage(page);
- };
-
- // 페이지 크기 변경 핸들러
- const handlePageSizeChange = (size: number) => {
- setPageSize(size);
- setCurrentPage(1); // 페이지 크기 변경 시 첫 페이지로
- };
-
- // 대시보드 삭제 확인 모달 열기
- const handleDeleteClick = (id: string, title: string) => {
- setDeleteTarget({ id, title });
- setDeleteDialogOpen(true);
- };
-
- // 대시보드 삭제 실행
- const handleDeleteConfirm = async () => {
- if (!deleteTarget) return;
-
- try {
- await dashboardApi.deleteDashboard(deleteTarget.id);
- setDeleteDialogOpen(false);
- setDeleteTarget(null);
- toast({
- title: "성공",
- description: "대시보드가 삭제되었습니다.",
- });
- loadDashboards();
- } catch (err) {
- console.error("Failed to delete dashboard:", err);
- setDeleteDialogOpen(false);
- toast({
- title: "오류",
- description: "대시보드 삭제에 실패했습니다.",
- variant: "destructive",
- });
- }
- };
-
- // 대시보드 복사
- const handleCopy = async (dashboard: Dashboard) => {
- try {
- const fullDashboard = await dashboardApi.getDashboard(dashboard.id);
-
- await dashboardApi.createDashboard({
- title: `${fullDashboard.title} (복사본)`,
- description: fullDashboard.description,
- elements: fullDashboard.elements || [],
- isPublic: false,
- tags: fullDashboard.tags,
- category: fullDashboard.category,
- settings: fullDashboard.settings as { resolution?: string; backgroundColor?: string },
- });
- toast({
- title: "성공",
- description: "대시보드가 복사되었습니다.",
- });
- loadDashboards();
- } catch (err) {
- console.error("Failed to copy dashboard:", err);
- toast({
- title: "오류",
- description: "대시보드 복사에 실패했습니다.",
- variant: "destructive",
- });
- }
- };
-
- // 포맷팅 헬퍼
- const formatDate = (dateString: string) => {
- return new Date(dateString).toLocaleDateString("ko-KR", {
- year: "numeric",
- month: "2-digit",
- day: "2-digit",
- });
- };
-
- return (
- <>
- {/* 검색 및 액션 */}
-
-
-
-
- setSearchTerm(e.target.value)}
- className="h-10 pl-10 text-sm"
- />
-
-
- 총 {totalCount.toLocaleString()} 건
-
-
-
router.push("/admin/screenMng/dashboardList/new")} className="h-10 gap-2 text-sm font-medium">
- 새 대시보드 생성
-
-
-
- {/* 대시보드 목록 */}
- {loading ? (
- <>
- {/* 데스크톱 테이블 스켈레톤 */}
-
-
-
-
- 제목
- 설명
- 생성자
- 생성일
- 수정일
- 작업
-
-
-
- {Array.from({ length: 10 }).map((_, index) => (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ))}
-
-
-
-
- {/* 모바일/태블릿 카드 스켈레톤 */}
-
- {Array.from({ length: 6 }).map((_, index) => (
-
-
-
- {Array.from({ length: 3 }).map((_, i) => (
-
- ))}
-
-
- ))}
-
- >
- ) : error ? (
-
-
-
-
-
데이터를 불러올 수 없습니다
-
{error}
-
-
-
- 다시 시도
-
-
-
- ) : dashboards.length === 0 ? (
-
- ) : (
- <>
- {/* 데스크톱 테이블 뷰 (lg 이상) */}
-
-
-
-
- 제목
- 설명
- 생성자
- 생성일
- 수정일
- 작업
-
-
-
- {dashboards.map((dashboard) => (
-
-
- router.push(`/admin/screenMng/dashboardList/edit/${dashboard.id}`)}
- className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
- >
- {dashboard.title}
-
-
-
- {dashboard.description || "-"}
-
-
- {dashboard.createdByName || dashboard.createdBy || "-"}
-
-
- {formatDate(dashboard.createdAt)}
-
-
- {formatDate(dashboard.updatedAt)}
-
-
-
-
-
-
-
-
-
- router.push(`/admin/screenMng/dashboardList/edit/${dashboard.id}`)}
- className="gap-2 text-sm"
- >
-
- 편집
-
- handleCopy(dashboard)} className="gap-2 text-sm">
-
- 복사
-
- handleDeleteClick(dashboard.id, dashboard.title)}
- className="text-destructive focus:text-destructive gap-2 text-sm"
- >
-
- 삭제
-
-
-
-
-
- ))}
-
-
-
-
- {/* 모바일/태블릿 카드 뷰 (lg 미만) */}
-
- {dashboards.map((dashboard) => (
-
- {/* 헤더 */}
-
-
-
router.push(`/admin/screenMng/dashboardList/edit/${dashboard.id}`)}
- className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
- >
- {dashboard.title}
-
-
{dashboard.id}
-
-
-
- {/* 정보 */}
-
-
- 설명
- {dashboard.description || "-"}
-
-
- 생성자
- {dashboard.createdByName || dashboard.createdBy || "-"}
-
-
- 생성일
- {formatDate(dashboard.createdAt)}
-
-
- 수정일
- {formatDate(dashboard.updatedAt)}
-
-
-
- {/* 액션 */}
-
- router.push(`/admin/screenMng/dashboardList/edit/${dashboard.id}`)}
- >
-
- 편집
-
- handleCopy(dashboard)}
- >
-
- 복사
-
- handleDeleteClick(dashboard.id, dashboard.title)}
- >
-
-
-
-
- ))}
-
- >
- )}
-
- {/* 페이지네이션 */}
- {!loading && dashboards.length > 0 && (
-
- )}
-
- {/* 삭제 확인 모달 */}
-
- "{deleteTarget?.title}" 대시보드를 삭제하시겠습니까?
- 이 작업은 되돌릴 수 없습니다.
- >
- }
- onConfirm={handleDeleteConfirm}
- />
- >
- );
-}
diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/app/(main)/admin/screenMng/dashboardList/[id]/page.tsx
similarity index 95%
rename from frontend/components/admin/dashboard/DashboardDesigner.tsx
rename to frontend/app/(main)/admin/screenMng/dashboardList/[id]/page.tsx
index b945cb3d..63900c78 100644
--- a/frontend/components/admin/dashboard/DashboardDesigner.tsx
+++ b/frontend/app/(main)/admin/screenMng/dashboardList/[id]/page.tsx
@@ -1,17 +1,18 @@
"use client";
import React, { useState, useRef, useCallback } from "react";
+import { use } from "react";
import { useRouter } from "next/navigation";
-import { DashboardCanvas } from "./DashboardCanvas";
-import { DashboardTopMenu } from "./DashboardTopMenu";
-import { WidgetConfigSidebar } from "./WidgetConfigSidebar";
-import { DashboardSaveModal } from "./DashboardSaveModal";
-import { DashboardElement, ElementType, ElementSubtype } from "./types";
-import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateBoxSize } from "./gridUtils";
-import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector";
+import { DashboardCanvas } from "@/components/admin/dashboard/DashboardCanvas";
+import { DashboardTopMenu } from "@/components/admin/dashboard/DashboardTopMenu";
+import { WidgetConfigSidebar } from "@/components/admin/dashboard/WidgetConfigSidebar";
+import { DashboardSaveModal } from "@/components/admin/dashboard/DashboardSaveModal";
+import { DashboardElement, ElementType, ElementSubtype } from "@/components/admin/dashboard/types";
+import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateBoxSize } from "@/components/admin/dashboard/gridUtils";
+import { Resolution, RESOLUTIONS, detectScreenResolution } from "@/components/admin/dashboard/ResolutionSelector";
import { DashboardProvider } from "@/contexts/DashboardContext";
import { useMenu } from "@/contexts/MenuContext";
-import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
+import { useKeyboardShortcuts } from "@/components/admin/dashboard/hooks/useKeyboardShortcuts";
import {
Dialog,
DialogContent,
@@ -32,18 +33,24 @@ import {
import { Button } from "@/components/ui/button";
import { CheckCircle2 } from "lucide-react";
-interface DashboardDesignerProps {
- dashboardId?: string;
-}
-
/**
- * 대시보드 설계 도구 메인 컴포넌트
+ * 대시보드 생성/편집 페이지
+ * URL: /admin/screenMng/dashboardList/[id]
+ * - id가 "new"면 새 대시보드 생성
+ * - id가 숫자면 기존 대시보드 편집
+ *
+ * 기능:
* - 드래그 앤 드롭으로 차트/위젯 배치
* - 그리드 기반 레이아웃 (12 컬럼)
* - 요소 이동, 크기 조절, 삭제 기능
* - 레이아웃 저장/불러오기 기능
*/
-export default function DashboardDesigner({ dashboardId: initialDashboardId }: DashboardDesignerProps = {}) {
+export default function DashboardDesignerPage({ params }: { params: Promise<{ id: string }> }) {
+ const { id: paramId } = use(params);
+
+ // "new"면 생성 모드, 아니면 편집 모드
+ const initialDashboardId = paramId === "new" ? undefined : paramId;
+
const router = useRouter();
const { refreshMenus } = useMenu();
const [elements, setElements] = useState([]);
diff --git a/frontend/app/(main)/admin/screenMng/dashboardList/edit/[id]/page.tsx b/frontend/app/(main)/admin/screenMng/dashboardList/edit/[id]/page.tsx
deleted file mode 100644
index 92220b6c..00000000
--- a/frontend/app/(main)/admin/screenMng/dashboardList/edit/[id]/page.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-"use client";
-
-import React from "react";
-import { use } from "react";
-import DashboardDesigner from "@/components/admin/dashboard/DashboardDesigner";
-
-interface PageProps {
- params: Promise<{ id: string }>;
-}
-
-/**
- * 대시보드 편집 페이지
- * - 기존 대시보드 편집
- */
-export default function DashboardEditPage({ params }: PageProps) {
- const { id } = use(params);
-
- return (
-
-
-
- );
-}
diff --git a/frontend/app/(main)/admin/screenMng/dashboardList/new/page.tsx b/frontend/app/(main)/admin/screenMng/dashboardList/new/page.tsx
deleted file mode 100644
index 56d28f46..00000000
--- a/frontend/app/(main)/admin/screenMng/dashboardList/new/page.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import DashboardDesigner from "@/components/admin/dashboard/DashboardDesigner";
-
-/**
- * 새 대시보드 생성 페이지
- */
-export default function DashboardNewPage() {
- return (
-
-
-
- );
-}
diff --git a/frontend/app/(main)/admin/screenMng/dashboardList/page.tsx b/frontend/app/(main)/admin/screenMng/dashboardList/page.tsx
index 62587c54..c346dc54 100644
--- a/frontend/app/(main)/admin/screenMng/dashboardList/page.tsx
+++ b/frontend/app/(main)/admin/screenMng/dashboardList/page.tsx
@@ -1,11 +1,167 @@
-import DashboardListClient from "@/app/(main)/admin/screenMng/dashboardList/DashboardListClient";
+"use client";
+
+import { useState, useEffect } from "react";
+import { useRouter } from "next/navigation";
+import { dashboardApi } from "@/lib/api/dashboard";
+import { Dashboard } from "@/lib/api/dashboard";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { useToast } from "@/hooks/use-toast";
+import { Pagination, PaginationInfo } from "@/components/common/Pagination";
+import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal";
+import { Plus, Search, Edit, Trash2, Copy, MoreVertical, AlertCircle, RefreshCw } from "lucide-react";
/**
* 대시보드 관리 페이지
- * - 클라이언트 컴포넌트를 렌더링하는 래퍼
- * - 초기 로딩부터 CSR로 처리
+ * - CSR 방식으로 초기 데이터 로드
+ * - 대시보드 목록 조회
+ * - 대시보드 생성/수정/삭제/복사
*/
export default function DashboardListPage() {
+ const router = useRouter();
+ const { toast } = useToast();
+
+ // 상태 관리
+ const [dashboards, setDashboards] = useState([]);
+ const [loading, setLoading] = useState(true); // CSR이므로 초기 로딩 true
+ const [error, setError] = useState(null);
+ const [searchTerm, setSearchTerm] = useState("");
+
+ // 페이지네이션 상태
+ const [currentPage, setCurrentPage] = useState(1);
+ const [pageSize, setPageSize] = useState(10);
+ const [totalCount, setTotalCount] = useState(0);
+
+ // 모달 상태
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null);
+
+ // 대시보드 목록 로드
+ const loadDashboards = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const result = await dashboardApi.getMyDashboards({
+ search: searchTerm,
+ page: currentPage,
+ limit: pageSize,
+ });
+ setDashboards(result.dashboards);
+ setTotalCount(result.pagination.total);
+ } catch (err) {
+ console.error("Failed to load dashboards:", err);
+ setError(
+ err instanceof Error
+ ? err.message
+ : "대시보드 목록을 불러오는데 실패했습니다. 네트워크 연결을 확인하거나 잠시 후 다시 시도해주세요.",
+ );
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // 검색어/페이지 변경 시 fetch (초기 로딩 포함)
+ useEffect(() => {
+ loadDashboards();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [searchTerm, currentPage, pageSize]);
+
+ // 페이지네이션 정보 계산
+ const paginationInfo: PaginationInfo = {
+ currentPage,
+ totalPages: Math.ceil(totalCount / pageSize) || 1,
+ totalItems: totalCount,
+ itemsPerPage: pageSize,
+ startItem: totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1,
+ endItem: Math.min(currentPage * pageSize, totalCount),
+ };
+
+ // 페이지 변경 핸들러
+ const handlePageChange = (page: number) => {
+ setCurrentPage(page);
+ };
+
+ // 페이지 크기 변경 핸들러
+ const handlePageSizeChange = (size: number) => {
+ setPageSize(size);
+ setCurrentPage(1); // 페이지 크기 변경 시 첫 페이지로
+ };
+
+ // 대시보드 삭제 확인 모달 열기
+ const handleDeleteClick = (id: string, title: string) => {
+ setDeleteTarget({ id, title });
+ setDeleteDialogOpen(true);
+ };
+
+ // 대시보드 삭제 실행
+ const handleDeleteConfirm = async () => {
+ if (!deleteTarget) return;
+
+ try {
+ await dashboardApi.deleteDashboard(deleteTarget.id);
+ setDeleteDialogOpen(false);
+ setDeleteTarget(null);
+ toast({
+ title: "성공",
+ description: "대시보드가 삭제되었습니다.",
+ });
+ loadDashboards();
+ } catch (err) {
+ console.error("Failed to delete dashboard:", err);
+ setDeleteDialogOpen(false);
+ toast({
+ title: "오류",
+ description: "대시보드 삭제에 실패했습니다.",
+ variant: "destructive",
+ });
+ }
+ };
+
+ // 대시보드 복사
+ const handleCopy = async (dashboard: Dashboard) => {
+ try {
+ const fullDashboard = await dashboardApi.getDashboard(dashboard.id);
+
+ await dashboardApi.createDashboard({
+ title: `${fullDashboard.title} (복사본)`,
+ description: fullDashboard.description,
+ elements: fullDashboard.elements || [],
+ isPublic: false,
+ tags: fullDashboard.tags,
+ category: fullDashboard.category,
+ settings: fullDashboard.settings as { resolution?: string; backgroundColor?: string },
+ });
+ toast({
+ title: "성공",
+ description: "대시보드가 복사되었습니다.",
+ });
+ loadDashboards();
+ } catch (err) {
+ console.error("Failed to copy dashboard:", err);
+ toast({
+ title: "오류",
+ description: "대시보드 복사에 실패했습니다.",
+ variant: "destructive",
+ });
+ }
+ };
+
+ // 포맷팅 헬퍼
+ const formatDate = (dateString: string) => {
+ return new Date(dateString).toLocaleDateString("ko-KR", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ });
+ };
+
return (
@@ -15,8 +171,287 @@ export default function DashboardListPage() {
대시보드를 생성하고 관리할 수 있습니다
- {/* 클라이언트 컴포넌트 */}
-
+ {/* 검색 및 액션 */}
+
+
+
+
+ setSearchTerm(e.target.value)}
+ className="h-10 pl-10 text-sm"
+ />
+
+
+ 총 {totalCount.toLocaleString()} 건
+
+
+
router.push("/admin/screenMng/dashboardList/new")} className="h-10 gap-2 text-sm font-medium">
+
+ 새 대시보드 생성
+
+
+
+ {/* 대시보드 목록 */}
+ {loading ? (
+ <>
+ {/* 데스크톱 테이블 스켈레톤 */}
+
+
+
+
+ 제목
+ 설명
+ 생성자
+ 생성일
+ 수정일
+ 작업
+
+
+
+ {Array.from({ length: 10 }).map((_, index) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+ {/* 모바일/태블릿 카드 스켈레톤 */}
+
+ {Array.from({ length: 6 }).map((_, index) => (
+
+
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+
+ ))}
+
+ >
+ ) : error ? (
+
+
+
+
+
데이터를 불러올 수 없습니다
+
{error}
+
+
+
+ 다시 시도
+
+
+
+ ) : dashboards.length === 0 ? (
+
+ ) : (
+ <>
+ {/* 데스크톱 테이블 뷰 (lg 이상) */}
+
+
+
+
+ 제목
+ 설명
+ 생성자
+ 생성일
+ 수정일
+ 작업
+
+
+
+ {dashboards.map((dashboard) => (
+
+
+ router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
+ className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
+ >
+ {dashboard.title}
+
+
+
+ {dashboard.description || "-"}
+
+
+ {dashboard.createdByName || dashboard.createdBy || "-"}
+
+
+ {formatDate(dashboard.createdAt)}
+
+
+ {formatDate(dashboard.updatedAt)}
+
+
+
+
+
+
+
+
+
+ router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
+ className="gap-2 text-sm"
+ >
+
+ 편집
+
+ handleCopy(dashboard)} className="gap-2 text-sm">
+
+ 복사
+
+ handleDeleteClick(dashboard.id, dashboard.title)}
+ className="text-destructive focus:text-destructive gap-2 text-sm"
+ >
+
+ 삭제
+
+
+
+
+
+ ))}
+
+
+
+
+ {/* 모바일/태블릿 카드 뷰 (lg 미만) */}
+
+ {dashboards.map((dashboard) => (
+
+ {/* 헤더 */}
+
+
+
router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
+ className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
+ >
+ {dashboard.title}
+
+
{dashboard.id}
+
+
+
+ {/* 정보 */}
+
+
+ 설명
+ {dashboard.description || "-"}
+
+
+ 생성자
+ {dashboard.createdByName || dashboard.createdBy || "-"}
+
+
+ 생성일
+ {formatDate(dashboard.createdAt)}
+
+
+ 수정일
+ {formatDate(dashboard.updatedAt)}
+
+
+
+ {/* 액션 */}
+
+ router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
+ >
+
+ 편집
+
+ handleCopy(dashboard)}
+ >
+
+ 복사
+
+ handleDeleteClick(dashboard.id, dashboard.title)}
+ >
+
+
+
+
+ ))}
+
+ >
+ )}
+
+ {/* 페이지네이션 */}
+ {!loading && dashboards.length > 0 && (
+
+ )}
+
+ {/* 삭제 확인 모달 */}
+
+ "{deleteTarget?.title}" 대시보드를 삭제하시겠습니까?
+ 이 작업은 되돌릴 수 없습니다.
+ >
+ }
+ onConfirm={handleDeleteConfirm}
+ />
);
diff --git a/frontend/app/(main)/admin/systemMng/i18nList/page.tsx b/frontend/app/(main)/admin/systemMng/i18nList/page.tsx
index 48655de7..3acce6fb 100644
--- a/frontend/app/(main)/admin/systemMng/i18nList/page.tsx
+++ b/frontend/app/(main)/admin/systemMng/i18nList/page.tsx
@@ -1,12 +1,823 @@
"use client";
-import MultiLang from "@/components/admin/MultiLang";
+import { useState, useEffect } from "react";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Badge } from "@/components/ui/badge";
+
+import { DataTable } from "@/components/common/DataTable";
+import { LoadingSpinner } from "@/components/common/LoadingSpinner";
+import { useAuth } from "@/hooks/useAuth";
+import LangKeyModal from "@/components/admin/LangKeyModal";
+import LanguageModal from "@/components/admin/LanguageModal";
+import { apiClient } from "@/lib/api/client";
+
+interface Language {
+ langCode: string;
+ langName: string;
+ langNative: string;
+ isActive: string;
+}
+
+interface LangKey {
+ keyId: number;
+ companyCode: string;
+ menuName: string;
+ langKey: string;
+ description: string;
+ isActive: string;
+}
+
+interface LangText {
+ textId: number;
+ keyId: number;
+ langCode: string;
+ langText: string;
+ isActive: string;
+}
export default function I18nPage() {
+ const { user } = useAuth();
+ const [loading, setLoading] = useState(true);
+ const [languages, setLanguages] = useState
([]);
+ const [langKeys, setLangKeys] = useState([]);
+ const [selectedKey, setSelectedKey] = useState(null);
+ const [langTexts, setLangTexts] = useState([]);
+ const [editingTexts, setEditingTexts] = useState([]);
+ const [selectedCompany, setSelectedCompany] = useState("all");
+ const [searchText, setSearchText] = useState("");
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [editingKey, setEditingKey] = useState(null);
+ const [selectedKeys, setSelectedKeys] = useState>(new Set());
+
+ // 언어 관리 관련 상태
+ const [isLanguageModalOpen, setIsLanguageModalOpen] = useState(false);
+ const [editingLanguage, setEditingLanguage] = useState(null);
+ const [selectedLanguages, setSelectedLanguages] = useState>(new Set());
+ const [activeTab, setActiveTab] = useState<"keys" | "languages">("keys");
+
+ const [companies, setCompanies] = useState>([]);
+
+ // 회사 목록 조회
+ const fetchCompanies = async () => {
+ try {
+ const response = await apiClient.get("/admin/companies");
+ const data = response.data;
+ if (data.success) {
+ const companyList = data.data.map((company: any) => ({
+ code: company.company_code,
+ name: company.company_name,
+ }));
+ setCompanies(companyList);
+ }
+ } catch (error) {
+ // console.error("회사 목록 조회 실패:", error);
+ }
+ };
+
+ // 언어 목록 조회
+ const fetchLanguages = async () => {
+ try {
+ const response = await apiClient.get("/multilang/languages");
+ const data = response.data;
+ if (data.success) {
+ setLanguages(data.data);
+ }
+ } catch (error) {
+ // console.error("언어 목록 조회 실패:", error);
+ }
+ };
+
+ // 다국어 키 목록 조회
+ const fetchLangKeys = async () => {
+ try {
+ const response = await apiClient.get("/multilang/keys");
+ const data = response.data;
+ if (data.success) {
+ setLangKeys(data.data);
+ }
+ } catch (error) {
+ // console.error("다국어 키 목록 조회 실패:", error);
+ }
+ };
+
+ // 필터링된 데이터 계산
+ const getFilteredLangKeys = () => {
+ let filteredKeys = langKeys;
+
+ // 회사 필터링
+ if (selectedCompany && selectedCompany !== "all") {
+ filteredKeys = filteredKeys.filter((key) => key.companyCode === selectedCompany);
+ }
+
+ // 텍스트 검색 필터링
+ if (searchText.trim()) {
+ const searchLower = searchText.toLowerCase();
+ filteredKeys = filteredKeys.filter((key) => {
+ const langKey = (key.langKey || "").toLowerCase();
+ const description = (key.description || "").toLowerCase();
+ const menuName = (key.menuName || "").toLowerCase();
+ const companyName = companies.find((c) => c.code === key.companyCode)?.name?.toLowerCase() || "";
+
+ return (
+ langKey.includes(searchLower) ||
+ description.includes(searchLower) ||
+ menuName.includes(searchLower) ||
+ companyName.includes(searchLower)
+ );
+ });
+ }
+
+ return filteredKeys;
+ };
+
+ // 선택된 키의 다국어 텍스트 조회
+ const fetchLangTexts = async (keyId: number) => {
+ try {
+ const response = await apiClient.get(`/multilang/keys/${keyId}/texts`);
+ const data = response.data;
+ if (data.success) {
+ setLangTexts(data.data);
+ const editingData = data.data.map((text: LangText) => ({ ...text }));
+ setEditingTexts(editingData);
+ }
+ } catch (error) {
+ // console.error("다국어 텍스트 조회 실패:", error);
+ }
+ };
+
+ // 언어 키 선택 처리
+ const handleKeySelect = (key: LangKey) => {
+ setSelectedKey(key);
+ fetchLangTexts(key.keyId);
+ };
+
+ // 텍스트 변경 처리
+ const handleTextChange = (langCode: string, value: string) => {
+ const newEditingTexts = [...editingTexts];
+ const existingIndex = newEditingTexts.findIndex((t) => t.langCode === langCode);
+
+ if (existingIndex >= 0) {
+ newEditingTexts[existingIndex].langText = value;
+ } else {
+ newEditingTexts.push({
+ textId: 0,
+ keyId: selectedKey!.keyId,
+ langCode: langCode,
+ langText: value,
+ isActive: "Y",
+ });
+ }
+
+ setEditingTexts(newEditingTexts);
+ };
+
+ // 텍스트 저장
+ const handleSave = async () => {
+ if (!selectedKey) return;
+
+ try {
+ const requestData = {
+ texts: editingTexts.map((text) => ({
+ langCode: text.langCode,
+ langText: text.langText,
+ isActive: text.isActive || "Y",
+ createdBy: user?.userId || "system",
+ updatedBy: user?.userId || "system",
+ })),
+ };
+
+ const response = await apiClient.post(`/multilang/keys/${selectedKey.keyId}/texts`, requestData);
+ const data = response.data;
+ if (data.success) {
+ alert("저장되었습니다.");
+ fetchLangTexts(selectedKey.keyId);
+ }
+ } catch (error) {
+ alert("저장에 실패했습니다.");
+ }
+ };
+
+ // 언어 키 추가/수정 모달 열기
+ const handleAddKey = () => {
+ setEditingKey(null);
+ setIsModalOpen(true);
+ };
+
+ // 언어 추가/수정 모달 열기
+ const handleAddLanguage = () => {
+ setEditingLanguage(null);
+ setIsLanguageModalOpen(true);
+ };
+
+ // 언어 수정
+ const handleEditLanguage = (language: Language) => {
+ setEditingLanguage(language);
+ setIsLanguageModalOpen(true);
+ };
+
+ // 언어 저장 (추가/수정)
+ const handleSaveLanguage = async (languageData: any) => {
+ try {
+ const requestData = {
+ ...languageData,
+ createdBy: user?.userId || "admin",
+ updatedBy: user?.userId || "admin",
+ };
+
+ let response;
+ if (editingLanguage) {
+ response = await apiClient.put(`/multilang/languages/${editingLanguage.langCode}`, requestData);
+ } else {
+ response = await apiClient.post("/multilang/languages", requestData);
+ }
+
+ const result = response.data;
+
+ if (result.success) {
+ alert(editingLanguage ? "언어가 수정되었습니다." : "언어가 추가되었습니다.");
+ setIsLanguageModalOpen(false);
+ fetchLanguages();
+ } else {
+ alert(`오류: ${result.message}`);
+ }
+ } catch (error) {
+ alert("언어 저장 중 오류가 발생했습니다.");
+ }
+ };
+
+ // 언어 삭제
+ const handleDeleteLanguages = async () => {
+ if (selectedLanguages.size === 0) {
+ alert("삭제할 언어를 선택해주세요.");
+ return;
+ }
+
+ if (
+ !confirm(
+ `선택된 ${selectedLanguages.size}개의 언어를 영구적으로 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.`,
+ )
+ ) {
+ return;
+ }
+
+ try {
+ const deletePromises = Array.from(selectedLanguages).map((langCode) =>
+ apiClient.delete(`/multilang/languages/${langCode}`),
+ );
+
+ const responses = await Promise.all(deletePromises);
+ const failedDeletes = responses.filter((response) => !response.data.success);
+
+ if (failedDeletes.length === 0) {
+ alert("선택된 언어가 삭제되었습니다.");
+ setSelectedLanguages(new Set());
+ fetchLanguages();
+ } else {
+ alert(`${failedDeletes.length}개의 언어 삭제에 실패했습니다.`);
+ }
+ } catch (error) {
+ alert("언어 삭제 중 오류가 발생했습니다.");
+ }
+ };
+
+ // 언어 선택 체크박스 처리
+ const handleLanguageCheckboxChange = (langCode: string, checked: boolean) => {
+ const newSelected = new Set(selectedLanguages);
+ if (checked) {
+ newSelected.add(langCode);
+ } else {
+ newSelected.delete(langCode);
+ }
+ setSelectedLanguages(newSelected);
+ };
+
+ // 언어 전체 선택/해제
+ const handleSelectAllLanguages = (checked: boolean) => {
+ if (checked) {
+ setSelectedLanguages(new Set(languages.map((lang) => lang.langCode)));
+ } else {
+ setSelectedLanguages(new Set());
+ }
+ };
+
+ // 언어 키 수정 모달 열기
+ const handleEditKey = (key: LangKey) => {
+ setEditingKey(key);
+ setIsModalOpen(true);
+ };
+
+ // 언어 키 저장 (추가/수정)
+ const handleSaveKey = async (keyData: any) => {
+ try {
+ const requestData = {
+ ...keyData,
+ createdBy: user?.userId || "admin",
+ updatedBy: user?.userId || "admin",
+ };
+
+ let response;
+ if (editingKey) {
+ response = await apiClient.put(`/multilang/keys/${editingKey.keyId}`, requestData);
+ } else {
+ response = await apiClient.post("/multilang/keys", requestData);
+ }
+
+ const data = response.data;
+
+ if (data.success) {
+ alert(editingKey ? "언어 키가 수정되었습니다." : "언어 키가 추가되었습니다.");
+ fetchLangKeys();
+ setIsModalOpen(false);
+ } else {
+ if (data.message && data.message.includes("이미 존재하는 언어키")) {
+ alert(data.message);
+ } else {
+ alert(data.message || "언어 키 저장에 실패했습니다.");
+ }
+ }
+ } catch (error) {
+ alert("언어 키 저장에 실패했습니다.");
+ }
+ };
+
+ // 체크박스 선택/해제
+ const handleCheckboxChange = (keyId: number, checked: boolean) => {
+ const newSelectedKeys = new Set(selectedKeys);
+ if (checked) {
+ newSelectedKeys.add(keyId);
+ } else {
+ newSelectedKeys.delete(keyId);
+ }
+ setSelectedKeys(newSelectedKeys);
+ };
+
+ // 키 상태 토글
+ const handleToggleStatus = async (keyId: number) => {
+ try {
+ const response = await apiClient.put(`/multilang/keys/${keyId}/toggle`);
+ const data = response.data;
+ if (data.success) {
+ alert(`키가 ${data.data}되었습니다.`);
+ fetchLangKeys();
+ } else {
+ alert("상태 변경 중 오류가 발생했습니다.");
+ }
+ } catch (error) {
+ alert("키 상태 변경 중 오류가 발생했습니다.");
+ }
+ };
+
+ // 언어 상태 토글
+ const handleToggleLanguageStatus = async (langCode: string) => {
+ try {
+ const response = await apiClient.put(`/multilang/languages/${langCode}/toggle`);
+ const data = response.data;
+ if (data.success) {
+ alert(`언어가 ${data.data}되었습니다.`);
+ fetchLanguages();
+ } else {
+ alert("언어 상태 변경 중 오류가 발생했습니다.");
+ }
+ } catch (error) {
+ alert("언어 상태 변경 중 오류가 발생했습니다.");
+ }
+ };
+
+ // 전체 선택/해제
+ const handleSelectAll = (checked: boolean) => {
+ if (checked) {
+ const allKeyIds = getFilteredLangKeys().map((key) => key.keyId);
+ setSelectedKeys(new Set(allKeyIds));
+ } else {
+ setSelectedKeys(new Set());
+ }
+ };
+
+ // 선택된 키들 일괄 삭제
+ const handleDeleteSelectedKeys = async () => {
+ if (selectedKeys.size === 0) {
+ alert("삭제할 키를 선택해주세요.");
+ return;
+ }
+
+ if (
+ !confirm(
+ `선택된 ${selectedKeys.size}개의 언어 키를 영구적으로 삭제하시겠습니까?\n\n⚠️ 이 작업은 되돌릴 수 없습니다.`,
+ )
+ ) {
+ return;
+ }
+
+ try {
+ const deletePromises = Array.from(selectedKeys).map((keyId) => apiClient.delete(`/multilang/keys/${keyId}`));
+
+ const responses = await Promise.all(deletePromises);
+ const allSuccess = responses.every((response) => response.data.success);
+
+ if (allSuccess) {
+ alert(`${selectedKeys.size}개의 언어 키가 영구적으로 삭제되었습니다.`);
+ setSelectedKeys(new Set());
+ fetchLangKeys();
+
+ if (selectedKey && selectedKeys.has(selectedKey.keyId)) {
+ handleCancel();
+ }
+ } else {
+ alert("일부 키 삭제에 실패했습니다.");
+ }
+ } catch (error) {
+ alert("선택된 키 삭제에 실패했습니다.");
+ }
+ };
+
+ // 개별 키 삭제
+ const handleDeleteKey = async (keyId: number) => {
+ if (!confirm("정말로 이 언어 키를 영구적으로 삭제하시겠습니까?\n\n⚠️ 이 작업은 되돌릴 수 없습니다.")) {
+ return;
+ }
+
+ try {
+ const response = await apiClient.delete(`/multilang/keys/${keyId}`);
+ const data = response.data;
+ if (data.success) {
+ alert("언어 키가 영구적으로 삭제되었습니다.");
+ fetchLangKeys();
+ if (selectedKey && selectedKey.keyId === keyId) {
+ handleCancel();
+ }
+ }
+ } catch (error) {
+ alert("언어 키 삭제에 실패했습니다.");
+ }
+ };
+
+ // 취소 처리
+ const handleCancel = () => {
+ setSelectedKey(null);
+ setLangTexts([]);
+ setEditingTexts([]);
+ };
+
+ useEffect(() => {
+ const initializeData = async () => {
+ setLoading(true);
+ await Promise.all([fetchCompanies(), fetchLanguages(), fetchLangKeys()]);
+ setLoading(false);
+ };
+ initializeData();
+ }, []);
+
+ const columns = [
+ {
+ id: "select",
+ header: () => {
+ const filteredKeys = getFilteredLangKeys();
+ return (
+ 0}
+ onChange={(e) => handleSelectAll(e.target.checked)}
+ className="h-4 w-4"
+ />
+ );
+ },
+ cell: ({ row }: any) => (
+ handleCheckboxChange(row.original.keyId, e.target.checked)}
+ onClick={(e) => e.stopPropagation()}
+ className="h-4 w-4"
+ disabled={row.original.isActive === "N"}
+ />
+ ),
+ },
+ {
+ accessorKey: "companyCode",
+ header: "회사",
+ cell: ({ row }: any) => {
+ const companyName =
+ row.original.companyCode === "*"
+ ? "공통"
+ : companies.find((c) => c.code === row.original.companyCode)?.name || row.original.companyCode;
+
+ return {companyName} ;
+ },
+ },
+ {
+ accessorKey: "menuName",
+ header: "메뉴명",
+ cell: ({ row }: any) => (
+ {row.original.menuName}
+ ),
+ },
+ {
+ accessorKey: "langKey",
+ header: "언어 키",
+ cell: ({ row }: any) => (
+ handleEditKey(row.original)}
+ >
+ {row.original.langKey}
+
+ ),
+ },
+ {
+ accessorKey: "description",
+ header: "설명",
+ cell: ({ row }: any) => (
+ {row.original.description}
+ ),
+ },
+ {
+ accessorKey: "isActive",
+ header: "상태",
+ cell: ({ row }: any) => (
+ handleToggleStatus(row.original.keyId)}
+ className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
+ row.original.isActive === "Y"
+ ? "bg-green-100 text-green-800 hover:bg-green-200"
+ : "bg-gray-100 text-gray-800 hover:bg-gray-200"
+ }`}
+ >
+ {row.original.isActive === "Y" ? "활성" : "비활성"}
+
+ ),
+ },
+ ];
+
+ // 언어 테이블 컬럼 정의
+ const languageColumns = [
+ {
+ id: "select",
+ header: () => (
+ 0}
+ onChange={(e) => handleSelectAllLanguages(e.target.checked)}
+ className="h-4 w-4"
+ />
+ ),
+ cell: ({ row }: any) => (
+ handleLanguageCheckboxChange(row.original.langCode, e.target.checked)}
+ onClick={(e) => e.stopPropagation()}
+ className="h-4 w-4"
+ disabled={row.original.isActive === "N"}
+ />
+ ),
+ },
+ {
+ accessorKey: "langCode",
+ header: "언어 코드",
+ cell: ({ row }: any) => (
+ handleEditLanguage(row.original)}
+ >
+ {row.original.langCode}
+
+ ),
+ },
+ {
+ accessorKey: "langName",
+ header: "언어명 (영문)",
+ cell: ({ row }: any) => (
+ {row.original.langName}
+ ),
+ },
+ {
+ accessorKey: "langNative",
+ header: "언어명 (원어)",
+ cell: ({ row }: any) => (
+ {row.original.langNative}
+ ),
+ },
+ {
+ accessorKey: "isActive",
+ header: "상태",
+ cell: ({ row }: any) => (
+ handleToggleLanguageStatus(row.original.langCode)}
+ className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
+ row.original.isActive === "Y"
+ ? "bg-green-100 text-green-800 hover:bg-green-200"
+ : "bg-gray-100 text-gray-800 hover:bg-gray-200"
+ }`}
+ >
+ {row.original.isActive === "Y" ? "활성" : "비활성"}
+
+ ),
+ },
+ ];
+
+ if (loading) {
+ return ;
+ }
+
return (
-
+
+ {/* 탭 네비게이션 */}
+
+ setActiveTab("keys")}
+ className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${
+ activeTab === "keys" ? "bg-accent0 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
+ }`}
+ >
+ 다국어 키 관리
+
+ setActiveTab("languages")}
+ className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${
+ activeTab === "languages" ? "bg-accent0 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
+ }`}
+ >
+ 언어 관리
+
+
+
+ {/* 메인 콘텐츠 영역 */}
+
+ {/* 언어 관리 탭 */}
+ {activeTab === "languages" && (
+
+
+ 언어 관리
+
+
+
+
총 {languages.length}개의 언어가 등록되어 있습니다.
+
+ {selectedLanguages.size > 0 && (
+
+ 선택 삭제 ({selectedLanguages.size})
+
+ )}
+ 새 언어 추가
+
+
+
+
+
+ )}
+
+ {/* 다국어 키 관리 탭 */}
+ {activeTab === "keys" && (
+
+ {/* 좌측: 언어 키 목록 (7/10) */}
+
+
+
+
언어 키 목록
+
+
+ 선택 삭제 ({selectedKeys.size})
+
+ 새 키 추가
+
+
+
+
+ {/* 검색 필터 영역 */}
+
+
+ 회사
+
+
+
+
+
+ 전체 회사
+ {companies.map((company) => (
+
+ {company.name}
+
+ ))}
+
+
+
+
+
+ 검색
+ setSearchText(e.target.value)}
+ />
+
+
+
+
검색 결과: {getFilteredLangKeys().length}건
+
+
+
+ {/* 테이블 영역 */}
+
+
전체: {getFilteredLangKeys().length}건
+
+
+
+
+
+ {/* 우측: 선택된 키의 다국어 관리 (3/10) */}
+
+
+
+ {selectedKey ? (
+ <>
+ 선택된 키:{" "}
+
+ {selectedKey.companyCode}.{selectedKey.menuName}.{selectedKey.langKey}
+
+ >
+ ) : (
+ "다국어 편집"
+ )}
+
+
+
+ {selectedKey ? (
+
+ {/* 스크롤 가능한 텍스트 영역 */}
+
+ {languages
+ .filter((lang) => lang.isActive === "Y")
+ .map((lang) => {
+ const text = editingTexts.find((t) => t.langCode === lang.langCode);
+ return (
+
+
+ {lang.langName}
+
+ handleTextChange(lang.langCode, e.target.value)}
+ className="flex-1"
+ />
+
+ );
+ })}
+
+ {/* 저장 버튼 - 고정 위치 */}
+
+ 저장
+
+ 취소
+
+
+
+ ) : (
+
+
+
언어 키를 선택하세요
+
좌측 목록에서 편집할 언어 키를 클릭하세요
+
+
+ )}
+
+
+
+ )}
+
+
+ {/* 언어 키 추가/수정 모달 */}
+
setIsModalOpen(false)}
+ onSave={handleSaveKey}
+ keyData={editingKey}
+ companies={companies}
+ />
+
+ {/* 언어 추가/수정 모달 */}
+ setIsLanguageModalOpen(false)}
+ onSave={handleSaveLanguage}
+ languageData={editingLanguage}
+ />
+
);
diff --git a/frontend/app/(main)/admin/userMng/companyList/[companyCode]/departments/page.tsx b/frontend/app/(main)/admin/userMng/companyList/[companyCode]/departments/page.tsx
index 7854e6ee..a9cd747c 100644
--- a/frontend/app/(main)/admin/userMng/companyList/[companyCode]/departments/page.tsx
+++ b/frontend/app/(main)/admin/userMng/companyList/[companyCode]/departments/page.tsx
@@ -1,12 +1,115 @@
"use client";
-import { useParams } from "next/navigation";
-import { DepartmentManagement } from "@/components/admin/department/DepartmentManagement";
+import { useState, useEffect } from "react";
+import { useParams, useRouter } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { ArrowLeft } from "lucide-react";
+import { DepartmentStructure } from "@/components/admin/department/DepartmentStructure";
+import { DepartmentMembers } from "@/components/admin/department/DepartmentMembers";
+import type { Department } from "@/types/department";
+import { getCompanyList } from "@/lib/api/company";
+/**
+ * 부서 관리 메인 페이지
+ * 좌측: 부서 구조, 우측: 부서 인원
+ */
export default function DepartmentManagementPage() {
const params = useParams();
+ const router = useRouter();
const companyCode = params.companyCode as string;
+ const [selectedDepartment, setSelectedDepartment] = useState(null);
+ const [activeTab, setActiveTab] = useState("structure");
+ const [companyName, setCompanyName] = useState("");
+ const [refreshTrigger, setRefreshTrigger] = useState(0);
- return ;
+ // 부서원 변경 시 부서 구조 새로고침
+ const handleMemberChange = () => {
+ setRefreshTrigger((prev) => prev + 1);
+ };
+
+ // 회사 정보 로드
+ useEffect(() => {
+ const loadCompanyInfo = async () => {
+ const response = await getCompanyList();
+ if (response.success && response.data) {
+ const company = response.data.find((c) => c.company_code === companyCode);
+ if (company) {
+ setCompanyName(company.company_name);
+ }
+ }
+ };
+ loadCompanyInfo();
+ }, [companyCode]);
+
+ const handleBackToList = () => {
+ router.push("/admin/userMng/companyList");
+ };
+
+ return (
+
+ {/* 상단 헤더: 회사 정보 + 뒤로가기 */}
+
+
+
+
+ 회사 목록
+
+
+
+
{companyName || companyCode}
+
부서 관리
+
+
+
+ {/* 탭 네비게이션 (모바일용) */}
+
+
+
+ 부서 구조
+ 부서 인원
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* 좌우 레이아웃 (데스크톱) */}
+
+ {/* 좌측: 부서 구조 (20%) */}
+
+
+
+
+ {/* 우측: 부서 인원 (80%) */}
+
+
+
+
+
+ );
}
-
diff --git a/frontend/app/(main)/admin/userMng/companyList/page.tsx b/frontend/app/(main)/admin/userMng/companyList/page.tsx
index c24afc7a..a36cd9c3 100644
--- a/frontend/app/(main)/admin/userMng/companyList/page.tsx
+++ b/frontend/app/(main)/admin/userMng/companyList/page.tsx
@@ -1,10 +1,56 @@
-import { CompanyManagement } from "@/components/admin/CompanyManagement";
+"use client";
+
+import { useCompanyManagement } from "@/hooks/useCompanyManagement";
+import { CompanyToolbar } from "@/components/admin/CompanyToolbar";
+import { CompanyTable } from "@/components/admin/CompanyTable";
+import { CompanyFormModal } from "@/components/admin/CompanyFormModal";
+import { CompanyDeleteDialog } from "@/components/admin/CompanyDeleteDialog";
+import { DiskUsageSummary } from "@/components/admin/DiskUsageSummary";
import { ScrollToTop } from "@/components/common/ScrollToTop";
/**
* 회사 관리 페이지
+ * 모든 회사 관리 기능을 통합하여 제공
*/
export default function CompanyPage() {
+ const {
+ // 데이터
+ companies,
+ searchFilter,
+ isLoading,
+ error,
+
+ // 디스크 사용량 관련
+ diskUsageInfo,
+ isDiskUsageLoading,
+ loadDiskUsage,
+
+ // 모달 상태
+ modalState,
+ deleteState,
+
+ // 검색 기능
+ updateSearchFilter,
+ clearSearchFilter,
+
+ // 모달 제어
+ openCreateModal,
+ openEditModal,
+ closeModal,
+ updateFormData,
+
+ // 삭제 다이얼로그 제어
+ openDeleteDialog,
+ closeDeleteDialog,
+
+ // CRUD 작업
+ saveCompany,
+ deleteCompany,
+
+ // 에러 처리
+ clearError,
+ } = useCompanyManagement();
+
return (
@@ -14,8 +60,42 @@ export default function CompanyPage() {
시스템에서 사용하는 회사 정보를 관리합니다
- {/* 메인 컨텐츠 */}
-
+ {/* 디스크 사용량 요약 */}
+
+
+ {/* 툴바 - 검색, 필터, 등록 버튼 */}
+
+
+ {/* 회사 목록 테이블 */}
+
+
+ {/* 회사 등록/수정 모달 */}
+
+
+ {/* 회사 삭제 확인 다이얼로그 */}
+
{/* Scroll to Top 버튼 */}
diff --git a/frontend/app/(main)/admin/userMng/rolesList/[id]/page.tsx b/frontend/app/(main)/admin/userMng/rolesList/[id]/page.tsx
index a1579bf2..30552af4 100644
--- a/frontend/app/(main)/admin/userMng/rolesList/[id]/page.tsx
+++ b/frontend/app/(main)/admin/userMng/rolesList/[id]/page.tsx
@@ -1,12 +1,20 @@
"use client";
+import React, { useState, useCallback, useEffect } from "react";
import { use } from "react";
-import { RoleDetailManagement } from "@/components/admin/RoleDetailManagement";
+import { Button } from "@/components/ui/button";
+import { ArrowLeft, Users, Menu as MenuIcon, Save, AlertCircle } from "lucide-react";
+import { roleAPI, RoleGroup } from "@/lib/api/role";
+import { useAuth } from "@/hooks/useAuth";
+import { useRouter } from "next/navigation";
+import { DualListBox } from "@/components/common/DualListBox";
+import { MenuPermissionsTable } from "@/components/admin/MenuPermissionsTable";
+import { useMenu } from "@/contexts/MenuContext";
import { ScrollToTop } from "@/components/common/ScrollToTop";
/**
* 권한 그룹 상세 페이지
- * URL: /admin/roles/[id]
+ * URL: /admin/userMng/rolesList/[id]
*
* 기능:
* - 권한 그룹 멤버 관리 (Dual List Box)
@@ -14,13 +22,324 @@ import { ScrollToTop } from "@/components/common/ScrollToTop";
*/
export default function RoleDetailPage({ params }: { params: Promise<{ id: string }> }) {
// Next.js 15: params는 Promise이므로 React.use()로 unwrap
- const { id } = use(params);
+ const { id: roleId } = use(params);
+ const { user: currentUser } = useAuth();
+ const router = useRouter();
+ const { refreshMenus } = useMenu();
+
+ const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
+
+ // 상태 관리
+ const [roleGroup, setRoleGroup] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ // 탭 상태
+ const [activeTab, setActiveTab] = useState<"members" | "permissions">("members");
+
+ // 멤버 관리 상태
+ const [availableUsers, setAvailableUsers] = useState>([]);
+ const [selectedUsers, setSelectedUsers] = useState>([]);
+ const [isSavingMembers, setIsSavingMembers] = useState(false);
+
+ // 메뉴 권한 상태
+ const [menuPermissions, setMenuPermissions] = useState([]);
+ const [isSavingPermissions, setIsSavingPermissions] = useState(false);
+
+ // 데이터 로드
+ const loadRoleGroup = useCallback(async () => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const response = await roleAPI.getById(parseInt(roleId, 10));
+
+ if (response.success && response.data) {
+ setRoleGroup(response.data);
+ } else {
+ setError(response.message || "권한 그룹 정보를 불러오는데 실패했습니다.");
+ }
+ } catch (err) {
+ console.error("권한 그룹 정보 로드 오류:", err);
+ setError("권한 그룹 정보를 불러오는 중 오류가 발생했습니다.");
+ } finally {
+ setIsLoading(false);
+ }
+ }, [roleId]);
+
+ // 멤버 목록 로드
+ const loadMembers = useCallback(async () => {
+ if (!roleGroup) return;
+
+ try {
+ // 1. 권한 그룹 멤버 조회
+ const membersResponse = await roleAPI.getMembers(roleGroup.objid);
+ if (membersResponse.success && membersResponse.data) {
+ setSelectedUsers(
+ membersResponse.data.map((member: any) => ({
+ id: member.userId,
+ label: member.userName || member.userId,
+ description: member.deptName,
+ })),
+ );
+ }
+
+ // 2. 전체 사용자 목록 조회 (같은 회사)
+ const userAPI = await import("@/lib/api/user");
+
+ console.log("🔍 사용자 목록 조회 요청:", {
+ companyCode: roleGroup.companyCode,
+ size: 1000,
+ });
+
+ const usersResponse = await userAPI.userAPI.getList({
+ companyCode: roleGroup.companyCode,
+ size: 1000, // 대량 조회
+ });
+
+ console.log("✅ 사용자 목록 응답:", {
+ success: usersResponse.success,
+ count: usersResponse.data?.length,
+ total: usersResponse.total,
+ });
+
+ if (usersResponse.success && usersResponse.data) {
+ setAvailableUsers(
+ usersResponse.data.map((user: any) => ({
+ id: user.userId,
+ label: user.userName || user.userId,
+ description: user.deptName,
+ })),
+ );
+ console.log("📋 설정된 전체 사용자 수:", usersResponse.data.length);
+ }
+ } catch (err) {
+ console.error("멤버 목록 로드 오류:", err);
+ }
+ }, [roleGroup]);
+
+ // 메뉴 권한 로드
+ const loadMenuPermissions = useCallback(async () => {
+ if (!roleGroup) return;
+
+ console.log("🔍 [loadMenuPermissions] 메뉴 권한 로드 시작", {
+ roleGroupId: roleGroup.objid,
+ roleGroupName: roleGroup.authName,
+ companyCode: roleGroup.companyCode,
+ });
+
+ try {
+ const response = await roleAPI.getMenuPermissions(roleGroup.objid);
+
+ console.log("✅ [loadMenuPermissions] API 응답", {
+ success: response.success,
+ dataCount: response.data?.length,
+ data: response.data,
+ });
+
+ if (response.success && response.data) {
+ setMenuPermissions(response.data);
+ console.log("✅ [loadMenuPermissions] 상태 업데이트 완료", {
+ count: response.data.length,
+ });
+ } else {
+ console.warn("⚠️ [loadMenuPermissions] 응답 실패", {
+ message: response.message,
+ });
+ }
+ } catch (err) {
+ console.error("❌ [loadMenuPermissions] 메뉴 권한 로드 오류:", err);
+ }
+ }, [roleGroup]);
+
+ useEffect(() => {
+ loadRoleGroup();
+ }, [loadRoleGroup]);
+
+ useEffect(() => {
+ if (roleGroup && activeTab === "members") {
+ loadMembers();
+ } else if (roleGroup && activeTab === "permissions") {
+ loadMenuPermissions();
+ }
+ }, [roleGroup, activeTab, loadMembers, loadMenuPermissions]);
+
+ // 멤버 저장 핸들러
+ const handleSaveMembers = useCallback(async () => {
+ if (!roleGroup) return;
+
+ setIsSavingMembers(true);
+ try {
+ // 현재 선택된 사용자 ID 목록
+ const selectedUserIds = selectedUsers.map((user) => user.id);
+
+ // 멤버 업데이트 API 호출
+ const response = await roleAPI.updateMembers(roleGroup.objid, selectedUserIds);
+
+ if (response.success) {
+ alert("멤버가 성공적으로 저장되었습니다.");
+ loadMembers(); // 새로고침
+
+ // 사이드바 메뉴 새로고침 (현재 사용자가 영향받을 수 있음)
+ await refreshMenus();
+ } else {
+ alert(response.message || "멤버 저장에 실패했습니다.");
+ }
+ } catch (err) {
+ console.error("멤버 저장 오류:", err);
+ alert("멤버 저장 중 오류가 발생했습니다.");
+ } finally {
+ setIsSavingMembers(false);
+ }
+ }, [roleGroup, selectedUsers, loadMembers, refreshMenus]);
+
+ // 메뉴 권한 저장 핸들러
+ const handleSavePermissions = useCallback(async () => {
+ if (!roleGroup) return;
+
+ setIsSavingPermissions(true);
+ try {
+ const response = await roleAPI.setMenuPermissions(roleGroup.objid, menuPermissions);
+
+ if (response.success) {
+ alert("메뉴 권한이 성공적으로 저장되었습니다.");
+ loadMenuPermissions(); // 새로고침
+
+ // 사이드바 메뉴 새로고침 (권한 변경 즉시 반영)
+ await refreshMenus();
+ } else {
+ alert(response.message || "메뉴 권한 저장에 실패했습니다.");
+ }
+ } catch (err) {
+ console.error("메뉴 권한 저장 오류:", err);
+ alert("메뉴 권한 저장 중 오류가 발생했습니다.");
+ } finally {
+ setIsSavingPermissions(false);
+ }
+ }, [roleGroup, menuPermissions, loadMenuPermissions, refreshMenus]);
+
+ if (isLoading) {
+ return (
+
+
+
+
권한 그룹 정보를 불러오는 중...
+
+
+ );
+ }
+
+ if (error || !roleGroup) {
+ return (
+
+
+
오류 발생
+
{error || "권한 그룹을 찾을 수 없습니다."}
+
router.push("/admin/userMng/rolesList")}>
+ 목록으로 돌아가기
+
+
+ );
+ }
return (
- {/* 메인 컨텐츠 */}
-
+ {/* 페이지 헤더 */}
+
+
+
router.push("/admin/userMng/rolesList")} className="h-10 w-10">
+
+
+
+
{roleGroup.authName}
+
+ {roleGroup.authCode} • {roleGroup.companyCode}
+
+
+
+ {roleGroup.status === "active" ? "활성" : "비활성"}
+
+
+
+
+ {/* 탭 네비게이션 */}
+
+ setActiveTab("members")}
+ className={`flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors ${
+ activeTab === "members"
+ ? "border-primary text-primary"
+ : "text-muted-foreground hover:text-foreground border-transparent"
+ }`}
+ >
+
+ 멤버 관리 ({selectedUsers.length})
+
+ setActiveTab("permissions")}
+ className={`flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors ${
+ activeTab === "permissions"
+ ? "border-primary text-primary"
+ : "text-muted-foreground hover:text-foreground border-transparent"
+ }`}
+ >
+
+ 메뉴 권한 ({menuPermissions.length})
+
+
+
+ {/* 탭 컨텐츠 */}
+
+ {activeTab === "members" && (
+ <>
+
+
+
멤버 관리
+
이 권한 그룹에 속한 사용자를 관리합니다
+
+
+
+ {isSavingMembers ? "저장 중..." : "멤버 저장"}
+
+
+
+
+ >
+ )}
+
+ {activeTab === "permissions" && (
+ <>
+
+
+
메뉴 권한 설정
+
이 권한 그룹에서 접근 가능한 메뉴와 권한을 설정합니다
+
+
+
+ {isSavingPermissions ? "저장 중..." : "권한 저장"}
+
+
+
+
+ >
+ )}
+
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
diff --git a/frontend/app/(main)/admin/userMng/rolesList/page.tsx b/frontend/app/(main)/admin/userMng/rolesList/page.tsx
index 2b973ad5..eeac1dec 100644
--- a/frontend/app/(main)/admin/userMng/rolesList/page.tsx
+++ b/frontend/app/(main)/admin/userMng/rolesList/page.tsx
@@ -1,6 +1,16 @@
"use client";
-import { RoleManagement } from "@/components/admin/RoleManagement";
+import React, { useState, useCallback, useEffect } from "react";
+import { Button } from "@/components/ui/button";
+import { Plus, Edit, Trash2, Users, Menu, Filter, X } from "lucide-react";
+import { roleAPI, RoleGroup } from "@/lib/api/role";
+import { useAuth } from "@/hooks/useAuth";
+import { AlertCircle } from "lucide-react";
+import { RoleFormModal } from "@/components/admin/RoleFormModal";
+import { RoleDeleteModal } from "@/components/admin/RoleDeleteModal";
+import { useRouter } from "next/navigation";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { companyAPI } from "@/lib/api/company";
import { ScrollToTop } from "@/components/common/ScrollToTop";
/**
@@ -14,21 +24,336 @@ import { ScrollToTop } from "@/components/common/ScrollToTop";
* - 권한 그룹 생성/수정/삭제
* - 멤버 관리 (Dual List Box)
* - 메뉴 권한 설정 (CRUD 권한)
+ * - 상세 페이지로 이동 (멤버 관리 + 메뉴 권한 설정)
*/
export default function RolesPage() {
+ const { user: currentUser } = useAuth();
+ const router = useRouter();
+
+ // 회사 관리자 또는 최고 관리자 여부
+ const isAdmin =
+ (currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN") ||
+ currentUser?.userType === "COMPANY_ADMIN";
+ const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
+
+ // 상태 관리
+ const [roleGroups, setRoleGroups] = useState
([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ // 회사 필터 (최고 관리자 전용)
+ const [companies, setCompanies] = useState>([]);
+ const [selectedCompany, setSelectedCompany] = useState("all");
+
+ // 모달 상태
+ const [formModal, setFormModal] = useState({
+ isOpen: false,
+ editingRole: null as RoleGroup | null,
+ });
+
+ const [deleteModal, setDeleteModal] = useState({
+ isOpen: false,
+ role: null as RoleGroup | null,
+ });
+
+ // 회사 목록 로드 (최고 관리자만)
+ const loadCompanies = useCallback(async () => {
+ if (!isSuperAdmin) return;
+
+ try {
+ const companies = await companyAPI.getList();
+ setCompanies(companies);
+ } catch (error) {
+ console.error("회사 목록 로드 오류:", error);
+ }
+ }, [isSuperAdmin]);
+
+ // 데이터 로드
+ const loadRoleGroups = useCallback(async () => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ // 최고 관리자: selectedCompany에 따라 필터링 (all이면 전체 조회)
+ // 회사 관리자: 자기 회사만 조회
+ const companyFilter =
+ isSuperAdmin && selectedCompany !== "all"
+ ? selectedCompany
+ : isSuperAdmin
+ ? undefined
+ : currentUser?.companyCode;
+
+ console.log("권한 그룹 목록 조회:", { isSuperAdmin, selectedCompany, companyFilter });
+
+ const response = await roleAPI.getList({
+ companyCode: companyFilter,
+ });
+
+ if (response.success && response.data) {
+ setRoleGroups(response.data);
+ console.log("권한 그룹 조회 성공:", response.data.length, "개");
+ } else {
+ setError(response.message || "권한 그룹 목록을 불러오는데 실패했습니다.");
+ }
+ } catch (err) {
+ console.error("권한 그룹 목록 로드 오류:", err);
+ setError("권한 그룹 목록을 불러오는 중 오류가 발생했습니다.");
+ } finally {
+ setIsLoading(false);
+ }
+ }, [isSuperAdmin, selectedCompany, currentUser?.companyCode]);
+
+ useEffect(() => {
+ if (isAdmin) {
+ if (isSuperAdmin) {
+ loadCompanies(); // 최고 관리자는 회사 목록 먼저 로드
+ }
+ loadRoleGroups();
+ } else {
+ setIsLoading(false);
+ }
+ }, [isAdmin, isSuperAdmin, loadRoleGroups, loadCompanies]);
+
+ // 권한 그룹 생성 핸들러
+ const handleCreateRole = useCallback(() => {
+ setFormModal({ isOpen: true, editingRole: null });
+ }, []);
+
+ // 권한 그룹 수정 핸들러
+ const handleEditRole = useCallback((role: RoleGroup) => {
+ setFormModal({ isOpen: true, editingRole: role });
+ }, []);
+
+ // 권한 그룹 삭제 핸들러
+ const handleDeleteRole = useCallback((role: RoleGroup) => {
+ setDeleteModal({ isOpen: true, role });
+ }, []);
+
+ // 폼 모달 닫기
+ const handleFormModalClose = useCallback(() => {
+ setFormModal({ isOpen: false, editingRole: null });
+ }, []);
+
+ // 삭제 모달 닫기
+ const handleDeleteModalClose = useCallback(() => {
+ setDeleteModal({ isOpen: false, role: null });
+ }, []);
+
+ // 모달 성공 후 새로고침
+ const handleModalSuccess = useCallback(() => {
+ loadRoleGroups();
+ }, [loadRoleGroups]);
+
+ // 상세 페이지로 이동
+ const handleViewDetail = useCallback(
+ (role: RoleGroup) => {
+ router.push(`/admin/userMng/rolesList/${role.objid}`);
+ },
+ [router],
+ );
+
+ // 관리자가 아니면 접근 제한
+ if (!isAdmin) {
+ return (
+
+
+
+
권한 그룹 관리
+
회사 내 권한 그룹을 생성하고 멤버를 관리합니다 (회사 관리자 이상)
+
+
+
+
+
접근 권한 없음
+
+ 권한 그룹 관리는 회사 관리자 이상만 접근할 수 있습니다.
+
+
window.history.back()}>
+ 뒤로 가기
+
+
+
+
+
+
+ );
+ }
+
return (
{/* 페이지 헤더 */}
권한 그룹 관리
-
- 회사 내 권한 그룹을 생성하고 멤버를 관리합니다 (회사 관리자 이상)
-
+
회사 내 권한 그룹을 생성하고 멤버를 관리합니다 (회사 관리자 이상)
- {/* 메인 컨텐츠 */}
-
+ {/* 에러 메시지 */}
+ {error && (
+
+
+
오류가 발생했습니다
+
setError(null)}
+ className="text-destructive hover:text-destructive/80 transition-colors"
+ aria-label="에러 메시지 닫기"
+ >
+ ✕
+
+
+
{error}
+
+ )}
+
+ {/* 액션 버튼 영역 */}
+
+
+
권한 그룹 목록 ({roleGroups.length})
+
+ {/* 최고 관리자 전용: 회사 필터 */}
+ {isSuperAdmin && (
+
+
+ setSelectedCompany(value)}>
+
+
+
+
+ 전체 회사
+ {companies.map((company) => (
+
+ {company.company_name}
+
+ ))}
+
+
+ {selectedCompany !== "all" && (
+ setSelectedCompany("all")} className="h-8 w-8 p-0">
+
+
+ )}
+
+ )}
+
+
+
+
+ 권한 그룹 생성
+
+
+
+ {/* 권한 그룹 목록 */}
+ {isLoading ? (
+
+
+
+
권한 그룹 목록을 불러오는 중...
+
+
+ ) : roleGroups.length === 0 ? (
+
+
+
등록된 권한 그룹이 없습니다.
+
권한 그룹을 생성하여 멤버를 관리해보세요.
+
+
+ ) : (
+
+ {roleGroups.map((role) => (
+
+ {/* 헤더 (클릭 시 상세 페이지) */}
+
handleViewDetail(role)}
+ >
+
+
+
{role.authName}
+
{role.authCode}
+
+
+ {role.status === "active" ? "활성" : "비활성"}
+
+
+
+ {/* 정보 */}
+
+ {/* 최고 관리자는 회사명 표시 */}
+ {isSuperAdmin && (
+
+ 회사
+
+ {companies.find((c) => c.company_code === role.companyCode)?.company_name || role.companyCode}
+
+
+ )}
+
+
+
+ 멤버 수
+
+ {role.memberCount || 0}명
+
+
+
+
+ 메뉴 권한
+
+ {role.menuCount || 0}개
+
+
+
+
+ {/* 액션 버튼 */}
+
+ {
+ e.stopPropagation();
+ handleEditRole(role);
+ }}
+ className="flex-1 gap-1 text-xs"
+ >
+
+ 수정
+
+ {
+ e.stopPropagation();
+ handleDeleteRole(role);
+ }}
+ className="text-destructive hover:bg-destructive hover:text-destructive-foreground gap-1 text-xs"
+ >
+
+ 삭제
+
+
+
+ ))}
+
+ )}
+
+ {/* 모달들 */}
+
+
+
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
diff --git a/frontend/app/(main)/admin/userMng/userAuthList/page.tsx b/frontend/app/(main)/admin/userMng/userAuthList/page.tsx
index 322bba64..4ad69183 100644
--- a/frontend/app/(main)/admin/userMng/userAuthList/page.tsx
+++ b/frontend/app/(main)/admin/userMng/userAuthList/page.tsx
@@ -1,6 +1,12 @@
"use client";
-import { UserAuthManagement } from "@/components/admin/UserAuthManagement";
+import React, { useState, useCallback, useEffect } from "react";
+import { UserAuthTable } from "@/components/admin/UserAuthTable";
+import { UserAuthEditModal } from "@/components/admin/UserAuthEditModal";
+import { userAPI } from "@/lib/api/user";
+import { useAuth } from "@/hooks/useAuth";
+import { AlertCircle } from "lucide-react";
+import { Button } from "@/components/ui/button";
import { ScrollToTop } from "@/components/common/ScrollToTop";
/**
@@ -11,6 +17,119 @@ import { ScrollToTop } from "@/components/common/ScrollToTop";
* 사용자별 권한 레벨(SUPER_ADMIN, COMPANY_ADMIN, USER 등) 관리
*/
export default function UserAuthPage() {
+ const { user: currentUser } = useAuth();
+
+ // 최고 관리자 여부
+ const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
+
+ // 상태 관리
+ const [users, setUsers] = useState
([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [paginationInfo, setPaginationInfo] = useState({
+ currentPage: 1,
+ pageSize: 20,
+ totalItems: 0,
+ totalPages: 0,
+ });
+
+ // 권한 변경 모달
+ const [authEditModal, setAuthEditModal] = useState({
+ isOpen: false,
+ user: null as any | null,
+ });
+
+ // 데이터 로드
+ const loadUsers = useCallback(
+ async (page: number = 1) => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const response = await userAPI.getList({
+ page,
+ size: paginationInfo.pageSize,
+ });
+
+ if (response.success && response.data) {
+ setUsers(response.data);
+ setPaginationInfo({
+ currentPage: response.currentPage || page,
+ pageSize: response.pageSize || paginationInfo.pageSize,
+ totalItems: response.total || 0,
+ totalPages: Math.ceil((response.total || 0) / (response.pageSize || paginationInfo.pageSize)),
+ });
+ } else {
+ setError(response.message || "사용자 목록을 불러오는데 실패했습니다.");
+ }
+ } catch (err) {
+ console.error("사용자 목록 로드 오류:", err);
+ setError("사용자 목록을 불러오는 중 오류가 발생했습니다.");
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [paginationInfo.pageSize],
+ );
+
+ useEffect(() => {
+ loadUsers(1);
+ }, []);
+
+ // 권한 변경 핸들러
+ const handleEditAuth = (user: any) => {
+ setAuthEditModal({
+ isOpen: true,
+ user,
+ });
+ };
+
+ // 권한 변경 모달 닫기
+ const handleAuthEditClose = () => {
+ setAuthEditModal({
+ isOpen: false,
+ user: null,
+ });
+ };
+
+ // 권한 변경 성공
+ const handleAuthEditSuccess = () => {
+ loadUsers(paginationInfo.currentPage);
+ handleAuthEditClose();
+ };
+
+ // 페이지 변경
+ const handlePageChange = (page: number) => {
+ loadUsers(page);
+ };
+
+ // 최고 관리자가 아닌 경우
+ if (!isSuperAdmin) {
+ return (
+
+
+
+
사용자 권한 관리
+
사용자별 권한 레벨을 관리합니다. (최고 관리자 전용)
+
+
+
+
+
접근 권한 없음
+
+ 권한 관리는 최고 관리자만 접근할 수 있습니다.
+
+
window.history.back()}>
+ 뒤로 가기
+
+
+
+
+
+
+ );
+ }
+
return (
@@ -20,8 +139,39 @@ export default function UserAuthPage() {
사용자별 권한 레벨을 관리합니다. (최고 관리자 전용)
- {/* 메인 컨텐츠 */}
-
+ {/* 에러 메시지 */}
+ {error && (
+
+
+
오류가 발생했습니다
+
setError(null)}
+ className="text-destructive hover:text-destructive/80 transition-colors"
+ aria-label="에러 메시지 닫기"
+ >
+ ✕
+
+
+
{error}
+
+ )}
+
+ {/* 사용자 권한 테이블 */}
+
+
+ {/* 권한 변경 모달 */}
+
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
diff --git a/frontend/app/(main)/admin/userMng/userMngList/page.tsx b/frontend/app/(main)/admin/userMng/userMngList/page.tsx
index 428e8986..828390d9 100644
--- a/frontend/app/(main)/admin/userMng/userMngList/page.tsx
+++ b/frontend/app/(main)/admin/userMng/userMngList/page.tsx
@@ -1,6 +1,12 @@
"use client";
-import { UserManagement } from "@/components/admin/UserManagement";
+import { useState } from "react";
+import { useUserManagement } from "@/hooks/useUserManagement";
+import { UserToolbar } from "@/components/admin/UserToolbar";
+import { UserTable } from "@/components/admin/UserTable";
+import { Pagination } from "@/components/common/Pagination";
+import { UserPasswordResetModal } from "@/components/admin/UserPasswordResetModal";
+import { UserFormModal } from "@/components/admin/UserFormModal";
import { ScrollToTop } from "@/components/common/ScrollToTop";
/**
@@ -8,8 +14,100 @@ import { ScrollToTop } from "@/components/common/ScrollToTop";
* URL: /admin/userMng
*
* shadcn/ui 스타일 가이드 적용
+ * - 원본 Spring + JSP 코드 패턴 기반 REST API 연동
+ * - 실제 데이터베이스와 연동되어 작동
*/
export default function UserMngPage() {
+ const {
+ // 데이터
+ users,
+ searchFilter,
+ isLoading,
+ isSearching,
+ error,
+ paginationInfo,
+
+ // 검색 기능
+ updateSearchFilter,
+
+ // 페이지네이션
+ handlePageChange,
+ handlePageSizeChange,
+
+ // 액션 핸들러
+ handleStatusToggle,
+
+ // 유틸리티
+ clearError,
+ refreshData,
+ } = useUserManagement();
+
+ // 비밀번호 초기화 모달 상태
+ const [passwordResetModal, setPasswordResetModal] = useState({
+ isOpen: false,
+ userId: null as string | null,
+ userName: null as string | null,
+ });
+
+ // 사용자 등록/수정 모달 상태
+ const [userFormModal, setUserFormModal] = useState({
+ isOpen: false,
+ editingUser: null as any | null,
+ });
+
+ // 사용자 등록 핸들러
+ const handleCreateUser = () => {
+ setUserFormModal({
+ isOpen: true,
+ editingUser: null,
+ });
+ };
+
+ // 사용자 수정 핸들러
+ const handleEditUser = (user: any) => {
+ setUserFormModal({
+ isOpen: true,
+ editingUser: user,
+ });
+ };
+
+ // 사용자 등록/수정 모달 닫기
+ const handleUserFormClose = () => {
+ setUserFormModal({
+ isOpen: false,
+ editingUser: null,
+ });
+ };
+
+ // 사용자 등록/수정 성공 핸들러
+ const handleUserFormSuccess = () => {
+ refreshData();
+ handleUserFormClose();
+ };
+
+ // 비밀번호 초기화 핸들러
+ const handlePasswordReset = (userId: string, userName: string) => {
+ setPasswordResetModal({
+ isOpen: true,
+ userId,
+ userName,
+ });
+ };
+
+ // 비밀번호 초기화 모달 닫기
+ const handlePasswordResetClose = () => {
+ setPasswordResetModal({
+ isOpen: false,
+ userId: null,
+ userName: null,
+ });
+ };
+
+ // 비밀번호 초기화 성공 핸들러
+ const handlePasswordResetSuccess = () => {
+ handlePasswordResetClose();
+ };
+
return (
@@ -19,8 +117,70 @@ export default function UserMngPage() {
시스템 사용자 계정 및 권한을 관리합니다
- {/* 메인 컨텐츠 */}
-
+ {/* 툴바 - 검색, 필터, 등록 버튼 */}
+
+
+ {/* 에러 메시지 */}
+ {error && (
+
+ )}
+
+ {/* 사용자 목록 테이블 */}
+
+
+ {/* 페이지네이션 */}
+ {!isLoading && users.length > 0 && (
+
+ )}
+
+ {/* 사용자 등록/수정 모달 */}
+
+
+ {/* 비밀번호 초기화 모달 */}
+
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
diff --git a/frontend/app/(main)/main/page.tsx b/frontend/app/(main)/main/page.tsx
index 00ef509b..56558f7e 100644
--- a/frontend/app/(main)/main/page.tsx
+++ b/frontend/app/(main)/main/page.tsx
@@ -10,7 +10,6 @@ import { Badge } from "@/components/ui/badge";
export default function MainPage() {
return (
- {/* 메인 컨텐츠 */}
{/* Welcome Message */}
diff --git a/frontend/components/admin/CompanyManagement.tsx b/frontend/components/admin/CompanyManagement.tsx
deleted file mode 100644
index 4e88e35a..00000000
--- a/frontend/components/admin/CompanyManagement.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-"use client";
-
-import { useCompanyManagement } from "@/hooks/useCompanyManagement";
-import { CompanyToolbar } from "./CompanyToolbar";
-import { CompanyTable } from "./CompanyTable";
-import { CompanyFormModal } from "./CompanyFormModal";
-import { CompanyDeleteDialog } from "./CompanyDeleteDialog";
-import { DiskUsageSummary } from "./DiskUsageSummary";
-
-/**
- * 회사 관리 메인 컴포넌트
- * 모든 회사 관리 기능을 통합하여 제공
- */
-export function CompanyManagement() {
- const {
- // 데이터
- companies,
- searchFilter,
- isLoading,
- error,
-
- // 디스크 사용량 관련
- diskUsageInfo,
- isDiskUsageLoading,
- loadDiskUsage,
-
- // 모달 상태
- modalState,
- deleteState,
-
- // 검색 기능
- updateSearchFilter,
- clearSearchFilter,
-
- // 모달 제어
- openCreateModal,
- openEditModal,
- closeModal,
- updateFormData,
-
- // 삭제 다이얼로그 제어
- openDeleteDialog,
- closeDeleteDialog,
-
- // CRUD 작업
- saveCompany,
- deleteCompany,
-
- // 에러 처리
- clearError,
- } = useCompanyManagement();
-
- return (
-
- {/* 디스크 사용량 요약 */}
-
-
- {/* 툴바 - 검색, 필터, 등록 버튼 */}
-
-
- {/* 회사 목록 테이블 */}
-
-
- {/* 회사 등록/수정 모달 */}
-
-
- {/* 회사 삭제 확인 다이얼로그 */}
-
-
- );
-}
diff --git a/frontend/components/admin/CompanySwitcher.tsx b/frontend/components/admin/CompanySwitcher.tsx
new file mode 100644
index 00000000..3d53accc
--- /dev/null
+++ b/frontend/components/admin/CompanySwitcher.tsx
@@ -0,0 +1,195 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Building2, Search } from "lucide-react";
+import { useAuth } from "@/hooks/useAuth";
+import { apiClient } from "@/lib/api/client";
+import { logger } from "@/lib/utils/logger";
+
+interface Company {
+ company_code: string;
+ company_name: string;
+ status: string;
+}
+
+interface CompanySwitcherProps {
+ onClose?: () => void;
+ isOpen?: boolean; // Dialog 열림 상태 (AppLayout에서 전달)
+}
+
+/**
+ * WACE 관리자 전용: 회사 선택 및 전환 컴포넌트
+ *
+ * - WACE 관리자(company_code = "*", userType = "SUPER_ADMIN")만 표시
+ * - 회사 선택 시 해당 회사로 전환하여 시스템 사용
+ * - JWT 토큰 재발급으로 company_code 변경
+ */
+export function CompanySwitcher({ onClose, isOpen = false }: CompanySwitcherProps = {}) {
+ const { user, switchCompany } = useAuth();
+ const [companies, setCompanies] = useState([]);
+ const [filteredCompanies, setFilteredCompanies] = useState([]);
+ const [searchText, setSearchText] = useState("");
+ const [loading, setLoading] = useState(false);
+
+ // WACE 관리자 권한 체크 (userType만 확인)
+ const isWaceAdmin = user?.userType === "SUPER_ADMIN";
+
+ // 현재 선택된 회사명 표시
+ const currentCompanyName = React.useMemo(() => {
+ if (!user?.companyCode) return "로딩 중...";
+
+ if (user.companyCode === "*") {
+ return "WACE (최고 관리자)";
+ }
+
+ // companies 배열에서 현재 회사 찾기
+ const currentCompany = companies.find(c => c.company_code === user.companyCode);
+ return currentCompany?.company_name || user.companyCode;
+ }, [user?.companyCode, companies]);
+
+ // 회사 목록 조회
+ useEffect(() => {
+ if (isWaceAdmin && isOpen) {
+ fetchCompanies();
+ }
+ }, [isWaceAdmin, isOpen]);
+
+ // 검색 필터링
+ useEffect(() => {
+ if (searchText.trim() === "") {
+ setFilteredCompanies(companies);
+ } else {
+ const filtered = companies.filter(company =>
+ company.company_name.toLowerCase().includes(searchText.toLowerCase()) ||
+ company.company_code.toLowerCase().includes(searchText.toLowerCase())
+ );
+ setFilteredCompanies(filtered);
+ }
+ }, [searchText, companies]);
+
+ const fetchCompanies = async () => {
+ try {
+ setLoading(true);
+ const response = await apiClient.get("/admin/companies/db");
+
+ if (response.data.success) {
+ // 활성 상태의 회사만 필터링 + company_code="*" 제외 (WACE는 별도 추가)
+ const activeCompanies = response.data.data
+ .filter((c: Company) => c.company_code !== "*") // DB의 "*" 제외
+ .filter((c: Company) => c.status === "active" || !c.status)
+ .sort((a: Company, b: Company) => a.company_name.localeCompare(b.company_name));
+
+ // WACE 복귀 옵션 추가
+ const companiesWithWace: Company[] = [
+ {
+ company_code: "*",
+ company_name: "WACE (최고 관리자)",
+ status: "active",
+ },
+ ...activeCompanies,
+ ];
+
+ setCompanies(companiesWithWace);
+ setFilteredCompanies(companiesWithWace);
+ }
+ } catch (error) {
+ logger.error("회사 목록 조회 실패", error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleCompanySwitch = async (companyCode: string) => {
+ try {
+ setLoading(true);
+
+ const result = await switchCompany(companyCode);
+
+ if (!result.success) {
+ alert(result.message || "회사 전환에 실패했습니다.");
+ setLoading(false);
+ return;
+ }
+
+ logger.info("회사 전환 성공", { companyCode });
+
+ // 즉시 페이지 새로고침 (토큰이 이미 저장됨)
+ window.location.reload();
+ } catch (error: any) {
+ logger.error("회사 전환 실패", error);
+ alert(error.message || "회사 전환 중 오류가 발생했습니다.");
+ setLoading(false);
+ }
+ };
+
+ // WACE 관리자가 아니면 렌더링하지 않음
+ if (!isWaceAdmin) {
+ return null;
+ }
+
+ return (
+
+ {/* 현재 회사 정보 */}
+
+
+
+
+
+
+
현재 관리 회사
+
{currentCompanyName}
+
+
+
+
+ {/* 회사 검색 */}
+
+
+ setSearchText(e.target.value)}
+ className="h-10 pl-10 text-sm"
+ />
+
+
+ {/* 회사 목록 */}
+
+ {loading ? (
+
+ 로딩 중...
+
+ ) : filteredCompanies.length === 0 ? (
+
+ 검색 결과가 없습니다.
+
+ ) : (
+ filteredCompanies.map((company) => (
+
handleCompanySwitch(company.company_code)}
+ >
+
+ {company.company_name}
+
+ {company.company_code}
+
+
+ {company.company_code === user?.companyCode && (
+
현재
+ )}
+
+ ))
+ )}
+
+
+ );
+}
+
diff --git a/frontend/components/admin/MenuManagement.tsx b/frontend/components/admin/MenuManagement.tsx
deleted file mode 100644
index 67e8bab6..00000000
--- a/frontend/components/admin/MenuManagement.tsx
+++ /dev/null
@@ -1,1136 +0,0 @@
-"use client";
-
-import React, { useState, useEffect, useMemo } from "react";
-import { menuApi } from "@/lib/api/menu";
-import type { MenuItem } from "@/lib/api/menu";
-import { MenuTable } from "./MenuTable";
-import { MenuFormModal } from "./MenuFormModal";
-import { MenuCopyDialog } from "./MenuCopyDialog";
-import { Button } from "@/components/ui/button";
-import { LoadingSpinner, LoadingOverlay } from "@/components/common/LoadingSpinner";
-import { toast } from "sonner";
-import { Badge } from "@/components/ui/badge";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from "@/components/ui/alert-dialog";
-import { useMenu } from "@/contexts/MenuContext";
-import { useMenuManagementText, setTranslationCache, getMenuTextSync } from "@/lib/utils/multilang";
-import { useMultiLang } from "@/hooks/useMultiLang";
-import { apiClient } from "@/lib/api/client";
-import { useAuth } from "@/hooks/useAuth"; // useAuth 추가
-
-type MenuType = "admin" | "user";
-
-export const MenuManagement: React.FC = () => {
- const { adminMenus, userMenus, refreshMenus } = useMenu();
- const { user } = useAuth(); // 현재 사용자 정보 가져오기
- const [selectedMenuType, setSelectedMenuType] = useState("admin");
- const [loading, setLoading] = useState(false);
- const [deleting, setDeleting] = useState(false);
- const [formModalOpen, setFormModalOpen] = useState(false);
- const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
- const [copyDialogOpen, setCopyDialogOpen] = useState(false);
- const [selectedMenuId, setSelectedMenuId] = useState("");
- const [selectedMenuName, setSelectedMenuName] = useState("");
- const [selectedMenus, setSelectedMenus] = useState>(new Set());
-
- // 메뉴 관리 화면용 로컬 상태 (모든 상태의 메뉴 표시)
- const [localAdminMenus, setLocalAdminMenus] = useState([]);
- const [localUserMenus, setLocalUserMenus] = useState([]);
-
- // 다국어 텍스트 훅 사용
- // getMenuText는 더 이상 사용하지 않음 - getUITextSync만 사용
- const { userLang } = useMultiLang({ companyCode: "*" });
-
- // SUPER_ADMIN 여부 확인
- const isSuperAdmin = user?.userType === "SUPER_ADMIN";
-
- // 다국어 텍스트 상태
- const [uiTexts, setUiTexts] = useState>({});
- const [uiTextsLoading, setUiTextsLoading] = useState(false);
-
- // 회사 목록 상태
- const [companies, setCompanies] = useState>([]);
- const [selectedCompany, setSelectedCompany] = useState("all");
- const [searchText, setSearchText] = useState("");
- const [expandedMenus, setExpandedMenus] = useState>(new Set());
- const [companySearchText, setCompanySearchText] = useState("");
- const [isCompanyDropdownOpen, setIsCompanyDropdownOpen] = useState(false);
- const [formData, setFormData] = useState({
- menuId: "",
- parentId: "",
- menuType: "",
- level: 0,
- parentCompanyCode: "",
- });
-
- // 언어별 텍스트 매핑 테이블 제거 - DB에서 직접 가져옴
-
- // 메뉴관리 페이지에서 사용할 다국어 키들 (실제 DB에 등록된 키들)
- const MENU_MANAGEMENT_LANG_KEYS = [
- // 페이지 제목 및 설명
- "menu.management.title",
- "menu.management.description",
- "menu.type.title",
- "menu.type.admin",
- "menu.type.user",
- "menu.management.admin",
- "menu.management.user",
- "menu.management.admin.description",
- "menu.management.user.description",
-
- // 버튼
- "button.add",
- "button.add.top.level",
- "button.add.sub",
- "button.edit",
- "button.delete",
- "button.delete.selected",
- "button.delete.selected.count",
- "button.delete.processing",
- "button.cancel",
- "button.save",
- "button.register",
- "button.modify",
-
- // 필터 및 검색
- "filter.company",
- "filter.company.all",
- "filter.company.common",
- "filter.company.search",
- "filter.search",
- "filter.search.placeholder",
- "filter.reset",
-
- // 테이블 헤더
- "table.header.select",
- "table.header.menu.name",
- "table.header.menu.url",
- "table.header.menu.type",
- "table.header.status",
- "table.header.company",
- "table.header.sequence",
- "table.header.actions",
-
- // 상태
- "status.active",
- "status.inactive",
- "status.unspecified",
-
- // 폼
- "form.menu.type",
- "form.menu.type.admin",
- "form.menu.type.user",
- "form.company",
- "form.company.select",
- "form.company.common",
- "form.company.submenu.note",
- "form.lang.key",
- "form.lang.key.select",
- "form.lang.key.none",
- "form.lang.key.search",
- "form.lang.key.selected",
- "form.menu.name",
- "form.menu.name.placeholder",
- "form.menu.url",
- "form.menu.url.placeholder",
- "form.menu.description",
- "form.menu.description.placeholder",
- "form.menu.sequence",
-
- // 모달
- "modal.menu.register.title",
- "modal.menu.modify.title",
- "modal.delete.title",
- "modal.delete.description",
- "modal.delete.batch.description",
-
- // 메시지
- "message.loading",
- "message.menu.delete.processing",
- "message.menu.save.success",
- "message.menu.save.failed",
- "message.menu.delete.success",
- "message.menu.delete.failed",
- "message.menu.delete.batch.success",
- "message.menu.delete.batch.partial",
- "message.menu.status.toggle.success",
- "message.menu.status.toggle.failed",
- "message.validation.menu.name.required",
- "message.validation.company.required",
- "message.validation.select.menu.delete",
- "message.error.load.menu.list",
- "message.error.load.menu.info",
- "message.error.load.company.list",
- "message.error.load.lang.key.list",
-
- // 리스트 정보
- "menu.list.title",
- "menu.list.total",
- "menu.list.search.result",
-
- // UI
- "ui.expand",
- "ui.collapse",
- "ui.menu.collapse",
- "ui.language",
- ];
-
- // 초기 로딩
- useEffect(() => {
- loadCompanies();
- loadMenus(false); // 메뉴 목록 로드 (메뉴 관리 화면용 - 모든 상태 표시)
- // 사용자 언어가 설정되지 않았을 때만 기본 텍스트 설정
- if (!userLang) {
- initializeDefaultTexts();
- }
- }, [userLang]); // userLang 변경 시마다 실행
-
- // 초기 기본 텍스트 설정 함수
- const initializeDefaultTexts = () => {
- const defaultTexts: Record = {};
- MENU_MANAGEMENT_LANG_KEYS.forEach((key) => {
- // 기본 한국어 텍스트 제공
- const defaultText = getDefaultText(key);
- defaultTexts[key] = defaultText;
- });
- setUiTexts(defaultTexts);
- // console.log("🌐 초기 기본 텍스트 설정 완료:", Object.keys(defaultTexts).length);
- };
-
- // 기본 텍스트 반환 함수
- const getDefaultText = (key: string): string => {
- const defaultTexts: Record = {
- "menu.management.title": "메뉴 관리",
- "menu.management.description": "시스템의 메뉴 구조와 권한을 관리합니다.",
- "menu.type.title": "메뉴 타입",
- "menu.type.admin": "관리자",
- "menu.type.user": "사용자",
- "menu.management.admin": "관리자 메뉴",
- "menu.management.user": "사용자 메뉴",
- "menu.management.admin.description": "시스템 관리 및 설정 메뉴",
- "menu.management.user.description": "일반 사용자 업무 메뉴",
- "button.add": "추가",
- "button.add.top.level": "최상위 메뉴 추가",
- "button.add.sub": "하위 메뉴 추가",
- "button.edit": "수정",
- "button.delete": "삭제",
- "button.delete.selected": "선택 삭제",
- "button.delete.selected.count": "선택 삭제 ({count})",
- "button.delete.processing": "삭제 중...",
- "button.cancel": "취소",
- "button.save": "저장",
- "button.register": "등록",
- "button.modify": "수정",
- "filter.company": "회사",
- "filter.company.all": "전체",
- "filter.company.common": "공통",
- "filter.company.search": "회사 검색",
- "filter.search": "검색",
- "filter.search.placeholder": "메뉴명 또는 URL로 검색...",
- "filter.reset": "초기화",
- "table.header.select": "선택",
- "table.header.menu.name": "메뉴명",
- "table.header.menu.url": "URL",
- "table.header.menu.type": "메뉴 타입",
- "table.header.status": "상태",
- "table.header.company": "회사",
- "table.header.sequence": "순서",
- "table.header.actions": "작업",
- "status.active": "활성화",
- "status.inactive": "비활성화",
- "status.unspecified": "미지정",
- "form.menu.type": "메뉴 타입",
- "form.menu.type.admin": "관리자",
- "form.menu.type.user": "사용자",
- "form.company": "회사",
- "form.company.select": "회사를 선택하세요",
- "form.company.common": "공통",
- "form.company.submenu.note": "하위 메뉴는 상위 메뉴와 동일한 회사를 가져야 합니다.",
- "form.lang.key": "다국어 키",
- "form.lang.key.select": "다국어 키를 선택하세요",
- "form.lang.key.none": "다국어 키 없음",
- "form.lang.key.search": "다국어 키 검색...",
- "form.lang.key.selected": "선택된 키: {key} - {description}",
- "form.menu.name": "메뉴명",
- "form.menu.name.placeholder": "메뉴명을 입력하세요",
- "form.menu.url": "URL",
- "form.menu.url.placeholder": "메뉴 URL을 입력하세요",
- "form.menu.description": "설명",
- "form.menu.description.placeholder": "메뉴 설명을 입력하세요",
- "form.menu.sequence": "순서",
- "modal.menu.register.title": "메뉴 등록",
- "modal.menu.modify.title": "메뉴 수정",
- "modal.delete.title": "메뉴 삭제",
- "modal.delete.description": "해당 메뉴를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
- "modal.delete.batch.description":
- "선택된 {count}개의 메뉴를 영구적으로 삭제하시겠습니까?\n\n⚠️ 주의: 상위 메뉴를 삭제하면 하위 메뉴들도 함께 삭제됩니다.\n이 작업은 되돌릴 수 없습니다.",
- "message.loading": "로딩 중...",
- "message.menu.delete.processing": "메뉴 삭제 중...",
- "message.menu.save.success": "메뉴가 성공적으로 저장되었습니다.",
- "message.menu.save.failed": "메뉴 저장에 실패했습니다.",
- "message.menu.delete.success": "메뉴가 성공적으로 삭제되었습니다.",
- "message.menu.delete.failed": "메뉴 삭제에 실패했습니다.",
- "message.menu.delete.batch.success": "선택된 메뉴들이 성공적으로 삭제되었습니다.",
- "message.menu.delete.batch.partial": "일부 메뉴 삭제에 실패했습니다.",
- "message.menu.status.toggle.success": "메뉴 상태가 변경되었습니다.",
- "message.menu.status.toggle.failed": "메뉴 상태 변경에 실패했습니다.",
- "message.validation.menu.name.required": "메뉴명을 입력해주세요.",
- "message.validation.company.required": "회사를 선택해주세요.",
- "message.validation.select.menu.delete": "삭제할 메뉴를 선택해주세요.",
- "message.error.load.menu.list": "메뉴 목록을 불러오는데 실패했습니다.",
- "message.error.load.menu.info": "메뉴 정보를 불러오는데 실패했습니다.",
- "message.error.load.company.list": "회사 목록을 불러오는데 실패했습니다.",
- "message.error.load.lang.key.list": "다국어 키 목록을 불러오는데 실패했습니다.",
- "menu.list.title": "메뉴 목록",
- "menu.list.total": "총 {count}개",
- "menu.list.search.result": "검색 결과: {count}개",
- "ui.expand": "펼치기",
- "ui.collapse": "접기",
- "ui.menu.collapse": "메뉴 접기",
- "ui.language": "언어",
- };
-
- return defaultTexts[key] || key;
- };
-
- // 컴포넌트 마운트 시 및 userLang 변경 시 다국어 텍스트 로드
- useEffect(() => {
- if (userLang && !uiTextsLoading) {
- loadUITexts();
- }
- }, [userLang]); // userLang 변경 시마다 실행
-
- // uiTexts 상태 변경 감지
- useEffect(() => {
- // console.log("🔄 uiTexts 상태 변경됨:", {
- // count: Object.keys(uiTexts).length,
- // sampleKeys: Object.keys(uiTexts).slice(0, 5),
- // sampleValues: Object.entries(uiTexts)
- // .slice(0, 3)
- // .map(([k, v]) => `${k}: ${v}`),
- // });
- }, [uiTexts]);
-
- // 컴포넌트 마운트 후 다국어 텍스트 강제 로드 (userLang이 아직 설정되지 않았을 수 있음)
- useEffect(() => {
- const timer = setTimeout(() => {
- if (userLang && !uiTextsLoading) {
- // console.log("🔄 컴포넌트 마운트 후 다국어 텍스트 강제 로드");
- loadUITexts();
- }
- }, 300); // 300ms 후 실행
-
- return () => clearTimeout(timer);
- }, [userLang]); // userLang이 설정된 후 실행
-
- // 추가 안전장치: 컴포넌트 마운트 후 일정 시간이 지나면 강제로 다국어 텍스트 로드
- useEffect(() => {
- const fallbackTimer = setTimeout(() => {
- if (!uiTextsLoading && Object.keys(uiTexts).length === 0) {
- // console.log("🔄 안전장치: 컴포넌트 마운트 후 강제 다국어 텍스트 로드");
- // 사용자 언어가 설정되지 않았을 때만 기본 텍스트 설정
- if (!userLang) {
- initializeDefaultTexts();
- } else {
- // 사용자 언어가 설정된 경우 다국어 텍스트 로드
- loadUITexts();
- }
- }
- }, 1000); // 1초 후 실행
-
- return () => clearTimeout(fallbackTimer);
- }, [userLang]); // userLang 변경 시마다 실행
-
- // 번역 로드 이벤트 감지
- useEffect(() => {
- const handleTranslationLoaded = (event: CustomEvent) => {
- const { key, text, userLang: loadedLang } = event.detail;
- if (loadedLang === userLang) {
- setUiTexts((prev) => ({ ...prev, [key]: text }));
- }
- };
-
- window.addEventListener("translation-loaded", handleTranslationLoaded as EventListener);
-
- return () => {
- window.removeEventListener("translation-loaded", handleTranslationLoaded as EventListener);
- };
- }, [userLang]);
-
- // 드롭다운 외부 클릭 시 닫기
- useEffect(() => {
- const handleClickOutside = (event: MouseEvent) => {
- const target = event.target as Element;
- if (!target.closest(".company-dropdown")) {
- setIsCompanyDropdownOpen(false);
- setCompanySearchText("");
- }
- };
-
- if (isCompanyDropdownOpen) {
- document.addEventListener("mousedown", handleClickOutside);
- }
-
- return () => {
- document.removeEventListener("mousedown", handleClickOutside);
- };
- }, [isCompanyDropdownOpen]);
-
- // 특정 메뉴 타입만 로드하는 함수
- const loadMenusForType = async (type: MenuType, showLoading = true) => {
- try {
- if (showLoading) {
- setLoading(true);
- }
-
- if (type === "admin") {
- const adminResponse = await menuApi.getAdminMenusForManagement();
- if (adminResponse.success && adminResponse.data) {
- setLocalAdminMenus(adminResponse.data);
- }
- } else {
- const userResponse = await menuApi.getUserMenusForManagement();
- if (userResponse.success && userResponse.data) {
- setLocalUserMenus(userResponse.data);
- }
- }
- } catch (error) {
- toast.error(getUITextSync("message.error.load.menu.list"));
- } finally {
- if (showLoading) {
- setLoading(false);
- }
- }
- };
-
- const loadMenus = async (showLoading = true) => {
- // console.log(`📋 메뉴 목록 조회 시작 (showLoading: ${showLoading})`);
- try {
- if (showLoading) {
- setLoading(true);
- }
-
- // 선택된 메뉴 타입에 해당하는 메뉴만 로드
- if (selectedMenuType === "admin") {
- const adminResponse = await menuApi.getAdminMenusForManagement();
- if (adminResponse.success && adminResponse.data) {
- setLocalAdminMenus(adminResponse.data);
- }
- } else {
- const userResponse = await menuApi.getUserMenusForManagement();
- if (userResponse.success && userResponse.data) {
- setLocalUserMenus(userResponse.data);
- }
- }
-
- // 전역 메뉴 상태도 업데이트 (좌측 사이드바용)
- await refreshMenus();
- // console.log("📋 메뉴 목록 조회 성공");
- } catch (error) {
- // console.error("❌ 메뉴 목록 조회 실패:", error);
- toast.error(getUITextSync("message.error.load.menu.list"));
- } finally {
- if (showLoading) {
- setLoading(false);
- }
- }
- };
-
- // 회사 목록 조회
- const loadCompanies = async () => {
- // console.log("🏢 회사 목록 조회 시작");
- try {
- const response = await apiClient.get("/admin/companies");
-
- if (response.data.success) {
- // console.log("🏢 회사 목록 응답:", response.data);
- const companyList = response.data.data.map((company: any) => ({
- code: company.company_code || company.companyCode,
- name: company.company_name || company.companyName,
- }));
- // console.log("🏢 변환된 회사 목록:", companyList);
- setCompanies(companyList);
- }
- } catch (error) {
- // console.error("❌ 회사 목록 조회 실패:", error);
- }
- };
-
- // 다국어 텍스트 로드 함수 - 배치 API 사용
- const loadUITexts = async () => {
- if (uiTextsLoading) return; // 이미 로딩 중이면 중단
-
- // userLang이 설정되지 않았으면 기본값 설정
- if (!userLang) {
- // console.log("🌐 사용자 언어가 설정되지 않음, 기본값 설정");
- const defaultTexts: Record = {};
- MENU_MANAGEMENT_LANG_KEYS.forEach((key) => {
- defaultTexts[key] = getDefaultText(key); // 기본 한국어 텍스트 사용
- });
- setUiTexts(defaultTexts);
- return;
- }
-
- // 사용자 언어가 설정된 경우, 기존 uiTexts가 비어있으면 기본 텍스트로 초기화
- if (Object.keys(uiTexts).length === 0) {
- // console.log("🌐 기존 uiTexts가 비어있음, 기본 텍스트로 초기화");
- const defaultTexts: Record = {};
- MENU_MANAGEMENT_LANG_KEYS.forEach((key) => {
- defaultTexts[key] = getDefaultText(key);
- });
- setUiTexts(defaultTexts);
- }
-
- // console.log("🌐 UI 다국어 텍스트 로드 시작", {
- // userLang,
- // apiParams: {
- // companyCode: "*",
- // menuCode: "menu.management",
- // userLang: userLang,
- // },
- // });
- setUiTextsLoading(true);
-
- try {
- // 배치 API를 사용하여 모든 다국어 키를 한 번에 조회
- const response = await apiClient.post(
- "/multilang/batch",
- {
- langKeys: MENU_MANAGEMENT_LANG_KEYS,
- companyCode: "*", // 모든 회사
- menuCode: "menu.management", // 메뉴관리 메뉴
- userLang: userLang, // body에 포함
- },
- {
- params: {}, // query params는 비움
- },
- );
-
- if (response.data.success) {
- const translations = response.data.data;
- // console.log("🌐 배치 다국어 텍스트 응답:", translations);
-
- // 번역 결과를 상태에 저장 (기존 uiTexts와 병합)
- const mergedTranslations = { ...uiTexts, ...translations };
- // console.log("🔧 setUiTexts 호출 전:", {
- // translationsCount: Object.keys(translations).length,
- // mergedCount: Object.keys(mergedTranslations).length,
- // });
- setUiTexts(mergedTranslations);
- // console.log("🔧 setUiTexts 호출 후 - mergedTranslations:", mergedTranslations);
-
- // 번역 캐시에 저장 (다른 컴포넌트에서도 사용할 수 있도록)
- setTranslationCache(userLang, mergedTranslations);
- } else {
- // console.error("❌ 다국어 텍스트 배치 조회 실패:", response.data.message);
- // API 실패 시에도 기존 uiTexts는 유지
- // console.log("🔄 API 실패로 인해 기존 uiTexts 유지");
- }
- } catch (error) {
- // console.error("❌ UI 다국어 텍스트 로드 실패:", error);
- // API 실패 시에도 기존 uiTexts는 유지
- // console.log("🔄 API 실패로 인해 기존 uiTexts 유지");
- } finally {
- setUiTextsLoading(false);
- }
- };
-
- // UI 텍스트 가져오기 함수 (동기 버전만 사용)
- // getUIText 함수는 제거 - getUITextSync만 사용
-
- // 동기 버전 (DB에서 가져온 번역 텍스트 사용)
- const getUITextSync = (key: string, params?: Record, fallback?: string): string => {
- // uiTexts에서 번역 텍스트 찾기
- let text = uiTexts[key];
-
- // uiTexts에 없으면 getMenuTextSync로 기본 한글 텍스트 가져오기
- if (!text) {
- text = getMenuTextSync(key, userLang) || fallback || key;
- }
-
- // 파라미터 치환
- if (params && text) {
- Object.entries(params).forEach(([paramKey, paramValue]) => {
- text = text!.replace(`{${paramKey}}`, String(paramValue));
- });
- }
-
- return text || key;
- };
-
- // 다국어 API 테스트 함수 (getUITextSync 사용)
- const testMultiLangAPI = async () => {
- // console.log("🧪 다국어 API 테스트 시작");
- try {
- const text = getUITextSync("menu.management.admin");
- // console.log("🧪 다국어 API 테스트 결과:", text);
- } catch (error) {
- // console.error("❌ 다국어 API 테스트 실패:", error);
- }
- };
-
- // 대문자 키를 소문자 키로 변환하는 함수
- const convertMenuData = (data: any[]): MenuItem[] => {
- return data.map((item) => ({
- objid: item.OBJID || item.objid,
- parent_obj_id: item.PARENT_OBJ_ID || item.parent_obj_id,
- menu_name_kor: item.MENU_NAME_KOR || item.menu_name_kor,
- menu_url: item.MENU_URL || item.menu_url,
- menu_desc: item.MENU_DESC || item.menu_desc,
- seq: item.SEQ || item.seq,
- menu_type: item.MENU_TYPE || item.menu_type,
- status: item.STATUS || item.status,
- lev: item.LEV || item.lev,
- lpad_menu_name_kor: item.LPAD_MENU_NAME_KOR || item.lpad_menu_name_kor,
- status_title: item.STATUS_TITLE || item.status_title,
- writer: item.WRITER || item.writer,
- regdate: item.REGDATE || item.regdate,
- company_code: item.COMPANY_CODE || item.company_code,
- company_name: item.COMPANY_NAME || item.company_name,
- }));
- };
-
- const handleAddTopLevelMenu = () => {
- setFormData({
- menuId: "",
- parentId: "0", // 최상위 메뉴는 parentId가 0
- menuType: getMenuTypeValue(),
- level: 1, // 최상위 메뉴는 level 1
- parentCompanyCode: "", // 최상위 메뉴는 상위 회사 정보 없음
- });
- setFormModalOpen(true);
- };
-
- const handleAddMenu = (parentId: string, menuType: string, level: number) => {
- // 상위 메뉴의 회사 정보 찾기
- const currentMenus = selectedMenuType === "admin" ? localAdminMenus : localUserMenus;
- const parentMenu = currentMenus.find((menu) => menu.objid === parentId);
-
- setFormData({
- menuId: "",
- parentId,
- menuType,
- level: level + 1,
- parentCompanyCode: parentMenu?.company_code || "",
- });
- setFormModalOpen(true);
- };
-
- const handleEditMenu = (menuId: string) => {
- // console.log("🔧 메뉴 수정 시작 - menuId:", menuId);
-
- // 현재 메뉴 정보 찾기
- const currentMenus = selectedMenuType === "admin" ? localAdminMenus : localUserMenus;
- const menuToEdit = currentMenus.find((menu) => (menu.objid || menu.OBJID) === menuId);
-
- if (menuToEdit) {
- // console.log("수정할 메뉴 정보:", menuToEdit);
-
- setFormData({
- menuId: menuId,
- parentId: menuToEdit.parent_obj_id || menuToEdit.PARENT_OBJ_ID || "",
- menuType: selectedMenuType, // 현재 선택된 메뉴 타입
- level: 0, // 기본값
- parentCompanyCode: menuToEdit.company_code || menuToEdit.COMPANY_CODE || "",
- });
-
- // console.log("설정된 formData:", {
- // menuId: menuId,
- // parentId: menuToEdit.parent_obj_id || menuToEdit.PARENT_OBJ_ID || "",
- // menuType: selectedMenuType,
- // level: 0,
- // parentCompanyCode: menuToEdit.company_code || menuToEdit.COMPANY_CODE || "",
- // });
- } else {
- // console.error("수정할 메뉴를 찾을 수 없음:", menuId);
- }
-
- setFormModalOpen(true);
- };
-
- const handleMenuSelectionChange = (menuId: string, checked: boolean) => {
- const newSelected = new Set(selectedMenus);
- if (checked) {
- newSelected.add(menuId);
- } else {
- newSelected.delete(menuId);
- }
- setSelectedMenus(newSelected);
- };
-
- const handleSelectAllMenus = (checked: boolean) => {
- const currentMenus = selectedMenuType === "admin" ? localAdminMenus : localUserMenus;
- if (checked) {
- // 모든 메뉴 선택 (최상위 메뉴 포함)
- setSelectedMenus(new Set(currentMenus.map((menu) => menu.objid || menu.OBJID || "")));
- } else {
- setSelectedMenus(new Set());
- }
- };
-
- const handleDeleteSelectedMenus = async () => {
- if (selectedMenus.size === 0) {
- toast.error(getUITextSync("message.validation.select.menu.delete"));
- return;
- }
-
- if (!confirm(getUITextSync("modal.delete.batch.description", { count: selectedMenus.size }))) {
- return;
- }
-
- setDeleting(true);
- try {
- const menuIds = Array.from(selectedMenus);
- // console.log("삭제할 메뉴 IDs:", menuIds);
-
- toast.info(getUITextSync("message.menu.delete.processing"));
-
- const response = await menuApi.deleteMenusBatch(menuIds);
- // console.log("삭제 API 응답:", response);
- // console.log("응답 구조:", {
- // success: response.success,
- // data: response.data,
- // message: response.message,
- // });
-
- if (response.success && response.data) {
- const { deletedCount, failedCount } = response.data;
- // console.log("삭제 결과:", { deletedCount, failedCount });
-
- // 선택된 메뉴 초기화
- setSelectedMenus(new Set());
-
- // 메뉴 목록 즉시 새로고침 (로딩 상태 없이)
- // console.log("메뉴 목록 새로고침 시작");
- await loadMenus(false);
- // 전역 메뉴 상태도 업데이트
- await refreshMenus();
- // console.log("메뉴 목록 새로고침 완료");
-
- // 삭제 결과 메시지
- if (failedCount === 0) {
- toast.success(getUITextSync("message.menu.delete.batch.success", { count: deletedCount }));
- } else {
- toast.success(
- getUITextSync("message.menu.delete.batch.partial", {
- success: deletedCount,
- failed: failedCount,
- }),
- );
- }
- } else {
- // console.error("삭제 실패:", response);
- toast.error(response.message || "메뉴 삭제에 실패했습니다.");
- }
- } catch (error) {
- // console.error("메뉴 삭제 중 오류:", error);
- toast.error(getUITextSync("message.menu.delete.failed"));
- } finally {
- setDeleting(false);
- }
- };
-
- const confirmDelete = async () => {
- try {
- const response = await menuApi.deleteMenu(selectedMenuId);
- if (response.success) {
- toast.success(response.message);
- await loadMenus(false);
- } else {
- toast.error(response.message);
- }
- } catch (error) {
- toast.error("메뉴 삭제에 실패했습니다.");
- } finally {
- setDeleteDialogOpen(false);
- setSelectedMenuId("");
- }
- };
-
- const handleCopyMenu = (menuId: string, menuName: string) => {
- setSelectedMenuId(menuId);
- setSelectedMenuName(menuName);
- setCopyDialogOpen(true);
- };
-
- const handleCopyComplete = async () => {
- // 복사 완료 후 메뉴 목록 새로고침
- await loadMenus(false);
- toast.success("메뉴 복사가 완료되었습니다");
- };
-
- const handleToggleStatus = async (menuId: string) => {
- try {
- const response = await menuApi.toggleMenuStatus(menuId);
- if (response.success) {
- toast.success(response.message);
- await loadMenus(false); // 메뉴 목록 새로고침
- // 전역 메뉴 상태도 업데이트
- await refreshMenus();
- } else {
- toast.error(response.message);
- }
- } catch (error) {
- // console.error("메뉴 상태 토글 오류:", error);
- toast.error(getUITextSync("message.menu.status.toggle.failed"));
- }
- };
-
- const handleFormSuccess = () => {
- loadMenus(false);
- // 전역 메뉴 상태도 업데이트
- refreshMenus();
- };
-
- const getCurrentMenus = () => {
- // 메뉴 관리 화면용: 모든 상태의 메뉴 표시 (localAdminMenus/localUserMenus 사용)
- const currentMenus = selectedMenuType === "admin" ? localAdminMenus : localUserMenus;
-
- // 검색어 필터링
- let filteredMenus = currentMenus;
- if (searchText.trim()) {
- const searchLower = searchText.toLowerCase();
- filteredMenus = currentMenus.filter((menu) => {
- const menuName = (menu.menu_name_kor || menu.MENU_NAME_KOR || "").toLowerCase();
- const menuUrl = (menu.menu_url || menu.MENU_URL || "").toLowerCase();
- return menuName.includes(searchLower) || menuUrl.includes(searchLower);
- });
- }
-
- // 회사 필터링
- if (selectedCompany !== "all") {
- filteredMenus = filteredMenus.filter((menu) => {
- const menuCompanyCode = menu.company_code || menu.COMPANY_CODE || "";
- return menuCompanyCode === selectedCompany;
- });
- }
-
- return filteredMenus;
- };
-
- // 메뉴 타입 변경 시 선택된 메뉴 초기화
- const handleMenuTypeChange = (type: MenuType) => {
- setSelectedMenuType(type);
- setSelectedMenus(new Set()); // 선택된 메뉴 초기화
- setExpandedMenus(new Set()); // 메뉴 타입 변경 시 확장 상태 초기화
-
- // 선택한 메뉴 타입에 해당하는 메뉴만 로드
- if (type === "admin" && localAdminMenus.length === 0) {
- loadMenusForType("admin", false);
- } else if (type === "user" && localUserMenus.length === 0) {
- loadMenusForType("user", false);
- }
- };
-
- const handleToggleExpand = (menuId: string) => {
- const newExpandedMenus = new Set(expandedMenus);
- if (newExpandedMenus.has(menuId)) {
- newExpandedMenus.delete(menuId);
- } else {
- newExpandedMenus.add(menuId);
- }
- setExpandedMenus(newExpandedMenus);
- };
-
- const getMenuTypeString = () => {
- return selectedMenuType === "admin" ? getUITextSync("menu.type.admin") : getUITextSync("menu.type.user");
- };
-
- const getMenuTypeValue = () => {
- return selectedMenuType === "admin" ? "0" : "1";
- };
-
- // uiTextsCount를 useMemo로 계산하여 상태 변경 시에만 재계산
- const uiTextsCount = useMemo(() => Object.keys(uiTexts).length, [uiTexts]);
- const adminMenusCount = useMemo(() => localAdminMenus?.length || 0, [localAdminMenus]);
- const userMenusCount = useMemo(() => localUserMenus?.length || 0, [localUserMenus]);
-
- // 디버깅을 위한 간단한 상태 표시
- // console.log("🔍 MenuManagement 렌더링 상태:", {
- // loading,
- // uiTextsLoading,
- // uiTextsCount,
- // adminMenusCount,
- // userMenusCount,
- // selectedMenuType,
- // userLang,
- // });
-
- if (loading) {
- return (
-
-
-
- );
- }
-
- return (
-
-
- {/* 좌측 사이드바 - 메뉴 타입 선택 (20%) */}
-
-
-
{getUITextSync("menu.type.title")}
-
- {/* 메뉴 타입 선택 카드들 */}
-
-
handleMenuTypeChange("admin")}
- >
-
-
-
{getUITextSync("menu.management.admin")}
-
- {getUITextSync("menu.management.admin.description")}
-
-
-
- {localAdminMenus.length}
-
-
-
-
-
handleMenuTypeChange("user")}
- >
-
-
-
{getUITextSync("menu.management.user")}
-
- {getUITextSync("menu.management.user.description")}
-
-
-
- {localUserMenus.length}
-
-
-
-
-
-
-
- {/* 우측 메인 영역 - 메뉴 목록 (80%) */}
-
-
- {/* 상단 헤더: 제목 + 검색 + 버튼 */}
-
- {/* 왼쪽: 제목 */}
-
- {getMenuTypeString()} {getUITextSync("menu.list.title")}
-
-
- {/* 오른쪽: 검색 + 버튼 */}
-
- {/* 회사 선택 */}
-
-
-
setIsCompanyDropdownOpen(!isCompanyDropdownOpen)}
- className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
- >
-
- {selectedCompany === "all"
- ? getUITextSync("filter.company.all")
- : selectedCompany === "*"
- ? getUITextSync("filter.company.common")
- : companies.find((c) => c.code === selectedCompany)?.name ||
- getUITextSync("filter.company.all")}
-
-
-
-
-
-
- {isCompanyDropdownOpen && (
-
-
- setCompanySearchText(e.target.value)}
- className="h-8 text-sm"
- onClick={(e) => e.stopPropagation()}
- />
-
-
-
-
{
- setSelectedCompany("all");
- setIsCompanyDropdownOpen(false);
- setCompanySearchText("");
- }}
- >
- {getUITextSync("filter.company.all")}
-
-
{
- setSelectedCompany("*");
- setIsCompanyDropdownOpen(false);
- setCompanySearchText("");
- }}
- >
- {getUITextSync("filter.company.common")}
-
-
- {companies
- .filter((company) => company.code && company.code.trim() !== "")
- .filter(
- (company) =>
- company.name.toLowerCase().includes(companySearchText.toLowerCase()) ||
- company.code.toLowerCase().includes(companySearchText.toLowerCase()),
- )
- .map((company, index) => (
-
{
- setSelectedCompany(company.code);
- setIsCompanyDropdownOpen(false);
- setCompanySearchText("");
- }}
- >
- {company.code === "*" ? getUITextSync("filter.company.common") : company.name}
-
- ))}
-
-
- )}
-
-
-
- {/* 검색 입력 */}
-
- setSearchText(e.target.value)}
- className="h-10 text-sm"
- />
-
-
- {/* 초기화 버튼 */}
-
{
- setSearchText("");
- setSelectedCompany("all");
- setCompanySearchText("");
- }}
- variant="outline"
- className="h-10 text-sm font-medium"
- >
- {getUITextSync("filter.reset")}
-
-
- {/* 최상위 메뉴 추가 */}
-
handleAddTopLevelMenu()} className="h-10 gap-2 text-sm font-medium">
- {getUITextSync("button.add.top.level")}
-
-
- {/* 선택 삭제 */}
- {selectedMenus.size > 0 && (
-
- {deleting ? (
- <>
-
- {getUITextSync("button.delete.processing")}
- >
- ) : (
- getUITextSync("button.delete.selected.count", {
- count: selectedMenus.size,
- })
- )}
-
- )}
-
-
-
- {/* 테이블 영역 */}
-
-
-
-
-
-
-
- setFormModalOpen(false)}
- onSuccess={handleFormSuccess}
- menuId={formData.menuId}
- parentId={formData.parentId}
- menuType={formData.menuType}
- level={formData.level}
- parentCompanyCode={formData.parentCompanyCode}
- uiTexts={uiTexts}
- />
-
-
-
-
- 메뉴 삭제
-
- 해당 메뉴를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
-
-
-
- 취소
- 삭제
-
-
-
-
-
-
- );
-};
diff --git a/frontend/components/admin/MonitoringDashboard.tsx b/frontend/components/admin/MonitoringDashboard.tsx
deleted file mode 100644
index 500dd4fb..00000000
--- a/frontend/components/admin/MonitoringDashboard.tsx
+++ /dev/null
@@ -1,288 +0,0 @@
-"use client";
-
-import React, { useState, useEffect } from "react";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { Button } from "@/components/ui/button";
-import { Badge } from "@/components/ui/badge";
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table";
-import { Progress } from "@/components/ui/progress";
-import { RefreshCw, Play, Pause, AlertCircle, CheckCircle, Clock } from "lucide-react";
-import { toast } from "sonner";
-import { BatchAPI, BatchMonitoring, BatchExecution } from "@/lib/api/batch";
-
-export default function MonitoringDashboard() {
- const [monitoring, setMonitoring] = useState(null);
- const [isLoading, setIsLoading] = useState(false);
- const [autoRefresh, setAutoRefresh] = useState(false);
-
- useEffect(() => {
- loadMonitoringData();
-
- let interval: NodeJS.Timeout;
- if (autoRefresh) {
- interval = setInterval(loadMonitoringData, 30000); // 30초마다 자동 새로고침
- }
-
- return () => {
- if (interval) clearInterval(interval);
- };
- }, [autoRefresh]);
-
- const loadMonitoringData = async () => {
- setIsLoading(true);
- try {
- const data = await BatchAPI.getBatchMonitoring();
- setMonitoring(data);
- } catch (error) {
- console.error("모니터링 데이터 조회 오류:", error);
- toast.error("모니터링 데이터를 불러오는데 실패했습니다.");
- } finally {
- setIsLoading(false);
- }
- };
-
- const handleRefresh = () => {
- loadMonitoringData();
- };
-
- const toggleAutoRefresh = () => {
- setAutoRefresh(!autoRefresh);
- };
-
- const getStatusIcon = (status: string) => {
- switch (status) {
- case 'completed':
- return ;
- case 'failed':
- return ;
- case 'running':
- return ;
- case 'pending':
- return ;
- default:
- return ;
- }
- };
-
- const getStatusBadge = (status: string) => {
- const variants = {
- completed: "bg-green-100 text-green-800",
- failed: "bg-destructive/20 text-red-800",
- running: "bg-primary/20 text-blue-800",
- pending: "bg-yellow-100 text-yellow-800",
- cancelled: "bg-gray-100 text-gray-800",
- };
-
- const labels = {
- completed: "완료",
- failed: "실패",
- running: "실행 중",
- pending: "대기 중",
- cancelled: "취소됨",
- };
-
- return (
-
- {labels[status as keyof typeof labels] || status}
-
- );
- };
-
- const formatDuration = (ms: number) => {
- if (ms < 1000) return `${ms}ms`;
- if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
- return `${(ms / 60000).toFixed(1)}m`;
- };
-
- const getSuccessRate = () => {
- if (!monitoring) return 0;
- const total = monitoring.successful_jobs_today + monitoring.failed_jobs_today;
- if (total === 0) return 100;
- return Math.round((monitoring.successful_jobs_today / total) * 100);
- };
-
- if (!monitoring) {
- return (
-
-
-
-
모니터링 데이터를 불러오는 중...
-
-
- );
- }
-
- return (
-
- {/* 헤더 */}
-
-
배치 모니터링
-
-
- {autoRefresh ? : }
- 자동 새로고침
-
-
-
- 새로고침
-
-
-
-
- {/* 통계 카드 */}
-
-
-
- 총 작업 수
- 📋
-
-
- {monitoring.total_jobs}
-
- 활성: {monitoring.active_jobs}개
-
-
-
-
-
-
- 실행 중
- 🔄
-
-
- {monitoring.running_jobs}
-
- 현재 실행 중인 작업
-
-
-
-
-
-
- 오늘 성공
- ✅
-
-
- {monitoring.successful_jobs_today}
-
- 성공률: {getSuccessRate()}%
-
-
-
-
-
-
- 오늘 실패
- ❌
-
-
- {monitoring.failed_jobs_today}
-
- 주의가 필요한 작업
-
-
-
-
-
- {/* 성공률 진행바 */}
-
-
- 오늘 실행 성공률
-
-
-
-
- 성공: {monitoring.successful_jobs_today}건
- 실패: {monitoring.failed_jobs_today}건
-
-
-
- {getSuccessRate()}% 성공률
-
-
-
-
-
- {/* 최근 실행 이력 */}
-
-
- 최근 실행 이력
-
-
- {monitoring.recent_executions.length === 0 ? (
-
- 최근 실행 이력이 없습니다.
-
- ) : (
-
-
-
- 상태
- 작업 ID
- 시작 시간
- 완료 시간
- 실행 시간
- 오류 메시지
-
-
-
- {monitoring.recent_executions.map((execution) => (
-
-
-
- {getStatusIcon(execution.execution_status)}
- {getStatusBadge(execution.execution_status)}
-
-
- #{execution.job_id}
-
- {execution.started_at
- ? new Date(execution.started_at).toLocaleString()
- : "-"}
-
-
- {execution.completed_at
- ? new Date(execution.completed_at).toLocaleString()
- : "-"}
-
-
- {execution.execution_time_ms
- ? formatDuration(execution.execution_time_ms)
- : "-"}
-
-
- {execution.error_message ? (
-
- {execution.error_message}
-
- ) : (
- "-"
- )}
-
-
- ))}
-
-
- )}
-
-
-
- );
-}
diff --git a/frontend/components/admin/MultiLang.tsx b/frontend/components/admin/MultiLang.tsx
deleted file mode 100644
index abdadcdb..00000000
--- a/frontend/components/admin/MultiLang.tsx
+++ /dev/null
@@ -1,859 +0,0 @@
-"use client";
-
-import { useState, useEffect } from "react";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
-import { Badge } from "@/components/ui/badge";
-
-import { DataTable } from "@/components/common/DataTable";
-import { LoadingSpinner } from "@/components/common/LoadingSpinner";
-import { useAuth } from "@/hooks/useAuth";
-import LangKeyModal from "./LangKeyModal";
-import LanguageModal from "./LanguageModal";
-import { apiClient } from "@/lib/api/client";
-
-interface Language {
- langCode: string;
- langName: string;
- langNative: string;
- isActive: string;
-}
-
-interface LangKey {
- keyId: number;
- companyCode: string;
- menuName: string;
- langKey: string;
- description: string;
- isActive: string;
-}
-
-interface LangText {
- textId: number;
- keyId: number;
- langCode: string;
- langText: string;
- isActive: string;
-}
-
-export default function MultiLangPage() {
- const { user } = useAuth();
- const [loading, setLoading] = useState(true);
- const [languages, setLanguages] = useState([]);
- const [langKeys, setLangKeys] = useState([]);
- const [selectedKey, setSelectedKey] = useState(null);
- const [langTexts, setLangTexts] = useState([]);
- const [editingTexts, setEditingTexts] = useState([]);
- const [selectedCompany, setSelectedCompany] = useState("all");
- const [searchText, setSearchText] = useState("");
- const [isModalOpen, setIsModalOpen] = useState(false);
- const [editingKey, setEditingKey] = useState(null);
- const [selectedKeys, setSelectedKeys] = useState>(new Set());
-
- // 언어 관리 관련 상태
- const [isLanguageModalOpen, setIsLanguageModalOpen] = useState(false);
- const [editingLanguage, setEditingLanguage] = useState(null);
- const [selectedLanguages, setSelectedLanguages] = useState>(new Set());
- const [activeTab, setActiveTab] = useState<"keys" | "languages">("keys");
-
- const [companies, setCompanies] = useState>([]);
-
- // 회사 목록 조회
- const fetchCompanies = async () => {
- try {
- // console.log("회사 목록 조회 시작");
- const response = await apiClient.get("/admin/companies");
- // console.log("회사 목록 응답 데이터:", response.data);
-
- const data = response.data;
- if (data.success) {
- const companyList = data.data.map((company: any) => ({
- code: company.company_code,
- name: company.company_name,
- }));
- // console.log("변환된 회사 목록:", companyList);
- setCompanies(companyList);
- } else {
- // console.error("회사 목록 조회 실패:", data.message);
- }
- } catch (error) {
- // console.error("회사 목록 조회 실패:", error);
- }
- };
-
- // 언어 목록 조회
- const fetchLanguages = async () => {
- try {
- const response = await apiClient.get("/multilang/languages");
- const data = response.data;
- if (data.success) {
- setLanguages(data.data);
- }
- } catch (error) {
- // console.error("언어 목록 조회 실패:", error);
- }
- };
-
- // 다국어 키 목록 조회
- const fetchLangKeys = async () => {
- try {
- const response = await apiClient.get("/multilang/keys");
- const data = response.data;
- if (data.success) {
- // console.log("✅ 전체 키 목록 로드:", data.data.length, "개");
- setLangKeys(data.data);
- } else {
- // console.error("❌ 키 목록 로드 실패:", data.message);
- }
- } catch (error) {
- // console.error("다국어 키 목록 조회 실패:", error);
- }
- };
-
- // 필터링된 데이터 계산 - 메뉴관리와 동일한 방식
- const getFilteredLangKeys = () => {
- let filteredKeys = langKeys;
-
- // 회사 필터링
- if (selectedCompany && selectedCompany !== "all") {
- filteredKeys = filteredKeys.filter((key) => key.companyCode === selectedCompany);
- }
-
- // 텍스트 검색 필터링
- if (searchText.trim()) {
- const searchLower = searchText.toLowerCase();
- filteredKeys = filteredKeys.filter((key) => {
- const langKey = (key.langKey || "").toLowerCase();
- const description = (key.description || "").toLowerCase();
- const menuName = (key.menuName || "").toLowerCase();
- const companyName = companies.find((c) => c.code === key.companyCode)?.name?.toLowerCase() || "";
-
- return (
- langKey.includes(searchLower) ||
- description.includes(searchLower) ||
- menuName.includes(searchLower) ||
- companyName.includes(searchLower)
- );
- });
- }
-
- return filteredKeys;
- };
-
- // 선택된 키의 다국어 텍스트 조회
- const fetchLangTexts = async (keyId: number) => {
- try {
- // console.log("다국어 텍스트 조회 시작: keyId =", keyId);
- const response = await apiClient.get(`/multilang/keys/${keyId}/texts`);
- const data = response.data;
- // console.log("다국어 텍스트 조회 응답:", data);
- if (data.success) {
- setLangTexts(data.data);
- // 편집용 텍스트 초기화
- const editingData = data.data.map((text: LangText) => ({ ...text }));
- setEditingTexts(editingData);
- // console.log("편집용 텍스트 설정:", editingData);
- }
- } catch (error) {
- // console.error("다국어 텍스트 조회 실패:", error);
- }
- };
-
- // 언어 키 선택 처리
- const handleKeySelect = (key: LangKey) => {
- // console.log("언어 키 선택:", key);
- setSelectedKey(key);
- fetchLangTexts(key.keyId);
- };
-
- // 디버깅용 useEffect
- useEffect(() => {
- if (selectedKey) {
- // console.log("선택된 키 변경:", selectedKey);
- // console.log("언어 목록:", languages);
- // console.log("편집 텍스트:", editingTexts);
- }
- }, [selectedKey, languages, editingTexts]);
-
- // 텍스트 변경 처리
- const handleTextChange = (langCode: string, value: string) => {
- const newEditingTexts = [...editingTexts];
- const existingIndex = newEditingTexts.findIndex((t) => t.langCode === langCode);
-
- if (existingIndex >= 0) {
- newEditingTexts[existingIndex].langText = value;
- } else {
- newEditingTexts.push({
- textId: 0,
- keyId: selectedKey!.keyId,
- langCode: langCode,
- langText: value,
- isActive: "Y",
- });
- }
-
- setEditingTexts(newEditingTexts);
- };
-
- // 텍스트 저장
- const handleSave = async () => {
- if (!selectedKey) return;
-
- try {
- // 백엔드가 기대하는 형식으로 데이터 변환
- const requestData = {
- texts: editingTexts.map((text) => ({
- langCode: text.langCode,
- langText: text.langText,
- isActive: text.isActive || "Y",
- createdBy: user?.userId || "system",
- updatedBy: user?.userId || "system",
- })),
- };
-
- const response = await apiClient.post(`/multilang/keys/${selectedKey.keyId}/texts`, requestData);
- const data = response.data;
- if (data.success) {
- alert("저장되었습니다.");
- // 저장 후 다시 조회
- fetchLangTexts(selectedKey.keyId);
- }
- } catch (error) {
- // console.error("텍스트 저장 실패:", error);
- alert("저장에 실패했습니다.");
- }
- };
-
- // 언어 키 추가/수정 모달 열기
- const handleAddKey = () => {
- setEditingKey(null); // 새 키 추가는 null로 설정
- setIsModalOpen(true);
- };
-
- // 언어 추가/수정 모달 열기
- const handleAddLanguage = () => {
- setEditingLanguage(null);
- setIsLanguageModalOpen(true);
- };
-
- // 언어 수정
- const handleEditLanguage = (language: Language) => {
- setEditingLanguage(language);
- setIsLanguageModalOpen(true);
- };
-
- // 언어 저장 (추가/수정)
- const handleSaveLanguage = async (languageData: any) => {
- try {
- const requestData = {
- ...languageData,
- createdBy: user?.userId || "admin",
- updatedBy: user?.userId || "admin",
- };
-
- let response;
- if (editingLanguage) {
- response = await apiClient.put(`/multilang/languages/${editingLanguage.langCode}`, requestData);
- } else {
- response = await apiClient.post("/multilang/languages", requestData);
- }
-
- const result = response.data;
-
- if (result.success) {
- alert(editingLanguage ? "언어가 수정되었습니다." : "언어가 추가되었습니다.");
- setIsLanguageModalOpen(false);
- fetchLanguages(); // 언어 목록 새로고침
- } else {
- alert(`오류: ${result.message}`);
- }
- } catch (error) {
- // console.error("언어 저장 중 오류:", error);
- alert("언어 저장 중 오류가 발생했습니다.");
- }
- };
-
- // 언어 삭제
- const handleDeleteLanguages = async () => {
- if (selectedLanguages.size === 0) {
- alert("삭제할 언어를 선택해주세요.");
- return;
- }
-
- if (
- !confirm(
- `선택된 ${selectedLanguages.size}개의 언어를 영구적으로 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.`,
- )
- ) {
- return;
- }
-
- try {
- const deletePromises = Array.from(selectedLanguages).map((langCode) =>
- apiClient.delete(`/multilang/languages/${langCode}`),
- );
-
- const responses = await Promise.all(deletePromises);
- const failedDeletes = responses.filter((response) => !response.data.success);
-
- if (failedDeletes.length === 0) {
- alert("선택된 언어가 삭제되었습니다.");
- setSelectedLanguages(new Set());
- fetchLanguages(); // 언어 목록 새로고침
- } else {
- alert(`${failedDeletes.length}개의 언어 삭제에 실패했습니다.`);
- }
- } catch (error) {
- // console.error("언어 삭제 중 오류:", error);
- alert("언어 삭제 중 오류가 발생했습니다.");
- }
- };
-
- // 언어 선택 체크박스 처리
- const handleLanguageCheckboxChange = (langCode: string, checked: boolean) => {
- const newSelected = new Set(selectedLanguages);
- if (checked) {
- newSelected.add(langCode);
- } else {
- newSelected.delete(langCode);
- }
- setSelectedLanguages(newSelected);
- };
-
- // 언어 전체 선택/해제
- const handleSelectAllLanguages = (checked: boolean) => {
- if (checked) {
- setSelectedLanguages(new Set(languages.map((lang) => lang.langCode)));
- } else {
- setSelectedLanguages(new Set());
- }
- };
-
- // 언어 키 수정 모달 열기
- const handleEditKey = (key: LangKey) => {
- setEditingKey(key);
- setIsModalOpen(true);
- };
-
- // 언어 키 저장 (추가/수정)
- const handleSaveKey = async (keyData: any) => {
- try {
- const requestData = {
- ...keyData,
- createdBy: user?.userId || "admin",
- updatedBy: user?.userId || "admin",
- };
-
- let response;
- if (editingKey) {
- response = await apiClient.put(`/multilang/keys/${editingKey.keyId}`, requestData);
- } else {
- response = await apiClient.post("/multilang/keys", requestData);
- }
-
- const data = response.data;
-
- if (data.success) {
- alert(editingKey ? "언어 키가 수정되었습니다." : "언어 키가 추가되었습니다.");
- fetchLangKeys(); // 목록 새로고침
- setIsModalOpen(false);
- } else {
- // 중복 체크 오류 메시지 처리
- if (data.message && data.message.includes("이미 존재하는 언어키")) {
- alert(data.message);
- } else {
- alert(data.message || "언어 키 저장에 실패했습니다.");
- }
- }
- } catch (error) {
- // console.error("언어 키 저장 실패:", error);
- alert("언어 키 저장에 실패했습니다.");
- }
- };
-
- // 체크박스 선택/해제
- const handleCheckboxChange = (keyId: number, checked: boolean) => {
- const newSelectedKeys = new Set(selectedKeys);
- if (checked) {
- newSelectedKeys.add(keyId);
- } else {
- newSelectedKeys.delete(keyId);
- }
- setSelectedKeys(newSelectedKeys);
- };
-
- // 키 상태 토글
- const handleToggleStatus = async (keyId: number) => {
- try {
- const response = await apiClient.put(`/multilang/keys/${keyId}/toggle`);
- const data = response.data;
- if (data.success) {
- alert(`키가 ${data.data}되었습니다.`);
- fetchLangKeys();
- } else {
- alert("상태 변경 중 오류가 발생했습니다.");
- }
- } catch (error) {
- // console.error("키 상태 토글 실패:", error);
- alert("키 상태 변경 중 오류가 발생했습니다.");
- }
- };
-
- // 언어 상태 토글
- const handleToggleLanguageStatus = async (langCode: string) => {
- try {
- const response = await apiClient.put(`/multilang/languages/${langCode}/toggle`);
- const data = response.data;
- if (data.success) {
- alert(`언어가 ${data.data}되었습니다.`);
- fetchLanguages();
- } else {
- alert("언어 상태 변경 중 오류가 발생했습니다.");
- }
- } catch (error) {
- // console.error("언어 상태 토글 실패:", error);
- alert("언어 상태 변경 중 오류가 발생했습니다.");
- }
- };
-
- // 전체 선택/해제
- const handleSelectAll = (checked: boolean) => {
- if (checked) {
- const allKeyIds = getFilteredLangKeys().map((key) => key.keyId);
- setSelectedKeys(new Set(allKeyIds));
- } else {
- setSelectedKeys(new Set());
- }
- };
-
- // 선택된 키들 일괄 삭제
- const handleDeleteSelectedKeys = async () => {
- if (selectedKeys.size === 0) {
- alert("삭제할 키를 선택해주세요.");
- return;
- }
-
- if (
- !confirm(
- `선택된 ${selectedKeys.size}개의 언어 키를 영구적으로 삭제하시겠습니까?\n\n⚠️ 이 작업은 되돌릴 수 없습니다.`,
- )
- ) {
- return;
- }
-
- try {
- const deletePromises = Array.from(selectedKeys).map((keyId) => apiClient.delete(`/multilang/keys/${keyId}`));
-
- const responses = await Promise.all(deletePromises);
- const allSuccess = responses.every((response) => response.data.success);
-
- if (allSuccess) {
- alert(`${selectedKeys.size}개의 언어 키가 영구적으로 삭제되었습니다.`);
- setSelectedKeys(new Set());
- fetchLangKeys(); // 목록 새로고침
-
- // 선택된 키가 삭제된 경우 편집 영역 닫기
- if (selectedKey && selectedKeys.has(selectedKey.keyId)) {
- handleCancel();
- }
- } else {
- alert("일부 키 삭제에 실패했습니다.");
- }
- } catch (error) {
- // console.error("선택된 키 삭제 실패:", error);
- alert("선택된 키 삭제에 실패했습니다.");
- }
- };
-
- // 개별 키 삭제 (기존 함수 유지)
- const handleDeleteKey = async (keyId: number) => {
- if (!confirm("정말로 이 언어 키를 영구적으로 삭제하시겠습니까?\n\n⚠️ 이 작업은 되돌릴 수 없습니다.")) {
- return;
- }
-
- try {
- const response = await apiClient.delete(`/multilang/keys/${keyId}`);
- const data = response.data;
- if (data.success) {
- alert("언어 키가 영구적으로 삭제되었습니다.");
- fetchLangKeys(); // 목록 새로고침
- if (selectedKey && selectedKey.keyId === keyId) {
- handleCancel(); // 선택된 키가 삭제된 경우 편집 영역 닫기
- }
- }
- } catch (error) {
- // console.error("언어 키 삭제 실패:", error);
- alert("언어 키 삭제에 실패했습니다.");
- }
- };
-
- // 취소 처리
- const handleCancel = () => {
- setSelectedKey(null);
- setLangTexts([]);
- setEditingTexts([]);
- };
-
- useEffect(() => {
- const initializeData = async () => {
- setLoading(true);
- await Promise.all([fetchCompanies(), fetchLanguages(), fetchLangKeys()]);
- setLoading(false);
- };
- initializeData();
- }, []);
-
- // 검색 관련 useEffect 제거 - 실시간 필터링만 사용
-
- const columns = [
- {
- id: "select",
- header: () => {
- const filteredKeys = getFilteredLangKeys();
- return (
- 0}
- onChange={(e) => handleSelectAll(e.target.checked)}
- className="h-4 w-4"
- />
- );
- },
- cell: ({ row }: any) => (
- handleCheckboxChange(row.original.keyId, e.target.checked)}
- onClick={(e) => e.stopPropagation()}
- className="h-4 w-4"
- disabled={row.original.isActive === "N"}
- />
- ),
- },
- {
- accessorKey: "companyCode",
- header: "회사",
- cell: ({ row }: any) => {
- const companyName =
- row.original.companyCode === "*"
- ? "공통"
- : companies.find((c) => c.code === row.original.companyCode)?.name || row.original.companyCode;
-
- return {companyName} ;
- },
- },
- {
- accessorKey: "menuName",
- header: "메뉴명",
- cell: ({ row }: any) => (
- {row.original.menuName}
- ),
- },
-
- {
- accessorKey: "langKey",
- header: "언어 키",
- cell: ({ row }: any) => (
- handleEditKey(row.original)}
- >
- {row.original.langKey}
-
- ),
- },
-
- {
- accessorKey: "description",
- header: "설명",
- cell: ({ row }: any) => (
- {row.original.description}
- ),
- },
- {
- accessorKey: "isActive",
- header: "상태",
- cell: ({ row }: any) => (
- handleToggleStatus(row.original.keyId)}
- className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
- row.original.isActive === "Y"
- ? "bg-green-100 text-green-800 hover:bg-green-200"
- : "bg-gray-100 text-gray-800 hover:bg-gray-200"
- }`}
- >
- {row.original.isActive === "Y" ? "활성" : "비활성"}
-
- ),
- },
- ];
-
- // 언어 테이블 컬럼 정의
- const languageColumns = [
- {
- id: "select",
- header: () => (
- 0}
- onChange={(e) => handleSelectAllLanguages(e.target.checked)}
- className="h-4 w-4"
- />
- ),
- cell: ({ row }: any) => (
- handleLanguageCheckboxChange(row.original.langCode, e.target.checked)}
- onClick={(e) => e.stopPropagation()}
- className="h-4 w-4"
- disabled={row.original.isActive === "N"}
- />
- ),
- },
- {
- accessorKey: "langCode",
- header: "언어 코드",
- cell: ({ row }: any) => (
- handleEditLanguage(row.original)}
- >
- {row.original.langCode}
-
- ),
- },
- {
- accessorKey: "langName",
- header: "언어명 (영문)",
- cell: ({ row }: any) => (
- {row.original.langName}
- ),
- },
- {
- accessorKey: "langNative",
- header: "언어명 (원어)",
- cell: ({ row }: any) => (
- {row.original.langNative}
- ),
- },
- {
- accessorKey: "isActive",
- header: "상태",
- cell: ({ row }: any) => (
- handleToggleLanguageStatus(row.original.langCode)}
- className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
- row.original.isActive === "Y"
- ? "bg-green-100 text-green-800 hover:bg-green-200"
- : "bg-gray-100 text-gray-800 hover:bg-gray-200"
- }`}
- >
- {row.original.isActive === "Y" ? "활성" : "비활성"}
-
- ),
- },
- ];
-
- if (loading) {
- return ;
- }
-
- return (
-
- {/* 탭 네비게이션 */}
-
- setActiveTab("keys")}
- className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${
- activeTab === "keys" ? "bg-accent0 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
- }`}
- >
- 다국어 키 관리
-
- setActiveTab("languages")}
- className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${
- activeTab === "languages" ? "bg-accent0 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
- }`}
- >
- 언어 관리
-
-
-
- {/* 메인 콘텐츠 영역 */}
-
- {/* 언어 관리 탭 */}
- {activeTab === "languages" && (
-
-
- 언어 관리
-
-
-
-
총 {languages.length}개의 언어가 등록되어 있습니다.
-
- {selectedLanguages.size > 0 && (
-
- 선택 삭제 ({selectedLanguages.size})
-
- )}
- 새 언어 추가
-
-
-
-
-
- )}
-
- {/* 다국어 키 관리 탭의 메인 영역 */}
- {activeTab === "keys" && (
-
- {/* 좌측: 언어 키 목록 (7/10) */}
-
-
-
-
언어 키 목록
-
-
- 선택 삭제 ({selectedKeys.size})
-
- 새 키 추가
-
-
-
-
- {/* 검색 필터 영역 */}
-
-
- 회사
-
-
-
-
-
- 전체 회사
- {companies.map((company) => (
-
- {company.name}
-
- ))}
-
-
-
-
-
- 검색
- setSearchText(e.target.value)}
- />
-
-
-
-
검색 결과: {getFilteredLangKeys().length}건
-
-
-
- {/* 테이블 영역 */}
-
-
전체: {getFilteredLangKeys().length}건
-
-
-
-
-
- {/* 우측: 선택된 키의 다국어 관리 (3/10) */}
-
-
-
- {selectedKey ? (
- <>
- 선택된 키:{" "}
-
- {selectedKey.companyCode}.{selectedKey.menuName}.{selectedKey.langKey}
-
- >
- ) : (
- "다국어 편집"
- )}
-
-
-
- {selectedKey ? (
-
- {/* 스크롤 가능한 텍스트 영역 */}
-
- {languages
- .filter((lang) => lang.isActive === "Y")
- .map((lang) => {
- const text = editingTexts.find((t) => t.langCode === lang.langCode);
- return (
-
-
- {lang.langName}
-
- handleTextChange(lang.langCode, e.target.value)}
- className="flex-1"
- />
-
- );
- })}
-
- {/* 저장 버튼 - 고정 위치 */}
-
- 저장
-
- 취소
-
-
-
- ) : (
-
-
-
언어 키를 선택하세요
-
좌측 목록에서 편집할 언어 키를 클릭하세요
-
-
- )}
-
-
-
- )}
-
-
- {/* 언어 키 추가/수정 모달 */}
-
setIsModalOpen(false)}
- onSave={handleSaveKey}
- keyData={editingKey}
- companies={companies}
- />
-
- {/* 언어 추가/수정 모달 */}
- setIsLanguageModalOpen(false)}
- onSave={handleSaveLanguage}
- languageData={editingLanguage}
- />
-
- );
-}
diff --git a/frontend/components/admin/RoleDetailManagement.tsx b/frontend/components/admin/RoleDetailManagement.tsx
deleted file mode 100644
index 92d03143..00000000
--- a/frontend/components/admin/RoleDetailManagement.tsx
+++ /dev/null
@@ -1,345 +0,0 @@
-"use client";
-
-import React, { useState, useCallback, useEffect } from "react";
-import { Button } from "@/components/ui/button";
-import { ArrowLeft, Users, Menu as MenuIcon, Save } from "lucide-react";
-import { roleAPI, RoleGroup } from "@/lib/api/role";
-import { useAuth } from "@/hooks/useAuth";
-import { useRouter } from "next/navigation";
-import { AlertCircle } from "lucide-react";
-import { DualListBox } from "@/components/common/DualListBox";
-import { MenuPermissionsTable } from "./MenuPermissionsTable";
-import { useMenu } from "@/contexts/MenuContext";
-
-interface RoleDetailManagementProps {
- roleId: string;
-}
-
-/**
- * 권한 그룹 상세 관리 컴포넌트
- *
- * 기능:
- * - 권한 그룹 정보 표시
- * - 멤버 관리 (Dual List Box)
- * - 메뉴 권한 설정 (CRUD 체크박스)
- */
-export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
- const { user: currentUser } = useAuth();
- const router = useRouter();
- const { refreshMenus } = useMenu();
-
- const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
-
- // 상태 관리
- const [roleGroup, setRoleGroup] = useState(null);
- const [isLoading, setIsLoading] = useState(true);
- const [error, setError] = useState(null);
-
- // 탭 상태
- const [activeTab, setActiveTab] = useState<"members" | "permissions">("members");
-
- // 멤버 관리 상태
- const [availableUsers, setAvailableUsers] = useState>([]);
- const [selectedUsers, setSelectedUsers] = useState>([]);
- const [isSavingMembers, setIsSavingMembers] = useState(false);
-
- // 메뉴 권한 상태
- const [menuPermissions, setMenuPermissions] = useState([]);
- const [isSavingPermissions, setIsSavingPermissions] = useState(false);
-
- // 데이터 로드
- const loadRoleGroup = useCallback(async () => {
- setIsLoading(true);
- setError(null);
-
- try {
- const response = await roleAPI.getById(parseInt(roleId, 10));
-
- if (response.success && response.data) {
- setRoleGroup(response.data);
- } else {
- setError(response.message || "권한 그룹 정보를 불러오는데 실패했습니다.");
- }
- } catch (err) {
- console.error("권한 그룹 정보 로드 오류:", err);
- setError("권한 그룹 정보를 불러오는 중 오류가 발생했습니다.");
- } finally {
- setIsLoading(false);
- }
- }, [roleId]);
-
- // 멤버 목록 로드
- const loadMembers = useCallback(async () => {
- if (!roleGroup) return;
-
- try {
- // 1. 권한 그룹 멤버 조회
- const membersResponse = await roleAPI.getMembers(roleGroup.objid);
- if (membersResponse.success && membersResponse.data) {
- setSelectedUsers(
- membersResponse.data.map((member: any) => ({
- id: member.userId,
- label: member.userName || member.userId,
- description: member.deptName,
- })),
- );
- }
-
- // 2. 전체 사용자 목록 조회 (같은 회사)
- const userAPI = await import("@/lib/api/user");
-
- console.log("🔍 사용자 목록 조회 요청:", {
- companyCode: roleGroup.companyCode,
- size: 1000,
- });
-
- const usersResponse = await userAPI.userAPI.getList({
- companyCode: roleGroup.companyCode,
- size: 1000, // 대량 조회
- });
-
- console.log("✅ 사용자 목록 응답:", {
- success: usersResponse.success,
- count: usersResponse.data?.length,
- total: usersResponse.total,
- });
-
- if (usersResponse.success && usersResponse.data) {
- setAvailableUsers(
- usersResponse.data.map((user: any) => ({
- id: user.userId,
- label: user.userName || user.userId,
- description: user.deptName,
- })),
- );
- console.log("📋 설정된 전체 사용자 수:", usersResponse.data.length);
- }
- } catch (err) {
- console.error("멤버 목록 로드 오류:", err);
- }
- }, [roleGroup]);
-
- // 메뉴 권한 로드
- const loadMenuPermissions = useCallback(async () => {
- if (!roleGroup) return;
-
- console.log("🔍 [loadMenuPermissions] 메뉴 권한 로드 시작", {
- roleGroupId: roleGroup.objid,
- roleGroupName: roleGroup.authName,
- companyCode: roleGroup.companyCode,
- });
-
- try {
- const response = await roleAPI.getMenuPermissions(roleGroup.objid);
-
- console.log("✅ [loadMenuPermissions] API 응답", {
- success: response.success,
- dataCount: response.data?.length,
- data: response.data,
- });
-
- if (response.success && response.data) {
- setMenuPermissions(response.data);
- console.log("✅ [loadMenuPermissions] 상태 업데이트 완료", {
- count: response.data.length,
- });
- } else {
- console.warn("⚠️ [loadMenuPermissions] 응답 실패", {
- message: response.message,
- });
- }
- } catch (err) {
- console.error("❌ [loadMenuPermissions] 메뉴 권한 로드 오류:", err);
- }
- }, [roleGroup]);
-
- useEffect(() => {
- loadRoleGroup();
- }, [loadRoleGroup]);
-
- useEffect(() => {
- if (roleGroup && activeTab === "members") {
- loadMembers();
- } else if (roleGroup && activeTab === "permissions") {
- loadMenuPermissions();
- }
- }, [roleGroup, activeTab, loadMembers, loadMenuPermissions]);
-
- // 멤버 저장 핸들러
- const handleSaveMembers = useCallback(async () => {
- if (!roleGroup) return;
-
- setIsSavingMembers(true);
- try {
- // 현재 선택된 사용자 ID 목록
- const selectedUserIds = selectedUsers.map((user) => user.id);
-
- // 멤버 업데이트 API 호출
- const response = await roleAPI.updateMembers(roleGroup.objid, selectedUserIds);
-
- if (response.success) {
- alert("멤버가 성공적으로 저장되었습니다.");
- loadMembers(); // 새로고침
-
- // 사이드바 메뉴 새로고침 (현재 사용자가 영향받을 수 있음)
- await refreshMenus();
- } else {
- alert(response.message || "멤버 저장에 실패했습니다.");
- }
- } catch (err) {
- console.error("멤버 저장 오류:", err);
- alert("멤버 저장 중 오류가 발생했습니다.");
- } finally {
- setIsSavingMembers(false);
- }
- }, [roleGroup, selectedUsers, loadMembers, refreshMenus]);
-
- // 메뉴 권한 저장 핸들러
- const handleSavePermissions = useCallback(async () => {
- if (!roleGroup) return;
-
- setIsSavingPermissions(true);
- try {
- const response = await roleAPI.setMenuPermissions(roleGroup.objid, menuPermissions);
-
- if (response.success) {
- alert("메뉴 권한이 성공적으로 저장되었습니다.");
- loadMenuPermissions(); // 새로고침
-
- // 사이드바 메뉴 새로고침 (권한 변경 즉시 반영)
- await refreshMenus();
- } else {
- alert(response.message || "메뉴 권한 저장에 실패했습니다.");
- }
- } catch (err) {
- console.error("메뉴 권한 저장 오류:", err);
- alert("메뉴 권한 저장 중 오류가 발생했습니다.");
- } finally {
- setIsSavingPermissions(false);
- }
- }, [roleGroup, menuPermissions, loadMenuPermissions, refreshMenus]);
-
- if (isLoading) {
- return (
-
-
-
-
권한 그룹 정보를 불러오는 중...
-
-
- );
- }
-
- if (error || !roleGroup) {
- return (
-
-
-
오류 발생
-
{error || "권한 그룹을 찾을 수 없습니다."}
-
router.push("/admin/userMng/rolesList")}>
- 목록으로 돌아가기
-
-
- );
- }
-
- return (
- <>
- {/* 페이지 헤더 */}
-
-
-
router.push("/admin/userMng/rolesList")} className="h-10 w-10">
-
-
-
-
{roleGroup.authName}
-
- {roleGroup.authCode} • {roleGroup.companyCode}
-
-
-
- {roleGroup.status === "active" ? "활성" : "비활성"}
-
-
-
-
- {/* 탭 네비게이션 */}
-
- setActiveTab("members")}
- className={`flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors ${
- activeTab === "members"
- ? "border-primary text-primary"
- : "text-muted-foreground hover:text-foreground border-transparent"
- }`}
- >
-
- 멤버 관리 ({selectedUsers.length})
-
- setActiveTab("permissions")}
- className={`flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors ${
- activeTab === "permissions"
- ? "border-primary text-primary"
- : "text-muted-foreground hover:text-foreground border-transparent"
- }`}
- >
-
- 메뉴 권한 ({menuPermissions.length})
-
-
-
- {/* 탭 컨텐츠 */}
-
- {activeTab === "members" && (
- <>
-
-
-
멤버 관리
-
이 권한 그룹에 속한 사용자를 관리합니다
-
-
-
- {isSavingMembers ? "저장 중..." : "멤버 저장"}
-
-
-
-
- >
- )}
-
- {activeTab === "permissions" && (
- <>
-
-
-
메뉴 권한 설정
-
이 권한 그룹에서 접근 가능한 메뉴와 권한을 설정합니다
-
-
-
- {isSavingPermissions ? "저장 중..." : "권한 저장"}
-
-
-
-
- >
- )}
-
- >
- );
-}
diff --git a/frontend/components/admin/RoleManagement.tsx b/frontend/components/admin/RoleManagement.tsx
deleted file mode 100644
index 3834b2a4..00000000
--- a/frontend/components/admin/RoleManagement.tsx
+++ /dev/null
@@ -1,335 +0,0 @@
-"use client";
-
-import React, { useState, useCallback, useEffect } from "react";
-import { Button } from "@/components/ui/button";
-import { Plus, Edit, Trash2, Users, Menu, Filter, X } from "lucide-react";
-import { roleAPI, RoleGroup } from "@/lib/api/role";
-import { useAuth } from "@/hooks/useAuth";
-import { AlertCircle } from "lucide-react";
-import { RoleFormModal } from "./RoleFormModal";
-import { RoleDeleteModal } from "./RoleDeleteModal";
-import { useRouter } from "next/navigation";
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
-import { companyAPI } from "@/lib/api/company";
-
-/**
- * 권한 그룹 관리 메인 컴포넌트
- *
- * 기능:
- * - 권한 그룹 목록 조회 (회사별)
- * - 권한 그룹 생성/수정/삭제
- * - 상세 페이지로 이동 (멤버 관리 + 메뉴 권한 설정)
- */
-export function RoleManagement() {
- const { user: currentUser } = useAuth();
- const router = useRouter();
-
- // 회사 관리자 또는 최고 관리자 여부
- const isAdmin =
- (currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN") ||
- currentUser?.userType === "COMPANY_ADMIN";
- const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
-
- // 상태 관리
- const [roleGroups, setRoleGroups] = useState([]);
- const [isLoading, setIsLoading] = useState(true);
- const [error, setError] = useState(null);
-
- // 회사 필터 (최고 관리자 전용)
- const [companies, setCompanies] = useState>([]);
- const [selectedCompany, setSelectedCompany] = useState("all");
-
- // 모달 상태
- const [formModal, setFormModal] = useState({
- isOpen: false,
- editingRole: null as RoleGroup | null,
- });
-
- const [deleteModal, setDeleteModal] = useState({
- isOpen: false,
- role: null as RoleGroup | null,
- });
-
- // 회사 목록 로드 (최고 관리자만)
- const loadCompanies = useCallback(async () => {
- if (!isSuperAdmin) return;
-
- try {
- const companies = await companyAPI.getList();
- setCompanies(companies);
- } catch (error) {
- console.error("회사 목록 로드 오류:", error);
- }
- }, [isSuperAdmin]);
-
- // 데이터 로드
- const loadRoleGroups = useCallback(async () => {
- setIsLoading(true);
- setError(null);
-
- try {
- // 최고 관리자: selectedCompany에 따라 필터링 (all이면 전체 조회)
- // 회사 관리자: 자기 회사만 조회
- const companyFilter =
- isSuperAdmin && selectedCompany !== "all"
- ? selectedCompany
- : isSuperAdmin
- ? undefined
- : currentUser?.companyCode;
-
- console.log("권한 그룹 목록 조회:", { isSuperAdmin, selectedCompany, companyFilter });
-
- const response = await roleAPI.getList({
- companyCode: companyFilter,
- });
-
- if (response.success && response.data) {
- setRoleGroups(response.data);
- console.log("권한 그룹 조회 성공:", response.data.length, "개");
- } else {
- setError(response.message || "권한 그룹 목록을 불러오는데 실패했습니다.");
- }
- } catch (err) {
- console.error("권한 그룹 목록 로드 오류:", err);
- setError("권한 그룹 목록을 불러오는 중 오류가 발생했습니다.");
- } finally {
- setIsLoading(false);
- }
- }, [isSuperAdmin, selectedCompany, currentUser?.companyCode]);
-
- useEffect(() => {
- if (isAdmin) {
- if (isSuperAdmin) {
- loadCompanies(); // 최고 관리자는 회사 목록 먼저 로드
- }
- loadRoleGroups();
- } else {
- setIsLoading(false);
- }
- }, [isAdmin, isSuperAdmin, loadRoleGroups, loadCompanies]);
-
- // 권한 그룹 생성 핸들러
- const handleCreateRole = useCallback(() => {
- setFormModal({ isOpen: true, editingRole: null });
- }, []);
-
- // 권한 그룹 수정 핸들러
- const handleEditRole = useCallback((role: RoleGroup) => {
- setFormModal({ isOpen: true, editingRole: role });
- }, []);
-
- // 권한 그룹 삭제 핸들러
- const handleDeleteRole = useCallback((role: RoleGroup) => {
- setDeleteModal({ isOpen: true, role });
- }, []);
-
- // 폼 모달 닫기
- const handleFormModalClose = useCallback(() => {
- setFormModal({ isOpen: false, editingRole: null });
- }, []);
-
- // 삭제 모달 닫기
- const handleDeleteModalClose = useCallback(() => {
- setDeleteModal({ isOpen: false, role: null });
- }, []);
-
- // 모달 성공 후 새로고침
- const handleModalSuccess = useCallback(() => {
- loadRoleGroups();
- }, [loadRoleGroups]);
-
- // 상세 페이지로 이동
- const handleViewDetail = useCallback(
- (role: RoleGroup) => {
- router.push(`/admin/userMng/rolesList/${role.objid}`);
- },
- [router],
- );
-
- // 관리자가 아니면 접근 제한
- if (!isAdmin) {
- return (
-
-
-
접근 권한 없음
-
- 권한 그룹 관리는 회사 관리자 이상만 접근할 수 있습니다.
-
-
window.history.back()}>
- 뒤로 가기
-
-
- );
- }
-
- return (
- <>
- {/* 에러 메시지 */}
- {error && (
-
-
-
오류가 발생했습니다
-
setError(null)}
- className="text-destructive hover:text-destructive/80 transition-colors"
- aria-label="에러 메시지 닫기"
- >
- ✕
-
-
-
{error}
-
- )}
-
- {/* 액션 버튼 영역 */}
-
-
-
권한 그룹 목록 ({roleGroups.length})
-
- {/* 최고 관리자 전용: 회사 필터 */}
- {isSuperAdmin && (
-
-
- setSelectedCompany(value)}>
-
-
-
-
- 전체 회사
- {companies.map((company) => (
-
- {company.company_name}
-
- ))}
-
-
- {selectedCompany !== "all" && (
- setSelectedCompany("all")} className="h-8 w-8 p-0">
-
-
- )}
-
- )}
-
-
-
-
- 권한 그룹 생성
-
-
-
- {/* 권한 그룹 목록 */}
- {isLoading ? (
-
-
-
-
권한 그룹 목록을 불러오는 중...
-
-
- ) : roleGroups.length === 0 ? (
-
-
-
등록된 권한 그룹이 없습니다.
-
권한 그룹을 생성하여 멤버를 관리해보세요.
-
-
- ) : (
-
- {roleGroups.map((role) => (
-
- {/* 헤더 (클릭 시 상세 페이지) */}
-
handleViewDetail(role)}
- >
-
-
-
{role.authName}
-
{role.authCode}
-
-
- {role.status === "active" ? "활성" : "비활성"}
-
-
-
- {/* 정보 */}
-
- {/* 최고 관리자는 회사명 표시 */}
- {isSuperAdmin && (
-
- 회사
-
- {companies.find((c) => c.company_code === role.companyCode)?.company_name || role.companyCode}
-
-
- )}
-
-
-
- 멤버 수
-
- {role.memberCount || 0}명
-
-
-
-
- 메뉴 권한
-
- {role.menuCount || 0}개
-
-
-
-
- {/* 액션 버튼 */}
-
- {
- e.stopPropagation();
- handleEditRole(role);
- }}
- className="flex-1 gap-1 text-xs"
- >
-
- 수정
-
- {
- e.stopPropagation();
- handleDeleteRole(role);
- }}
- className="text-destructive hover:bg-destructive hover:text-destructive-foreground gap-1 text-xs"
- >
-
- 삭제
-
-
-
- ))}
-
- )}
-
- {/* 모달들 */}
-
-
-
- >
- );
-}
diff --git a/frontend/components/admin/UserAuthManagement.tsx b/frontend/components/admin/UserAuthManagement.tsx
deleted file mode 100644
index 27163ba5..00000000
--- a/frontend/components/admin/UserAuthManagement.tsx
+++ /dev/null
@@ -1,157 +0,0 @@
-"use client";
-
-import React, { useState, useCallback, useEffect } from "react";
-import { UserAuthTable } from "./UserAuthTable";
-import { UserAuthEditModal } from "./UserAuthEditModal";
-import { userAPI } from "@/lib/api/user";
-import { useAuth } from "@/hooks/useAuth";
-import { AlertCircle } from "lucide-react";
-import { Button } from "@/components/ui/button";
-
-/**
- * 사용자 권한 관리 메인 컴포넌트
- *
- * 기능:
- * - 사용자 목록 조회 (권한 정보 포함)
- * - 권한 변경 모달
- * - 최고 관리자 권한 체크
- */
-export function UserAuthManagement() {
- const { user: currentUser } = useAuth();
-
- // 최고 관리자 여부
- const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
-
- // 상태 관리
- const [users, setUsers] = useState([]);
- const [isLoading, setIsLoading] = useState(true);
- const [error, setError] = useState(null);
- const [paginationInfo, setPaginationInfo] = useState({
- currentPage: 1,
- pageSize: 20,
- totalItems: 0,
- totalPages: 0,
- });
-
- // 권한 변경 모달
- const [authEditModal, setAuthEditModal] = useState({
- isOpen: false,
- user: null as any | null,
- });
-
- // 데이터 로드
- const loadUsers = useCallback(
- async (page: number = 1) => {
- setIsLoading(true);
- setError(null);
-
- try {
- const response = await userAPI.getList({
- page,
- size: paginationInfo.pageSize,
- });
-
- if (response.success && response.data) {
- setUsers(response.data);
- setPaginationInfo({
- currentPage: response.currentPage || page,
- pageSize: response.pageSize || paginationInfo.pageSize,
- totalItems: response.total || 0,
- totalPages: Math.ceil((response.total || 0) / (response.pageSize || paginationInfo.pageSize)),
- });
- } else {
- setError(response.message || "사용자 목록을 불러오는데 실패했습니다.");
- }
- } catch (err) {
- console.error("사용자 목록 로드 오류:", err);
- setError("사용자 목록을 불러오는 중 오류가 발생했습니다.");
- } finally {
- setIsLoading(false);
- }
- },
- [paginationInfo.pageSize],
- );
-
- useEffect(() => {
- loadUsers(1);
- }, []);
-
- // 권한 변경 핸들러
- const handleEditAuth = (user: any) => {
- setAuthEditModal({
- isOpen: true,
- user,
- });
- };
-
- // 권한 변경 모달 닫기
- const handleAuthEditClose = () => {
- setAuthEditModal({
- isOpen: false,
- user: null,
- });
- };
-
- // 권한 변경 성공
- const handleAuthEditSuccess = () => {
- loadUsers(paginationInfo.currentPage);
- handleAuthEditClose();
- };
-
- // 페이지 변경
- const handlePageChange = (page: number) => {
- loadUsers(page);
- };
-
- // 최고 관리자가 아닌 경우
- if (!isSuperAdmin) {
- return (
-
-
-
접근 권한 없음
-
권한 관리는 최고 관리자만 접근할 수 있습니다.
-
window.history.back()}>
- 뒤로 가기
-
-
- );
- }
-
- return (
-
- {/* 에러 메시지 */}
- {error && (
-
-
-
오류가 발생했습니다
-
setError(null)}
- className="text-destructive hover:text-destructive/80 transition-colors"
- aria-label="에러 메시지 닫기"
- >
- ✕
-
-
-
{error}
-
- )}
-
- {/* 사용자 권한 테이블 */}
-
-
- {/* 권한 변경 모달 */}
-
-
- );
-}
diff --git a/frontend/components/admin/UserManagement.tsx b/frontend/components/admin/UserManagement.tsx
deleted file mode 100644
index 987b986e..00000000
--- a/frontend/components/admin/UserManagement.tsx
+++ /dev/null
@@ -1,176 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { useUserManagement } from "@/hooks/useUserManagement";
-import { UserToolbar } from "./UserToolbar";
-import { UserTable } from "./UserTable";
-import { Pagination } from "@/components/common/Pagination";
-import { UserPasswordResetModal } from "./UserPasswordResetModal";
-import { UserFormModal } from "./UserFormModal";
-
-/**
- * 사용자 관리 메인 컴포넌트
- * - 원본 Spring + JSP 코드 패턴 기반 REST API 연동
- * - 실제 데이터베이스와 연동되어 작동
- */
-export function UserManagement() {
- const {
- // 데이터
- users,
- searchFilter,
- isLoading,
- isSearching,
- error,
- paginationInfo,
-
- // 검색 기능
- updateSearchFilter,
-
- // 페이지네이션
- handlePageChange,
- handlePageSizeChange,
-
- // 액션 핸들러
- handleStatusToggle,
-
- // 유틸리티
- clearError,
- refreshData,
- } = useUserManagement();
-
- // 비밀번호 초기화 모달 상태
- const [passwordResetModal, setPasswordResetModal] = useState({
- isOpen: false,
- userId: null as string | null,
- userName: null as string | null,
- });
-
- // 사용자 등록/수정 모달 상태
- const [userFormModal, setUserFormModal] = useState({
- isOpen: false,
- editingUser: null as any | null,
- });
-
- // 사용자 등록 핸들러
- const handleCreateUser = () => {
- setUserFormModal({
- isOpen: true,
- editingUser: null,
- });
- };
-
- // 사용자 수정 핸들러
- const handleEditUser = (user: any) => {
- setUserFormModal({
- isOpen: true,
- editingUser: user,
- });
- };
-
- // 사용자 등록/수정 모달 닫기
- const handleUserFormClose = () => {
- setUserFormModal({
- isOpen: false,
- editingUser: null,
- });
- };
-
- // 사용자 등록/수정 성공 핸들러
- const handleUserFormSuccess = () => {
- refreshData(); // 목록 새로고침
- handleUserFormClose();
- };
-
- // 비밀번호 초기화 핸들러
- const handlePasswordReset = (userId: string, userName: string) => {
- setPasswordResetModal({
- isOpen: true,
- userId,
- userName,
- });
- };
-
- // 비밀번호 초기화 모달 닫기
- const handlePasswordResetClose = () => {
- setPasswordResetModal({
- isOpen: false,
- userId: null,
- userName: null,
- });
- };
-
- // 비밀번호 초기화 성공 핸들러
- const handlePasswordResetSuccess = () => {
- // refreshData(); // 비밀번호 변경은 목록에 영향을 주지 않으므로 새로고침 불필요
- handlePasswordResetClose();
- };
-
- return (
-
- {/* 툴바 - 검색, 필터, 등록 버튼 */}
-
-
- {/* 에러 메시지 */}
- {error && (
-
- )}
-
- {/* 사용자 목록 테이블 */}
-
-
- {/* 페이지네이션 */}
- {!isLoading && users.length > 0 && (
-
- )}
-
- {/* 사용자 등록/수정 모달 */}
-
-
- {/* 비밀번호 초기화 모달 */}
-
-
- );
-}
diff --git a/frontend/components/admin/department/DepartmentManagement.tsx b/frontend/components/admin/department/DepartmentManagement.tsx
deleted file mode 100644
index 4939e24e..00000000
--- a/frontend/components/admin/department/DepartmentManagement.tsx
+++ /dev/null
@@ -1,117 +0,0 @@
-"use client";
-
-import { useState, useEffect } from "react";
-import { useRouter } from "next/navigation";
-import { Button } from "@/components/ui/button";
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { ArrowLeft } from "lucide-react";
-import { DepartmentStructure } from "./DepartmentStructure";
-import { DepartmentMembers } from "./DepartmentMembers";
-import type { Department } from "@/types/department";
-import { getCompanyList } from "@/lib/api/company";
-
-interface DepartmentManagementProps {
- companyCode: string;
-}
-
-/**
- * 부서 관리 메인 컴포넌트
- * 좌측: 부서 구조, 우측: 부서 인원
- */
-export function DepartmentManagement({ companyCode }: DepartmentManagementProps) {
- const router = useRouter();
- const [selectedDepartment, setSelectedDepartment] = useState(null);
- const [activeTab, setActiveTab] = useState("structure");
- const [companyName, setCompanyName] = useState("");
- const [refreshTrigger, setRefreshTrigger] = useState(0);
-
- // 부서원 변경 시 부서 구조 새로고침
- const handleMemberChange = () => {
- setRefreshTrigger((prev) => prev + 1);
- };
-
- // 회사 정보 로드
- useEffect(() => {
- const loadCompanyInfo = async () => {
- const response = await getCompanyList();
- if (response.success && response.data) {
- const company = response.data.find((c) => c.company_code === companyCode);
- if (company) {
- setCompanyName(company.company_name);
- }
- }
- };
- loadCompanyInfo();
- }, [companyCode]);
-
- const handleBackToList = () => {
- router.push("/admin/userMng/companyList");
- };
-
- return (
-
- {/* 상단 헤더: 회사 정보 + 뒤로가기 */}
-
-
-
-
- 회사 목록
-
-
-
-
{companyName || companyCode}
-
부서 관리
-
-
-
- {/* 탭 네비게이션 (모바일용) */}
-
-
-
- 부서 구조
- 부서 인원
-
-
-
-
-
-
-
-
-
-
-
-
- {/* 좌우 레이아웃 (데스크톱) */}
-
- {/* 좌측: 부서 구조 (20%) */}
-
-
-
-
- {/* 우측: 부서 인원 (80%) */}
-
-
-
-
-
- );
-}
diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx
index 449a9c49..b28e4d01 100644
--- a/frontend/components/layout/AppLayout.tsx
+++ b/frontend/components/layout/AppLayout.tsx
@@ -17,12 +17,14 @@ import {
UserCheck,
LogOut,
User,
+ Building2,
} from "lucide-react";
import { useMenu } from "@/contexts/MenuContext";
import { useAuth } from "@/hooks/useAuth";
import { useProfile } from "@/hooks/useProfile";
import { MenuItem } from "@/lib/api/menu";
import { menuScreenApi } from "@/lib/api/screen";
+import { apiClient } from "@/lib/api/client";
import { toast } from "sonner";
import { ProfileModal } from "./ProfileModal";
import { Logo } from "./Logo";
@@ -35,6 +37,14 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+} from "@/components/ui/dialog";
+import { CompanySwitcher } from "@/components/admin/CompanySwitcher";
// useAuth의 UserInfo 타입을 확장
interface ExtendedUserInfo {
@@ -206,11 +216,38 @@ function AppLayoutInner({ children }: AppLayoutProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
- const { user, logout, refreshUserData } = useAuth();
+ const { user, logout, refreshUserData, switchCompany } = useAuth();
const { userMenus, adminMenus, loading, refreshMenus } = useMenu();
const [sidebarOpen, setSidebarOpen] = useState(true);
const [expandedMenus, setExpandedMenus] = useState>(new Set());
const [isMobile, setIsMobile] = useState(false);
+ const [showCompanySwitcher, setShowCompanySwitcher] = useState(false);
+ const [currentCompanyName, setCurrentCompanyName] = useState("");
+
+ // 현재 회사명 조회 (SUPER_ADMIN 전용)
+ useEffect(() => {
+ const fetchCurrentCompanyName = async () => {
+ if ((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN") {
+ const companyCode = (user as ExtendedUserInfo)?.companyCode;
+
+ if (companyCode === "*") {
+ setCurrentCompanyName("WACE (최고 관리자)");
+ } else if (companyCode) {
+ try {
+ const response = await apiClient.get("/admin/companies/db");
+ if (response.data.success) {
+ const company = response.data.data.find((c: any) => c.company_code === companyCode);
+ setCurrentCompanyName(company?.company_name || companyCode);
+ }
+ } catch (error) {
+ setCurrentCompanyName(companyCode);
+ }
+ }
+ }
+ };
+
+ fetchCurrentCompanyName();
+ }, [(user as ExtendedUserInfo)?.companyCode, (user as ExtendedUserInfo)?.userType]);
// 화면 크기 감지 및 사이드바 초기 상태 설정
useEffect(() => {
@@ -333,11 +370,32 @@ function AppLayoutInner({ children }: AppLayoutProps) {
};
// 모드 전환 핸들러
- const handleModeSwitch = () => {
+ const handleModeSwitch = async () => {
if (isAdminMode) {
+ // 관리자 → 사용자 모드: 선택한 회사 유지
router.push("/main");
} else {
- router.push("/admin");
+ // 사용자 → 관리자 모드: WACE로 복귀 필요 (SUPER_ADMIN만)
+ if ((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN") {
+ const currentCompanyCode = (user as ExtendedUserInfo)?.companyCode;
+
+ // 이미 WACE("*")가 아니면 WACE로 전환 후 관리자 페이지로 이동
+ if (currentCompanyCode !== "*") {
+ const result = await switchCompany("*");
+ if (result.success) {
+ // 페이지 새로고침 (관리자 페이지로 이동)
+ window.location.href = "/admin";
+ } else {
+ toast.error("WACE로 전환 실패");
+ }
+ } else {
+ // 이미 WACE면 바로 관리자 페이지로 이동
+ router.push("/admin");
+ }
+ } else {
+ // 일반 관리자는 바로 관리자 페이지로 이동
+ router.push("/admin");
+ }
}
};
@@ -498,11 +556,27 @@ function AppLayoutInner({ children }: AppLayoutProps) {
)}
+ {/* WACE 관리자: 현재 관리 회사 표시 */}
+ {(user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" && (
+
+
+
+
+
현재 관리 회사
+
+ {currentCompanyName || "로딩 중..."}
+
+
+
+
+ )}
+
{/* Admin/User 모드 전환 버튼 (관리자만) */}
{((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" ||
(user as ExtendedUserInfo)?.userType === "COMPANY_ADMIN" ||
(user as ExtendedUserInfo)?.userType === "admin") && (
-
+
+ {/* 관리자/사용자 메뉴 전환 */}
)}
+
+ {/* WACE 관리자 전용: 회사 선택 버튼 */}
+ {(user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" && (
+ { console.log("🔴 회사 선택 버튼 클릭!"); setShowCompanySwitcher(true); }}
+ className="flex w-full items-center justify-center gap-2 rounded-lg border border-purple-200 bg-purple-50 px-3 py-2 text-sm font-medium text-purple-700 transition-colors duration-200 hover:cursor-pointer hover:bg-purple-100"
+ >
+
+ 회사 선택
+
+ )}
)}
@@ -653,6 +738,21 @@ function AppLayoutInner({ children }: AppLayoutProps) {
onSave={saveProfile}
onAlertClose={closeAlert}
/>
+
+ {/* 회사 전환 모달 (WACE 관리자 전용) */}
+
+
+
+ 회사 선택
+
+ 관리할 회사를 선택하면 해당 회사의 관점에서 시스템을 사용할 수 있습니다.
+
+
+
+ setShowCompanySwitcher(false)} isOpen={showCompanySwitcher} />
+
+
+
);
}
diff --git a/frontend/contexts/MenuContext.tsx b/frontend/contexts/MenuContext.tsx
index 1ced8546..88b15542 100644
--- a/frontend/contexts/MenuContext.tsx
+++ b/frontend/contexts/MenuContext.tsx
@@ -4,6 +4,7 @@ import React, { createContext, useContext, useState, useEffect, ReactNode } from
import type { MenuItem } from "@/lib/api/menu";
import { menuApi } from "@/lib/api/menu"; // API 호출 활성화
import { toast } from "sonner";
+import { useAuth } from "@/hooks/useAuth"; // user 정보 가져오기
interface MenuContextType {
adminMenus: MenuItem[];
@@ -18,6 +19,7 @@ export function MenuProvider({ children }: { children: ReactNode }) {
const [adminMenus, setAdminMenus] = useState([]);
const [userMenus, setUserMenus] = useState([]);
const [loading, setLoading] = useState(true);
+ const { user } = useAuth(); // user 정보 가져오기
const convertMenuData = (data: any[]): MenuItem[] => {
return data.map((item) => ({
@@ -96,8 +98,10 @@ export function MenuProvider({ children }: { children: ReactNode }) {
};
useEffect(() => {
+ // user.companyCode가 변경되면 메뉴 다시 로드
+ // console.log("🔄 MenuContext: user.companyCode 변경 감지, 메뉴 재로드", user?.companyCode);
loadMenus();
- }, []); // 초기 로드만
+ }, [user?.companyCode]); // companyCode 변경 시 재로드
return (
{children}
diff --git a/frontend/hooks/useAuth.ts b/frontend/hooks/useAuth.ts
index 854a9196..29752559 100644
--- a/frontend/hooks/useAuth.ts
+++ b/frontend/hooks/useAuth.ts
@@ -331,6 +331,61 @@ export const useAuth = () => {
[apiCall, refreshUserData],
);
+ /**
+ * 회사 전환 처리 (WACE 관리자 전용)
+ */
+ const switchCompany = useCallback(
+ async (companyCode: string): Promise<{ success: boolean; message: string }> => {
+ try {
+ // console.log("🔵 useAuth.switchCompany 시작:", companyCode);
+ setLoading(true);
+ setError(null);
+
+ // console.log("🔵 API 호출: POST /auth/switch-company");
+ const response = await apiCall("POST", "/auth/switch-company", {
+ companyCode,
+ });
+ // console.log("🔵 API 응답:", response);
+
+ if (response.success && response.data?.token) {
+ // console.log("🔵 새 토큰 받음:", response.data.token.substring(0, 20) + "...");
+
+ // 새로운 JWT 토큰 저장
+ TokenManager.setToken(response.data.token);
+ // console.log("🔵 토큰 저장 완료");
+
+ // refreshUserData 호출하지 않고 바로 성공 반환
+ // (페이지 새로고침 시 자동으로 갱신됨)
+ // console.log("🔵 회사 전환 완료 (페이지 새로고침 필요)");
+
+ return {
+ success: true,
+ message: response.message || "회사 전환에 성공했습니다.",
+ };
+ } else {
+ // console.error("🔵 API 응답 실패:", response);
+ return {
+ success: false,
+ message: response.message || "회사 전환에 실패했습니다.",
+ };
+ }
+ } catch (error: any) {
+ // console.error("🔵 switchCompany 에러:", error);
+ const errorMessage = error.message || "회사 전환 중 오류가 발생했습니다.";
+ setError(errorMessage);
+
+ return {
+ success: false,
+ message: errorMessage,
+ };
+ } finally {
+ setLoading(false);
+ // console.log("🔵 switchCompany 완료");
+ }
+ },
+ [apiCall]
+ );
+
/**
* 로그아웃 처리
*/
@@ -493,6 +548,7 @@ export const useAuth = () => {
// 함수
login,
logout,
+ switchCompany, // 🆕 회사 전환 함수
checkMenuAuth,
refreshUserData,
diff --git a/frontend/lib/api/menu.ts b/frontend/lib/api/menu.ts
index 82ab39ac..67de76ae 100644
--- a/frontend/lib/api/menu.ts
+++ b/frontend/lib/api/menu.ts
@@ -85,9 +85,9 @@ export const menuApi = {
return response.data;
},
- // 사용자 메뉴 목록 조회 (좌측 사이드바용 - active만 표시)
+ // 사용자 메뉴 목록 조회 (좌측 사이드바용 - active만 표시, 회사별 필터링)
getUserMenus: async (): Promise> => {
- const response = await apiClient.get("/admin/menus", { params: { menuType: "1" } });
+ const response = await apiClient.get("/admin/user-menus");
return response.data;
},
From 7d6bff49aa7d737fb6bb6fa78490b99b2d595a0f Mon Sep 17 00:00:00 2001
From: kjs
Date: Tue, 30 Dec 2025 15:36:28 +0900
Subject: [PATCH 23/95] =?UTF-8?q?=ED=8F=BC=20=EC=B1=84=EB=B2=88=20?=
=?UTF-8?q?=EC=98=A4=EC=9E=91=EB=8F=99=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?=
=?UTF-8?q?=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/lib/utils/buttonActions.ts | 60 +++++++++++++++++++++++++++++
1 file changed, 60 insertions(+)
diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts
index 9a6a606e..944f7126 100644
--- a/frontend/lib/utils/buttonActions.ts
+++ b/frontend/lib/utils/buttonActions.ts
@@ -1608,6 +1608,66 @@ export class ButtonActionExecutor {
return { handled: false, success: false };
}
+ // 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가)
+ console.log("🔍 [handleUniversalFormModalTableSectionSave] 채번 규칙 할당 체크 시작");
+
+ const fieldsWithNumbering: Record = {};
+
+ // commonFieldsData와 modalData에서 채번 규칙이 설정된 필드 찾기
+ for (const [key, value] of Object.entries(modalData)) {
+ if (key.endsWith("_numberingRuleId") && value) {
+ const fieldName = key.replace("_numberingRuleId", "");
+ fieldsWithNumbering[fieldName] = value as string;
+ console.log(`🎯 [handleUniversalFormModalTableSectionSave] 채번 필드 발견: ${fieldName} → 규칙 ${value}`);
+ }
+ }
+
+ // formData에서도 확인 (모달 외부에 있을 수 있음)
+ for (const [key, value] of Object.entries(formData)) {
+ if (key.endsWith("_numberingRuleId") && value && !fieldsWithNumbering[key.replace("_numberingRuleId", "")]) {
+ const fieldName = key.replace("_numberingRuleId", "");
+ fieldsWithNumbering[fieldName] = value as string;
+ console.log(
+ `🎯 [handleUniversalFormModalTableSectionSave] 채번 필드 발견 (formData): ${fieldName} → 규칙 ${value}`,
+ );
+ }
+ }
+
+ console.log("📋 [handleUniversalFormModalTableSectionSave] 채번 규칙이 설정된 필드:", fieldsWithNumbering);
+
+ // 🔥 저장 시점에 allocateCode 호출하여 실제 순번 증가
+ if (Object.keys(fieldsWithNumbering).length > 0) {
+ console.log("🎯 [handleUniversalFormModalTableSectionSave] 채번 규칙 할당 시작 (allocateCode 호출)");
+ const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
+
+ for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) {
+ try {
+ console.log(
+ `🔄 [handleUniversalFormModalTableSectionSave] ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`,
+ );
+ const allocateResult = await allocateNumberingCode(ruleId);
+
+ if (allocateResult.success && allocateResult.data?.generatedCode) {
+ const newCode = allocateResult.data.generatedCode;
+ console.log(
+ `✅ [handleUniversalFormModalTableSectionSave] ${fieldName} 새 코드 할당: ${commonFieldsData[fieldName]} → ${newCode}`,
+ );
+ commonFieldsData[fieldName] = newCode;
+ } else {
+ console.warn(
+ `⚠️ [handleUniversalFormModalTableSectionSave] ${fieldName} 코드 할당 실패, 기존 값 유지:`,
+ allocateResult.error,
+ );
+ }
+ } catch (allocateError) {
+ console.error(`❌ [handleUniversalFormModalTableSectionSave] ${fieldName} 코드 할당 오류:`, allocateError);
+ // 오류 시 기존 값 유지
+ }
+ }
+ }
+
+ console.log("✅ [handleUniversalFormModalTableSectionSave] 채번 규칙 할당 완료");
+
try {
// 사용자 정보 추가
if (!context.userId) {
From bd49db16c6c55c226a28151491864797ae777203 Mon Sep 17 00:00:00 2001
From: kjs
Date: Tue, 30 Dec 2025 17:45:38 +0900
Subject: [PATCH 24/95] 1
---
frontend/app/(main)/admin/systemMng/dataflow/page.tsx | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/frontend/app/(main)/admin/systemMng/dataflow/page.tsx b/frontend/app/(main)/admin/systemMng/dataflow/page.tsx
index 87d937ec..d55a6cf1 100644
--- a/frontend/app/(main)/admin/systemMng/dataflow/page.tsx
+++ b/frontend/app/(main)/admin/systemMng/dataflow/page.tsx
@@ -51,17 +51,17 @@ export default function DataFlowPage() {
// 에디터 모드일 때는 레이아웃 없이 전체 화면 사용
if (isEditorMode) {
return (
-
+
{/* 에디터 헤더 */}
-
+
목록으로
노드 플로우 에디터
-
+
드래그 앤 드롭으로 데이터 제어 플로우를 시각적으로 설계합니다
@@ -77,12 +77,12 @@ export default function DataFlowPage() {
}
return (
-
+
{/* 페이지 헤더 */}
제어 관리
-
노드 기반 데이터 플로우를 시각적으로 설계하고 관리합니다
+
노드 기반 데이터 플로우를 시각적으로 설계하고 관리합니다
{/* 플로우 목록 */}
From 5bdc903b0dee0382f21de3792b916937cf36fe8c Mon Sep 17 00:00:00 2001
From: kjs
Date: Wed, 31 Dec 2025 10:54:07 +0900
Subject: [PATCH 25/95] =?UTF-8?q?=EB=B2=94=EC=9A=A9=20=ED=8F=BC=EB=AA=A8?=
=?UTF-8?q?=EB=8B=AC=20=EC=A0=9C=EC=96=B4=EB=A1=9C=EC=A7=81=20=EC=97=B0?=
=?UTF-8?q?=EB=8F=99?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/routes/dataflow/node-flows.ts | 67 ++++++++++++++++
frontend/lib/api/nodeFlows.ts | 38 +++++++++
frontend/lib/utils/buttonActions.ts | 78 +++++++++++++++++++
3 files changed, 183 insertions(+)
diff --git a/backend-node/src/routes/dataflow/node-flows.ts b/backend-node/src/routes/dataflow/node-flows.ts
index 6de84866..177b4304 100644
--- a/backend-node/src/routes/dataflow/node-flows.ts
+++ b/backend-node/src/routes/dataflow/node-flows.ts
@@ -214,6 +214,73 @@ router.delete("/:flowId", async (req: Request, res: Response) => {
}
});
+/**
+ * 플로우 소스 테이블 조회
+ * GET /api/dataflow/node-flows/:flowId/source-table
+ * 플로우의 첫 번째 소스 노드(tableSource, externalDBSource)에서 테이블명 추출
+ */
+router.get("/:flowId/source-table", async (req: Request, res: Response) => {
+ try {
+ const { flowId } = req.params;
+
+ const flow = await queryOne<{ flow_data: any }>(
+ `SELECT flow_data FROM node_flows WHERE flow_id = $1`,
+ [flowId]
+ );
+
+ if (!flow) {
+ return res.status(404).json({
+ success: false,
+ message: "플로우를 찾을 수 없습니다.",
+ });
+ }
+
+ const flowData =
+ typeof flow.flow_data === "string"
+ ? JSON.parse(flow.flow_data)
+ : flow.flow_data;
+
+ const nodes = flowData.nodes || [];
+
+ // 소스 노드 찾기 (tableSource, externalDBSource 타입)
+ const sourceNode = nodes.find(
+ (node: any) =>
+ node.type === "tableSource" || node.type === "externalDBSource"
+ );
+
+ if (!sourceNode || !sourceNode.data?.tableName) {
+ return res.json({
+ success: true,
+ data: {
+ sourceTable: null,
+ sourceNodeType: null,
+ message: "소스 노드가 없거나 테이블명이 설정되지 않았습니다.",
+ },
+ });
+ }
+
+ logger.info(
+ `플로우 소스 테이블 조회: flowId=${flowId}, table=${sourceNode.data.tableName}`
+ );
+
+ return res.json({
+ success: true,
+ data: {
+ sourceTable: sourceNode.data.tableName,
+ sourceNodeType: sourceNode.type,
+ sourceNodeId: sourceNode.id,
+ displayName: sourceNode.data.displayName,
+ },
+ });
+ } catch (error) {
+ logger.error("플로우 소스 테이블 조회 실패:", error);
+ return res.status(500).json({
+ success: false,
+ message: "플로우 소스 테이블을 조회하지 못했습니다.",
+ });
+ }
+});
+
/**
* 플로우 실행
* POST /api/dataflow/node-flows/:flowId/execute
diff --git a/frontend/lib/api/nodeFlows.ts b/frontend/lib/api/nodeFlows.ts
index b42340d7..27bb1b96 100644
--- a/frontend/lib/api/nodeFlows.ts
+++ b/frontend/lib/api/nodeFlows.ts
@@ -120,3 +120,41 @@ export interface NodeExecutionSummary {
duration?: number;
error?: string;
}
+
+/**
+ * 플로우 소스 테이블 정보 인터페이스
+ */
+export interface FlowSourceTableInfo {
+ sourceTable: string | null;
+ sourceNodeType: string | null;
+ sourceNodeId?: string;
+ displayName?: string;
+ message?: string;
+}
+
+/**
+ * 플로우 소스 테이블 조회
+ * 플로우의 첫 번째 소스 노드(tableSource, externalDBSource)에서 테이블명 추출
+ */
+export async function getFlowSourceTable(flowId: number): Promise {
+ try {
+ const response = await apiClient.get>(
+ `/dataflow/node-flows/${flowId}/source-table`,
+ );
+ if (response.data.success && response.data.data) {
+ return response.data.data;
+ }
+ return {
+ sourceTable: null,
+ sourceNodeType: null,
+ message: response.data.message || "소스 테이블 정보를 가져올 수 없습니다.",
+ };
+ } catch (error) {
+ console.error("플로우 소스 테이블 조회 실패:", error);
+ return {
+ sourceTable: null,
+ sourceNodeType: null,
+ message: "API 호출 중 오류가 발생했습니다.",
+ };
+ }
+}
diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts
index 944f7126..327cb87f 100644
--- a/frontend/lib/utils/buttonActions.ts
+++ b/frontend/lib/utils/buttonActions.ts
@@ -1864,6 +1864,84 @@ export class ButtonActionExecutor {
console.log(`✅ [handleUniversalFormModalTableSectionSave] 완료: ${resultMessage}`);
toast.success(`저장 완료: ${resultMessage}`);
+ // 🆕 저장 성공 후 제어 관리 실행 (다중 테이블 저장 시 소스 테이블과 일치하는 섹션만 실행)
+ if (config.enableDataflowControl && config.dataflowConfig?.flowConfig?.flowId) {
+ const flowId = config.dataflowConfig.flowConfig.flowId;
+ console.log("🎯 [handleUniversalFormModalTableSectionSave] 제어 관리 실행 시작:", { flowId });
+
+ try {
+ // 플로우 소스 테이블 조회
+ const { getFlowSourceTable } = await import("@/lib/api/nodeFlows");
+ const flowSourceInfo = await getFlowSourceTable(flowId);
+
+ console.log("📊 [handleUniversalFormModalTableSectionSave] 플로우 소스 테이블:", flowSourceInfo);
+
+ if (flowSourceInfo.sourceTable) {
+ // 각 섹션 확인하여 소스 테이블과 일치하는 섹션 찾기
+ let controlExecuted = false;
+
+ for (const [sectionId, sectionItems] of Object.entries(tableSectionData)) {
+ const sectionConfig = sections.find((s: any) => s.id === sectionId);
+ const sectionTargetTable = sectionConfig?.tableConfig?.saveConfig?.targetTable || tableName;
+
+ console.log(`🔍 [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} 테이블 비교:`, {
+ sectionTargetTable,
+ flowSourceTable: flowSourceInfo.sourceTable,
+ isMatch: sectionTargetTable === flowSourceInfo.sourceTable,
+ });
+
+ // 소스 테이블과 일치하는 섹션만 제어 실행
+ if (sectionTargetTable === flowSourceInfo.sourceTable && sectionItems.length > 0) {
+ console.log(
+ `✅ [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} → 플로우 소스 테이블 일치! 제어 실행`,
+ );
+
+ // 공통 필드 + 해당 섹션 데이터 병합하여 sourceData 생성
+ const sourceData = sectionItems.map((item: any) => ({
+ ...commonFieldsData,
+ ...item,
+ }));
+
+ console.log(
+ `📦 [handleUniversalFormModalTableSectionSave] 제어 전달 데이터: ${sourceData.length}건`,
+ sourceData[0],
+ );
+
+ // 제어 관리용 컨텍스트 생성
+ const controlContext: ButtonActionContext = {
+ ...context,
+ selectedRowsData: sourceData,
+ formData: commonFieldsData,
+ };
+
+ // 제어 관리 실행
+ await this.executeAfterSaveControl(config, controlContext);
+ controlExecuted = true;
+ break; // 첫 번째 매칭 섹션만 실행
+ }
+ }
+
+ // 매칭되는 섹션이 없으면 메인 테이블 확인
+ if (!controlExecuted && tableName === flowSourceInfo.sourceTable) {
+ console.log("✅ [handleUniversalFormModalTableSectionSave] 메인 테이블 일치! 공통 필드로 제어 실행");
+
+ const controlContext: ButtonActionContext = {
+ ...context,
+ selectedRowsData: [commonFieldsData],
+ formData: commonFieldsData,
+ };
+
+ await this.executeAfterSaveControl(config, controlContext);
+ }
+ } else {
+ console.log("⚠️ [handleUniversalFormModalTableSectionSave] 플로우 소스 테이블 없음 - 제어 스킵");
+ }
+ } catch (controlError) {
+ console.error("❌ [handleUniversalFormModalTableSectionSave] 제어 관리 실행 오류:", controlError);
+ // 제어 관리 실패는 저장 성공에 영향주지 않음
+ }
+ }
+
// 저장 성공 이벤트 발생
window.dispatchEvent(new CustomEvent("saveSuccess"));
window.dispatchEvent(new CustomEvent("refreshTable"));
From 417e1d297b52176aa7060af6bb22194deef71a05 Mon Sep 17 00:00:00 2001
From: kjs
Date: Wed, 31 Dec 2025 13:53:30 +0900
Subject: [PATCH 26/95] =?UTF-8?q?=ED=8F=BC=20=EC=A1=B0=EA=B1=B4=EB=B3=84?=
=?UTF-8?q?=20=EA=B3=84=EC=82=B0=EC=8B=9D=20=EC=84=A4=EC=A0=95=EA=B8=B0?=
=?UTF-8?q?=EB=8A=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../TableSectionRenderer.tsx | 58 ++-
.../modals/TableSectionSettingsModal.tsx | 474 ++++++++++++++++--
.../components/universal-form-modal/types.ts | 24 +
3 files changed, 505 insertions(+), 51 deletions(-)
diff --git a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx
index a1c0bd76..1242e1d2 100644
--- a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx
@@ -16,7 +16,13 @@ import { ItemSelectionModal } from "../modal-repeater-table/ItemSelectionModal";
import { RepeaterColumnConfig, CalculationRule } from "../modal-repeater-table/types";
// 타입 정의
-import { TableSectionConfig, TableColumnConfig, TableJoinCondition, FormDataState } from "./types";
+import {
+ TableSectionConfig,
+ TableColumnConfig,
+ TableJoinCondition,
+ FormDataState,
+ TableCalculationRule,
+} from "./types";
interface TableSectionRendererProps {
sectionId: string;
@@ -811,39 +817,69 @@ export function TableSectionRenderer({
});
}, [tableConfig.columns, dynamicSelectOptionsMap]);
- // 계산 규칙 변환
- const calculationRules: CalculationRule[] = (tableConfig.calculations || []).map(convertToCalculationRule);
+ // 원본 계산 규칙 (조건부 계산 포함)
+ const originalCalculationRules: TableCalculationRule[] = useMemo(
+ () => tableConfig.calculations || [],
+ [tableConfig.calculations],
+ );
- // 계산 로직
+ // 기본 계산 규칙 변환 (RepeaterTable용 - 조건부 계산이 없는 경우에 사용)
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const calculationRules: CalculationRule[] = originalCalculationRules.map(convertToCalculationRule);
+
+ // 조건부 계산 로직: 행의 조건 필드 값에 따라 적절한 계산식 선택
+ const getFormulaForRow = useCallback((rule: TableCalculationRule, row: Record): string => {
+ // 조건부 계산이 활성화된 경우
+ if (rule.conditionalCalculation?.enabled && rule.conditionalCalculation.conditionField) {
+ const conditionValue = row[rule.conditionalCalculation.conditionField];
+ // 조건값과 일치하는 규칙 찾기
+ const matchedRule = rule.conditionalCalculation.rules?.find((r) => r.conditionValue === conditionValue);
+ if (matchedRule) {
+ return matchedRule.formula;
+ }
+ // 일치하는 규칙이 없으면 기본 계산식 사용
+ if (rule.conditionalCalculation.defaultFormula) {
+ return rule.conditionalCalculation.defaultFormula;
+ }
+ }
+ // 조건부 계산이 비활성화되었거나 기본값이 없으면 원래 계산식 사용
+ return rule.formula;
+ }, []);
+
+ // 계산 로직 (조건부 계산 지원)
const calculateRow = useCallback(
(row: any): any => {
- if (calculationRules.length === 0) return row;
+ if (originalCalculationRules.length === 0) return row;
const updatedRow = { ...row };
- for (const rule of calculationRules) {
+ for (const rule of originalCalculationRules) {
try {
- let formula = rule.formula;
+ // 조건부 계산에 따라 적절한 계산식 선택
+ let formula = getFormulaForRow(rule, row);
+
+ if (!formula) continue;
+
const fieldMatches = formula.match(/[a-zA-Z_][a-zA-Z0-9_]*/g) || [];
const dependencies = rule.dependencies.length > 0 ? rule.dependencies : fieldMatches;
for (const dep of dependencies) {
- if (dep === rule.result) continue;
+ if (dep === rule.resultField) continue;
const value = parseFloat(row[dep]) || 0;
formula = formula.replace(new RegExp(`\\b${dep}\\b`, "g"), value.toString());
}
const result = new Function(`return ${formula}`)();
- updatedRow[rule.result] = result;
+ updatedRow[rule.resultField] = result;
} catch (error) {
console.error(`계산 오류 (${rule.formula}):`, error);
- updatedRow[rule.result] = 0;
+ updatedRow[rule.resultField] = 0;
}
}
return updatedRow;
},
- [calculationRules],
+ [originalCalculationRules, getFormulaForRow],
);
const calculateAll = useCallback(
diff --git a/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx
index d82db59b..037707ca 100644
--- a/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx
@@ -24,6 +24,8 @@ import {
TablePreFilter,
TableModalFilter,
TableCalculationRule,
+ ConditionalCalculationRule,
+ ConditionalCalculationConfig,
LookupOption,
LookupCondition,
ConditionalTableOption,
@@ -52,6 +54,429 @@ const HelpText = ({ children }: { children: React.ReactNode }) => (
{children}
);
+// 계산 규칙 편집 컴포넌트 (조건부 계산 지원)
+interface CalculationRuleEditorProps {
+ calc: TableCalculationRule;
+ index: number;
+ columns: TableColumnConfig[];
+ sourceTableName?: string; // 소스 테이블명 추가
+ onUpdate: (updates: Partial) => void;
+ onRemove: () => void;
+}
+
+const CalculationRuleEditor: React.FC = ({
+ calc,
+ index,
+ columns,
+ sourceTableName,
+ onUpdate,
+ onRemove,
+}) => {
+ const [categoryOptions, setCategoryOptions] = useState<{ value: string; label: string }[]>([]);
+ const [loadingOptions, setLoadingOptions] = useState(false);
+ const [categoryColumns, setCategoryColumns] = useState>({});
+
+ // 조건부 계산 활성화 여부
+ const isConditionalEnabled = calc.conditionalCalculation?.enabled ?? false;
+
+ // 소스 테이블의 카테고리 컬럼 정보 로드
+ useEffect(() => {
+ const loadCategoryColumns = async () => {
+ console.log("[CalculationRuleEditor] sourceTableName:", sourceTableName);
+
+ if (!sourceTableName) {
+ setCategoryColumns({});
+ return;
+ }
+
+ try {
+ const { getCategoryColumns } = await import("@/lib/api/tableCategoryValue");
+ const result = await getCategoryColumns(sourceTableName);
+ console.log("[CalculationRuleEditor] getCategoryColumns 결과:", result);
+
+ if (result && result.success && Array.isArray(result.data)) {
+ const categoryMap: Record = {};
+ result.data.forEach((col: any) => {
+ // API 응답은 camelCase (columnName)
+ const colName = col.columnName || col.column_name;
+ if (colName) {
+ categoryMap[colName] = true;
+ }
+ });
+ console.log("[CalculationRuleEditor] categoryMap:", categoryMap);
+ setCategoryColumns(categoryMap);
+ }
+ } catch (error) {
+ console.error("카테고리 컬럼 조회 실패:", error);
+ }
+ };
+
+ loadCategoryColumns();
+ }, [sourceTableName]);
+
+ // 조건 필드가 선택되었을 때 옵션 로드 (테이블 타입 관리의 카테고리 기준)
+ useEffect(() => {
+ const loadConditionOptions = async () => {
+ if (!isConditionalEnabled || !calc.conditionalCalculation?.conditionField) {
+ setCategoryOptions([]);
+ return;
+ }
+
+ const conditionField = calc.conditionalCalculation.conditionField;
+
+ // 소스 필드(sourceField)가 있으면 해당 필드명 사용, 없으면 field명 사용
+ const selectedColumn = columns.find((col) => col.field === conditionField);
+ const actualFieldName = selectedColumn?.sourceField || conditionField;
+
+ console.log("[loadConditionOptions] 조건 필드:", {
+ conditionField,
+ actualFieldName,
+ sourceTableName,
+ categoryColumnsKeys: Object.keys(categoryColumns),
+ isCategoryColumn: categoryColumns[actualFieldName],
+ });
+
+ // 소스 테이블에서 해당 컬럼이 카테고리 타입인지 확인
+ if (sourceTableName && categoryColumns[actualFieldName]) {
+ try {
+ setLoadingOptions(true);
+ const { getCategoryValues } = await import("@/lib/api/tableCategoryValue");
+ console.log("[loadConditionOptions] getCategoryValues 호출:", sourceTableName, actualFieldName);
+ const result = await getCategoryValues(sourceTableName, actualFieldName, false);
+ console.log("[loadConditionOptions] getCategoryValues 결과:", result);
+ if (result && result.success && Array.isArray(result.data)) {
+ const options = result.data.map((item: any) => ({
+ // API 응답은 camelCase (valueCode, valueLabel)
+ value: item.valueCode || item.value_code || item.value,
+ label: item.valueLabel || item.displayLabel || item.display_label || item.label || item.valueCode || item.value_code || item.value,
+ }));
+ console.log("[loadConditionOptions] 매핑된 옵션:", options);
+ setCategoryOptions(options);
+ } else {
+ setCategoryOptions([]);
+ }
+ } catch (error) {
+ console.error("카테고리 값 로드 실패:", error);
+ setCategoryOptions([]);
+ } finally {
+ setLoadingOptions(false);
+ }
+ return;
+ }
+
+ // 카테고리 키가 직접 설정된 경우 (저장된 값)
+ const categoryKey = calc.conditionalCalculation?.conditionFieldCategoryKey;
+ if (categoryKey) {
+ try {
+ setLoadingOptions(true);
+ const [tableName, columnName] = categoryKey.split(".");
+ if (tableName && columnName) {
+ const { getCategoryValues } = await import("@/lib/api/tableCategoryValue");
+ const result = await getCategoryValues(tableName, columnName, false);
+ if (result && result.success && Array.isArray(result.data)) {
+ setCategoryOptions(
+ result.data.map((item: any) => ({
+ // API 응답은 camelCase (valueCode, valueLabel)
+ value: item.valueCode || item.value_code || item.value,
+ label: item.valueLabel || item.displayLabel || item.display_label || item.label || item.valueCode || item.value_code || item.value,
+ }))
+ );
+ }
+ }
+ } catch (error) {
+ console.error("카테고리 옵션 로드 실패:", error);
+ } finally {
+ setLoadingOptions(false);
+ }
+ return;
+ }
+
+ // 그 외 타입은 옵션 없음 (직접 입력)
+ setCategoryOptions([]);
+ };
+
+ loadConditionOptions();
+ }, [isConditionalEnabled, calc.conditionalCalculation?.conditionField, calc.conditionalCalculation?.conditionFieldCategoryKey, columns, sourceTableName, categoryColumns]);
+
+ // 조건부 계산 토글
+ const toggleConditionalCalculation = (enabled: boolean) => {
+ onUpdate({
+ conditionalCalculation: enabled
+ ? {
+ enabled: true,
+ conditionField: "",
+ conditionFieldType: "static",
+ rules: [],
+ defaultFormula: calc.formula || "",
+ }
+ : undefined,
+ });
+ };
+
+ // 조건 필드 변경
+ const updateConditionField = (field: string) => {
+ const selectedColumn = columns.find((col) => col.field === field);
+ const actualFieldName = selectedColumn?.sourceField || field;
+
+ // 컬럼의 타입과 옵션 확인 (테이블 타입 관리의 카테고리 기준)
+ let conditionFieldType: "static" | "code" | "table" = "static";
+ let conditionFieldCategoryKey = "";
+
+ // 소스 테이블에서 해당 컬럼이 카테고리 타입인지 확인
+ if (sourceTableName && categoryColumns[actualFieldName]) {
+ conditionFieldType = "code";
+ conditionFieldCategoryKey = `${sourceTableName}.${actualFieldName}`;
+ }
+
+ onUpdate({
+ conditionalCalculation: {
+ ...calc.conditionalCalculation!,
+ conditionField: field,
+ conditionFieldType,
+ conditionFieldCategoryKey,
+ rules: [], // 필드 변경 시 규칙 초기화
+ },
+ });
+ };
+
+ // 조건 규칙 추가
+ const addConditionRule = () => {
+ const newRule: ConditionalCalculationRule = {
+ conditionValue: "",
+ formula: calc.formula || "",
+ };
+ onUpdate({
+ conditionalCalculation: {
+ ...calc.conditionalCalculation!,
+ rules: [...(calc.conditionalCalculation?.rules || []), newRule],
+ },
+ });
+ };
+
+ // 조건 규칙 업데이트
+ const updateConditionRule = (ruleIndex: number, updates: Partial) => {
+ const newRules = [...(calc.conditionalCalculation?.rules || [])];
+ newRules[ruleIndex] = { ...newRules[ruleIndex], ...updates };
+ onUpdate({
+ conditionalCalculation: {
+ ...calc.conditionalCalculation!,
+ rules: newRules,
+ },
+ });
+ };
+
+ // 조건 규칙 삭제
+ const removeConditionRule = (ruleIndex: number) => {
+ onUpdate({
+ conditionalCalculation: {
+ ...calc.conditionalCalculation!,
+ rules: (calc.conditionalCalculation?.rules || []).filter((_, i) => i !== ruleIndex),
+ },
+ });
+ };
+
+ // 기본 계산식 업데이트
+ const updateDefaultFormula = (formula: string) => {
+ onUpdate({
+ conditionalCalculation: {
+ ...calc.conditionalCalculation!,
+ defaultFormula: formula,
+ },
+ });
+ };
+
+ // 조건 필드로 사용 가능한 컬럼 (모든 컬럼)
+ const availableColumns = columns.filter((col) => col.field);
+
+ return (
+
+ {/* 기본 계산 규칙 */}
+
+ onUpdate({ resultField: value })}
+ >
+
+
+
+
+ {columns.length === 0 ? (
+
+ 컬럼 설정에서 먼저 컬럼을 추가하세요
+
+ ) : (
+ columns
+ .filter((col) => col.field)
+ .map((col, idx) => (
+
+ {col.label || col.field}
+
+ ))
+ )}
+
+
+ =
+ onUpdate({ formula: e.target.value })}
+ placeholder="수식 (예: qty * unit_price)"
+ className="h-8 text-xs flex-1"
+ disabled={isConditionalEnabled}
+ />
+
+
+
+
+
+ {/* 조건부 계산 토글 */}
+
+
+
+ 조건부 계산 활성화
+
+ {availableColumns.length === 0 && !isConditionalEnabled && (
+
+ (컬럼 설정에서 먼저 컬럼을 추가하세요)
+
+ )}
+
+
+ {/* 조건부 계산 설정 */}
+ {isConditionalEnabled && (
+
+ {/* 조건 필드 선택 */}
+
+ 조건 필드:
+
+
+
+
+
+ {availableColumns.length === 0 ? (
+
+ 컬럼이 없습니다
+
+ ) : (
+ availableColumns.map((col, idx) => {
+ // 소스 필드명으로 카테고리 여부 확인
+ const actualFieldName = col.sourceField || col.field;
+ const isCategoryColumn = categoryColumns[actualFieldName];
+ return (
+
+ {col.label || col.field} {isCategoryColumn ? "(카테고리)" : `(${col.type})`}
+
+ );
+ })
+ )}
+
+
+
+
+ {/* 조건별 계산식 목록 */}
+ {calc.conditionalCalculation?.conditionField && (
+
+
+
조건별 계산식:
+
+
+ 조건 추가
+
+
+
+ {(calc.conditionalCalculation?.rules || []).map((rule, ruleIndex) => (
+
+ {/* 조건값 선택 */}
+ {categoryOptions.length > 0 ? (
+
+ updateConditionRule(ruleIndex, { conditionValue: value })
+ }
+ >
+
+
+
+
+ {categoryOptions.map((opt, optIdx) => (
+
+ {opt.label}
+
+ ))}
+
+
+ ) : (
+
+ updateConditionRule(ruleIndex, { conditionValue: e.target.value })
+ }
+ placeholder="조건값"
+ className="h-7 text-xs w-[120px]"
+ />
+ )}
+ →
+
+ updateConditionRule(ruleIndex, { formula: e.target.value })
+ }
+ placeholder="계산식"
+ className="h-7 text-xs flex-1"
+ />
+ removeConditionRule(ruleIndex)}
+ className="h-7 w-7 p-0 text-destructive hover:text-destructive"
+ >
+
+
+
+ ))}
+
+ {/* 기본 계산식 */}
+
+
+ (기본값)
+
+ →
+ updateDefaultFormula(e.target.value)}
+ placeholder="기본 계산식 (조건 미해당 시)"
+ className="h-7 text-xs flex-1"
+ />
+
+
+ )}
+
+ {loadingOptions && (
+
옵션 로딩 중...
+ )}
+
+ )}
+
+ );
+};
+
// 옵션 소스 설정 컴포넌트 (검색 가능한 Combobox)
interface OptionSourceConfigProps {
optionSource: {
@@ -3034,46 +3459,15 @@ export function TableSectionSettingsModal({
{(tableConfig.calculations || []).map((calc, index) => (
-
- updateCalculation(index, { resultField: value })}
- >
-
-
-
-
- {(tableConfig.columns || []).length === 0 ? (
-
- 컬럼 설정에서 먼저 컬럼을 추가하세요
-
- ) : (
- (tableConfig.columns || [])
- .filter((col) => col.field) // 빈 필드명 제외
- .map((col, idx) => (
-
- {col.label || col.field}
-
- ))
- )}
-
-
- =
- updateCalculation(index, { formula: e.target.value })}
- placeholder="수식 (예: quantity * unit_price)"
- className="h-8 text-xs flex-1"
- />
- removeCalculation(index)}
- className="h-8 w-8 p-0 text-destructive hover:text-destructive"
- >
-
-
-
+
updateCalculation(index, updates)}
+ onRemove={() => removeCalculation(index)}
+ />
))}
diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts
index a07feed6..3d8cac6c 100644
--- a/frontend/lib/registry/components/universal-form-modal/types.ts
+++ b/frontend/lib/registry/components/universal-form-modal/types.ts
@@ -604,6 +604,27 @@ export interface ColumnModeConfig {
valueMapping: ValueMappingConfig; // 이 모드의 값 매핑
}
+/**
+ * 조건부 계산 규칙
+ * 특정 필드 값에 따라 다른 계산식 적용
+ */
+export interface ConditionalCalculationRule {
+ conditionValue: string; // 조건 값 (예: "국내", "해외")
+ formula: string; // 해당 조건일 때 사용할 계산식
+}
+
+/**
+ * 조건부 계산 설정
+ */
+export interface ConditionalCalculationConfig {
+ enabled: boolean; // 조건부 계산 활성화 여부
+ conditionField: string; // 조건 기준 필드 (예: "sales_type")
+ conditionFieldType?: "static" | "code" | "table"; // 조건 필드의 옵션 타입
+ conditionFieldCategoryKey?: string; // 카테고리 키 (예: "sales_order_mng.sales_type")
+ rules: ConditionalCalculationRule[]; // 조건별 계산 규칙
+ defaultFormula?: string; // 조건에 해당하지 않을 때 기본 계산식
+}
+
/**
* 테이블 계산 규칙
* 다른 컬럼 값을 기반으로 자동 계산
@@ -612,6 +633,9 @@ export interface TableCalculationRule {
resultField: string; // 결과를 저장할 필드
formula: string; // 계산 공식 (예: "quantity * unit_price")
dependencies: string[]; // 의존하는 필드들
+
+ // 조건부 계산 (선택사항)
+ conditionalCalculation?: ConditionalCalculationConfig;
}
// 다중 행 저장 설정
From eb868965df4372656276edcf5dfdc7d18966b6b4 Mon Sep 17 00:00:00 2001
From: kjs
Date: Wed, 31 Dec 2025 14:17:39 +0900
Subject: [PATCH 27/95] =?UTF-8?q?=EC=A1=B0=EA=B1=B4=EB=B6=80=20=EA=B3=84?=
=?UTF-8?q?=EC=82=B0=EC=8B=9D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../modal-repeater-table/RepeaterTable.tsx | 41 ++++++++++---------
.../components/modal-repeater-table/types.ts | 1 +
.../TableSectionRenderer.tsx | 1 +
.../modals/TableSectionSettingsModal.tsx | 23 ++++-------
.../components/universal-form-modal/types.ts | 1 +
5 files changed, 33 insertions(+), 34 deletions(-)
diff --git a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx
index 70b15e7d..995ebccb 100644
--- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx
+++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx
@@ -1,6 +1,6 @@
"use client";
-import React, { useState, useEffect, useRef } from "react";
+import React, { useState, useEffect, useRef, useMemo } from "react";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -84,6 +84,9 @@ export function RepeaterTable({
onSelectionChange,
equalizeWidthsTrigger,
}: RepeaterTableProps) {
+ // 히든 컬럼을 제외한 표시 가능한 컬럼만 필터링
+ const visibleColumns = useMemo(() => columns.filter((col) => !col.hidden), [columns]);
+
// 컨테이너 ref - 실제 너비 측정용
const containerRef = useRef(null);
@@ -145,7 +148,7 @@ export function RepeaterTable({
// 컬럼 너비 상태 관리
const [columnWidths, setColumnWidths] = useState>(() => {
const widths: Record = {};
- columns.forEach((col) => {
+ columns.filter((col) => !col.hidden).forEach((col) => {
widths[col.field] = col.width ? parseInt(col.width) : 120;
});
return widths;
@@ -154,11 +157,11 @@ export function RepeaterTable({
// 기본 너비 저장 (리셋용)
const defaultWidths = React.useMemo(() => {
const widths: Record = {};
- columns.forEach((col) => {
+ visibleColumns.forEach((col) => {
widths[col.field] = col.width ? parseInt(col.width) : 120;
});
return widths;
- }, [columns]);
+ }, [visibleColumns]);
// 리사이즈 상태
const [resizing, setResizing] = useState<{ field: string; startX: number; startWidth: number } | null>(null);
@@ -206,7 +209,7 @@ export function RepeaterTable({
// 해당 컬럼의 가장 긴 글자 너비 계산
// equalWidth: 균등 분배 시 너비 (값이 없는 컬럼의 최소값으로 사용)
const calculateColumnContentWidth = (field: string, equalWidth: number): number => {
- const column = columns.find((col) => col.field === field);
+ const column = visibleColumns.find((col) => col.field === field);
if (!column) return equalWidth;
// 날짜 필드는 110px (yyyy-MM-dd)
@@ -257,7 +260,7 @@ export function RepeaterTable({
// 헤더 더블클릭: 해당 컬럼만 글자 너비에 맞춤
const handleDoubleClick = (field: string) => {
const availableWidth = getAvailableWidth();
- const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length));
+ const equalWidth = Math.max(60, Math.floor(availableWidth / visibleColumns.length));
const contentWidth = calculateColumnContentWidth(field, equalWidth);
setColumnWidths((prev) => ({
...prev,
@@ -268,10 +271,10 @@ export function RepeaterTable({
// 균등 분배: 컬럼 수로 테이블 너비를 균등 분배
const applyEqualizeWidths = () => {
const availableWidth = getAvailableWidth();
- const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length));
+ const equalWidth = Math.max(60, Math.floor(availableWidth / visibleColumns.length));
const newWidths: Record = {};
- columns.forEach((col) => {
+ visibleColumns.forEach((col) => {
newWidths[col.field] = equalWidth;
});
@@ -280,15 +283,15 @@ export function RepeaterTable({
// 자동 맞춤: 각 컬럼을 글자 너비에 맞추고, 컨테이너보다 작으면 남는 공간 분배
const applyAutoFitWidths = () => {
- if (columns.length === 0) return;
+ if (visibleColumns.length === 0) return;
// 균등 분배 너비 계산 (값이 없는 컬럼의 최소값)
const availableWidth = getAvailableWidth();
- const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length));
+ const equalWidth = Math.max(60, Math.floor(availableWidth / visibleColumns.length));
// 1. 각 컬럼의 글자 너비 계산 (값이 없으면 균등 분배 너비 사용)
const newWidths: Record = {};
- columns.forEach((col) => {
+ visibleColumns.forEach((col) => {
newWidths[col.field] = calculateColumnContentWidth(col.field, equalWidth);
});
@@ -298,8 +301,8 @@ export function RepeaterTable({
// 3. 컨테이너보다 작으면 남는 공간을 균등 분배 (테이블 꽉 참 유지)
if (totalContentWidth < availableWidth) {
const extraSpace = availableWidth - totalContentWidth;
- const extraPerColumn = Math.floor(extraSpace / columns.length);
- columns.forEach((col) => {
+ const extraPerColumn = Math.floor(extraSpace / visibleColumns.length);
+ visibleColumns.forEach((col) => {
newWidths[col.field] += extraPerColumn;
});
}
@@ -311,7 +314,7 @@ export function RepeaterTable({
// 초기 마운트 시 균등 분배 적용
useEffect(() => {
if (initializedRef.current) return;
- if (!containerRef.current || columns.length === 0) return;
+ if (!containerRef.current || visibleColumns.length === 0) return;
const timer = setTimeout(() => {
applyEqualizeWidths();
@@ -319,7 +322,7 @@ export function RepeaterTable({
}, 100);
return () => clearTimeout(timer);
- }, [columns]);
+ }, [visibleColumns]);
// 트리거 감지: 1=균등분배, 2=자동맞춤
useEffect(() => {
@@ -357,7 +360,7 @@ export function RepeaterTable({
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
- }, [resizing, columns, data]);
+ }, [resizing, visibleColumns, data]);
// 데이터 변경 감지 (필요시 활성화)
// useEffect(() => {
@@ -531,7 +534,7 @@ export function RepeaterTable({
className={cn("border-gray-400", isIndeterminate && "data-[state=checked]:bg-primary")}
/>
- {columns.map((col) => {
+ {visibleColumns.map((col) => {
const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0;
const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId;
const activeOption = hasDynamicSource
@@ -631,7 +634,7 @@ export function RepeaterTable({
{data.length === 0 ? (
추가된 항목이 없습니다
@@ -672,7 +675,7 @@ export function RepeaterTable({
/>
{/* 데이터 컬럼들 */}
- {columns.map((col) => (
+ {visibleColumns.map((col) => (
= ({
// 소스 테이블의 카테고리 컬럼 정보 로드
useEffect(() => {
const loadCategoryColumns = async () => {
- console.log("[CalculationRuleEditor] sourceTableName:", sourceTableName);
-
if (!sourceTableName) {
setCategoryColumns({});
return;
@@ -92,7 +90,6 @@ const CalculationRuleEditor: React.FC = ({
try {
const { getCategoryColumns } = await import("@/lib/api/tableCategoryValue");
const result = await getCategoryColumns(sourceTableName);
- console.log("[CalculationRuleEditor] getCategoryColumns 결과:", result);
if (result && result.success && Array.isArray(result.data)) {
const categoryMap: Record = {};
@@ -103,7 +100,6 @@ const CalculationRuleEditor: React.FC = ({
categoryMap[colName] = true;
}
});
- console.log("[CalculationRuleEditor] categoryMap:", categoryMap);
setCategoryColumns(categoryMap);
}
} catch (error) {
@@ -128,29 +124,18 @@ const CalculationRuleEditor: React.FC = ({
const selectedColumn = columns.find((col) => col.field === conditionField);
const actualFieldName = selectedColumn?.sourceField || conditionField;
- console.log("[loadConditionOptions] 조건 필드:", {
- conditionField,
- actualFieldName,
- sourceTableName,
- categoryColumnsKeys: Object.keys(categoryColumns),
- isCategoryColumn: categoryColumns[actualFieldName],
- });
-
// 소스 테이블에서 해당 컬럼이 카테고리 타입인지 확인
if (sourceTableName && categoryColumns[actualFieldName]) {
try {
setLoadingOptions(true);
const { getCategoryValues } = await import("@/lib/api/tableCategoryValue");
- console.log("[loadConditionOptions] getCategoryValues 호출:", sourceTableName, actualFieldName);
const result = await getCategoryValues(sourceTableName, actualFieldName, false);
- console.log("[loadConditionOptions] getCategoryValues 결과:", result);
if (result && result.success && Array.isArray(result.data)) {
const options = result.data.map((item: any) => ({
// API 응답은 camelCase (valueCode, valueLabel)
value: item.valueCode || item.value_code || item.value,
label: item.valueLabel || item.displayLabel || item.display_label || item.label || item.valueCode || item.value_code || item.value,
}));
- console.log("[loadConditionOptions] 매핑된 옵션:", options);
setCategoryOptions(options);
} else {
setCategoryOptions([]);
@@ -1094,6 +1079,14 @@ function ColumnSettingItem({
/>
필수
+
+ onUpdate({ hidden: checked })}
+ className="scale-75"
+ />
+ 히든
+
Date: Thu, 1 Jan 2026 02:29:53 +0900
Subject: [PATCH 28/95] =?UTF-8?q?=EA=B8=B0=EC=A1=B4=20=ED=9A=8C=EC=82=AC?=
=?UTF-8?q?=EB=B3=80=EA=B2=BD=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/components/layout/AppLayout.tsx | 25 +++---------------------
1 file changed, 3 insertions(+), 22 deletions(-)
diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx
index b28e4d01..236071ac 100644
--- a/frontend/components/layout/AppLayout.tsx
+++ b/frontend/components/layout/AppLayout.tsx
@@ -370,32 +370,13 @@ function AppLayoutInner({ children }: AppLayoutProps) {
};
// 모드 전환 핸들러
- const handleModeSwitch = async () => {
+ const handleModeSwitch = () => {
if (isAdminMode) {
// 관리자 → 사용자 모드: 선택한 회사 유지
router.push("/main");
} else {
- // 사용자 → 관리자 모드: WACE로 복귀 필요 (SUPER_ADMIN만)
- if ((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN") {
- const currentCompanyCode = (user as ExtendedUserInfo)?.companyCode;
-
- // 이미 WACE("*")가 아니면 WACE로 전환 후 관리자 페이지로 이동
- if (currentCompanyCode !== "*") {
- const result = await switchCompany("*");
- if (result.success) {
- // 페이지 새로고침 (관리자 페이지로 이동)
- window.location.href = "/admin";
- } else {
- toast.error("WACE로 전환 실패");
- }
- } else {
- // 이미 WACE면 바로 관리자 페이지로 이동
- router.push("/admin");
- }
- } else {
- // 일반 관리자는 바로 관리자 페이지로 이동
- router.push("/admin");
- }
+ // 사용자 → 관리자 모드: 선택한 회사 유지 (회사 전환 없음)
+ router.push("/admin");
}
};
From 4ad58ba942e6e868aeb08f76efa1a652eb20e8c1 Mon Sep 17 00:00:00 2001
From: SeongHyun Kim
Date: Sun, 4 Jan 2026 17:41:07 +0900
Subject: [PATCH 29/95] =?UTF-8?q?feat:=20=ED=8F=BC=20=EB=AA=A8=EB=8B=AC=20?=
=?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=B1=84=EB=B2=88=20=EA=B8=B0=EB=8A=A5=20?=
=?UTF-8?q?=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=EC=9E=84=EB=B2=A0=EB=94=94?=
=?UTF-8?q?=EB=93=9C=20=EC=8A=A4=ED=81=AC=EB=A6=B0=20=EB=A0=8C=EB=8D=94?=
=?UTF-8?q?=EB=A7=81=20=EC=B5=9C=EC=A0=81=ED=99=94?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
UniversalFormModalComponent에 폼 초기화 시 채번 규칙을 가져와 적용하는 generateNumberingValues 구현
채번 생성 중복 호출 방지를 위한 useRef 로직 추가
데이터 업데이트 시 불필요한 리마운트 및 포커스 분실을 방지하기 위해 EmbeddedScreen 컴포넌트 key에서 formDataVersion 제거
---
.../screen-embedding/EmbeddedScreen.tsx | 4 +-
.../UniversalFormModalComponent.tsx | 182 ++++++++++--------
2 files changed, 109 insertions(+), 77 deletions(-)
diff --git a/frontend/components/screen-embedding/EmbeddedScreen.tsx b/frontend/components/screen-embedding/EmbeddedScreen.tsx
index 12496310..3bfb7a77 100644
--- a/frontend/components/screen-embedding/EmbeddedScreen.tsx
+++ b/frontend/components/screen-embedding/EmbeddedScreen.tsx
@@ -415,8 +415,10 @@ export const EmbeddedScreen = forwardRef
+
{
+ console.log("[UniversalFormModal] useEffect 시작", {
+ initialData,
+ hasInitialized: hasInitialized.current,
+ lastInitializedId: lastInitializedId.current,
+ });
+
// initialData에서 ID 값 추출 (id, ID, objid 등)
const currentId = initialData?.id || initialData?.ID || initialData?.objid;
const currentIdString = currentId !== undefined ? String(currentId) : undefined;
@@ -229,9 +235,20 @@ export function UniversalFormModalComponent({
if (hasInitialized.current && lastInitializedId.current === currentIdString) {
// 생성 모드에서 데이터가 새로 전달된 경우는 재초기화 필요
if (!createModeDataHash || capturedInitialData.current) {
+ console.log("[UniversalFormModal] 초기화 스킵 - 이미 초기화됨");
+ // 🆕 채번 플래그가 true인데 formData에 값이 없으면 재생성 필요
+ // (컴포넌트 remount로 인해 state가 초기화된 경우)
return;
}
}
+
+ // 🆕 컴포넌트 remount 감지: hasInitialized가 true인데 formData가 비어있으면 재초기화
+ // (React의 Strict Mode나 EmbeddedScreen 리렌더링으로 인한 remount)
+ if (hasInitialized.current && !currentIdString) {
+ console.log("[UniversalFormModal] 컴포넌트 remount 감지 - 채번 플래그 초기화");
+ numberingGeneratedRef.current = false;
+ isGeneratingRef.current = false;
+ }
// 🆕 수정 모드: initialData에 데이터가 있으면서 ID가 변경된 경우 재초기화
if (hasInitialized.current && currentIdString && lastInitializedId.current !== currentIdString) {
@@ -252,6 +269,7 @@ export function UniversalFormModalComponent({
console.log("[UniversalFormModal] 초기 데이터 캡처:", capturedInitialData.current);
}
+ console.log("[UniversalFormModal] initializeForm 호출 예정");
hasInitialized.current = true;
initializeForm();
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -389,6 +407,94 @@ export function UniversalFormModalComponent({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config.sections]);
+ // 채번규칙 자동 생성 (중복 호출 방지)
+ // 중요: initializeForm에서 호출되므로 반드시 initializeForm보다 먼저 선언해야 함
+ const numberingGeneratedRef = useRef(false);
+ const isGeneratingRef = useRef(false); // 진행 중 플래그 추가
+
+ const generateNumberingValues = useCallback(
+ async (currentFormData: FormDataState) => {
+ // 이미 생성되었거나 진행 중이면 스킵
+ if (numberingGeneratedRef.current) {
+ console.log("[채번] 이미 생성됨 - 스킵");
+ return;
+ }
+
+ if (isGeneratingRef.current) {
+ console.log("[채번] 생성 진행 중 - 스킵");
+ return;
+ }
+
+ isGeneratingRef.current = true; // 진행 중 표시
+ console.log("[채번] 생성 시작", { sectionsCount: config.sections.length });
+
+ const updatedData = { ...currentFormData };
+ let hasChanges = false;
+
+ for (const section of config.sections) {
+ console.log("[채번] 섹션 검사:", section.title, { type: section.type, repeatable: section.repeatable, fieldsCount: section.fields?.length });
+ if (section.repeatable || section.type === "table") continue;
+
+ for (const field of (section.fields || [])) {
+ // generateOnOpen은 기본값 true (undefined일 경우 true로 처리)
+ const shouldGenerateOnOpen = field.numberingRule?.generateOnOpen !== false;
+ console.log("[채번] 필드 검사:", field.columnName, {
+ hasNumberingRule: !!field.numberingRule,
+ enabled: field.numberingRule?.enabled,
+ generateOnOpen: field.numberingRule?.generateOnOpen,
+ shouldGenerateOnOpen,
+ ruleId: field.numberingRule?.ruleId,
+ currentValue: updatedData[field.columnName],
+ });
+ if (
+ field.numberingRule?.enabled &&
+ shouldGenerateOnOpen &&
+ field.numberingRule?.ruleId &&
+ !updatedData[field.columnName]
+ ) {
+ try {
+ console.log(`[채번 미리보기 API 호출] ${field.columnName}, ruleId: ${field.numberingRule.ruleId}`);
+ // generateOnOpen: 미리보기만 표시 (DB 시퀀스 증가 안 함)
+ const response = await previewNumberingCode(field.numberingRule.ruleId);
+ if (response.success && response.data?.generatedCode) {
+ updatedData[field.columnName] = response.data.generatedCode;
+
+ // 저장 시 실제 할당을 위해 ruleId 저장 (TextInput과 동일한 키 형식)
+ const ruleIdKey = `${field.columnName}_numberingRuleId`;
+ updatedData[ruleIdKey] = field.numberingRule.ruleId;
+
+ hasChanges = true;
+ numberingGeneratedRef.current = true; // 생성 완료 표시
+ console.log(
+ `[채번 미리보기 완료] ${field.columnName} = ${response.data.generatedCode} (저장 시 실제 할당)`,
+ );
+ console.log(`[채번 규칙 ID 저장] ${ruleIdKey} = ${field.numberingRule.ruleId}`);
+
+ // 부모 컴포넌트에도 ruleId 전달 (ModalRepeaterTable → ScreenModal)
+ if (onChange) {
+ onChange({
+ ...updatedData,
+ [ruleIdKey]: field.numberingRule.ruleId,
+ });
+ console.log(`[채번] 부모에게 ruleId 전달: ${ruleIdKey}`);
+ }
+ }
+ } catch (error) {
+ console.error(`채번규칙 미리보기 실패 (${field.columnName}):`, error);
+ }
+ }
+ }
+ }
+
+ isGeneratingRef.current = false; // 진행 완료
+
+ if (hasChanges) {
+ setFormData(updatedData);
+ }
+ },
+ [config, onChange],
+ );
+
// 폼 초기화
const initializeForm = useCallback(async () => {
console.log("[initializeForm] 시작");
@@ -585,82 +691,6 @@ export function UniversalFormModalComponent({
return item;
};
- // 채번규칙 자동 생성 (중복 호출 방지)
- const numberingGeneratedRef = useRef(false);
- const isGeneratingRef = useRef(false); // 진행 중 플래그 추가
-
- const generateNumberingValues = useCallback(
- async (currentFormData: FormDataState) => {
- // 이미 생성되었거나 진행 중이면 스킵
- if (numberingGeneratedRef.current) {
- console.log("[채번] 이미 생성됨 - 스킵");
- return;
- }
-
- if (isGeneratingRef.current) {
- console.log("[채번] 생성 진행 중 - 스킵");
- return;
- }
-
- isGeneratingRef.current = true; // 진행 중 표시
- console.log("[채번] 생성 시작");
-
- const updatedData = { ...currentFormData };
- let hasChanges = false;
-
- for (const section of config.sections) {
- if (section.repeatable || section.type === "table") continue;
-
- for (const field of (section.fields || [])) {
- if (
- field.numberingRule?.enabled &&
- field.numberingRule?.generateOnOpen &&
- field.numberingRule?.ruleId &&
- !updatedData[field.columnName]
- ) {
- try {
- console.log(`[채번 미리보기 API 호출] ${field.columnName}, ruleId: ${field.numberingRule.ruleId}`);
- // generateOnOpen: 미리보기만 표시 (DB 시퀀스 증가 안 함)
- const response = await previewNumberingCode(field.numberingRule.ruleId);
- if (response.success && response.data?.generatedCode) {
- updatedData[field.columnName] = response.data.generatedCode;
-
- // 저장 시 실제 할당을 위해 ruleId 저장 (TextInput과 동일한 키 형식)
- const ruleIdKey = `${field.columnName}_numberingRuleId`;
- updatedData[ruleIdKey] = field.numberingRule.ruleId;
-
- hasChanges = true;
- numberingGeneratedRef.current = true; // 생성 완료 표시
- console.log(
- `[채번 미리보기 완료] ${field.columnName} = ${response.data.generatedCode} (저장 시 실제 할당)`,
- );
- console.log(`[채번 규칙 ID 저장] ${ruleIdKey} = ${field.numberingRule.ruleId}`);
-
- // 부모 컴포넌트에도 ruleId 전달 (ModalRepeaterTable → ScreenModal)
- if (onChange) {
- onChange({
- ...updatedData,
- [ruleIdKey]: field.numberingRule.ruleId,
- });
- console.log(`[채번] 부모에게 ruleId 전달: ${ruleIdKey}`);
- }
- }
- } catch (error) {
- console.error(`채번규칙 미리보기 실패 (${field.columnName}):`, error);
- }
- }
- }
- }
-
- isGeneratingRef.current = false; // 진행 완료
-
- if (hasChanges) {
- setFormData(updatedData);
- }
- },
- [config, onChange],
- );
-
// 필드 값 변경 핸들러
const handleFieldChange = useCallback(
(columnName: string, value: any) => {
From 239e4800c716c4d1376801a5d35020310a11e48f Mon Sep 17 00:00:00 2001
From: kjs
Date: Mon, 5 Jan 2026 13:37:39 +0900
Subject: [PATCH 30/95] =?UTF-8?q?=EC=A6=89=EC=8B=9C=EC=A0=80=EC=9E=A5=20?=
=?UTF-8?q?=EC=95=A1=EC=85=98=20=ED=95=84=EC=88=98=ED=95=AD=EB=AA=A9=20?=
=?UTF-8?q?=EC=B2=B4=ED=81=AC=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/lib/utils/buttonActions.ts | 63 +++++++++++++++++++++++++++++
1 file changed, 63 insertions(+)
diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts
index 327cb87f..5587fc1a 100644
--- a/frontend/lib/utils/buttonActions.ts
+++ b/frontend/lib/utils/buttonActions.ts
@@ -5931,6 +5931,69 @@ export class ButtonActionExecutor {
return false;
}
+ // ✅ allComponents가 있으면 기존 필수 항목 검증 수행
+ if (context.allComponents && context.allComponents.length > 0) {
+ console.log("🔍 [handleQuickInsert] 필수 항목 검증 시작:", {
+ hasAllComponents: !!context.allComponents,
+ allComponentsLength: context.allComponents?.length || 0,
+ });
+ const requiredValidation = this.validateRequiredFields(context);
+ if (!requiredValidation.isValid) {
+ console.log("❌ [handleQuickInsert] 필수 항목 누락:", requiredValidation.missingFields);
+ toast.error(`필수 항목을 입력해주세요: ${requiredValidation.missingFields.join(", ")}`);
+ return false;
+ }
+ console.log("✅ [handleQuickInsert] 필수 항목 검증 통과");
+ }
+
+ // ✅ quickInsert 전용 검증: component 타입 매핑에서 값이 비어있는지 확인
+ const mappingsForValidation = quickInsertConfig.columnMappings || [];
+ const missingMappingFields: string[] = [];
+
+ for (const mapping of mappingsForValidation) {
+ // component 타입 매핑은 필수 입력으로 간주
+ if (mapping.sourceType === "component" && mapping.sourceComponentId) {
+ let value: any = undefined;
+
+ // 값 가져오기 (formData에서)
+ if (mapping.sourceColumnName) {
+ value = context.formData?.[mapping.sourceColumnName];
+ }
+ if (value === undefined || value === null) {
+ value = context.formData?.[mapping.sourceComponentId];
+ }
+ // allComponents에서 컴포넌트 찾아서 columnName으로 시도
+ if ((value === undefined || value === null) && context.allComponents) {
+ const comp = context.allComponents.find((c: any) => c.id === mapping.sourceComponentId);
+ if (comp?.columnName) {
+ value = context.formData?.[comp.columnName];
+ }
+ }
+ // targetColumn으로 폴백
+ if ((value === undefined || value === null) && mapping.targetColumn) {
+ value = context.formData?.[mapping.targetColumn];
+ }
+
+ // 값이 비어있으면 필수 누락으로 처리
+ if (value === undefined || value === null || (typeof value === "string" && value.trim() === "")) {
+ console.log("❌ [handleQuickInsert] component 매핑 값 누락:", {
+ targetColumn: mapping.targetColumn,
+ sourceComponentId: mapping.sourceComponentId,
+ sourceColumnName: mapping.sourceColumnName,
+ value,
+ });
+ missingMappingFields.push(mapping.targetColumn);
+ }
+ }
+ }
+
+ if (missingMappingFields.length > 0) {
+ console.log("❌ [handleQuickInsert] 필수 입력 항목 누락:", missingMappingFields);
+ toast.error(`다음 항목을 입력해주세요: ${missingMappingFields.join(", ")}`);
+ return false;
+ }
+ console.log("✅ [handleQuickInsert] quickInsert 매핑 검증 통과");
+
const { formData, splitPanelContext, userId, userName, companyCode } = context;
console.log("⚡ Quick Insert 상세 정보:", {
From 85519e302fd99de8fab4308ccf104a77c79e8d01 Mon Sep 17 00:00:00 2001
From: kjs
Date: Mon, 5 Jan 2026 13:54:41 +0900
Subject: [PATCH 31/95] =?UTF-8?q?=ED=96=89=EC=84=A0=ED=83=9D=EC=8B=9C?=
=?UTF-8?q?=EC=97=90=EB=A7=8C=20=EB=B2=84=ED=8A=BC=20=ED=99=9C=EC=84=B1?=
=?UTF-8?q?=ED=99=94=20=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 | 22 +++++--
.../table-list/TableListComponent.tsx | 64 +++++++++----------
2 files changed, 49 insertions(+), 37 deletions(-)
diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx
index f311c035..a71f6e03 100644
--- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx
+++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx
@@ -299,6 +299,20 @@ export const ButtonPrimaryComponent: React.FC = ({
// 🆕 modalDataStore에서 선택된 데이터 확인 (분할 패널 등에서 저장됨)
const [modalStoreData, setModalStoreData] = useState>({});
+ // 🆕 splitPanelContext?.selectedLeftData를 로컬 상태로 추적 (리렌더링 보장)
+ const [trackedSelectedLeftData, setTrackedSelectedLeftData] = useState | null>(null);
+
+ // splitPanelContext?.selectedLeftData 변경 감지 및 로컬 상태 동기화
+ useEffect(() => {
+ const newData = splitPanelContext?.selectedLeftData ?? null;
+ setTrackedSelectedLeftData(newData);
+ console.log("🔄 [ButtonPrimary] selectedLeftData 변경 감지:", {
+ label: component.label,
+ hasData: !!newData,
+ dataKeys: newData ? Object.keys(newData) : [],
+ });
+ }, [splitPanelContext?.selectedLeftData, component.label]);
+
// modalDataStore 상태 구독 (실시간 업데이트)
useEffect(() => {
const actionConfig = component.componentConfig?.action;
@@ -357,8 +371,8 @@ export const ButtonPrimaryComponent: React.FC = ({
// 2. 분할 패널 좌측 선택 데이터 확인
if (rowSelectionSource === "auto" || rowSelectionSource === "splitPanelLeft") {
- // SplitPanelContext에서 확인
- if (splitPanelContext?.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0) {
+ // SplitPanelContext에서 확인 (trackedSelectedLeftData 사용으로 리렌더링 보장)
+ if (trackedSelectedLeftData && Object.keys(trackedSelectedLeftData).length > 0) {
if (!hasSelection) {
hasSelection = true;
selectionCount = 1;
@@ -397,7 +411,7 @@ export const ButtonPrimaryComponent: React.FC = ({
selectionCount,
selectionSource,
hasSplitPanelContext: !!splitPanelContext,
- selectedLeftData: splitPanelContext?.selectedLeftData,
+ trackedSelectedLeftData: trackedSelectedLeftData,
selectedRowsData: selectedRowsData?.length,
selectedRows: selectedRows?.length,
flowSelectedData: flowSelectedData?.length,
@@ -429,7 +443,7 @@ export const ButtonPrimaryComponent: React.FC = ({
component.label,
selectedRows,
selectedRowsData,
- splitPanelContext?.selectedLeftData,
+ trackedSelectedLeftData,
flowSelectedData,
splitPanelContext,
modalStoreData,
diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx
index 7ac521af..7a787ed3 100644
--- a/frontend/lib/registry/components/table-list/TableListComponent.tsx
+++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx
@@ -2043,7 +2043,7 @@ export const TableListComponent: React.FC = ({
return row.id || row.uuid || `row-${index}`;
};
- const handleRowSelection = (rowKey: string, checked: boolean) => {
+ const handleRowSelection = (rowKey: string, checked: boolean, rowData?: any) => {
const newSelectedRows = new Set(selectedRows);
if (checked) {
newSelectedRows.add(rowKey);
@@ -2086,6 +2086,31 @@ export const TableListComponent: React.FC = ({
});
}
+ // 🆕 분할 패널 컨텍스트에 선택된 데이터 저장/해제 (체크박스 선택 시에도 작동)
+ const effectiveSplitPosition = splitPanelPosition || currentSplitPosition;
+ if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
+ if (checked && selectedRowsData.length > 0) {
+ // 선택된 경우: 첫 번째 선택된 데이터 저장 (또는 전달된 rowData)
+ const dataToStore = rowData || selectedRowsData[selectedRowsData.length - 1];
+ splitPanelContext.setSelectedLeftData(dataToStore);
+ console.log("🔗 [TableList] handleRowSelection - 분할 패널 좌측 데이터 저장:", {
+ rowKey,
+ dataToStore,
+ });
+ } else if (!checked && selectedRowsData.length === 0) {
+ // 모든 선택이 해제된 경우: 데이터 초기화
+ splitPanelContext.setSelectedLeftData(null);
+ console.log("🔗 [TableList] handleRowSelection - 분할 패널 좌측 데이터 초기화");
+ } else if (selectedRowsData.length > 0) {
+ // 일부 선택 해제된 경우: 남은 첫 번째 데이터로 업데이트
+ splitPanelContext.setSelectedLeftData(selectedRowsData[0]);
+ console.log("🔗 [TableList] handleRowSelection - 분할 패널 좌측 데이터 업데이트:", {
+ remainingCount: selectedRowsData.length,
+ firstData: selectedRowsData[0],
+ });
+ }
+ }
+
const allRowsSelected = filteredData.every((row, index) => newSelectedRows.has(getRowKey(row, index)));
setIsAllSelected(allRowsSelected && filteredData.length > 0);
};
@@ -2155,35 +2180,8 @@ export const TableListComponent: React.FC = ({
const rowKey = getRowKey(row, index);
const isCurrentlySelected = selectedRows.has(rowKey);
- handleRowSelection(rowKey, !isCurrentlySelected);
-
- // 🆕 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우)
- // disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달)
- // currentSplitPosition을 사용하여 정확한 위치 확인 (splitPanelPosition이 없을 수 있음)
- const effectiveSplitPosition = splitPanelPosition || currentSplitPosition;
-
- console.log("🔗 [TableList] 행 클릭 - 분할 패널 위치 확인:", {
- splitPanelPosition,
- currentSplitPosition,
- effectiveSplitPosition,
- hasSplitPanelContext: !!splitPanelContext,
- disableAutoDataTransfer: splitPanelContext?.disableAutoDataTransfer,
- });
-
- if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
- if (!isCurrentlySelected) {
- // 선택된 경우: 데이터 저장
- splitPanelContext.setSelectedLeftData(row);
- console.log("🔗 [TableList] 분할 패널 좌측 데이터 저장:", {
- row,
- parentDataMapping: splitPanelContext.parentDataMapping,
- });
- } else {
- // 선택 해제된 경우: 데이터 초기화
- splitPanelContext.setSelectedLeftData(null);
- console.log("🔗 [TableList] 분할 패널 좌측 데이터 초기화");
- }
- }
+ // handleRowSelection에서 분할 패널 데이터 처리도 함께 수행됨
+ handleRowSelection(rowKey, !isCurrentlySelected, row);
console.log("행 클릭:", { row, index, isSelected: !isCurrentlySelected });
};
@@ -3918,7 +3916,7 @@ export const TableListComponent: React.FC = ({
if (enterRow) {
const rowKey = getRowKey(enterRow, rowIndex);
const isCurrentlySelected = selectedRows.has(rowKey);
- handleRowSelection(rowKey, !isCurrentlySelected);
+ handleRowSelection(rowKey, !isCurrentlySelected, enterRow);
}
break;
case " ": // Space
@@ -3928,7 +3926,7 @@ export const TableListComponent: React.FC = ({
if (spaceRow) {
const currentRowKey = getRowKey(spaceRow, rowIndex);
const isChecked = selectedRows.has(currentRowKey);
- handleRowSelection(currentRowKey, !isChecked);
+ handleRowSelection(currentRowKey, !isChecked, spaceRow);
}
break;
case "F2":
@@ -4142,7 +4140,7 @@ export const TableListComponent: React.FC = ({
return (
handleRowSelection(rowKey, checked as boolean)}
+ onCheckedChange={(checked) => handleRowSelection(rowKey, checked as boolean, row)}
aria-label={`행 ${index + 1} 선택`}
/>
);
From 914f3d57f33ffd2bc0aa14da820505d00c0c7814 Mon Sep 17 00:00:00 2001
From: SeongHyun Kim
Date: Mon, 5 Jan 2026 13:58:13 +0900
Subject: [PATCH 32/95] =?UTF-8?q?fix:=20TableList=20=EC=B9=B4=ED=85=8C?=
=?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EB=9D=BC=EB=B2=A8=20=ED=91=9C=EC=8B=9C?=
=?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=EB=A9=80=ED=8B=B0=ED=85=8C?=
=?UTF-8?q?=EB=84=8C=EC=8B=9C=20fallback=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?=
=?UTF-8?q?=EA=B0=80=20getColumnInputTypes=20API=EC=97=90=EC=84=9C=20?=
=?UTF-8?q?=ED=9A=8C=EC=82=AC=EB=B3=84=20=EC=84=A4=EC=A0=95=EC=9D=B4=20?=
=?UTF-8?q?=EC=97=86=EC=9D=84=20=EB=95=8C=20=EA=B8=B0=EB=B3=B8=EC=84=A4?=
=?UTF-8?q?=EC=A0=95()=20fallback=20=EC=A0=81=EC=9A=A9=20table=5Ftype=5Fco?=
=?UTF-8?q?lumns,=20category=5Fcolumn=5Fmapping=20=EC=A1=B0=ED=9A=8C=20?=
=?UTF-8?q?=EC=8B=9C=20DISTINCT=20ON=20+=20ORDER=20BY=20CASE=20WHEN=20?=
=?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=82=AC=EC=9A=A9=20=EC=98=81=ED=96=A5=20?=
=?UTF-8?q?=EB=B2=94=EC=9C=84:=20=EB=AA=A8=EB=93=A0=20TableList=20?=
=?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=EC=9D=98=20=EC=B9=B4?=
=?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EC=BB=AC=EB=9F=BC=20=ED=91=9C?=
=?UTF-8?q?=EC=8B=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/services/tableManagementService.ts | 18 ++++++++++++------
1 file changed, 12 insertions(+), 6 deletions(-)
diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts
index b714b186..def9a978 100644
--- a/backend-node/src/services/tableManagementService.ts
+++ b/backend-node/src/services/tableManagementService.ts
@@ -3930,9 +3930,10 @@ export class TableManagementService {
`컬럼 입력타입 정보 조회: ${tableName}, company: ${companyCode}`
);
- // table_type_columns에서 입력타입 정보 조회 (company_code 필터링)
+ // table_type_columns에서 입력타입 정보 조회
+ // 회사별 설정 우선, 없으면 기본 설정(*) fallback
const rawInputTypes = await query(
- `SELECT
+ `SELECT DISTINCT ON (ttc.column_name)
ttc.column_name as "columnName",
COALESCE(cl.column_label, ttc.column_name) as "displayName",
ttc.input_type as "inputType",
@@ -3946,8 +3947,10 @@ export class TableManagementService {
LEFT JOIN information_schema.columns ic
ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name
WHERE ttc.table_name = $1
- AND ttc.company_code = $2
- ORDER BY ttc.display_order, ttc.column_name`,
+ AND ttc.company_code IN ($2, '*')
+ ORDER BY ttc.column_name,
+ CASE WHEN ttc.company_code = $2 THEN 0 ELSE 1 END,
+ ttc.display_order`,
[tableName, companyCode]
);
@@ -3961,17 +3964,20 @@ export class TableManagementService {
const mappingTableExists = tableExistsResult[0]?.table_exists === true;
// 카테고리 컬럼의 경우, 매핑된 메뉴 목록 조회
+ // 회사별 설정 우선, 없으면 기본 설정(*) fallback
let categoryMappings: Map = new Map();
if (mappingTableExists) {
logger.info("카테고리 매핑 조회 시작", { tableName, companyCode });
const mappings = await query(
- `SELECT
+ `SELECT DISTINCT ON (logical_column_name, menu_objid)
logical_column_name as "columnName",
menu_objid as "menuObjid"
FROM category_column_mapping
WHERE table_name = $1
- AND company_code = $2`,
+ AND company_code IN ($2, '*')
+ ORDER BY logical_column_name, menu_objid,
+ CASE WHEN company_code = $2 THEN 0 ELSE 1 END`,
[tableName, companyCode]
);
From 2a7066b6fd6feddbcf4d86a88aea1c6f3332c11a Mon Sep 17 00:00:00 2001
From: hjjeong
Date: Mon, 5 Jan 2026 17:08:03 +0900
Subject: [PATCH 33/95] =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=EC=97=90=20?=
=?UTF-8?q?=EC=A1=B4=EC=9E=AC=ED=95=98=EB=8A=94=20=EC=BB=AC=EB=9F=BC?=
=?UTF-8?q?=EB=A7=8C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend-node/src/services/tableManagementService.ts | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts
index def9a978..8ac5989b 100644
--- a/backend-node/src/services/tableManagementService.ts
+++ b/backend-node/src/services/tableManagementService.ts
@@ -2409,11 +2409,19 @@ export class TableManagementService {
}
// SET 절 생성 (수정할 데이터) - 먼저 생성
+ // 🔧 테이블에 존재하는 컬럼만 UPDATE (가상 컬럼 제외)
const setConditions: string[] = [];
const setValues: any[] = [];
let paramIndex = 1;
+ const skippedColumns: string[] = [];
Object.keys(updatedData).forEach((column) => {
+ // 테이블에 존재하지 않는 컬럼은 스킵
+ if (!columnTypeMap.has(column)) {
+ skippedColumns.push(column);
+ return;
+ }
+
const dataType = columnTypeMap.get(column) || "text";
setConditions.push(
`"${column}" = $${paramIndex}::${this.getPostgreSQLType(dataType)}`
@@ -2424,6 +2432,10 @@ export class TableManagementService {
paramIndex++;
});
+ if (skippedColumns.length > 0) {
+ logger.info(`⚠️ 테이블에 존재하지 않는 컬럼 스킵: ${skippedColumns.join(", ")}`);
+ }
+
// WHERE 조건 생성 (PRIMARY KEY 우선, 없으면 모든 원본 데이터 사용)
let whereConditions: string[] = [];
let whereValues: any[] = [];
From 714698c20f6e7109c096563ae1275cc4dc1f6568 Mon Sep 17 00:00:00 2001
From: hjjeong
Date: Mon, 5 Jan 2026 17:08:47 +0900
Subject: [PATCH 34/95] =?UTF-8?q?=EA=B5=AC=EB=A7=A4=EA=B4=80=EB=A6=AC=5F?=
=?UTF-8?q?=EB=B0=9C=EC=A3=BC=EA=B4=80=EB=A6=AC=20=EC=A0=80=EC=9E=A5=20?=
=?UTF-8?q?=EC=8B=9C=20=EB=A7=88=EC=8A=A4=ED=84=B0=EC=A0=95=EB=B3=B4=20?=
=?UTF-8?q?=ED=92=88=EB=AA=A9=EC=97=90=20=EC=A0=84=EB=8B=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/lib/utils/buttonActions.ts | 34 +++++++++++++++++++++++++++++
1 file changed, 34 insertions(+)
diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts
index 5587fc1a..681e9a3f 100644
--- a/frontend/lib/utils/buttonActions.ts
+++ b/frontend/lib/utils/buttonActions.ts
@@ -995,6 +995,40 @@ export class ButtonActionExecutor {
console.log("📋 [handleSave] 범용 폼 모달 공통 필드:", commonFields);
}
+ // 🆕 루트 레벨 formData에서 RepeaterFieldGroup에 전달할 공통 필드 추출
+ // 주문번호, 발주번호 등 마스터-디테일 관계에서 필요한 필드만 명시적으로 지정
+ const masterDetailFields = [
+ // 번호 필드
+ "order_no", // 발주번호
+ "sales_order_no", // 수주번호
+ "shipment_no", // 출하번호
+ "receipt_no", // 입고번호
+ "work_order_no", // 작업지시번호
+ // 거래처 필드
+ "supplier_code", // 공급처 코드
+ "supplier_name", // 공급처 이름
+ "customer_code", // 고객 코드
+ "customer_name", // 고객 이름
+ // 날짜 필드
+ "order_date", // 발주일
+ "sales_date", // 수주일
+ "shipment_date", // 출하일
+ "receipt_date", // 입고일
+ "due_date", // 납기일
+ // 담당자/메모 필드
+ "manager", // 담당자
+ "memo", // 메모
+ "remark", // 비고
+ ];
+
+ for (const fieldName of masterDetailFields) {
+ const value = context.formData[fieldName];
+ if (value !== undefined && value !== "" && value !== null && !(fieldName in commonFields)) {
+ commonFields[fieldName] = value;
+ }
+ }
+ console.log("📋 [handleSave] 최종 공통 필드 (마스터-디테일 필드 포함):", commonFields);
+
for (const item of parsedData) {
// 메타 필드 제거 (eslint 경고 무시 - 의도적으로 분리)
From 64105bf525644f6c76f8f87b8ba3ea1d7acd0788 Mon Sep 17 00:00:00 2001
From: hjjeong
Date: Mon, 5 Jan 2026 18:21:29 +0900
Subject: [PATCH 35/95] =?UTF-8?q?=EB=B0=9C=EC=A3=BC=EA=B4=80=EB=A6=AC=20?=
=?UTF-8?q?=EC=88=98=EC=A0=95=EC=8B=9C=20=EA=B3=B5=EA=B8=89=EC=B2=98=20?=
=?UTF-8?q?=ED=91=9C=EC=8B=9C=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../AutocompleteSearchInputComponent.tsx | 145 +++++++++++++++++-
1 file changed, 141 insertions(+), 4 deletions(-)
diff --git a/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx b/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx
index 7a115ea3..cbd2744c 100644
--- a/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx
+++ b/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx
@@ -44,7 +44,42 @@ export function AutocompleteSearchInputComponent({
const displayField = config?.displayField || propDisplayField || "";
const displayFields = config?.displayFields || (displayField ? [displayField] : []); // 다중 표시 필드
const displaySeparator = config?.displaySeparator || " → "; // 구분자
- const valueField = config?.valueField || propValueField || "";
+
+ // valueField 결정: fieldMappings 기반으로 추론 (config.valueField가 fieldMappings에 없으면 무시)
+ const getValueField = () => {
+ // fieldMappings가 있으면 그 안에서 추론 (가장 신뢰할 수 있는 소스)
+ if (config?.fieldMappings && config.fieldMappings.length > 0) {
+ // config.valueField가 fieldMappings의 sourceField에 있으면 사용
+ if (config?.valueField) {
+ const hasValueFieldInMappings = config.fieldMappings.some(
+ (m: any) => m.sourceField === config.valueField
+ );
+ if (hasValueFieldInMappings) {
+ return config.valueField;
+ }
+ // fieldMappings에 없으면 무시하고 추론
+ }
+
+ // _code 또는 _id로 끝나는 필드 우선 (보통 PK나 코드 필드)
+ const codeMapping = config.fieldMappings.find(
+ (m: any) => m.sourceField?.endsWith("_code") || m.sourceField?.endsWith("_id")
+ );
+ if (codeMapping) {
+ return codeMapping.sourceField;
+ }
+
+ // 없으면 첫 번째 매핑 사용
+ return config.fieldMappings[0].sourceField || "";
+ }
+
+ // fieldMappings가 없으면 기존 방식
+ if (config?.valueField) return config.valueField;
+ if (propValueField) return propValueField;
+
+ return "";
+ };
+ const valueField = getValueField();
+
const searchFields = config?.searchFields || propSearchFields || displayFields; // 검색 필드도 다중 표시 필드 사용
const placeholder = config?.placeholder || propPlaceholder || "검색...";
@@ -76,11 +111,39 @@ export function AutocompleteSearchInputComponent({
// 선택된 데이터를 ref로도 유지 (리렌더링 시 초기화 방지)
const selectedDataRef = useRef(null);
const inputValueRef = useRef("");
+ const initialValueLoadedRef = useRef(null); // 초기값 로드 추적
// formData에서 현재 값 가져오기 (isInteractive 모드)
- const currentValue = isInteractive && formData && component?.columnName
- ? formData[component.columnName]
- : value;
+ // 우선순위: 1) component.columnName, 2) fieldMappings에서 valueField에 매핑된 targetField
+ const getCurrentValue = () => {
+ if (!isInteractive || !formData) {
+ return value;
+ }
+
+ // 1. component.columnName으로 직접 바인딩된 경우
+ if (component?.columnName && formData[component.columnName] !== undefined) {
+ return formData[component.columnName];
+ }
+
+ // 2. fieldMappings에서 valueField와 매핑된 targetField에서 값 가져오기
+ if (config?.fieldMappings && Array.isArray(config.fieldMappings)) {
+ const valueFieldMapping = config.fieldMappings.find(
+ (mapping: any) => mapping.sourceField === valueField
+ );
+
+ if (valueFieldMapping) {
+ const targetField = valueFieldMapping.targetField || valueFieldMapping.targetColumn;
+
+ if (targetField && formData[targetField] !== undefined) {
+ return formData[targetField];
+ }
+ }
+ }
+
+ return value;
+ };
+
+ const currentValue = getCurrentValue();
// selectedData 변경 시 ref도 업데이트
useEffect(() => {
@@ -98,6 +161,79 @@ export function AutocompleteSearchInputComponent({
}
}, []);
+ // 초기값이 있을 때 해당 값의 표시 텍스트를 조회하여 설정
+ useEffect(() => {
+ const loadInitialDisplayValue = async () => {
+ // 이미 로드된 값이거나, 값이 없거나, 이미 선택된 데이터가 있으면 스킵
+ if (!currentValue || selectedData || selectedDataRef.current) {
+ return;
+ }
+
+ // 이미 같은 값을 로드한 적이 있으면 스킵
+ if (initialValueLoadedRef.current === currentValue) {
+ return;
+ }
+
+ // 테이블명과 필드 정보가 없으면 스킵
+ if (!tableName || !valueField) {
+ return;
+ }
+
+ console.log("🔄 AutocompleteSearchInput 초기값 로드:", {
+ currentValue,
+ tableName,
+ valueField,
+ displayFields,
+ });
+
+ try {
+ // API를 통해 해당 값의 표시 텍스트 조회
+ const { apiClient } = await import("@/lib/api/client");
+ const filterConditionWithValue = {
+ ...filterCondition,
+ [valueField]: currentValue,
+ };
+
+ const params = new URLSearchParams({
+ searchText: "",
+ searchFields: searchFields.join(","),
+ filterCondition: JSON.stringify(filterConditionWithValue),
+ page: "1",
+ limit: "10",
+ });
+
+ const response = await apiClient.get<{ success: boolean; data: EntitySearchResult[] }>(
+ `/entity-search/${tableName}?${params.toString()}`
+ );
+
+ if (response.data.success && response.data.data && response.data.data.length > 0) {
+ const matchedItem = response.data.data.find((item: EntitySearchResult) =>
+ String(item[valueField]) === String(currentValue)
+ );
+
+ if (matchedItem) {
+ const displayText = getDisplayValue(matchedItem);
+ console.log("✅ 초기값 표시 텍스트 로드 성공:", {
+ currentValue,
+ displayText,
+ matchedItem,
+ });
+
+ setSelectedData(matchedItem);
+ setInputValue(displayText);
+ selectedDataRef.current = matchedItem;
+ inputValueRef.current = displayText;
+ initialValueLoadedRef.current = currentValue;
+ }
+ }
+ } catch (error) {
+ console.error("❌ 초기값 표시 텍스트 로드 실패:", error);
+ }
+ };
+
+ loadInitialDisplayValue();
+ }, [currentValue, tableName, valueField, displayFields, filterCondition, searchFields, selectedData]);
+
// value가 변경되면 표시값 업데이트 - 단, selectedData가 있으면 유지
useEffect(() => {
// selectedData가 있으면 표시값 유지 (사용자가 방금 선택한 경우)
@@ -107,6 +243,7 @@ export function AutocompleteSearchInputComponent({
if (!currentValue) {
setInputValue("");
+ initialValueLoadedRef.current = null; // 값이 없어지면 초기화
}
}, [currentValue, selectedData]);
From b3ee2b50e8ad0da804de0c1f3f2e9e28e7c10388 Mon Sep 17 00:00:00 2001
From: SeongHyun Kim
Date: Mon, 5 Jan 2026 18:41:49 +0900
Subject: [PATCH 36/95] =?UTF-8?q?fix:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?=
=?UTF-8?q?=EB=A6=AC=20Select=20=ED=95=84=EB=93=9C=20=EC=A0=80=EC=9E=A5=20?=
=?UTF-8?q?=EC=8B=9C=20=EB=9D=BC=EB=B2=A8=EA=B0=92=20=EB=8C=80=EC=8B=A0=20?=
=?UTF-8?q?=EC=BD=94=EB=93=9C=EA=B0=92=20=EC=A0=80=EC=9E=A5=EB=90=98?=
=?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- UniversalFormModalComponent.tsx: 카테고리 옵션 value를 valueLabel에서 valueCode로 변경
- 제어 로직 조건 비교 정상화 및 500 에러 해결
---
.../universal-form-modal/UniversalFormModalComponent.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx
index 5f087b71..1484d4fd 100644
--- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx
@@ -866,9 +866,9 @@ export function UniversalFormModalComponent({
`/table-categories/${categoryTable}/${categoryColumn}/values`
);
if (response.data?.success && response.data?.data) {
- // 라벨값을 DB에 저장 (화면에 표시되는 값 그대로 저장)
+ // 코드값을 DB에 저장하고 라벨값을 화면에 표시
options = response.data.data.map((item: any) => ({
- value: item.valueLabel || item.value_label,
+ value: item.valueCode || item.value_code,
label: item.valueLabel || item.value_label,
}));
}
From 4f77c382077ca2a499f76d1badf8417dcacb79ad Mon Sep 17 00:00:00 2001
From: kjs
Date: Tue, 6 Jan 2026 10:27:54 +0900
Subject: [PATCH 37/95] =?UTF-8?q?=EA=B6=8C=ED=95=9C=EA=B8=B0=EB=8A=A5=20?=
=?UTF-8?q?=EC=9E=84=EC=8B=9C=20=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/routes/cascadingAutoFillRoutes.ts | 1 +
.../src/routes/cascadingConditionRoutes.ts | 1 +
.../src/routes/cascadingHierarchyRoutes.ts | 1 +
.../routes/cascadingMutualExclusionRoutes.ts | 1 +
backend-node/src/services/adminService.ts | 18 ++++++++++++++++++
docs/노드플로우_개선사항.md | 1 +
docs/메일발송_기능_사용_가이드.md | 1 +
docs/즉시저장_버튼_액션_구현_계획서.md | 1 +
.../admin/screenMng/screenMngList/page.tsx | 1 +
frontend/contexts/ActiveTabContext.tsx | 1 +
frontend/hooks/useAutoFill.ts | 1 +
..._임베딩_및_데이터_전달_시스템_구현_계획서.md | 1 +
화면_임베딩_시스템_Phase1-4_구현_완료.md | 1 +
화면_임베딩_시스템_충돌_분석_보고서.md | 1 +
14 files changed, 31 insertions(+)
diff --git a/backend-node/src/routes/cascadingAutoFillRoutes.ts b/backend-node/src/routes/cascadingAutoFillRoutes.ts
index a5107448..c1d69e9f 100644
--- a/backend-node/src/routes/cascadingAutoFillRoutes.ts
+++ b/backend-node/src/routes/cascadingAutoFillRoutes.ts
@@ -55,3 +55,4 @@ export default router;
+
diff --git a/backend-node/src/routes/cascadingConditionRoutes.ts b/backend-node/src/routes/cascadingConditionRoutes.ts
index 22cd2d2b..bbc9384d 100644
--- a/backend-node/src/routes/cascadingConditionRoutes.ts
+++ b/backend-node/src/routes/cascadingConditionRoutes.ts
@@ -51,3 +51,4 @@ export default router;
+
diff --git a/backend-node/src/routes/cascadingHierarchyRoutes.ts b/backend-node/src/routes/cascadingHierarchyRoutes.ts
index 79a1c6e8..35ced071 100644
--- a/backend-node/src/routes/cascadingHierarchyRoutes.ts
+++ b/backend-node/src/routes/cascadingHierarchyRoutes.ts
@@ -67,3 +67,4 @@ export default router;
+
diff --git a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts
index 352a05b5..29ac8ee4 100644
--- a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts
+++ b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts
@@ -55,3 +55,4 @@ export default router;
+
diff --git a/backend-node/src/services/adminService.ts b/backend-node/src/services/adminService.ts
index 1b9280db..95d8befa 100644
--- a/backend-node/src/services/adminService.ts
+++ b/backend-node/src/services/adminService.ts
@@ -65,6 +65,13 @@ export class AdminService {
}
);
+ // [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시
+ // TODO: 권한 체크 다시 활성화 필요
+ logger.info(
+ `⚠️ [임시 비활성화] 권한 그룹 체크 스킵 - 사용자 ${userId}(${userType})에게 전체 메뉴 표시`
+ );
+
+ /* [원본 코드 - 권한 그룹 체크]
if (userType === "COMPANY_ADMIN") {
// 회사 관리자: 권한 그룹 기반 필터링 적용
if (userRoleGroups.length > 0) {
@@ -141,6 +148,7 @@ export class AdminService {
return [];
}
}
+ */
} else if (
menuType !== undefined &&
userType === "SUPER_ADMIN" &&
@@ -412,6 +420,15 @@ export class AdminService {
let queryParams: any[] = [userLang];
let paramIndex = 2;
+ // [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시
+ // TODO: 권한 체크 다시 활성화 필요
+ logger.info(
+ `⚠️ [임시 비활성화] getUserMenuList 권한 그룹 체크 스킵 - 사용자 ${userId}(${userType})에게 전체 메뉴 표시`
+ );
+ authFilter = "";
+ unionFilter = "";
+
+ /* [원본 코드 - getUserMenuList 권한 그룹 체크]
if (userType === "SUPER_ADMIN") {
// SUPER_ADMIN: 권한 그룹 체크 없이 해당 회사의 모든 메뉴 표시
logger.info(`✅ 좌측 사이드바 (SUPER_ADMIN): 회사 ${userCompanyCode}의 모든 메뉴 표시`);
@@ -471,6 +488,7 @@ export class AdminService {
return [];
}
}
+ */
// 2. 회사별 필터링 조건 생성
let companyFilter = "";
diff --git a/docs/노드플로우_개선사항.md b/docs/노드플로우_개선사항.md
index c9349b94..32757807 100644
--- a/docs/노드플로우_개선사항.md
+++ b/docs/노드플로우_개선사항.md
@@ -587,3 +587,4 @@ const result = await executeNodeFlow(flowId, {
+
diff --git a/docs/메일발송_기능_사용_가이드.md b/docs/메일발송_기능_사용_가이드.md
index 42900211..8bfe484e 100644
--- a/docs/메일발송_기능_사용_가이드.md
+++ b/docs/메일발송_기능_사용_가이드.md
@@ -360,3 +360,4 @@
+
diff --git a/docs/즉시저장_버튼_액션_구현_계획서.md b/docs/즉시저장_버튼_액션_구현_계획서.md
index c392eece..8d8fb497 100644
--- a/docs/즉시저장_버튼_액션_구현_계획서.md
+++ b/docs/즉시저장_버튼_액션_구현_계획서.md
@@ -346,3 +346,4 @@ const getComponentValue = (componentId: string) => {
+
diff --git a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx
index 3145d9d3..0327e122 100644
--- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx
+++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx
@@ -127,3 +127,4 @@ export default function ScreenManagementPage() {
);
}
+
diff --git a/frontend/contexts/ActiveTabContext.tsx b/frontend/contexts/ActiveTabContext.tsx
index 228dc990..35081225 100644
--- a/frontend/contexts/ActiveTabContext.tsx
+++ b/frontend/contexts/ActiveTabContext.tsx
@@ -140,3 +140,4 @@ export const useActiveTabOptional = () => {
+
diff --git a/frontend/hooks/useAutoFill.ts b/frontend/hooks/useAutoFill.ts
index caa1e826..7d78322b 100644
--- a/frontend/hooks/useAutoFill.ts
+++ b/frontend/hooks/useAutoFill.ts
@@ -197,3 +197,4 @@ export function applyAutoFillToFormData(
+
diff --git a/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md b/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md
index f61ab2fb..1108475c 100644
--- a/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md
+++ b/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md
@@ -1689,3 +1689,4 @@ const 출고등록_설정: ScreenSplitPanel = {
+
diff --git a/화면_임베딩_시스템_Phase1-4_구현_완료.md b/화면_임베딩_시스템_Phase1-4_구현_완료.md
index 0596216f..c20a94bc 100644
--- a/화면_임베딩_시스템_Phase1-4_구현_완료.md
+++ b/화면_임베딩_시스템_Phase1-4_구현_완료.md
@@ -536,3 +536,4 @@ const { data: config } = await getScreenSplitPanel(screenId);
+
diff --git a/화면_임베딩_시스템_충돌_분석_보고서.md b/화면_임베딩_시스템_충돌_분석_보고서.md
index 4f0bfabb..77ad05b2 100644
--- a/화면_임베딩_시스템_충돌_분석_보고서.md
+++ b/화면_임베딩_시스템_충돌_분석_보고서.md
@@ -523,3 +523,4 @@ function ScreenViewPage() {
+
From 6bfc1a97a32e481f2242b48083fe0e050e6e727c Mon Sep 17 00:00:00 2001
From: kjs
Date: Tue, 6 Jan 2026 11:43:26 +0900
Subject: [PATCH 38/95] =?UTF-8?q?=EB=B2=94=EC=9A=A9=20=ED=8F=BC=20?=
=?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EC=82=AC=EC=A0=84=ED=95=84=ED=84=B0=20?=
=?UTF-8?q?=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
---
.../src/controllers/entitySearchController.ts | 86 ++-
.../entity-search-input/useEntitySearch.ts | 6 +
.../ItemSelectionModal.tsx | 76 +-
.../components/modal-repeater-table/types.ts | 3 +
.../TableSectionRenderer.tsx | 43 +-
.../UniversalFormModalConfigPanel.tsx | 686 +++++++++---------
.../modals/TableSectionSettingsModal.tsx | 186 +++--
7 files changed, 689 insertions(+), 397 deletions(-)
diff --git a/backend-node/src/controllers/entitySearchController.ts b/backend-node/src/controllers/entitySearchController.ts
index 4d911c57..5f198c3f 100644
--- a/backend-node/src/controllers/entitySearchController.ts
+++ b/backend-node/src/controllers/entitySearchController.ts
@@ -107,14 +107,88 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
}
// 추가 필터 조건 (존재하는 컬럼만)
+ // 지원 연산자: =, !=, >, <, >=, <=, in, notIn, like
+ // 특수 키 형식: column__operator (예: division__in, name__like)
const additionalFilter = JSON.parse(filterCondition as string);
for (const [key, value] of Object.entries(additionalFilter)) {
- if (existingColumns.has(key)) {
- whereConditions.push(`${key} = $${paramIndex}`);
- params.push(value);
- paramIndex++;
- } else {
- logger.warn("엔티티 검색: 필터 조건에 존재하지 않는 컬럼 제외", { tableName, key });
+ // 특수 키 형식 파싱: column__operator
+ let columnName = key;
+ let operator = "=";
+
+ if (key.includes("__")) {
+ const parts = key.split("__");
+ columnName = parts[0];
+ operator = parts[1] || "=";
+ }
+
+ if (!existingColumns.has(columnName)) {
+ logger.warn("엔티티 검색: 필터 조건에 존재하지 않는 컬럼 제외", { tableName, key, columnName });
+ continue;
+ }
+
+ // 연산자별 WHERE 조건 생성
+ switch (operator) {
+ case "=":
+ whereConditions.push(`"${columnName}" = $${paramIndex}`);
+ params.push(value);
+ paramIndex++;
+ break;
+ case "!=":
+ whereConditions.push(`"${columnName}" != $${paramIndex}`);
+ params.push(value);
+ paramIndex++;
+ break;
+ case ">":
+ whereConditions.push(`"${columnName}" > $${paramIndex}`);
+ params.push(value);
+ paramIndex++;
+ break;
+ case "<":
+ whereConditions.push(`"${columnName}" < $${paramIndex}`);
+ params.push(value);
+ paramIndex++;
+ break;
+ case ">=":
+ whereConditions.push(`"${columnName}" >= $${paramIndex}`);
+ params.push(value);
+ paramIndex++;
+ break;
+ case "<=":
+ whereConditions.push(`"${columnName}" <= $${paramIndex}`);
+ params.push(value);
+ paramIndex++;
+ break;
+ case "in":
+ // IN 연산자: 값이 배열이거나 쉼표로 구분된 문자열
+ const inValues = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim());
+ if (inValues.length > 0) {
+ const placeholders = inValues.map((_, i) => `$${paramIndex + i}`).join(", ");
+ whereConditions.push(`"${columnName}" IN (${placeholders})`);
+ params.push(...inValues);
+ paramIndex += inValues.length;
+ }
+ break;
+ case "notIn":
+ // NOT IN 연산자
+ const notInValues = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim());
+ if (notInValues.length > 0) {
+ const placeholders = notInValues.map((_, i) => `$${paramIndex + i}`).join(", ");
+ whereConditions.push(`"${columnName}" NOT IN (${placeholders})`);
+ params.push(...notInValues);
+ paramIndex += notInValues.length;
+ }
+ break;
+ case "like":
+ whereConditions.push(`"${columnName}"::text ILIKE $${paramIndex}`);
+ params.push(`%${value}%`);
+ paramIndex++;
+ break;
+ default:
+ // 알 수 없는 연산자는 등호로 처리
+ whereConditions.push(`"${columnName}" = $${paramIndex}`);
+ params.push(value);
+ paramIndex++;
+ break;
}
}
diff --git a/frontend/lib/registry/components/entity-search-input/useEntitySearch.ts b/frontend/lib/registry/components/entity-search-input/useEntitySearch.ts
index 1fac26d6..2ae71595 100644
--- a/frontend/lib/registry/components/entity-search-input/useEntitySearch.ts
+++ b/frontend/lib/registry/components/entity-search-input/useEntitySearch.ts
@@ -53,6 +53,12 @@ export function useEntitySearch({
limit: pagination.limit.toString(),
});
+ console.log("[useEntitySearch] 검색 실행:", {
+ tableName,
+ filterCondition: filterConditionRef.current,
+ searchText: text,
+ });
+
const response = await apiClient.get(
`/entity-search/${tableName}?${params.toString()}`
);
diff --git a/frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx b/frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx
index ad73c317..1eca9fab 100644
--- a/frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx
+++ b/frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx
@@ -32,6 +32,7 @@ export function ItemSelectionModal({
onSelect,
columnLabels = {},
modalFilters = [],
+ categoryColumns = [],
}: ItemSelectionModalProps) {
const [localSearchText, setLocalSearchText] = useState("");
const [selectedItems, setSelectedItems] = useState([]);
@@ -41,6 +42,9 @@ export function ItemSelectionModal({
// 카테고리 옵션 상태 (categoryRef별로 로드된 옵션)
const [categoryOptions, setCategoryOptions] = useState>({});
+
+ // 카테고리 코드 → 라벨 매핑 (테이블 데이터 표시용)
+ const [categoryLabelMap, setCategoryLabelMap] = useState>({});
// 모달 필터 값과 기본 filterCondition을 합친 최종 필터 조건
const combinedFilterCondition = useMemo(() => {
@@ -152,6 +156,54 @@ export function ItemSelectionModal({
}
}, [modalFilterValues]);
+ // 검색 결과가 변경되면 카테고리 값들의 라벨 조회
+ useEffect(() => {
+ const loadCategoryLabels = async () => {
+ if (!open || categoryColumns.length === 0 || results.length === 0) {
+ return;
+ }
+
+ // 현재 결과에서 카테고리 컬럼의 모든 고유한 값 수집
+ // 쉼표로 구분된 다중 값도 개별적으로 수집
+ const allCodes = new Set();
+ for (const row of results) {
+ for (const col of categoryColumns) {
+ const val = row[col];
+ if (val && typeof val === "string") {
+ // 쉼표로 구분된 다중 값 처리
+ const codes = val.split(",").map((c) => c.trim()).filter(Boolean);
+ for (const code of codes) {
+ if (!categoryLabelMap[code]) {
+ allCodes.add(code);
+ }
+ }
+ }
+ }
+ }
+
+ if (allCodes.size === 0) {
+ return;
+ }
+
+ try {
+ const response = await apiClient.post("/table-categories/labels-by-codes", {
+ valueCodes: Array.from(allCodes),
+ });
+
+ if (response.data?.success && response.data.data) {
+ setCategoryLabelMap((prev) => ({
+ ...prev,
+ ...response.data.data,
+ }));
+ }
+ } catch (error) {
+ console.error("카테고리 라벨 조회 실패:", error);
+ }
+ };
+
+ loadCategoryLabels();
+ }, [open, results, categoryColumns]);
+
// 모달 필터 값 변경 핸들러
const handleModalFilterChange = (column: string, value: any) => {
setModalFilterValues((prev) => ({
@@ -450,11 +502,25 @@ export function ItemSelectionModal({
)}
- {validColumns.map((col) => (
-
- {item[col] || "-"}
-
- ))}
+ {validColumns.map((col) => {
+ const rawValue = item[col];
+ // 카테고리 컬럼이면 라벨로 변환
+ const isCategory = categoryColumns.includes(col);
+ let displayValue = rawValue;
+
+ if (isCategory && rawValue && typeof rawValue === "string") {
+ // 쉼표로 구분된 다중 값 처리
+ const codes = rawValue.split(",").map((c) => c.trim()).filter(Boolean);
+ const labels = codes.map((code) => categoryLabelMap[code] || code);
+ displayValue = labels.join(", ");
+ }
+
+ return (
+
+ {displayValue || "-"}
+
+ );
+ })}
);
})
diff --git a/frontend/lib/registry/components/modal-repeater-table/types.ts b/frontend/lib/registry/components/modal-repeater-table/types.ts
index ad373200..ba23c60e 100644
--- a/frontend/lib/registry/components/modal-repeater-table/types.ts
+++ b/frontend/lib/registry/components/modal-repeater-table/types.ts
@@ -202,4 +202,7 @@ export interface ItemSelectionModalProps {
// 모달 내부 필터 (사용자 선택 가능)
modalFilters?: ModalFilterConfig[];
+
+ // 카테고리 타입 컬럼 목록 (해당 컬럼은 코드 → 라벨로 변환하여 표시)
+ categoryColumns?: string[];
}
diff --git a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx
index 405e2abf..fb1b2ea3 100644
--- a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx
@@ -381,6 +381,34 @@ export function TableSectionRenderer({
const [dynamicOptions, setDynamicOptions] = useState<{ id: string; value: string; label: string }[]>([]);
const [dynamicOptionsLoading, setDynamicOptionsLoading] = useState(false);
const dynamicOptionsLoadedRef = React.useRef(false);
+
+ // 소스 테이블의 카테고리 타입 컬럼 목록
+ const [sourceCategoryColumns, setSourceCategoryColumns] = useState([]);
+
+ // 소스 테이블의 카테고리 타입 컬럼 목록 로드
+ useEffect(() => {
+ const loadCategoryColumns = async () => {
+ if (!tableConfig.source.tableName) return;
+
+ try {
+ const response = await apiClient.get(
+ `/table-categories/${tableConfig.source.tableName}/columns`
+ );
+
+ if (response.data?.success && Array.isArray(response.data.data)) {
+ const categoryColNames = response.data.data.map(
+ (col: { columnName?: string; column_name?: string }) =>
+ col.columnName || col.column_name || ""
+ ).filter(Boolean);
+ setSourceCategoryColumns(categoryColNames);
+ }
+ } catch (error) {
+ console.error("카테고리 컬럼 목록 조회 실패:", error);
+ }
+ };
+
+ loadCategoryColumns();
+ }, [tableConfig.source.tableName]);
// 조건부 테이블: 동적 옵션 로드 (optionSource 설정이 있는 경우)
useEffect(() => {
@@ -1281,16 +1309,25 @@ export function TableSectionRenderer({
const addRowButtonText = uiConfig?.addRowButtonText || "직접 입력";
// 기본 필터 조건 생성 (사전 필터만 - 모달 필터는 ItemSelectionModal에서 처리)
+ // 연산자별로 특수 키 형식 사용: column__operator (예: division__in)
const baseFilterCondition: Record = useMemo(() => {
const condition: Record = {};
if (filters?.preFilters) {
for (const filter of filters.preFilters) {
- // 간단한 "=" 연산자만 처리 (확장 가능)
- if (filter.operator === "=") {
+ if (!filter.column || filter.value === undefined || filter.value === "") continue;
+
+ const operator = filter.operator || "=";
+
+ if (operator === "=") {
+ // 기본 등호 연산자는 그대로 전달
condition[filter.column] = filter.value;
+ } else {
+ // 다른 연산자는 특수 키 형식 사용: column__operator
+ condition[`${filter.column}__${operator}`] = filter.value;
}
}
}
+ console.log("[TableSectionRenderer] baseFilterCondition:", condition, "preFilters:", filters?.preFilters);
return condition;
}, [filters?.preFilters]);
@@ -1892,6 +1929,7 @@ export function TableSectionRenderer({
onSelect={handleConditionalAddItems}
columnLabels={columnLabels}
modalFilters={modalFiltersForModal}
+ categoryColumns={sourceCategoryColumns}
/>
);
@@ -2000,6 +2038,7 @@ export function TableSectionRenderer({
onSelect={handleAddItems}
columnLabels={columnLabels}
modalFilters={modalFiltersForModal}
+ categoryColumns={sourceCategoryColumns}
/>
);
diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx
index 27af68f1..7186ca7e 100644
--- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx
@@ -9,17 +9,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
-import {
- Plus,
- Trash2,
- GripVertical,
- ChevronUp,
- ChevronDown,
- Settings,
- Database,
- Layout,
- Table,
-} from "lucide-react";
+import { Plus, Trash2, GripVertical, ChevronUp, ChevronDown, Settings, Database, Layout, Table } from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { getNumberingRules } from "@/lib/api/numberingRule";
@@ -31,11 +21,7 @@ import {
MODAL_SIZE_OPTIONS,
SECTION_TYPE_OPTIONS,
} from "./types";
-import {
- defaultSectionConfig,
- defaultTableSectionConfig,
- generateSectionId,
-} from "./config";
+import { defaultSectionConfig, defaultTableSectionConfig, generateSectionId } from "./config";
// 모달 import
import { FieldDetailSettingsModal } from "./modals/FieldDetailSettingsModal";
@@ -45,22 +31,26 @@ import { TableSectionSettingsModal } from "./modals/TableSectionSettingsModal";
// 도움말 텍스트 컴포넌트
const HelpText = ({ children }: { children: React.ReactNode }) => (
-
{children}
+
{children}
);
// 부모 화면에서 전달 가능한 필드 타입
interface AvailableParentField {
- name: string; // 필드명 (columnName)
- label: string; // 표시 라벨
+ name: string; // 필드명 (columnName)
+ label: string; // 표시 라벨
sourceComponent?: string; // 출처 컴포넌트 (예: "TableList", "SplitPanelLayout2")
- sourceTable?: string; // 출처 테이블명
+ sourceTable?: string; // 출처 테이블명
}
-export function UniversalFormModalConfigPanel({ config, onChange, allComponents = [] }: UniversalFormModalConfigPanelProps) {
+export function UniversalFormModalConfigPanel({
+ config,
+ onChange,
+ allComponents = [],
+}: UniversalFormModalConfigPanelProps) {
// 테이블 목록
const [tables, setTables] = useState<{ name: string; label: string }[]>([]);
const [tableColumns, setTableColumns] = useState<{
- [tableName: string]: { name: string; type: string; label: string }[];
+ [tableName: string]: { name: string; type: string; label: string; inputType?: string }[];
}>({});
// 부모 화면에서 전달 가능한 필드 목록
@@ -140,7 +130,7 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
}
});
}
-
+
// 좌측 패널 테이블 컬럼도 추출
const leftTableName = compConfig.leftPanel?.tableName;
if (leftTableName) {
@@ -152,7 +142,7 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
const colName = col.columnName || col.column_name;
const colLabel = col.displayName || col.columnComment || col.column_comment || colName;
// 중복 방지
- if (!fields.some(f => f.name === colName && f.sourceTable === leftTableName)) {
+ if (!fields.some((f) => f.name === colName && f.sourceTable === leftTableName)) {
fields.push({
name: colName,
label: colLabel,
@@ -179,7 +169,7 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
columns.forEach((col: any) => {
const colName = col.columnName || col.column_name;
const colLabel = col.displayName || col.columnComment || col.column_comment || colName;
- if (!fields.some(f => f.name === colName && f.sourceTable === tableName)) {
+ if (!fields.some((f) => f.name === colName && f.sourceTable === tableName)) {
fields.push({
name: colName,
label: colLabel,
@@ -198,11 +188,11 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
// 4. 버튼 컴포넌트 - openModalWithData의 fieldMappings/dataMapping에서 소스 컬럼 추출
if (compType === "button-primary" || compType === "button" || compType === "button-secondary") {
const action = compConfig.action || {};
-
+
// fieldMappings에서 소스 컬럼 추출
const fieldMappings = action.fieldMappings || [];
fieldMappings.forEach((mapping: any) => {
- if (mapping.sourceColumn && !fields.some(f => f.name === mapping.sourceColumn)) {
+ if (mapping.sourceColumn && !fields.some((f) => f.name === mapping.sourceColumn)) {
fields.push({
name: mapping.sourceColumn,
label: mapping.sourceColumn,
@@ -211,11 +201,11 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
});
}
});
-
+
// dataMapping에서 소스 컬럼 추출
const dataMapping = action.dataMapping || [];
dataMapping.forEach((mapping: any) => {
- if (mapping.sourceColumn && !fields.some(f => f.name === mapping.sourceColumn)) {
+ if (mapping.sourceColumn && !fields.some((f) => f.name === mapping.sourceColumn)) {
fields.push({
name: mapping.sourceColumn,
label: mapping.sourceColumn,
@@ -237,7 +227,7 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
columns.forEach((col: any) => {
const colName = col.columnName || col.column_name;
const colLabel = col.displayName || col.columnComment || col.column_comment || colName;
- if (!fields.some(f => f.name === colName)) {
+ if (!fields.some((f) => f.name === colName)) {
fields.push({
name: colName,
label: colLabel,
@@ -253,8 +243,8 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
}
// 중복 제거 (같은 name이면 첫 번째만 유지)
- const uniqueFields = fields.filter((field, index, self) =>
- index === self.findIndex(f => f.name === field.name)
+ const uniqueFields = fields.filter(
+ (field, index, self) => index === self.findIndex((f) => f.name === field.name),
);
setAvailableParentFields(uniqueFields);
@@ -276,11 +266,19 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
const data = response.data?.data;
if (response.data?.success && Array.isArray(data)) {
setTables(
- data.map((t: { tableName?: string; table_name?: string; displayName?: string; tableLabel?: string; table_label?: string }) => ({
- name: t.tableName || t.table_name || "",
- // displayName 우선, 없으면 tableLabel, 그것도 없으면 테이블명
- label: t.displayName || t.tableLabel || t.table_label || "",
- })),
+ data.map(
+ (t: {
+ tableName?: string;
+ table_name?: string;
+ displayName?: string;
+ tableLabel?: string;
+ table_label?: string;
+ }) => ({
+ name: t.tableName || t.table_name || "",
+ // displayName 우선, 없으면 tableLabel, 그것도 없으면 테이블명
+ label: t.displayName || t.tableLabel || t.table_label || "",
+ }),
+ ),
);
}
} catch (error) {
@@ -308,10 +306,13 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
displayName?: string;
columnComment?: string;
column_comment?: string;
+ inputType?: string;
+ input_type?: string;
}) => ({
name: c.columnName || c.column_name || "",
type: c.dataType || c.data_type || "text",
label: c.displayName || c.columnComment || c.column_comment || c.columnName || c.column_name || "",
+ inputType: c.inputType || c.input_type || "text",
}),
),
}));
@@ -359,21 +360,24 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
);
// 섹션 관리
- const addSection = useCallback((type: "fields" | "table" = "fields") => {
- const newSection: FormSectionConfig = {
- ...defaultSectionConfig,
- id: generateSectionId(),
- title: type === "table" ? `테이블 섹션 ${config.sections.length + 1}` : `섹션 ${config.sections.length + 1}`,
- type,
- fields: type === "fields" ? [] : undefined,
- tableConfig: type === "table" ? { ...defaultTableSectionConfig } : undefined,
- };
- onChange({
- ...config,
- sections: [...config.sections, newSection],
- });
- }, [config, onChange]);
-
+ const addSection = useCallback(
+ (type: "fields" | "table" = "fields") => {
+ const newSection: FormSectionConfig = {
+ ...defaultSectionConfig,
+ id: generateSectionId(),
+ title: type === "table" ? `테이블 섹션 ${config.sections.length + 1}` : `섹션 ${config.sections.length + 1}`,
+ type,
+ fields: type === "fields" ? [] : undefined,
+ tableConfig: type === "table" ? { ...defaultTableSectionConfig } : undefined,
+ };
+ onChange({
+ ...config,
+ sections: [...config.sections, newSection],
+ });
+ },
+ [config, onChange],
+ );
+
// 섹션 타입 변경
const changeSectionType = useCallback(
(sectionId: string, newType: "fields" | "table") => {
@@ -381,7 +385,7 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
...config,
sections: config.sections.map((s) => {
if (s.id !== sectionId) return s;
-
+
if (newType === "table") {
return {
...s,
@@ -400,9 +404,9 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
}),
});
},
- [config, onChange]
+ [config, onChange],
);
-
+
// 테이블 섹션 설정 모달 열기
const handleOpenTableSectionSettings = (section: FormSectionConfig) => {
setSelectedSection(section);
@@ -487,293 +491,310 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
};
return (
-
-
-
- {/* 모달 기본 설정 */}
-
-
-
-
-
- 모달 기본 설정
-
-
-
-
- 모달 제목
- updateModalConfig({ title: e.target.value })}
- className="h-9 text-sm w-full max-w-full"
- />
- 모달 상단에 표시될 제목입니다
-
-
-
- 모달 크기
- updateModalConfig({ size: value })}>
-
-
-
-
- {MODAL_SIZE_OPTIONS.map((opt) => (
-
- {opt.label}
-
- ))}
-
-
- 모달 창의 크기를 선택하세요
-
-
- {/* 저장 버튼 표시 설정 */}
-
-
-
updateModalConfig({ showSaveButton: checked === true })}
- />
-
- 저장 버튼 표시
-
+
+
+
+ {/* 모달 기본 설정 */}
+
+
+
+
+
+ 모달 기본 설정
- 체크 해제 시 모달 하단의 저장 버튼이 숨겨집니다
-
-
-
-
-
-
- {/* 저장 설정 */}
-
-
-
-
-
- 저장 설정
-
-
-
-
-
-
저장 테이블
-
- {config.saveConfig.tableName || "(미설정)"}
-
- {config.saveConfig.customApiSave?.enabled && config.saveConfig.customApiSave?.multiTable?.enabled && (
-
- 다중 테이블 모드
-
- )}
+ {/* 저장 버튼 표시 설정 */}
+
+
+ updateModalConfig({ showSaveButton: checked === true })}
+ />
+
+ 저장 버튼 표시
+
+
+
체크 해제 시 모달 하단의 저장 버튼이 숨겨집니다
-
setSaveSettingsModalOpen(true)}
- className="h-9 text-xs w-full"
- >
-
- 저장 설정 열기
-
-
-
- 데이터를 저장할 테이블과 방식을 설정합니다.
-
- "저장 설정 열기"를 클릭하여 상세 설정을 변경하세요.
-
-
-
-
- {/* 섹션 구성 */}
-
-
-
-
-
- 섹션 구성
-
- {config.sections.length}개
-
-
-
-
- {/* 섹션 추가 버튼들 */}
-
-
addSection("fields")} className="h-9 text-xs flex-1 min-w-0">
-
- 필드 섹션
-
-
addSection("table")} className="h-9 text-xs flex-1 min-w-0">
-
- 테이블 섹션
-
-
-
- 필드 섹션: 일반 입력 필드들을 배치합니다.
-
- 테이블 섹션: 품목 목록 등 반복 테이블 형식 데이터를 관리합니다.
-
-
- {config.sections.length === 0 ? (
-
-
섹션이 없습니다
-
위 버튼으로 섹션을 추가하세요
+
- ) : (
-
- {config.sections.map((section, index) => (
-
- {/* 헤더: 제목 + 타입 배지 + 삭제 */}
-
-
-
-
{section.title}
+
+
+
+
+ {/* 저장 설정 */}
+
+
+
+
+
+ 저장 설정
+
+
+
+
+
+
저장 테이블
+
{config.saveConfig.tableName || "(미설정)"}
+ {config.saveConfig.customApiSave?.enabled &&
+ config.saveConfig.customApiSave?.multiTable?.enabled && (
+
+ 다중 테이블 모드
+
+ )}
+
+
setSaveSettingsModalOpen(true)}
+ className="h-9 w-full text-xs"
+ >
+
+ 저장 설정 열기
+
+
+
+ 데이터를 저장할 테이블과 방식을 설정합니다.
+
+ "저장 설정 열기"를 클릭하여 상세 설정을 변경하세요.
+
+
+
+
+
+ {/* 섹션 구성 */}
+
+
+
+
+
+ 섹션 구성
+
+ {config.sections.length}개
+
+
+
+
+ {/* 섹션 추가 버튼들 */}
+
+
addSection("fields")}
+ className="h-9 min-w-0 flex-1 text-xs"
+ >
+
+ 필드 섹션
+
+
addSection("table")}
+ className="h-9 min-w-0 flex-1 text-xs"
+ >
+
+ 테이블 섹션
+
+
+
+ 필드 섹션: 일반 입력 필드들을 배치합니다.
+
+ 테이블 섹션: 품목 목록 등 반복 테이블 형식 데이터를 관리합니다.
+
+
+ {config.sections.length === 0 ? (
+
+
섹션이 없습니다
+
위 버튼으로 섹션을 추가하세요
+
+ ) : (
+
+ {config.sections.map((section, index) => (
+
+ {/* 헤더: 제목 + 타입 배지 + 삭제 */}
+
+
+
+ {section.title}
+ {section.type === "table" ? (
+
+ 테이블
+
+ ) : section.repeatable ? (
+
+ 반복
+
+ ) : null}
+
{section.type === "table" ? (
-
- 테이블
+
+ {section.tableConfig?.source?.tableName || "(소스 미설정)"}
- ) : section.repeatable ? (
-
- 반복
+ ) : (
+
+ {(section.fields || []).length}개 필드
- ) : null}
+ )}
- {section.type === "table" ? (
-
- {section.tableConfig?.source?.tableName || "(소스 미설정)"}
-
- ) : (
-
- {(section.fields || []).length}개 필드
-
- )}
-
-
-
removeSection(section.id)}
- className="h-7 w-7 p-0 text-destructive hover:text-destructive shrink-0"
- >
-
-
-
- {/* 순서 조정 버튼 */}
-
-
-
+ removeSection(section.id)}
+ className="text-destructive hover:text-destructive h-7 w-7 shrink-0 p-0"
+ >
+
+
+
+
+ {/* 순서 조정 버튼 */}
+
+
+
+ moveSectionUp(index)}
+ disabled={index === 0}
+ className="h-7 px-2 text-xs"
+ >
+
+
+ moveSectionDown(index)}
+ disabled={index === config.sections.length - 1}
+ className="h-7 px-2 text-xs"
+ >
+
+
+
+
+
+ {/* 필드 목록 (필드 타입만) */}
+ {section.type !== "table" && (section.fields || []).length > 0 && (
+
+ {(section.fields || []).slice(0, 4).map((field) => (
+
+ {field.label}
+
+ ))}
+ {(section.fields || []).length > 4 && (
+
+ +{(section.fields || []).length - 4}
+
+ )}
+
+ )}
+
+ {/* 테이블 컬럼 목록 (테이블 타입만) */}
+ {section.type === "table" &&
+ section.tableConfig?.columns &&
+ section.tableConfig.columns.length > 0 && (
+
+ {section.tableConfig.columns.slice(0, 4).map((col, idx) => (
+
+ {col.label || col.field || `컬럼 ${idx + 1}`}
+
+ ))}
+ {section.tableConfig.columns.length > 4 && (
+
+ +{section.tableConfig.columns.length - 4}
+
+ )}
+
+ )}
+
+ {/* 설정 버튼 (타입에 따라 다름) */}
+ {section.type === "table" ? (
moveSectionUp(index)}
- disabled={index === 0}
- className="h-7 px-2 text-xs"
+ onClick={() => handleOpenTableSectionSettings(section)}
+ className="h-9 w-full text-xs"
>
-
+
+ 테이블 설정
+ ) : (
moveSectionDown(index)}
- disabled={index === config.sections.length - 1}
- className="h-7 px-2 text-xs"
+ onClick={() => handleOpenSectionLayout(section)}
+ className="h-9 w-full text-xs"
>
-
+
+ 레이아웃 설정
-
+ )}
-
- {/* 필드 목록 (필드 타입만) */}
- {section.type !== "table" && (section.fields || []).length > 0 && (
-
- {(section.fields || []).slice(0, 4).map((field) => (
-
- {field.label}
-
- ))}
- {(section.fields || []).length > 4 && (
-
- +{(section.fields || []).length - 4}
-
- )}
-
- )}
-
- {/* 테이블 컬럼 목록 (테이블 타입만) */}
- {section.type === "table" && section.tableConfig?.columns && section.tableConfig.columns.length > 0 && (
-
- {section.tableConfig.columns.slice(0, 4).map((col, idx) => (
-
- {col.label || col.field || `컬럼 ${idx + 1}`}
-
- ))}
- {section.tableConfig.columns.length > 4 && (
-
- +{section.tableConfig.columns.length - 4}
-
- )}
-
- )}
-
- {/* 설정 버튼 (타입에 따라 다름) */}
- {section.type === "table" ? (
- handleOpenTableSectionSettings(section)}
- className="h-9 text-xs w-full"
- >
-
- 테이블 설정
-
- ) : (
- handleOpenSectionLayout(section)}
- className="h-9 text-xs w-full"
- >
-
- 레이아웃 설정
-
- )}
-
- ))}
-
- )}
-
-
-
+ ))}
+
+ )}
+
+
+
@@ -813,11 +834,13 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
setFieldDetailModalOpen(true);
}}
tableName={config.saveConfig.tableName}
- tableColumns={tableColumns[config.saveConfig.tableName || ""]?.map(col => ({
- name: col.name,
- type: col.type,
- label: col.label || col.name
- })) || []}
+ tableColumns={
+ tableColumns[config.saveConfig.tableName || ""]?.map((col) => ({
+ name: col.name,
+ type: col.type,
+ label: col.label || col.name,
+ })) || []
+ }
/>
)}
@@ -845,15 +868,13 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
fields: group.fields.map((f) => (f.id === updatedField.id ? updatedField : f)),
})),
};
-
+
// config 업데이트
onChange({
...config,
- sections: config.sections.map((s) =>
- s.id === selectedSection.id ? updatedSection : s
- ),
+ sections: config.sections.map((s) => (s.id === selectedSection.id ? updatedSection : s)),
});
-
+
// selectedSection과 selectedField 상태도 업데이트 (다음에 다시 열었을 때 최신 값 반영)
setSelectedSection(updatedSection);
setSelectedField(updatedField as FormFieldConfig);
@@ -881,29 +902,28 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
...selectedSection,
...updates,
};
-
+
// config 업데이트
onChange({
...config,
- sections: config.sections.map((s) =>
- s.id === selectedSection.id ? updatedSection : s
- ),
+ sections: config.sections.map((s) => (s.id === selectedSection.id ? updatedSection : s)),
});
-
+
setSelectedSection(updatedSection);
setTableSectionSettingsModalOpen(false);
}}
- tables={tables.map(t => ({ table_name: t.name, comment: t.label }))}
+ tables={tables.map((t) => ({ table_name: t.name, comment: t.label }))}
tableColumns={Object.fromEntries(
Object.entries(tableColumns).map(([tableName, cols]) => [
tableName,
- cols.map(c => ({
+ cols.map((c) => ({
column_name: c.name,
data_type: c.type,
is_nullable: "YES",
comment: c.label,
+ input_type: c.inputType || "text",
})),
- ])
+ ]),
)}
onLoadTableColumns={loadTableColumns}
allSections={config.sections as FormSectionConfig[]}
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 b01d6b09..a7bec622 100644
--- a/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx
@@ -1,6 +1,6 @@
"use client";
-import React, { useState, useEffect, useMemo } from "react";
+import React, { useState, useEffect, useMemo, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -15,6 +15,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Trash2, GripVertical, ChevronUp, ChevronDown, Settings, Check, ChevronsUpDown, Filter, Table as TableIcon, Search } from "lucide-react";
import { cn } from "@/lib/utils";
+import { apiClient } from "@/lib/api/client";
// 타입 import
import {
@@ -2415,7 +2416,7 @@ interface TableSectionSettingsModalProps {
section: FormSectionConfig;
onSave: (updates: Partial
) => void;
tables: { table_name: string; comment?: string }[];
- tableColumns: Record;
+ tableColumns: Record;
onLoadTableColumns: (tableName: string) => void;
// 카테고리 목록 (table_column_category_values에서 가져옴)
categoryList?: { tableName: string; columnName: string; displayName?: string }[];
@@ -2452,6 +2453,11 @@ export function TableSectionSettingsModal({
// 활성 탭
const [activeTab, setActiveTab] = useState("source");
+
+ // 사전 필터 카테고리 옵션 캐시 (컬럼명 -> 옵션 배열)
+ const [preFilterCategoryOptions, setPreFilterCategoryOptions] = useState<
+ Record
+ >({});
// open이 변경될 때마다 데이터 동기화
useEffect(() => {
@@ -2513,6 +2519,47 @@ export function TableSectionSettingsModal({
return tableColumns[tableConfig.source.tableName] || [];
}, [tableColumns, tableConfig.source.tableName]);
+ // 카테고리 옵션 로드 함수
+ const loadCategoryOptions = useCallback(async (columnName: string) => {
+ if (!tableConfig.source.tableName || !columnName) return;
+
+ // 이미 로드된 경우 스킵
+ if (preFilterCategoryOptions[columnName]) return;
+
+ try {
+ const response = await apiClient.get(
+ `/table-categories/${tableConfig.source.tableName}/${columnName}/values`
+ );
+ if (response.data?.success && response.data?.data) {
+ const options = response.data.data.map((item: any) => ({
+ // value는 DB에 저장된 실제 값(valueCode)을 사용해야 필터링이 정상 작동
+ value: item.valueCode || item.value_code || item.valueLabel || item.value_label || "",
+ // label은 사용자에게 보여질 라벨
+ label: item.valueLabel || item.value_label || item.valueCode || item.value_code || "",
+ }));
+ setPreFilterCategoryOptions((prev) => ({
+ ...prev,
+ [columnName]: options,
+ }));
+ }
+ } catch (error) {
+ console.error(`카테고리 옵션 로드 실패 (${columnName}):`, error);
+ }
+ }, [tableConfig.source.tableName, preFilterCategoryOptions]);
+
+ // 사전 필터에서 선택된 카테고리 컬럼들의 옵션 자동 로드
+ useEffect(() => {
+ const preFilters = tableConfig.filters?.preFilters || [];
+ preFilters.forEach((filter) => {
+ if (filter.column) {
+ const col = sourceTableColumns.find((c) => c.column_name === filter.column);
+ if (col && col.input_type === "category") {
+ loadCategoryOptions(filter.column);
+ }
+ }
+ });
+ }, [tableConfig.filters?.preFilters, sourceTableColumns, loadCategoryOptions]);
+
// 저장 테이블의 컬럼 목록
const saveTableColumns = useMemo(() => {
// 저장 테이블이 지정되어 있으면 해당 테이블의 컬럼, 아니면 소스 테이블의 컬럼 사용
@@ -3159,59 +3206,96 @@ export function TableSectionSettingsModal({
- {(tableConfig.filters?.preFilters || []).map((filter, index) => (
-
+ );
+ })}
From 40fd5f905564cddb75935ddab965d381aefba0e5 Mon Sep 17 00:00:00 2001
From: SeongHyun Kim
Date: Tue, 6 Jan 2026 13:06:28 +0900
Subject: [PATCH 39/95] =?UTF-8?q?feat:=20=EC=B1=84=EB=B2=88=EA=B7=9C?=
=?UTF-8?q?=EC=B9=99=20editable=20=EC=98=B5=EC=85=98=20=EC=88=98=EB=8F=99?=
=?UTF-8?q?=20=EB=AA=A8=EB=93=9C=20=EA=B0=90=EC=A7=80=20=EA=B8=B0=EB=8A=A5?=
=?UTF-8?q?=20=EA=B5=AC=ED=98=84=20=EB=AA=A8=EB=8B=AC=20=EC=98=A4=ED=94=88?=
=?UTF-8?q?=20=EC=8B=9C=20=EC=B1=84=EB=B2=88=20=EB=AF=B8=EB=A6=AC=EB=B3=B4?=
=?UTF-8?q?=EA=B8=B0=20=EC=9B=90=EB=B3=B8=EA=B0=92=20=EC=A0=80=EC=9E=A5=20?=
=?UTF-8?q?(numberingOriginalValues)=20handleFieldChange=EC=97=90=EC=84=9C?=
=?UTF-8?q?=20=EC=9B=90=EB=B3=B8=EA=B0=92=20=EB=B9=84=EA=B5=90=ED=95=98?=
=?UTF-8?q?=EC=97=AC=20=EC=88=98=EB=8F=99/=EC=9E=90=EB=8F=99=20=EB=AA=A8?=
=?UTF-8?q?=EB=93=9C=20=EC=A0=84=ED=99=98=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?=
=?UTF-8?q?=EC=88=98=EC=A0=95=20=EC=8B=9C=20ruleId=20=EC=A0=9C=EA=B1=B0?=
=?UTF-8?q?=ED=95=98=EC=97=AC=20=EC=A0=80=EC=9E=A5=20=EC=8B=9C=20=EC=B1=84?=
=?UTF-8?q?=EB=B2=88=20=EC=8A=A4=ED=82=B5=20=EC=9B=90=EB=B3=B8=EA=B0=92=20?=
=?UTF-8?q?=EB=B3=B5=EA=B5=AC=20=EC=8B=9C=20ruleId=20=EB=B3=B5=EA=B5=AC?=
=?UTF-8?q?=ED=95=98=EC=97=AC=20=EC=9E=90=EB=8F=99=20=EB=AA=A8=EB=93=9C=20?=
=?UTF-8?q?=EB=B3=B5=EC=9B=90=20handleSave=EC=97=90=EC=84=9C=20=EC=B1=84?=
=?UTF-8?q?=EB=B2=88=20=ED=95=A0=EB=8B=B9=20=EC=A1=B0=EA=B1=B4=20=EB=B6=84?=
=?UTF-8?q?=EA=B8=B0=20=EC=B2=98=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../UniversalFormModalComponent.tsx | 95 +++++++++++++++++--
1 file changed, 85 insertions(+), 10 deletions(-)
diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx
index 1484d4fd..312c3de6 100644
--- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx
@@ -197,6 +197,10 @@ export function UniversalFormModalComponent({
// 로딩 상태
const [saving, setSaving] = useState(false);
+ // 채번규칙 원본 값 추적 (수동 모드 감지용)
+ // key: columnName, value: 자동 생성된 원본 값
+ const [numberingOriginalValues, setNumberingOriginalValues] = useState>({});
+
// 🆕 수정 모드: 원본 그룹 데이터 (INSERT/UPDATE/DELETE 추적용)
const [originalGroupedData, setOriginalGroupedData] = useState([]);
const groupedDataInitializedRef = useRef(false);
@@ -457,16 +461,23 @@ export function UniversalFormModalComponent({
// generateOnOpen: 미리보기만 표시 (DB 시퀀스 증가 안 함)
const response = await previewNumberingCode(field.numberingRule.ruleId);
if (response.success && response.data?.generatedCode) {
- updatedData[field.columnName] = response.data.generatedCode;
+ const generatedCode = response.data.generatedCode;
+ updatedData[field.columnName] = generatedCode;
// 저장 시 실제 할당을 위해 ruleId 저장 (TextInput과 동일한 키 형식)
const ruleIdKey = `${field.columnName}_numberingRuleId`;
updatedData[ruleIdKey] = field.numberingRule.ruleId;
+ // 원본 채번 값 저장 (수동 모드 감지용)
+ setNumberingOriginalValues((prev) => ({
+ ...prev,
+ [field.columnName]: generatedCode,
+ }));
+
hasChanges = true;
numberingGeneratedRef.current = true; // 생성 완료 표시
console.log(
- `[채번 미리보기 완료] ${field.columnName} = ${response.data.generatedCode} (저장 시 실제 할당)`,
+ `[채번 미리보기 완료] ${field.columnName} = ${generatedCode} (저장 시 실제 할당)`,
);
console.log(`[채번 규칙 ID 저장] ${ruleIdKey} = ${field.numberingRule.ruleId}`);
@@ -694,8 +705,46 @@ export function UniversalFormModalComponent({
// 필드 값 변경 핸들러
const handleFieldChange = useCallback(
(columnName: string, value: any) => {
+ // 채번규칙 필드의 수동 모드 감지
+ const originalNumberingValue = numberingOriginalValues[columnName];
+ const ruleIdKey = `${columnName}_numberingRuleId`;
+
+ // 해당 필드의 채번규칙 설정 찾기
+ let fieldConfig: FormFieldConfig | undefined;
+ for (const section of config.sections) {
+ if (section.type === "table" || section.repeatable) continue;
+ fieldConfig = section.fields?.find((f) => f.columnName === columnName);
+ if (fieldConfig) break;
+ // 옵셔널 필드 그룹에서도 찾기
+ for (const group of section.optionalFieldGroups || []) {
+ fieldConfig = group.fields?.find((f) => f.columnName === columnName);
+ if (fieldConfig) break;
+ }
+ if (fieldConfig) break;
+ }
+
setFormData((prev) => {
const newData = { ...prev, [columnName]: value };
+
+ // 채번규칙이 활성화된 필드이고, "사용자 수정 가능"이 ON인 경우
+ if (
+ fieldConfig?.numberingRule?.enabled &&
+ fieldConfig?.numberingRule?.editable &&
+ originalNumberingValue
+ ) {
+ // 사용자가 값을 수정했으면 (원본과 다르면) ruleId 제거 → 수동 모드
+ if (value !== originalNumberingValue) {
+ delete newData[ruleIdKey];
+ console.log(`[채번 수동 모드] ${columnName}: 사용자가 값 수정 → ruleId 제거`);
+ } else {
+ // 원본 값으로 복구하면 ruleId 복구 → 자동 모드
+ if (fieldConfig.numberingRule.ruleId) {
+ newData[ruleIdKey] = fieldConfig.numberingRule.ruleId;
+ console.log(`[채번 자동 모드] ${columnName}: 원본 값 복구 → ruleId 복구`);
+ }
+ }
+ }
+
// onChange는 렌더링 외부에서 호출해야 함 (setTimeout 사용)
if (onChange) {
setTimeout(() => onChange(newData), 0);
@@ -703,7 +752,7 @@ export function UniversalFormModalComponent({
return newData;
});
},
- [onChange],
+ [onChange, numberingOriginalValues, config.sections],
);
// 반복 섹션 필드 값 변경 핸들러
@@ -975,19 +1024,45 @@ export function UniversalFormModalComponent({
}
});
- // 저장 시점 채번규칙 처리 (generateOnSave만 처리)
+ // 저장 시점 채번규칙 처리
for (const section of config.sections) {
// 테이블 타입 섹션은 건너뛰기
if (section.type === "table") continue;
for (const field of (section.fields || [])) {
- if (field.numberingRule?.enabled && field.numberingRule?.generateOnSave && field.numberingRule?.ruleId) {
- const response = await allocateNumberingCode(field.numberingRule.ruleId);
- if (response.success && response.data?.generatedCode) {
- dataToSave[field.columnName] = response.data.generatedCode;
- console.log(`[채번 할당] ${field.columnName} = ${response.data.generatedCode}`);
+ if (field.numberingRule?.enabled && field.numberingRule?.ruleId) {
+ const ruleIdKey = `${field.columnName}_numberingRuleId`;
+ const hasRuleId = dataToSave[ruleIdKey]; // 사용자가 수정하지 않았으면 ruleId 유지됨
+
+ // 채번 규칙 할당 조건
+ const shouldAllocate =
+ // 1. generateOnSave가 ON인 경우: 항상 저장 시점에 할당
+ field.numberingRule.generateOnSave ||
+ // 2. editable이 OFF인 경우: 사용자 입력 무시하고 채번 규칙으로 덮어씌움
+ !field.numberingRule.editable ||
+ // 3. editable이 ON이고 사용자가 수정하지 않은 경우 (ruleId 유지됨): 실제 번호 할당
+ (field.numberingRule.editable && hasRuleId);
+
+ if (shouldAllocate) {
+ const response = await allocateNumberingCode(field.numberingRule.ruleId);
+ if (response.success && response.data?.generatedCode) {
+ dataToSave[field.columnName] = response.data.generatedCode;
+ let reason = "(알 수 없음)";
+ if (field.numberingRule.generateOnSave) {
+ reason = "(generateOnSave)";
+ } else if (!field.numberingRule.editable) {
+ reason = "(editable=OFF, 강제 덮어씌움)";
+ } else if (hasRuleId) {
+ reason = "(editable=ON, 사용자 미수정)";
+ }
+ console.log(`[채번 할당] ${field.columnName} = ${response.data.generatedCode} ${reason}`);
+ } else {
+ console.error(`[채번 실패] ${field.columnName}:`, response.error);
+ }
} else {
- console.error(`[채번 실패] ${field.columnName}:`, response.error);
+ console.log(
+ `[채번 스킵] ${field.columnName}: 사용자가 직접 입력한 값 유지 = ${dataToSave[field.columnName]}`,
+ );
}
}
}
From cded99d64473e6414c3ded874932adaae6eec6bc Mon Sep 17 00:00:00 2001
From: kjs
Date: Tue, 6 Jan 2026 13:08:33 +0900
Subject: [PATCH 40/95] =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EC=A0=9C=EA=B1=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../app/(main)/screens/[screenId]/page.tsx | 2 +-
frontend/components/common/ScreenModal.tsx | 20 ++--
frontend/components/screen/EditModal.tsx | 58 ++++++------
.../button-primary/ButtonPrimaryComponent.tsx | 10 +-
.../entity-search-input/useEntitySearch.ts | 10 +-
.../RepeatScreenModalRenderer.tsx | 2 +-
.../SplitPanelLayoutComponent.tsx | 62 ++++++------
.../TableSectionRenderer.tsx | 10 +-
.../UniversalFormModalComponent.tsx | 94 +++++++++----------
frontend/lib/utils/buttonActions.ts | 10 +-
frontend/stores/modalDataStore.ts | 2 +-
11 files changed, 140 insertions(+), 140 deletions(-)
diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx
index dffbd75b..9e92bf2b 100644
--- a/frontend/app/(main)/screens/[screenId]/page.tsx
+++ b/frontend/app/(main)/screens/[screenId]/page.tsx
@@ -104,7 +104,7 @@ function ScreenViewPage() {
// 편집 모달 이벤트 리스너 등록
useEffect(() => {
const handleOpenEditModal = (event: CustomEvent) => {
- console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail);
+ // console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail);
setEditModalConfig({
screenId: event.detail.screenId,
diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx
index cceadae9..f41c62af 100644
--- a/frontend/components/common/ScreenModal.tsx
+++ b/frontend/components/common/ScreenModal.tsx
@@ -261,7 +261,7 @@ export const ScreenModal: React.FC = ({ className }) => {
// dataSourceId 파라미터 제거
currentUrl.searchParams.delete("dataSourceId");
window.history.pushState({}, "", currentUrl.toString());
- console.log("🧹 URL 파라미터 제거");
+ // console.log("🧹 URL 파라미터 제거");
}
setModalState({
@@ -277,7 +277,7 @@ export const ScreenModal: React.FC = ({ className }) => {
setSelectedData([]); // 🆕 선택된 데이터 초기화
setContinuousMode(false);
localStorage.setItem("screenModal_continuousMode", "false"); // localStorage에 저장
- console.log("🔄 연속 모드 초기화: false");
+ // console.log("🔄 연속 모드 초기화: false");
};
// 저장 성공 이벤트 처리 (연속 등록 모드 지원)
@@ -285,36 +285,36 @@ export const ScreenModal: React.FC = ({ className }) => {
// 🆕 모달이 열린 후 500ms 이내의 저장 성공 이벤트는 무시 (이전 이벤트 방지)
const timeSinceOpen = Date.now() - modalOpenedAtRef.current;
if (timeSinceOpen < 500) {
- console.log("⏭️ [ScreenModal] 모달 열린 직후 저장 성공 이벤트 무시:", { timeSinceOpen });
+ // console.log("⏭️ [ScreenModal] 모달 열린 직후 저장 성공 이벤트 무시:", { timeSinceOpen });
return;
}
const isContinuousMode = continuousMode;
- console.log("💾 저장 성공 이벤트 수신");
- console.log("📌 현재 연속 모드 상태:", isContinuousMode);
- console.log("📌 localStorage:", localStorage.getItem("screenModal_continuousMode"));
+ // console.log("💾 저장 성공 이벤트 수신");
+ // console.log("📌 현재 연속 모드 상태:", isContinuousMode);
+ // console.log("📌 localStorage:", localStorage.getItem("screenModal_continuousMode"));
if (isContinuousMode) {
// 연속 모드: 폼만 초기화하고 모달은 유지
- console.log("✅ 연속 모드 활성화 - 폼 초기화 및 화면 리셋");
+ // console.log("✅ 연속 모드 활성화 - 폼 초기화 및 화면 리셋");
// 1. 폼 데이터 초기화
setFormData({});
// 2. 리셋 키 변경 (컴포넌트 강제 리마운트)
setResetKey((prev) => prev + 1);
- console.log("🔄 resetKey 증가 - 컴포넌트 리마운트");
+ // console.log("🔄 resetKey 증가 - 컴포넌트 리마운트");
// 3. 화면 데이터 다시 로드 (채번 규칙 새로 생성)
if (modalState.screenId) {
- console.log("🔄 화면 데이터 다시 로드:", modalState.screenId);
+ // console.log("🔄 화면 데이터 다시 로드:", modalState.screenId);
loadScreenData(modalState.screenId);
}
toast.success("저장되었습니다. 계속 입력하세요.");
} else {
// 일반 모드: 모달 닫기
- console.log("❌ 일반 모드 - 모달 닫기");
+ // console.log("❌ 일반 모드 - 모달 닫기");
handleCloseModal();
}
};
diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx
index 58149088..32451d18 100644
--- a/frontend/components/screen/EditModal.tsx
+++ b/frontend/components/screen/EditModal.tsx
@@ -190,14 +190,14 @@ export const EditModal: React.FC = ({ className }) => {
const innerLayoutData = await screenApi.getLayout(section.screenId);
saveButton = findSaveButtonInComponents(innerLayoutData?.components || []);
if (saveButton) {
- console.log("[EditModal] 조건부 컨테이너 내부에서 저장 버튼 발견:", {
- sectionScreenId: section.screenId,
- sectionLabel: section.label,
- });
+ // console.log("[EditModal] 조건부 컨테이너 내부에서 저장 버튼 발견:", {
+ // sectionScreenId: section.screenId,
+ // sectionLabel: section.label,
+ // });
break;
}
} catch (innerError) {
- console.warn("[EditModal] 내부 화면 레이아웃 조회 실패:", section.screenId);
+ // console.warn("[EditModal] 내부 화면 레이아웃 조회 실패:", section.screenId);
}
}
}
@@ -207,7 +207,7 @@ export const EditModal: React.FC = ({ className }) => {
}
if (!saveButton) {
- console.log("[EditModal] 저장 버튼을 찾을 수 없음:", targetScreenId);
+ // console.log("[EditModal] 저장 버튼을 찾을 수 없음:", targetScreenId);
return null;
}
@@ -219,14 +219,14 @@ export const EditModal: React.FC = ({ className }) => {
dataflowConfig: webTypeConfig.dataflowConfig,
dataflowTiming: webTypeConfig.dataflowConfig?.flowConfig?.executionTiming || "after",
};
- console.log("[EditModal] 저장 버튼 제어로직 설정 발견:", config);
+ // console.log("[EditModal] 저장 버튼 제어로직 설정 발견:", config);
return config;
}
- console.log("[EditModal] 저장 버튼에 제어로직 설정 없음");
+ // console.log("[EditModal] 저장 버튼에 제어로직 설정 없음");
return null;
} catch (error) {
- console.warn("[EditModal] 저장 버튼 설정 조회 실패:", error);
+ // console.warn("[EditModal] 저장 버튼 설정 조회 실패:", error);
return null;
}
};
@@ -309,16 +309,16 @@ export const EditModal: React.FC = ({ className }) => {
// 🆕 그룹 데이터 조회 함수
const loadGroupData = async () => {
if (!modalState.tableName || !modalState.groupByColumns || modalState.groupByColumns.length === 0) {
- console.warn("테이블명 또는 그룹핑 컬럼이 없습니다.");
+ // console.warn("테이블명 또는 그룹핑 컬럼이 없습니다.");
return;
}
try {
- console.log("🔍 그룹 데이터 조회 시작:", {
- tableName: modalState.tableName,
- groupByColumns: modalState.groupByColumns,
- editData: modalState.editData,
- });
+ // console.log("🔍 그룹 데이터 조회 시작:", {
+ // tableName: modalState.tableName,
+ // groupByColumns: modalState.groupByColumns,
+ // editData: modalState.editData,
+ // });
// 그룹핑 컬럼 값 추출 (예: order_no = "ORD-20251124-001")
const groupValues: Record = {};
@@ -329,14 +329,14 @@ export const EditModal: React.FC = ({ className }) => {
});
if (Object.keys(groupValues).length === 0) {
- console.warn("그룹핑 컬럼 값이 없습니다:", modalState.groupByColumns);
+ // console.warn("그룹핑 컬럼 값이 없습니다:", modalState.groupByColumns);
return;
}
- console.log("🔍 그룹 조회 요청:", {
- tableName: modalState.tableName,
- groupValues,
- });
+ // console.log("🔍 그룹 조회 요청:", {
+ // tableName: modalState.tableName,
+ // groupValues,
+ // });
// 같은 그룹의 모든 레코드 조회 (entityJoinApi 사용)
const { entityJoinApi } = await import("@/lib/api/entityJoin");
@@ -347,13 +347,13 @@ export const EditModal: React.FC = ({ className }) => {
enableEntityJoin: true,
});
- console.log("🔍 그룹 조회 응답:", response);
+ // console.log("🔍 그룹 조회 응답:", response);
// entityJoinApi는 배열 또는 { data: [] } 형식으로 반환
const dataArray = Array.isArray(response) ? response : response?.data || [];
if (dataArray.length > 0) {
- console.log("✅ 그룹 데이터 조회 성공:", dataArray.length, "건");
+ // console.log("✅ 그룹 데이터 조회 성공:", dataArray.length, "건");
setGroupData(dataArray);
setOriginalGroupData(JSON.parse(JSON.stringify(dataArray))); // Deep copy
toast.info(`${dataArray.length}개의 관련 품목을 불러왔습니다.`);
@@ -374,7 +374,7 @@ export const EditModal: React.FC = ({ className }) => {
try {
setLoading(true);
- console.log("화면 데이터 로딩 시작:", screenId);
+ // console.log("화면 데이터 로딩 시작:", screenId);
// 화면 정보와 레이아웃 데이터 로딩
const [screenInfo, layoutData] = await Promise.all([
@@ -382,7 +382,7 @@ export const EditModal: React.FC = ({ className }) => {
screenApi.getLayout(screenId),
]);
- console.log("API 응답:", { screenInfo, layoutData });
+ // console.log("API 응답:", { screenInfo, layoutData });
if (screenInfo && layoutData) {
const components = layoutData.components || [];
@@ -395,11 +395,11 @@ export const EditModal: React.FC = ({ className }) => {
components,
screenInfo: screenInfo,
});
- console.log("화면 데이터 설정 완료:", {
- componentsCount: components.length,
- dimensions,
- screenInfo,
- });
+ // console.log("화면 데이터 설정 완료:", {
+ // componentsCount: components.length,
+ // dimensions,
+ // screenInfo,
+ // });
} else {
throw new Error("화면 데이터가 없습니다");
}
diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx
index a71f6e03..86155bd6 100644
--- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx
+++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx
@@ -306,11 +306,11 @@ export const ButtonPrimaryComponent: React.FC = ({
useEffect(() => {
const newData = splitPanelContext?.selectedLeftData ?? null;
setTrackedSelectedLeftData(newData);
- console.log("🔄 [ButtonPrimary] selectedLeftData 변경 감지:", {
- label: component.label,
- hasData: !!newData,
- dataKeys: newData ? Object.keys(newData) : [],
- });
+ // console.log("🔄 [ButtonPrimary] selectedLeftData 변경 감지:", {
+ // label: component.label,
+ // hasData: !!newData,
+ // dataKeys: newData ? Object.keys(newData) : [],
+ // });
}, [splitPanelContext?.selectedLeftData, component.label]);
// modalDataStore 상태 구독 (실시간 업데이트)
diff --git a/frontend/lib/registry/components/entity-search-input/useEntitySearch.ts b/frontend/lib/registry/components/entity-search-input/useEntitySearch.ts
index 2ae71595..a118cffe 100644
--- a/frontend/lib/registry/components/entity-search-input/useEntitySearch.ts
+++ b/frontend/lib/registry/components/entity-search-input/useEntitySearch.ts
@@ -53,11 +53,11 @@ export function useEntitySearch({
limit: pagination.limit.toString(),
});
- console.log("[useEntitySearch] 검색 실행:", {
- tableName,
- filterCondition: filterConditionRef.current,
- searchText: text,
- });
+ // console.log("[useEntitySearch] 검색 실행:", {
+ // tableName,
+ // filterCondition: filterConditionRef.current,
+ // searchText: text,
+ // });
const response = await apiClient.get(
`/entity-search/${tableName}?${params.toString()}`
diff --git a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalRenderer.tsx b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalRenderer.tsx
index b43722d3..814e3545 100644
--- a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalRenderer.tsx
+++ b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalRenderer.tsx
@@ -6,7 +6,7 @@ import { RepeatScreenModalDefinition } from "./index";
// 컴포넌트 자동 등록
if (typeof window !== "undefined") {
ComponentRegistry.registerComponent(RepeatScreenModalDefinition);
- console.log("✅ RepeatScreenModal 컴포넌트 등록 완료");
+ // console.log("✅ RepeatScreenModal 컴포넌트 등록 완료");
}
export {};
diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx
index bfb26c90..ad7f5302 100644
--- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx
+++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx
@@ -205,12 +205,12 @@ export const SplitPanelLayoutComponent: React.FC
const splitPanelId = `split-panel-${component.id}`;
// 디버깅: Context 연결 상태 확인
- console.log("🔗 [SplitPanelLayout] Context 연결 상태:", {
- componentId: component.id,
- splitPanelId,
- hasRegisterFunc: typeof ctxRegisterSplitPanel === "function",
- splitPanelsSize: splitPanelContext.splitPanels?.size ?? "없음",
- });
+ // console.log("🔗 [SplitPanelLayout] Context 연결 상태:", {
+ // componentId: component.id,
+ // splitPanelId,
+ // hasRegisterFunc: typeof ctxRegisterSplitPanel === "function",
+ // splitPanelsSize: splitPanelContext.splitPanels?.size ?? "없음",
+ // });
// Context에 분할 패널 등록 (좌표 정보 포함) - 마운트 시 1회만 실행
const ctxRegisterRef = useRef(ctxRegisterSplitPanel);
@@ -235,15 +235,15 @@ export const SplitPanelLayoutComponent: React.FC
isDragging: false,
};
- console.log("📦 [SplitPanelLayout] Context에 분할 패널 등록:", {
- splitPanelId,
- panelInfo,
- });
+ // console.log("📦 [SplitPanelLayout] Context에 분할 패널 등록:", {
+ // splitPanelId,
+ // panelInfo,
+ // });
ctxRegisterRef.current(splitPanelId, panelInfo);
return () => {
- console.log("📦 [SplitPanelLayout] Context에서 분할 패널 해제:", splitPanelId);
+ // console.log("📦 [SplitPanelLayout] Context에서 분할 패널 해제:", splitPanelId);
ctxUnregisterRef.current(splitPanelId);
};
// 마운트/언마운트 시에만 실행, 위치/크기 변경은 별도 업데이트로 처리
@@ -311,11 +311,11 @@ export const SplitPanelLayoutComponent: React.FC
// 🆕 그룹별 합산된 데이터 계산
const summedLeftData = useMemo(() => {
- console.log("🔍 [그룹합산] leftGroupSumConfig:", leftGroupSumConfig);
+ // console.log("🔍 [그룹합산] leftGroupSumConfig:", leftGroupSumConfig);
// 그룹핑이 비활성화되었거나 그룹 기준 컬럼이 없으면 원본 데이터 반환
if (!leftGroupSumConfig?.enabled || !leftGroupSumConfig?.groupByColumn) {
- console.log("🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환");
+ // console.log("🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환");
return leftData;
}
@@ -756,8 +756,8 @@ export const SplitPanelLayoutComponent: React.FC
}
});
- console.log("🔗 [분할패널] additionalJoinColumns:", additionalJoinColumns);
- console.log("🔗 [분할패널] configuredColumns:", configuredColumns);
+ // console.log("🔗 [분할패널] additionalJoinColumns:", additionalJoinColumns);
+ // console.log("🔗 [분할패널] configuredColumns:", configuredColumns);
const result = await entityJoinApi.getTableDataWithJoins(leftTableName, {
page: 1,
@@ -769,10 +769,10 @@ export const SplitPanelLayoutComponent: React.FC
});
// 🔍 디버깅: API 응답 데이터의 키 확인
- if (result.data && result.data.length > 0) {
- console.log("🔗 [분할패널] API 응답 첫 번째 데이터 키:", Object.keys(result.data[0]));
- console.log("🔗 [분할패널] API 응답 첫 번째 데이터:", result.data[0]);
- }
+ // if (result.data && result.data.length > 0) {
+ // console.log("🔗 [분할패널] API 응답 첫 번째 데이터 키:", Object.keys(result.data[0]));
+ // console.log("🔗 [분할패널] API 응답 첫 번째 데이터:", result.data[0]);
+ // }
// 가나다순 정렬 (좌측 패널의 표시 컬럼 기준)
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
@@ -1000,7 +1000,7 @@ export const SplitPanelLayoutComponent: React.FC
if (leftTableName && !isDesignMode) {
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
useModalDataStore.getState().setData(leftTableName, [item]);
- console.log(`✅ 분할 패널 좌측 선택: ${leftTableName}`, item);
+ // console.log(`✅ 분할 패널 좌측 선택: ${leftTableName}`, item);
});
}
},
@@ -1198,7 +1198,7 @@ export const SplitPanelLayoutComponent: React.FC
}
});
setLeftColumnLabels(labels);
- console.log("✅ 좌측 컬럼 라벨 로드:", labels);
+ // console.log("✅ 좌측 컬럼 라벨 로드:", labels);
} catch (error) {
console.error("좌측 테이블 컬럼 라벨 로드 실패:", error);
}
@@ -1227,7 +1227,7 @@ export const SplitPanelLayoutComponent: React.FC
}
});
setRightColumnLabels(labels);
- console.log("✅ 우측 컬럼 라벨 로드:", labels);
+ // console.log("✅ 우측 컬럼 라벨 로드:", labels);
} catch (error) {
console.error("우측 테이블 컬럼 정보 로드 실패:", error);
}
@@ -1269,7 +1269,7 @@ export const SplitPanelLayoutComponent: React.FC
};
});
mappings[columnName] = valueMap;
- console.log(`✅ 좌측 카테고리 매핑 로드 [${columnName}]:`, valueMap);
+ // console.log(`✅ 좌측 카테고리 매핑 로드 [${columnName}]:`, valueMap);
}
} catch (error) {
console.error(`좌측 카테고리 값 조회 실패 [${columnName}]:`, error);
@@ -1307,7 +1307,7 @@ export const SplitPanelLayoutComponent: React.FC
}
});
- console.log("🔍 우측 패널 카테고리 로드 대상 테이블:", Array.from(tablesToLoad));
+ // console.log("🔍 우측 패널 카테고리 로드 대상 테이블:", Array.from(tablesToLoad));
// 각 테이블에 대해 카테고리 매핑 로드
for (const tableName of tablesToLoad) {
@@ -1940,7 +1940,7 @@ export const SplitPanelLayoutComponent: React.FC
useEffect(() => {
const handleRefreshTable = () => {
if (!isDesignMode) {
- console.log("🔄 [SplitPanel] refreshTable 이벤트 수신 - 데이터 새로고침");
+ // console.log("🔄 [SplitPanel] refreshTable 이벤트 수신 - 데이터 새로고침");
loadLeftData();
// 선택된 항목이 있으면 우측 패널도 새로고침
if (selectedLeftItem) {
@@ -2104,12 +2104,12 @@ export const SplitPanelLayoutComponent: React.FC
(() => {
// 🆕 그룹별 합산된 데이터 사용
const dataSource = summedLeftData;
- console.log(
- "🔍 [테이블모드 렌더링] dataSource 개수:",
- dataSource.length,
- "leftGroupSumConfig:",
- leftGroupSumConfig,
- );
+ // console.log(
+ // "🔍 [테이블모드 렌더링] dataSource 개수:",
+ // dataSource.length,
+ // "leftGroupSumConfig:",
+ // leftGroupSumConfig,
+ // );
// 🔧 로컬 검색 필터 적용
const filteredData = leftSearchQuery
diff --git a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx
index fb1b2ea3..b4c6d2d8 100644
--- a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx
@@ -820,10 +820,10 @@ export function TableSectionRenderer({
const initialData = formData[tableSectionKey];
if (Array.isArray(initialData) && initialData.length > 0) {
- console.log("[TableSectionRenderer] 초기 데이터 로드:", {
- sectionId,
- itemCount: initialData.length,
- });
+ // console.log("[TableSectionRenderer] 초기 데이터 로드:", {
+ // sectionId,
+ // itemCount: initialData.length,
+ // });
setTableData(initialData);
initialDataLoadedRef.current = true;
@@ -1327,7 +1327,7 @@ export function TableSectionRenderer({
}
}
}
- console.log("[TableSectionRenderer] baseFilterCondition:", condition, "preFilters:", filters?.preFilters);
+ // console.log("[TableSectionRenderer] baseFilterCondition:", condition, "preFilters:", filters?.preFilters);
return condition;
}, [filters?.preFilters]);
diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx
index 5f087b71..3ba30347 100644
--- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx
@@ -216,11 +216,11 @@ export function UniversalFormModalComponent({
// 초기화 - 최초 마운트 시 또는 initialData가 변경되었을 때 실행
useEffect(() => {
- console.log("[UniversalFormModal] useEffect 시작", {
- initialData,
- hasInitialized: hasInitialized.current,
- lastInitializedId: lastInitializedId.current,
- });
+ // console.log("[UniversalFormModal] useEffect 시작", {
+ // initialData,
+ // hasInitialized: hasInitialized.current,
+ // lastInitializedId: lastInitializedId.current,
+ // });
// initialData에서 ID 값 추출 (id, ID, objid 등)
const currentId = initialData?.id || initialData?.ID || initialData?.objid;
@@ -235,7 +235,7 @@ export function UniversalFormModalComponent({
if (hasInitialized.current && lastInitializedId.current === currentIdString) {
// 생성 모드에서 데이터가 새로 전달된 경우는 재초기화 필요
if (!createModeDataHash || capturedInitialData.current) {
- console.log("[UniversalFormModal] 초기화 스킵 - 이미 초기화됨");
+ // console.log("[UniversalFormModal] 초기화 스킵 - 이미 초기화됨");
// 🆕 채번 플래그가 true인데 formData에 값이 없으면 재생성 필요
// (컴포넌트 remount로 인해 state가 초기화된 경우)
return;
@@ -245,18 +245,18 @@ export function UniversalFormModalComponent({
// 🆕 컴포넌트 remount 감지: hasInitialized가 true인데 formData가 비어있으면 재초기화
// (React의 Strict Mode나 EmbeddedScreen 리렌더링으로 인한 remount)
if (hasInitialized.current && !currentIdString) {
- console.log("[UniversalFormModal] 컴포넌트 remount 감지 - 채번 플래그 초기화");
+ // console.log("[UniversalFormModal] 컴포넌트 remount 감지 - 채번 플래그 초기화");
numberingGeneratedRef.current = false;
isGeneratingRef.current = false;
}
// 🆕 수정 모드: initialData에 데이터가 있으면서 ID가 변경된 경우 재초기화
if (hasInitialized.current && currentIdString && lastInitializedId.current !== currentIdString) {
- console.log("[UniversalFormModal] ID 변경 감지 - 재초기화:", {
- prevId: lastInitializedId.current,
- newId: currentIdString,
- initialData: initialData,
- });
+ // console.log("[UniversalFormModal] ID 변경 감지 - 재초기화:", {
+ // prevId: lastInitializedId.current,
+ // newId: currentIdString,
+ // initialData: initialData,
+ // });
// 채번 플래그 초기화 (새 항목이므로)
numberingGeneratedRef.current = false;
isGeneratingRef.current = false;
@@ -266,10 +266,10 @@ export function UniversalFormModalComponent({
if (initialData && Object.keys(initialData).length > 0) {
capturedInitialData.current = JSON.parse(JSON.stringify(initialData)); // 깊은 복사
lastInitializedId.current = currentIdString;
- console.log("[UniversalFormModal] 초기 데이터 캡처:", capturedInitialData.current);
+ // console.log("[UniversalFormModal] 초기 데이터 캡처:", capturedInitialData.current);
}
- console.log("[UniversalFormModal] initializeForm 호출 예정");
+ // console.log("[UniversalFormModal] initializeForm 호출 예정");
hasInitialized.current = true;
initializeForm();
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -279,7 +279,7 @@ export function UniversalFormModalComponent({
useEffect(() => {
if (!hasInitialized.current) return; // 최초 초기화 전이면 스킵
- console.log("[useEffect config 변경] 재초기화 스킵 (채번 중복 방지)");
+ // console.log("[useEffect config 변경] 재초기화 스킵 (채번 중복 방지)");
// initializeForm(); // 주석 처리 - config 변경 시 재초기화 안 함 (채번 중복 방지)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]);
@@ -287,7 +287,7 @@ export function UniversalFormModalComponent({
// 컴포넌트 unmount 시 채번 플래그 초기화
useEffect(() => {
return () => {
- console.log("[채번] 컴포넌트 unmount - 플래그 초기화");
+ // console.log("[채번] 컴포넌트 unmount - 플래그 초기화");
numberingGeneratedRef.current = false;
isGeneratingRef.current = false;
};
@@ -359,14 +359,14 @@ export function UniversalFormModalComponent({
// 테이블 타입 섹션 찾기
const tableSection = config.sections.find((s) => s.type === "table");
if (!tableSection) {
- console.log("[UniversalFormModal] 테이블 섹션 없음 - _groupedData 무시");
+ // console.log("[UniversalFormModal] 테이블 섹션 없음 - _groupedData 무시");
return;
}
- console.log("[UniversalFormModal] 수정 모드 - 테이블 섹션 초기화:", {
- sectionId: tableSection.id,
- itemCount: _groupedData.length,
- });
+ // console.log("[UniversalFormModal] 수정 모드 - 테이블 섹션 초기화:", {
+ // sectionId: tableSection.id,
+ // itemCount: _groupedData.length,
+ // });
// 원본 데이터 저장 (수정/삭제 추적용)
setOriginalGroupedData(JSON.parse(JSON.stringify(_groupedData)));
@@ -421,31 +421,31 @@ export function UniversalFormModalComponent({
}
if (isGeneratingRef.current) {
- console.log("[채번] 생성 진행 중 - 스킵");
+ // console.log("[채번] 생성 진행 중 - 스킵");
return;
}
isGeneratingRef.current = true; // 진행 중 표시
- console.log("[채번] 생성 시작", { sectionsCount: config.sections.length });
+ // console.log("[채번] 생성 시작", { sectionsCount: config.sections.length });
const updatedData = { ...currentFormData };
let hasChanges = false;
for (const section of config.sections) {
- console.log("[채번] 섹션 검사:", section.title, { type: section.type, repeatable: section.repeatable, fieldsCount: section.fields?.length });
+ // console.log("[채번] 섹션 검사:", section.title, { type: section.type, repeatable: section.repeatable, fieldsCount: section.fields?.length });
if (section.repeatable || section.type === "table") continue;
for (const field of (section.fields || [])) {
// generateOnOpen은 기본값 true (undefined일 경우 true로 처리)
const shouldGenerateOnOpen = field.numberingRule?.generateOnOpen !== false;
- console.log("[채번] 필드 검사:", field.columnName, {
- hasNumberingRule: !!field.numberingRule,
- enabled: field.numberingRule?.enabled,
- generateOnOpen: field.numberingRule?.generateOnOpen,
- shouldGenerateOnOpen,
- ruleId: field.numberingRule?.ruleId,
- currentValue: updatedData[field.columnName],
- });
+ // console.log("[채번] 필드 검사:", field.columnName, {
+ // hasNumberingRule: !!field.numberingRule,
+ // enabled: field.numberingRule?.enabled,
+ // generateOnOpen: field.numberingRule?.generateOnOpen,
+ // shouldGenerateOnOpen,
+ // ruleId: field.numberingRule?.ruleId,
+ // currentValue: updatedData[field.columnName],
+ // });
if (
field.numberingRule?.enabled &&
shouldGenerateOnOpen &&
@@ -453,7 +453,7 @@ export function UniversalFormModalComponent({
!updatedData[field.columnName]
) {
try {
- console.log(`[채번 미리보기 API 호출] ${field.columnName}, ruleId: ${field.numberingRule.ruleId}`);
+ // console.log(`[채번 미리보기 API 호출] ${field.columnName}, ruleId: ${field.numberingRule.ruleId}`);
// generateOnOpen: 미리보기만 표시 (DB 시퀀스 증가 안 함)
const response = await previewNumberingCode(field.numberingRule.ruleId);
if (response.success && response.data?.generatedCode) {
@@ -465,10 +465,10 @@ export function UniversalFormModalComponent({
hasChanges = true;
numberingGeneratedRef.current = true; // 생성 완료 표시
- console.log(
- `[채번 미리보기 완료] ${field.columnName} = ${response.data.generatedCode} (저장 시 실제 할당)`,
- );
- console.log(`[채번 규칙 ID 저장] ${ruleIdKey} = ${field.numberingRule.ruleId}`);
+ // console.log(
+ // `[채번 미리보기 완료] ${field.columnName} = ${response.data.generatedCode} (저장 시 실제 할당)`,
+ // );
+ // console.log(`[채번 규칙 ID 저장] ${ruleIdKey} = ${field.numberingRule.ruleId}`);
// 부모 컴포넌트에도 ruleId 전달 (ModalRepeaterTable → ScreenModal)
if (onChange) {
@@ -476,7 +476,7 @@ export function UniversalFormModalComponent({
...updatedData,
[ruleIdKey]: field.numberingRule.ruleId,
});
- console.log(`[채번] 부모에게 ruleId 전달: ${ruleIdKey}`);
+ // console.log(`[채번] 부모에게 ruleId 전달: ${ruleIdKey}`);
}
}
} catch (error) {
@@ -497,17 +497,17 @@ export function UniversalFormModalComponent({
// 폼 초기화
const initializeForm = useCallback(async () => {
- console.log("[initializeForm] 시작");
+ // console.log("[initializeForm] 시작");
// 캡처된 initialData 사용 (props로 전달된 initialData가 아닌)
const effectiveInitialData = capturedInitialData.current || initialData;
- console.log("[initializeForm] 초기 데이터:", {
- capturedInitialData: capturedInitialData.current,
- initialData: initialData,
- effectiveInitialData: effectiveInitialData,
- hasData: effectiveInitialData && Object.keys(effectiveInitialData).length > 0,
- });
+ // console.log("[initializeForm] 초기 데이터:", {
+ // capturedInitialData: capturedInitialData.current,
+ // initialData: initialData,
+ // effectiveInitialData: effectiveInitialData,
+ // hasData: effectiveInitialData && Object.keys(effectiveInitialData).length > 0,
+ // });
const newFormData: FormDataState = {};
const newRepeatSections: { [sectionId: string]: RepeatSectionItem[] } = {};
@@ -671,9 +671,9 @@ export function UniversalFormModalComponent({
}
// 채번규칙 자동 생성
- console.log("[initializeForm] generateNumberingValues 호출");
+ // console.log("[initializeForm] generateNumberingValues 호출");
await generateNumberingValues(newFormData);
- console.log("[initializeForm] 완료");
+ // console.log("[initializeForm] 완료");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]); // initialData는 의존성에서 제거 (capturedInitialData.current 사용)
diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts
index 681e9a3f..53373d41 100644
--- a/frontend/lib/utils/buttonActions.ts
+++ b/frontend/lib/utils/buttonActions.ts
@@ -3022,11 +3022,11 @@ export class ButtonActionExecutor {
comp.componentType === "split-panel-layout",
);
}
- console.log("🔍 [openEditModal] 분할 패널 확인:", {
- targetScreenId: config.targetScreenId,
- hasSplitPanel,
- componentTypes: layoutData?.components?.map((c: any) => c.type || c.componentType) || [],
- });
+ // console.log("🔍 [openEditModal] 분할 패널 확인:", {
+ // targetScreenId: config.targetScreenId,
+ // hasSplitPanel,
+ // componentTypes: layoutData?.components?.map((c: any) => c.type || c.componentType) || [],
+ // });
} catch (error) {
console.warn("레이아웃 정보를 가져오지 못했습니다:", error);
}
diff --git a/frontend/stores/modalDataStore.ts b/frontend/stores/modalDataStore.ts
index eb6a2d11..6b2b0f70 100644
--- a/frontend/stores/modalDataStore.ts
+++ b/frontend/stores/modalDataStore.ts
@@ -77,7 +77,7 @@ export const useModalDataStore = create()(
dataRegistry: {},
setData: (sourceId, items) => {
- console.log("📦 [ModalDataStore] 데이터 저장:", { sourceId, itemCount: items.length, items });
+ // console.log("📦 [ModalDataStore] 데이터 저장:", { sourceId, itemCount: items.length, items });
set((state) => ({
dataRegistry: {
...state.dataRegistry,
From eb61506acd4cc3304d54717113b35f5e90cb76d2 Mon Sep 17 00:00:00 2001
From: kjs
Date: Tue, 6 Jan 2026 13:43:47 +0900
Subject: [PATCH 41/95] =?UTF-8?q?=EB=B6=84=ED=95=A0=ED=8C=A8=EB=84=90=20?=
=?UTF-8?q?=EC=88=98=EC=A0=95=EB=8F=99=EC=9E=91=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../controllers/tableManagementController.ts | 10 +-
.../TableSectionRenderer.tsx | 19 +-
.../UniversalFormModalComponent.tsx | 219 ++++++++++++++++++
3 files changed, 242 insertions(+), 6 deletions(-)
diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts
index 7c84898b..83384be6 100644
--- a/backend-node/src/controllers/tableManagementController.ts
+++ b/backend-node/src/controllers/tableManagementController.ts
@@ -775,7 +775,8 @@ export async function getTableData(
const userField = autoFilter?.userField || "companyCode";
const userValue = (req.user as any)[userField];
- if (userValue) {
+ // 🆕 최고 관리자(company_code = '*')는 모든 회사 데이터 조회 가능
+ if (userValue && userValue !== "*") {
enhancedSearch[filterColumn] = userValue;
logger.info("🔍 현재 사용자 필터 적용:", {
@@ -784,6 +785,10 @@ export async function getTableData(
userValue,
tableName,
});
+ } else if (userValue === "*") {
+ logger.info("🔓 최고 관리자 - 회사 필터 미적용 (모든 회사 데이터 조회)", {
+ tableName,
+ });
} else {
logger.warn("⚠️ 사용자 정보 필드 값 없음:", {
userField,
@@ -792,6 +797,9 @@ export async function getTableData(
}
}
+ // 🆕 최종 검색 조건 로그
+ logger.info(`🔍 최종 검색 조건 (enhancedSearch):`, JSON.stringify(enhancedSearch));
+
// 데이터 조회
const result = await tableManagementService.getTableData(tableName, {
page: parseInt(page),
diff --git a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx
index b4c6d2d8..9c238b47 100644
--- a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx
@@ -816,14 +816,23 @@ export function TableSectionRenderer({
// 이미 초기화되었으면 스킵
if (initialDataLoadedRef.current) return;
- const tableSectionKey = `_tableSection_${sectionId}`;
+ const tableSectionKey = `__tableSection_${sectionId}`;
const initialData = formData[tableSectionKey];
+ console.log("[TableSectionRenderer] 초기 데이터 확인:", {
+ sectionId,
+ tableSectionKey,
+ hasInitialData: !!initialData,
+ initialDataLength: Array.isArray(initialData) ? initialData.length : 0,
+ formDataKeys: Object.keys(formData).filter(k => k.startsWith("__tableSection_")),
+ });
+
if (Array.isArray(initialData) && initialData.length > 0) {
- // console.log("[TableSectionRenderer] 초기 데이터 로드:", {
- // sectionId,
- // itemCount: initialData.length,
- // });
+ console.log("[TableSectionRenderer] 초기 데이터 로드:", {
+ sectionId,
+ itemCount: initialData.length,
+ firstItem: initialData[0],
+ });
setTableData(initialData);
initialDataLoadedRef.current = true;
diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx
index 3ba30347..a605c45e 100644
--- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx
@@ -588,6 +588,225 @@ export function UniversalFormModalComponent({
}
}
+ // 🆕 테이블 섹션(type: "table") 디테일 데이터 로드 (마스터-디테일 구조)
+ // 수정 모드일 때 디테일 테이블에서 데이터 가져오기
+ if (effectiveInitialData) {
+ console.log("[initializeForm] 테이블 섹션 디테일 로드 시작", {
+ sectionsCount: config.sections.length,
+ effectiveInitialDataKeys: Object.keys(effectiveInitialData),
+ });
+
+ for (const section of config.sections) {
+ if (section.type !== "table" || !section.tableConfig) {
+ continue;
+ }
+
+ const tableConfig = section.tableConfig;
+ const editConfig = tableConfig.editConfig;
+ const saveConfig = tableConfig.saveConfig;
+
+ console.log(`[initializeForm] 테이블 섹션 ${section.id} 검사:`, {
+ hasEditConfig: !!editConfig,
+ loadOnEdit: editConfig?.loadOnEdit,
+ hasSaveConfig: !!saveConfig,
+ targetTable: saveConfig?.targetTable,
+ linkColumn: editConfig?.linkColumn,
+ });
+
+ // 수정 모드 로드 설정 확인 (기본값: true)
+ if (editConfig?.loadOnEdit === false) {
+ console.log(`[initializeForm] 테이블 섹션 ${section.id}: loadOnEdit=false, 스킵`);
+ continue;
+ }
+
+ // 디테일 테이블과 연결 정보 확인
+ const detailTable = saveConfig?.targetTable;
+ let linkColumn = editConfig?.linkColumn;
+
+ if (!detailTable) {
+ console.log(`[initializeForm] 테이블 섹션 ${section.id}: saveConfig.targetTable 미설정, 스킵`);
+ continue;
+ }
+
+ // linkColumn이 설정되지 않았으면, 디테일 테이블 컬럼 정보 조회하여 자동 감지
+ if (!linkColumn?.masterField || !linkColumn?.detailField) {
+ try {
+ // 마스터 테이블명 확인 (saveConfig에서)
+ // 1. customApiSave.multiTable.mainTable.tableName (다중 테이블 저장)
+ // 2. saveConfig.tableName (단일 테이블 저장)
+ const masterTable = config.saveConfig?.customApiSave?.multiTable?.mainTable?.tableName
+ || config.saveConfig?.tableName;
+
+ // 디테일 테이블의 컬럼 목록 조회
+ const columnsResponse = await apiClient.get(`/table-management/tables/${detailTable}/columns`);
+
+ if (columnsResponse.data?.success && columnsResponse.data?.data) {
+ // API 응답 구조: { success, data: { columns: [...], total, page, ... } }
+ const columnsArray = columnsResponse.data.data.columns || columnsResponse.data.data || [];
+ const detailColumnsData = Array.isArray(columnsArray) ? columnsArray : [];
+ const detailColumns = detailColumnsData.map((col: any) => col.column_name || col.columnName);
+ const masterKeys = Object.keys(effectiveInitialData);
+
+ console.log(`[initializeForm] 테이블 섹션 ${section.id}: 연결 필드 자동 감지`, {
+ masterTable,
+ detailTable,
+ detailColumnsCount: detailColumnsData.length,
+ });
+
+ // 방법 1: 엔티티 관계 기반 감지 (정확)
+ // 디테일 테이블에서 마스터 테이블을 참조하는 엔티티 컬럼 찾기
+ if (masterTable) {
+ for (const col of detailColumnsData) {
+ const colName = col.column_name || col.columnName;
+ const inputType = col.input_type || col.inputType;
+
+ // 엔티티 타입 컬럼 확인
+ if (inputType === "entity") {
+ // reference_table 또는 detail_settings에서 참조 테이블 확인
+ let refTable = col.reference_table || col.referenceTable;
+
+ // detail_settings에서 referenceTable 확인
+ if (!refTable && col.detail_settings) {
+ try {
+ const settings = typeof col.detail_settings === "string"
+ ? JSON.parse(col.detail_settings)
+ : col.detail_settings;
+ refTable = settings.referenceTable;
+ } catch {
+ // JSON 파싱 실패 무시
+ }
+ }
+
+ // 마스터 테이블을 참조하는 컬럼 발견
+ if (refTable === masterTable) {
+ // 참조 컬럼 확인 (마스터 테이블의 어떤 컬럼을 참조하는지)
+ let refColumn = col.reference_column || col.referenceColumn;
+ if (!refColumn && col.detail_settings) {
+ try {
+ const settings = typeof col.detail_settings === "string"
+ ? JSON.parse(col.detail_settings)
+ : col.detail_settings;
+ refColumn = settings.referenceColumn;
+ } catch {
+ // JSON 파싱 실패 무시
+ }
+ }
+
+ // 마스터 데이터에 해당 컬럼 값이 있는지 확인
+ if (refColumn && effectiveInitialData[refColumn]) {
+ console.log(`[initializeForm] 테이블 섹션 ${section.id}: 엔티티 관계 감지 - ${colName} → ${masterTable}.${refColumn}`);
+ linkColumn = { masterField: refColumn, detailField: colName };
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ // 방법 2: 공통 컬럼 패턴 기반 감지 (폴백)
+ // 엔티티 관계가 없으면 공통 컬럼명 패턴으로 찾기
+ if (!linkColumn) {
+ const priorityPatterns = ["_no", "_number", "_code", "_id"];
+
+ for (const pattern of priorityPatterns) {
+ for (const masterKey of masterKeys) {
+ if (masterKey.endsWith(pattern) &&
+ detailColumns.includes(masterKey) &&
+ effectiveInitialData[masterKey] &&
+ masterKey !== "id" && masterKey !== "company_code") {
+ console.log(`[initializeForm] 테이블 섹션 ${section.id}: 공통 컬럼 패턴 감지 - ${masterKey}`);
+ linkColumn = { masterField: masterKey, detailField: masterKey };
+ break;
+ }
+ }
+ if (linkColumn) break;
+ }
+ }
+
+ // 방법 3: 일반 공통 컬럼 (마지막 폴백)
+ if (!linkColumn) {
+ for (const masterKey of masterKeys) {
+ if (detailColumns.includes(masterKey) &&
+ effectiveInitialData[masterKey] &&
+ masterKey !== "id" && masterKey !== "company_code" &&
+ !masterKey.startsWith("__")) {
+ console.log(`[initializeForm] 테이블 섹션 ${section.id}: 공통 컬럼 감지 - ${masterKey}`);
+ linkColumn = { masterField: masterKey, detailField: masterKey };
+ break;
+ }
+ }
+ }
+ }
+ } catch (error) {
+ console.warn(`[initializeForm] 테이블 섹션 ${section.id}: 컬럼 정보 조회 실패`, error);
+ }
+ }
+
+ if (!linkColumn?.masterField || !linkColumn?.detailField) {
+ console.log(`[initializeForm] 테이블 섹션 ${section.id}: linkColumn 미설정 및 자동 감지 실패, 스킵`);
+ continue;
+ }
+
+ // 마스터 테이블의 연결 필드 값 가져오기
+ const masterValue = effectiveInitialData[linkColumn.masterField];
+ if (!masterValue) {
+ console.log(`[initializeForm] 테이블 섹션 ${section.id}: masterField(${linkColumn.masterField}) 값 없음, 스킵`);
+ continue;
+ }
+
+ try {
+ console.log(`[initializeForm] 테이블 섹션 ${section.id}: 디테일 데이터 로드 시작`, {
+ detailTable,
+ linkColumn,
+ masterValue,
+ });
+
+ // 디테일 테이블에서 데이터 조회
+ // operator: "equals"를 사용하여 정확히 일치하는 값만 검색 (엔티티 타입 컬럼에서 중요)
+ const searchCondition: Record = {
+ [linkColumn.detailField]: { value: masterValue, operator: "equals" },
+ };
+
+ console.log(`[initializeForm] 테이블 섹션 ${section.id}: API 요청 - URL: /table-management/tables/${detailTable}/data`);
+ console.log(`[initializeForm] 테이블 섹션 ${section.id}: API 요청 - search:`, JSON.stringify(searchCondition));
+
+ const response = await apiClient.post(`/table-management/tables/${detailTable}/data`, {
+ search: searchCondition, // filters가 아닌 search로 전달
+ page: 1,
+ size: 1000, // pageSize가 아닌 size로 전달
+ autoFilter: { enabled: true }, // 멀티테넌시 필터 적용
+ });
+
+ console.log(`[initializeForm] 테이블 섹션 ${section.id}: API 응답 - success: ${response.data?.success}, total: ${response.data?.data?.total}, dataLength: ${response.data?.data?.data?.length}`);
+
+ if (response.data?.success) {
+ // 다양한 응답 구조 처리
+ let items: any[] = [];
+ const data = response.data.data;
+
+ if (Array.isArray(data)) {
+ items = data;
+ } else if (data?.items && Array.isArray(data.items)) {
+ items = data.items;
+ } else if (data?.rows && Array.isArray(data.rows)) {
+ items = data.rows;
+ } else if (data?.data && Array.isArray(data.data)) {
+ items = data.data;
+ }
+
+ console.log(`[initializeForm] 테이블 섹션 ${section.id}: ${items.length}건 로드됨`, items);
+
+ // 테이블 섹션 데이터를 formData에 저장 (TableSectionRenderer에서 사용)
+ const tableSectionKey = `__tableSection_${section.id}`;
+ newFormData[tableSectionKey] = items;
+ console.log(`[initializeForm] 테이블 섹션 ${section.id}: formData[${tableSectionKey}]에 저장됨`);
+ }
+ } catch (error) {
+ console.error(`[initializeForm] 테이블 섹션 ${section.id}: 디테일 데이터 로드 실패`, error);
+ }
+ }
+ }
+
setFormData(newFormData);
setRepeatSections(newRepeatSections);
setCollapsedSections(newCollapsed);
From f0322a49addf778bd1f228fde2e6d9bcce852817 Mon Sep 17 00:00:00 2001
From: kjs
Date: Tue, 6 Jan 2026 14:24:30 +0900
Subject: [PATCH 42/95] =?UTF-8?q?=EB=B2=94=EC=9A=A9=20=ED=8F=BC=EB=AA=A8?=
=?UTF-8?q?=EB=8B=AC=20=EB=9D=BC=EB=B2=A8=EB=A1=9C=20=EB=9C=A8=EA=B2=8C=20?=
=?UTF-8?q?=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../TableSectionRenderer.tsx | 48 ++++++++++++++++++-
1 file changed, 47 insertions(+), 1 deletion(-)
diff --git a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx
index 9c238b47..4f872bc1 100644
--- a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx
@@ -385,6 +385,9 @@ export function TableSectionRenderer({
// 소스 테이블의 카테고리 타입 컬럼 목록
const [sourceCategoryColumns, setSourceCategoryColumns] = useState([]);
+ // 소스 테이블의 컬럼 라벨 (API에서 동적 로드)
+ const [sourceColumnLabels, setSourceColumnLabels] = useState>({});
+
// 소스 테이블의 카테고리 타입 컬럼 목록 로드
useEffect(() => {
const loadCategoryColumns = async () => {
@@ -410,6 +413,44 @@ export function TableSectionRenderer({
loadCategoryColumns();
}, [tableConfig.source.tableName]);
+ // 소스 테이블의 컬럼 라벨 로드 (source.columnLabels가 비어있을 때만)
+ useEffect(() => {
+ const loadColumnLabels = async () => {
+ const sourceTableName = tableConfig.source.tableName;
+ if (!sourceTableName) return;
+
+ // 이미 source.columnLabels가 설정되어 있으면 스킵
+ if (tableConfig.source.columnLabels && Object.keys(tableConfig.source.columnLabels).length > 0) {
+ return;
+ }
+
+ try {
+ const response = await apiClient.get(`/table-management/tables/${sourceTableName}/columns`);
+
+ if (response.data?.success && response.data.data) {
+ const columnsData = response.data.data.columns || response.data.data || [];
+ const labels: Record = {};
+
+ for (const col of columnsData) {
+ const colName = col.column_name || col.columnName;
+ // displayName: API에서 반환하는 라벨 (COALESCE(cl.column_label, c.column_name))
+ const colLabel = col.displayName || col.column_label || col.columnLabel || col.comment;
+ // 라벨이 컬럼명과 다를 때만 저장 (의미있는 라벨인 경우)
+ if (colName && colLabel && colLabel !== colName) {
+ labels[colName] = colLabel;
+ }
+ }
+
+ setSourceColumnLabels(labels);
+ }
+ } catch (error) {
+ console.error("소스 테이블 컬럼 라벨 조회 실패:", error);
+ }
+ };
+
+ loadColumnLabels();
+ }, [tableConfig.source.tableName, tableConfig.source.columnLabels]);
+
// 조건부 테이블: 동적 옵션 로드 (optionSource 설정이 있는 경우)
useEffect(() => {
if (!isConditionalMode) return;
@@ -1305,7 +1346,12 @@ export function TableSectionRenderer({
const sourceTable = source.tableName;
const sourceColumns = source.displayColumns;
const sourceSearchFields = source.searchColumns;
- const columnLabels = source.columnLabels || {};
+ // 컬럼 라벨: source.columnLabels가 있으면 우선 사용, 없으면 동적 로드된 라벨 사용
+ const columnLabels = useMemo(() => {
+ const configLabels = source.columnLabels || {};
+ // 설정된 라벨이 있으면 설정 우선, 없으면 API에서 로드한 라벨 사용
+ return { ...sourceColumnLabels, ...configLabels };
+ }, [source.columnLabels, sourceColumnLabels]);
const modalTitle = uiConfig?.modalTitle || "항목 검색 및 선택";
const multiSelect = uiConfig?.multiSelect ?? true;
From 6ae0778b4cc8cf46c8940e33005954c2eb90c3a4 Mon Sep 17 00:00:00 2001
From: leeheejin
Date: Tue, 6 Jan 2026 14:43:57 +0900
Subject: [PATCH 43/95] =?UTF-8?q?=ED=95=84=ED=84=B0=EC=9D=98=20=EB=9D=BC?=
=?UTF-8?q?=EB=B2=A8=EB=8F=84=20=EC=BD=94=EB=93=9C=EB=A7=90=EA=B3=A0=20?=
=?UTF-8?q?=EC=84=A4=EC=A0=95=ED=95=9C=EA=B1=B8=EB=A1=9C=20=EB=82=98?=
=?UTF-8?q?=EC=98=A4=EA=B2=8C=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../table-list/TableListComponent.tsx | 38 ++++++++++++++-----
1 file changed, 28 insertions(+), 10 deletions(-)
diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx
index 7ac521af..5f3f7c8d 100644
--- a/frontend/lib/registry/components/table-list/TableListComponent.tsx
+++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx
@@ -2250,9 +2250,9 @@ export const TableListComponent: React.FC = ({
// 🆕 편집 모드 진입 placeholder (실제 구현은 visibleColumns 정의 후)
const startEditingRef = useRef<() => void>(() => {});
- // 🆕 각 컬럼의 고유값 목록 계산
+ // 🆕 각 컬럼의 고유값 목록 계산 (라벨 포함)
const columnUniqueValues = useMemo(() => {
- const result: Record = {};
+ const result: Record> = {};
if (data.length === 0) return result;
@@ -2260,16 +2260,34 @@ export const TableListComponent: React.FC = ({
if (column.columnName === "__checkbox__") return;
const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName;
- const values = new Set();
+ // 라벨 컬럼 후보들 (백엔드에서 _name, _label, _value_label 등으로 반환할 수 있음)
+ const labelColumnCandidates = [
+ `${column.columnName}_name`, // 예: division_name
+ `${column.columnName}_label`, // 예: division_label
+ `${column.columnName}_value_label`, // 예: division_value_label
+ ];
+ const valuesMap = new Map(); // value -> label
data.forEach((row) => {
const val = row[mappedColumnName];
if (val !== null && val !== undefined && val !== "") {
- values.add(String(val));
+ const valueStr = String(val);
+ // 라벨 컬럼 후보들 중 값이 있는 것 사용, 없으면 원본 값 사용
+ let label = valueStr;
+ for (const labelCol of labelColumnCandidates) {
+ if (row[labelCol] && row[labelCol] !== "") {
+ label = String(row[labelCol]);
+ break;
+ }
+ }
+ valuesMap.set(valueStr, label);
}
});
- result[column.columnName] = Array.from(values).sort();
+ // value-label 쌍으로 저장하고 라벨 기준 정렬
+ result[column.columnName] = Array.from(valuesMap.entries())
+ .map(([value, label]) => ({ value, label }))
+ .sort((a, b) => a.label.localeCompare(b.label));
});
return result;
@@ -5758,16 +5776,16 @@ export const TableListComponent: React.FC = ({
)}
- {columnUniqueValues[column.columnName]?.slice(0, 50).map((val) => {
- const isSelected = headerFilters[column.columnName]?.has(val);
+ {columnUniqueValues[column.columnName]?.slice(0, 50).map((item) => {
+ const isSelected = headerFilters[column.columnName]?.has(item.value);
return (
toggleHeaderFilter(column.columnName, val)}
+ onClick={() => toggleHeaderFilter(column.columnName, item.value)}
>
= ({
>
{isSelected && }
-
{val || "(빈 값)"}
+
{item.label || "(빈 값)"}
);
})}
From e08c50c771a5de38cd9313373a8a3a3750b0154c Mon Sep 17 00:00:00 2001
From: hjjeong
Date: Tue, 6 Jan 2026 15:01:50 +0900
Subject: [PATCH 44/95] =?UTF-8?q?=EA=B1=B0=EB=9E=98=EC=B2=98=20=ED=92=88?=
=?UTF-8?q?=EB=AA=A9=EC=A0=95=EB=B3=B4=20=EA=B1=B0=EB=9E=98=EC=B2=98?=
=?UTF-8?q?=ED=92=88=EB=B2=88/=EB=8B=A8=EA=B0=80=20=EC=9E=85=EB=A0=A5=20?=
=?UTF-8?q?=EC=97=86=EC=9D=B4=20=EC=A0=80=EC=9E=A5=EB=90=98=EB=8F=84?=
=?UTF-8?q?=EB=A1=9D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../SelectedItemsDetailInputComponent.tsx | 20 +++++++++++++++++--
frontend/lib/utils/buttonActions.ts | 15 +++++++++++++-
2 files changed, 32 insertions(+), 3 deletions(-)
diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx
index 925ca174..e91d34f4 100644
--- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx
+++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx
@@ -432,10 +432,25 @@ export const SelectedItemsDetailInputComponent: React.FC {
+ itemsList.forEach((item, itemIndex) => {
// 각 그룹의 엔트리 배열들을 준비
const groupEntriesArrays: GroupEntry[][] = groups.map((group) => item.fieldGroups[group.id] || []);
+ // 🆕 모든 그룹이 비어있는지 확인
+ const allGroupsEmpty = groupEntriesArrays.every((arr) => arr.length === 0);
+
+ if (allGroupsEmpty) {
+ // 🆕 모든 그룹이 비어있으면 품목 기본 정보만으로 레코드 생성
+ // (거래처 품번/품명, 기간별 단가 없이도 저장 가능)
+ console.log("📝 [generateCartesianProduct] 모든 그룹이 비어있음 - 품목 기본 레코드 생성", {
+ itemIndex,
+ itemId: item.id,
+ });
+ // 빈 객체를 추가하면 parentKeys와 합쳐져서 기본 레코드가 됨
+ allRecords.push({});
+ return;
+ }
+
// Cartesian Product 재귀 함수
const cartesian = (arrays: GroupEntry[][], currentIndex: number, currentCombination: Record) => {
if (currentIndex === arrays.length) {
@@ -446,7 +461,8 @@ export const SelectedItemsDetailInputComponent: React.FC g.entries);
- const combinations = cartesianProduct(entryArrays);
+
+ // 🆕 모든 그룹이 비어있는지 확인
+ const allGroupsEmpty = entryArrays.every((arr) => arr.length === 0);
+
+ let combinations: any[][];
+ if (allGroupsEmpty) {
+ // 🆕 모든 그룹이 비어있으면 빈 조합 하나 생성 (품목 기본 정보만으로 저장)
+ console.log("📝 [handleBatchSave] 모든 그룹이 비어있음 - 기본 레코드 생성");
+ combinations = [[]];
+ } else {
+ // 빈 그룹을 필터링하여 카티션 곱 계산 (빈 그룹은 무시)
+ const nonEmptyArrays = entryArrays.filter((arr) => arr.length > 0);
+ combinations = nonEmptyArrays.length > 0 ? cartesianProduct(nonEmptyArrays) : [[]];
+ }
// 각 조합을 개별 레코드로 저장
for (let i = 0; i < combinations.length; i++) {
From 77bb917248bb85edb79ee00efa7de66554e0ed4b Mon Sep 17 00:00:00 2001
From: SeongHyun Kim
Date: Tue, 6 Jan 2026 15:03:22 +0900
Subject: [PATCH 45/95] =?UTF-8?q?feat:=20RepeaterFieldGroup=20=EC=83=81?=
=?UTF-8?q?=EC=9C=84=20=ED=8F=BC=20=ED=95=84=EB=93=9C=20=EC=A0=84=EB=8B=AC?=
=?UTF-8?q?=20=EB=B0=A9=EC=8B=9D=20=EA=B0=9C=EC=84=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 하드코딩된 masterDetailFields 배열을 규칙 기반 필터링으로 변경
- 제외 규칙: comp_ 접두사, _numberingRuleId 접미사, 배열/객체 타입, 빈 값 등
- 새 필드 추가 시 코드 수정 불필요하도록 개선
- 에러 로깅 상세 정보 추가 (status, data, message, fullError)
---
frontend/lib/utils/buttonActions.ts | 58 +++++++++++++----------------
1 file changed, 26 insertions(+), 32 deletions(-)
diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts
index 681e9a3f..472de705 100644
--- a/frontend/lib/utils/buttonActions.ts
+++ b/frontend/lib/utils/buttonActions.ts
@@ -996,38 +996,27 @@ export class ButtonActionExecutor {
}
// 🆕 루트 레벨 formData에서 RepeaterFieldGroup에 전달할 공통 필드 추출
- // 주문번호, 발주번호 등 마스터-디테일 관계에서 필요한 필드만 명시적으로 지정
- const masterDetailFields = [
- // 번호 필드
- "order_no", // 발주번호
- "sales_order_no", // 수주번호
- "shipment_no", // 출하번호
- "receipt_no", // 입고번호
- "work_order_no", // 작업지시번호
- // 거래처 필드
- "supplier_code", // 공급처 코드
- "supplier_name", // 공급처 이름
- "customer_code", // 고객 코드
- "customer_name", // 고객 이름
- // 날짜 필드
- "order_date", // 발주일
- "sales_date", // 수주일
- "shipment_date", // 출하일
- "receipt_date", // 입고일
- "due_date", // 납기일
- // 담당자/메모 필드
- "manager", // 담당자
- "memo", // 메모
- "remark", // 비고
- ];
+ // 규칙 기반 필터링: 하드코딩 대신 패턴으로 제외할 필드를 정의
+ for (const [fieldName, value] of Object.entries(context.formData)) {
+ // 제외 규칙 1: comp_로 시작하는 필드 (하위 항목 배열)
+ if (fieldName.startsWith("comp_")) continue;
+ // 제외 규칙 2: _numberingRuleId로 끝나는 필드 (채번 규칙 메타 정보)
+ if (fieldName.endsWith("_numberingRuleId")) continue;
+ // 제외 규칙 3: _로 시작하는 필드 (내부 메타 필드)
+ if (fieldName.startsWith("_")) continue;
+ // 제외 규칙 4: 배열 타입 (하위 항목 데이터)
+ if (Array.isArray(value)) continue;
+ // 제외 규칙 5: 객체 타입 (복잡한 구조 데이터) - null 제외
+ if (value !== null && typeof value === "object") continue;
+ // 제외 규칙 6: 빈 값
+ if (value === undefined || value === "" || value === null) continue;
+ // 제외 규칙 7: 이미 commonFields에 있는 필드 (범용 폼 모달에서 가져온 필드)
+ if (fieldName in commonFields) continue;
- for (const fieldName of masterDetailFields) {
- const value = context.formData[fieldName];
- if (value !== undefined && value !== "" && value !== null && !(fieldName in commonFields)) {
- commonFields[fieldName] = value;
- }
+ // 위 규칙에 해당하지 않는 단순 값(문자열, 숫자, 날짜 등)은 공통 필드로 전달
+ commonFields[fieldName] = value;
}
- console.log("📋 [handleSave] 최종 공통 필드 (마스터-디테일 필드 포함):", commonFields);
+ console.log("📋 [handleSave] 최종 공통 필드 (규칙 기반 자동 추출):", commonFields);
for (const item of parsedData) {
// 메타 필드 제거 (eslint 경고 무시 - 의도적으로 분리)
@@ -1089,10 +1078,15 @@ export class ButtonActionExecutor {
console.log("✅ [handleSave] RepeaterFieldGroup UPDATE 완료:", updateResult.data);
}
} catch (err) {
- const error = err as { response?: { data?: unknown }; message?: string };
+ const error = err as { response?: { data?: unknown; status?: number }; message?: string };
console.error(
`❌ [handleSave] RepeaterFieldGroup 저장 실패 (${repeaterTargetTable}):`,
- error.response?.data || error.message,
+ {
+ status: error.response?.status,
+ data: error.response?.data,
+ message: error.message,
+ fullError: JSON.stringify(error.response?.data, null, 2),
+ },
);
}
}
From 25b7e637de6ec0fc23d6370c5c9ad06601a94ba2 Mon Sep 17 00:00:00 2001
From: SeongHyun Kim
Date: Tue, 6 Jan 2026 15:29:26 +0900
Subject: [PATCH 46/95] =?UTF-8?q?feat:=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?=
=?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=80=EC=9E=A5=20=EC=8B=9C=20?=
=?UTF-8?q?=EC=A1=B4=EC=9E=AC=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20?=
=?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=EC=9E=90=EB=8F=99=20=ED=95=84=ED=84=B0?=
=?UTF-8?q?=EB=A7=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- tableManagementService.addTableData: 테이블 스키마 기반 컬럼 필터링 로직 추가
- 무시된 컬럼 정보를 API 응답에 포함 (skippedColumns, savedColumns)
- 프론트엔드 콘솔에 무시된 컬럼 경고 출력
- conditional-container의 UI 제어용 필드(condition) 등으로 인한 저장 에러 방지
---
.../controllers/tableManagementController.ts | 16 +++++--
.../src/services/tableManagementService.ts | 48 +++++++++++++++++--
frontend/lib/utils/buttonActions.ts | 7 +++
3 files changed, 63 insertions(+), 8 deletions(-)
diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts
index 83384be6..9b3d81a2 100644
--- a/backend-node/src/controllers/tableManagementController.ts
+++ b/backend-node/src/controllers/tableManagementController.ts
@@ -901,13 +901,23 @@ export async function addTableData(
}
// 데이터 추가
- await tableManagementService.addTableData(tableName, data);
+ const result = await tableManagementService.addTableData(tableName, data);
logger.info(`테이블 데이터 추가 완료: ${tableName}`);
- const response: ApiResponse = {
+ // 무시된 컬럼이 있으면 경고 정보 포함
+ const response: ApiResponse<{
+ skippedColumns?: string[];
+ savedColumns?: string[];
+ }> = {
success: true,
- message: "테이블 데이터를 성공적으로 추가했습니다.",
+ message: result.skippedColumns.length > 0
+ ? `테이블 데이터를 추가했습니다. (무시된 컬럼 ${result.skippedColumns.length}개: ${result.skippedColumns.join(", ")})`
+ : "테이블 데이터를 성공적으로 추가했습니다.",
+ data: {
+ skippedColumns: result.skippedColumns.length > 0 ? result.skippedColumns : undefined,
+ savedColumns: result.savedColumns,
+ },
};
res.status(201).json(response);
diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts
index 8ac5989b..98db1eee 100644
--- a/backend-node/src/services/tableManagementService.ts
+++ b/backend-node/src/services/tableManagementService.ts
@@ -2261,11 +2261,12 @@ export class TableManagementService {
/**
* 테이블에 데이터 추가
+ * @returns 무시된 컬럼 정보 (디버깅용)
*/
async addTableData(
tableName: string,
data: Record
- ): Promise {
+ ): Promise<{ skippedColumns: string[]; savedColumns: string[] }> {
try {
logger.info(`=== 테이블 데이터 추가 시작: ${tableName} ===`);
logger.info(`추가할 데이터:`, data);
@@ -2296,10 +2297,41 @@ export class TableManagementService {
logger.info(`created_date 자동 추가: ${data.created_date}`);
}
- // 컬럼명과 값을 분리하고 타입에 맞게 변환
- const columns = Object.keys(data);
- const values = Object.values(data).map((value, index) => {
- const columnName = columns[index];
+ // 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼은 무시)
+ const skippedColumns: string[] = [];
+ const existingColumns = Object.keys(data).filter((col) => {
+ const exists = columnTypeMap.has(col);
+ if (!exists) {
+ skippedColumns.push(col);
+ }
+ return exists;
+ });
+
+ // 무시된 컬럼이 있으면 경고 로그 출력
+ if (skippedColumns.length > 0) {
+ logger.warn(
+ `⚠️ [${tableName}] 테이블에 존재하지 않는 컬럼 ${skippedColumns.length}개 무시됨: ${skippedColumns.join(", ")}`
+ );
+ logger.warn(
+ `⚠️ [${tableName}] 무시된 컬럼 상세:`,
+ skippedColumns.map((col) => ({ column: col, value: data[col] }))
+ );
+ }
+
+ if (existingColumns.length === 0) {
+ throw new Error(
+ `저장할 유효한 컬럼이 없습니다. 테이블: ${tableName}, 전달된 컬럼: ${Object.keys(data).join(", ")}`
+ );
+ }
+
+ logger.info(
+ `✅ [${tableName}] 저장될 컬럼 ${existingColumns.length}개: ${existingColumns.join(", ")}`
+ );
+
+ // 컬럼명과 값을 분리하고 타입에 맞게 변환 (존재하는 컬럼만)
+ const columns = existingColumns;
+ const values = columns.map((columnName) => {
+ const value = data[columnName];
const dataType = columnTypeMap.get(columnName) || "text";
const convertedValue = this.convertValueForPostgreSQL(value, dataType);
logger.info(
@@ -2355,6 +2387,12 @@ export class TableManagementService {
await query(insertQuery, values);
logger.info(`테이블 데이터 추가 완료: ${tableName}`);
+
+ // 무시된 컬럼과 저장된 컬럼 정보 반환
+ return {
+ skippedColumns,
+ savedColumns: existingColumns,
+ };
} catch (error) {
logger.error(`테이블 데이터 추가 오류: ${tableName}`, error);
throw error;
diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts
index e9327082..9b847ef3 100644
--- a/frontend/lib/utils/buttonActions.ts
+++ b/frontend/lib/utils/buttonActions.ts
@@ -1063,6 +1063,13 @@ export class ButtonActionExecutor {
dataWithMeta,
);
console.log("✅ [handleSave] RepeaterFieldGroup INSERT 완료:", insertResult.data);
+ // 무시된 컬럼이 있으면 경고 출력
+ if (insertResult.data?.data?.skippedColumns?.length > 0) {
+ console.warn(
+ `⚠️ [${repeaterTargetTable}] 테이블에 존재하지 않는 컬럼이 무시됨:`,
+ insertResult.data.data.skippedColumns,
+ );
+ }
} else if (item.id) {
// UPDATE (기존 항목)
const originalData = { id: item.id };
From c1425be57fa888ee3fe490b15c95febaefde6f6a Mon Sep 17 00:00:00 2001
From: kjs
Date: Tue, 6 Jan 2026 15:33:44 +0900
Subject: [PATCH 47/95] =?UTF-8?q?=EC=B0=BD=EA=B3=A0=EC=BD=94=EB=93=9C=20?=
=?UTF-8?q?=EA=B0=99=EC=9D=B4=20=EC=98=AC=EB=9D=BC=EA=B0=80=EA=B2=8C=20?=
=?UTF-8?q?=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../rack-structure/RackStructureComponent.tsx | 14 +++++++++++++-
.../registry/components/rack-structure/types.ts | 2 +-
frontend/lib/utils/buttonActions.ts | 12 ++++++------
3 files changed, 20 insertions(+), 8 deletions(-)
diff --git a/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx b/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx
index d80fd2c7..77eadca0 100644
--- a/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx
+++ b/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx
@@ -605,7 +605,7 @@ export const RackStructureComponent: React.FC = ({
location_type: context?.locationType || "선반",
status: context?.status || "사용",
// 추가 필드 (테이블 컬럼명과 동일)
- warehouse_id: context?.warehouseCode,
+ warehouse_code: context?.warehouseCode,
warehouse_name: context?.warehouseName,
floor: context?.floor,
zone: context?.zone,
@@ -623,6 +623,18 @@ export const RackStructureComponent: React.FC = ({
setPreviewData(locations);
setIsPreviewGenerated(true);
+
+ console.log("🏗️ [RackStructure] 생성된 위치 데이터:", {
+ locationsCount: locations.length,
+ firstLocation: locations[0],
+ context: {
+ warehouseCode: context?.warehouseCode,
+ warehouseName: context?.warehouseName,
+ floor: context?.floor,
+ zone: context?.zone,
+ },
+ });
+
onChange?.(locations);
}, [conditions, context, generateLocationCode, onChange, missingFields, hasRowOverlap, duplicateErrors, existingLocations, rowOverlapErrors]);
diff --git a/frontend/lib/registry/components/rack-structure/types.ts b/frontend/lib/registry/components/rack-structure/types.ts
index 5ab7bd7e..8670d4a0 100644
--- a/frontend/lib/registry/components/rack-structure/types.ts
+++ b/frontend/lib/registry/components/rack-structure/types.ts
@@ -27,7 +27,7 @@ export interface GeneratedLocation {
location_type?: string; // 위치 유형
status?: string; // 사용 여부
// 추가 필드 (상위 폼에서 매핑된 값)
- warehouse_id?: string; // 창고 ID/코드
+ warehouse_code?: string; // 창고 코드 (DB 컬럼명과 동일)
warehouse_name?: string; // 창고명
floor?: string; // 층
zone?: string; // 구역
diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts
index 53373d41..099daa91 100644
--- a/frontend/lib/utils/buttonActions.ts
+++ b/frontend/lib/utils/buttonActions.ts
@@ -1374,17 +1374,17 @@ export class ButtonActionExecutor {
// 저장 전 중복 체크
const firstLocation = locations[0];
- const warehouseId = firstLocation.warehouse_id || firstLocation.warehouseCode;
+ const warehouseCode = firstLocation.warehouse_code || firstLocation.warehouse_id || firstLocation.warehouseCode;
const floor = firstLocation.floor;
const zone = firstLocation.zone;
- if (warehouseId && floor && zone) {
- console.log("🔍 [handleRackStructureBatchSave] 기존 데이터 중복 체크:", { warehouseId, floor, zone });
+ if (warehouseCode && floor && zone) {
+ console.log("🔍 [handleRackStructureBatchSave] 기존 데이터 중복 체크:", { warehouseCode, floor, zone });
try {
const existingResponse = await DynamicFormApi.getTableData(tableName, {
filters: {
- warehouse_id: warehouseId,
+ warehouse_code: warehouseCode,
floor: floor,
zone: zone,
},
@@ -1434,8 +1434,8 @@ export class ButtonActionExecutor {
location_name: loc.location_name || loc.locationName,
row_num: loc.row_num || String(loc.rowNum),
level_num: loc.level_num || String(loc.levelNum),
- // 창고 정보 (렉 구조 컴포넌트에서 전달)
- warehouse_id: loc.warehouse_id || loc.warehouseCode,
+ // 창고 정보 (렉 구조 컴포넌트에서 전달) - DB 컬럼명은 warehouse_code
+ warehouse_code: loc.warehouse_code || loc.warehouse_id || loc.warehouseCode,
warehouse_name: loc.warehouse_name || loc.warehouseName,
// 위치 정보 (렉 구조 컴포넌트에서 전달)
floor: loc.floor,
From 7120d5edc3c28592df2d914c314be5f3240023a5 Mon Sep 17 00:00:00 2001
From: kjs
Date: Tue, 6 Jan 2026 17:02:42 +0900
Subject: [PATCH 48/95] =?UTF-8?q?=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../entity-search-input/EntitySearchInputComponent.tsx | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx b/frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx
index 8bdd5758..70785171 100644
--- a/frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx
+++ b/frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx
@@ -52,7 +52,8 @@ export function EntitySearchInputComponent({
// 연쇄관계 설정 추출 (webTypeConfig 또는 component.componentConfig에서)
const config = component?.componentConfig || {};
const effectiveCascadingRelationCode = cascadingRelationCode || config.cascadingRelationCode;
- const effectiveParentFieldId = parentFieldId || config.parentFieldId;
+ // cascadingParentField: ConfigPanel에서 저장되는 필드명
+ const effectiveParentFieldId = parentFieldId || config.cascadingParentField || config.parentFieldId;
const effectiveCascadingRole = config.cascadingRole; // "parent" | "child" | undefined
// 부모 역할이면 연쇄관계 로직 적용 안함 (자식만 부모 값에 따라 필터링됨)
From a2b701a4bfa95aca1aa3f57f705c6abbee4e5790 Mon Sep 17 00:00:00 2001
From: SeongHyun Kim
Date: Tue, 6 Jan 2026 17:29:41 +0900
Subject: [PATCH 49/95] =?UTF-8?q?feat:=20=EC=A1=B0=EA=B1=B4=EB=B6=80=20?=
=?UTF-8?q?=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20initialData=20=EC=A0=84?=
=?UTF-8?q?=EB=8B=AC=20=EC=B2=B4=EA=B3=84=20=EA=B5=AC=ED=98=84=20Interacti?=
=?UTF-8?q?veScreenViewerDynamic:=20originalData=EB=A5=BC=20initialData?=
=?UTF-8?q?=EB=A1=9C=20=EC=B6=94=EA=B0=80=20=EC=A0=84=EB=8B=AC=20DynamicCo?=
=?UTF-8?q?mponentRenderer:=20initialData=20=EC=9A=B0=EC=84=A0=EC=88=9C?=
=?UTF-8?q?=EC=9C=84=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20Condition?=
=?UTF-8?q?alContainerComponent:=20initialData=20props=20=EC=B6=94?=
=?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=ED=95=98=EC=9C=84=20=EC=A0=84=EB=8B=AC?=
=?UTF-8?q?=20ConditionalSectionViewer:=20initialData=20props=20=EC=B6=94?=
=?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=ED=95=98=EC=9C=84=20=EC=A0=84=EB=8B=AC?=
=?UTF-8?q?=20types.ts:=20initialData=20=ED=83=80=EC=9E=85=20=EC=A0=95?=
=?UTF-8?q?=EC=9D=98=20=EC=B6=94=EA=B0=80=20=EC=88=98=EC=A0=95=20=EB=AA=A8?=
=?UTF-8?q?=EB=93=9C=EC=97=90=EC=84=9C=20=EC=A1=B0=EA=B1=B4=EB=B6=80=20?=
=?UTF-8?q?=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20=EB=82=B4=EB=B6=80=20?=
=?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B4=88=EA=B8=B0?=
=?UTF-8?q?=EA=B0=92=20=ED=91=9C=EC=8B=9C=20=EC=A7=80=EC=9B=90?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../screen/InteractiveScreenViewerDynamic.tsx | 1 +
frontend/lib/registry/DynamicComponentRenderer.tsx | 8 +++++---
.../ConditionalContainerComponent.tsx | 11 +++++++++++
.../ConditionalSectionViewer.tsx | 11 +++++++++++
.../components/conditional-container/types.ts | 3 +++
5 files changed, 31 insertions(+), 3 deletions(-)
diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx
index 4763507e..1dfdba14 100644
--- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx
+++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx
@@ -365,6 +365,7 @@ export const InteractiveScreenViewerDynamic: React.FC =
groupedData: props.groupedData, // ✅ 언더스코어 제거하여 직접 전달
_groupedData: props.groupedData, // 하위 호환성 유지
// 🆕 UniversalFormModal용 initialData 전달
- // originalData가 비어있지 않으면 originalData 사용, 아니면 formData 사용
- // 생성 모드에서는 originalData가 빈 객체이므로 formData를 사용해야 함
- _initialData: (originalData && Object.keys(originalData).length > 0) ? originalData : formData,
+ // 우선순위: props.initialData > originalData > formData
+ // 조건부 컨테이너에서 전달된 initialData가 있으면 그것을 사용
+ _initialData: props.initialData || ((originalData && Object.keys(originalData).length > 0) ? originalData : formData),
_originalData: originalData,
+ // 🆕 initialData도 직접 전달 (조건부 컨테이너 → 내부 컴포넌트)
+ initialData: props.initialData,
// 🆕 탭 관련 정보 전달 (탭 내부의 테이블 컴포넌트에서 사용)
parentTabId: props.parentTabId,
parentTabsComponentId: props.parentTabsComponentId,
diff --git a/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx b/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx
index db3fde4c..e3d84d43 100644
--- a/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx
+++ b/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx
@@ -42,7 +42,16 @@ export function ConditionalContainerComponent({
className,
groupedData, // 🆕 그룹 데이터
onSave, // 🆕 EditModal의 handleSave 콜백
+ initialData, // 🆕 수정 모드: 초기 데이터 (발주일, 담당자, 메모 등)
}: ConditionalContainerProps) {
+ // 🔍 디버그: initialData 수신 확인
+ React.useEffect(() => {
+ console.log("[ConditionalContainer] initialData 수신:", {
+ hasInitialData: !!initialData,
+ initialDataKeys: initialData ? Object.keys(initialData) : [],
+ initialData,
+ });
+ }, [initialData]);
// 화면 컨텍스트 (데이터 제공자로 등록)
const screenContext = useScreenContextOptional();
@@ -221,6 +230,7 @@ export function ConditionalContainerComponent({
onSave={onSave}
controlField={controlField}
selectedCondition={selectedValue}
+ initialData={initialData}
/>
))}
@@ -244,6 +254,7 @@ export function ConditionalContainerComponent({
onSave={onSave}
controlField={controlField}
selectedCondition={selectedValue}
+ initialData={initialData}
/>
) : null
)
diff --git a/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx b/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx
index 59c82421..1338f40b 100644
--- a/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx
+++ b/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx
@@ -29,7 +29,17 @@ export function ConditionalSectionViewer({
onSave, // 🆕 EditModal의 handleSave 콜백
controlField, // 🆕 조건부 컨테이너의 제어 필드명
selectedCondition, // 🆕 현재 선택된 조건 값
+ initialData, // 🆕 수정 모드: 초기 데이터 (발주일, 담당자, 메모 등)
}: ConditionalSectionViewerProps) {
+ // 🔍 디버그: initialData 수신 확인
+ React.useEffect(() => {
+ console.log("[ConditionalSectionViewer] initialData 수신:", {
+ sectionId,
+ hasInitialData: !!initialData,
+ initialDataKeys: initialData ? Object.keys(initialData) : [],
+ initialData,
+ });
+ }, [initialData, sectionId]);
const { userId, userName, user } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [components, setComponents] = useState
([]);
@@ -191,6 +201,7 @@ export function ConditionalSectionViewer({
onFormDataChange={onFormDataChange}
groupedData={groupedData}
onSave={hasUniversalFormModal ? undefined : onSave}
+ initialData={initialData}
/>
);
diff --git a/frontend/lib/registry/components/conditional-container/types.ts b/frontend/lib/registry/components/conditional-container/types.ts
index 284e0855..24ba2033 100644
--- a/frontend/lib/registry/components/conditional-container/types.ts
+++ b/frontend/lib/registry/components/conditional-container/types.ts
@@ -47,6 +47,7 @@ export interface ConditionalContainerProps {
onFormDataChange?: (fieldName: string, value: any) => void;
groupedData?: Record[]; // 🆕 그룹 데이터 (EditModal → ModalRepeaterTable)
onSave?: () => Promise; // 🆕 EditModal의 handleSave 콜백
+ initialData?: Record; // 🆕 수정 모드: 초기 데이터 (발주일, 담당자, 메모 등)
// 화면 편집기 관련
isDesignMode?: boolean; // 디자인 모드 여부
@@ -82,5 +83,7 @@ export interface ConditionalSectionViewerProps {
// 🆕 조건부 컨테이너 정보 (자식 화면에 전달)
controlField?: string; // 제어 필드명 (예: "inbound_type")
selectedCondition?: string; // 현재 선택된 조건 값 (예: "PURCHASE_IN")
+ // 🆕 수정 모드: 초기 데이터 전달 (발주일, 담당자, 메모 등)
+ initialData?: Record;
}
From 12d3419b7ff99546f2c37755e2ccb29dbfcb60a4 Mon Sep 17 00:00:00 2001
From: leeheejin
Date: Tue, 6 Jan 2026 17:39:36 +0900
Subject: [PATCH 50/95] =?UTF-8?q?=EA=B5=AC=EB=B6=84=20=ED=95=84=ED=84=B0?=
=?UTF-8?q?=EB=A7=81=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../table-list/TableListComponent.tsx | 113 +++++++++++++++++-
1 file changed, 107 insertions(+), 6 deletions(-)
diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx
index 24a93af8..389dd92d 100644
--- a/frontend/lib/registry/components/table-list/TableListComponent.tsx
+++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx
@@ -6,6 +6,7 @@ import { WebType } from "@/types/common";
import { tableTypeApi } from "@/lib/api/screen";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { codeCache } from "@/lib/caching/codeCache";
+import { getCategoryLabelsByCodes } from "@/lib/api/tableCategoryValue";
import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization";
import { getFullImageUrl } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
@@ -471,6 +472,7 @@ export const TableListComponent: React.FC = ({
}
// 2. 헤더 필터 적용 (joinColumnMapping 사용 안 함 - 직접 컬럼명 사용)
+ // 🆕 다중 값 지원: 셀 값이 "A,B,C" 형태일 때, 필터에서 "A"를 선택하면 해당 행도 표시
if (Object.keys(headerFilters).length > 0) {
result = result.filter((row) => {
return Object.entries(headerFilters).every(([columnName, values]) => {
@@ -480,7 +482,16 @@ export const TableListComponent: React.FC = ({
const cellValue = row[columnName] ?? row[columnName.toLowerCase()] ?? row[columnName.toUpperCase()];
const cellStr = cellValue !== null && cellValue !== undefined ? String(cellValue) : "";
- return values.has(cellStr);
+ // 정확히 일치하는 경우
+ if (values.has(cellStr)) return true;
+
+ // 다중 값인 경우: 콤마로 분리해서 하나라도 포함되면 true
+ if (cellStr.includes(",")) {
+ const cellValues = cellStr.split(",").map(v => v.trim());
+ return cellValues.some(v => values.has(v));
+ }
+
+ return false;
});
});
}
@@ -2248,12 +2259,18 @@ export const TableListComponent: React.FC = ({
// 🆕 편집 모드 진입 placeholder (실제 구현은 visibleColumns 정의 후)
const startEditingRef = useRef<() => void>(() => {});
+ // 🆕 카테고리 라벨 매핑 (API에서 가져온 것)
+ const [categoryLabelCache, setCategoryLabelCache] = useState>({});
+
// 🆕 각 컬럼의 고유값 목록 계산 (라벨 포함)
const columnUniqueValues = useMemo(() => {
const result: Record> = {};
if (data.length === 0) return result;
+ // 🆕 전체 데이터에서 개별 값 -> 라벨 매핑 테이블 구축 (다중 값 처리용)
+ const globalLabelMap: Record> = {};
+
(tableConfig.columns || []).forEach((column: { columnName: string }) => {
if (column.columnName === "__checkbox__") return;
@@ -2265,23 +2282,70 @@ export const TableListComponent: React.FC = ({
`${column.columnName}_value_label`, // 예: division_value_label
];
const valuesMap = new Map(); // value -> label
+ const singleValueLabelMap = new Map(); // 개별 값 -> 라벨 (다중값 처리용)
+ // 1차: 모든 데이터에서 개별 값 -> 라벨 매핑 수집 (단일값 + 다중값 모두)
data.forEach((row) => {
const val = row[mappedColumnName];
if (val !== null && val !== undefined && val !== "") {
const valueStr = String(val);
- // 라벨 컬럼 후보들 중 값이 있는 것 사용, 없으면 원본 값 사용
- let label = valueStr;
+
+ // 라벨 컬럼에서 라벨 찾기
+ let labelStr = "";
for (const labelCol of labelColumnCandidates) {
if (row[labelCol] && row[labelCol] !== "") {
- label = String(row[labelCol]);
+ labelStr = String(row[labelCol]);
break;
}
}
- valuesMap.set(valueStr, label);
+
+ // 단일 값인 경우
+ if (!valueStr.includes(",")) {
+ if (labelStr) {
+ singleValueLabelMap.set(valueStr, labelStr);
+ }
+ } else {
+ // 다중 값인 경우: 값과 라벨을 각각 분리해서 매핑
+ const individualValues = valueStr.split(",").map(v => v.trim());
+ const individualLabels = labelStr ? labelStr.split(",").map(l => l.trim()) : [];
+
+ // 값과 라벨 개수가 같으면 1:1 매핑
+ if (individualValues.length === individualLabels.length) {
+ individualValues.forEach((v, idx) => {
+ if (individualLabels[idx] && !singleValueLabelMap.has(v)) {
+ singleValueLabelMap.set(v, individualLabels[idx]);
+ }
+ });
+ }
+ }
}
});
+ // 2차: 모든 값 처리 (다중 값 포함) - 필터 목록용
+ data.forEach((row) => {
+ const val = row[mappedColumnName];
+ if (val !== null && val !== undefined && val !== "") {
+ const valueStr = String(val);
+
+ // 콤마로 구분된 다중 값인지 확인
+ if (valueStr.includes(",")) {
+ // 다중 값: 각각 분리해서 개별 라벨 찾기
+ const individualValues = valueStr.split(",").map(v => v.trim());
+ // 🆕 singleValueLabelMap → categoryLabelCache 순으로 라벨 찾기
+ const individualLabels = individualValues.map(v =>
+ singleValueLabelMap.get(v) || categoryLabelCache[v] || v
+ );
+ valuesMap.set(valueStr, individualLabels.join(", "));
+ } else {
+ // 단일 값: 매핑에서 찾거나 캐시에서 찾거나 원본 사용
+ const label = singleValueLabelMap.get(valueStr) || categoryLabelCache[valueStr] || valueStr;
+ valuesMap.set(valueStr, label);
+ }
+ }
+ });
+
+ globalLabelMap[column.columnName] = singleValueLabelMap;
+
// value-label 쌍으로 저장하고 라벨 기준 정렬
result[column.columnName] = Array.from(valuesMap.entries())
.map(([value, label]) => ({ value, label }))
@@ -2289,7 +2353,44 @@ export const TableListComponent: React.FC = ({
});
return result;
- }, [data, tableConfig.columns, joinColumnMapping]);
+ }, [data, tableConfig.columns, joinColumnMapping, categoryLabelCache]);
+
+ // 🆕 라벨을 못 찾은 CATEGORY_ 코드들을 API로 조회
+ useEffect(() => {
+ const unlabeledCodes = new Set();
+
+ // columnUniqueValues에서 라벨이 코드 그대로인 항목 찾기
+ Object.values(columnUniqueValues).forEach(items => {
+ items.forEach(item => {
+ // 라벨에 CATEGORY_가 포함되어 있으면 라벨을 못 찾은 것
+ if (item.label.includes("CATEGORY_")) {
+ // 콤마로 분리해서 개별 코드 추출
+ const codes = item.label.split(",").map(c => c.trim());
+ codes.forEach(code => {
+ if (code.startsWith("CATEGORY_") && !categoryLabelCache[code]) {
+ unlabeledCodes.add(code);
+ }
+ });
+ }
+ });
+ });
+
+ if (unlabeledCodes.size === 0) return;
+
+ // API로 라벨 조회
+ const fetchLabels = async () => {
+ try {
+ const response = await getCategoryLabelsByCodes(Array.from(unlabeledCodes));
+ if (response.success && response.data) {
+ setCategoryLabelCache(prev => ({ ...prev, ...response.data }));
+ }
+ } catch (error) {
+ console.error("카테고리 라벨 조회 실패:", error);
+ }
+ };
+
+ fetchLabels();
+ }, [columnUniqueValues, categoryLabelCache]);
// 🆕 헤더 필터 토글
const toggleHeaderFilter = useCallback((columnName: string, value: string) => {
From ea848b97ee662a2cdad3b26c43ca128bf68baabf Mon Sep 17 00:00:00 2001
From: leeheejin
Date: Tue, 6 Jan 2026 17:56:31 +0900
Subject: [PATCH 51/95] =?UTF-8?q?=EA=B2=80=EC=83=89=ED=95=84=ED=84=B0=20?=
=?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../ItemSelectionModal.tsx | 42 ++++++++++++++++---
1 file changed, 37 insertions(+), 5 deletions(-)
diff --git a/frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx b/frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx
index 1eca9fab..7bf7a81d 100644
--- a/frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx
+++ b/frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx
@@ -17,6 +17,7 @@ import { Search, Loader2 } from "lucide-react";
import { useEntitySearch } from "../entity-search-input/useEntitySearch";
import { ItemSelectionModalProps, ModalFilterConfig } from "./types";
import { apiClient } from "@/lib/api/client";
+import { getCategoryLabelsByCodes } from "@/lib/api/tableCategoryValue";
export function ItemSelectionModal({
open,
@@ -99,13 +100,44 @@ export function ItemSelectionModal({
}
}
- // 정렬 후 옵션으로 변환
+ // 🆕 CATEGORY_ 코드가 있는지 확인하고 라벨 조회
+ const allCodes = new Set();
+ for (const val of uniqueValues) {
+ // 콤마로 구분된 다중 값도 처리
+ const codes = val.split(",").map(c => c.trim());
+ codes.forEach(code => {
+ if (code.startsWith("CATEGORY_")) {
+ allCodes.add(code);
+ }
+ });
+ }
+
+ // CATEGORY_ 코드가 있으면 라벨 조회
+ let labelMap: Record = {};
+ if (allCodes.size > 0) {
+ try {
+ const labelResponse = await getCategoryLabelsByCodes(Array.from(allCodes));
+ if (labelResponse.success && labelResponse.data) {
+ labelMap = labelResponse.data;
+ }
+ } catch (labelError) {
+ console.error("카테고리 라벨 조회 실패:", labelError);
+ }
+ }
+
+ // 정렬 후 옵션으로 변환 (라벨 적용)
const options = Array.from(uniqueValues)
.sort()
- .map((val) => ({
- value: val,
- label: val,
- }));
+ .map((val) => {
+ // 콤마로 구분된 다중 값 처리
+ if (val.includes(",")) {
+ const codes = val.split(",").map(c => c.trim());
+ const labels = codes.map(code => labelMap[code] || code);
+ return { value: val, label: labels.join(", ") };
+ }
+ // 단일 값
+ return { value: val, label: labelMap[val] || val };
+ });
setCategoryOptions((prev) => ({
...prev,
From 126da9b46f481f6a4650965c1d91d6977252b2ef Mon Sep 17 00:00:00 2001
From: leeheejin
Date: Wed, 7 Jan 2026 09:37:02 +0900
Subject: [PATCH 52/95] =?UTF-8?q?=ED=95=98=EC=9D=B4=ED=81=90=EB=A7=88?=
=?UTF-8?q?=EA=B7=B8=20=EC=A0=90=EA=B2=80=ED=95=AD=EB=AA=A9=20=EC=B6=94?=
=?UTF-8?q?=EA=B0=80=20=ED=9B=84=20=EC=A3=BC=EA=B8=B0=EB=AA=85/=EC=A0=90?=
=?UTF-8?q?=EA=B2=80=EB=B0=A9=EB=B2=95=EB=AA=85=20=EC=98=A4=EB=A5=98=20?=
=?UTF-8?q?=ED=95=B4=EA=B2=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../table-list/TableListComponent.tsx | 68 ++++++++++++++++++-
1 file changed, 66 insertions(+), 2 deletions(-)
diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx
index 389dd92d..74cea859 100644
--- a/frontend/lib/registry/components/table-list/TableListComponent.tsx
+++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx
@@ -2392,6 +2392,44 @@ export const TableListComponent: React.FC = ({
fetchLabels();
}, [columnUniqueValues, categoryLabelCache]);
+ // 🆕 데이터에서 CATEGORY_ 코드를 찾아 라벨 미리 로드 (테이블 셀 렌더링용)
+ useEffect(() => {
+ if (data.length === 0) return;
+
+ const categoryCodesToFetch = new Set();
+
+ // 모든 데이터 행에서 CATEGORY_ 코드 수집
+ data.forEach((row) => {
+ Object.entries(row).forEach(([key, value]) => {
+ if (value && typeof value === "string") {
+ // 콤마로 구분된 다중 값도 처리
+ const codes = value.split(",").map((v) => v.trim());
+ codes.forEach((code) => {
+ if (code.startsWith("CATEGORY_") && !categoryLabelCache[code]) {
+ categoryCodesToFetch.add(code);
+ }
+ });
+ }
+ });
+ });
+
+ if (categoryCodesToFetch.size === 0) return;
+
+ // API로 라벨 조회
+ const fetchLabels = async () => {
+ try {
+ const response = await getCategoryLabelsByCodes(Array.from(categoryCodesToFetch));
+ if (response.success && response.data && Object.keys(response.data).length > 0) {
+ setCategoryLabelCache((prev) => ({ ...prev, ...response.data }));
+ }
+ } catch (error) {
+ console.error("CATEGORY_ 라벨 조회 실패:", error);
+ }
+ };
+
+ fetchLabels();
+ }, [data, categoryLabelCache]);
+
// 🆕 헤더 필터 토글
const toggleHeaderFilter = useCallback((columnName: string, value: string) => {
setHeaderFilters((prev) => {
@@ -4548,10 +4586,36 @@ export const TableListComponent: React.FC = ({
case "boolean":
return value ? "예" : "아니오";
default:
- return String(value);
+ // 🆕 CATEGORY_ 코드 자동 변환 (inputType이 category가 아니어도)
+ const strValue = String(value);
+ if (strValue.startsWith("CATEGORY_")) {
+ // rowData에서 _label 필드 찾기
+ if (rowData) {
+ const labelFieldCandidates = [
+ `${column.columnName}_label`,
+ `${column.columnName}_name`,
+ `${column.columnName}_value_label`,
+ ];
+ for (const labelField of labelFieldCandidates) {
+ if (rowData[labelField] && rowData[labelField] !== "") {
+ return String(rowData[labelField]);
+ }
+ }
+ }
+ // categoryMappings에서 찾기
+ const mapping = categoryMappings[column.columnName];
+ if (mapping && mapping[strValue]) {
+ return mapping[strValue].label;
+ }
+ // categoryLabelCache에서 찾기 (필터용 캐시)
+ if (categoryLabelCache[strValue]) {
+ return categoryLabelCache[strValue];
+ }
+ }
+ return strValue;
}
},
- [columnMeta, joinedColumnMeta, optimizedConvertCode, categoryMappings],
+ [columnMeta, joinedColumnMeta, optimizedConvertCode, categoryMappings, categoryLabelCache],
);
// ========================================
From e308fd0cccde34e92c4915fe6291ef99bbf2400d Mon Sep 17 00:00:00 2001
From: kjs
Date: Wed, 7 Jan 2026 09:55:19 +0900
Subject: [PATCH 53/95] =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=EC=97=90=20?=
=?UTF-8?q?=EC=A1=B4=EC=9E=AC=ED=95=98=EB=8A=94=EC=A7=80=20=ED=99=95?=
=?UTF-8?q?=EC=9D=B8=ED=95=98=EB=8A=94=20=EC=A0=9C=EC=96=B4=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
---
.../src/services/nodeFlowExecutionService.ts | 163 +++++-
.../node-editor/nodes/ConditionNode.tsx | 33 +-
.../panels/properties/ConditionProperties.tsx | 545 +++++++++++++++---
frontend/types/node-editor.ts | 37 +-
4 files changed, 659 insertions(+), 119 deletions(-)
diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts
index 6f481198..616b4564 100644
--- a/backend-node/src/services/nodeFlowExecutionService.ts
+++ b/backend-node/src/services/nodeFlowExecutionService.ts
@@ -2707,28 +2707,48 @@ export class NodeFlowExecutionService {
const trueData: any[] = [];
const falseData: any[] = [];
- inputData.forEach((item: any) => {
- const results = conditions.map((condition: any) => {
+ // 배열의 각 항목에 대해 조건 평가 (EXISTS 조건은 비동기)
+ for (const item of inputData) {
+ const results: boolean[] = [];
+
+ for (const condition of conditions) {
const fieldValue = item[condition.field];
- let compareValue = condition.value;
- if (condition.valueType === "field") {
- compareValue = item[condition.value];
+ // EXISTS 계열 연산자 처리
+ if (
+ condition.operator === "EXISTS_IN" ||
+ condition.operator === "NOT_EXISTS_IN"
+ ) {
+ const existsResult = await this.evaluateExistsCondition(
+ fieldValue,
+ condition.operator,
+ condition.lookupTable,
+ condition.lookupField,
+ context.companyCode
+ );
+ results.push(existsResult);
logger.info(
- `🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
+ `🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}`
);
} else {
- logger.info(
- `📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}`
+ // 일반 연산자 처리
+ let compareValue = condition.value;
+ if (condition.valueType === "field") {
+ compareValue = item[condition.value];
+ logger.info(
+ `🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
+ );
+ } else {
+ logger.info(
+ `📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}`
+ );
+ }
+
+ results.push(
+ this.evaluateCondition(fieldValue, condition.operator, compareValue)
);
}
-
- return this.evaluateCondition(
- fieldValue,
- condition.operator,
- compareValue
- );
- });
+ }
const result =
logic === "OR"
@@ -2740,7 +2760,7 @@ export class NodeFlowExecutionService {
} else {
falseData.push(item);
}
- });
+ }
logger.info(
`🔍 조건 필터링 결과: TRUE ${trueData.length}건 / FALSE ${falseData.length}건 (${logic} 로직)`
@@ -2755,27 +2775,46 @@ export class NodeFlowExecutionService {
}
// 단일 객체인 경우
- const results = conditions.map((condition: any) => {
+ const results: boolean[] = [];
+
+ for (const condition of conditions) {
const fieldValue = inputData[condition.field];
- let compareValue = condition.value;
- if (condition.valueType === "field") {
- compareValue = inputData[condition.value];
+ // EXISTS 계열 연산자 처리
+ if (
+ condition.operator === "EXISTS_IN" ||
+ condition.operator === "NOT_EXISTS_IN"
+ ) {
+ const existsResult = await this.evaluateExistsCondition(
+ fieldValue,
+ condition.operator,
+ condition.lookupTable,
+ condition.lookupField,
+ context.companyCode
+ );
+ results.push(existsResult);
logger.info(
- `🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
+ `🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}`
);
} else {
- logger.info(
- `📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}`
+ // 일반 연산자 처리
+ let compareValue = condition.value;
+ if (condition.valueType === "field") {
+ compareValue = inputData[condition.value];
+ logger.info(
+ `🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
+ );
+ } else {
+ logger.info(
+ `📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}`
+ );
+ }
+
+ results.push(
+ this.evaluateCondition(fieldValue, condition.operator, compareValue)
);
}
-
- return this.evaluateCondition(
- fieldValue,
- condition.operator,
- compareValue
- );
- });
+ }
const result =
logic === "OR"
@@ -2784,7 +2823,7 @@ export class NodeFlowExecutionService {
logger.info(`🔍 조건 평가 결과: ${result} (${logic} 로직)`);
- // ⚠️ 조건 노드는 TRUE/FALSE 브랜치를 위한 특별한 처리 필요
+ // 조건 노드는 TRUE/FALSE 브랜치를 위한 특별한 처리 필요
// 조건 결과를 저장하고, 원본 데이터는 항상 반환
// 다음 노드에서 sourceHandle을 기반으로 필터링됨
return {
@@ -2795,6 +2834,68 @@ export class NodeFlowExecutionService {
};
}
+ /**
+ * EXISTS_IN / NOT_EXISTS_IN 조건 평가
+ * 다른 테이블에 값이 존재하는지 확인
+ */
+ private static async evaluateExistsCondition(
+ fieldValue: any,
+ operator: string,
+ lookupTable: string,
+ lookupField: string,
+ companyCode?: string
+ ): Promise {
+ if (!lookupTable || !lookupField) {
+ logger.warn("⚠️ EXISTS 조건: lookupTable 또는 lookupField가 없습니다");
+ return false;
+ }
+
+ if (fieldValue === null || fieldValue === undefined || fieldValue === "") {
+ logger.info(
+ `⚠️ EXISTS 조건: 필드값이 비어있어 ${operator === "NOT_EXISTS_IN" ? "TRUE" : "FALSE"} 반환`
+ );
+ // 값이 비어있으면: EXISTS_IN은 false, NOT_EXISTS_IN은 true
+ return operator === "NOT_EXISTS_IN";
+ }
+
+ try {
+ // 멀티테넌시: company_code 필터 적용 여부 확인
+ // company_mng 테이블은 제외
+ const hasCompanyCode = lookupTable !== "company_mng" && companyCode;
+
+ let sql: string;
+ let params: any[];
+
+ if (hasCompanyCode) {
+ sql = `SELECT EXISTS(SELECT 1 FROM "${lookupTable}" WHERE "${lookupField}" = $1 AND company_code = $2) as exists_result`;
+ params = [fieldValue, companyCode];
+ } else {
+ sql = `SELECT EXISTS(SELECT 1 FROM "${lookupTable}" WHERE "${lookupField}" = $1) as exists_result`;
+ params = [fieldValue];
+ }
+
+ logger.info(`🔍 EXISTS 쿼리: ${sql}, params: ${JSON.stringify(params)}`);
+
+ const result = await query(sql, params);
+ const existsInTable = result[0]?.exists_result === true;
+
+ logger.info(
+ `🔍 EXISTS 결과: ${fieldValue}이(가) ${lookupTable}.${lookupField}에 ${existsInTable ? "존재함" : "존재하지 않음"}`
+ );
+
+ // EXISTS_IN: 존재하면 true
+ // NOT_EXISTS_IN: 존재하지 않으면 true
+ if (operator === "EXISTS_IN") {
+ return existsInTable;
+ } else {
+ return !existsInTable;
+ }
+ } catch (error: any) {
+ logger.error(`❌ EXISTS 조건 평가 실패: ${error.message}`);
+ return false;
+ }
+ }
+
/**
* WHERE 절 생성
*/
diff --git a/frontend/components/dataflow/node-editor/nodes/ConditionNode.tsx b/frontend/components/dataflow/node-editor/nodes/ConditionNode.tsx
index 5418fcab..4cf5e32d 100644
--- a/frontend/components/dataflow/node-editor/nodes/ConditionNode.tsx
+++ b/frontend/components/dataflow/node-editor/nodes/ConditionNode.tsx
@@ -22,6 +22,13 @@ const OPERATOR_LABELS: Record = {
NOT_IN: "NOT IN",
IS_NULL: "NULL",
IS_NOT_NULL: "NOT NULL",
+ EXISTS_IN: "EXISTS IN",
+ NOT_EXISTS_IN: "NOT EXISTS IN",
+};
+
+// EXISTS 계열 연산자인지 확인
+const isExistsOperator = (operator: string): boolean => {
+ return operator === "EXISTS_IN" || operator === "NOT_EXISTS_IN";
};
export const ConditionNode = memo(({ data, selected }: NodeProps) => {
@@ -54,15 +61,31 @@ export const ConditionNode = memo(({ data, selected }: NodeProps 0 && (
{data.logic}
)}
-
+
{condition.field}
-
+
{OPERATOR_LABELS[condition.operator] || condition.operator}
- {condition.value !== null && condition.value !== undefined && (
-
- {typeof condition.value === "string" ? `"${condition.value}"` : String(condition.value)}
+ {/* EXISTS 연산자인 경우 테이블.필드 표시 */}
+ {isExistsOperator(condition.operator) ? (
+
+ {(condition as any).lookupTableLabel || (condition as any).lookupTable || "..."}
+ {(condition as any).lookupField && `.${(condition as any).lookupFieldLabel || (condition as any).lookupField}`}
+ ) : (
+ // 일반 연산자인 경우 값 표시
+ condition.value !== null &&
+ condition.value !== undefined && (
+
+ {typeof condition.value === "string" ? `"${condition.value}"` : String(condition.value)}
+
+ )
)}
diff --git a/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx
index 87f7f771..a2d060d4 100644
--- a/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx
+++ b/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx
@@ -4,14 +4,18 @@
* 조건 분기 노드 속성 편집
*/
-import { useEffect, useState } from "react";
-import { Plus, Trash2 } from "lucide-react";
+import { useEffect, useState, useCallback } from "react";
+import { Plus, Trash2, Database, Search, Check, ChevronsUpDown } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
-import type { ConditionNodeData } from "@/types/node-editor";
+import type { ConditionNodeData, ConditionOperator } from "@/types/node-editor";
+import { tableManagementApi } from "@/lib/api/tableManagement";
+import { cn } from "@/lib/utils";
// 필드 정의
interface FieldDefinition {
@@ -20,6 +24,19 @@ interface FieldDefinition {
type?: string;
}
+// 테이블 정보
+interface TableInfo {
+ tableName: string;
+ tableLabel: string;
+}
+
+// 테이블 컬럼 정보
+interface ColumnInfo {
+ columnName: string;
+ columnLabel: string;
+ dataType: string;
+}
+
interface ConditionPropertiesProps {
nodeId: string;
data: ConditionNodeData;
@@ -38,8 +55,194 @@ const OPERATORS = [
{ value: "NOT_IN", label: "NOT IN" },
{ value: "IS_NULL", label: "NULL" },
{ value: "IS_NOT_NULL", label: "NOT NULL" },
+ { value: "EXISTS_IN", label: "다른 테이블에 존재함" },
+ { value: "NOT_EXISTS_IN", label: "다른 테이블에 존재하지 않음" },
] as const;
+// EXISTS 계열 연산자인지 확인
+const isExistsOperator = (operator: string): boolean => {
+ return operator === "EXISTS_IN" || operator === "NOT_EXISTS_IN";
+};
+
+// 테이블 선택용 검색 가능한 Combobox
+function TableCombobox({
+ tables,
+ value,
+ onSelect,
+ placeholder = "테이블 검색...",
+}: {
+ tables: TableInfo[];
+ value: string;
+ onSelect: (value: string) => void;
+ placeholder?: string;
+}) {
+ const [open, setOpen] = useState(false);
+
+ const selectedTable = tables.find((t) => t.tableName === value);
+
+ return (
+
+
+
+ {selectedTable ? (
+
+ {selectedTable.tableLabel}
+ ({selectedTable.tableName})
+
+ ) : (
+ 테이블 선택
+ )}
+
+
+
+
+
+
+
+ 테이블을 찾을 수 없습니다.
+
+ {tables.map((table) => (
+ {
+ onSelect(table.tableName);
+ setOpen(false);
+ }}
+ className="text-xs"
+ >
+
+
+ {table.tableLabel}
+ {table.tableName}
+
+
+ ))}
+
+
+
+
+
+ );
+}
+
+// 컬럼 선택용 검색 가능한 Combobox
+function ColumnCombobox({
+ columns,
+ value,
+ onSelect,
+ placeholder = "컬럼 검색...",
+}: {
+ columns: ColumnInfo[];
+ value: string;
+ onSelect: (value: string) => void;
+ placeholder?: string;
+}) {
+ const [open, setOpen] = useState(false);
+
+ const selectedColumn = columns.find((c) => c.columnName === value);
+
+ return (
+
+
+
+ {selectedColumn ? (
+
+ {selectedColumn.columnLabel}
+ ({selectedColumn.columnName})
+
+ ) : (
+ 컬럼 선택
+ )}
+
+
+
+
+
+
+
+ 컬럼을 찾을 수 없습니다.
+
+ {columns.map((col) => (
+ {
+ onSelect(col.columnName);
+ setOpen(false);
+ }}
+ className="text-xs"
+ >
+
+ {col.columnLabel}
+ ({col.columnName})
+
+ ))}
+
+
+
+
+
+ );
+}
+
+// 컬럼 선택 섹션 (자동 로드 포함)
+function ColumnSelectSection({
+ lookupTable,
+ lookupField,
+ tableColumnsCache,
+ loadingColumns,
+ loadTableColumns,
+ onSelect,
+}: {
+ lookupTable: string;
+ lookupField: string;
+ tableColumnsCache: Record;
+ loadingColumns: Record;
+ loadTableColumns: (tableName: string) => Promise;
+ onSelect: (value: string) => void;
+}) {
+ // 캐시에 없고 로딩 중이 아니면 자동으로 로드
+ useEffect(() => {
+ if (lookupTable && !tableColumnsCache[lookupTable] && !loadingColumns[lookupTable]) {
+ loadTableColumns(lookupTable);
+ }
+ }, [lookupTable, tableColumnsCache, loadingColumns, loadTableColumns]);
+
+ const isLoading = loadingColumns[lookupTable];
+ const columns = tableColumnsCache[lookupTable];
+
+ return (
+
+
+
+ 비교할 컬럼
+
+ {isLoading ? (
+
+ 컬럼 목록 로딩 중...
+
+ ) : columns && columns.length > 0 ? (
+
+ ) : (
+
+ 컬럼 목록을 로드할 수 없습니다
+
+ )}
+
+ );
+}
+
export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) {
const { updateNode, nodes, edges } = useFlowEditorStore();
@@ -48,6 +251,12 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
const [logic, setLogic] = useState<"AND" | "OR">(data.logic || "AND");
const [availableFields, setAvailableFields] = useState([]);
+ // EXISTS 연산자용 상태
+ const [allTables, setAllTables] = useState([]);
+ const [tableColumnsCache, setTableColumnsCache] = useState>({});
+ const [loadingTables, setLoadingTables] = useState(false);
+ const [loadingColumns, setLoadingColumns] = useState>({});
+
// 데이터 변경 시 로컬 상태 업데이트
useEffect(() => {
setDisplayName(data.displayName || "조건 분기");
@@ -55,6 +264,100 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
setLogic(data.logic || "AND");
}, [data]);
+ // 전체 테이블 목록 로드 (EXISTS 연산자용)
+ useEffect(() => {
+ const loadAllTables = async () => {
+ // 이미 EXISTS 연산자가 있거나 로드된 적이 있으면 스킵
+ if (allTables.length > 0) return;
+
+ // EXISTS 연산자가 하나라도 있으면 테이블 목록 로드
+ const hasExistsOperator = conditions.some((c) => isExistsOperator(c.operator));
+ if (!hasExistsOperator) return;
+
+ setLoadingTables(true);
+ try {
+ const response = await tableManagementApi.getTableList();
+ if (response.success && response.data) {
+ setAllTables(
+ response.data.map((t: any) => ({
+ tableName: t.tableName,
+ tableLabel: t.tableLabel || t.tableName,
+ }))
+ );
+ }
+ } catch (error) {
+ console.error("테이블 목록 로드 실패:", error);
+ } finally {
+ setLoadingTables(false);
+ }
+ };
+
+ loadAllTables();
+ }, [conditions, allTables.length]);
+
+ // 테이블 컬럼 로드 함수
+ const loadTableColumns = useCallback(
+ async (tableName: string): Promise => {
+ // 캐시에 있으면 반환
+ if (tableColumnsCache[tableName]) {
+ return tableColumnsCache[tableName];
+ }
+
+ // 이미 로딩 중이면 스킵
+ if (loadingColumns[tableName]) {
+ return [];
+ }
+
+ // 로딩 상태 설정
+ setLoadingColumns((prev) => ({ ...prev, [tableName]: true }));
+
+ try {
+ // getColumnList 반환: { success, data: { columns, total, ... } }
+ const response = await tableManagementApi.getColumnList(tableName);
+ if (response.success && response.data && response.data.columns) {
+ const columns = response.data.columns.map((c: any) => ({
+ columnName: c.columnName,
+ columnLabel: c.columnLabel || c.columnName,
+ dataType: c.dataType,
+ }));
+ setTableColumnsCache((prev) => ({ ...prev, [tableName]: columns }));
+ console.log(`✅ 테이블 ${tableName} 컬럼 로드 완료:`, columns.length, "개");
+ return columns;
+ } else {
+ console.warn(`⚠️ 테이블 ${tableName} 컬럼 조회 실패:`, response);
+ }
+ } catch (error) {
+ console.error(`❌ 테이블 ${tableName} 컬럼 로드 실패:`, error);
+ } finally {
+ setLoadingColumns((prev) => ({ ...prev, [tableName]: false }));
+ }
+ return [];
+ },
+ [tableColumnsCache, loadingColumns]
+ );
+
+ // EXISTS 연산자 선택 시 테이블 목록 강제 로드
+ const ensureTablesLoaded = useCallback(async () => {
+ if (allTables.length > 0) return;
+
+ setLoadingTables(true);
+ try {
+ const response = await tableManagementApi.getTableList();
+ if (response.success && response.data) {
+ setAllTables(
+ response.data.map((t: any) => ({
+ tableName: t.tableName,
+ tableLabel: t.tableLabel || t.tableName,
+ }))
+ );
+ }
+ } catch (error) {
+ console.error("테이블 목록 로드 실패:", error);
+ } finally {
+ setLoadingTables(false);
+ }
+ }, [allTables.length]);
+
// 🔥 연결된 소스 노드의 필드를 재귀적으로 수집
useEffect(() => {
const getAllSourceFields = (currentNodeId: string, visited: Set = new Set()): FieldDefinition[] => {
@@ -170,15 +473,18 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
}, [nodeId, nodes, edges]);
const handleAddCondition = () => {
- setConditions([
- ...conditions,
- {
- field: "",
- operator: "EQUALS",
- value: "",
- valueType: "static", // "static" (고정값) 또는 "field" (필드 참조)
- },
- ]);
+ const newCondition = {
+ field: "",
+ operator: "EQUALS" as ConditionOperator,
+ value: "",
+ valueType: "static" as "static" | "field",
+ // EXISTS 연산자용 필드는 초기값 없음
+ lookupTable: undefined,
+ lookupTableLabel: undefined,
+ lookupField: undefined,
+ lookupFieldLabel: undefined,
+ };
+ setConditions([...conditions, newCondition]);
};
const handleRemoveCondition = (index: number) => {
@@ -196,9 +502,50 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
});
};
- const handleConditionChange = (index: number, field: string, value: any) => {
+ const handleConditionChange = async (index: number, field: string, value: any) => {
const newConditions = [...conditions];
newConditions[index] = { ...newConditions[index], [field]: value };
+
+ // EXISTS 연산자로 변경 시 테이블 목록 로드 및 기존 value/valueType 초기화
+ if (field === "operator" && isExistsOperator(value)) {
+ await ensureTablesLoaded();
+ // EXISTS 연산자에서는 value, valueType이 필요 없으므로 초기화
+ newConditions[index].value = "";
+ newConditions[index].valueType = undefined;
+ }
+
+ // EXISTS 연산자에서 다른 연산자로 변경 시 lookup 필드들 초기화
+ if (field === "operator" && !isExistsOperator(value)) {
+ newConditions[index].lookupTable = undefined;
+ newConditions[index].lookupTableLabel = undefined;
+ newConditions[index].lookupField = undefined;
+ newConditions[index].lookupFieldLabel = undefined;
+ }
+
+ // lookupTable 변경 시 컬럼 목록 로드 및 라벨 설정
+ if (field === "lookupTable" && value) {
+ const tableInfo = allTables.find((t) => t.tableName === value);
+ if (tableInfo) {
+ newConditions[index].lookupTableLabel = tableInfo.tableLabel;
+ }
+ // 테이블 변경 시 필드 초기화
+ newConditions[index].lookupField = undefined;
+ newConditions[index].lookupFieldLabel = undefined;
+ // 컬럼 목록 미리 로드
+ await loadTableColumns(value);
+ }
+
+ // lookupField 변경 시 라벨 설정
+ if (field === "lookupField" && value) {
+ const tableName = newConditions[index].lookupTable;
+ if (tableName && tableColumnsCache[tableName]) {
+ const columnInfo = tableColumnsCache[tableName].find((c) => c.columnName === value);
+ if (columnInfo) {
+ newConditions[index].lookupFieldLabel = columnInfo.columnLabel;
+ }
+ }
+ }
+
setConditions(newConditions);
updateNode(nodeId, {
conditions: newConditions,
@@ -329,64 +676,114 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
- {condition.operator !== "IS_NULL" && condition.operator !== "IS_NOT_NULL" && (
+ {/* EXISTS 연산자인 경우: 테이블/필드 선택 UI (검색 가능한 Combobox) */}
+ {isExistsOperator(condition.operator) && (
<>
-
비교 값 타입
-
handleConditionChange(index, "valueType", value)}
- >
-
-
-
-
- 고정값
- 필드 참조
-
-
+
+
+ 조회할 테이블
+
+ {loadingTables ? (
+
+ 테이블 목록 로딩 중...
+
+ ) : allTables.length > 0 ? (
+
handleConditionChange(index, "lookupTable", value)}
+ placeholder="테이블 검색..."
+ />
+ ) : (
+
+ 테이블 목록을 로드할 수 없습니다
+
+ )}
-
-
- {(condition as any).valueType === "field" ? "비교 필드" : "비교 값"}
-
- {(condition as any).valueType === "field" ? (
- // 필드 참조: 드롭다운으로 선택
- availableFields.length > 0 ? (
-
handleConditionChange(index, "value", value)}
- >
-
-
-
-
- {availableFields.map((field) => (
-
- {field.label || field.name}
- {field.type && ({field.type}) }
-
- ))}
-
-
- ) : (
-
- 소스 노드를 연결하세요
-
- )
- ) : (
- // 고정값: 직접 입력
-
handleConditionChange(index, "value", e.target.value)}
- placeholder="비교할 값"
- className="mt-1 h-8 text-xs"
- />
- )}
+ {(condition as any).lookupTable && (
+
handleConditionChange(index, "lookupField", value)}
+ />
+ )}
+
+
+ {condition.operator === "EXISTS_IN"
+ ? `소스의 "${condition.field || "..."}" 값이 "${(condition as any).lookupTableLabel || "..."}" 테이블의 "${(condition as any).lookupFieldLabel || "..."}" 컬럼에 존재하면 TRUE`
+ : `소스의 "${condition.field || "..."}" 값이 "${(condition as any).lookupTableLabel || "..."}" 테이블의 "${(condition as any).lookupFieldLabel || "..."}" 컬럼에 존재하지 않으면 TRUE`}
>
)}
+
+ {/* 일반 연산자인 경우: 기존 비교값 UI */}
+ {condition.operator !== "IS_NULL" &&
+ condition.operator !== "IS_NOT_NULL" &&
+ !isExistsOperator(condition.operator) && (
+ <>
+
+ 비교 값 타입
+ handleConditionChange(index, "valueType", value)}
+ >
+
+
+
+
+ 고정값
+ 필드 참조
+
+
+
+
+
+
+ {(condition as any).valueType === "field" ? "비교 필드" : "비교 값"}
+
+ {(condition as any).valueType === "field" ? (
+ // 필드 참조: 드롭다운으로 선택
+ availableFields.length > 0 ? (
+
handleConditionChange(index, "value", value)}
+ >
+
+
+
+
+ {availableFields.map((field) => (
+
+ {field.label || field.name}
+ {field.type && (
+ ({field.type})
+ )}
+
+ ))}
+
+
+ ) : (
+
+ 소스 노드를 연결하세요
+
+ )
+ ) : (
+ // 고정값: 직접 입력
+
handleConditionChange(index, "value", e.target.value)}
+ placeholder="비교할 값"
+ className="mt-1 h-8 text-xs"
+ />
+ )}
+
+ >
+ )}
))}
@@ -402,20 +799,28 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
{/* 안내 */}
- 🔌 소스 노드 연결 : 테이블/외부DB 노드를 연결하면 자동으로 필드 목록이 표시됩니다.
+ 소스 노드 연결 : 테이블/외부DB 노드를 연결하면 자동으로 필드 목록이 표시됩니다.
- 🔄 비교 값 타입 : • 고정값 : 직접 입력한 값과 비교 (예: age > 30)
- • 필드 참조 : 다른 필드의 값과 비교 (예: 주문수량 > 재고수량)
+ 비교 값 타입 :
+ - 고정값 : 직접 입력한 값과 비교 (예: age > 30)
+ - 필드 참조 : 다른 필드의 값과 비교 (예: 주문수량 > 재고수량)
+
+
+ 테이블 존재 여부 검사 :
+ - 다른 테이블에 존재함 : 값이 다른 테이블에 있으면 TRUE
+ - 다른 테이블에 존재하지 않음 : 값이 다른 테이블에 없으면 TRUE
+
+ (예: 품명이 품목정보 테이블에 없으면 자동 등록)
- 💡 AND : 모든 조건이 참이어야 TRUE 출력
+ AND : 모든 조건이 참이어야 TRUE 출력
- 💡 OR : 하나라도 참이면 TRUE 출력
+ OR : 하나라도 참이면 TRUE 출력
- ⚡ TRUE 출력은 오른쪽 위, FALSE 출력은 오른쪽 아래입니다.
+ TRUE 출력은 오른쪽 위, FALSE 출력은 오른쪽 아래입니다.
diff --git a/frontend/types/node-editor.ts b/frontend/types/node-editor.ts
index 55c8f67e..6eb1bb1c 100644
--- a/frontend/types/node-editor.ts
+++ b/frontend/types/node-editor.ts
@@ -95,24 +95,35 @@ export interface RestAPISourceNodeData {
displayName?: string;
}
+// 조건 연산자 타입
+export type ConditionOperator =
+ | "EQUALS"
+ | "NOT_EQUALS"
+ | "GREATER_THAN"
+ | "LESS_THAN"
+ | "GREATER_THAN_OR_EQUAL"
+ | "LESS_THAN_OR_EQUAL"
+ | "LIKE"
+ | "NOT_LIKE"
+ | "IN"
+ | "NOT_IN"
+ | "IS_NULL"
+ | "IS_NOT_NULL"
+ | "EXISTS_IN" // 다른 테이블에 존재함
+ | "NOT_EXISTS_IN"; // 다른 테이블에 존재하지 않음
+
// 조건 분기 노드
export interface ConditionNodeData {
conditions: Array<{
field: string;
- operator:
- | "EQUALS"
- | "NOT_EQUALS"
- | "GREATER_THAN"
- | "LESS_THAN"
- | "GREATER_THAN_OR_EQUAL"
- | "LESS_THAN_OR_EQUAL"
- | "LIKE"
- | "NOT_LIKE"
- | "IN"
- | "NOT_IN"
- | "IS_NULL"
- | "IS_NOT_NULL";
+ operator: ConditionOperator;
value: any;
+ valueType?: "static" | "field"; // 비교 값 타입
+ // EXISTS_IN / NOT_EXISTS_IN 전용 필드
+ lookupTable?: string; // 조회할 테이블명
+ lookupTableLabel?: string; // 조회할 테이블 라벨
+ lookupField?: string; // 조회할 테이블의 비교 필드
+ lookupFieldLabel?: string; // 조회할 테이블의 비교 필드 라벨
}>;
logic: "AND" | "OR";
displayName?: string;
From c6ff839e54fedfe910aa1820e0d18934a9ce8e36 Mon Sep 17 00:00:00 2001
From: hjjeong
Date: Wed, 7 Jan 2026 10:05:32 +0900
Subject: [PATCH 54/95] =?UTF-8?q?=EC=9E=85=EA=B3=A0=20=EC=B2=98=EB=A6=AC?=
=?UTF-8?q?=20=EC=8B=9C=20=EC=9E=AC=EA=B3=A0=20=ED=85=8C=EC=9D=B4=EB=B8=94?=
=?UTF-8?q?=20=EC=A0=80=EC=9E=A5=20=EB=B0=8F=20=EC=9E=AC=EA=B3=A0=EC=9D=B4?=
=?UTF-8?q?=EB=A0=A5=20stock=5Fid=20=EC=A0=84=EB=8B=AC=20=EC=88=98?=
=?UTF-8?q?=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/services/nodeFlowExecutionService.ts | 27 ++++--
frontend/lib/utils/buttonActions.ts | 82 +++++++++++++++++--
2 files changed, 96 insertions(+), 13 deletions(-)
diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts
index 6f481198..40eada6e 100644
--- a/backend-node/src/services/nodeFlowExecutionService.ts
+++ b/backend-node/src/services/nodeFlowExecutionService.ts
@@ -2282,6 +2282,7 @@ export class NodeFlowExecutionService {
UPDATE ${targetTable}
SET ${setClauses.join(", ")}
WHERE ${updateWhereConditions}
+ RETURNING *
`;
logger.info(`🔄 UPDATE 실행:`, {
@@ -2292,8 +2293,14 @@ export class NodeFlowExecutionService {
values: updateValues,
});
- await txClient.query(updateSql, updateValues);
+ const updateResult = await txClient.query(updateSql, updateValues);
updatedCount++;
+
+ // 🆕 UPDATE 결과를 입력 데이터에 병합 (다음 노드에서 id 등 사용 가능)
+ if (updateResult.rows && updateResult.rows[0]) {
+ Object.assign(data, updateResult.rows[0]);
+ logger.info(` 📦 UPDATE 결과 병합: id=${updateResult.rows[0].id}`);
+ }
} else {
// 3-B. 없으면 INSERT
const columns: string[] = [];
@@ -2340,6 +2347,7 @@ export class NodeFlowExecutionService {
const insertSql = `
INSERT INTO ${targetTable} (${columns.join(", ")})
VALUES (${placeholders})
+ RETURNING *
`;
logger.info(`➕ INSERT 실행:`, {
@@ -2348,8 +2356,14 @@ export class NodeFlowExecutionService {
conflictKeyValues,
});
- await txClient.query(insertSql, values);
+ const insertResult = await txClient.query(insertSql, values);
insertedCount++;
+
+ // 🆕 INSERT 결과를 입력 데이터에 병합 (다음 노드에서 id 등 사용 가능)
+ if (insertResult.rows && insertResult.rows[0]) {
+ Object.assign(data, insertResult.rows[0]);
+ logger.info(` 📦 INSERT 결과 병합: id=${insertResult.rows[0].id}`);
+ }
}
}
@@ -2357,11 +2371,10 @@ export class NodeFlowExecutionService {
`✅ UPSERT 완료 (내부 DB): ${targetTable}, INSERT ${insertedCount}건, UPDATE ${updatedCount}건`
);
- return {
- insertedCount,
- updatedCount,
- totalCount: insertedCount + updatedCount,
- };
+ // 🔥 다음 노드에 전달할 데이터 반환
+ // dataArray에는 Object.assign으로 UPSERT 결과(id 등)가 이미 병합되어 있음
+ // 카운트 정보도 함께 반환하여 기존 호환성 유지
+ return dataArray;
};
// 🔥 클라이언트가 전달되었으면 사용, 아니면 독립 트랜잭션 생성
diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts
index 6daf17e9..29ff8f3f 100644
--- a/frontend/lib/utils/buttonActions.ts
+++ b/frontend/lib/utils/buttonActions.ts
@@ -304,6 +304,9 @@ export interface ButtonActionContext {
selectedLeftData?: Record;
refreshRightPanel?: () => void;
};
+
+ // 🆕 저장된 데이터 (저장 후 제어 실행 시 플로우에 전달)
+ savedData?: any;
}
/**
@@ -1251,7 +1254,49 @@ export class ButtonActionExecutor {
// 🔥 저장 성공 후 연결된 제어 실행 (dataflowTiming이 'after'인 경우)
if (config.enableDataflowControl && config.dataflowConfig) {
console.log("🎯 저장 후 제어 실행 시작:", config.dataflowConfig);
- await this.executeAfterSaveControl(config, context);
+
+ // 테이블 섹션 데이터 파싱 (comp_로 시작하는 필드에 JSON 배열이 있는 경우)
+ // 입고 화면 등에서 품목 목록이 comp_xxx 필드에 JSON 문자열로 저장됨
+ const formData: Record = (saveResult.data || context.formData || {}) as Record;
+ let parsedSectionData: any[] = [];
+
+ // comp_로 시작하는 필드에서 테이블 섹션 데이터 찾기
+ const compFieldKey = Object.keys(formData).find(key =>
+ key.startsWith("comp_") && typeof formData[key] === "string"
+ );
+
+ if (compFieldKey) {
+ try {
+ const sectionData = JSON.parse(formData[compFieldKey]);
+ if (Array.isArray(sectionData) && sectionData.length > 0) {
+ // 공통 필드와 섹션 데이터 병합
+ parsedSectionData = sectionData.map((item: any) => {
+ // 섹션 데이터에서 불필요한 내부 필드 제거
+ const { _isNewItem, _targetTable, _existingRecord, ...cleanItem } = item;
+ // 공통 필드(comp_ 필드 제외) + 섹션 아이템 병합
+ const commonFields: Record = {};
+ Object.keys(formData).forEach(key => {
+ if (!key.startsWith("comp_") && !key.endsWith("_numberingRuleId")) {
+ commonFields[key] = formData[key];
+ }
+ });
+ return { ...commonFields, ...cleanItem };
+ });
+ console.log(`📦 [handleSave] 테이블 섹션 데이터 파싱 완료: ${parsedSectionData.length}건`, parsedSectionData[0]);
+ }
+ } catch (parseError) {
+ console.warn("⚠️ [handleSave] 테이블 섹션 데이터 파싱 실패:", parseError);
+ }
+ }
+
+ // 저장된 데이터를 context에 추가하여 플로우에 전달
+ const contextWithSavedData = {
+ ...context,
+ savedData: formData,
+ // 파싱된 섹션 데이터가 있으면 selectedRowsData로 전달
+ selectedRowsData: parsedSectionData.length > 0 ? parsedSectionData : context.selectedRowsData,
+ };
+ await this.executeAfterSaveControl(config, contextWithSavedData);
}
} else {
throw new Error("저장에 필요한 정보가 부족합니다. (테이블명 또는 화면ID 누락)");
@@ -3643,8 +3688,20 @@ export class ButtonActionExecutor {
// 노드 플로우 실행 API
const { executeNodeFlow } = await import("@/lib/api/nodeFlows");
- // 데이터 소스 준비
- const sourceData: any = context.formData || {};
+ // 데이터 소스 준비: context-data 모드는 배열을 기대함
+ // 우선순위: selectedRowsData > savedData > formData
+ // - selectedRowsData: 테이블 섹션에서 저장된 하위 항목들 (item_code, inbound_qty 등 포함)
+ // - savedData: 저장 API 응답 데이터
+ // - formData: 폼에 입력된 데이터
+ let sourceData: any[];
+ if (context.selectedRowsData && context.selectedRowsData.length > 0) {
+ sourceData = context.selectedRowsData;
+ console.log("📦 [다중제어] selectedRowsData 사용:", sourceData.length, "건");
+ } else {
+ const savedData = context.savedData || context.formData || {};
+ sourceData = Array.isArray(savedData) ? savedData : [savedData];
+ console.log("📦 [다중제어] savedData/formData 사용:", sourceData.length, "건");
+ }
let allSuccess = true;
const results: Array<{ flowId: number; flowName: string; success: boolean; message?: string }> = [];
@@ -3751,8 +3808,20 @@ export class ButtonActionExecutor {
// 노드 플로우 실행 API 호출
const { executeNodeFlow } = await import("@/lib/api/nodeFlows");
- // 데이터 소스 준비
- const sourceData: any = context.formData || {};
+ // 데이터 소스 준비: context-data 모드는 배열을 기대함
+ // 우선순위: selectedRowsData > savedData > formData
+ // - selectedRowsData: 테이블 섹션에서 저장된 하위 항목들 (item_code, inbound_qty 등 포함)
+ // - savedData: 저장 API 응답 데이터
+ // - formData: 폼에 입력된 데이터
+ let sourceData: any[];
+ if (context.selectedRowsData && context.selectedRowsData.length > 0) {
+ sourceData = context.selectedRowsData;
+ console.log("📦 [단일제어] selectedRowsData 사용:", sourceData.length, "건");
+ } else {
+ const savedData = context.savedData || context.formData || {};
+ sourceData = Array.isArray(savedData) ? savedData : [savedData];
+ console.log("📦 [단일제어] savedData/formData 사용:", sourceData.length, "건");
+ }
// repeat-screen-modal 데이터가 있으면 병합
const repeatScreenModalKeys = Object.keys(context.formData || {}).filter((key) =>
@@ -3765,7 +3834,8 @@ export class ButtonActionExecutor {
console.log("📦 노드 플로우에 전달할 데이터:", {
flowId,
dataSourceType: controlDataSource,
- sourceData,
+ sourceDataCount: sourceData.length,
+ sourceDataSample: sourceData[0],
});
const result = await executeNodeFlow(flowId, {
From 7c165a724ef51346dd44b8fa763ec901d82ddf12 Mon Sep 17 00:00:00 2001
From: SeongHyun Kim
Date: Wed, 7 Jan 2026 10:24:01 +0900
Subject: [PATCH 55/95] =?UTF-8?q?feat:=20=EC=B6=9C=EA=B3=A0=EA=B4=80?=
=?UTF-8?q?=EB=A6=AC=20=EC=88=98=EC=A0=95=20=EB=AA=A8=EB=8B=AC=20=EC=A0=80?=
=?UTF-8?q?=EC=9E=A5=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F?=
=?UTF-8?q?=20=EA=B7=B8=EB=A3=B9=ED=99=94=20=EC=BB=AC=EB=9F=BC=20=EC=84=A4?=
=?UTF-8?q?=EC=A0=95=20UI=20=EC=B6=94=EA=B0=80=20ButtonConfigPanel:=20?=
=?UTF-8?q?=EC=88=98=EC=A0=95=20=EC=95=A1=EC=85=98=EC=97=90=20=EA=B7=B8?=
=?UTF-8?q?=EB=A3=B9=ED=99=94=20=EC=BB=AC=EB=9F=BC=20=EC=84=A0=ED=83=9D=20?=
=?UTF-8?q?=EB=93=9C=EB=A1=AD=EB=8B=A4=EC=9A=B4=20=EC=B6=94=EA=B0=80=20(?=
=?UTF-8?q?=EC=98=81=EB=AC=B8/=ED=95=9C=EA=B8=80=20=EA=B2=80=EC=83=89=20?=
=?UTF-8?q?=EC=A7=80=EC=9B=90)=20ScreenSplitPanel/EmbeddedScreen:=20groupe?=
=?UTF-8?q?dData=20prop=20=EC=A0=84=EB=8B=AC=20=EA=B2=BD=EB=A1=9C=20?=
=?UTF-8?q?=EC=B6=94=EA=B0=80=20buttonActions:=20RepeaterFieldGroup=20?=
=?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EC=8B=9C=20=EA=B3=B5=ED=86=B5=20=ED=95=84?=
=?UTF-8?q?=EB=93=9C=20=EC=9A=B0=EC=84=A0=20=EC=A0=81=EC=9A=A9=EB=90=98?=
=?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EB=B3=91=ED=95=A9=20=EC=88=9C=EC=84=9C=20?=
=?UTF-8?q?=EB=B3=80=EA=B2=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../screen-embedding/EmbeddedScreen.tsx | 5 +-
.../screen-embedding/ScreenSplitPanel.tsx | 7 +-
.../config-panels/ButtonConfigPanel.tsx | 136 ++++++++++++++++++
.../ScreenSplitPanelRenderer.tsx | 3 +-
frontend/lib/utils/buttonActions.ts | 5 +-
5 files changed, 149 insertions(+), 7 deletions(-)
diff --git a/frontend/components/screen-embedding/EmbeddedScreen.tsx b/frontend/components/screen-embedding/EmbeddedScreen.tsx
index 3bfb7a77..0b32830e 100644
--- a/frontend/components/screen-embedding/EmbeddedScreen.tsx
+++ b/frontend/components/screen-embedding/EmbeddedScreen.tsx
@@ -27,13 +27,14 @@ interface EmbeddedScreenProps {
onSelectionChanged?: (selectedRows: any[]) => void;
position?: SplitPanelPosition; // 분할 패널 내 위치 (left/right)
initialFormData?: Record; // 🆕 수정 모드에서 전달되는 초기 데이터
+ groupedData?: Record[]; // 🆕 그룹 데이터 (수정 모드에서 원본 데이터 추적용)
}
/**
* 임베드된 화면 컴포넌트
*/
export const EmbeddedScreen = forwardRef(
- ({ embedding, onSelectionChanged, position, initialFormData }, ref) => {
+ ({ embedding, onSelectionChanged, position, initialFormData, groupedData }, ref) => {
const [layout, setLayout] = useState([]);
const [selectedRows, setSelectedRows] = useState([]);
const [loading, setLoading] = useState(true);
@@ -430,6 +431,8 @@ export const EmbeddedScreen = forwardRef
);
diff --git a/frontend/components/screen-embedding/ScreenSplitPanel.tsx b/frontend/components/screen-embedding/ScreenSplitPanel.tsx
index f457e851..2f30a4ec 100644
--- a/frontend/components/screen-embedding/ScreenSplitPanel.tsx
+++ b/frontend/components/screen-embedding/ScreenSplitPanel.tsx
@@ -17,13 +17,14 @@ interface ScreenSplitPanelProps {
screenId?: number;
config?: any; // 설정 패널에서 오는 config (leftScreenId, rightScreenId, splitRatio, resizable)
initialFormData?: Record
; // 🆕 수정 모드에서 전달되는 초기 데이터
+ groupedData?: Record[]; // 🆕 그룹 데이터 (수정 모드에서 원본 데이터 추적용)
}
/**
* 분할 패널 컴포넌트
* 순수하게 화면 분할 기능만 제공합니다.
*/
-export function ScreenSplitPanel({ screenId, config, initialFormData }: ScreenSplitPanelProps) {
+export function ScreenSplitPanel({ screenId, config, initialFormData, groupedData }: ScreenSplitPanelProps) {
// config에서 splitRatio 추출 (기본값 50)
const configSplitRatio = config?.splitRatio ?? 50;
@@ -117,7 +118,7 @@ export function ScreenSplitPanel({ screenId, config, initialFormData }: ScreenSp
{/* 좌측 패널 */}
{hasLeftScreen ? (
-
+
) : (
좌측 화면을 선택하세요
@@ -157,7 +158,7 @@ export function ScreenSplitPanel({ screenId, config, initialFormData }: ScreenSp
{/* 우측 패널 */}
{hasRightScreen ? (
-
+
) : (
우측 화면을 선택하세요
diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx
index 417ea4ff..a97d78b3 100644
--- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx
+++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx
@@ -60,6 +60,7 @@ export const ButtonConfigPanel: React.FC
= ({
editModalTitle: String(config.action?.editModalTitle || ""),
editModalDescription: String(config.action?.editModalDescription || ""),
targetUrl: String(config.action?.targetUrl || ""),
+ groupByColumn: String(config.action?.groupByColumns?.[0] || ""),
});
const [screens, setScreens] = useState([]);
@@ -97,6 +98,11 @@ export const ButtonConfigPanel: React.FC = ({
const [modalTargetColumns, setModalTargetColumns] = useState>([]);
const [modalSourcePopoverOpen, setModalSourcePopoverOpen] = useState>({});
const [modalTargetPopoverOpen, setModalTargetPopoverOpen] = useState>({});
+
+ // 🆕 그룹화 컬럼 선택용 상태
+ const [currentTableColumns, setCurrentTableColumns] = useState>([]);
+ const [groupByColumnOpen, setGroupByColumnOpen] = useState(false);
+ const [groupByColumnSearch, setGroupByColumnSearch] = useState("");
const [modalSourceSearch, setModalSourceSearch] = useState>({});
const [modalTargetSearch, setModalTargetSearch] = useState>({});
@@ -130,6 +136,7 @@ export const ButtonConfigPanel: React.FC = ({
editModalTitle: String(latestAction.editModalTitle || ""),
editModalDescription: String(latestAction.editModalDescription || ""),
targetUrl: String(latestAction.targetUrl || ""),
+ groupByColumn: String(latestAction.groupByColumns?.[0] || ""),
});
// 🆕 제목 블록 초기화
@@ -327,6 +334,35 @@ export const ButtonConfigPanel: React.FC = ({
loadColumns();
}, [config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable]);
+ // 🆕 현재 테이블 컬럼 로드 (그룹화 컬럼 선택용)
+ useEffect(() => {
+ if (!currentTableName) return;
+
+ const loadCurrentTableColumns = async () => {
+ try {
+ const response = await apiClient.get(`/table-management/tables/${currentTableName}/columns`);
+ if (response.data.success) {
+ let columnData = response.data.data;
+ if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
+ if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
+
+ if (Array.isArray(columnData)) {
+ const columns = columnData.map((col: any) => ({
+ name: col.name || col.columnName,
+ label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
+ }));
+ setCurrentTableColumns(columns);
+ console.log(`✅ 현재 테이블 ${currentTableName} 컬럼 로드 성공:`, columns.length, "개");
+ }
+ }
+ } catch (error) {
+ console.error("현재 테이블 컬럼 로드 실패:", error);
+ }
+ };
+
+ loadCurrentTableColumns();
+ }, [currentTableName]);
+
// 🆕 openModalWithData 소스/타겟 테이블 컬럼 로드
useEffect(() => {
const actionType = config.action?.type;
@@ -1529,6 +1565,106 @@ export const ButtonConfigPanel: React.FC = ({
>
)}
+
+
+
그룹화 컬럼
+
+
+
+ {localInputs.groupByColumn ? (
+
+ {localInputs.groupByColumn}
+ {currentTableColumns.find((col) => col.name === localInputs.groupByColumn)?.label &&
+ currentTableColumns.find((col) => col.name === localInputs.groupByColumn)?.label !== localInputs.groupByColumn && (
+
+ ({currentTableColumns.find((col) => col.name === localInputs.groupByColumn)?.label})
+
+ )}
+
+ ) : (
+ 컬럼을 선택하세요
+ )}
+
+
+
+
+
+
+
+ setGroupByColumnSearch(e.target.value)}
+ className="border-0 p-0 focus-visible:ring-0"
+ />
+
+
+ {currentTableColumns.length === 0 ? (
+
+ {currentTableName ? "컬럼을 불러오는 중..." : "테이블이 설정되지 않았습니다"}
+
+ ) : (
+ <>
+ {/* 선택 해제 옵션 */}
+
{
+ setLocalInputs((prev) => ({ ...prev, groupByColumn: "" }));
+ onUpdateProperty("componentConfig.action.groupByColumns", undefined);
+ setGroupByColumnOpen(false);
+ setGroupByColumnSearch("");
+ }}
+ >
+
+ 선택 안 함
+
+ {/* 컬럼 목록 */}
+ {currentTableColumns
+ .filter((col) => {
+ if (!groupByColumnSearch) return true;
+ const search = groupByColumnSearch.toLowerCase();
+ return (
+ col.name.toLowerCase().includes(search) ||
+ col.label.toLowerCase().includes(search)
+ );
+ })
+ .map((col) => (
+
{
+ setLocalInputs((prev) => ({ ...prev, groupByColumn: col.name }));
+ onUpdateProperty("componentConfig.action.groupByColumns", [col.name]);
+ setGroupByColumnOpen(false);
+ setGroupByColumnSearch("");
+ }}
+ >
+
+
+ {col.name}
+ {col.label !== col.name && (
+ {col.label}
+ )}
+
+
+ ))}
+ >
+ )}
+
+
+
+
+
+ 여러 행을 하나의 그룹으로 묶어서 수정할 때 사용합니다
+
+
)}
diff --git a/frontend/lib/registry/components/screen-split-panel/ScreenSplitPanelRenderer.tsx b/frontend/lib/registry/components/screen-split-panel/ScreenSplitPanelRenderer.tsx
index 5dc1830c..adeb9e20 100644
--- a/frontend/lib/registry/components/screen-split-panel/ScreenSplitPanelRenderer.tsx
+++ b/frontend/lib/registry/components/screen-split-panel/ScreenSplitPanelRenderer.tsx
@@ -66,7 +66,7 @@ class ScreenSplitPanelRenderer extends AutoRegisteringComponentRenderer {
};
render() {
- const { component, style = {}, componentConfig, config, screenId, formData } = this.props as any;
+ const { component, style = {}, componentConfig, config, screenId, formData, groupedData } = this.props as any;
// componentConfig 또는 config 또는 component.componentConfig 사용
const finalConfig = componentConfig || config || component?.componentConfig || {};
@@ -77,6 +77,7 @@ class ScreenSplitPanelRenderer extends AutoRegisteringComponentRenderer {
screenId={screenId || finalConfig.screenId}
config={finalConfig}
initialFormData={formData} // 🆕 수정 데이터 전달
+ groupedData={groupedData} // 🆕 그룹 데이터 전달 (수정 모드에서 원본 데이터 추적용)
/>
);
diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts
index 6daf17e9..6c5d5d36 100644
--- a/frontend/lib/utils/buttonActions.ts
+++ b/frontend/lib/utils/buttonActions.ts
@@ -1036,10 +1036,11 @@ export class ButtonActionExecutor {
}
// 🆕 공통 필드 병합 + 사용자 정보 추가
- // 공통 필드를 먼저 넣고, 개별 항목 데이터로 덮어씀 (개별 항목이 우선)
+ // 개별 항목 데이터를 먼저 넣고, 공통 필드로 덮어씀 (공통 필드가 우선)
+ // 이유: 사용자가 공통 필드(출고상태 등)를 변경하면 모든 항목에 적용되어야 함
const dataWithMeta: Record
= {
- ...commonFields, // 범용 폼 모달의 공통 필드 (order_no, manager_id 등)
...dataToSave, // RepeaterFieldGroup의 개별 항목 데이터
+ ...commonFields, // 범용 폼 모달의 공통 필드 (outbound_status 등) - 공통 필드가 우선!
created_by: context.userId,
updated_by: context.userId,
company_code: context.companyCode,
From 856db80a3664cf35824cf4e68bec1cbab4422380 Mon Sep 17 00:00:00 2001
From: kjs
Date: Wed, 7 Jan 2026 13:30:57 +0900
Subject: [PATCH 56/95] =?UTF-8?q?=EB=B9=8C=EB=93=9C=20=EC=97=90=EB=9F=AC?=
=?UTF-8?q?=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend-node/src/services/nodeFlowExecutionService.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts
index 616b4564..24bbb88f 100644
--- a/backend-node/src/services/nodeFlowExecutionService.ts
+++ b/backend-node/src/services/nodeFlowExecutionService.ts
@@ -2724,7 +2724,7 @@ export class NodeFlowExecutionService {
condition.operator,
condition.lookupTable,
condition.lookupField,
- context.companyCode
+ context.buttonContext?.companyCode
);
results.push(existsResult);
logger.info(
@@ -2790,7 +2790,7 @@ export class NodeFlowExecutionService {
condition.operator,
condition.lookupTable,
condition.lookupField,
- context.companyCode
+ context.buttonContext?.companyCode
);
results.push(existsResult);
logger.info(
From df70538027a2012001064231025843d4486a7370 Mon Sep 17 00:00:00 2001
From: leeheejin
Date: Wed, 7 Jan 2026 14:31:04 +0900
Subject: [PATCH 57/95] =?UTF-8?q?pop=ED=99=94=EB=A9=B4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.playwright-mcp/pop-page-initial.png | Bin 0 -> 25260 bytes
frontend/app/(pop)/layout.tsx | 10 +
frontend/app/(pop)/pop/page.tsx | 7 +
frontend/app/(pop)/pop/work/page.tsx | 8 +
frontend/app/(pop)/work/page.tsx | 8 +
frontend/app/globals.css | 179 ++
frontend/components/pop/PopAcceptModal.tsx | 131 ++
frontend/components/pop/PopApp.tsx | 462 +++++
frontend/components/pop/PopBottomNav.tsx | 30 +
frontend/components/pop/PopEquipmentModal.tsx | 80 +
frontend/components/pop/PopHeader.tsx | 123 ++
frontend/components/pop/PopProcessModal.tsx | 92 +
.../components/pop/PopProductionPanel.tsx | 346 ++++
frontend/components/pop/PopSettingsModal.tsx | 135 ++
frontend/components/pop/PopStatusTabs.tsx | 48 +
frontend/components/pop/PopWorkCard.tsx | 274 +++
.../components/pop/dashboard/ActivityList.tsx | 35 +
.../pop/dashboard/DashboardFooter.tsx | 24 +
.../pop/dashboard/DashboardHeader.tsx | 96 +
frontend/components/pop/dashboard/KpiBar.tsx | 60 +
.../components/pop/dashboard/MenuGrid.tsx | 43 +
.../components/pop/dashboard/NoticeBanner.tsx | 22 +
.../components/pop/dashboard/NoticeList.tsx | 31 +
.../components/pop/dashboard/PopDashboard.tsx | 83 +
.../components/pop/dashboard/dashboard.css | 906 +++++++++
frontend/components/pop/dashboard/data.ts | 139 ++
frontend/components/pop/dashboard/index.ts | 11 +
frontend/components/pop/dashboard/types.ts | 51 +
frontend/components/pop/data.ts | 373 ++++
frontend/components/pop/index.ts | 13 +
frontend/components/pop/styles.css | 1784 +++++++++++++++++
frontend/components/pop/types.ts | 104 +
32 files changed, 5708 insertions(+)
create mode 100644 .playwright-mcp/pop-page-initial.png
create mode 100644 frontend/app/(pop)/layout.tsx
create mode 100644 frontend/app/(pop)/pop/page.tsx
create mode 100644 frontend/app/(pop)/pop/work/page.tsx
create mode 100644 frontend/app/(pop)/work/page.tsx
create mode 100644 frontend/components/pop/PopAcceptModal.tsx
create mode 100644 frontend/components/pop/PopApp.tsx
create mode 100644 frontend/components/pop/PopBottomNav.tsx
create mode 100644 frontend/components/pop/PopEquipmentModal.tsx
create mode 100644 frontend/components/pop/PopHeader.tsx
create mode 100644 frontend/components/pop/PopProcessModal.tsx
create mode 100644 frontend/components/pop/PopProductionPanel.tsx
create mode 100644 frontend/components/pop/PopSettingsModal.tsx
create mode 100644 frontend/components/pop/PopStatusTabs.tsx
create mode 100644 frontend/components/pop/PopWorkCard.tsx
create mode 100644 frontend/components/pop/dashboard/ActivityList.tsx
create mode 100644 frontend/components/pop/dashboard/DashboardFooter.tsx
create mode 100644 frontend/components/pop/dashboard/DashboardHeader.tsx
create mode 100644 frontend/components/pop/dashboard/KpiBar.tsx
create mode 100644 frontend/components/pop/dashboard/MenuGrid.tsx
create mode 100644 frontend/components/pop/dashboard/NoticeBanner.tsx
create mode 100644 frontend/components/pop/dashboard/NoticeList.tsx
create mode 100644 frontend/components/pop/dashboard/PopDashboard.tsx
create mode 100644 frontend/components/pop/dashboard/dashboard.css
create mode 100644 frontend/components/pop/dashboard/data.ts
create mode 100644 frontend/components/pop/dashboard/index.ts
create mode 100644 frontend/components/pop/dashboard/types.ts
create mode 100644 frontend/components/pop/data.ts
create mode 100644 frontend/components/pop/index.ts
create mode 100644 frontend/components/pop/styles.css
create mode 100644 frontend/components/pop/types.ts
diff --git a/.playwright-mcp/pop-page-initial.png b/.playwright-mcp/pop-page-initial.png
new file mode 100644
index 0000000000000000000000000000000000000000..b14666b32f4c12230ff4756e0d0faf8ed2543a20
GIT binary patch
literal 25260
zcmeI5Pe@cz6o-$Fsf|p`Hl=uAi=cwq7}(||V>(4rAv&~awr!&_E19P|`vYPUE?OiM
zEh2T{&c)a!!d*ecY;@J8re&=Hd-t3-7g`(ovTtCR%i}V>`@Qq~-FwcxjMJBf26EZX
ztcc`_gM}+1*&Fe@y}dcUQm>sE5XnoiaCZ34vwH1R(^{BsSvav#{qk_~QD*t=>S|Nm
z&Aloc_9@ZxPsz35`mN;J;MGG(Yj&i~TG0!-IT8TTcpyR)1Cmh;n1f=#91O!Qm_w_C
zAHLdOmzkB`q;=%YfuvP@ov~K-aI|us09dlibgVdieey7F$_B=Mg(ZY5n$>C7+ofPb`ObMz(RGe6$F{5aobm6a(gfIY=tC&@pkbN{E_W2S4kfV-e0&f3$SXopbFAjgzx${^abk-yuBi57^Sp!4iNd1|mc;
zAQ{DghZqLT0dqhLXhE14m=}aKk_G`<*gY+j7fKzz7C;844}wLSV!)knC#o4{&1eBF
zparx*G2CL{ztKYe-tE8*Ie5Q$cPe+jo6UU-05)xzdf8i@I)8_r)o6@1cOBjXaOy7!rWp2EuaOofEGwn8o$oYFHxu(D99*=?dA*f
zU7vFz%?I885pZ8fDL}DO_EZcq5`S!MEmbzZ$BkAOBpDeWJ9c{children}>;
+}
diff --git a/frontend/app/(pop)/pop/page.tsx b/frontend/app/(pop)/pop/page.tsx
new file mode 100644
index 00000000..3cf5de33
--- /dev/null
+++ b/frontend/app/(pop)/pop/page.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { PopDashboard } from "@/components/pop/dashboard";
+
+export default function PopPage() {
+ return ;
+}
diff --git a/frontend/app/(pop)/pop/work/page.tsx b/frontend/app/(pop)/pop/work/page.tsx
new file mode 100644
index 00000000..15608959
--- /dev/null
+++ b/frontend/app/(pop)/pop/work/page.tsx
@@ -0,0 +1,8 @@
+"use client";
+
+import { PopApp } from "@/components/pop";
+
+export default function PopWorkPage() {
+ return ;
+}
+
diff --git a/frontend/app/(pop)/work/page.tsx b/frontend/app/(pop)/work/page.tsx
new file mode 100644
index 00000000..15608959
--- /dev/null
+++ b/frontend/app/(pop)/work/page.tsx
@@ -0,0 +1,8 @@
+"use client";
+
+import { PopApp } from "@/components/pop";
+
+export default function PopWorkPage() {
+ return ;
+}
+
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index 06b7bd27..b332f5a0 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -388,4 +388,183 @@ select {
border-spacing: 0 !important;
}
+/* ===== POP (Production Operation Panel) Styles ===== */
+
+/* POP 전용 다크 테마 변수 */
+.pop-dark {
+ /* 배경 색상 */
+ --pop-bg-deepest: 8 12 21;
+ --pop-bg-deep: 10 15 28;
+ --pop-bg-primary: 13 19 35;
+ --pop-bg-secondary: 18 26 47;
+ --pop-bg-tertiary: 25 35 60;
+ --pop-bg-elevated: 32 45 75;
+
+ /* 네온 강조색 */
+ --pop-neon-cyan: 0 212 255;
+ --pop-neon-cyan-bright: 0 240 255;
+ --pop-neon-cyan-dim: 0 150 190;
+ --pop-neon-pink: 255 0 102;
+ --pop-neon-purple: 138 43 226;
+
+ /* 상태 색상 */
+ --pop-success: 0 255 136;
+ --pop-success-dim: 0 180 100;
+ --pop-warning: 255 170 0;
+ --pop-warning-dim: 200 130 0;
+ --pop-danger: 255 51 51;
+ --pop-danger-dim: 200 40 40;
+
+ /* 텍스트 색상 */
+ --pop-text-primary: 255 255 255;
+ --pop-text-secondary: 180 195 220;
+ --pop-text-muted: 100 120 150;
+
+ /* 테두리 색상 */
+ --pop-border: 40 55 85;
+ --pop-border-light: 55 75 110;
+}
+
+/* POP 전용 라이트 테마 변수 */
+.pop-light {
+ --pop-bg-deepest: 245 247 250;
+ --pop-bg-deep: 240 243 248;
+ --pop-bg-primary: 250 251 253;
+ --pop-bg-secondary: 255 255 255;
+ --pop-bg-tertiary: 245 247 250;
+ --pop-bg-elevated: 235 238 245;
+
+ --pop-neon-cyan: 0 122 204;
+ --pop-neon-cyan-bright: 0 140 230;
+ --pop-neon-cyan-dim: 0 100 170;
+ --pop-neon-pink: 220 38 127;
+ --pop-neon-purple: 118 38 200;
+
+ --pop-success: 22 163 74;
+ --pop-success-dim: 21 128 61;
+ --pop-warning: 245 158 11;
+ --pop-warning-dim: 217 119 6;
+ --pop-danger: 220 38 38;
+ --pop-danger-dim: 185 28 28;
+
+ --pop-text-primary: 15 23 42;
+ --pop-text-secondary: 71 85 105;
+ --pop-text-muted: 148 163 184;
+
+ --pop-border: 226 232 240;
+ --pop-border-light: 203 213 225;
+}
+
+/* POP 배경 그리드 패턴 */
+.pop-bg-pattern::before {
+ content: "";
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: repeating-linear-gradient(90deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px),
+ repeating-linear-gradient(0deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px),
+ radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0, 212, 255, 0.08) 0%, transparent 60%);
+ pointer-events: none;
+ z-index: 0;
+}
+
+.pop-light .pop-bg-pattern::before {
+ background: repeating-linear-gradient(90deg, rgba(0, 122, 204, 0.02) 0px, transparent 1px, transparent 60px),
+ repeating-linear-gradient(0deg, rgba(0, 122, 204, 0.02) 0px, transparent 1px, transparent 60px),
+ radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0, 122, 204, 0.05) 0%, transparent 60%);
+}
+
+/* POP 글로우 효과 */
+.pop-glow-cyan {
+ box-shadow: 0 0 20px rgba(0, 212, 255, 0.5), 0 0 40px rgba(0, 212, 255, 0.3);
+}
+
+.pop-glow-cyan-strong {
+ box-shadow: 0 0 10px rgba(0, 212, 255, 0.8), 0 0 30px rgba(0, 212, 255, 0.5), 0 0 50px rgba(0, 212, 255, 0.3);
+}
+
+.pop-glow-success {
+ box-shadow: 0 0 15px rgba(0, 255, 136, 0.5);
+}
+
+.pop-glow-warning {
+ box-shadow: 0 0 15px rgba(255, 170, 0, 0.5);
+}
+
+.pop-glow-danger {
+ box-shadow: 0 0 15px rgba(255, 51, 51, 0.5);
+}
+
+/* POP 펄스 글로우 애니메이션 */
+@keyframes pop-pulse-glow {
+ 0%,
+ 100% {
+ box-shadow: 0 0 5px rgba(0, 212, 255, 0.5);
+ }
+ 50% {
+ box-shadow: 0 0 20px rgba(0, 212, 255, 0.8), 0 0 30px rgba(0, 212, 255, 0.4);
+ }
+}
+
+.pop-animate-pulse-glow {
+ animation: pop-pulse-glow 2s ease-in-out infinite;
+}
+
+/* POP 프로그레스 바 샤인 애니메이션 */
+@keyframes pop-progress-shine {
+ 0% {
+ opacity: 0;
+ transform: translateX(-20px);
+ }
+ 50% {
+ opacity: 1;
+ }
+ 100% {
+ opacity: 0;
+ transform: translateX(20px);
+ }
+}
+
+.pop-progress-shine::after {
+ content: "";
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ width: 20px;
+ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3));
+ animation: pop-progress-shine 1.5s ease-in-out infinite;
+}
+
+/* POP 스크롤바 스타일 */
+.pop-scrollbar::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+
+.pop-scrollbar::-webkit-scrollbar-track {
+ background: rgb(var(--pop-bg-secondary));
+}
+
+.pop-scrollbar::-webkit-scrollbar-thumb {
+ background: rgb(var(--pop-border-light));
+ border-radius: 9999px;
+}
+
+.pop-scrollbar::-webkit-scrollbar-thumb:hover {
+ background: rgb(var(--pop-neon-cyan-dim));
+}
+
+/* POP 스크롤바 숨기기 */
+.pop-hide-scrollbar::-webkit-scrollbar {
+ display: none;
+}
+
+.pop-hide-scrollbar {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+}
+
/* ===== End of Global Styles ===== */
diff --git a/frontend/components/pop/PopAcceptModal.tsx b/frontend/components/pop/PopAcceptModal.tsx
new file mode 100644
index 00000000..06f1759e
--- /dev/null
+++ b/frontend/components/pop/PopAcceptModal.tsx
@@ -0,0 +1,131 @@
+"use client";
+
+import React from "react";
+import { X, Info } from "lucide-react";
+import { WorkOrder } from "./types";
+
+interface PopAcceptModalProps {
+ isOpen: boolean;
+ workOrder: WorkOrder | null;
+ quantity: number;
+ onQuantityChange: (qty: number) => void;
+ onConfirm: (quantity: number) => void;
+ onClose: () => void;
+}
+
+export function PopAcceptModal({
+ isOpen,
+ workOrder,
+ quantity,
+ onQuantityChange,
+ onConfirm,
+ onClose,
+}: PopAcceptModalProps) {
+ if (!isOpen || !workOrder) return null;
+
+ const acceptedQty = workOrder.acceptedQuantity || 0;
+ const remainingQty = workOrder.orderQuantity - acceptedQty;
+
+ const handleAdjust = (delta: number) => {
+ const newQty = Math.max(1, Math.min(quantity + delta, remainingQty));
+ onQuantityChange(newQty);
+ };
+
+ const handleInputChange = (e: React.ChangeEvent) => {
+ const val = parseInt(e.target.value) || 0;
+ const newQty = Math.max(0, Math.min(val, remainingQty));
+ onQuantityChange(newQty);
+ };
+
+ const handleConfirm = () => {
+ if (quantity > 0) {
+ onConfirm(quantity);
+ }
+ };
+
+ return (
+ e.target === e.currentTarget && onClose()}>
+
+
+
작업 접수
+
+
+
+
+
+
+
+ {/* 작업지시 정보 */}
+
+
{workOrder.id}
+
+ {workOrder.itemName} ({workOrder.spec})
+
+
+ 지시수량: {workOrder.orderQuantity} EA | 기 접수: {acceptedQty} EA
+
+
+
+ {/* 수량 입력 */}
+
+
접수 수량
+
+ handleAdjust(-10)}>
+ -10
+
+ handleAdjust(-1)}>
+ -1
+
+
+ handleAdjust(1)}>
+ +1
+
+ handleAdjust(10)}>
+ +10
+
+
+
미접수 수량: {remainingQty} EA
+
+
+ {/* 분할접수 안내 */}
+ {quantity < remainingQty && (
+
+
+
+
+
+
분할 접수
+
+ {quantity}EA 접수 후 {remainingQty - quantity}EA가 접수대기 상태로 남습니다.
+
+
+
+ )}
+
+
+
+
+
+ 취소
+
+
+ 접수 ({quantity} EA)
+
+
+
+
+ );
+}
+
diff --git a/frontend/components/pop/PopApp.tsx b/frontend/components/pop/PopApp.tsx
new file mode 100644
index 00000000..b1eb6551
--- /dev/null
+++ b/frontend/components/pop/PopApp.tsx
@@ -0,0 +1,462 @@
+"use client";
+
+import React, { useState, useEffect, useCallback } from "react";
+import "./styles.css";
+
+import {
+ AppState,
+ ModalState,
+ PanelState,
+ StatusType,
+ ProductionType,
+ WorkOrder,
+ WorkStep,
+ Equipment,
+ Process,
+} from "./types";
+import { WORK_ORDERS, EQUIPMENTS, PROCESSES, WORK_STEP_TEMPLATES, STATUS_TEXT } from "./data";
+
+import { PopHeader } from "./PopHeader";
+import { PopStatusTabs } from "./PopStatusTabs";
+import { PopWorkCard } from "./PopWorkCard";
+import { PopBottomNav } from "./PopBottomNav";
+import { PopEquipmentModal } from "./PopEquipmentModal";
+import { PopProcessModal } from "./PopProcessModal";
+import { PopAcceptModal } from "./PopAcceptModal";
+import { PopSettingsModal } from "./PopSettingsModal";
+import { PopProductionPanel } from "./PopProductionPanel";
+
+export function PopApp() {
+ // 앱 상태
+ const [appState, setAppState] = useState({
+ currentStatus: "waiting",
+ selectedEquipment: null,
+ selectedProcess: null,
+ selectedWorkOrder: null,
+ showMyWorkOnly: false,
+ currentWorkSteps: [],
+ currentStepIndex: 0,
+ currentProductionType: "work-order",
+ selectionMode: "single",
+ completionAction: "close",
+ acceptTargetWorkOrder: null,
+ acceptQuantity: 0,
+ theme: "dark",
+ });
+
+ // 모달 상태
+ const [modalState, setModalState] = useState({
+ equipment: false,
+ process: false,
+ accept: false,
+ settings: false,
+ });
+
+ // 패널 상태
+ const [panelState, setPanelState] = useState({
+ production: false,
+ });
+
+ // 현재 시간 (hydration 에러 방지를 위해 초기값 null)
+ const [currentDateTime, setCurrentDateTime] = useState(null);
+ const [isClient, setIsClient] = useState(false);
+
+ // 작업지시 목록 (상태 변경을 위해 로컬 상태로 관리)
+ const [workOrders, setWorkOrders] = useState(WORK_ORDERS);
+
+ // 클라이언트 마운트 확인 및 시계 업데이트
+ useEffect(() => {
+ setIsClient(true);
+ setCurrentDateTime(new Date());
+
+ const timer = setInterval(() => {
+ setCurrentDateTime(new Date());
+ }, 1000);
+ return () => clearInterval(timer);
+ }, []);
+
+ // 로컬 스토리지에서 설정 로드
+ useEffect(() => {
+ const savedSelectionMode = localStorage.getItem("selectionMode") as "single" | "multi" | null;
+ const savedCompletionAction = localStorage.getItem("completionAction") as "close" | "stay" | null;
+ const savedTheme = localStorage.getItem("popTheme") as "dark" | "light" | null;
+
+ setAppState((prev) => ({
+ ...prev,
+ selectionMode: savedSelectionMode || "single",
+ completionAction: savedCompletionAction || "close",
+ theme: savedTheme || "dark",
+ }));
+ }, []);
+
+ // 상태별 카운트 계산
+ const getStatusCounts = useCallback(() => {
+ const myProcessId = appState.selectedProcess?.id;
+
+ let waitingCount = 0;
+ let pendingAcceptCount = 0;
+ let inProgressCount = 0;
+ let completedCount = 0;
+
+ workOrders.forEach((wo) => {
+ if (!wo.processFlow) return;
+
+ const myProcessIndex = myProcessId
+ ? wo.processFlow.findIndex((step) => step.id === myProcessId)
+ : -1;
+
+ if (wo.status === "completed") {
+ completedCount++;
+ } else if (wo.status === "in-progress" && wo.accepted) {
+ inProgressCount++;
+ } else if (myProcessIndex >= 0) {
+ const currentProcessIndex = wo.currentProcessIndex || 0;
+ const myStep = wo.processFlow[myProcessIndex];
+
+ if (currentProcessIndex < myProcessIndex) {
+ waitingCount++;
+ } else if (currentProcessIndex === myProcessIndex && myStep.status !== "completed") {
+ pendingAcceptCount++;
+ } else if (myStep.status === "completed") {
+ completedCount++;
+ }
+ } else {
+ if (wo.status === "waiting") waitingCount++;
+ else if (wo.status === "in-progress") inProgressCount++;
+ }
+ });
+
+ return { waitingCount, pendingAcceptCount, inProgressCount, completedCount };
+ }, [workOrders, appState.selectedProcess]);
+
+ // 필터링된 작업 목록
+ const getFilteredWorkOrders = useCallback(() => {
+ const myProcessId = appState.selectedProcess?.id;
+ let filtered: WorkOrder[] = [];
+
+ workOrders.forEach((wo) => {
+ if (!wo.processFlow) return;
+
+ const myProcessIndex = myProcessId
+ ? wo.processFlow.findIndex((step) => step.id === myProcessId)
+ : -1;
+ const currentProcessIndex = wo.currentProcessIndex || 0;
+ const myStep = myProcessIndex >= 0 ? wo.processFlow[myProcessIndex] : null;
+
+ switch (appState.currentStatus) {
+ case "waiting":
+ if (myProcessIndex >= 0 && currentProcessIndex < myProcessIndex) {
+ filtered.push(wo);
+ } else if (!myProcessId && wo.status === "waiting") {
+ filtered.push(wo);
+ }
+ break;
+
+ case "pending-accept":
+ if (
+ myProcessIndex >= 0 &&
+ currentProcessIndex === myProcessIndex &&
+ myStep &&
+ myStep.status !== "completed" &&
+ !wo.accepted
+ ) {
+ filtered.push(wo);
+ }
+ break;
+
+ case "in-progress":
+ if (wo.accepted && wo.status === "in-progress") {
+ filtered.push(wo);
+ } else if (!myProcessId && wo.status === "in-progress") {
+ filtered.push(wo);
+ }
+ break;
+
+ case "completed":
+ if (wo.status === "completed") {
+ filtered.push(wo);
+ } else if (myStep && myStep.status === "completed") {
+ filtered.push(wo);
+ }
+ break;
+ }
+ });
+
+ // 내 작업만 보기 필터
+ if (appState.showMyWorkOnly && myProcessId) {
+ filtered = filtered.filter((wo) => {
+ const mySteps = wo.processFlow.filter((step) => step.id === myProcessId);
+ if (mySteps.length === 0) return false;
+ return !mySteps.every((step) => step.status === "completed");
+ });
+ }
+
+ return filtered;
+ }, [workOrders, appState.currentStatus, appState.selectedProcess, appState.showMyWorkOnly]);
+
+ // 상태 탭 변경
+ const handleStatusChange = (status: StatusType) => {
+ setAppState((prev) => ({ ...prev, currentStatus: status }));
+ };
+
+ // 생산 유형 변경
+ const handleProductionTypeChange = (type: ProductionType) => {
+ setAppState((prev) => ({ ...prev, currentProductionType: type }));
+ };
+
+ // 내 작업만 보기 토글
+ const handleMyWorkToggle = () => {
+ setAppState((prev) => ({ ...prev, showMyWorkOnly: !prev.showMyWorkOnly }));
+ };
+
+ // 테마 토글
+ const handleThemeToggle = () => {
+ const newTheme = appState.theme === "dark" ? "light" : "dark";
+ setAppState((prev) => ({ ...prev, theme: newTheme }));
+ localStorage.setItem("popTheme", newTheme);
+ };
+
+ // 모달 열기/닫기
+ const openModal = (type: keyof ModalState) => {
+ setModalState((prev) => ({ ...prev, [type]: true }));
+ };
+
+ const closeModal = (type: keyof ModalState) => {
+ setModalState((prev) => ({ ...prev, [type]: false }));
+ };
+
+ // 설비 선택
+ const handleEquipmentSelect = (equipment: Equipment) => {
+ setAppState((prev) => ({
+ ...prev,
+ selectedEquipment: equipment,
+ // 공정이 1개면 자동 선택
+ selectedProcess:
+ equipment.processIds.length === 1
+ ? PROCESSES.find((p) => p.id === equipment.processIds[0]) || null
+ : null,
+ }));
+ };
+
+ // 공정 선택
+ const handleProcessSelect = (process: Process) => {
+ setAppState((prev) => ({ ...prev, selectedProcess: process }));
+ };
+
+ // 작업 접수 모달 열기
+ const handleOpenAcceptModal = (workOrder: WorkOrder) => {
+ const acceptedQty = workOrder.acceptedQuantity || 0;
+ const remainingQty = workOrder.orderQuantity - acceptedQty;
+
+ setAppState((prev) => ({
+ ...prev,
+ acceptTargetWorkOrder: workOrder,
+ acceptQuantity: remainingQty,
+ }));
+ openModal("accept");
+ };
+
+ // 접수 확인
+ const handleConfirmAccept = (quantity: number) => {
+ if (!appState.acceptTargetWorkOrder) return;
+
+ setWorkOrders((prev) =>
+ prev.map((wo) => {
+ if (wo.id === appState.acceptTargetWorkOrder!.id) {
+ const previousAccepted = wo.acceptedQuantity || 0;
+ const newAccepted = previousAccepted + quantity;
+ return {
+ ...wo,
+ acceptedQuantity: newAccepted,
+ remainingQuantity: wo.orderQuantity - newAccepted,
+ accepted: true,
+ status: "in-progress" as const,
+ isPartialAccept: newAccepted < wo.orderQuantity,
+ };
+ }
+ return wo;
+ })
+ );
+
+ closeModal("accept");
+ setAppState((prev) => ({
+ ...prev,
+ acceptTargetWorkOrder: null,
+ acceptQuantity: 0,
+ }));
+ };
+
+ // 접수 취소
+ const handleCancelAccept = (workOrderId: string) => {
+ setWorkOrders((prev) =>
+ prev.map((wo) => {
+ if (wo.id === workOrderId) {
+ return {
+ ...wo,
+ accepted: false,
+ acceptedQuantity: 0,
+ remainingQuantity: wo.orderQuantity,
+ isPartialAccept: false,
+ status: "waiting" as const,
+ };
+ }
+ return wo;
+ })
+ );
+ };
+
+ // 생산진행 패널 열기
+ const handleOpenProductionPanel = (workOrder: WorkOrder) => {
+ const template = WORK_STEP_TEMPLATES[workOrder.process] || WORK_STEP_TEMPLATES["default"];
+ const workSteps: WorkStep[] = template.map((step) => ({
+ ...step,
+ status: "pending" as const,
+ startTime: null,
+ endTime: null,
+ data: {},
+ }));
+
+ setAppState((prev) => ({
+ ...prev,
+ selectedWorkOrder: workOrder,
+ currentWorkSteps: workSteps,
+ currentStepIndex: 0,
+ }));
+ setPanelState((prev) => ({ ...prev, production: true }));
+ };
+
+ // 생산진행 패널 닫기
+ const handleCloseProductionPanel = () => {
+ setPanelState((prev) => ({ ...prev, production: false }));
+ setAppState((prev) => ({
+ ...prev,
+ selectedWorkOrder: null,
+ currentWorkSteps: [],
+ currentStepIndex: 0,
+ }));
+ };
+
+ // 설정 저장
+ const handleSaveSettings = (selectionMode: "single" | "multi", completionAction: "close" | "stay") => {
+ setAppState((prev) => ({ ...prev, selectionMode, completionAction }));
+ localStorage.setItem("selectionMode", selectionMode);
+ localStorage.setItem("completionAction", completionAction);
+ closeModal("settings");
+ };
+
+ const statusCounts = getStatusCounts();
+ const filteredWorkOrders = getFilteredWorkOrders();
+
+ return (
+
+
+ {/* 헤더 */}
+
openModal("equipment")}
+ onProcessClick={() => openModal("process")}
+ onMyWorkToggle={handleMyWorkToggle}
+ onSearchClick={() => {
+ /* 조회 */
+ }}
+ onSettingsClick={() => openModal("settings")}
+ onThemeToggle={handleThemeToggle}
+ />
+
+ {/* 상태 탭 */}
+
+
+ {/* 메인 콘텐츠 */}
+
+ {filteredWorkOrders.length === 0 ? (
+
+
작업이 없습니다
+
+ {appState.currentStatus === "waiting" && "대기 중인 작업이 없습니다"}
+ {appState.currentStatus === "pending-accept" && "접수 대기 작업이 없습니다"}
+ {appState.currentStatus === "in-progress" && "진행 중인 작업이 없습니다"}
+ {appState.currentStatus === "completed" && "완료된 작업이 없습니다"}
+
+
+ ) : (
+
+ {filteredWorkOrders.map((workOrder) => (
+
handleOpenAcceptModal(workOrder)}
+ onCancelAccept={() => handleCancelAccept(workOrder.id)}
+ onStartProduction={() => handleOpenProductionPanel(workOrder)}
+ onClick={() => handleOpenProductionPanel(workOrder)}
+ />
+ ))}
+
+ )}
+
+
+ {/* 하단 네비게이션 */}
+
+
+
+ {/* 모달들 */}
+
closeModal("equipment")}
+ />
+
+ closeModal("process")}
+ />
+
+ setAppState((prev) => ({ ...prev, acceptQuantity: qty }))}
+ onConfirm={handleConfirmAccept}
+ onClose={() => closeModal("accept")}
+ />
+
+ closeModal("settings")}
+ />
+
+ {/* 생산진행 패널 */}
+ setAppState((prev) => ({ ...prev, currentStepIndex: index }))}
+ onStepsUpdate={(steps) => setAppState((prev) => ({ ...prev, currentWorkSteps: steps }))}
+ onClose={handleCloseProductionPanel}
+ />
+
+ );
+}
+
diff --git a/frontend/components/pop/PopBottomNav.tsx b/frontend/components/pop/PopBottomNav.tsx
new file mode 100644
index 00000000..f3fb86ae
--- /dev/null
+++ b/frontend/components/pop/PopBottomNav.tsx
@@ -0,0 +1,30 @@
+"use client";
+
+import React from "react";
+import { Clock, ClipboardList } from "lucide-react";
+
+export function PopBottomNav() {
+ const handleHistoryClick = () => {
+ console.log("작업이력 클릭");
+ // TODO: 작업이력 페이지 이동 또는 모달 열기
+ };
+
+ const handleRegisterClick = () => {
+ console.log("실적등록 클릭");
+ // TODO: 실적등록 모달 열기
+ };
+
+ return (
+
+
+
+ 작업이력
+
+
+
+ 실적등록
+
+
+ );
+}
+
diff --git a/frontend/components/pop/PopEquipmentModal.tsx b/frontend/components/pop/PopEquipmentModal.tsx
new file mode 100644
index 00000000..cfae902f
--- /dev/null
+++ b/frontend/components/pop/PopEquipmentModal.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+import React from "react";
+import { X } from "lucide-react";
+import { Equipment } from "./types";
+
+interface PopEquipmentModalProps {
+ isOpen: boolean;
+ equipments: Equipment[];
+ selectedEquipment: Equipment | null;
+ onSelect: (equipment: Equipment) => void;
+ onClose: () => void;
+}
+
+export function PopEquipmentModal({
+ isOpen,
+ equipments,
+ selectedEquipment,
+ onSelect,
+ onClose,
+}: PopEquipmentModalProps) {
+ const [tempSelected, setTempSelected] = React.useState(selectedEquipment);
+
+ React.useEffect(() => {
+ setTempSelected(selectedEquipment);
+ }, [selectedEquipment, isOpen]);
+
+ const handleConfirm = () => {
+ if (tempSelected) {
+ onSelect(tempSelected);
+ onClose();
+ }
+ };
+
+ if (!isOpen) return null;
+
+ return (
+ e.target === e.currentTarget && onClose()}>
+
+
+
설비 선택
+
+
+
+
+
+
+
+ {equipments.map((equip) => (
+
setTempSelected(equip)}
+ >
+
✓
+
{equip.name}
+
{equip.processNames.join(", ")}
+
+ ))}
+
+
+
+
+
+ 취소
+
+
+ 확인
+
+
+
+
+ );
+}
+
diff --git a/frontend/components/pop/PopHeader.tsx b/frontend/components/pop/PopHeader.tsx
new file mode 100644
index 00000000..b2266eef
--- /dev/null
+++ b/frontend/components/pop/PopHeader.tsx
@@ -0,0 +1,123 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import { Moon, Sun } from "lucide-react";
+import { Equipment, Process, ProductionType } from "./types";
+
+interface PopHeaderProps {
+ currentDateTime: Date;
+ productionType: ProductionType;
+ selectedEquipment: Equipment | null;
+ selectedProcess: Process | null;
+ showMyWorkOnly: boolean;
+ theme: "dark" | "light";
+ onProductionTypeChange: (type: ProductionType) => void;
+ onEquipmentClick: () => void;
+ onProcessClick: () => void;
+ onMyWorkToggle: () => void;
+ onSearchClick: () => void;
+ onSettingsClick: () => void;
+ onThemeToggle: () => void;
+}
+
+export function PopHeader({
+ currentDateTime,
+ productionType,
+ selectedEquipment,
+ selectedProcess,
+ showMyWorkOnly,
+ theme,
+ onProductionTypeChange,
+ onEquipmentClick,
+ onProcessClick,
+ onMyWorkToggle,
+ onSearchClick,
+ onSettingsClick,
+ onThemeToggle,
+}: PopHeaderProps) {
+ const [mounted, setMounted] = useState(false);
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ const formatDate = (date: Date) => {
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, "0");
+ const day = String(date.getDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
+ };
+
+ const formatTime = (date: Date) => {
+ const hours = String(date.getHours()).padStart(2, "0");
+ const minutes = String(date.getMinutes()).padStart(2, "0");
+ return `${hours}:${minutes}`;
+ };
+
+ return (
+
+ {/* 1행: 날짜/시간 + 테마 토글 + 작업지시/원자재 */}
+
+
+ {mounted ? formatDate(currentDateTime) : "----.--.--"}
+ {mounted ? formatTime(currentDateTime) : "--:--"}
+
+
+ {/* 테마 토글 버튼 */}
+
+ {theme === "dark" ? : }
+
+
+
+
+
+ onProductionTypeChange("work-order")}
+ >
+ 작업지시
+
+ onProductionTypeChange("material")}
+ >
+ 원자재
+
+
+
+
+ {/* 2행: 필터 버튼들 */}
+
+
+ {selectedEquipment?.name || "설비"}
+
+
+ {selectedProcess?.name || "공정"}
+
+
+ 내 작업
+
+
+
+
+
+ 조회
+
+
+ 설정
+
+
+
+ );
+}
+
diff --git a/frontend/components/pop/PopProcessModal.tsx b/frontend/components/pop/PopProcessModal.tsx
new file mode 100644
index 00000000..74f72c7e
--- /dev/null
+++ b/frontend/components/pop/PopProcessModal.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import React from "react";
+import { X } from "lucide-react";
+import { Equipment, Process } from "./types";
+
+interface PopProcessModalProps {
+ isOpen: boolean;
+ selectedEquipment: Equipment | null;
+ selectedProcess: Process | null;
+ processes: Process[];
+ onSelect: (process: Process) => void;
+ onClose: () => void;
+}
+
+export function PopProcessModal({
+ isOpen,
+ selectedEquipment,
+ selectedProcess,
+ processes,
+ onSelect,
+ onClose,
+}: PopProcessModalProps) {
+ const [tempSelected, setTempSelected] = React.useState(selectedProcess);
+
+ React.useEffect(() => {
+ setTempSelected(selectedProcess);
+ }, [selectedProcess, isOpen]);
+
+ const handleConfirm = () => {
+ if (tempSelected) {
+ onSelect(tempSelected);
+ onClose();
+ }
+ };
+
+ if (!isOpen || !selectedEquipment) return null;
+
+ // 선택된 설비의 공정만 필터링
+ const availableProcesses = selectedEquipment.processIds.map((processId, index) => {
+ const process = processes.find((p) => p.id === processId);
+ return {
+ id: processId,
+ name: selectedEquipment.processNames[index],
+ code: process?.code || "",
+ };
+ });
+
+ return (
+ e.target === e.currentTarget && onClose()}>
+
+
+
공정 선택
+
+
+
+
+
+
+
+ {availableProcesses.map((process) => (
+
setTempSelected(process as Process)}
+ >
+
✓
+
{process.name}
+
{process.code}
+
+ ))}
+
+
+
+
+
+ 취소
+
+
+ 확인
+
+
+
+
+ );
+}
+
diff --git a/frontend/components/pop/PopProductionPanel.tsx b/frontend/components/pop/PopProductionPanel.tsx
new file mode 100644
index 00000000..6d61bd9b
--- /dev/null
+++ b/frontend/components/pop/PopProductionPanel.tsx
@@ -0,0 +1,346 @@
+"use client";
+
+import React from "react";
+import { X, Play, Square, ChevronRight } from "lucide-react";
+import { WorkOrder, WorkStep } from "./types";
+
+interface PopProductionPanelProps {
+ isOpen: boolean;
+ workOrder: WorkOrder | null;
+ workSteps: WorkStep[];
+ currentStepIndex: number;
+ currentDateTime: Date;
+ onStepChange: (index: number) => void;
+ onStepsUpdate: (steps: WorkStep[]) => void;
+ onClose: () => void;
+}
+
+export function PopProductionPanel({
+ isOpen,
+ workOrder,
+ workSteps,
+ currentStepIndex,
+ currentDateTime,
+ onStepChange,
+ onStepsUpdate,
+ onClose,
+}: PopProductionPanelProps) {
+ if (!isOpen || !workOrder) return null;
+
+ const currentStep = workSteps[currentStepIndex];
+
+ const formatDate = (date: Date) => {
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, "0");
+ const day = String(date.getDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
+ };
+
+ const formatTime = (date: Date | null) => {
+ if (!date) return "--:--";
+ const d = new Date(date);
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
+ };
+
+ const handleStartStep = () => {
+ const newSteps = [...workSteps];
+ newSteps[currentStepIndex] = {
+ ...newSteps[currentStepIndex],
+ status: "in-progress",
+ startTime: new Date(),
+ };
+ onStepsUpdate(newSteps);
+ };
+
+ const handleEndStep = () => {
+ const newSteps = [...workSteps];
+ newSteps[currentStepIndex] = {
+ ...newSteps[currentStepIndex],
+ endTime: new Date(),
+ };
+ onStepsUpdate(newSteps);
+ };
+
+ const handleSaveAndNext = () => {
+ const newSteps = [...workSteps];
+ const step = newSteps[currentStepIndex];
+
+ // 시간 자동 설정
+ if (!step.startTime) step.startTime = new Date();
+ if (!step.endTime) step.endTime = new Date();
+ step.status = "completed";
+
+ onStepsUpdate(newSteps);
+
+ // 다음 단계로 이동
+ if (currentStepIndex < workSteps.length - 1) {
+ onStepChange(currentStepIndex + 1);
+ }
+ };
+
+ const renderStepForm = () => {
+ if (!currentStep) return null;
+
+ const isCompleted = currentStep.status === "completed";
+
+ if (currentStep.type === "work" || currentStep.type === "record") {
+ return (
+
+
작업 내용 입력
+
+
+ 비고
+
+
+
+ );
+ }
+
+ if (currentStep.type === "equipment-check" || currentStep.type === "inspection") {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ };
+
+ return (
+
+
+
+ {/* 헤더 */}
+
+
+
생산진행
+ {workOrder.processName}
+
+
+
+ {formatDate(currentDateTime)}
+
+ {formatTime(currentDateTime)}
+
+
+
+
+
+
+
+
+ {/* 작업지시 정보 */}
+
+
+
+ 작업지시
+ {workOrder.id}
+
+
+ 품목
+ {workOrder.itemName}
+
+
+ 규격
+ {workOrder.spec}
+
+
+ 지시수량
+ {workOrder.orderQuantity} EA
+
+
+ 생산수량
+ {workOrder.producedQuantity} EA
+
+
+ 납기일
+ {workOrder.dueDate}
+
+
+
+
+ {/* 바디 */}
+
+
+ {/* 작업순서 사이드바 */}
+
+
작업순서
+
+ {workSteps.map((step, index) => (
+
onStepChange(index)}
+ >
+
{index + 1}
+
+
{step.name}
+
+ {formatTime(step.startTime)} ~ {formatTime(step.endTime)}
+
+
+
+ {step.status === "completed" ? "완료" : step.status === "in-progress" ? "진행중" : "대기"}
+
+
+ ))}
+
+
+
+ {/* 작업 콘텐츠 영역 */}
+
+ {currentStep && (
+ <>
+ {/* 스텝 헤더 */}
+
+
{currentStep.name}
+
{currentStep.description}
+
+
+ {/* 시간 컨트롤 */}
+ {currentStep.status !== "completed" && (
+
+
+
+ 시작 {currentStep.startTime ? formatTime(currentStep.startTime) : ""}
+
+
+
+ 종료 {currentStep.endTime ? formatTime(currentStep.endTime) : ""}
+
+
+ )}
+
+ {/* 폼 */}
+ {renderStepForm()}
+
+ {/* 액션 버튼 */}
+ {currentStep.status !== "completed" && (
+
+ onStepChange(Math.max(0, currentStepIndex - 1))}
+ disabled={currentStepIndex === 0}
+ >
+ 이전
+
+
+ {currentStepIndex === workSteps.length - 1 ? "완료" : "저장 후 다음"}
+
+
+
+ )}
+
+ {/* 완료 메시지 */}
+ {currentStep.status === "completed" && (
+
+ )}
+ >
+ )}
+
+
+
+
+ {/* 푸터 */}
+
+
+ 닫기
+
+
+ 작업 완료
+
+
+
+
+ );
+}
+
diff --git a/frontend/components/pop/PopSettingsModal.tsx b/frontend/components/pop/PopSettingsModal.tsx
new file mode 100644
index 00000000..2b385bb8
--- /dev/null
+++ b/frontend/components/pop/PopSettingsModal.tsx
@@ -0,0 +1,135 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import { X } from "lucide-react";
+
+interface PopSettingsModalProps {
+ isOpen: boolean;
+ selectionMode: "single" | "multi";
+ completionAction: "close" | "stay";
+ onSave: (selectionMode: "single" | "multi", completionAction: "close" | "stay") => void;
+ onClose: () => void;
+}
+
+export function PopSettingsModal({
+ isOpen,
+ selectionMode,
+ completionAction,
+ onSave,
+ onClose,
+}: PopSettingsModalProps) {
+ const [tempSelectionMode, setTempSelectionMode] = useState(selectionMode);
+ const [tempCompletionAction, setTempCompletionAction] = useState(completionAction);
+
+ useEffect(() => {
+ setTempSelectionMode(selectionMode);
+ setTempCompletionAction(completionAction);
+ }, [selectionMode, completionAction, isOpen]);
+
+ const handleSave = () => {
+ onSave(tempSelectionMode, tempCompletionAction);
+ };
+
+ if (!isOpen) return null;
+
+ return (
+ e.target === e.currentTarget && onClose()}>
+
+
+
설정
+
+
+
+
+
+
+ {/* 선택 모드 */}
+
+
설비/공정 선택 모드
+
+
+ setTempSelectionMode("single")}
+ />
+
+
단일 선택 모드
+
+ 설비와 공정을 선택하여 해당 작업만 표시합니다.
+
+
+
+
+ setTempSelectionMode("multi")}
+ />
+
+
다중 선택 모드
+
+ 모든 설비/공정의 작업을 표시합니다.
+
+
+
+
+
+
+
+
+ {/* 완료 후 동작 */}
+
+
작업 완료 후 동작
+
+
+ setTempCompletionAction("close")}
+ />
+
+
패널 닫기
+
+ 작업 완료 시 생산진행 패널을 자동으로 닫습니다.
+
+
+
+
+ setTempCompletionAction("stay")}
+ />
+
+
패널 유지
+
+ 작업 완료 후에도 패널을 유지합니다.
+
+
+
+
+
+
+
+
+
+ 취소
+
+
+ 저장
+
+
+
+
+ );
+}
+
diff --git a/frontend/components/pop/PopStatusTabs.tsx b/frontend/components/pop/PopStatusTabs.tsx
new file mode 100644
index 00000000..d9c610fb
--- /dev/null
+++ b/frontend/components/pop/PopStatusTabs.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import React from "react";
+import { StatusType } from "./types";
+
+interface StatusCounts {
+ waitingCount: number;
+ pendingAcceptCount: number;
+ inProgressCount: number;
+ completedCount: number;
+}
+
+interface PopStatusTabsProps {
+ currentStatus: StatusType;
+ counts: StatusCounts;
+ onStatusChange: (status: StatusType) => void;
+}
+
+const STATUS_CONFIG: {
+ id: StatusType;
+ label: string;
+ detail: string;
+ countKey: keyof StatusCounts;
+}[] = [
+ { id: "waiting", label: "대기", detail: "내 공정 이전", countKey: "waitingCount" },
+ { id: "pending-accept", label: "접수대기", detail: "내 차례", countKey: "pendingAcceptCount" },
+ { id: "in-progress", label: "진행", detail: "작업중", countKey: "inProgressCount" },
+ { id: "completed", label: "완료", detail: "처리완료", countKey: "completedCount" },
+];
+
+export function PopStatusTabs({ currentStatus, counts, onStatusChange }: PopStatusTabsProps) {
+ return (
+
+ {STATUS_CONFIG.map((status) => (
+
onStatusChange(status.id)}
+ >
+ {status.label}
+ {counts[status.countKey]}
+ {status.detail}
+
+ ))}
+
+ );
+}
+
diff --git a/frontend/components/pop/PopWorkCard.tsx b/frontend/components/pop/PopWorkCard.tsx
new file mode 100644
index 00000000..2e3416dd
--- /dev/null
+++ b/frontend/components/pop/PopWorkCard.tsx
@@ -0,0 +1,274 @@
+"use client";
+
+import React, { useRef, useEffect, useState } from "react";
+import { WorkOrder, Process, StatusType } from "./types";
+import { STATUS_TEXT } from "./data";
+
+interface PopWorkCardProps {
+ workOrder: WorkOrder;
+ currentStatus: StatusType;
+ selectedProcess: Process | null;
+ onAccept: () => void;
+ onCancelAccept: () => void;
+ onStartProduction: () => void;
+ onClick: () => void;
+}
+
+export function PopWorkCard({
+ workOrder,
+ currentStatus,
+ selectedProcess,
+ onAccept,
+ onCancelAccept,
+ onStartProduction,
+ onClick,
+}: PopWorkCardProps) {
+ const chipsRef = useRef(null);
+ const [showLeftBtn, setShowLeftBtn] = useState(false);
+ const [showRightBtn, setShowRightBtn] = useState(false);
+
+ const progress = ((workOrder.producedQuantity / workOrder.orderQuantity) * 100).toFixed(1);
+ const isReturnWork = workOrder.isReturn === true;
+
+ // 공정 스크롤 버튼 표시 여부 확인
+ const checkScrollButtons = () => {
+ const container = chipsRef.current;
+ if (!container) return;
+
+ const isScrollable = container.scrollWidth > container.clientWidth;
+ if (isScrollable) {
+ const scrollLeft = container.scrollLeft;
+ const maxScroll = container.scrollWidth - container.clientWidth;
+ setShowLeftBtn(scrollLeft > 5);
+ setShowRightBtn(scrollLeft < maxScroll - 5);
+ } else {
+ setShowLeftBtn(false);
+ setShowRightBtn(false);
+ }
+ };
+
+ // 현재 공정으로 스크롤
+ const scrollToCurrentProcess = () => {
+ const container = chipsRef.current;
+ if (!container || !workOrder.processFlow) return;
+
+ let targetIndex = -1;
+
+ // 내 공정 우선
+ if (selectedProcess) {
+ targetIndex = workOrder.processFlow.findIndex(
+ (step) =>
+ step.id === selectedProcess.id &&
+ (step.status === "current" || step.status === "pending")
+ );
+ }
+
+ // 없으면 현재 진행 중인 공정
+ if (targetIndex === -1) {
+ targetIndex = workOrder.processFlow.findIndex((step) => step.status === "current");
+ }
+
+ if (targetIndex === -1) return;
+
+ const chips = container.querySelectorAll(".pop-process-chip");
+ if (chips.length > targetIndex) {
+ const targetChip = chips[targetIndex] as HTMLElement;
+ const scrollPos =
+ targetChip.offsetLeft - container.clientWidth / 2 + targetChip.offsetWidth / 2;
+ container.scrollLeft = Math.max(0, scrollPos);
+ }
+ };
+
+ useEffect(() => {
+ scrollToCurrentProcess();
+ checkScrollButtons();
+
+ const container = chipsRef.current;
+ if (container) {
+ container.addEventListener("scroll", checkScrollButtons);
+ return () => container.removeEventListener("scroll", checkScrollButtons);
+ }
+ }, [workOrder, selectedProcess]);
+
+ const handleScroll = (direction: "left" | "right", e: React.MouseEvent) => {
+ e.stopPropagation();
+ const container = chipsRef.current;
+ if (!container) return;
+
+ const scrollAmount = 150;
+ container.scrollLeft += direction === "left" ? -scrollAmount : scrollAmount;
+ setTimeout(checkScrollButtons, 100);
+ };
+
+ // 상태 텍스트 결정
+ const statusText =
+ isReturnWork && currentStatus === "pending-accept" ? "리턴" : STATUS_TEXT[workOrder.status];
+ const statusClass = isReturnWork ? "return" : workOrder.status;
+
+ // 완료된 공정 수
+ const completedCount = workOrder.processFlow.filter((s) => s.status === "completed").length;
+ const totalCount = workOrder.processFlow.length;
+
+ return (
+
+ {/* 헤더 */}
+
+
+ {workOrder.id}
+ {isReturnWork && 리턴 }
+ {workOrder.acceptedQuantity && workOrder.acceptedQuantity > 0 && workOrder.acceptedQuantity < workOrder.orderQuantity && (
+
+ {workOrder.acceptedQuantity}/{workOrder.orderQuantity} 접수
+
+ )}
+
+
{statusText}
+
+ {/* 액션 버튼 */}
+ {currentStatus === "pending-accept" && (
+
+ {
+ e.stopPropagation();
+ onAccept();
+ }}
+ >
+ 접수
+
+
+ )}
+ {currentStatus === "in-progress" && (
+
+ {
+ e.stopPropagation();
+ onCancelAccept();
+ }}
+ >
+ 접수취소
+
+ {
+ e.stopPropagation();
+ onStartProduction();
+ }}
+ >
+ 생산진행
+
+
+ )}
+
+
+ {/* 리턴 정보 배너 */}
+ {isReturnWork && currentStatus === "pending-accept" && (
+
+
🔄
+
+
+ {workOrder.returnFromProcessName} 공정에서 리턴됨
+
+
{workOrder.returnReason || "사유 없음"}
+
+
+ )}
+
+ {/* 바디 */}
+
+
+
+ 품목
+ {workOrder.itemName}
+
+
+ 규격
+ {workOrder.spec}
+
+
+ 지시
+ {workOrder.orderQuantity}
+
+
+ 납기
+ {workOrder.dueDate}
+
+
+
+
+ {/* 공정 타임라인 */}
+
+
+
+ 공정 진행
+
+ {completedCount} /{totalCount}
+
+
+
+ {workOrder.processFlow.map((step, index) => {
+ let segmentClass = "";
+ if (step.status === "completed") segmentClass = "done";
+ else if (step.status === "current") segmentClass = "current";
+ if (selectedProcess && step.id === selectedProcess.id) {
+ segmentClass += " my-work";
+ }
+ return
;
+ })}
+
+
+
+
+
handleScroll("left", e)}
+ >
+ ‹
+
+
+ {workOrder.processFlow.map((step, index) => {
+ let chipClass = "";
+ if (step.status === "completed") chipClass = "done";
+ else if (step.status === "current") chipClass = "current";
+ if (selectedProcess && step.id === selectedProcess.id) {
+ chipClass += " my-work";
+ }
+ return (
+
+ {index + 1}
+ {step.name}
+
+ );
+ })}
+
+
handleScroll("right", e)}
+ >
+ ›
+
+
+
+
+ {/* 진행률 바 */}
+ {workOrder.status !== "completed" && (
+
+
+
+ {workOrder.producedQuantity} / {workOrder.orderQuantity} EA
+
+ {progress}%
+
+
+
+ )}
+
+ );
+}
+
diff --git a/frontend/components/pop/dashboard/ActivityList.tsx b/frontend/components/pop/dashboard/ActivityList.tsx
new file mode 100644
index 00000000..e0efd123
--- /dev/null
+++ b/frontend/components/pop/dashboard/ActivityList.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+import React from "react";
+import { ActivityItem } from "./types";
+
+interface ActivityListProps {
+ items: ActivityItem[];
+ onMoreClick: () => void;
+}
+
+export function ActivityList({ items, onMoreClick }: ActivityListProps) {
+ return (
+
+
+
최근 활동
+
+ 전체보기
+
+
+
+ {items.map((item) => (
+
+
{item.time}
+
+
+
{item.title}
+
{item.description}
+
+
+ ))}
+
+
+ );
+}
+
diff --git a/frontend/components/pop/dashboard/DashboardFooter.tsx b/frontend/components/pop/dashboard/DashboardFooter.tsx
new file mode 100644
index 00000000..202427fc
--- /dev/null
+++ b/frontend/components/pop/dashboard/DashboardFooter.tsx
@@ -0,0 +1,24 @@
+"use client";
+
+import React from "react";
+
+interface DashboardFooterProps {
+ companyName: string;
+ version: string;
+ emergencyContact: string;
+}
+
+export function DashboardFooter({
+ companyName,
+ version,
+ emergencyContact,
+}: DashboardFooterProps) {
+ return (
+
+ © 2024 {companyName}
+ Version {version}
+ 긴급연락: {emergencyContact}
+
+ );
+}
+
diff --git a/frontend/components/pop/dashboard/DashboardHeader.tsx b/frontend/components/pop/dashboard/DashboardHeader.tsx
new file mode 100644
index 00000000..a16cbb05
--- /dev/null
+++ b/frontend/components/pop/dashboard/DashboardHeader.tsx
@@ -0,0 +1,96 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import { Moon, Sun } from "lucide-react";
+import { WeatherInfo, UserInfo, CompanyInfo } from "./types";
+
+interface DashboardHeaderProps {
+ theme: "dark" | "light";
+ weather: WeatherInfo;
+ user: UserInfo;
+ company: CompanyInfo;
+ onThemeToggle: () => void;
+ onUserClick: () => void;
+}
+
+export function DashboardHeader({
+ theme,
+ weather,
+ user,
+ company,
+ onThemeToggle,
+ onUserClick,
+}: DashboardHeaderProps) {
+ const [mounted, setMounted] = useState(false);
+ const [currentTime, setCurrentTime] = useState(new Date());
+
+ useEffect(() => {
+ setMounted(true);
+ const timer = setInterval(() => {
+ setCurrentTime(new Date());
+ }, 1000);
+ return () => clearInterval(timer);
+ }, []);
+
+ const formatTime = (date: Date) => {
+ const hours = String(date.getHours()).padStart(2, "0");
+ const minutes = String(date.getMinutes()).padStart(2, "0");
+ const seconds = String(date.getSeconds()).padStart(2, "0");
+ return `${hours}:${minutes}:${seconds}`;
+ };
+
+ const formatDate = (date: Date) => {
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, "0");
+ const day = String(date.getDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
+ };
+
+ return (
+
+ );
+}
+
diff --git a/frontend/components/pop/dashboard/KpiBar.tsx b/frontend/components/pop/dashboard/KpiBar.tsx
new file mode 100644
index 00000000..8d75df7d
--- /dev/null
+++ b/frontend/components/pop/dashboard/KpiBar.tsx
@@ -0,0 +1,60 @@
+"use client";
+
+import React from "react";
+import { KpiItem } from "./types";
+
+interface KpiBarProps {
+ items: KpiItem[];
+}
+
+export function KpiBar({ items }: KpiBarProps) {
+ const getStrokeDashoffset = (percentage: number) => {
+ const circumference = 264; // 2 * PI * 42
+ return circumference - (circumference * percentage) / 100;
+ };
+
+ const formatValue = (value: number) => {
+ if (value >= 1000) {
+ return value.toLocaleString();
+ }
+ return value.toString();
+ };
+
+ return (
+
+ {items.map((item) => (
+
+
+
+
+
+
+
+ {item.percentage}%
+
+
+
+
{item.label}
+
+ {formatValue(item.value)}
+ {item.unit}
+
+
+
+ ))}
+
+ );
+}
+
diff --git a/frontend/components/pop/dashboard/MenuGrid.tsx b/frontend/components/pop/dashboard/MenuGrid.tsx
new file mode 100644
index 00000000..d921d156
--- /dev/null
+++ b/frontend/components/pop/dashboard/MenuGrid.tsx
@@ -0,0 +1,43 @@
+"use client";
+
+import React from "react";
+import { useRouter } from "next/navigation";
+import { MenuItem } from "./types";
+
+interface MenuGridProps {
+ items: MenuItem[];
+}
+
+export function MenuGrid({ items }: MenuGridProps) {
+ const router = useRouter();
+
+ const handleClick = (item: MenuItem) => {
+ if (item.href === "#") {
+ alert(`${item.title} 화면은 준비 중입니다.`);
+ } else {
+ router.push(item.href);
+ }
+ };
+
+ return (
+
+ {items.map((item) => (
+
handleClick(item)}
+ >
+
+
{item.title}
+
+ {item.count}
+
+
+
{item.description}
+
{item.status}
+
+ ))}
+
+ );
+}
+
diff --git a/frontend/components/pop/dashboard/NoticeBanner.tsx b/frontend/components/pop/dashboard/NoticeBanner.tsx
new file mode 100644
index 00000000..011142e6
--- /dev/null
+++ b/frontend/components/pop/dashboard/NoticeBanner.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import React from "react";
+
+interface NoticeBannerProps {
+ text: string;
+}
+
+export function NoticeBanner({ text }: NoticeBannerProps) {
+ return (
+
+ );
+}
+
diff --git a/frontend/components/pop/dashboard/NoticeList.tsx b/frontend/components/pop/dashboard/NoticeList.tsx
new file mode 100644
index 00000000..fdad92a7
--- /dev/null
+++ b/frontend/components/pop/dashboard/NoticeList.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import React from "react";
+import { NoticeItem } from "./types";
+
+interface NoticeListProps {
+ items: NoticeItem[];
+ onMoreClick: () => void;
+}
+
+export function NoticeList({ items, onMoreClick }: NoticeListProps) {
+ return (
+
+
+
공지사항
+
+ 더보기
+
+
+
+ {items.map((item) => (
+
+
{item.title}
+
{item.date}
+
+ ))}
+
+
+ );
+}
+
diff --git a/frontend/components/pop/dashboard/PopDashboard.tsx b/frontend/components/pop/dashboard/PopDashboard.tsx
new file mode 100644
index 00000000..dde9498b
--- /dev/null
+++ b/frontend/components/pop/dashboard/PopDashboard.tsx
@@ -0,0 +1,83 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import { DashboardHeader } from "./DashboardHeader";
+import { NoticeBanner } from "./NoticeBanner";
+import { KpiBar } from "./KpiBar";
+import { MenuGrid } from "./MenuGrid";
+import { ActivityList } from "./ActivityList";
+import { NoticeList } from "./NoticeList";
+import { DashboardFooter } from "./DashboardFooter";
+import {
+ KPI_ITEMS,
+ MENU_ITEMS,
+ ACTIVITY_ITEMS,
+ NOTICE_ITEMS,
+ NOTICE_MARQUEE_TEXT,
+} from "./data";
+import "./dashboard.css";
+
+export function PopDashboard() {
+ const [theme, setTheme] = useState<"dark" | "light">("dark");
+
+ // 로컬 스토리지에서 테마 로드
+ useEffect(() => {
+ const savedTheme = localStorage.getItem("popTheme") as "dark" | "light" | null;
+ if (savedTheme) {
+ setTheme(savedTheme);
+ }
+ }, []);
+
+ const handleThemeToggle = () => {
+ const newTheme = theme === "dark" ? "light" : "dark";
+ setTheme(newTheme);
+ localStorage.setItem("popTheme", newTheme);
+ };
+
+ const handleUserClick = () => {
+ if (confirm("로그아웃 하시겠습니까?")) {
+ alert("로그아웃되었습니다.");
+ }
+ };
+
+ const handleActivityMore = () => {
+ alert("전체 활동 내역 화면으로 이동합니다.");
+ };
+
+ const handleNoticeMore = () => {
+ alert("전체 공지사항 화면으로 이동합니다.");
+ };
+
+ return (
+
+ );
+}
+
diff --git a/frontend/components/pop/dashboard/dashboard.css b/frontend/components/pop/dashboard/dashboard.css
new file mode 100644
index 00000000..a78d7926
--- /dev/null
+++ b/frontend/components/pop/dashboard/dashboard.css
@@ -0,0 +1,906 @@
+/* ============================================
+ POP 대시보드 스타일시트
+ 다크 모드 (사이버펑크) + 라이트 모드 (소프트 그레이 민트)
+ ============================================ */
+
+/* ========== 다크 모드 (기본) ========== */
+.pop-dashboard-container {
+ --db-bg-page: #080c15;
+ --db-bg-card: linear-gradient(145deg, rgba(25, 35, 60, 0.9) 0%, rgba(18, 26, 47, 0.95) 100%);
+ --db-bg-card-solid: #121a2f;
+ --db-bg-card-alt: rgba(0, 0, 0, 0.2);
+ --db-bg-elevated: #202d4b;
+
+ --db-accent-primary: #00d4ff;
+ --db-accent-primary-light: #00f0ff;
+ --db-indigo: #4169e1;
+ --db-violet: #8a2be2;
+ --db-mint: #00d4ff;
+ --db-emerald: #00ff88;
+ --db-amber: #ffaa00;
+ --db-rose: #ff3333;
+
+ --db-text-primary: #ffffff;
+ --db-text-secondary: #b4c3dc;
+ --db-text-muted: #64788c;
+
+ --db-border: rgba(40, 55, 85, 1);
+ --db-border-light: rgba(55, 75, 110, 1);
+
+ --db-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3);
+ --db-shadow-md: 0 4px 16px rgba(0, 0, 0, 0.4);
+ --db-shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.5);
+ --db-glow-accent: 0 0 20px rgba(0, 212, 255, 0.5), 0 0 40px rgba(0, 212, 255, 0.3);
+
+ --db-radius-sm: 6px;
+ --db-radius-md: 10px;
+ --db-radius-lg: 14px;
+
+ --db-card-border-production: rgba(65, 105, 225, 0.5);
+ --db-card-border-material: rgba(138, 43, 226, 0.5);
+ --db-card-border-quality: rgba(0, 212, 255, 0.5);
+ --db-card-border-equipment: rgba(0, 255, 136, 0.5);
+ --db-card-border-safety: rgba(255, 170, 0, 0.5);
+
+ --db-notice-bg: rgba(255, 170, 0, 0.1);
+ --db-notice-border: rgba(255, 170, 0, 0.3);
+ --db-notice-text: #ffaa00;
+
+ --db-weather-bg: rgba(0, 0, 0, 0.2);
+ --db-weather-border: rgba(40, 55, 85, 1);
+
+ --db-user-badge-bg: rgba(0, 0, 0, 0.3);
+ --db-user-badge-hover: rgba(0, 212, 255, 0.1);
+
+ --db-btn-more-bg: rgba(0, 212, 255, 0.08);
+ --db-btn-more-border: rgba(0, 212, 255, 0.2);
+ --db-btn-more-color: #00d4ff;
+
+ --db-status-bg: rgba(0, 212, 255, 0.1);
+ --db-status-border: rgba(0, 212, 255, 0.2);
+ --db-status-color: #00d4ff;
+}
+
+/* ========== 라이트 모드 ========== */
+.pop-dashboard-container.light {
+ --db-bg-page: #f8f9fb;
+ --db-bg-card: #ffffff;
+ --db-bg-card-solid: #ffffff;
+ --db-bg-card-alt: #f3f5f7;
+ --db-bg-elevated: #fafbfc;
+
+ --db-accent-primary: #14b8a6;
+ --db-accent-primary-light: #2dd4bf;
+ --db-indigo: #6366f1;
+ --db-violet: #8b5cf6;
+ --db-mint: #14b8a6;
+ --db-emerald: #10b981;
+ --db-amber: #f59e0b;
+ --db-rose: #f43f5e;
+
+ --db-text-primary: #1e293b;
+ --db-text-secondary: #475569;
+ --db-text-muted: #94a3b8;
+
+ --db-border: #e2e8f0;
+ --db-border-light: #f1f5f9;
+
+ --db-shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.06);
+ --db-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
+ --db-shadow-lg: 0 10px 25px -5px rgba(0, 0, 0, 0.08), 0 8px 10px -6px rgba(0, 0, 0, 0.04);
+ --db-glow-accent: none;
+
+ --db-card-border-production: rgba(99, 102, 241, 0.3);
+ --db-card-border-material: rgba(139, 92, 246, 0.3);
+ --db-card-border-quality: rgba(20, 184, 166, 0.3);
+ --db-card-border-equipment: rgba(16, 185, 129, 0.3);
+ --db-card-border-safety: rgba(245, 158, 11, 0.3);
+
+ --db-notice-bg: linear-gradient(90deg, rgba(245, 158, 11, 0.08), rgba(251, 191, 36, 0.05));
+ --db-notice-border: rgba(245, 158, 11, 0.2);
+ --db-notice-text: #475569;
+
+ --db-weather-bg: rgba(20, 184, 166, 0.08);
+ --db-weather-border: rgba(20, 184, 166, 0.25);
+
+ --db-user-badge-bg: #f3f5f7;
+ --db-user-badge-hover: #e2e8f0;
+
+ --db-btn-more-bg: rgba(20, 184, 166, 0.08);
+ --db-btn-more-border: rgba(20, 184, 166, 0.25);
+ --db-btn-more-color: #0d9488;
+
+ --db-status-bg: #f3f5f7;
+ --db-status-border: transparent;
+ --db-status-color: #475569;
+}
+
+/* ========== 기본 컨테이너 ========== */
+.pop-dashboard-container {
+ font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
+ background: var(--db-bg-page);
+ color: var(--db-text-primary);
+ min-height: 100vh;
+ min-height: 100dvh;
+ transition: background 0.3s, color 0.3s;
+ position: relative;
+}
+
+/* 다크 모드 배경 그리드 */
+.pop-dashboard-container::before {
+ content: '';
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background:
+ repeating-linear-gradient(90deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px),
+ repeating-linear-gradient(0deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px),
+ radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0, 212, 255, 0.08) 0%, transparent 60%),
+ linear-gradient(180deg, #080c15 0%, #0a0f1c 50%, #0d1323 100%);
+ pointer-events: none;
+ z-index: 0;
+}
+
+.pop-dashboard-container.light::before {
+ background: linear-gradient(180deg, #f1f5f9 0%, #f8fafc 50%, #ffffff 100%);
+}
+
+.pop-dashboard {
+ position: relative;
+ z-index: 1;
+ max-width: 1600px;
+ margin: 0 auto;
+ padding: 20px;
+ min-height: 100vh;
+ min-height: 100dvh;
+}
+
+/* ========== 헤더 ========== */
+.pop-dashboard-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 16px;
+ padding: 14px 24px;
+ background: var(--db-bg-card);
+ border: 1px solid var(--db-border);
+ border-radius: var(--db-radius-lg);
+ box-shadow: var(--db-shadow-sm);
+ position: relative;
+ overflow: hidden;
+}
+
+.pop-dashboard-container:not(.light) .pop-dashboard-header::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 20%;
+ right: 20%;
+ height: 2px;
+ background: linear-gradient(90deg, transparent, var(--db-accent-primary), transparent);
+}
+
+.pop-dashboard-header-left {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.pop-dashboard-time-display {
+ display: flex;
+ align-items: baseline;
+ gap: 12px;
+}
+
+.pop-dashboard-time-main {
+ font-size: 26px;
+ font-weight: 600;
+ letter-spacing: 1px;
+ color: var(--db-accent-primary);
+ line-height: 1;
+ font-variant-numeric: tabular-nums;
+}
+
+.pop-dashboard-container:not(.light) .pop-dashboard-time-main {
+ text-shadow: var(--db-glow-accent);
+ animation: neonFlicker 3s infinite;
+}
+
+.pop-dashboard-time-date {
+ font-size: 13px;
+ color: var(--db-text-muted);
+ font-variant-numeric: tabular-nums;
+}
+
+.pop-dashboard-header-right {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+}
+
+/* 테마 토글 */
+.pop-dashboard-theme-toggle {
+ width: 36px;
+ height: 36px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--db-bg-card-alt);
+ border: 1px solid var(--db-border);
+ border-radius: var(--db-radius-md);
+ color: var(--db-text-secondary);
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.pop-dashboard-theme-toggle:hover {
+ border-color: var(--db-accent-primary);
+ color: var(--db-accent-primary);
+}
+
+/* 날씨 정보 */
+.pop-dashboard-weather {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 12px;
+ background: var(--db-weather-bg);
+ border: 1px solid var(--db-weather-border);
+ border-radius: var(--db-radius-md);
+}
+
+.pop-dashboard-weather-temp {
+ font-size: 13px;
+ font-weight: 700;
+ color: var(--db-amber);
+}
+
+.pop-dashboard-container.light .pop-dashboard-weather-temp {
+ color: var(--db-accent-primary);
+}
+
+.pop-dashboard-weather-desc {
+ font-size: 11px;
+ color: var(--db-text-muted);
+}
+
+/* 회사 정보 */
+.pop-dashboard-company {
+ padding-right: 14px;
+ border-right: 1px solid var(--db-border);
+ text-align: right;
+}
+
+.pop-dashboard-company-name {
+ font-size: 15px;
+ font-weight: 700;
+ color: var(--db-text-primary);
+}
+
+.pop-dashboard-company-sub {
+ font-size: 11px;
+ color: var(--db-text-muted);
+ margin-top: 2px;
+}
+
+/* 사용자 배지 */
+.pop-dashboard-user-badge {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 14px;
+ background: var(--db-user-badge-bg);
+ border: 1px solid var(--db-border);
+ border-radius: var(--db-radius-md);
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.pop-dashboard-user-badge:hover {
+ background: var(--db-user-badge-hover);
+}
+
+.pop-dashboard-user-badge:active {
+ transform: scale(0.98);
+}
+
+.pop-dashboard-user-avatar {
+ width: 32px;
+ height: 32px;
+ background: linear-gradient(135deg, var(--db-accent-primary), var(--db-emerald));
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 13px;
+ font-weight: 700;
+ color: white;
+}
+
+.pop-dashboard-container:not(.light) .pop-dashboard-user-avatar {
+ box-shadow: 0 0 10px rgba(0, 212, 255, 0.4);
+}
+
+.pop-dashboard-container.light .pop-dashboard-user-avatar {
+ box-shadow: 0 2px 8px rgba(20, 184, 166, 0.3);
+}
+
+.pop-dashboard-user-name {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--db-text-primary);
+}
+
+.pop-dashboard-user-role {
+ font-size: 11px;
+ color: var(--db-text-muted);
+}
+
+/* ========== 공지사항 배너 ========== */
+.pop-dashboard-notice-banner {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 16px;
+ padding: 10px 16px;
+ background: var(--db-notice-bg);
+ border: 1px solid var(--db-notice-border);
+ border-radius: var(--db-radius-md);
+}
+
+.pop-dashboard-notice-label {
+ font-size: 10px;
+ font-weight: 700;
+ color: var(--db-bg-page);
+ background: var(--db-amber);
+ padding: 3px 8px;
+ border-radius: 4px;
+ flex-shrink: 0;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.pop-dashboard-container.light .pop-dashboard-notice-label {
+ color: white;
+}
+
+.pop-dashboard-notice-content {
+ flex: 1;
+ overflow: hidden;
+}
+
+.pop-dashboard-notice-marquee {
+ display: flex;
+ animation: dashboardMarquee 30s linear infinite;
+ white-space: nowrap;
+}
+
+.pop-dashboard-notice-text {
+ font-size: 12px;
+ color: var(--db-notice-text);
+ padding-right: 100px;
+}
+
+@keyframes dashboardMarquee {
+ 0% { transform: translateX(0); }
+ 100% { transform: translateX(-50%); }
+}
+
+.pop-dashboard-notice-banner:hover .pop-dashboard-notice-marquee {
+ animation-play-state: paused;
+}
+
+/* ========== KPI 바 ========== */
+.pop-dashboard-kpi-bar {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 12px;
+ margin-bottom: 16px;
+}
+
+.pop-dashboard-kpi-item {
+ background: var(--db-bg-card);
+ border: 1px solid var(--db-border);
+ border-radius: var(--db-radius-lg);
+ padding: 16px 18px;
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ box-shadow: var(--db-shadow-sm);
+ transition: all 0.2s;
+ position: relative;
+ overflow: hidden;
+}
+
+.pop-dashboard-container:not(.light) .pop-dashboard-kpi-item::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 1px;
+ background: linear-gradient(90deg, transparent, rgba(0, 212, 255, 0.5), transparent);
+}
+
+.pop-dashboard-kpi-item:hover {
+ transform: translateY(-2px);
+ box-shadow: var(--db-shadow-md);
+}
+
+.pop-dashboard-container:not(.light) .pop-dashboard-kpi-item:hover {
+ border-color: rgba(0, 212, 255, 0.3);
+ box-shadow: 0 0 30px rgba(0, 212, 255, 0.1), inset 0 0 30px rgba(0, 212, 255, 0.02);
+}
+
+.pop-dashboard-kpi-gauge {
+ width: 52px;
+ height: 52px;
+ position: relative;
+ flex-shrink: 0;
+}
+
+.pop-dashboard-kpi-gauge svg {
+ transform: rotate(-90deg);
+}
+
+.pop-dashboard-kpi-gauge-bg {
+ fill: none;
+ stroke: var(--db-border);
+ stroke-width: 5;
+}
+
+.pop-dashboard-kpi-gauge-fill {
+ fill: none;
+ stroke-width: 5;
+ stroke-linecap: round;
+ transition: stroke-dashoffset 0.5s;
+}
+
+.pop-dashboard-container:not(.light) .pop-dashboard-kpi-gauge-fill {
+ filter: drop-shadow(0 0 6px currentColor);
+}
+
+.pop-dashboard-container.light .pop-dashboard-kpi-gauge-fill {
+ filter: drop-shadow(0 1px 2px rgba(0,0,0,0.1));
+}
+
+.pop-dashboard-kpi-gauge-text {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ font-size: 12px;
+ font-weight: 700;
+ font-variant-numeric: tabular-nums;
+}
+
+.pop-dashboard-kpi-info { flex: 1; }
+
+.pop-dashboard-kpi-label {
+ font-size: 11px;
+ color: var(--db-text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-bottom: 4px;
+}
+
+.pop-dashboard-kpi-value {
+ font-size: 22px;
+ font-weight: 700;
+ font-variant-numeric: tabular-nums;
+}
+
+.pop-dashboard-kpi-unit {
+ font-size: 12px;
+ color: var(--db-text-muted);
+ margin-left: 3px;
+ font-weight: 500;
+}
+
+/* KPI 색상 */
+.kpi-color-cyan { color: var(--db-mint); stroke: var(--db-mint); }
+.kpi-color-emerald { color: var(--db-emerald); stroke: var(--db-emerald); }
+.kpi-color-rose { color: var(--db-rose); stroke: var(--db-rose); }
+.kpi-color-amber { color: var(--db-amber); stroke: var(--db-amber); }
+
+/* ========== 메뉴 그리드 ========== */
+.pop-dashboard-menu-grid {
+ display: grid;
+ grid-template-columns: repeat(5, 1fr);
+ gap: 12px;
+ margin-bottom: 16px;
+}
+
+.pop-dashboard-menu-card {
+ background: var(--db-bg-card);
+ border-radius: var(--db-radius-lg);
+ padding: 18px;
+ cursor: pointer;
+ transition: all 0.2s;
+ box-shadow: var(--db-shadow-sm);
+ position: relative;
+ overflow: hidden;
+}
+
+.pop-dashboard-menu-card:hover {
+ transform: translateY(-4px);
+ box-shadow: var(--db-shadow-lg);
+}
+
+.pop-dashboard-menu-card:active {
+ transform: scale(0.98);
+}
+
+.pop-dashboard-menu-card.production { border: 2px solid var(--db-card-border-production); }
+.pop-dashboard-menu-card.material { border: 2px solid var(--db-card-border-material); }
+.pop-dashboard-menu-card.quality { border: 2px solid var(--db-card-border-quality); }
+.pop-dashboard-menu-card.equipment { border: 2px solid var(--db-card-border-equipment); }
+.pop-dashboard-menu-card.safety { border: 2px solid var(--db-card-border-safety); }
+
+.pop-dashboard-container:not(.light) .pop-dashboard-menu-card.production:hover { box-shadow: 0 0 20px rgba(65, 105, 225, 0.3); }
+.pop-dashboard-container:not(.light) .pop-dashboard-menu-card.material:hover { box-shadow: 0 0 20px rgba(138, 43, 226, 0.3); }
+.pop-dashboard-container:not(.light) .pop-dashboard-menu-card.quality:hover { box-shadow: 0 0 20px rgba(0, 212, 255, 0.3); }
+.pop-dashboard-container:not(.light) .pop-dashboard-menu-card.equipment:hover { box-shadow: 0 0 20px rgba(0, 255, 136, 0.3); }
+.pop-dashboard-container:not(.light) .pop-dashboard-menu-card.safety:hover { box-shadow: 0 0 20px rgba(255, 170, 0, 0.3); }
+
+.pop-dashboard-menu-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: 10px;
+}
+
+.pop-dashboard-menu-title {
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--db-text-primary);
+}
+
+.pop-dashboard-menu-count {
+ font-size: 24px;
+ font-weight: 800;
+ font-variant-numeric: tabular-nums;
+}
+
+.pop-dashboard-container:not(.light) .pop-dashboard-menu-count {
+ text-shadow: 0 0 20px currentColor;
+}
+
+.pop-dashboard-menu-count.production { color: var(--db-indigo); }
+.pop-dashboard-menu-count.material { color: var(--db-violet); }
+.pop-dashboard-menu-count.quality { color: var(--db-mint); }
+.pop-dashboard-menu-count.equipment { color: var(--db-emerald); }
+.pop-dashboard-menu-count.safety { color: var(--db-amber); }
+
+.pop-dashboard-menu-desc {
+ font-size: 11px;
+ color: var(--db-text-muted);
+ line-height: 1.5;
+}
+
+.pop-dashboard-menu-status {
+ display: inline-block;
+ margin-top: 10px;
+ padding: 4px 10px;
+ background: var(--db-status-bg);
+ border: 1px solid var(--db-status-border);
+ border-radius: 16px;
+ font-size: 10px;
+ font-weight: 600;
+ color: var(--db-status-color);
+}
+
+/* ========== 하단 섹션 ========== */
+.pop-dashboard-bottom-section {
+ display: grid;
+ grid-template-columns: 2fr 1fr;
+ gap: 12px;
+}
+
+.pop-dashboard-card {
+ background: var(--db-bg-card);
+ border: 1px solid var(--db-border);
+ border-radius: var(--db-radius-lg);
+ padding: 18px;
+ box-shadow: var(--db-shadow-sm);
+ position: relative;
+ overflow: hidden;
+}
+
+.pop-dashboard-container:not(.light) .pop-dashboard-card::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 1px;
+ background: linear-gradient(90deg, transparent, rgba(0, 212, 255, 0.5), transparent);
+}
+
+.pop-dashboard-card-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 14px;
+ padding-bottom: 12px;
+ border-bottom: 1px solid var(--db-border);
+}
+
+.pop-dashboard-card-title {
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--db-text-primary);
+}
+
+.pop-dashboard-btn-more {
+ padding: 6px 12px;
+ background: var(--db-btn-more-bg);
+ border: 1px solid var(--db-btn-more-border);
+ color: var(--db-btn-more-color);
+ border-radius: var(--db-radius-sm);
+ font-size: 11px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.pop-dashboard-btn-more:hover {
+ background: var(--db-accent-primary);
+ color: white;
+ border-color: var(--db-accent-primary);
+}
+
+/* 활동 리스트 */
+.pop-dashboard-activity-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.pop-dashboard-activity-item {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px;
+ background: var(--db-bg-card-alt);
+ border-radius: var(--db-radius-md);
+ transition: all 0.2s;
+}
+
+.pop-dashboard-container:not(.light) .pop-dashboard-activity-item {
+ border: 1px solid transparent;
+}
+
+.pop-dashboard-container:not(.light) .pop-dashboard-activity-item:hover {
+ background: rgba(0, 212, 255, 0.05);
+ border-color: rgba(0, 212, 255, 0.2);
+}
+
+.pop-dashboard-container.light .pop-dashboard-activity-item:hover {
+ background: var(--db-border-light);
+}
+
+.pop-dashboard-activity-time {
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--db-accent-primary);
+ font-variant-numeric: tabular-nums;
+ min-width: 48px;
+}
+
+.pop-dashboard-container:not(.light) .pop-dashboard-activity-time {
+ text-shadow: 0 0 10px rgba(0, 212, 255, 0.5);
+}
+
+.pop-dashboard-activity-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.pop-dashboard-container:not(.light) .pop-dashboard-activity-dot {
+ box-shadow: 0 0 8px currentColor;
+}
+
+.pop-dashboard-activity-dot.production { background: var(--db-indigo); color: var(--db-indigo); }
+.pop-dashboard-activity-dot.material { background: var(--db-violet); color: var(--db-violet); }
+.pop-dashboard-activity-dot.quality { background: var(--db-mint); color: var(--db-mint); }
+.pop-dashboard-activity-dot.equipment { background: var(--db-emerald); color: var(--db-emerald); }
+
+.pop-dashboard-activity-content { flex: 1; }
+
+.pop-dashboard-activity-title {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--db-text-primary);
+ margin-bottom: 2px;
+}
+
+.pop-dashboard-activity-desc {
+ font-size: 11px;
+ color: var(--db-text-muted);
+}
+
+/* 공지사항 리스트 */
+.pop-dashboard-notice-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.pop-dashboard-notice-item {
+ padding: 12px;
+ background: var(--db-bg-card-alt);
+ border-radius: var(--db-radius-md);
+ transition: all 0.2s;
+ cursor: pointer;
+}
+
+.pop-dashboard-container:not(.light) .pop-dashboard-notice-item:hover {
+ background: rgba(255, 170, 0, 0.05);
+}
+
+.pop-dashboard-container.light .pop-dashboard-notice-item:hover {
+ background: var(--db-border-light);
+}
+
+.pop-dashboard-notice-title {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--db-text-primary);
+ margin-bottom: 4px;
+}
+
+.pop-dashboard-notice-date {
+ font-size: 11px;
+ color: var(--db-text-muted);
+ font-variant-numeric: tabular-nums;
+}
+
+/* ========== 푸터 ========== */
+.pop-dashboard-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-top: 16px;
+ padding: 14px 18px;
+ background: var(--db-bg-card);
+ border: 1px solid var(--db-border);
+ border-radius: var(--db-radius-md);
+ font-size: 11px;
+ color: var(--db-text-muted);
+}
+
+/* ========== 반응형 ========== */
+
+/* 가로 모드 */
+@media (orientation: landscape) {
+ .pop-dashboard { padding: 16px 24px; }
+ .pop-dashboard-kpi-bar { grid-template-columns: repeat(4, 1fr) !important; gap: 10px; }
+ .pop-dashboard-kpi-item { padding: 12px 14px; }
+ .pop-dashboard-kpi-gauge { width: 44px; height: 44px; }
+ .pop-dashboard-kpi-gauge svg { width: 44px; height: 44px; }
+ .pop-dashboard-kpi-value { font-size: 20px; }
+
+ .pop-dashboard-menu-grid { grid-template-columns: repeat(5, 1fr) !important; gap: 10px; }
+ .pop-dashboard-menu-card { padding: 14px; display: block; }
+ .pop-dashboard-menu-header { margin-bottom: 8px; display: block; }
+ .pop-dashboard-menu-title { font-size: 13px; }
+ .pop-dashboard-menu-count { font-size: 20px; }
+ .pop-dashboard-menu-desc { display: block; font-size: 10px; }
+ .pop-dashboard-menu-status { margin-top: 8px; }
+
+ .pop-dashboard-bottom-section { grid-template-columns: 2fr 1fr; }
+}
+
+/* 세로 모드 */
+@media (orientation: portrait) {
+ .pop-dashboard { padding: 16px; }
+ .pop-dashboard-kpi-bar { grid-template-columns: repeat(2, 1fr) !important; gap: 10px; }
+
+ .pop-dashboard-menu-grid { grid-template-columns: 1fr !important; gap: 8px; }
+ .pop-dashboard-menu-card {
+ padding: 14px 18px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+ .pop-dashboard-menu-header { margin-bottom: 0; display: flex; align-items: center; gap: 12px; }
+ .pop-dashboard-menu-title { font-size: 15px; }
+ .pop-dashboard-menu-count { font-size: 20px; }
+ .pop-dashboard-menu-desc { display: none; }
+ .pop-dashboard-menu-status { margin-top: 0; padding: 5px 12px; font-size: 11px; }
+
+ .pop-dashboard-bottom-section { grid-template-columns: 1fr; }
+}
+
+/* 작은 화면 세로 */
+@media (max-width: 600px) and (orientation: portrait) {
+ .pop-dashboard { padding: 12px; }
+ .pop-dashboard-header { padding: 10px 14px; }
+ .pop-dashboard-time-main { font-size: 20px; }
+ .pop-dashboard-time-date { display: none; }
+ .pop-dashboard-weather { padding: 4px 8px; }
+ .pop-dashboard-weather-temp { font-size: 11px; }
+ .pop-dashboard-weather-desc { display: none; }
+ .pop-dashboard-company { display: none; }
+ .pop-dashboard-user-text { display: none; }
+ .pop-dashboard-user-avatar { width: 30px; height: 30px; }
+
+ .pop-dashboard-notice-banner { padding: 8px 12px; }
+ .pop-dashboard-notice-label { font-size: 9px; }
+ .pop-dashboard-notice-text { font-size: 11px; }
+
+ .pop-dashboard-kpi-item { padding: 12px 14px; gap: 10px; }
+ .pop-dashboard-kpi-gauge { width: 44px; height: 44px; }
+ .pop-dashboard-kpi-gauge svg { width: 44px; height: 44px; }
+ .pop-dashboard-kpi-gauge-text { font-size: 10px; }
+ .pop-dashboard-kpi-label { font-size: 10px; }
+ .pop-dashboard-kpi-value { font-size: 18px; }
+
+ .pop-dashboard-menu-card { padding: 12px 16px; }
+ .pop-dashboard-menu-title { font-size: 14px; }
+ .pop-dashboard-menu-count { font-size: 18px; }
+ .pop-dashboard-menu-status { padding: 4px 10px; font-size: 10px; }
+}
+
+/* 작은 화면 가로 */
+@media (max-width: 600px) and (orientation: landscape) {
+ .pop-dashboard { padding: 10px 16px; }
+ .pop-dashboard-header { padding: 8px 12px; }
+ .pop-dashboard-time-main { font-size: 18px; }
+ .pop-dashboard-time-date { font-size: 10px; }
+ .pop-dashboard-weather { display: none; }
+ .pop-dashboard-company { display: none; }
+ .pop-dashboard-user-text { display: none; }
+
+ .pop-dashboard-notice-banner { padding: 6px 10px; margin-bottom: 10px; }
+
+ .pop-dashboard-kpi-item { padding: 8px 10px; gap: 8px; }
+ .pop-dashboard-kpi-gauge { width: 36px; height: 36px; }
+ .pop-dashboard-kpi-gauge svg { width: 36px; height: 36px; }
+ .pop-dashboard-kpi-gauge-text { font-size: 9px; }
+ .pop-dashboard-kpi-label { font-size: 9px; }
+ .pop-dashboard-kpi-value { font-size: 16px; }
+
+ .pop-dashboard-menu-card { padding: 10px; }
+ .pop-dashboard-menu-title { font-size: 11px; }
+ .pop-dashboard-menu-count { font-size: 16px; }
+ .pop-dashboard-menu-status { margin-top: 4px; padding: 2px 6px; font-size: 8px; }
+}
+
+/* ========== 애니메이션 ========== */
+@keyframes fadeIn {
+ from { opacity: 0; transform: translateY(8px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+@keyframes neonFlicker {
+ 0%, 19%, 21%, 23%, 25%, 54%, 56%, 100% { opacity: 1; }
+ 20%, 24%, 55% { opacity: 0.85; }
+}
+
+.pop-dashboard-kpi-item, .pop-dashboard-menu-card, .pop-dashboard-card {
+ animation: fadeIn 0.35s ease-out backwards;
+}
+
+.pop-dashboard-kpi-item:nth-child(1) { animation-delay: 0.05s; }
+.pop-dashboard-kpi-item:nth-child(2) { animation-delay: 0.1s; }
+.pop-dashboard-kpi-item:nth-child(3) { animation-delay: 0.15s; }
+.pop-dashboard-kpi-item:nth-child(4) { animation-delay: 0.2s; }
+
+.pop-dashboard-menu-card:nth-child(1) { animation-delay: 0.1s; }
+.pop-dashboard-menu-card:nth-child(2) { animation-delay: 0.15s; }
+.pop-dashboard-menu-card:nth-child(3) { animation-delay: 0.2s; }
+.pop-dashboard-menu-card:nth-child(4) { animation-delay: 0.25s; }
+.pop-dashboard-menu-card:nth-child(5) { animation-delay: 0.3s; }
+
+/* 스크롤바 */
+.pop-dashboard-container ::-webkit-scrollbar { width: 6px; height: 6px; }
+.pop-dashboard-container ::-webkit-scrollbar-track { background: transparent; }
+.pop-dashboard-container ::-webkit-scrollbar-thumb { background: var(--db-border); border-radius: 3px; }
+.pop-dashboard-container ::-webkit-scrollbar-thumb:hover { background: var(--db-accent-primary); }
+
diff --git a/frontend/components/pop/dashboard/data.ts b/frontend/components/pop/dashboard/data.ts
new file mode 100644
index 00000000..ce6ac542
--- /dev/null
+++ b/frontend/components/pop/dashboard/data.ts
@@ -0,0 +1,139 @@
+// POP 대시보드 샘플 데이터
+
+import { KpiItem, MenuItem, ActivityItem, NoticeItem } from "./types";
+
+export const KPI_ITEMS: KpiItem[] = [
+ {
+ id: "achievement",
+ label: "목표 달성률",
+ value: 83.3,
+ unit: "%",
+ percentage: 83,
+ color: "cyan",
+ },
+ {
+ id: "production",
+ label: "금일 생산실적",
+ value: 1250,
+ unit: "EA",
+ percentage: 100,
+ color: "emerald",
+ },
+ {
+ id: "defect",
+ label: "불량률",
+ value: 0.8,
+ unit: "%",
+ percentage: 1,
+ color: "rose",
+ },
+ {
+ id: "equipment",
+ label: "가동 설비",
+ value: 8,
+ unit: "/ 10",
+ percentage: 80,
+ color: "amber",
+ },
+];
+
+export const MENU_ITEMS: MenuItem[] = [
+ {
+ id: "production",
+ title: "생산관리",
+ count: 5,
+ description: "작업지시 / 생산실적 / 공정관리",
+ status: "진행중",
+ category: "production",
+ href: "/pop/work",
+ },
+ {
+ id: "material",
+ title: "자재관리",
+ count: 12,
+ description: "자재출고 / 재고확인 / 입고처리",
+ status: "대기",
+ category: "material",
+ href: "#",
+ },
+ {
+ id: "quality",
+ title: "품질관리",
+ count: 3,
+ description: "품질검사 / 불량처리 / 검사기록",
+ status: "검사대기",
+ category: "quality",
+ href: "#",
+ },
+ {
+ id: "equipment",
+ title: "설비관리",
+ count: 2,
+ description: "설비현황 / 점검관리 / 고장신고",
+ status: "점검필요",
+ category: "equipment",
+ href: "#",
+ },
+ {
+ id: "safety",
+ title: "안전관리",
+ count: 0,
+ description: "안전점검 / 사고신고 / 안전교육",
+ status: "이상무",
+ category: "safety",
+ href: "#",
+ },
+];
+
+export const ACTIVITY_ITEMS: ActivityItem[] = [
+ {
+ id: "1",
+ time: "14:25",
+ title: "생산실적 등록",
+ description: "WO-2024-156 - 500EA 생산완료",
+ category: "production",
+ },
+ {
+ id: "2",
+ time: "13:50",
+ title: "자재출고",
+ description: "알루미늄 프로파일 A100 - 200EA",
+ category: "material",
+ },
+ {
+ id: "3",
+ time: "11:30",
+ title: "품질검사 완료",
+ description: "LOT-2024-156 합격 (불량 0건)",
+ category: "quality",
+ },
+ {
+ id: "4",
+ time: "09:15",
+ title: "설비점검",
+ description: "5호기 정기점검 완료",
+ category: "equipment",
+ },
+];
+
+export const NOTICE_ITEMS: NoticeItem[] = [
+ {
+ id: "1",
+ title: "금일 15:00 전체 안전교육",
+ date: "2024-01-05",
+ },
+ {
+ id: "2",
+ title: "3호기 정기점검 안내",
+ date: "2024-01-04",
+ },
+ {
+ id: "3",
+ title: "11월 우수팀 - 생산1팀",
+ date: "2024-01-03",
+ },
+];
+
+export const NOTICE_MARQUEE_TEXT =
+ "[공지] 금일 오후 3시 전체 안전교육 실시 예정입니다. 전 직원 필참 바랍니다. | [알림] 내일 설비 정기점검으로 인한 3호기 가동 중지 예정 | [안내] 11월 생산실적 우수팀 발표 - 생산1팀 축하드립니다!";
+
diff --git a/frontend/components/pop/dashboard/index.ts b/frontend/components/pop/dashboard/index.ts
new file mode 100644
index 00000000..dbafdc54
--- /dev/null
+++ b/frontend/components/pop/dashboard/index.ts
@@ -0,0 +1,11 @@
+export { PopDashboard } from "./PopDashboard";
+export { DashboardHeader } from "./DashboardHeader";
+export { NoticeBanner } from "./NoticeBanner";
+export { KpiBar } from "./KpiBar";
+export { MenuGrid } from "./MenuGrid";
+export { ActivityList } from "./ActivityList";
+export { NoticeList } from "./NoticeList";
+export { DashboardFooter } from "./DashboardFooter";
+export * from "./types";
+export * from "./data";
+
diff --git a/frontend/components/pop/dashboard/types.ts b/frontend/components/pop/dashboard/types.ts
new file mode 100644
index 00000000..9c3c67f3
--- /dev/null
+++ b/frontend/components/pop/dashboard/types.ts
@@ -0,0 +1,51 @@
+// POP 대시보드 타입 정의
+
+export interface KpiItem {
+ id: string;
+ label: string;
+ value: number;
+ unit: string;
+ percentage: number;
+ color: string;
+}
+
+export interface MenuItem {
+ id: string;
+ title: string;
+ count: number;
+ description: string;
+ status: string;
+ category: "production" | "material" | "quality" | "equipment" | "safety";
+ href: string;
+}
+
+export interface ActivityItem {
+ id: string;
+ time: string;
+ title: string;
+ description: string;
+ category: "production" | "material" | "quality" | "equipment";
+}
+
+export interface NoticeItem {
+ id: string;
+ title: string;
+ date: string;
+}
+
+export interface WeatherInfo {
+ temp: string;
+ description: string;
+}
+
+export interface UserInfo {
+ name: string;
+ role: string;
+ avatar: string;
+}
+
+export interface CompanyInfo {
+ name: string;
+ subTitle: string;
+}
+
diff --git a/frontend/components/pop/data.ts b/frontend/components/pop/data.ts
new file mode 100644
index 00000000..64df7b6d
--- /dev/null
+++ b/frontend/components/pop/data.ts
@@ -0,0 +1,373 @@
+// POP 샘플 데이터
+
+import { Process, Equipment, WorkOrder, WorkStepTemplate } from "./types";
+
+// 공정 목록
+export const PROCESSES: Process[] = [
+ { id: "P001", name: "절단", code: "CUT" },
+ { id: "P002", name: "용접", code: "WELD" },
+ { id: "P003", name: "도장", code: "PAINT" },
+ { id: "P004", name: "조립", code: "ASSY" },
+ { id: "P005", name: "검사", code: "QC" },
+ { id: "P006", name: "포장", code: "PACK" },
+ { id: "P007", name: "프레스", code: "PRESS" },
+ { id: "P008", name: "연마", code: "POLISH" },
+ { id: "P009", name: "열처리", code: "HEAT" },
+ { id: "P010", name: "표면처리", code: "SURFACE" },
+ { id: "P011", name: "드릴링", code: "DRILL" },
+ { id: "P012", name: "밀링", code: "MILL" },
+ { id: "P013", name: "선반", code: "LATHE" },
+ { id: "P014", name: "연삭", code: "GRIND" },
+ { id: "P015", name: "측정", code: "MEASURE" },
+ { id: "P016", name: "세척", code: "CLEAN" },
+ { id: "P017", name: "건조", code: "DRY" },
+ { id: "P018", name: "코팅", code: "COAT" },
+ { id: "P019", name: "라벨링", code: "LABEL" },
+ { id: "P020", name: "출하검사", code: "FINAL_QC" },
+];
+
+// 설비 목록
+export const EQUIPMENTS: Equipment[] = [
+ {
+ id: "E001",
+ name: "CNC-01",
+ processIds: ["P001"],
+ processNames: ["절단"],
+ status: "running",
+ },
+ {
+ id: "E002",
+ name: "CNC-02",
+ processIds: ["P001"],
+ processNames: ["절단"],
+ status: "idle",
+ },
+ {
+ id: "E003",
+ name: "용접기-01",
+ processIds: ["P002"],
+ processNames: ["용접"],
+ status: "running",
+ },
+ {
+ id: "E004",
+ name: "도장라인-A",
+ processIds: ["P003"],
+ processNames: ["도장"],
+ status: "running",
+ },
+ {
+ id: "E005",
+ name: "조립라인-01",
+ processIds: ["P004", "P006"],
+ processNames: ["조립", "포장"],
+ status: "running",
+ },
+ {
+ id: "E006",
+ name: "검사대-01",
+ processIds: ["P005"],
+ processNames: ["검사"],
+ status: "idle",
+ },
+ {
+ id: "E007",
+ name: "작업대-A",
+ processIds: ["P001", "P002", "P004"],
+ processNames: ["절단", "용접", "조립"],
+ status: "idle",
+ },
+ {
+ id: "E008",
+ name: "작업대-B",
+ processIds: ["P003", "P005", "P006"],
+ processNames: ["도장", "검사", "포장"],
+ status: "idle",
+ },
+];
+
+// 작업순서 템플릿
+export const WORK_STEP_TEMPLATES: Record = {
+ P001: [
+ // 절단 공정
+ {
+ id: 1,
+ name: "설비 점검",
+ type: "equipment-check",
+ description: "설비 상태 및 안전 점검",
+ },
+ {
+ id: 2,
+ name: "원자재 확인",
+ type: "material-check",
+ description: "원자재 수량 및 품질 확인",
+ },
+ { id: 3, name: "설비 셋팅", type: "setup", description: "절단 조건 설정" },
+ { id: 4, name: "가공 작업", type: "work", description: "절단 가공 진행" },
+ {
+ id: 5,
+ name: "품질 검사",
+ type: "inspection",
+ description: "가공 결과 품질 검사",
+ },
+ { id: 6, name: "작업 기록", type: "record", description: "작업 실적 기록" },
+ ],
+ P002: [
+ // 용접 공정
+ {
+ id: 1,
+ name: "설비 점검",
+ type: "equipment-check",
+ description: "용접기 및 안전장비 점검",
+ },
+ {
+ id: 2,
+ name: "자재 준비",
+ type: "material-check",
+ description: "용접 자재 및 부품 확인",
+ },
+ {
+ id: 3,
+ name: "용접 조건 설정",
+ type: "setup",
+ description: "전류, 전압 등 설정",
+ },
+ { id: 4, name: "용접 작업", type: "work", description: "용접 진행" },
+ {
+ id: 5,
+ name: "용접부 검사",
+ type: "inspection",
+ description: "용접 품질 검사",
+ },
+ { id: 6, name: "작업 기록", type: "record", description: "용접 실적 기록" },
+ ],
+ default: [
+ {
+ id: 1,
+ name: "작업 준비",
+ type: "preparation",
+ description: "작업 전 준비사항 확인",
+ },
+ { id: 2, name: "작업 실행", type: "work", description: "작업 진행" },
+ {
+ id: 3,
+ name: "품질 확인",
+ type: "inspection",
+ description: "작업 결과 확인",
+ },
+ { id: 4, name: "작업 기록", type: "record", description: "작업 내용 기록" },
+ ],
+};
+
+// 작업지시 목록
+export const WORK_ORDERS: WorkOrder[] = [
+ {
+ id: "WO-2025-001",
+ itemCode: "PROD-001",
+ itemName: "LCD 패널 A101",
+ spec: "1920x1080",
+ orderQuantity: 500,
+ producedQuantity: 0,
+ status: "waiting",
+ process: "P001",
+ processName: "절단",
+ equipment: "E001",
+ equipmentName: "CNC-01",
+ startDate: "2025-01-06",
+ dueDate: "2025-01-10",
+ priority: "high",
+ accepted: false,
+ processFlow: [
+ { id: "P001", name: "절단", status: "pending" },
+ { id: "P007", name: "프레스", status: "pending" },
+ { id: "P011", name: "드릴링", status: "pending" },
+ { id: "P002", name: "용접", status: "pending" },
+ { id: "P008", name: "연마", status: "pending" },
+ { id: "P003", name: "도장", status: "pending" },
+ { id: "P004", name: "조립", status: "pending" },
+ { id: "P005", name: "검사", status: "pending" },
+ { id: "P006", name: "포장", status: "pending" },
+ ],
+ currentProcessIndex: 0,
+ },
+ {
+ id: "WO-2025-002",
+ itemCode: "PROD-002",
+ itemName: "LED 모듈 B202",
+ spec: "500x500",
+ orderQuantity: 300,
+ producedQuantity: 150,
+ status: "in-progress",
+ process: "P002",
+ processName: "용접",
+ equipment: "E003",
+ equipmentName: "용접기-01",
+ startDate: "2025-01-05",
+ dueDate: "2025-01-08",
+ priority: "medium",
+ accepted: true,
+ processFlow: [
+ { id: "P001", name: "절단", status: "completed" },
+ { id: "P007", name: "프레스", status: "completed" },
+ { id: "P011", name: "드릴링", status: "completed" },
+ { id: "P002", name: "용접", status: "current" },
+ { id: "P008", name: "연마", status: "pending" },
+ { id: "P003", name: "도장", status: "pending" },
+ { id: "P004", name: "조립", status: "pending" },
+ { id: "P005", name: "검사", status: "pending" },
+ { id: "P006", name: "포장", status: "pending" },
+ ],
+ currentProcessIndex: 3,
+ },
+ {
+ id: "WO-2025-003",
+ itemCode: "PROD-003",
+ itemName: "OLED 디스플레이",
+ spec: "2560x1440",
+ orderQuantity: 200,
+ producedQuantity: 50,
+ status: "in-progress",
+ process: "P004",
+ processName: "조립",
+ equipment: "E005",
+ equipmentName: "조립라인-01",
+ startDate: "2025-01-04",
+ dueDate: "2025-01-09",
+ priority: "high",
+ accepted: true,
+ processFlow: [
+ { id: "P001", name: "절단", status: "completed" },
+ { id: "P007", name: "프레스", status: "completed" },
+ { id: "P002", name: "용접", status: "completed" },
+ { id: "P003", name: "도장", status: "completed" },
+ { id: "P004", name: "조립", status: "current" },
+ { id: "P005", name: "검사", status: "pending" },
+ { id: "P006", name: "포장", status: "pending" },
+ ],
+ currentProcessIndex: 4,
+ },
+ {
+ id: "WO-2025-004",
+ itemCode: "PROD-004",
+ itemName: "스틸 프레임 C300",
+ spec: "800x600",
+ orderQuantity: 150,
+ producedQuantity: 30,
+ status: "in-progress",
+ process: "P005",
+ processName: "검사",
+ equipment: "E006",
+ equipmentName: "검사대-01",
+ startDate: "2025-01-03",
+ dueDate: "2025-01-10",
+ priority: "medium",
+ accepted: false,
+ processFlow: [
+ { id: "P001", name: "절단", status: "completed" },
+ { id: "P002", name: "용접", status: "completed" },
+ { id: "P008", name: "연마", status: "completed" },
+ { id: "P003", name: "도장", status: "completed" },
+ { id: "P004", name: "조립", status: "completed" },
+ { id: "P005", name: "검사", status: "current" },
+ { id: "P006", name: "포장", status: "pending" },
+ ],
+ currentProcessIndex: 5,
+ },
+ {
+ id: "WO-2025-005",
+ itemCode: "PROD-005",
+ itemName: "알루미늄 케이스",
+ spec: "300x400",
+ orderQuantity: 400,
+ producedQuantity: 400,
+ status: "completed",
+ process: "P006",
+ processName: "포장",
+ equipment: "E005",
+ equipmentName: "조립라인-01",
+ startDate: "2025-01-01",
+ dueDate: "2025-01-05",
+ completedDate: "2025-01-05",
+ priority: "high",
+ accepted: true,
+ processFlow: [
+ { id: "P001", name: "절단", status: "completed" },
+ { id: "P007", name: "프레스", status: "completed" },
+ { id: "P008", name: "연마", status: "completed" },
+ { id: "P003", name: "도장", status: "completed" },
+ { id: "P004", name: "조립", status: "completed" },
+ { id: "P005", name: "검사", status: "completed" },
+ { id: "P006", name: "포장", status: "completed" },
+ ],
+ currentProcessIndex: 6,
+ },
+ // 공정 리턴 작업지시
+ {
+ id: "WO-2025-006",
+ itemCode: "PROD-006",
+ itemName: "리턴품 샤프트 F100",
+ spec: "50x300",
+ orderQuantity: 80,
+ producedQuantity: 30,
+ status: "in-progress",
+ process: "P008",
+ processName: "연마",
+ equipment: null,
+ equipmentName: null,
+ startDate: "2025-01-03",
+ dueDate: "2025-01-08",
+ priority: "high",
+ accepted: false,
+ isReturn: true,
+ returnReason: "검사 불합격 - 표면 조도 미달",
+ returnFromProcess: "P005",
+ returnFromProcessName: "검사",
+ processFlow: [
+ { id: "P001", name: "절단", status: "completed" },
+ { id: "P002", name: "용접", status: "completed" },
+ { id: "P008", name: "연마", status: "pending", isReturnTarget: true },
+ { id: "P014", name: "연삭", status: "pending" },
+ { id: "P016", name: "세척", status: "pending" },
+ { id: "P005", name: "검사", status: "pending" },
+ ],
+ currentProcessIndex: 2,
+ },
+ // 분할접수 작업지시
+ {
+ id: "WO-2025-007",
+ itemCode: "PROD-007",
+ itemName: "분할접수 테스트 품목",
+ spec: "100x200",
+ orderQuantity: 200,
+ producedQuantity: 50,
+ acceptedQuantity: 50,
+ remainingQuantity: 150,
+ status: "in-progress",
+ process: "P002",
+ processName: "용접",
+ equipment: "E003",
+ equipmentName: "용접기-01",
+ startDate: "2025-01-04",
+ dueDate: "2025-01-10",
+ priority: "normal",
+ accepted: true,
+ isPartialAccept: true,
+ processFlow: [
+ { id: "P001", name: "절단", status: "completed" },
+ { id: "P002", name: "용접", status: "current" },
+ { id: "P003", name: "도장", status: "pending" },
+ { id: "P004", name: "조립", status: "pending" },
+ { id: "P005", name: "검사", status: "pending" },
+ { id: "P006", name: "포장", status: "pending" },
+ ],
+ currentProcessIndex: 1,
+ },
+];
+
+// 상태 텍스트 매핑
+export const STATUS_TEXT: Record = {
+ waiting: "대기",
+ "in-progress": "진행중",
+ completed: "완료",
+};
+
diff --git a/frontend/components/pop/index.ts b/frontend/components/pop/index.ts
new file mode 100644
index 00000000..ff57ee80
--- /dev/null
+++ b/frontend/components/pop/index.ts
@@ -0,0 +1,13 @@
+export { PopApp } from "./PopApp";
+export { PopHeader } from "./PopHeader";
+export { PopStatusTabs } from "./PopStatusTabs";
+export { PopWorkCard } from "./PopWorkCard";
+export { PopBottomNav } from "./PopBottomNav";
+export { PopEquipmentModal } from "./PopEquipmentModal";
+export { PopProcessModal } from "./PopProcessModal";
+export { PopAcceptModal } from "./PopAcceptModal";
+export { PopSettingsModal } from "./PopSettingsModal";
+export { PopProductionPanel } from "./PopProductionPanel";
+
+export * from "./types";
+export * from "./data";
diff --git a/frontend/components/pop/styles.css b/frontend/components/pop/styles.css
new file mode 100644
index 00000000..a12b80fa
--- /dev/null
+++ b/frontend/components/pop/styles.css
@@ -0,0 +1,1784 @@
+/* POP 생산실적관리 - 사이버펑크 스타일 */
+
+/* ==================== 다크 테마 (기본) ==================== */
+.pop-container {
+ --bg-deepest: 8 12 21;
+ --bg-deep: 10 15 28;
+ --bg-primary: 13 19 35;
+ --bg-secondary: 18 26 47;
+ --bg-tertiary: 25 35 60;
+ --bg-elevated: 32 45 75;
+
+ --neon-cyan: 0 212 255;
+ --neon-cyan-bright: 0 240 255;
+ --neon-cyan-dim: 0 150 190;
+ --neon-pink: 255 0 102;
+ --neon-purple: 138 43 226;
+ --neon-blue: 65 105 225;
+
+ --success: 0 255 136;
+ --success-dim: 0 180 100;
+ --warning: 255 170 0;
+ --warning-dim: 200 130 0;
+ --danger: 255 51 51;
+ --danger-dim: 200 40 40;
+ --info: 100 149 237;
+
+ --text-primary: 255 255 255;
+ --text-secondary: 180 195 220;
+ --text-muted: 100 120 150;
+ --text-disabled: 60 75 95;
+
+ --border: 40 55 85;
+ --border-light: 55 75 110;
+ --border-glow: 0 212 255;
+
+ --glow-cyan: 0 0 20px rgba(0, 212, 255, 0.5), 0 0 40px rgba(0, 212, 255, 0.3);
+ --glow-cyan-strong: 0 0 10px rgba(0, 212, 255, 0.8), 0 0 30px rgba(0, 212, 255, 0.5), 0 0 50px rgba(0, 212, 255, 0.3);
+ --glow-pink: 0 0 20px rgba(255, 0, 102, 0.5), 0 0 40px rgba(255, 0, 102, 0.3);
+ --glow-success: 0 0 15px rgba(0, 255, 136, 0.5);
+ --glow-warning: 0 0 15px rgba(255, 170, 0, 0.5);
+ --glow-danger: 0 0 15px rgba(255, 51, 51, 0.5);
+
+ --gradient-card: linear-gradient(145deg, rgba(25, 35, 60, 0.9) 0%, rgba(18, 26, 47, 0.95) 100%);
+ --gradient-glass: linear-gradient(135deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%);
+ --gradient-neon: linear-gradient(90deg, rgb(var(--neon-cyan)) 0%, rgb(var(--neon-pink)) 100%);
+ --gradient-header: linear-gradient(180deg, rgba(13, 19, 35, 0.98) 0%, rgba(13, 19, 35, 0.85) 100%);
+
+ --spacing-xs: 0.25rem;
+ --spacing-sm: 0.5rem;
+ --spacing-md: 0.75rem;
+ --spacing-lg: 1rem;
+ --spacing-xl: 1.5rem;
+ --spacing-2xl: 2rem;
+
+ --radius-sm: 4px;
+ --radius-md: 8px;
+ --radius-lg: 12px;
+ --radius-xl: 16px;
+ --radius-full: 9999px;
+
+ --transition-fast: 0.15s ease;
+ --transition-normal: 0.25s ease;
+ --transition-slow: 0.4s ease;
+
+ --text-2xs: 0.625rem;
+ --text-xs: 0.75rem;
+ --text-sm: 0.875rem;
+ --text-base: 1rem;
+ --text-lg: 1.125rem;
+ --text-xl: 1.25rem;
+ --text-2xl: 1.5rem;
+ --text-3xl: 2rem;
+
+ --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3);
+ --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.4);
+ --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.5);
+ --shadow-glow: 0 0 30px rgba(0, 212, 255, 0.2);
+
+ --z-dropdown: 100;
+ --z-sticky: 200;
+ --z-fixed: 300;
+ --z-modal-backdrop: 400;
+ --z-modal: 500;
+ --z-popover: 600;
+ --z-tooltip: 700;
+ --z-header: 100;
+}
+
+/* ==================== 라이트 테마 ==================== */
+.pop-container.light {
+ --bg-deepest: 245 247 250;
+ --bg-deep: 240 243 248;
+ --bg-primary: 250 251 253;
+ --bg-secondary: 255 255 255;
+ --bg-tertiary: 245 247 250;
+ --bg-elevated: 235 238 245;
+
+ --neon-cyan: 0 122 204;
+ --neon-cyan-bright: 0 140 230;
+ --neon-cyan-dim: 0 100 170;
+ --neon-pink: 220 38 127;
+ --neon-purple: 118 38 200;
+ --neon-blue: 37 99 235;
+
+ --success: 22 163 74;
+ --success-dim: 21 128 61;
+ --warning: 245 158 11;
+ --warning-dim: 217 119 6;
+ --danger: 220 38 38;
+ --danger-dim: 185 28 28;
+ --info: 59 130 246;
+
+ --text-primary: 15 23 42;
+ --text-secondary: 71 85 105;
+ --text-muted: 148 163 184;
+ --text-disabled: 203 213 225;
+
+ --border: 226 232 240;
+ --border-light: 203 213 225;
+ --border-glow: 0 122 204;
+
+ --glow-cyan: 0 0 15px rgba(0, 122, 204, 0.3), 0 0 30px rgba(0, 122, 204, 0.15);
+ --glow-cyan-strong: 0 0 8px rgba(0, 122, 204, 0.5), 0 0 20px rgba(0, 122, 204, 0.3);
+ --glow-success: 0 0 10px rgba(22, 163, 74, 0.3);
+ --glow-warning: 0 0 10px rgba(245, 158, 11, 0.3);
+ --glow-danger: 0 0 10px rgba(220, 38, 38, 0.3);
+
+ --gradient-card: linear-gradient(145deg, rgba(255, 255, 255, 0.95) 0%, rgba(250, 251, 253, 0.98) 100%);
+ --gradient-glass: linear-gradient(135deg, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0.6) 100%);
+ --gradient-header: linear-gradient(180deg, rgba(250, 251, 253, 0.98) 0%, rgba(250, 251, 253, 0.9) 100%);
+
+ --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06);
+ --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07), 0 2px 4px rgba(0, 0, 0, 0.06);
+ --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1), 0 4px 6px rgba(0, 0, 0, 0.05);
+ --shadow-glow: 0 0 20px rgba(0, 122, 204, 0.1);
+}
+
+/* ==================== 기본 스타일 ==================== */
+.pop-container {
+ font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, Roboto, sans-serif;
+ font-size: var(--text-sm);
+ line-height: 1.5;
+ color: rgb(var(--text-primary));
+ background: rgb(var(--bg-deepest));
+ min-height: 100vh;
+ min-height: 100dvh;
+ overflow-x: hidden;
+ -webkit-font-smoothing: antialiased;
+ position: relative;
+}
+
+/* 배경 그리드 패턴 */
+.pop-container::before {
+ content: '';
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background:
+ repeating-linear-gradient(90deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px),
+ repeating-linear-gradient(0deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px),
+ radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0, 212, 255, 0.08) 0%, transparent 60%),
+ linear-gradient(180deg, rgb(var(--bg-deepest)) 0%, rgb(var(--bg-deep)) 50%, rgb(var(--bg-primary)) 100%);
+ pointer-events: none;
+ z-index: -1;
+}
+
+.pop-container.light::before {
+ background:
+ repeating-linear-gradient(90deg, rgba(0, 122, 204, 0.02) 0px, transparent 1px, transparent 60px),
+ repeating-linear-gradient(0deg, rgba(0, 122, 204, 0.02) 0px, transparent 1px, transparent 60px),
+ radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0, 122, 204, 0.05) 0%, transparent 60%),
+ linear-gradient(180deg, rgb(var(--bg-deepest)) 0%, rgb(var(--bg-deep)) 50%, rgb(var(--bg-primary)) 100%);
+}
+
+/* 스크롤바 */
+.pop-container ::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+
+.pop-container ::-webkit-scrollbar-track {
+ background: rgb(var(--bg-secondary));
+}
+
+.pop-container ::-webkit-scrollbar-thumb {
+ background: rgb(var(--border-light));
+ border-radius: var(--radius-full);
+}
+
+.pop-container ::-webkit-scrollbar-thumb:hover {
+ background: rgb(var(--neon-cyan-dim));
+}
+
+/* ==================== 앱 레이아웃 ==================== */
+.pop-app {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+ min-height: 100dvh;
+ padding: var(--spacing-sm);
+ padding-bottom: calc(60px + var(--spacing-sm) + env(safe-area-inset-bottom, 0px));
+}
+
+/* ==================== 헤더 컨테이너 ==================== */
+.pop-header-container {
+ background: var(--gradient-card);
+ border: 1px solid rgb(var(--border));
+ border-radius: var(--radius-lg);
+ margin-bottom: var(--spacing-sm);
+ position: relative;
+ overflow: hidden;
+}
+
+.pop-header-container::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 1px;
+ background: linear-gradient(90deg, transparent 0%, rgba(0, 212, 255, 0.5) 50%, transparent 100%);
+}
+
+/* 상단 바 */
+.pop-top-bar {
+ display: flex;
+ align-items: center;
+ padding: var(--spacing-sm) var(--spacing-md);
+ gap: var(--spacing-md);
+}
+
+.pop-top-bar.row-1 {
+ border-bottom: 1px solid rgba(var(--border), 0.5);
+}
+
+.pop-spacer {
+ flex: 1;
+}
+
+/* 날짜/시간 */
+.pop-datetime {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+ font-size: var(--text-xs);
+}
+
+.pop-date {
+ color: rgb(var(--text-secondary));
+}
+
+.pop-time {
+ color: rgb(var(--neon-cyan));
+ font-weight: 700;
+ font-size: var(--text-sm);
+}
+
+/* 생산유형 버튼 */
+.pop-type-buttons {
+ display: flex;
+ gap: var(--spacing-xs);
+}
+
+.pop-type-btn {
+ padding: var(--spacing-xs) var(--spacing-md);
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid rgb(var(--border));
+ border-radius: var(--radius-md);
+ color: rgb(var(--text-muted));
+ font-size: var(--text-xs);
+ font-weight: 500;
+ cursor: pointer;
+ transition: all var(--transition-fast);
+}
+
+.pop-type-btn:hover {
+ background: rgba(0, 212, 255, 0.08);
+ color: rgb(var(--text-primary));
+}
+
+.pop-type-btn.active {
+ background: linear-gradient(135deg, rgba(0, 212, 255, 0.15) 0%, rgba(0, 180, 220, 0.2) 100%);
+ border-color: rgba(0, 212, 255, 0.5);
+ color: rgb(var(--neon-cyan));
+ box-shadow: 0 0 15px rgba(0, 212, 255, 0.15);
+}
+
+/* 필터 버튼 */
+.pop-filter-btn {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-xs);
+ padding: var(--spacing-sm) var(--spacing-md);
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid rgb(var(--border));
+ border-radius: var(--radius-md);
+ color: rgb(var(--text-secondary));
+ font-size: var(--text-xs);
+ font-weight: 500;
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ white-space: nowrap;
+}
+
+.pop-filter-btn:hover:not(:disabled) {
+ background: rgba(0, 212, 255, 0.08);
+ border-color: rgba(0, 212, 255, 0.3);
+ color: rgb(var(--text-primary));
+}
+
+.pop-filter-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.pop-filter-btn.active {
+ background: linear-gradient(135deg, rgba(0, 212, 255, 0.15) 0%, rgba(0, 180, 220, 0.2) 100%);
+ border-color: rgba(0, 212, 255, 0.5);
+ color: rgb(var(--neon-cyan));
+}
+
+.pop-filter-btn.primary {
+ background: linear-gradient(135deg, rgba(0, 212, 255, 0.2) 0%, rgba(0, 180, 220, 0.3) 100%);
+ border: 1px solid rgba(0, 212, 255, 0.5);
+ color: rgb(var(--neon-cyan-bright));
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 0 15px rgba(0, 212, 255, 0.2);
+}
+
+.pop-filter-btn.primary:hover:not(:disabled) {
+ background: linear-gradient(135deg, rgba(0, 212, 255, 0.3) 0%, rgba(0, 180, 220, 0.4) 100%);
+ border-color: rgb(var(--neon-cyan));
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), var(--glow-cyan);
+}
+
+/* ==================== 상태 탭 ==================== */
+.pop-status-tabs {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 1px;
+ background: rgb(var(--border));
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+ margin-bottom: var(--spacing-sm);
+}
+
+.pop-status-tab {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: var(--spacing-sm) var(--spacing-xs);
+ background: rgb(var(--bg-secondary));
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ position: relative;
+}
+
+.pop-status-tab:hover {
+ background: rgb(var(--bg-tertiary));
+}
+
+.pop-status-tab.active {
+ background: linear-gradient(180deg, rgba(0, 212, 255, 0.15) 0%, rgba(0, 212, 255, 0.05) 100%);
+}
+
+/* 밑줄 제거됨 */
+
+.pop-status-tab-label {
+ font-size: var(--text-2xs);
+ color: rgb(var(--text-muted));
+ margin-bottom: 2px;
+}
+
+.pop-status-tab.active .pop-status-tab-label {
+ color: rgb(var(--neon-cyan));
+}
+
+.pop-status-tab-count {
+ font-size: var(--text-lg);
+ font-weight: 700;
+ color: rgb(var(--text-primary));
+}
+
+.pop-status-tab.active .pop-status-tab-count {
+ color: rgb(var(--neon-cyan));
+ text-shadow: var(--glow-cyan);
+}
+
+.pop-status-tab-detail {
+ font-size: var(--text-2xs);
+ color: rgb(var(--text-muted));
+}
+
+/* ==================== 메인 콘텐츠 ==================== */
+.pop-main-content {
+ flex: 1;
+ overflow-y: auto;
+}
+
+.pop-work-list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm);
+}
+
+/* ==================== 작업 카드 ==================== */
+.pop-work-card {
+ background: var(--gradient-card);
+ border: 1px solid rgb(var(--border));
+ border-radius: var(--radius-lg);
+ padding: var(--spacing-md);
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ position: relative;
+ overflow: hidden;
+}
+
+.pop-work-card::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 1px;
+ background: linear-gradient(90deg, transparent 0%, rgba(0, 212, 255, 0.5) 50%, transparent 100%);
+}
+
+.pop-work-card:hover {
+ border-color: rgba(0, 212, 255, 0.3);
+ box-shadow: 0 0 30px rgba(0, 212, 255, 0.1), inset 0 0 30px rgba(0, 212, 255, 0.02);
+}
+
+.pop-work-card.return-card {
+ border-color: rgba(255, 170, 0, 0.4);
+}
+
+.pop-work-card.return-card::before {
+ background: linear-gradient(90deg, transparent 0%, rgba(255, 170, 0, 0.5) 50%, transparent 100%);
+}
+
+/* 카드 헤더 */
+.pop-work-card-header {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+ margin-bottom: var(--spacing-sm);
+ flex-wrap: wrap;
+}
+
+.pop-work-number {
+ font-size: var(--text-sm);
+ font-weight: 700;
+ color: rgb(var(--neon-cyan));
+}
+
+.pop-work-status {
+ padding: 2px 8px;
+ border-radius: var(--radius-full);
+ font-size: var(--text-2xs);
+ font-weight: 600;
+}
+
+.pop-work-status.waiting {
+ background: rgba(0, 212, 255, 0.15);
+ color: rgb(var(--neon-cyan));
+}
+
+.pop-work-status.in-progress {
+ background: rgba(255, 170, 0, 0.15);
+ color: rgb(var(--warning));
+}
+
+.pop-work-status.completed {
+ background: rgba(0, 255, 136, 0.15);
+ color: rgb(var(--success));
+}
+
+.pop-work-status.return {
+ background: rgba(255, 170, 0, 0.2);
+ color: rgb(var(--warning));
+}
+
+/* 리턴 배지 */
+.pop-return-badge {
+ padding: 2px 6px;
+ background: rgba(255, 170, 0, 0.2);
+ border: 1px solid rgba(255, 170, 0, 0.4);
+ border-radius: var(--radius-sm);
+ font-size: var(--text-2xs);
+ font-weight: 600;
+ color: rgb(var(--warning));
+}
+
+/* 분할접수 배지 */
+.pop-partial-badge {
+ padding: 2px 6px;
+ background: rgba(0, 212, 255, 0.1);
+ border: 1px solid rgba(0, 212, 255, 0.3);
+ border-radius: var(--radius-sm);
+ font-size: var(--text-2xs);
+ color: rgb(var(--neon-cyan));
+}
+
+/* 리턴 정보 배너 */
+.pop-return-banner {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--spacing-sm);
+ padding: var(--spacing-sm);
+ background: rgba(255, 170, 0, 0.1);
+ border: 1px solid rgba(255, 170, 0, 0.3);
+ border-radius: var(--radius-md);
+ margin-bottom: var(--spacing-sm);
+}
+
+.pop-return-banner-icon {
+ font-size: var(--text-base);
+}
+
+.pop-return-banner-title {
+ font-size: var(--text-xs);
+ font-weight: 600;
+ color: rgb(var(--warning));
+}
+
+.pop-return-banner-reason {
+ font-size: var(--text-2xs);
+ color: rgb(var(--text-muted));
+}
+
+/* 카드 바디 */
+.pop-work-card-body {
+ margin-bottom: var(--spacing-sm);
+}
+
+.pop-work-info-line {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--spacing-md);
+}
+
+.pop-work-info-item {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-xs);
+}
+
+.pop-work-info-label {
+ font-size: var(--text-2xs);
+ color: rgb(var(--text-muted));
+}
+
+.pop-work-info-value {
+ font-size: var(--text-xs);
+ color: rgb(var(--text-primary));
+ font-weight: 500;
+}
+
+/* 카드 액션 */
+.pop-work-card-actions {
+ display: flex;
+ gap: var(--spacing-xs);
+ margin-left: auto;
+}
+
+/* ==================== 공정 타임라인 ==================== */
+.pop-process-timeline {
+ margin-top: var(--spacing-sm);
+}
+
+.pop-process-bar {
+ margin-bottom: var(--spacing-sm);
+}
+
+.pop-process-bar-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: var(--spacing-xs);
+}
+
+.pop-process-bar-label {
+ font-size: var(--text-2xs);
+ color: rgb(var(--text-muted));
+}
+
+.pop-process-bar-count {
+ font-size: var(--text-2xs);
+ color: rgb(var(--text-muted));
+}
+
+.pop-process-bar-count span {
+ color: rgb(var(--neon-cyan));
+ font-weight: 600;
+}
+
+.pop-process-segments {
+ display: flex;
+ gap: 2px;
+ height: 4px;
+}
+
+.pop-process-segment {
+ flex: 1;
+ background: rgb(var(--border));
+ border-radius: 2px;
+ transition: all var(--transition-fast);
+}
+
+.pop-process-segment.done {
+ background: rgb(var(--success));
+ box-shadow: var(--glow-success);
+}
+
+.pop-process-segment.current {
+ background: rgb(var(--warning));
+ box-shadow: var(--glow-warning);
+ animation: pulse-glow 2s ease-in-out infinite;
+}
+
+.pop-process-segment.my-work {
+ border: 1px solid rgb(var(--neon-cyan));
+}
+
+/* 공정 칩 */
+.pop-process-chips-container {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-xs);
+ position: relative;
+}
+
+.pop-process-scroll-btn {
+ width: 24px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgb(var(--bg-tertiary));
+ border: 1px solid rgb(var(--border));
+ border-radius: var(--radius-sm);
+ color: rgb(var(--text-muted));
+ cursor: pointer;
+ flex-shrink: 0;
+ transition: all var(--transition-fast);
+}
+
+.pop-process-scroll-btn:hover {
+ background: rgba(0, 212, 255, 0.1);
+ border-color: rgba(0, 212, 255, 0.3);
+ color: rgb(var(--neon-cyan));
+}
+
+.pop-process-scroll-btn.hidden {
+ visibility: hidden;
+}
+
+.pop-process-chips {
+ display: flex;
+ gap: var(--spacing-xs);
+ overflow-x: auto;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+ scroll-behavior: smooth;
+ flex: 1;
+}
+
+.pop-process-chips::-webkit-scrollbar {
+ display: none;
+}
+
+.pop-process-chip {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 8px;
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid rgb(var(--border));
+ border-radius: var(--radius-sm);
+ font-size: var(--text-2xs);
+ color: rgb(var(--text-muted));
+ white-space: nowrap;
+ flex-shrink: 0;
+ transition: all var(--transition-fast);
+}
+
+.pop-chip-num {
+ width: 16px;
+ height: 16px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgb(var(--border));
+ border-radius: 50%;
+ font-size: 9px;
+ font-weight: 600;
+}
+
+.pop-process-chip.done {
+ background: rgba(0, 255, 136, 0.1);
+ border-color: rgba(0, 255, 136, 0.3);
+ color: rgb(var(--success));
+}
+
+.pop-process-chip.done .pop-chip-num {
+ background: rgb(var(--success));
+ color: white;
+}
+
+.pop-process-chip.current {
+ background: rgba(255, 170, 0, 0.1);
+ border-color: rgba(255, 170, 0, 0.4);
+ color: rgb(var(--warning));
+}
+
+.pop-process-chip.current .pop-chip-num {
+ background: rgb(var(--warning));
+ color: white;
+}
+
+.pop-process-chip.my-work {
+ border-color: rgb(var(--neon-cyan));
+ box-shadow: 0 0 10px rgba(0, 212, 255, 0.2);
+}
+
+/* ==================== 진행률 바 ==================== */
+.pop-work-progress {
+ margin-top: var(--spacing-sm);
+ padding-top: var(--spacing-sm);
+ border-top: 1px solid rgba(var(--border), 0.5);
+}
+
+.pop-progress-info {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: var(--spacing-xs);
+}
+
+.pop-progress-text {
+ font-size: var(--text-2xs);
+ color: rgb(var(--text-muted));
+}
+
+.pop-progress-percent {
+ font-size: var(--text-xs);
+ font-weight: 600;
+ color: rgb(var(--neon-cyan));
+}
+
+.pop-progress-bar {
+ height: 4px;
+ background: rgb(var(--border));
+ border-radius: 2px;
+ overflow: hidden;
+}
+
+.pop-progress-fill {
+ height: 100%;
+ background: linear-gradient(90deg, rgb(var(--neon-cyan)) 0%, rgb(var(--neon-cyan-bright)) 100%);
+ border-radius: 2px;
+ transition: width var(--transition-normal);
+}
+
+/* ==================== 빈 상태 ==================== */
+.pop-empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: var(--spacing-2xl);
+ text-align: center;
+}
+
+.pop-empty-state-text {
+ font-size: var(--text-base);
+ font-weight: 600;
+ color: rgb(var(--text-primary));
+ margin-bottom: var(--spacing-xs);
+}
+
+.pop-empty-state-desc {
+ font-size: var(--text-sm);
+ color: rgb(var(--text-muted));
+}
+
+/* ==================== 하단 네비게이션 ==================== */
+.pop-bottom-nav {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ display: flex;
+ gap: var(--spacing-sm);
+ padding: var(--spacing-sm) var(--spacing-md);
+ padding-bottom: calc(var(--spacing-sm) + env(safe-area-inset-bottom, 0px));
+ background: rgba(13, 19, 35, 0.95);
+ backdrop-filter: blur(10px);
+ -webkit-backdrop-filter: blur(10px);
+ border-top: 1px solid rgb(var(--border));
+ z-index: var(--z-fixed);
+}
+
+.pop-nav-btn {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--spacing-sm);
+ padding: var(--spacing-md);
+ border-radius: var(--radius-md);
+ font-size: var(--text-sm);
+ font-weight: 600;
+ cursor: pointer;
+ transition: all var(--transition-fast);
+}
+
+.pop-nav-btn svg {
+ width: 18px;
+ height: 18px;
+}
+
+.pop-nav-btn.secondary {
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid rgb(var(--border));
+ color: rgb(var(--text-secondary));
+}
+
+.pop-nav-btn.secondary:hover {
+ background: rgba(0, 212, 255, 0.08);
+ border-color: rgba(0, 212, 255, 0.3);
+ color: rgb(var(--text-primary));
+}
+
+.pop-nav-btn.primary {
+ background: linear-gradient(135deg, rgba(0, 212, 255, 0.2) 0%, rgba(0, 180, 220, 0.3) 100%);
+ border: 1px solid rgba(0, 212, 255, 0.5);
+ color: rgb(var(--neon-cyan-bright));
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 0 15px rgba(0, 212, 255, 0.2);
+}
+
+.pop-nav-btn.primary:hover {
+ background: linear-gradient(135deg, rgba(0, 212, 255, 0.3) 0%, rgba(0, 180, 220, 0.4) 100%);
+ border-color: rgb(var(--neon-cyan));
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), var(--glow-cyan);
+}
+
+/* ==================== 버튼 ==================== */
+.pop-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--spacing-sm);
+ padding: var(--spacing-sm) var(--spacing-lg);
+ border: 1px solid transparent;
+ border-radius: var(--radius-md);
+ font-size: var(--text-xs);
+ font-weight: 600;
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ white-space: nowrap;
+}
+
+.pop-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.pop-btn-sm {
+ padding: var(--spacing-xs) var(--spacing-sm);
+ font-size: var(--text-2xs);
+}
+
+.pop-btn-primary {
+ background: linear-gradient(135deg, rgba(0, 212, 255, 0.2) 0%, rgba(0, 180, 220, 0.3) 100%);
+ border: 1px solid rgba(0, 212, 255, 0.5);
+ color: rgb(var(--neon-cyan-bright));
+}
+
+.pop-btn-primary:hover:not(:disabled) {
+ background: linear-gradient(135deg, rgba(0, 212, 255, 0.3) 0%, rgba(0, 180, 220, 0.4) 100%);
+ border-color: rgb(var(--neon-cyan));
+ box-shadow: var(--glow-cyan);
+}
+
+.pop-btn-success {
+ background: linear-gradient(135deg, rgba(0, 255, 136, 0.15) 0%, rgba(0, 200, 100, 0.25) 100%);
+ border: 1px solid rgba(0, 255, 136, 0.4);
+ color: rgb(var(--success));
+}
+
+.pop-btn-success:hover:not(:disabled) {
+ background: linear-gradient(135deg, rgba(0, 255, 136, 0.25) 0%, rgba(0, 200, 100, 0.35) 100%);
+ box-shadow: var(--glow-success);
+}
+
+.pop-btn-ghost {
+ background: transparent;
+ border: none;
+ color: rgb(var(--text-muted));
+}
+
+.pop-btn-ghost:hover:not(:disabled) {
+ background: rgba(0, 212, 255, 0.1);
+ color: rgb(var(--neon-cyan));
+}
+
+.pop-btn-outline {
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid rgb(var(--border));
+ color: rgb(var(--text-secondary));
+}
+
+.pop-btn-outline:hover:not(:disabled) {
+ background: rgba(0, 212, 255, 0.08);
+ border-color: rgba(0, 212, 255, 0.4);
+ color: rgb(var(--neon-cyan));
+}
+
+/* ==================== 모달 ==================== */
+.pop-modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(8, 12, 21, 0.85);
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
+ z-index: var(--z-modal-backdrop);
+ display: none;
+ align-items: center;
+ justify-content: center;
+ padding: var(--spacing-lg);
+}
+
+.pop-modal-overlay.active {
+ display: flex;
+}
+
+.pop-modal {
+ background: var(--gradient-card);
+ border: 1px solid rgb(var(--border));
+ border-radius: var(--radius-xl);
+ width: 100%;
+ max-width: 500px;
+ max-height: 85vh;
+ display: flex;
+ flex-direction: column;
+ box-shadow: var(--shadow-lg), 0 0 60px rgba(0, 212, 255, 0.1);
+ position: relative;
+ overflow: hidden;
+}
+
+.pop-modal::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 2px;
+ background: linear-gradient(90deg, transparent 0%, rgb(var(--neon-cyan)) 30%, rgb(var(--neon-pink)) 70%, transparent 100%);
+}
+
+.pop-modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--spacing-lg) var(--spacing-xl);
+ border-bottom: 1px solid rgb(var(--border));
+}
+
+.pop-modal-title {
+ font-size: var(--text-lg);
+ font-weight: 700;
+ color: rgb(var(--text-primary));
+}
+
+.pop-modal-close {
+ width: 28px;
+ height: 28px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid rgb(var(--border));
+ border-radius: var(--radius-md);
+ color: rgb(var(--text-muted));
+ cursor: pointer;
+ transition: all var(--transition-fast);
+}
+
+.pop-modal-close:hover {
+ background: rgba(255, 51, 51, 0.15);
+ border-color: rgba(255, 51, 51, 0.4);
+ color: rgb(var(--danger));
+}
+
+.pop-modal-body {
+ flex: 1;
+ overflow-y: auto;
+ padding: var(--spacing-xl);
+}
+
+.pop-modal-footer {
+ display: flex;
+ gap: var(--spacing-md);
+ padding: var(--spacing-lg) var(--spacing-xl);
+ border-top: 1px solid rgb(var(--border));
+}
+
+/* 선택 그리드 */
+.pop-selection-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+ gap: var(--spacing-md);
+}
+
+.pop-selection-card {
+ padding: var(--spacing-lg);
+ background: rgba(255, 255, 255, 0.02);
+ border: 2px solid rgb(var(--border));
+ border-radius: var(--radius-lg);
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ text-align: center;
+ position: relative;
+}
+
+.pop-selection-card:hover {
+ background: rgba(0, 212, 255, 0.05);
+ border-color: rgba(0, 212, 255, 0.3);
+}
+
+.pop-selection-card.selected {
+ background: linear-gradient(135deg, rgba(0, 212, 255, 0.1) 0%, rgba(0, 180, 220, 0.15) 100%);
+ border-color: rgb(var(--neon-cyan));
+ box-shadow: var(--glow-cyan);
+}
+
+.pop-selection-card-check {
+ position: absolute;
+ top: var(--spacing-sm);
+ right: var(--spacing-sm);
+ width: 20px;
+ height: 20px;
+ background: rgb(var(--neon-cyan));
+ border-radius: 50%;
+ display: none;
+ align-items: center;
+ justify-content: center;
+ color: white;
+ font-size: var(--text-2xs);
+ box-shadow: var(--glow-cyan);
+}
+
+.pop-selection-card.selected .pop-selection-card-check {
+ display: flex;
+}
+
+.pop-selection-card-icon {
+ font-size: 2rem;
+ margin-bottom: var(--spacing-sm);
+}
+
+.pop-selection-card-name {
+ font-size: var(--text-sm);
+ font-weight: 600;
+ color: rgb(var(--text-primary));
+ margin-bottom: var(--spacing-xs);
+}
+
+.pop-selection-card-info {
+ font-size: var(--text-2xs);
+ color: rgb(var(--text-muted));
+}
+
+/* ==================== 슬라이드 패널 ==================== */
+.pop-slide-panel {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: var(--z-modal);
+ display: none;
+}
+
+.pop-slide-panel.active {
+ display: block;
+}
+
+.pop-slide-panel-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(8, 12, 21, 0.7);
+ backdrop-filter: blur(4px);
+}
+
+.pop-slide-panel-content {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ width: 100%;
+ max-width: 600px;
+ background: rgb(var(--bg-primary));
+ display: flex;
+ flex-direction: column;
+ box-shadow: -10px 0 50px rgba(0, 0, 0, 0.5);
+ animation: slideIn 0.3s ease;
+}
+
+@keyframes slideIn {
+ from {
+ transform: translateX(100%);
+ }
+ to {
+ transform: translateX(0);
+ }
+}
+
+.pop-slide-panel-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--spacing-md) var(--spacing-lg);
+ background: var(--gradient-card);
+ border-bottom: 1px solid rgb(var(--border));
+}
+
+.pop-slide-panel-title {
+ font-size: var(--text-lg);
+ font-weight: 700;
+ color: rgb(var(--text-primary));
+}
+
+.pop-badge {
+ padding: 2px 8px;
+ border-radius: var(--radius-full);
+ font-size: var(--text-2xs);
+ font-weight: 600;
+}
+
+.pop-badge-primary {
+ background: rgba(0, 212, 255, 0.15);
+ color: rgb(var(--neon-cyan));
+}
+
+.pop-slide-panel-body {
+ flex: 1;
+ display: flex;
+ overflow: hidden;
+}
+
+.pop-panel-body-content {
+ display: flex;
+ flex: 1;
+ overflow: hidden;
+}
+
+/* 작업순서 사이드바 */
+.pop-work-steps-sidebar {
+ width: 180px;
+ min-width: 180px;
+ background: rgb(var(--bg-secondary));
+ border-right: 1px solid rgb(var(--border));
+ display: flex;
+ flex-direction: column;
+}
+
+.pop-work-steps-header {
+ padding: var(--spacing-md);
+ font-size: var(--text-xs);
+ font-weight: 600;
+ color: rgb(var(--text-muted));
+ border-bottom: 1px solid rgb(var(--border));
+}
+
+.pop-work-steps-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: var(--spacing-sm);
+}
+
+.pop-work-step-item {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+ padding: var(--spacing-sm);
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ margin-bottom: var(--spacing-xs);
+}
+
+.pop-work-step-item:hover {
+ background: rgba(0, 212, 255, 0.05);
+}
+
+.pop-work-step-item.active {
+ background: rgba(0, 212, 255, 0.1);
+ border: 1px solid rgba(0, 212, 255, 0.3);
+}
+
+.pop-work-step-item.completed {
+ opacity: 0.7;
+}
+
+.pop-work-step-number {
+ width: 24px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgb(var(--border));
+ border-radius: 50%;
+ font-size: var(--text-2xs);
+ font-weight: 600;
+ color: rgb(var(--text-muted));
+}
+
+.pop-work-step-item.active .pop-work-step-number {
+ background: rgb(var(--neon-cyan));
+ color: white;
+}
+
+.pop-work-step-item.completed .pop-work-step-number {
+ background: rgb(var(--success));
+ color: white;
+}
+
+.pop-work-step-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.pop-work-step-name {
+ font-size: var(--text-xs);
+ font-weight: 500;
+ color: rgb(var(--text-primary));
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.pop-work-step-time {
+ font-size: var(--text-2xs);
+ color: rgb(var(--text-muted));
+}
+
+.pop-work-step-status {
+ font-size: var(--text-2xs);
+ padding: 2px 6px;
+ border-radius: var(--radius-sm);
+}
+
+.pop-work-step-status.pending {
+ background: rgb(var(--border));
+ color: rgb(var(--text-muted));
+}
+
+.pop-work-step-status.in-progress {
+ background: rgba(255, 170, 0, 0.15);
+ color: rgb(var(--warning));
+}
+
+.pop-work-step-status.completed {
+ background: rgba(0, 255, 136, 0.15);
+ color: rgb(var(--success));
+}
+
+/* 작업 콘텐츠 영역 */
+.pop-work-content-area {
+ flex: 1;
+ padding: var(--spacing-lg);
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-md);
+}
+
+.pop-step-header {
+ margin-bottom: var(--spacing-md);
+}
+
+.pop-step-title {
+ font-size: var(--text-lg);
+ font-weight: 700;
+ color: rgb(var(--text-primary));
+ margin-bottom: var(--spacing-xs);
+}
+
+.pop-step-description {
+ font-size: var(--text-sm);
+ color: rgb(var(--text-muted));
+}
+
+/* 시간 컨트롤 */
+.pop-step-time-controls {
+ display: flex;
+ gap: var(--spacing-sm);
+}
+
+.pop-time-control-btn {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--spacing-sm);
+ padding: var(--spacing-md);
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid rgb(var(--border));
+ border-radius: var(--radius-md);
+ color: rgb(var(--text-secondary));
+ font-size: var(--text-sm);
+ font-weight: 500;
+ cursor: pointer;
+ transition: all var(--transition-fast);
+}
+
+.pop-time-control-btn svg {
+ width: 16px;
+ height: 16px;
+}
+
+.pop-time-control-btn:hover:not(:disabled) {
+ background: rgba(0, 212, 255, 0.08);
+ border-color: rgba(0, 212, 255, 0.3);
+}
+
+.pop-time-control-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.pop-time-control-btn.start:not(:disabled) {
+ background: rgba(0, 255, 136, 0.1);
+ border-color: rgba(0, 255, 136, 0.3);
+ color: rgb(var(--success));
+}
+
+.pop-time-control-btn.end:not(:disabled) {
+ background: rgba(255, 51, 51, 0.1);
+ border-color: rgba(255, 51, 51, 0.3);
+ color: rgb(var(--danger));
+}
+
+/* 폼 섹션 */
+.pop-step-form-section {
+ background: rgba(255, 255, 255, 0.02);
+ border: 1px solid rgb(var(--border));
+ border-radius: var(--radius-md);
+ padding: var(--spacing-md);
+}
+
+.pop-step-form-title {
+ font-size: var(--text-sm);
+ font-weight: 600;
+ color: rgb(var(--text-primary));
+ margin-bottom: var(--spacing-md);
+}
+
+.pop-form-group {
+ margin-bottom: var(--spacing-md);
+}
+
+.pop-form-group:last-child {
+ margin-bottom: 0;
+}
+
+.pop-form-label {
+ display: block;
+ font-size: var(--text-xs);
+ font-weight: 500;
+ color: rgb(var(--text-secondary));
+ margin-bottom: var(--spacing-xs);
+}
+
+.pop-input {
+ width: 100%;
+ padding: var(--spacing-sm) var(--spacing-md);
+ background: rgba(var(--bg-tertiary), 0.5);
+ border: 1px solid rgb(var(--border));
+ border-radius: var(--radius-md);
+ color: rgb(var(--text-primary));
+ font-size: var(--text-sm);
+ transition: all var(--transition-fast);
+}
+
+.pop-input::placeholder {
+ color: rgb(var(--text-muted));
+}
+
+.pop-input:focus {
+ border-color: rgba(0, 212, 255, 0.5);
+ box-shadow: 0 0 0 3px rgba(0, 212, 255, 0.1), 0 0 15px rgba(0, 212, 255, 0.1);
+ outline: none;
+}
+
+.pop-input:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.pop-form-row {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: var(--spacing-md);
+}
+
+/* 슬라이드 패널 푸터 */
+.pop-slide-panel-footer {
+ display: flex;
+ gap: var(--spacing-md);
+ padding: var(--spacing-md) var(--spacing-lg);
+ background: var(--gradient-card);
+ border-top: 1px solid rgb(var(--border));
+}
+
+/* 작업지시 정보 섹션 */
+.pop-work-order-info-section {
+ padding: var(--spacing-md) var(--spacing-lg);
+ background: rgba(0, 0, 0, 0.2);
+ border-bottom: 1px solid rgb(var(--border));
+}
+
+.pop-work-order-info-card {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
+ gap: var(--spacing-sm);
+}
+
+.pop-work-order-info-item {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.pop-work-order-info-item .label {
+ font-size: var(--text-2xs);
+ color: rgb(var(--text-muted));
+}
+
+.pop-work-order-info-item .value {
+ font-size: var(--text-sm);
+ font-weight: 500;
+ color: rgb(var(--text-primary));
+}
+
+.pop-work-order-info-item .value.primary {
+ color: rgb(var(--neon-cyan));
+}
+
+/* ==================== 분할접수 모달 ==================== */
+.pop-accept-modal-content {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-lg);
+}
+
+.pop-accept-work-info {
+ padding: var(--spacing-md);
+ background: rgba(0, 0, 0, 0.3);
+ border: 1px solid rgb(var(--border));
+ border-radius: var(--radius-md);
+}
+
+.pop-accept-work-info .work-id {
+ font-size: var(--text-sm);
+ font-weight: 700;
+ color: rgb(var(--text-primary));
+ margin-bottom: var(--spacing-xs);
+}
+
+.pop-accept-work-info .work-name {
+ font-size: var(--text-xs);
+ color: rgb(var(--text-secondary));
+}
+
+.pop-quantity-input-wrapper {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-xs);
+ background: rgba(0, 0, 0, 0.3);
+ border: 1px solid rgb(var(--border));
+ border-radius: var(--radius-md);
+ padding: var(--spacing-xs);
+}
+
+.pop-qty-btn {
+ padding: var(--spacing-sm) var(--spacing-md);
+ background: rgba(0, 212, 255, 0.1);
+ border: 1px solid rgba(0, 212, 255, 0.3);
+ border-radius: var(--radius-sm);
+ color: rgb(var(--neon-cyan));
+ font-size: var(--text-sm);
+ font-weight: 600;
+ cursor: pointer;
+ transition: all var(--transition-fast);
+}
+
+.pop-qty-btn:hover {
+ background: rgba(0, 212, 255, 0.2);
+}
+
+.pop-qty-btn.minus {
+ background: rgba(255, 51, 51, 0.1);
+ border-color: rgba(255, 51, 51, 0.3);
+ color: rgb(var(--danger));
+}
+
+.pop-qty-btn.minus:hover {
+ background: rgba(255, 51, 51, 0.2);
+}
+
+.pop-qty-input {
+ flex: 1;
+ padding: var(--spacing-sm);
+ background: transparent;
+ border: none;
+ color: rgb(var(--text-primary));
+ font-size: var(--text-lg);
+ font-weight: 700;
+ text-align: center;
+}
+
+.pop-qty-input:focus {
+ outline: none;
+}
+
+.pop-qty-hint {
+ font-size: var(--text-xs);
+ color: rgb(var(--text-muted));
+ text-align: center;
+ margin-top: var(--spacing-xs);
+}
+
+.pop-accept-info-box {
+ display: flex;
+ gap: var(--spacing-sm);
+ padding: var(--spacing-md);
+ background: rgba(0, 212, 255, 0.05);
+ border: 1px solid rgba(0, 212, 255, 0.2);
+ border-radius: var(--radius-md);
+}
+
+.pop-accept-info-box .info-icon {
+ font-size: var(--text-lg);
+}
+
+.pop-accept-info-box .info-title {
+ font-size: var(--text-sm);
+ font-weight: 600;
+ color: rgb(var(--neon-cyan));
+ margin-bottom: 2px;
+}
+
+.pop-accept-info-box .info-desc {
+ font-size: var(--text-xs);
+ color: rgb(var(--text-muted));
+}
+
+/* ==================== 설정 모달 ==================== */
+.pop-settings-section {
+ margin-bottom: var(--spacing-lg);
+}
+
+.pop-settings-title {
+ font-size: var(--text-sm);
+ font-weight: 600;
+ color: rgb(var(--text-primary));
+ margin-bottom: var(--spacing-md);
+}
+
+.pop-mode-options {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm);
+}
+
+.pop-mode-option {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--spacing-md);
+ padding: var(--spacing-md);
+ background: rgba(255, 255, 255, 0.02);
+ border: 1px solid rgb(var(--border));
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ transition: all var(--transition-fast);
+}
+
+.pop-mode-option:hover {
+ background: rgba(0, 212, 255, 0.05);
+ border-color: rgba(0, 212, 255, 0.3);
+}
+
+.pop-mode-option input[type="radio"] {
+ margin-top: 2px;
+ accent-color: rgb(var(--neon-cyan));
+}
+
+.pop-mode-info {
+ flex: 1;
+}
+
+.pop-mode-name {
+ font-size: var(--text-sm);
+ font-weight: 600;
+ color: rgb(var(--text-primary));
+ margin-bottom: 2px;
+}
+
+.pop-mode-desc {
+ font-size: var(--text-xs);
+ color: rgb(var(--text-muted));
+}
+
+.pop-settings-divider {
+ height: 1px;
+ background: rgb(var(--border));
+ margin: var(--spacing-lg) 0;
+}
+
+/* ==================== 테마 토글 ==================== */
+/* 기존 플로팅 테마 토글 (더 이상 사용 안함) */
+.pop-theme-toggle {
+ display: none;
+}
+
+/* 헤더 인라인 테마 토글 버튼 */
+.pop-theme-toggle-inline {
+ width: 32px;
+ height: 32px;
+ margin-left: var(--spacing-sm);
+ border-radius: var(--radius-md);
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid rgb(var(--border));
+ color: rgb(var(--text-secondary));
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all var(--transition-fast);
+ flex-shrink: 0;
+}
+
+.pop-theme-toggle-inline:hover {
+ border-color: rgb(var(--neon-cyan));
+ color: rgb(var(--neon-cyan));
+ box-shadow: var(--glow-cyan);
+}
+
+.pop-theme-toggle-inline svg {
+ width: 18px;
+ height: 18px;
+}
+
+/* ==================== 아이콘 버튼 ==================== */
+.pop-icon-btn {
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--radius-md);
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid rgb(var(--border));
+ color: rgb(var(--text-muted));
+ cursor: pointer;
+ transition: all var(--transition-fast);
+}
+
+.pop-icon-btn svg {
+ width: 16px;
+ height: 16px;
+}
+
+.pop-icon-btn:hover {
+ background: rgba(0, 212, 255, 0.1);
+ border-color: rgba(0, 212, 255, 0.4);
+ color: rgb(var(--neon-cyan));
+}
+
+/* ==================== 애니메이션 ==================== */
+@keyframes pulse-glow {
+ 0%, 100% {
+ box-shadow: 0 0 5px rgba(0, 212, 255, 0.5);
+ }
+ 50% {
+ box-shadow: 0 0 20px rgba(0, 212, 255, 0.8), 0 0 30px rgba(0, 212, 255, 0.4);
+ }
+}
+
+/* ==================== 반응형 ==================== */
+@media (max-width: 768px) {
+ .pop-work-steps-sidebar {
+ width: 100%;
+ max-height: 150px;
+ border-right: none;
+ border-bottom: 1px solid rgb(var(--border));
+ }
+
+ .pop-work-steps-list {
+ display: flex;
+ overflow-x: auto;
+ padding: var(--spacing-xs);
+ gap: var(--spacing-xs);
+ }
+
+ .pop-work-step-item {
+ flex: 0 0 auto;
+ min-width: 100px;
+ }
+
+ .pop-panel-body-content {
+ flex-direction: column;
+ }
+
+ .pop-slide-panel-content {
+ max-width: 100%;
+ }
+}
+
+@media (min-width: 1024px) {
+ .pop-app {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: var(--spacing-lg);
+ padding-bottom: calc(64px + var(--spacing-lg) + env(safe-area-inset-bottom, 0px));
+ }
+
+ .pop-work-list {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
+ gap: var(--spacing-md);
+ }
+
+ .pop-slide-panel-content {
+ max-width: 700px;
+ }
+
+ .pop-work-steps-sidebar {
+ width: 240px;
+ }
+}
+
+/* ==================== 라이트 모드 컴포넌트 스타일 ==================== */
+
+/* 하단 네비게이션 - 라이트 모드 */
+.pop-container.light .pop-bottom-nav {
+ background: rgba(255, 255, 255, 0.95);
+ border-top: 1px solid rgb(var(--border));
+}
+
+.pop-container.light .pop-nav-btn.secondary {
+ background: rgb(var(--bg-tertiary));
+ border: 1px solid rgb(var(--border));
+ color: rgb(var(--text-secondary));
+}
+
+.pop-container.light .pop-nav-btn.secondary:hover {
+ background: rgba(0, 122, 204, 0.08);
+ border-color: rgba(0, 122, 204, 0.3);
+ color: rgb(var(--text-primary));
+}
+
+.pop-container.light .pop-nav-btn.primary {
+ background: linear-gradient(135deg, rgba(0, 122, 204, 0.15) 0%, rgba(0, 100, 180, 0.25) 100%);
+ border: 1px solid rgba(0, 122, 204, 0.4);
+ color: rgb(var(--neon-cyan));
+ box-shadow: 0 2px 8px rgba(0, 122, 204, 0.15);
+}
+
+.pop-container.light .pop-nav-btn.primary:hover {
+ background: linear-gradient(135deg, rgba(0, 122, 204, 0.25) 0%, rgba(0, 100, 180, 0.35) 100%);
+ border-color: rgb(var(--neon-cyan));
+ box-shadow: 0 4px 12px rgba(0, 122, 204, 0.25);
+}
+
diff --git a/frontend/components/pop/types.ts b/frontend/components/pop/types.ts
new file mode 100644
index 00000000..4585e4d0
--- /dev/null
+++ b/frontend/components/pop/types.ts
@@ -0,0 +1,104 @@
+// POP 생산실적관리 타입 정의
+
+export interface Process {
+ id: string;
+ name: string;
+ code: string;
+}
+
+export interface Equipment {
+ id: string;
+ name: string;
+ processIds: string[];
+ processNames: string[];
+ status: "running" | "idle" | "maintenance";
+}
+
+export interface ProcessFlowStep {
+ id: string;
+ name: string;
+ status: "pending" | "current" | "completed";
+ isReturnTarget?: boolean;
+}
+
+export interface WorkOrder {
+ id: string;
+ itemCode: string;
+ itemName: string;
+ spec: string;
+ orderQuantity: number;
+ producedQuantity: number;
+ status: "waiting" | "in-progress" | "completed";
+ process: string;
+ processName: string;
+ equipment: string | null;
+ equipmentName: string | null;
+ startDate: string;
+ dueDate: string;
+ completedDate?: string;
+ priority: "high" | "medium" | "normal" | "low";
+ accepted: boolean;
+ processFlow: ProcessFlowStep[];
+ currentProcessIndex: number;
+ // 리턴 관련
+ isReturn?: boolean;
+ returnReason?: string;
+ returnFromProcess?: string;
+ returnFromProcessName?: string;
+ // 분할접수 관련
+ acceptedQuantity?: number;
+ remainingQuantity?: number;
+ isPartialAccept?: boolean;
+}
+
+export interface WorkStepTemplate {
+ id: number;
+ name: string;
+ type:
+ | "equipment-check"
+ | "material-check"
+ | "setup"
+ | "work"
+ | "inspection"
+ | "record"
+ | "preparation";
+ description: string;
+}
+
+export interface WorkStep extends WorkStepTemplate {
+ status: "pending" | "in-progress" | "completed";
+ startTime: Date | null;
+ endTime: Date | null;
+ data: Record;
+}
+
+export type StatusType = "waiting" | "pending-accept" | "in-progress" | "completed";
+
+export type ProductionType = "work-order" | "material";
+
+export interface AppState {
+ currentStatus: StatusType;
+ selectedEquipment: Equipment | null;
+ selectedProcess: Process | null;
+ selectedWorkOrder: WorkOrder | null;
+ showMyWorkOnly: boolean;
+ currentWorkSteps: WorkStep[];
+ currentStepIndex: number;
+ currentProductionType: ProductionType;
+ selectionMode: "single" | "multi";
+ completionAction: "close" | "stay";
+ acceptTargetWorkOrder: WorkOrder | null;
+ acceptQuantity: number;
+ theme: "dark" | "light";
+}
+
+export interface ModalState {
+ equipment: boolean;
+ process: boolean;
+ accept: boolean;
+ settings: boolean;
+}
+
+export interface PanelState {
+ production: boolean;
+}
From 47ac9ecd8a2c0264be5423d5d20f2ea1649f9415 Mon Sep 17 00:00:00 2001
From: kjs
Date: Wed, 7 Jan 2026 16:10:11 +0900
Subject: [PATCH 58/95] =?UTF-8?q?=EB=B2=94=EC=9A=A9=ED=8F=BC=EB=AA=A8?=
=?UTF-8?q?=EB=8B=AC=20=EC=99=B8=EB=B6=80=EC=86=8C=EC=8A=A4=20=EC=A7=80?=
=?UTF-8?q?=EC=9B=90?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../admin/systemMng/tableMngList/page.tsx | 341 +++++++++++++-----
.../TableSectionRenderer.tsx | 60 +++
.../UniversalFormModalComponent.tsx | 1 +
.../modals/TableSectionSettingsModal.tsx | 203 +++++++++++
.../components/universal-form-modal/types.ts | 9 +
5 files changed, 525 insertions(+), 89 deletions(-)
diff --git a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx
index 0b5ff573..4ba1e6c0 100644
--- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx
+++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx
@@ -6,7 +6,10 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
-import { Search, Database, RefreshCw, Settings, Plus, Activity, Trash2, Copy } from "lucide-react";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
+import { Search, Database, RefreshCw, Settings, Plus, Activity, Trash2, Copy, Check, ChevronsUpDown } from "lucide-react";
+import { cn } from "@/lib/utils";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { toast } from "sonner";
import { useMultiLang } from "@/hooks/useMultiLang";
@@ -90,6 +93,13 @@ export default function TableManagementPage() {
// 🎯 Entity 조인 관련 상태
const [referenceTableColumns, setReferenceTableColumns] = useState>({});
+ // 🆕 Entity 타입 Combobox 열림/닫힘 상태 (컬럼별 관리)
+ const [entityComboboxOpen, setEntityComboboxOpen] = useState>({});
+
// DDL 기능 관련 상태
const [createTableModalOpen, setCreateTableModalOpen] = useState(false);
const [addColumnModalOpen, setAddColumnModalOpen] = useState(false);
@@ -1388,113 +1398,266 @@ export default function TableManagementPage() {
{/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
{column.inputType === "entity" && (
<>
- {/* 참조 테이블 */}
-
+ {/* 참조 테이블 - 검색 가능한 Combobox */}
+
참조 테이블
-
- handleDetailSettingsChange(column.columnName, "entity", value)
+
+ setEntityComboboxOpen((prev) => ({
+ ...prev,
+ [column.columnName]: { ...prev[column.columnName], table: open },
+ }))
}
>
-
-
-
-
- {referenceTableOptions.map((option, index) => (
-
-
- {option.label}
- {option.value}
-
-
- ))}
-
-
+
+
+ {column.referenceTable && column.referenceTable !== "none"
+ ? referenceTableOptions.find((opt) => opt.value === column.referenceTable)?.label ||
+ column.referenceTable
+ : "테이블 선택..."}
+
+
+
+
+
+
+
+
+ 테이블을 찾을 수 없습니다.
+
+
+ {referenceTableOptions.map((option) => (
+ {
+ handleDetailSettingsChange(column.columnName, "entity", option.value);
+ setEntityComboboxOpen((prev) => ({
+ ...prev,
+ [column.columnName]: { ...prev[column.columnName], table: false },
+ }));
+ }}
+ className="text-xs"
+ >
+
+
+ {option.label}
+ {option.value !== "none" && (
+ {option.value}
+ )}
+
+
+ ))}
+
+
+
+
+
- {/* 조인 컬럼 */}
+ {/* 조인 컬럼 - 검색 가능한 Combobox */}
{column.referenceTable && column.referenceTable !== "none" && (
-
+
조인 컬럼
-
- handleDetailSettingsChange(
- column.columnName,
- "entity_reference_column",
- value,
- )
+
+ setEntityComboboxOpen((prev) => ({
+ ...prev,
+ [column.columnName]: { ...prev[column.columnName], joinColumn: open },
+ }))
}
>
-
-
-
-
- -- 선택 안함 --
- {referenceTableColumns[column.referenceTable]?.map((refCol, index) => (
-
- {refCol.columnName}
-
- ))}
- {(!referenceTableColumns[column.referenceTable] ||
- referenceTableColumns[column.referenceTable].length === 0) && (
-
-
+
+
+ {!referenceTableColumns[column.referenceTable] || referenceTableColumns[column.referenceTable].length === 0 ? (
+
- 로딩중
-
-
- )}
-
-
+ 로딩중...
+
+ ) : column.referenceColumn && column.referenceColumn !== "none" ? (
+ column.referenceColumn
+ ) : (
+ "컬럼 선택..."
+ )}
+
+
+
+
+
+
+
+
+ 컬럼을 찾을 수 없습니다.
+
+
+ {
+ handleDetailSettingsChange(column.columnName, "entity_reference_column", "none");
+ setEntityComboboxOpen((prev) => ({
+ ...prev,
+ [column.columnName]: { ...prev[column.columnName], joinColumn: false },
+ }));
+ }}
+ className="text-xs"
+ >
+
+ -- 선택 안함 --
+
+ {referenceTableColumns[column.referenceTable]?.map((refCol) => (
+ {
+ handleDetailSettingsChange(column.columnName, "entity_reference_column", refCol.columnName);
+ setEntityComboboxOpen((prev) => ({
+ ...prev,
+ [column.columnName]: { ...prev[column.columnName], joinColumn: false },
+ }));
+ }}
+ className="text-xs"
+ >
+
+
+ {refCol.columnName}
+ {refCol.columnLabel && (
+ {refCol.columnLabel}
+ )}
+
+
+ ))}
+
+
+
+
+
)}
- {/* 표시 컬럼 */}
+ {/* 표시 컬럼 - 검색 가능한 Combobox */}
{column.referenceTable &&
column.referenceTable !== "none" &&
column.referenceColumn &&
column.referenceColumn !== "none" && (
-
+
표시 컬럼
-
- handleDetailSettingsChange(
- column.columnName,
- "entity_display_column",
- value,
- )
+
+ setEntityComboboxOpen((prev) => ({
+ ...prev,
+ [column.columnName]: { ...prev[column.columnName], displayColumn: open },
+ }))
}
>
-
-
-
-
- -- 선택 안함 --
- {referenceTableColumns[column.referenceTable]?.map((refCol, index) => (
-
- {refCol.columnName}
-
- ))}
- {(!referenceTableColumns[column.referenceTable] ||
- referenceTableColumns[column.referenceTable].length === 0) && (
-
-
+
+
+ {!referenceTableColumns[column.referenceTable] || referenceTableColumns[column.referenceTable].length === 0 ? (
+
- 로딩중
-
-
- )}
-
-
+ 로딩중...
+
+ ) : column.displayColumn && column.displayColumn !== "none" ? (
+ column.displayColumn
+ ) : (
+ "컬럼 선택..."
+ )}
+
+
+
+
+
+
+
+
+ 컬럼을 찾을 수 없습니다.
+
+
+ {
+ handleDetailSettingsChange(column.columnName, "entity_display_column", "none");
+ setEntityComboboxOpen((prev) => ({
+ ...prev,
+ [column.columnName]: { ...prev[column.columnName], displayColumn: false },
+ }));
+ }}
+ className="text-xs"
+ >
+
+ -- 선택 안함 --
+
+ {referenceTableColumns[column.referenceTable]?.map((refCol) => (
+ {
+ handleDetailSettingsChange(column.columnName, "entity_display_column", refCol.columnName);
+ setEntityComboboxOpen((prev) => ({
+ ...prev,
+ [column.columnName]: { ...prev[column.columnName], displayColumn: false },
+ }));
+ }}
+ className="text-xs"
+ >
+
+
+ {refCol.columnName}
+ {refCol.columnLabel && (
+ {refCol.columnLabel}
+ )}
+
+
+ ))}
+
+
+
+
+
)}
@@ -1505,8 +1668,8 @@ export default function TableManagementPage() {
column.referenceColumn !== "none" &&
column.displayColumn &&
column.displayColumn !== "none" && (
-
-
✓
+
+
설정 완료
)}
diff --git a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx
index 4f872bc1..ac43e1ed 100644
--- a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx
@@ -32,6 +32,8 @@ interface TableSectionRendererProps {
onTableDataChange: (data: any[]) => void;
// 조건부 테이블용 콜백 (조건별 데이터 변경)
onConditionalTableDataChange?: (conditionValue: string, data: any[]) => void;
+ // 외부 데이터 (데이터 전달 모달열기 액션으로 전달받은 데이터)
+ groupedData?: Record
[];
className?: string;
}
@@ -337,6 +339,7 @@ export function TableSectionRenderer({
onFormDataChange,
onTableDataChange,
onConditionalTableDataChange,
+ groupedData,
className,
}: TableSectionRendererProps) {
// 테이블 데이터 상태 (일반 모드)
@@ -373,6 +376,13 @@ export function TableSectionRenderer({
// 초기 데이터 로드 완료 플래그 (무한 루프 방지)
const initialDataLoadedRef = React.useRef(false);
+ // 외부 데이터 로드 완료 플래그
+ const externalDataLoadedRef = React.useRef(false);
+
+ // 외부 데이터 소스 설정
+ const externalDataConfig = tableConfig.externalDataSource;
+ const isExternalDataMode = externalDataConfig?.enabled && externalDataConfig?.tableName;
+
// 조건부 테이블 설정
const conditionalConfig = tableConfig.conditionalTable;
const isConditionalMode = conditionalConfig?.enabled ?? false;
@@ -388,6 +398,56 @@ export function TableSectionRenderer({
// 소스 테이블의 컬럼 라벨 (API에서 동적 로드)
const [sourceColumnLabels, setSourceColumnLabels] = useState>({});
+ // 외부 데이터(groupedData) 처리: 데이터 전달 모달열기 액션으로 전달받은 데이터를 초기 테이블 데이터로 설정
+ useEffect(() => {
+ // 외부 데이터 소스가 활성화되지 않았거나, groupedData가 없으면 스킵
+ if (!isExternalDataMode) return;
+ if (!groupedData || groupedData.length === 0) return;
+ // 이미 로드된 경우 스킵
+ if (externalDataLoadedRef.current) return;
+
+ console.log("[TableSectionRenderer] 외부 데이터 처리 시작:", {
+ externalTableName: externalDataConfig?.tableName,
+ groupedDataCount: groupedData.length,
+ columns: tableConfig.columns?.length,
+ });
+
+ // groupedData를 테이블 컬럼 매핑에 따라 변환
+ const mappedData = groupedData.map((externalRow, index) => {
+ const newRow: Record = {
+ _id: `external_${Date.now()}_${index}`,
+ _sourceData: externalRow, // 원본 데이터 보관
+ };
+
+ // 각 컬럼에 대해 externalField 또는 field로 값을 매핑
+ tableConfig.columns?.forEach((col) => {
+ // externalField가 설정되어 있으면 사용, 아니면 field와 동일한 이름으로 매핑
+ const externalFieldName = col.externalField || col.field;
+ const value = externalRow[externalFieldName];
+
+ // 값이 있으면 설정
+ if (value !== undefined) {
+ newRow[col.field] = value;
+ } else if (col.defaultValue !== undefined) {
+ // 기본값이 설정되어 있으면 기본값 사용
+ newRow[col.field] = col.defaultValue;
+ }
+ });
+
+ return newRow;
+ });
+
+ console.log("[TableSectionRenderer] 외부 데이터 매핑 완료:", {
+ mappedCount: mappedData.length,
+ sampleRow: mappedData[0],
+ });
+
+ // 테이블 데이터 설정
+ setTableData(mappedData);
+ onTableDataChange(mappedData);
+ externalDataLoadedRef.current = true;
+ }, [isExternalDataMode, groupedData, tableConfig.columns, externalDataConfig?.tableName, onTableDataChange]);
+
// 소스 테이블의 카테고리 타입 컬럼 목록 로드
useEffect(() => {
const loadCategoryColumns = async () => {
diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx
index cebd8aa6..0b0f73a6 100644
--- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx
@@ -2295,6 +2295,7 @@ export function UniversalFormModalComponent({
// 테이블 섹션 데이터를 formData에 저장
handleFieldChange(`_tableSection_${section.id}`, data);
}}
+ groupedData={_groupedData}
/>
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 a7bec622..a145b49a 100644
--- a/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx
@@ -710,6 +710,9 @@ interface ColumnSettingItemProps {
displayColumns: string[]; // 검색 설정에서 선택한 표시 컬럼 목록
sourceTableColumns: { column_name: string; data_type: string; is_nullable: string; comment?: string }[]; // 소스 테이블 컬럼
sourceTableName: string; // 소스 테이블명
+ externalTableColumns: { column_name: string; data_type: string; is_nullable: string; comment?: string }[]; // 외부 데이터 테이블 컬럼
+ externalTableName?: string; // 외부 데이터 테이블명
+ externalDataEnabled?: boolean; // 외부 데이터 소스 활성화 여부
tables: { table_name: string; comment?: string }[]; // 전체 테이블 목록
tableColumns: Record; // 테이블별 컬럼
sections: { id: string; title: string }[]; // 섹션 목록
@@ -731,6 +734,9 @@ function ColumnSettingItem({
displayColumns,
sourceTableColumns,
sourceTableName,
+ externalTableColumns,
+ externalTableName,
+ externalDataEnabled,
tables,
tableColumns,
sections,
@@ -745,6 +751,7 @@ function ColumnSettingItem({
}: ColumnSettingItemProps) {
const [fieldSearchOpen, setFieldSearchOpen] = useState(false);
const [sourceFieldSearchOpen, setSourceFieldSearchOpen] = useState(false);
+ const [externalFieldSearchOpen, setExternalFieldSearchOpen] = useState(false);
const [parentFieldSearchOpen, setParentFieldSearchOpen] = useState(false);
const [lookupTableOpenMap, setLookupTableOpenMap] = useState>({});
@@ -1014,6 +1021,88 @@ function ColumnSettingItem({
+ {/* 외부 필드 - Combobox (외부 데이터에서 가져올 컬럼) */}
+ {externalDataEnabled && externalTableName && (
+
+
외부 필드
+
+
+
+
+ {col.externalField || "(필드명과 동일)"}
+
+
+
+
+
+
+
+
+
+ 외부 필드를 찾을 수 없습니다.
+
+
+ {/* 필드명과 동일 옵션 */}
+ {
+ onUpdate({ externalField: undefined });
+ setExternalFieldSearchOpen(false);
+ }}
+ className="text-xs"
+ >
+
+ (필드명과 동일)
+
+ {/* 외부 테이블 컬럼 목록 */}
+ {externalTableColumns.map((extCol) => (
+ {
+ onUpdate({ externalField: extCol.column_name });
+ setExternalFieldSearchOpen(false);
+ }}
+ className="text-xs"
+ >
+
+
+ {extCol.column_name}
+ {extCol.comment && (
+
+ {extCol.comment}
+
+ )}
+
+
+ ))}
+
+
+
+
+
+
+ 외부 데이터({externalTableName})에서 이 컬럼에 매핑할 필드
+
+
+ )}
+
{/* 라벨 */}
라벨
@@ -2450,6 +2539,7 @@ export function TableSectionSettingsModal({
// 테이블 검색 Combobox 상태
const [tableSearchOpen, setTableSearchOpen] = useState(false);
const [saveTableSearchOpen, setSaveTableSearchOpen] = useState(false);
+ const [externalTableSearchOpen, setExternalTableSearchOpen] = useState(false);
// 활성 탭
const [activeTab, setActiveTab] = useState("source");
@@ -2623,6 +2713,24 @@ export function TableSectionSettingsModal({
});
};
+ const updateExternalDataSource = (updates: Partial>) => {
+ updateTableConfig({
+ externalDataSource: { ...tableConfig.externalDataSource, enabled: false, tableName: "", ...updates },
+ });
+ };
+
+ // 외부 데이터 소스 테이블 컬럼 목록
+ const externalTableColumns = useMemo(() => {
+ return tableColumns[tableConfig.externalDataSource?.tableName || ""] || [];
+ }, [tableColumns, tableConfig.externalDataSource?.tableName]);
+
+ // 외부 데이터 소스 테이블 변경 시 컬럼 로드
+ useEffect(() => {
+ if (tableConfig.externalDataSource?.enabled && tableConfig.externalDataSource?.tableName) {
+ onLoadTableColumns(tableConfig.externalDataSource.tableName);
+ }
+ }, [tableConfig.externalDataSource?.enabled, tableConfig.externalDataSource?.tableName, onLoadTableColumns]);
+
// 저장 함수
const handleSave = () => {
onSave({
@@ -2986,6 +3094,98 @@ export function TableSectionSettingsModal({
+
+ {/* 외부 데이터 소스 설정 */}
+
+
+
+
외부 데이터 소스
+
+ "데이터 전달 모달열기" 액션으로 전달받은 데이터를 테이블에 표시합니다.
+
+
+
{
+ if (checked) {
+ updateExternalDataSource({ enabled: true, tableName: "" });
+ } else {
+ updateTableConfig({ externalDataSource: undefined });
+ }
+ }}
+ />
+
+
+ {tableConfig.externalDataSource?.enabled && (
+
+
+
외부 데이터 테이블
+
+
+
+ {tableConfig.externalDataSource?.tableName || "테이블 선택..."}
+
+
+
+
+
+
+
+
+ 테이블을 찾을 수 없습니다.
+
+
+ {tables.map((table) => (
+ {
+ updateExternalDataSource({ enabled: true, tableName: table.table_name });
+ onLoadTableColumns(table.table_name);
+ setExternalTableSearchOpen(false);
+ }}
+ className="text-sm"
+ >
+
+
+ {table.table_name}
+ {table.comment && (
+ {table.comment}
+ )}
+
+
+ ))}
+
+
+
+
+
+
이전 화면에서 전달받을 데이터의 원본 테이블을 선택하세요. (예: 수주상세 데이터를 전달받는 경우 sales_order_detail)
+
+
+ {tableConfig.externalDataSource?.tableName && externalTableColumns.length > 0 && (
+
+
+ 선택한 테이블 컬럼: {externalTableColumns.length}개
+
+
+ "컬럼 설정" 탭에서 각 컬럼의 "외부 필드"를 설정하여 전달받은 데이터의 컬럼을 매핑하세요.
+
+
+ )}
+
+ )}
+
{/* 컬럼 설정 탭 */}
@@ -3041,6 +3241,9 @@ export function TableSectionSettingsModal({
displayColumns={tableConfig.source.displayColumns || []}
sourceTableColumns={sourceTableColumns}
sourceTableName={tableConfig.source.tableName}
+ externalTableColumns={externalTableColumns}
+ externalTableName={tableConfig.externalDataSource?.tableName}
+ externalDataEnabled={tableConfig.externalDataSource?.enabled}
tables={tables}
tableColumns={tableColumns}
sections={otherSections}
diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts
index 9d54270b..303705b6 100644
--- a/frontend/lib/registry/components/universal-form-modal/types.ts
+++ b/frontend/lib/registry/components/universal-form-modal/types.ts
@@ -230,6 +230,12 @@ export interface TableSectionConfig {
columnLabels?: Record
; // 컬럼 라벨 (컬럼명 -> 표시 라벨)
};
+ // 1-1. 외부 데이터 소스 설정 (데이터 전달 모달열기 액션으로 전달받은 데이터)
+ externalDataSource?: {
+ enabled: boolean; // 외부 데이터 소스 사용 여부
+ tableName: string; // 전달받을 데이터의 소스 테이블명 (예: sales_order_detail)
+ };
+
// 2. 필터 설정
filters?: {
// 사전 필터 (항상 적용, 사용자에게 노출되지 않음)
@@ -374,6 +380,9 @@ export interface TableColumnConfig {
// 소스 필드 매핑 (검색 모달에서 가져올 컬럼명)
sourceField?: string; // 소스 테이블의 컬럼명 (미설정 시 field와 동일)
+ // 외부 데이터 필드 매핑 (데이터 전달 모달열기로 전달받은 데이터의 컬럼명)
+ externalField?: string; // 외부 데이터의 컬럼명 (미설정 시 field와 동일)
+
// 편집 설정
editable?: boolean; // 편집 가능 여부 (기본: true)
calculated?: boolean; // 계산 필드 여부 (자동 읽기전용)
From 6f4c9b7fdd80615dc5e712f0ecef96031390649d Mon Sep 17 00:00:00 2001
From: SeongHyun Kim
Date: Wed, 7 Jan 2026 17:42:40 +0900
Subject: [PATCH 59/95] =?UTF-8?q?ix:=20=EB=B6=80=EB=AA=A8-=EC=9E=90?=
=?UTF-8?q?=EC=8B=9D=20=EB=AA=A8=EB=8B=AC=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?=
=?UTF-8?q?=EC=A0=84=EB=8B=AC=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0=20?=
=?UTF-8?q?=EB=B0=8F=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20multiRowSave=20?=
=?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=A0=9C=EA=B1=B0=20InteractiveScreenView?=
=?UTF-8?q?erDynamic:=20=EC=83=9D=EC=84=B1=20=EB=AA=A8=EB=93=9C=EC=97=90?=
=?UTF-8?q?=EC=84=9C=20formData=EB=A5=BC=20initialData=EB=A1=9C=20?=
=?UTF-8?q?=EC=A0=84=EB=8B=AC=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?=
=?UTF-8?q?=EC=A0=95=20UniversalFormModal:=20saveMultipleRows=20=ED=95=A8?=
=?UTF-8?q?=EC=88=98=20=EB=B0=8F=20multiRowSave=20=EA=B4=80=EB=A0=A8=20?=
=?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=84=EC=B2=B4=20=EC=A0=9C=EA=B1=B0=20?=
=?UTF-8?q?types/config:=20MultiRowSaveConfig=20=EC=9D=B8=ED=84=B0?=
=?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EB=B0=8F=20=EA=B8=B0=EB=B3=B8?=
=?UTF-8?q?=EA=B0=92=20=EC=A0=9C=EA=B1=B0=20FieldDetailSettingsModal:=20re?=
=?UTF-8?q?ceiveFromParent=20UI=20=EC=98=B5=EC=85=98=20=EC=A0=9C=EA=B1=B0?=
=?UTF-8?q?=20SaveSettingsModal:=20=EC=A0=80=EC=9E=A5=20=EB=AA=A8=EB=93=9C?=
=?UTF-8?q?=20=EC=84=A4=EB=AA=85=20=EA=B0=9C=EC=84=A0=20DB:=20multiRowSave?=
=?UTF-8?q?.enabled=3Dtrue=EC=9D=B8=20=ED=99=94=EB=A9=B4=203=EA=B0=9C=20?=
=?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../screen/InteractiveScreenViewerDynamic.tsx | 2 +-
.../SplitPanelLayout2Component.tsx | 1 -
.../UniversalFormModalComponent.tsx | 130 +-----------------
.../UniversalFormModalConfigPanel.tsx | 1 -
.../components/universal-form-modal/config.ts | 9 --
.../components/universal-form-modal/index.ts | 21 ++-
.../modals/FieldDetailSettingsModal.tsx | 57 --------
.../modals/SaveSettingsModal.tsx | 32 +++--
.../components/universal-form-modal/types.ts | 16 ---
9 files changed, 40 insertions(+), 229 deletions(-)
diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx
index 1dfdba14..d906a404 100644
--- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx
+++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx
@@ -365,7 +365,7 @@ export const InteractiveScreenViewerDynamic: React.FC 0) ? originalData : formData} // 🆕 originalData가 있으면 사용, 없으면 formData 사용 (생성 모드에서 부모 데이터 전달)
onFormDataChange={handleFormDataChange}
screenId={screenInfo?.id}
tableName={screenInfo?.tableName}
diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx
index 6e38e86e..a06c046f 100644
--- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx
+++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx
@@ -654,7 +654,6 @@ export const SplitPanelLayout2Component: React.FC DynamicComponentRenderer에서 전달된 prop
const initialData = propInitialData || _initialData;
+
// 설정 병합
const config: UniversalFormModalConfig = useMemo(() => {
const componentConfig = component?.config || {};
@@ -155,11 +156,6 @@ export function UniversalFormModalComponent({
...defaultConfig.saveConfig,
...propConfig?.saveConfig,
...componentConfig.saveConfig,
- multiRowSave: {
- ...defaultConfig.saveConfig.multiRowSave,
- ...propConfig?.saveConfig?.multiRowSave,
- ...componentConfig.saveConfig?.multiRowSave,
- },
afterSave: {
...defaultConfig.saveConfig.afterSave,
...propConfig?.saveConfig?.afterSave,
@@ -1504,118 +1500,6 @@ export function UniversalFormModalComponent({
formData,
]);
- // 다중 행 저장 (겸직 등)
- const saveMultipleRows = useCallback(async () => {
- const { multiRowSave } = config.saveConfig;
- if (!multiRowSave) return;
-
- let { commonFields = [], repeatSectionId = "" } = multiRowSave;
- const { typeColumn, mainTypeValue, subTypeValue, mainSectionFields = [] } = multiRowSave;
-
- // 공통 필드가 설정되지 않은 경우, 기본정보 섹션의 모든 필드를 공통 필드로 사용
- if (commonFields.length === 0) {
- const nonRepeatableSections = config.sections.filter((s) => !s.repeatable);
- commonFields = nonRepeatableSections.flatMap((s) => (s.fields || []).map((f) => f.columnName));
- }
-
- // 반복 섹션 ID가 설정되지 않은 경우, 첫 번째 반복 섹션 사용
- if (!repeatSectionId) {
- const repeatableSection = config.sections.find((s) => s.repeatable);
- if (repeatableSection) {
- repeatSectionId = repeatableSection.id;
- }
- }
-
- // 반복 섹션 데이터
- const repeatItems = repeatSections[repeatSectionId] || [];
-
- // 저장할 행들 생성
- const rowsToSave: any[] = [];
-
- // 공통 데이터 (모든 행에 적용)
- const commonData: any = {};
- commonFields.forEach((fieldName) => {
- if (formData[fieldName] !== undefined) {
- commonData[fieldName] = formData[fieldName];
- }
- });
-
- // 메인 섹션 필드 데이터 (메인 행에만 적용되는 부서/직급 등)
- const mainSectionData: any = {};
- mainSectionFields.forEach((fieldName) => {
- if (formData[fieldName] !== undefined) {
- mainSectionData[fieldName] = formData[fieldName];
- }
- });
-
- // 메인 행 (공통 데이터 + 메인 섹션 필드)
- const mainRow: any = { ...commonData, ...mainSectionData };
- if (typeColumn) {
- mainRow[typeColumn] = mainTypeValue || "main";
- }
- rowsToSave.push(mainRow);
-
- // 반복 섹션 행들 (공통 데이터 + 반복 섹션 필드)
- for (const item of repeatItems) {
- const subRow: any = { ...commonData };
-
- // 반복 섹션의 필드 값 추가
- const repeatSection = config.sections.find((s) => s.id === repeatSectionId);
- (repeatSection?.fields || []).forEach((field) => {
- if (item[field.columnName] !== undefined) {
- subRow[field.columnName] = item[field.columnName];
- }
- });
-
- if (typeColumn) {
- subRow[typeColumn] = subTypeValue || "concurrent";
- }
-
- rowsToSave.push(subRow);
- }
-
- // 저장 시점 채번규칙 처리 (메인 행만)
- for (const section of config.sections) {
- if (section.repeatable || section.type === "table") continue;
-
- for (const field of section.fields || []) {
- if (field.numberingRule?.enabled && field.numberingRule?.ruleId) {
- // generateOnSave 또는 generateOnOpen 모두 저장 시 실제 순번 할당
- const shouldAllocate = field.numberingRule.generateOnSave || field.numberingRule.generateOnOpen;
- if (shouldAllocate) {
- const response = await allocateNumberingCode(field.numberingRule.ruleId);
- if (response.success && response.data?.generatedCode) {
- // 모든 행에 동일한 채번 값 적용 (공통 필드인 경우)
- if (commonFields.includes(field.columnName)) {
- rowsToSave.forEach((row) => {
- row[field.columnName] = response.data?.generatedCode;
- });
- } else {
- rowsToSave[0][field.columnName] = response.data?.generatedCode;
- }
- }
- }
- }
- }
- }
-
- // 모든 행 저장
- for (let i = 0; i < rowsToSave.length; i++) {
- const row = rowsToSave[i];
-
- // 빈 객체 체크
- if (Object.keys(row).length === 0) {
- continue;
- }
-
- const response = await apiClient.post(`/table-management/tables/${config.saveConfig.tableName}/add`, row);
-
- if (!response.data?.success) {
- throw new Error(response.data?.message || `${i + 1}번째 행 저장 실패`);
- }
- }
- }, [config.sections, config.saveConfig, formData, repeatSections]);
-
// 다중 테이블 저장 (범용)
const saveWithMultiTable = useCallback(async () => {
const { customApiSave } = config.saveConfig;
@@ -1863,16 +1747,13 @@ export function UniversalFormModalComponent({
setSaving(true);
try {
- const { multiRowSave, customApiSave } = config.saveConfig;
+ const { customApiSave } = config.saveConfig;
- // 커스텀 API 저장 모드
+ // 커스텀 API 저장 모드 (다중 테이블)
if (customApiSave?.enabled) {
await saveWithCustomApi();
- } else if (multiRowSave?.enabled) {
- // 다중 행 저장
- await saveMultipleRows();
} else {
- // 단일 행 저장
+ // 단일 테이블 저장
await saveSingleRow();
}
@@ -1886,7 +1767,7 @@ export function UniversalFormModalComponent({
}
// onSave 콜백은 저장 완료 알림용으로만 사용
- // 실제 저장은 이미 위에서 완료됨 (saveSingleRow 또는 saveMultipleRows)
+ // 실제 저장은 이미 위에서 완료됨
// EditModal 등 부모 컴포넌트의 저장 로직이 다시 실행되지 않도록
// _saveCompleted 플래그를 포함하여 전달
if (onSave) {
@@ -1916,7 +1797,6 @@ export function UniversalFormModalComponent({
onSave,
validateRequiredFields,
saveSingleRow,
- saveMultipleRows,
saveWithCustomApi,
]);
diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx
index 7186ca7e..b060c4b4 100644
--- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx
@@ -885,7 +885,6 @@ export function UniversalFormModalConfigPanel({
tableColumns={tableColumns}
numberingRules={numberingRules}
onLoadTableColumns={loadTableColumns}
- availableParentFields={availableParentFields}
targetTableName={config.saveConfig?.tableName}
targetTableColumns={config.saveConfig?.tableName ? tableColumns[config.saveConfig.tableName] || [] : []}
/>
diff --git a/frontend/lib/registry/components/universal-form-modal/config.ts b/frontend/lib/registry/components/universal-form-modal/config.ts
index 41c18043..78b1583e 100644
--- a/frontend/lib/registry/components/universal-form-modal/config.ts
+++ b/frontend/lib/registry/components/universal-form-modal/config.ts
@@ -45,15 +45,6 @@ export const defaultConfig: UniversalFormModalConfig = {
saveConfig: {
tableName: "",
primaryKeyColumn: "id",
- multiRowSave: {
- enabled: false,
- commonFields: [],
- repeatSectionId: "",
- typeColumn: "",
- mainTypeValue: "main",
- subTypeValue: "concurrent",
- mainSectionFields: [],
- },
afterSave: {
closeModal: true,
refreshParent: true,
diff --git a/frontend/lib/registry/components/universal-form-modal/index.ts b/frontend/lib/registry/components/universal-form-modal/index.ts
index f98bf438..40af1dfc 100644
--- a/frontend/lib/registry/components/universal-form-modal/index.ts
+++ b/frontend/lib/registry/components/universal-form-modal/index.ts
@@ -9,14 +9,14 @@ import { defaultConfig } from "./config";
/**
* 범용 폼 모달 컴포넌트 정의
*
- * 섹션 기반 폼 레이아웃, 채번규칙, 다중 행 저장을 지원하는
+ * 섹션 기반 폼 레이아웃, 채번규칙을 지원하는
* 범용 모달 컴포넌트입니다.
*/
export const UniversalFormModalDefinition = createComponentDefinition({
id: "universal-form-modal",
name: "범용 폼 모달",
nameEng: "Universal Form Modal",
- description: "섹션 기반 폼 레이아웃, 채번규칙, 다중 행 저장을 지원하는 범용 모달 컴포넌트",
+ description: "섹션 기반 폼 레이아웃, 채번규칙을 지원하는 범용 모달 컴포넌트",
category: ComponentCategory.INPUT,
webType: "form",
component: UniversalFormModalComponent,
@@ -28,7 +28,7 @@ export const UniversalFormModalDefinition = createComponentDefinition({
},
configPanel: UniversalFormModalConfigPanel,
icon: "FormInput",
- tags: ["폼", "모달", "입력", "저장", "채번", "겸직", "다중행"],
+ tags: ["폼", "모달", "입력", "저장", "채번"],
version: "1.0.0",
author: "개발팀",
documentation: `
@@ -36,22 +36,22 @@ export const UniversalFormModalDefinition = createComponentDefinition({
### 주요 기능
- **섹션 기반 레이아웃**: 기본 정보, 추가 정보 등 섹션별로 폼 구성
-- **반복 섹션**: 겸직처럼 동일한 필드 그룹을 여러 개 추가 가능
+- **반복 섹션**: 동일한 필드 그룹을 여러 개 추가 가능
- **채번규칙 연동**: 자동 코드 생성 (모달 열릴 때 또는 저장 시점)
-- **다중 행 저장**: 공통 필드 + 개별 필드 조합으로 여러 행 동시 저장
+- **단일/다중 테이블 저장**: 단일 테이블 또는 메인+서브 테이블에 저장
- **외부 데이터 수신**: 부모 화면에서 전달받은 값 자동 채움
### 사용 예시
-1. 부서관리 사원 추가 + 겸직 등록
-2. 품목 등록 + 규격 옵션 추가
-3. 거래처 등록 + 담당자 정보 추가
+1. 사원 등록, 부서 등록, 거래처 등록 (단일 테이블)
+2. 주문 등록 + 주문 상세 (다중 테이블)
+3. 품목 등록 + 규격 옵션 추가
### 설정 방법
1. 저장 테이블 선택
-2. 섹션 추가 (기본 정보, 겸직 정보 등)
+2. 섹션 추가 (기본 정보 등)
3. 각 섹션에 필드 추가
4. 반복 섹션 설정 (필요 시)
-5. 다중 행 저장 설정 (필요 시)
+5. 다중 테이블 저장 설정 (필요 시)
6. 채번규칙 연동 (필요 시)
`,
});
@@ -69,7 +69,6 @@ export type {
FormSectionConfig,
FormFieldConfig,
SaveConfig,
- MultiRowSaveConfig,
NumberingRuleConfig,
SelectOptionConfig,
FormDataState,
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 8882d9bc..453429e7 100644
--- a/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx
@@ -65,8 +65,6 @@ interface FieldDetailSettingsModalProps {
tableColumns: { [tableName: string]: { name: string; type: string; label: string }[] };
numberingRules: { id: string; name: string }[];
onLoadTableColumns: (tableName: string) => void;
- // 부모 화면에서 전달 가능한 필드 목록 (선택사항)
- availableParentFields?: AvailableParentField[];
// 저장 테이블 정보 (타겟 컬럼 선택용)
targetTableName?: string;
targetTableColumns?: { name: string; type: string; label: string }[];
@@ -81,7 +79,6 @@ export function FieldDetailSettingsModal({
tableColumns,
numberingRules,
onLoadTableColumns,
- availableParentFields = [],
// targetTableName은 타겟 컬럼 선택 시 참고용으로 전달됨 (현재 targetTableColumns만 사용)
targetTableName: _targetTableName,
targetTableColumns = [],
@@ -330,60 +327,6 @@ export function FieldDetailSettingsModal({
/>
화면에 표시하지 않지만 값은 저장됩니다
-
-
-
-
- 부모에서 값 받기
- updateField({ receiveFromParent: checked })}
- />
-
- 부모 화면에서 전달받은 값으로 자동 채워집니다
-
- {/* 부모에서 값 받기 활성화 시 필드 선택 */}
- {localField.receiveFromParent && (
-
-
부모 필드명 선택
- {availableParentFields.length > 0 ? (
-
updateField({ parentFieldName: value })}
- >
-
-
-
-
- {availableParentFields.map((pf) => (
-
-
- {pf.label || pf.name}
- {pf.sourceComponent && (
-
- {pf.sourceComponent}{pf.sourceTable && ` (${pf.sourceTable})`}
-
- )}
-
-
- ))}
-
-
- ) : (
-
-
updateField({ parentFieldName: e.target.value })}
- placeholder={`예: ${localField.columnName || "parent_field_name"}`}
- className="h-8 text-xs"
- />
-
- 부모 화면에서 전달받을 필드명을 입력하세요. 비워두면 "{localField.columnName}"을 사용합니다.
-
-
- )}
-
- )}
{/* Accordion으로 고급 설정 */}
diff --git a/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx
index 11b3a8ae..01c87fcb 100644
--- a/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx
@@ -378,7 +378,11 @@ export function SaveSettingsModal({
단일 테이블 저장
-
모든 필드를 하나의 테이블에 저장합니다 (기본 방식)
+
+ 폼 데이터를 하나의 테이블에 1개 행으로 저장합니다.
+
+ 예: 사원 등록, 부서 등록, 거래처 등록 등 단순 등록 화면
+
@@ -387,9 +391,13 @@ export function SaveSettingsModal({
- 메인 테이블 + 서브 테이블에 트랜잭션으로 저장합니다
+ 하나의 폼으로 여러 테이블에 동시 저장합니다. (트랜잭션으로 묶임)
- 예: 주문(orders) + 주문상세(order_items), 사원(user_info) + 부서(user_dept)
+ 메인 테이블: 폼의 모든 필드 중 해당 테이블 컬럼과 일치하는 것 자동 저장
+
+ 서브 테이블: 필드 매핑에서 지정한 필드만 저장 (메인 테이블의 키 값이 자동 연결됨)
+
+ 예: 사원+부서배정(user_info+user_dept), 주문+주문상세(orders+order_items)
@@ -691,9 +699,11 @@ export function SaveSettingsModal({
- 반복 섹션 데이터를 별도 테이블에 저장합니다.
+ 폼에서 입력한 필드를 서브 테이블에 나눠서 저장합니다.
- 예: 주문상세(order_items), 겸직부서(user_dept)
+ 메인 테이블의 키 값(예: user_id)이 서브 테이블에 자동으로 연결됩니다.
+
+ 필드 매핑에서 지정한 필드만 서브 테이블에 저장됩니다.
{(localSaveConfig.customApiSave?.multiTable?.subTables || []).length === 0 ? (
@@ -802,13 +812,13 @@ export function SaveSettingsModal({
- 연결할 반복 섹션
+ 연결할 반복 섹션 (선택사항)
updateSubTable(subIndex, { repeatSectionId: value })}
>
-
+
{repeatSections.length === 0 ? (
@@ -824,7 +834,13 @@ export function SaveSettingsModal({
)}
- 이 서브 테이블에 저장할 반복 섹션을 선택하세요
+
+ 반복 섹션: 폼 안에서 동적으로 항목을 추가/삭제할 수 있는 섹션 (예: 주문 품목 목록)
+
+ 반복 섹션이 있으면 해당 섹션의 각 항목이 서브 테이블에 여러 행으로 저장됩니다.
+
+ 반복 섹션 없이 필드 매핑만 사용하면 1개 행만 저장됩니다.
+
diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts
index 9d54270b..ebd0b902 100644
--- a/frontend/lib/registry/components/universal-form-modal/types.ts
+++ b/frontend/lib/registry/components/universal-form-modal/types.ts
@@ -639,19 +639,6 @@ export interface TableCalculationRule {
conditionalCalculation?: ConditionalCalculationConfig;
}
-// 다중 행 저장 설정
-export interface MultiRowSaveConfig {
- enabled?: boolean; // 사용 여부 (기본: false)
- commonFields?: string[]; // 모든 행에 공통 저장할 필드 (columnName 기준)
- repeatSectionId?: string; // 반복 섹션 ID
- typeColumn?: string; // 구분 컬럼명 (예: "employment_type")
- mainTypeValue?: string; // 메인 행 값 (예: "main")
- subTypeValue?: string; // 서브 행 값 (예: "concurrent")
-
- // 메인 섹션 필드 (반복 섹션이 아닌 곳의 부서/직급 등)
- mainSectionFields?: string[]; // 메인 행에만 저장할 필드
-}
-
/**
* 섹션별 저장 방식 설정
* 공통 저장: 해당 섹션의 필드 값이 모든 품목 행에 동일하게 저장됩니다 (예: 수주번호, 거래처)
@@ -672,9 +659,6 @@ export interface SaveConfig {
tableName: string;
primaryKeyColumn?: string; // PK 컬럼 (수정 시 사용)
- // 다중 행 저장 설정
- multiRowSave?: MultiRowSaveConfig;
-
// 커스텀 API 저장 설정 (테이블 직접 저장 대신 전용 API 사용)
customApiSave?: CustomApiSaveConfig;
From 17498b1b2bf5fbc7c8a58f4ddf389d31f4e6659f Mon Sep 17 00:00:00 2001
From: SeongHyun Kim
Date: Thu, 8 Jan 2026 10:04:05 +0900
Subject: [PATCH 60/95] =?UTF-8?q?refactor:=20UniversalFormModalComponent?=
=?UTF-8?q?=20=EC=9E=90=EC=B2=B4=20=EC=A0=80=EC=9E=A5=20=EA=B8=B0=EB=8A=A5?=
=?UTF-8?q?=20=EC=A0=9C=EA=B1=B0=20saveSingleRow,=20saveWithCustomApi,=20h?=
=?UTF-8?q?andleSave,=20handleReset=20=ED=95=A8=EC=88=98=20=EC=82=AD?=
=?UTF-8?q?=EC=A0=9C=20saving=20=EC=83=81=ED=83=9C=20=EB=B0=8F=20=EC=A0=80?=
=?UTF-8?q?=EC=9E=A5/=EC=B4=88=EA=B8=B0=ED=99=94=20=EB=B2=84=ED=8A=BC=20UI?=
=?UTF-8?q?=20=EC=82=AD=EC=A0=9C=20=EC=84=A4=EC=A0=95=20=ED=8C=A8=EB=84=90?=
=?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=A0=80=EC=9E=A5=20=EB=B2=84=ED=8A=BC=20?=
=?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=84=A4=EC=A0=95=20UI=20=EC=82=AD?=
=?UTF-8?q?=EC=A0=9C=20ModalConfig=20=ED=83=80=EC=9E=85=EC=97=90=EC=84=9C?=
=?UTF-8?q?=20=EB=B2=84=ED=8A=BC=20=EA=B4=80=EB=A0=A8=20=EC=86=8D=EC=84=B1?=
=?UTF-8?q?=20=EC=82=AD=EC=A0=9C=20=EC=A0=80=EC=9E=A5=20=EC=B2=98=EB=A6=AC?=
=?UTF-8?q?=EB=8A=94=20button-primary=20(action:=20save)=EB=A1=9C=20?=
=?UTF-8?q?=EC=9C=84=EC=9E=84=20=EC=95=BD=20468=EC=A4=84=20=EC=BD=94?=
=?UTF-8?q?=EB=93=9C=20=EC=82=AD=EC=A0=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../UniversalFormModalComponent.tsx | 426 +-----------------
.../UniversalFormModalConfigPanel.tsx | 34 --
.../components/universal-form-modal/config.ts | 5 -
.../components/universal-form-modal/types.ts | 7 -
4 files changed, 4 insertions(+), 468 deletions(-)
diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx
index 5da98365..4ee024a4 100644
--- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx
+++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx
@@ -19,11 +19,11 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
-import { ChevronDown, ChevronUp, ChevronRight, Plus, Trash2, RefreshCw, Loader2 } from "lucide-react";
+import { ChevronDown, ChevronUp, ChevronRight, Plus, Trash2, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
-import { generateNumberingCode, allocateNumberingCode, previewNumberingCode } from "@/lib/api/numberingRule";
+import { allocateNumberingCode, previewNumberingCode } from "@/lib/api/numberingRule";
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
import { CascadingDropdownConfig } from "@/types/screen-management";
@@ -190,9 +190,6 @@ export function UniversalFormModalComponent({
[tableKey: string]: Record[];
}>({});
- // 로딩 상태
- const [saving, setSaving] = useState(false);
-
// 채번규칙 원본 값 추적 (수동 모드 감지용)
// key: columnName, value: 자동 생성된 원본 값
const [numberingOriginalValues, setNumberingOriginalValues] = useState>({});
@@ -610,7 +607,8 @@ export function UniversalFormModalComponent({
}
const tableConfig = section.tableConfig;
- const editConfig = tableConfig.editConfig;
+ // editConfig는 타입에 정의되지 않았지만 런타임에 존재할 수 있음
+ const editConfig = (tableConfig as any).editConfig;
const saveConfig = tableConfig.saveConfig;
console.log(`[initializeForm] 테이블 섹션 ${section.id} 검사:`, {
@@ -1240,266 +1238,6 @@ export function UniversalFormModalComponent({
return { valid: missingFields.length === 0, missingFields };
}, [config.sections, formData]);
- // 단일 행 저장
- const saveSingleRow = useCallback(async () => {
- const dataToSave = { ...formData };
-
- // 테이블 섹션 데이터 추출 (별도 저장용)
- const tableSectionData: Record = {};
-
- // 메타데이터 필드 제거 (채번 규칙 ID는 유지 - buttonActions.ts에서 사용)
- Object.keys(dataToSave).forEach((key) => {
- if (key.startsWith("_tableSection_")) {
- // 테이블 섹션 데이터는 별도로 저장
- const sectionId = key.replace("_tableSection_", "");
- tableSectionData[sectionId] = dataToSave[key] || [];
- delete dataToSave[key];
- } else if (key.startsWith("_") && !key.includes("_numberingRuleId")) {
- delete dataToSave[key];
- }
- });
-
- // 저장 시점 채번규칙 처리
- for (const section of config.sections) {
- // 테이블 타입 섹션은 건너뛰기
- if (section.type === "table") continue;
-
- for (const field of section.fields || []) {
- if (field.numberingRule?.enabled && field.numberingRule?.ruleId) {
- const ruleIdKey = `${field.columnName}_numberingRuleId`;
- const hasRuleId = dataToSave[ruleIdKey]; // 사용자가 수정하지 않았으면 ruleId 유지됨
-
- // 채번 규칙 할당 조건
- const shouldAllocate =
- // 1. generateOnSave가 ON인 경우: 항상 저장 시점에 할당
- field.numberingRule.generateOnSave ||
- // 2. editable이 OFF인 경우: 사용자 입력 무시하고 채번 규칙으로 덮어씌움
- !field.numberingRule.editable ||
- // 3. editable이 ON이고 사용자가 수정하지 않은 경우 (ruleId 유지됨): 실제 번호 할당
- (field.numberingRule.editable && hasRuleId);
-
- if (shouldAllocate) {
- const response = await allocateNumberingCode(field.numberingRule.ruleId);
- if (response.success && response.data?.generatedCode) {
- dataToSave[field.columnName] = response.data.generatedCode;
- let reason = "(알 수 없음)";
- if (field.numberingRule.generateOnSave) {
- reason = "(generateOnSave)";
- } else if (!field.numberingRule.editable) {
- reason = "(editable=OFF, 강제 덮어씌움)";
- } else if (hasRuleId) {
- reason = "(editable=ON, 사용자 미수정)";
- }
- console.log(`[채번 할당] ${field.columnName} = ${response.data.generatedCode} ${reason}`);
- } else {
- console.error(`[채번 실패] ${field.columnName}:`, response.error);
- }
- } else {
- console.log(
- `[채번 스킵] ${field.columnName}: 사용자가 직접 입력한 값 유지 = ${dataToSave[field.columnName]}`,
- );
- }
- }
- }
- }
-
- // 별도 테이블에 저장해야 하는 테이블 섹션 목록
- 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(
- (s) =>
- s.type === "table" &&
- (!s.tableConfig?.saveConfig?.targetTable ||
- 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 = {};
- const { sectionSaveModes } = config.saveConfig;
-
- // 필드 타입 섹션에서 공통 저장 필드 수집
- for (const section of config.sections) {
- if (section.type === "table") continue;
-
- const sectionMode = sectionSaveModes?.find((s) => s.sectionId === section.id);
- const defaultMode = "common"; // 필드 타입 섹션의 기본값은 공통 저장
- const sectionSaveMode = sectionMode?.saveMode || defaultMode;
-
- if (section.fields) {
- for (const field of section.fields) {
- const fieldOverride = sectionMode?.fieldOverrides?.find((f) => f.fieldName === field.columnName);
- const fieldSaveMode = fieldOverride?.saveMode || sectionSaveMode;
-
- if (fieldSaveMode === "common" && dataToSave[field.columnName] !== undefined) {
- commonFieldsData[field.columnName] = dataToSave[field.columnName];
- }
- }
- }
- }
-
- // 각 테이블 섹션의 품목 데이터에 공통 필드 병합하여 저장
- for (const tableSection of tableSectionsForMainTable) {
- const sectionData = tableSectionData[tableSection.id] || [];
-
- if (sectionData.length > 0) {
- // 품목별로 행 저장
- for (const item of sectionData) {
- const rowToSave = { ...commonFieldsData, ...item };
-
- // _sourceData 등 내부 메타데이터 제거
- Object.keys(rowToSave).forEach((key) => {
- if (key.startsWith("_")) {
- delete rowToSave[key];
- }
- });
-
- const response = await apiClient.post(
- `/table-management/tables/${config.saveConfig.tableName}/add`,
- rowToSave,
- );
-
- if (!response.data?.success) {
- throw new Error(response.data?.message || "품목 저장 실패");
- }
- }
-
- // 이미 저장했으므로 아래 로직에서 다시 저장하지 않도록 제거
- delete tableSectionData[tableSection.id];
- }
- }
-
- // 품목이 없으면 공통 데이터만 저장하지 않음 (품목이 필요한 화면이므로)
- // 다른 테이블 섹션이 있는 경우에만 메인 데이터 저장
- const hasOtherTableSections = Object.keys(tableSectionData).length > 0;
- if (!hasOtherTableSections) {
- return; // 메인 테이블에 저장할 품목이 없으면 종료
- }
- }
-
- // 메인 데이터 저장 (테이블 섹션이 없거나 별도 테이블에 저장하는 경우)
- const response = await apiClient.post(`/table-management/tables/${config.saveConfig.tableName}/add`, dataToSave);
-
- if (!response.data?.success) {
- throw new Error(response.data?.message || "저장 실패");
- }
-
- // 테이블 섹션 데이터 저장 (별도 테이블에)
- for (const section of config.sections) {
- if (section.type === "table" && section.tableConfig?.saveConfig?.targetTable) {
- const sectionData = tableSectionData[section.id];
- if (sectionData && sectionData.length > 0) {
- // 메인 레코드 ID가 필요한 경우 (response.data에서 가져오기)
- const mainRecordId = response.data?.data?.id;
-
- // 공통 저장 필드 수집: 다른 섹션(필드 타입)에서 공통 저장으로 설정된 필드 값
- // 기본값: 필드 타입 섹션은 'common', 테이블 타입 섹션은 'individual'
- const commonFieldsData: Record = {};
- const { sectionSaveModes } = config.saveConfig;
-
- // 다른 섹션에서 공통 저장으로 설정된 필드 값 수집
- 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 };
-
- // 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];
- }
- }
-
- // _sourceData 등 내부 메타데이터 제거
- Object.keys(itemToSave).forEach((key) => {
- if (key.startsWith("_")) {
- delete itemToSave[key];
- }
- });
-
- // 메인 레코드와 연결이 필요한 경우
- if (mainRecordId && config.saveConfig.primaryKeyColumn) {
- itemToSave[config.saveConfig.primaryKeyColumn] = mainRecordId;
- }
-
- 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 || "테이블 섹션"} 저장 실패`);
- }
- }
- }
- }
- }
- }, [
- config.sections,
- config.saveConfig.tableName,
- config.saveConfig.primaryKeyColumn,
- config.saveConfig.sectionSaveModes,
- formData,
- ]);
-
// 다중 테이블 저장 (범용)
const saveWithMultiTable = useCallback(async () => {
const { customApiSave } = config.saveConfig;
@@ -1682,130 +1420,6 @@ export function UniversalFormModalComponent({
}
}, [config.sections, config.saveConfig, formData, repeatSections, initialData]);
- // 커스텀 API 저장
- const saveWithCustomApi = useCallback(async () => {
- const { customApiSave } = config.saveConfig;
- if (!customApiSave) return;
-
- const saveWithGenericCustomApi = async () => {
- if (!customApiSave.customEndpoint) {
- throw new Error("커스텀 API 엔드포인트가 설정되지 않았습니다.");
- }
-
- const dataToSave = { ...formData };
-
- // 메타데이터 필드 제거
- Object.keys(dataToSave).forEach((key) => {
- if (key.startsWith("_")) {
- delete dataToSave[key];
- }
- });
-
- // 반복 섹션 데이터 포함
- if (Object.keys(repeatSections).length > 0) {
- dataToSave._repeatSections = repeatSections;
- }
-
- const method = customApiSave.customMethod || "POST";
- const response =
- method === "PUT"
- ? await apiClient.put(customApiSave.customEndpoint, dataToSave)
- : await apiClient.post(customApiSave.customEndpoint, dataToSave);
-
- if (!response.data?.success) {
- throw new Error(response.data?.message || "저장 실패");
- }
- };
-
- switch (customApiSave.apiType) {
- case "multi-table":
- await saveWithMultiTable();
- break;
- case "custom":
- await saveWithGenericCustomApi();
- break;
- default:
- throw new Error(`지원하지 않는 API 타입: ${customApiSave.apiType}`);
- }
- }, [config.saveConfig, formData, repeatSections, saveWithMultiTable]);
-
- // 저장 처리
- const handleSave = useCallback(async () => {
- // 커스텀 API 저장 모드가 아닌 경우에만 테이블명 체크
- if (!config.saveConfig.customApiSave?.enabled && !config.saveConfig.tableName) {
- toast.error("저장할 테이블이 설정되지 않았습니다.");
- return;
- }
-
- // 필수 필드 검증
- const { valid, missingFields } = validateRequiredFields();
- if (!valid) {
- toast.error(`필수 항목을 입력해주세요: ${missingFields.join(", ")}`);
- return;
- }
-
- setSaving(true);
-
- try {
- const { customApiSave } = config.saveConfig;
-
- // 커스텀 API 저장 모드 (다중 테이블)
- if (customApiSave?.enabled) {
- await saveWithCustomApi();
- } else {
- // 단일 테이블 저장
- await saveSingleRow();
- }
-
- // 저장 후 동작
- if (config.saveConfig.afterSave?.showToast) {
- toast.success("저장되었습니다.");
- }
-
- if (config.saveConfig.afterSave?.refreshParent) {
- window.dispatchEvent(new CustomEvent("refreshParentData"));
- }
-
- // onSave 콜백은 저장 완료 알림용으로만 사용
- // 실제 저장은 이미 위에서 완료됨
- // EditModal 등 부모 컴포넌트의 저장 로직이 다시 실행되지 않도록
- // _saveCompleted 플래그를 포함하여 전달
- if (onSave) {
- onSave({ ...formData, _saveCompleted: true });
- }
-
- // 저장 완료 후 모달 닫기 이벤트 발생
- if (config.saveConfig.afterSave?.closeModal !== false) {
- window.dispatchEvent(new CustomEvent("closeEditModal"));
- }
- } catch (error: any) {
- console.error("저장 실패:", error);
- // axios 에러의 경우 서버 응답 메시지 추출
- const errorMessage =
- error.response?.data?.message ||
- error.response?.data?.error?.details ||
- error.message ||
- "저장에 실패했습니다.";
- toast.error(errorMessage);
- } finally {
- setSaving(false);
- }
- }, [
- config,
- formData,
- repeatSections,
- onSave,
- validateRequiredFields,
- saveSingleRow,
- saveWithCustomApi,
- ]);
-
- // 폼 초기화
- const handleReset = useCallback(() => {
- initializeForm();
- toast.info("폼이 초기화되었습니다.");
- }, [initializeForm]);
-
// 필드 요소 렌더링 (입력 컴포넌트만)
// repeatContext: 반복 섹션인 경우 { sectionId, itemId }를 전달
const renderFieldElement = (
@@ -2544,38 +2158,6 @@ export function UniversalFormModalComponent({
{/* 섹션들 */}
{config.sections.map((section) => renderSection(section))}
- {/* 버튼 영역 - 저장 버튼이 표시될 때만 렌더링 */}
- {config.modal.showSaveButton !== false && (
-
- {config.modal.showResetButton && (
- {
- e.preventDefault();
- e.stopPropagation();
- handleReset();
- }}
- disabled={saving}
- >
-
- {config.modal.resetButtonText || "초기화"}
-
- )}
- {
- e.preventDefault();
- e.stopPropagation();
- handleSave();
- }}
- disabled={saving || !config.saveConfig.tableName}
- >
- {saving ? "저장 중..." : config.modal.saveButtonText || "저장"}
-
-
- )}
-
{/* 삭제 확인 다이얼로그 */}
모달 창의 크기를 선택하세요
-
- {/* 저장 버튼 표시 설정 */}
-
-
- updateModalConfig({ showSaveButton: checked === true })}
- />
-
- 저장 버튼 표시
-
-
-
체크 해제 시 모달 하단의 저장 버튼이 숨겨집니다
-
-
-
diff --git a/frontend/lib/registry/components/universal-form-modal/config.ts b/frontend/lib/registry/components/universal-form-modal/config.ts
index 78b1583e..08baf766 100644
--- a/frontend/lib/registry/components/universal-form-modal/config.ts
+++ b/frontend/lib/registry/components/universal-form-modal/config.ts
@@ -23,11 +23,6 @@ export const defaultConfig: UniversalFormModalConfig = {
size: "lg",
closeOnOutsideClick: false,
showCloseButton: true,
- showSaveButton: true,
- saveButtonText: "저장",
- cancelButtonText: "취소",
- showResetButton: false,
- resetButtonText: "초기화",
},
sections: [
{
diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts
index db4d5503..25d04ea8 100644
--- a/frontend/lib/registry/components/universal-form-modal/types.ts
+++ b/frontend/lib/registry/components/universal-form-modal/types.ts
@@ -786,13 +786,6 @@ export interface ModalConfig {
size: "sm" | "md" | "lg" | "xl" | "full";
closeOnOutsideClick?: boolean;
showCloseButton?: boolean;
-
- // 버튼 설정
- showSaveButton?: boolean; // 저장 버튼 표시 (기본: true)
- saveButtonText?: string; // 저장 버튼 텍스트 (기본: "저장")
- cancelButtonText?: string; // 취소 버튼 텍스트 (기본: "취소")
- showResetButton?: boolean; // 초기화 버튼 표시
- resetButtonText?: string; // 초기화 버튼 텍스트
}
// 전체 설정
From 23ebae95d64faae7fc72d33d3c03c3cba4c7c1f8 Mon Sep 17 00:00:00 2001
From: kjs
Date: Thu, 8 Jan 2026 10:39:48 +0900
Subject: [PATCH 61/95] =?UTF-8?q?=EA=B2=80=EC=83=89=ED=95=84=ED=84=B0=20?=
=?UTF-8?q?=ED=8B=80=EA=B3=A0=EC=A0=95=EA=B8=B0=EB=8A=A5=20=EC=98=A4?=
=?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../table-options/ColumnVisibilityPanel.tsx | 8 ++-
.../table-list/TableListComponent.tsx | 68 ++++++++++++++-----
frontend/types/table-options.ts | 2 +-
3 files changed, 57 insertions(+), 21 deletions(-)
diff --git a/frontend/components/screen/table-options/ColumnVisibilityPanel.tsx b/frontend/components/screen/table-options/ColumnVisibilityPanel.tsx
index 64acd942..67c11171 100644
--- a/frontend/components/screen/table-options/ColumnVisibilityPanel.tsx
+++ b/frontend/components/screen/table-options/ColumnVisibilityPanel.tsx
@@ -97,9 +97,13 @@ export const ColumnVisibilityPanel: React.FC = ({
table.onColumnOrderChange(newOrder);
}
- // 틀고정 컬럼 수 변경 콜백 호출
+ // 틀고정 컬럼 수 변경 콜백 호출 (현재 컬럼 상태도 함께 전달)
if (table?.onFrozenColumnCountChange) {
- table.onFrozenColumnCountChange(frozenColumnCount);
+ const updatedColumns = localColumns.map((col) => ({
+ columnName: col.columnName,
+ visible: col.visible,
+ }));
+ table.onFrozenColumnCountChange(frozenColumnCount, updatedColumns);
}
onClose();
diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx
index 74cea859..09422164 100644
--- a/frontend/lib/registry/components/table-list/TableListComponent.tsx
+++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx
@@ -1039,14 +1039,16 @@ export const TableListComponent: React.FC = ({
onGroupSumChange: setGroupSumConfig, // 그룹별 합산 설정
// 틀고정 컬럼 관련
frozenColumnCount, // 현재 틀고정 컬럼 수
- onFrozenColumnCountChange: (count: number) => {
+ onFrozenColumnCountChange: (count: number, updatedColumns?: Array<{ columnName: string; visible: boolean }>) => {
setFrozenColumnCount(count);
// 체크박스 컬럼은 항상 틀고정에 포함
const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : [];
// 표시 가능한 컬럼 중 처음 N개를 틀고정 컬럼으로 설정
- const visibleCols = columnsToRegister
+ // updatedColumns가 전달되면 그것을 사용, 아니면 columnsToRegister 사용
+ const colsToUse = updatedColumns || columnsToRegister;
+ const visibleCols = colsToUse
.filter((col) => col.visible !== false)
- .map((col) => col.columnName || col.field);
+ .map((col) => col.columnName || (col as any).field);
const newFrozenColumns = [...checkboxColumn, ...visibleCols.slice(0, count)];
setFrozenColumns(newFrozenColumns);
},
@@ -4754,9 +4756,22 @@ export const TableListComponent: React.FC = ({
});
setColumnWidths(newWidths);
- // 틀고정 컬럼 업데이트
- const newFrozenColumns = config.columns.filter((col) => col.frozen).map((col) => col.columnName);
+ // 틀고정 컬럼 업데이트 (보이는 컬럼 기준으로 처음 N개를 틀고정)
+ // 기존 frozen 개수를 유지하면서, 숨겨진 컬럼을 제외한 보이는 컬럼 중 처음 N개를 틀고정
+ const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : [];
+ const visibleCols = config.columns
+ .filter((col) => col.visible && col.columnName !== "__checkbox__")
+ .map((col) => col.columnName);
+
+ // 현재 설정된 frozen 컬럼 개수 (체크박스 제외)
+ const currentFrozenCount = config.columns.filter(
+ (col) => col.frozen && col.columnName !== "__checkbox__"
+ ).length;
+
+ // 보이는 컬럼 중 처음 currentFrozenCount개를 틀고정으로 설정
+ const newFrozenColumns = [...checkboxColumn, ...visibleCols.slice(0, currentFrozenCount)];
setFrozenColumns(newFrozenColumns);
+ setFrozenColumnCount(currentFrozenCount);
// 그리드선 표시 업데이트
setShowGridLines(config.showGridLines);
@@ -5819,13 +5834,18 @@ export const TableListComponent: React.FC = ({
{visibleColumns.map((column, columnIndex) => {
const columnWidth = columnWidths[column.columnName];
const isFrozen = frozenColumns.includes(column.columnName);
- const frozenIndex = frozenColumns.indexOf(column.columnName);
-
- // 틀고정된 컬럼의 left 위치 계산
+
+ // 틀고정된 컬럼의 left 위치 계산 (보이는 컬럼 기준으로 계산)
+ // 숨겨진 컬럼은 제외하고 보이는 틀고정 컬럼만 포함
+ const visibleFrozenColumns = visibleColumns
+ .filter(col => frozenColumns.includes(col.columnName))
+ .map(col => col.columnName);
+ const frozenIndex = visibleFrozenColumns.indexOf(column.columnName);
+
let leftPosition = 0;
if (isFrozen && frozenIndex > 0) {
for (let i = 0; i < frozenIndex; i++) {
- const frozenCol = frozenColumns[i];
+ const frozenCol = visibleFrozenColumns[i];
// 체크박스 컬럼은 48px 고정
const frozenColWidth = frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150;
leftPosition += frozenColWidth;
@@ -6131,13 +6151,17 @@ export const TableListComponent: React.FC = ({
const isNumeric = inputType === "number" || inputType === "decimal";
const isFrozen = frozenColumns.includes(column.columnName);
- const frozenIndex = frozenColumns.indexOf(column.columnName);
+
+ // 틀고정된 컬럼의 left 위치 계산 (보이는 컬럼 기준으로 계산)
+ const visibleFrozenColumns = visibleColumns
+ .filter(col => frozenColumns.includes(col.columnName))
+ .map(col => col.columnName);
+ const frozenIndex = visibleFrozenColumns.indexOf(column.columnName);
- // 틀고정된 컬럼의 left 위치 계산
let leftPosition = 0;
if (isFrozen && frozenIndex > 0) {
for (let i = 0; i < frozenIndex; i++) {
- const frozenCol = frozenColumns[i];
+ const frozenCol = visibleFrozenColumns[i];
// 체크박스 컬럼은 48px 고정
const frozenColWidth =
frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150;
@@ -6284,7 +6308,12 @@ export const TableListComponent: React.FC = ({
const isNumeric = inputType === "number" || inputType === "decimal";
const isFrozen = frozenColumns.includes(column.columnName);
- const frozenIndex = frozenColumns.indexOf(column.columnName);
+
+ // 틀고정된 컬럼의 left 위치 계산 (보이는 컬럼 기준으로 계산)
+ const visibleFrozenColumns = visibleColumns
+ .filter(col => frozenColumns.includes(col.columnName))
+ .map(col => col.columnName);
+ const frozenIndex = visibleFrozenColumns.indexOf(column.columnName);
// 셀 포커스 상태
const isCellFocused = focusedCell?.rowIndex === index && focusedCell?.colIndex === colIndex;
@@ -6298,11 +6327,10 @@ export const TableListComponent: React.FC = ({
// 🆕 검색 하이라이트 여부
const isSearchHighlighted = searchHighlights.has(`${index}-${colIndex}`);
- // 틀고정된 컬럼의 left 위치 계산
let leftPosition = 0;
if (isFrozen && frozenIndex > 0) {
for (let i = 0; i < frozenIndex; i++) {
- const frozenCol = frozenColumns[i];
+ const frozenCol = visibleFrozenColumns[i];
// 체크박스 컬럼은 48px 고정
const frozenColWidth =
frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150;
@@ -6462,13 +6490,17 @@ export const TableListComponent: React.FC = ({
const summary = summaryData[column.columnName];
const columnWidth = columnWidths[column.columnName];
const isFrozen = frozenColumns.includes(column.columnName);
- const frozenIndex = frozenColumns.indexOf(column.columnName);
+
+ // 틀고정된 컬럼의 left 위치 계산 (보이는 컬럼 기준으로 계산)
+ const visibleFrozenColumns = visibleColumns
+ .filter(col => frozenColumns.includes(col.columnName))
+ .map(col => col.columnName);
+ const frozenIndex = visibleFrozenColumns.indexOf(column.columnName);
- // 틀고정된 컬럼의 left 위치 계산
let leftPosition = 0;
if (isFrozen && frozenIndex > 0) {
for (let i = 0; i < frozenIndex; i++) {
- const frozenCol = frozenColumns[i];
+ const frozenCol = visibleFrozenColumns[i];
// 체크박스 컬럼은 48px 고정
const frozenColWidth = frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150;
leftPosition += frozenColWidth;
diff --git a/frontend/types/table-options.ts b/frontend/types/table-options.ts
index bfcfccbc..6f9b2644 100644
--- a/frontend/types/table-options.ts
+++ b/frontend/types/table-options.ts
@@ -66,7 +66,7 @@ export interface TableRegistration {
onGroupChange: (groups: string[]) => void;
onColumnVisibilityChange: (columns: ColumnVisibility[]) => void;
onGroupSumChange?: (config: GroupSumConfig | null) => void; // 그룹별 합산 설정 변경
- onFrozenColumnCountChange?: (count: number) => void; // 틀고정 컬럼 수 변경
+ onFrozenColumnCountChange?: (count: number, updatedColumns?: Array<{ columnName: string; visible: boolean }>) => void; // 틀고정 컬럼 수 변경
// 현재 설정 값 (읽기 전용)
frozenColumnCount?: number; // 현재 틀고정 컬럼 수
From d90a403ed9c2012fc297772ed8c5a6c4563241e8 Mon Sep 17 00:00:00 2001
From: kjs
Date: Thu, 8 Jan 2026 11:09:40 +0900
Subject: [PATCH 62/95] =?UTF-8?q?=EC=97=91=EC=85=80=20=EC=97=85=EB=A1=9C?=
=?UTF-8?q?=EB=93=9C=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=ED=83=80?=
=?UTF-8?q?=EC=9E=85=20=EC=9E=90=EB=8F=99=20=EA=B0=90=EC=A7=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/services/dynamicFormService.ts | 19 ++
.../src/services/tableCategoryValueService.ts | 214 ++++++++++++++++++
.../components/common/ExcelUploadModal.tsx | 15 +-
3 files changed, 247 insertions(+), 1 deletion(-)
diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts
index 68c30252..8337ed74 100644
--- a/backend-node/src/services/dynamicFormService.ts
+++ b/backend-node/src/services/dynamicFormService.ts
@@ -1,6 +1,7 @@
import { query, queryOne, transaction, getPool } from "../database/db";
import { EventTriggerService } from "./eventTriggerService";
import { DataflowControlService } from "./dataflowControlService";
+import tableCategoryValueService from "./tableCategoryValueService";
export interface FormDataResult {
id: number;
@@ -427,6 +428,24 @@ export class DynamicFormService {
dataToInsert,
});
+ // 카테고리 타입 컬럼의 라벨 값을 코드 값으로 변환 (엑셀 업로드 등 지원)
+ console.log("🏷️ 카테고리 라벨→코드 변환 시작...");
+ const companyCodeForCategory = company_code || "*";
+ const { convertedData: categoryConvertedData, conversions } =
+ await tableCategoryValueService.convertCategoryLabelsToCodesForData(
+ tableName,
+ companyCodeForCategory,
+ dataToInsert
+ );
+
+ if (conversions.length > 0) {
+ console.log(`🏷️ 카테고리 라벨→코드 변환 완료: ${conversions.length}개`, conversions);
+ // 변환된 데이터로 교체
+ Object.assign(dataToInsert, categoryConvertedData);
+ } else {
+ console.log("🏷️ 카테고리 라벨→코드 변환 없음 (카테고리 컬럼 없거나 이미 코드 값)");
+ }
+
// 테이블 컬럼 정보 조회하여 타입 변환 적용
console.log("🔍 테이블 컬럼 정보 조회 중...");
const columnInfo = await this.getTableColumnInfo(tableName);
diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts
index 1638a417..edeb55b2 100644
--- a/backend-node/src/services/tableCategoryValueService.ts
+++ b/backend-node/src/services/tableCategoryValueService.ts
@@ -1398,6 +1398,220 @@ class TableCategoryValueService {
throw error;
}
}
+
+ /**
+ * 테이블의 카테고리 타입 컬럼과 해당 값 매핑 조회 (라벨 → 코드 변환용)
+ *
+ * 엑셀 업로드 등에서 라벨 값을 코드 값으로 변환할 때 사용
+ *
+ * @param tableName - 테이블명
+ * @param companyCode - 회사 코드
+ * @returns { [columnName]: { [label]: code } } 형태의 매핑 객체
+ */
+ async getCategoryLabelToCodeMapping(
+ tableName: string,
+ companyCode: string
+ ): Promise>> {
+ try {
+ logger.info("카테고리 라벨→코드 매핑 조회", { tableName, companyCode });
+
+ const pool = getPool();
+
+ // 1. 해당 테이블의 카테고리 타입 컬럼 조회
+ const categoryColumnsQuery = `
+ SELECT column_name
+ FROM table_type_columns
+ WHERE table_name = $1
+ AND input_type = 'category'
+ `;
+ const categoryColumnsResult = await pool.query(categoryColumnsQuery, [tableName]);
+
+ if (categoryColumnsResult.rows.length === 0) {
+ logger.info("카테고리 타입 컬럼 없음", { tableName });
+ return {};
+ }
+
+ const categoryColumns = categoryColumnsResult.rows.map(row => row.column_name);
+ logger.info(`카테고리 컬럼 ${categoryColumns.length}개 발견`, { categoryColumns });
+
+ // 2. 각 카테고리 컬럼의 라벨→코드 매핑 조회
+ const result: Record> = {};
+
+ for (const columnName of categoryColumns) {
+ let query: string;
+ let params: any[];
+
+ if (companyCode === "*") {
+ // 최고 관리자: 모든 카테고리 값 조회
+ query = `
+ SELECT value_code, value_label
+ FROM table_column_category_values
+ WHERE table_name = $1
+ AND column_name = $2
+ AND is_active = true
+ `;
+ params = [tableName, columnName];
+ } else {
+ // 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회
+ query = `
+ SELECT value_code, value_label
+ FROM table_column_category_values
+ WHERE table_name = $1
+ AND column_name = $2
+ AND is_active = true
+ AND (company_code = $3 OR company_code = '*')
+ `;
+ params = [tableName, columnName, companyCode];
+ }
+
+ const valuesResult = await pool.query(query, params);
+
+ // { [label]: code } 형태로 변환
+ const labelToCodeMap: Record = {};
+ for (const row of valuesResult.rows) {
+ // 라벨을 소문자로 변환하여 대소문자 구분 없이 매핑
+ labelToCodeMap[row.value_label] = row.value_code;
+ // 소문자 키도 추가 (대소문자 무시 검색용)
+ labelToCodeMap[row.value_label.toLowerCase()] = row.value_code;
+ }
+
+ if (Object.keys(labelToCodeMap).length > 0) {
+ result[columnName] = labelToCodeMap;
+ logger.info(`컬럼 ${columnName}의 라벨→코드 매핑 ${valuesResult.rows.length}개 조회`);
+ }
+ }
+
+ logger.info(`카테고리 라벨→코드 매핑 조회 완료`, {
+ tableName,
+ columnCount: Object.keys(result).length
+ });
+
+ return result;
+ } catch (error: any) {
+ logger.error(`카테고리 라벨→코드 매핑 조회 실패: ${error.message}`, { error });
+ throw error;
+ }
+ }
+
+ /**
+ * 데이터의 카테고리 라벨 값을 코드 값으로 변환
+ *
+ * 엑셀 업로드 등에서 사용자가 입력한 라벨 값을 DB 저장용 코드 값으로 변환
+ *
+ * @param tableName - 테이블명
+ * @param companyCode - 회사 코드
+ * @param data - 변환할 데이터 객체
+ * @returns 라벨이 코드로 변환된 데이터 객체
+ */
+ async convertCategoryLabelsToCodesForData(
+ tableName: string,
+ companyCode: string,
+ data: Record
+ ): Promise<{ convertedData: Record; conversions: Array<{ column: string; label: string; code: string }> }> {
+ try {
+ // 라벨→코드 매핑 조회
+ const labelToCodeMapping = await this.getCategoryLabelToCodeMapping(tableName, companyCode);
+
+ if (Object.keys(labelToCodeMapping).length === 0) {
+ // 카테고리 컬럼 없음
+ return { convertedData: data, conversions: [] };
+ }
+
+ const convertedData = { ...data };
+ const conversions: Array<{ column: string; label: string; code: string }> = [];
+
+ for (const [columnName, labelCodeMap] of Object.entries(labelToCodeMapping)) {
+ const value = data[columnName];
+
+ if (value !== undefined && value !== null && value !== "") {
+ const stringValue = String(value).trim();
+
+ // 다중 값 확인 (쉼표로 구분된 경우)
+ if (stringValue.includes(",")) {
+ // 다중 카테고리 값 처리
+ const labels = stringValue.split(",").map(s => s.trim()).filter(s => s !== "");
+ const convertedCodes: string[] = [];
+ let allConverted = true;
+
+ for (const label of labels) {
+ // 정확한 라벨 매칭 시도
+ let matchedCode = labelCodeMap[label];
+
+ // 대소문자 무시 매칭
+ if (!matchedCode) {
+ matchedCode = labelCodeMap[label.toLowerCase()];
+ }
+
+ if (matchedCode) {
+ convertedCodes.push(matchedCode);
+ conversions.push({
+ column: columnName,
+ label: label,
+ code: matchedCode,
+ });
+ logger.info(`카테고리 라벨→코드 변환 (다중): ${columnName} "${label}" → "${matchedCode}"`);
+ } else {
+ // 이미 코드값인지 확인
+ const isAlreadyCode = Object.values(labelCodeMap).includes(label);
+ if (isAlreadyCode) {
+ // 이미 코드값이면 그대로 사용
+ convertedCodes.push(label);
+ } else {
+ // 라벨도 코드도 아니면 원래 값 유지
+ convertedCodes.push(label);
+ allConverted = false;
+ logger.warn(`카테고리 값 매핑 없음 (다중): ${columnName} = "${label}" (라벨도 코드도 아님)`);
+ }
+ }
+ }
+
+ // 변환된 코드들을 쉼표로 합쳐서 저장
+ convertedData[columnName] = convertedCodes.join(",");
+ logger.info(`다중 카테고리 변환 완료: ${columnName} "${stringValue}" → "${convertedData[columnName]}"`);
+ } else {
+ // 단일 값 처리
+ // 정확한 라벨 매칭 시도
+ let matchedCode = labelCodeMap[stringValue];
+
+ // 대소문자 무시 매칭
+ if (!matchedCode) {
+ matchedCode = labelCodeMap[stringValue.toLowerCase()];
+ }
+
+ if (matchedCode) {
+ // 라벨 값을 코드 값으로 변환
+ convertedData[columnName] = matchedCode;
+ conversions.push({
+ column: columnName,
+ label: stringValue,
+ code: matchedCode,
+ });
+ logger.info(`카테고리 라벨→코드 변환: ${columnName} "${stringValue}" → "${matchedCode}"`);
+ } else {
+ // 이미 코드값인지 확인 (역방향 확인)
+ const isAlreadyCode = Object.values(labelCodeMap).includes(stringValue);
+ if (!isAlreadyCode) {
+ logger.warn(`카테고리 값 매핑 없음: ${columnName} = "${stringValue}" (라벨도 코드도 아님)`);
+ }
+ // 변환 없이 원래 값 유지
+ }
+ }
+ }
+ }
+
+ logger.info(`카테고리 라벨→코드 변환 완료`, {
+ tableName,
+ conversionCount: conversions.length,
+ conversions,
+ });
+
+ return { convertedData, conversions };
+ } catch (error: any) {
+ logger.error(`카테고리 라벨→코드 변환 실패: ${error.message}`, { error });
+ // 실패 시 원본 데이터 반환
+ return { convertedData: data, conversions: [] };
+ }
+ }
}
export default new TableCategoryValueService();
diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx
index a4a17274..28be5688 100644
--- a/frontend/components/common/ExcelUploadModal.tsx
+++ b/frontend/components/common/ExcelUploadModal.tsx
@@ -307,10 +307,23 @@ export const ExcelUploadModal: React.FC = ({
return mappedRow;
});
+ // 빈 행 필터링: 모든 값이 비어있거나 undefined/null인 행 제외
+ const filteredData = mappedData.filter((row) => {
+ const values = Object.values(row);
+ // 하나라도 유효한 값이 있는지 확인
+ return values.some((value) => {
+ if (value === undefined || value === null) return false;
+ if (typeof value === "string" && value.trim() === "") return false;
+ return true;
+ });
+ });
+
+ console.log(`📊 엑셀 업로드: 전체 ${mappedData.length}행 중 유효한 ${filteredData.length}행`);
+
let successCount = 0;
let failCount = 0;
- for (const row of mappedData) {
+ for (const row of filteredData) {
try {
if (uploadMode === "insert") {
const formData = { screenId: 0, tableName, data: row };
From 5321ea5b806cc027b2e682de9ca89d3c7bed3825 Mon Sep 17 00:00:00 2001
From: kjs
Date: Thu, 8 Jan 2026 11:45:39 +0900
Subject: [PATCH 63/95] =?UTF-8?q?=EC=97=91=EC=85=80=20=EC=97=85=EB=A1=9C?=
=?UTF-8?q?=EB=93=9C=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EA=B8=B0=EB=8A=A5=20?=
=?UTF-8?q?=EA=B5=AC=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend-node/src/app.ts | 2 +
.../src/controllers/excelMappingController.ts | 208 +++++++++++++
backend-node/src/routes/excelMappingRoutes.ts | 25 ++
.../src/services/excelMappingService.ts | 283 ++++++++++++++++++
.../components/common/ExcelUploadModal.tsx | 274 +++++++++++------
frontend/lib/api/excelMapping.ts | 106 +++++++
6 files changed, 809 insertions(+), 89 deletions(-)
create mode 100644 backend-node/src/controllers/excelMappingController.ts
create mode 100644 backend-node/src/routes/excelMappingRoutes.ts
create mode 100644 backend-node/src/services/excelMappingService.ts
create mode 100644 frontend/lib/api/excelMapping.ts
diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts
index e928f96c..80e406b9 100644
--- a/backend-node/src/app.ts
+++ b/backend-node/src/app.ts
@@ -58,6 +58,7 @@ import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관
import todoRoutes from "./routes/todoRoutes"; // To-Do 관리
import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리
import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리
+import excelMappingRoutes from "./routes/excelMappingRoutes"; // 엑셀 매핑 템플릿
import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 3D 필드
//import materialRoutes from "./routes/materialRoutes"; // 자재 관리
import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제)
@@ -220,6 +221,7 @@ app.use("/api/external-rest-api-connections", externalRestApiConnectionRoutes);
app.use("/api/multi-connection", multiConnectionRoutes);
app.use("/api/screen-files", screenFileRoutes);
app.use("/api/batch-configs", batchRoutes);
+app.use("/api/excel-mapping", excelMappingRoutes); // 엑셀 매핑 템플릿
app.use("/api/batch-management", batchManagementRoutes);
app.use("/api/batch-execution-logs", batchExecutionLogRoutes);
// app.use("/api/db-type-categories", dbTypeCategoryRoutes); // 파일이 존재하지 않음
diff --git a/backend-node/src/controllers/excelMappingController.ts b/backend-node/src/controllers/excelMappingController.ts
new file mode 100644
index 00000000..e29d4fe2
--- /dev/null
+++ b/backend-node/src/controllers/excelMappingController.ts
@@ -0,0 +1,208 @@
+import { Response } from "express";
+import { AuthenticatedRequest } from "../middleware/authMiddleware";
+import excelMappingService from "../services/excelMappingService";
+import { logger } from "../utils/logger";
+
+/**
+ * 엑셀 컬럼 구조로 매핑 템플릿 조회
+ * POST /api/excel-mapping/find
+ */
+export async function findMappingByColumns(
+ req: AuthenticatedRequest,
+ res: Response
+): Promise {
+ try {
+ const { tableName, excelColumns } = req.body;
+ const companyCode = req.user?.companyCode || "*";
+
+ if (!tableName || !excelColumns || !Array.isArray(excelColumns)) {
+ res.status(400).json({
+ success: false,
+ message: "tableName과 excelColumns(배열)가 필요합니다.",
+ });
+ return;
+ }
+
+ logger.info("엑셀 매핑 템플릿 조회 요청", {
+ tableName,
+ excelColumns,
+ companyCode,
+ userId: req.user?.userId,
+ });
+
+ const template = await excelMappingService.findMappingByColumns(
+ tableName,
+ excelColumns,
+ companyCode
+ );
+
+ if (template) {
+ res.json({
+ success: true,
+ data: template,
+ message: "기존 매핑 템플릿을 찾았습니다.",
+ });
+ } else {
+ res.json({
+ success: true,
+ data: null,
+ message: "일치하는 매핑 템플릿이 없습니다.",
+ });
+ }
+ } catch (error: any) {
+ logger.error("매핑 템플릿 조회 실패", { error: error.message });
+ res.status(500).json({
+ success: false,
+ message: "매핑 템플릿 조회 중 오류가 발생했습니다.",
+ error: error.message,
+ });
+ }
+}
+
+/**
+ * 매핑 템플릿 저장 (UPSERT)
+ * POST /api/excel-mapping/save
+ */
+export async function saveMappingTemplate(
+ req: AuthenticatedRequest,
+ res: Response
+): Promise {
+ try {
+ const { tableName, excelColumns, columnMappings } = req.body;
+ const companyCode = req.user?.companyCode || "*";
+ const userId = req.user?.userId;
+
+ if (!tableName || !excelColumns || !columnMappings) {
+ res.status(400).json({
+ success: false,
+ message: "tableName, excelColumns, columnMappings가 필요합니다.",
+ });
+ return;
+ }
+
+ logger.info("엑셀 매핑 템플릿 저장 요청", {
+ tableName,
+ excelColumns,
+ columnMappings,
+ companyCode,
+ userId,
+ });
+
+ const template = await excelMappingService.saveMappingTemplate(
+ tableName,
+ excelColumns,
+ columnMappings,
+ companyCode,
+ userId
+ );
+
+ res.json({
+ success: true,
+ data: template,
+ message: "매핑 템플릿이 저장되었습니다.",
+ });
+ } catch (error: any) {
+ logger.error("매핑 템플릿 저장 실패", { error: error.message });
+ res.status(500).json({
+ success: false,
+ message: "매핑 템플릿 저장 중 오류가 발생했습니다.",
+ error: error.message,
+ });
+ }
+}
+
+/**
+ * 테이블의 매핑 템플릿 목록 조회
+ * GET /api/excel-mapping/list/:tableName
+ */
+export async function getMappingTemplates(
+ req: AuthenticatedRequest,
+ res: Response
+): Promise {
+ try {
+ const { tableName } = req.params;
+ const companyCode = req.user?.companyCode || "*";
+
+ if (!tableName) {
+ res.status(400).json({
+ success: false,
+ message: "tableName이 필요합니다.",
+ });
+ return;
+ }
+
+ logger.info("매핑 템플릿 목록 조회 요청", {
+ tableName,
+ companyCode,
+ });
+
+ const templates = await excelMappingService.getMappingTemplates(
+ tableName,
+ companyCode
+ );
+
+ res.json({
+ success: true,
+ data: templates,
+ });
+ } catch (error: any) {
+ logger.error("매핑 템플릿 목록 조회 실패", { error: error.message });
+ res.status(500).json({
+ success: false,
+ message: "매핑 템플릿 목록 조회 중 오류가 발생했습니다.",
+ error: error.message,
+ });
+ }
+}
+
+/**
+ * 매핑 템플릿 삭제
+ * DELETE /api/excel-mapping/:id
+ */
+export async function deleteMappingTemplate(
+ req: AuthenticatedRequest,
+ res: Response
+): Promise {
+ try {
+ const { id } = req.params;
+ const companyCode = req.user?.companyCode || "*";
+
+ if (!id) {
+ res.status(400).json({
+ success: false,
+ message: "id가 필요합니다.",
+ });
+ return;
+ }
+
+ logger.info("매핑 템플릿 삭제 요청", {
+ id,
+ companyCode,
+ });
+
+ const deleted = await excelMappingService.deleteMappingTemplate(
+ parseInt(id),
+ companyCode
+ );
+
+ if (deleted) {
+ res.json({
+ success: true,
+ message: "매핑 템플릿이 삭제되었습니다.",
+ });
+ } else {
+ res.status(404).json({
+ success: false,
+ message: "삭제할 매핑 템플릿을 찾을 수 없습니다.",
+ });
+ }
+ } catch (error: any) {
+ logger.error("매핑 템플릿 삭제 실패", { error: error.message });
+ res.status(500).json({
+ success: false,
+ message: "매핑 템플릿 삭제 중 오류가 발생했습니다.",
+ error: error.message,
+ });
+ }
+}
+
diff --git a/backend-node/src/routes/excelMappingRoutes.ts b/backend-node/src/routes/excelMappingRoutes.ts
new file mode 100644
index 00000000..cbcecc15
--- /dev/null
+++ b/backend-node/src/routes/excelMappingRoutes.ts
@@ -0,0 +1,25 @@
+import { Router } from "express";
+import { authenticateToken } from "../middleware/authMiddleware";
+import {
+ findMappingByColumns,
+ saveMappingTemplate,
+ getMappingTemplates,
+ deleteMappingTemplate,
+} from "../controllers/excelMappingController";
+
+const router = Router();
+
+// 엑셀 컬럼 구조로 매핑 템플릿 조회
+router.post("/find", authenticateToken, findMappingByColumns);
+
+// 매핑 템플릿 저장 (UPSERT)
+router.post("/save", authenticateToken, saveMappingTemplate);
+
+// 테이블의 매핑 템플릿 목록 조회
+router.get("/list/:tableName", authenticateToken, getMappingTemplates);
+
+// 매핑 템플릿 삭제
+router.delete("/:id", authenticateToken, deleteMappingTemplate);
+
+export default router;
+
diff --git a/backend-node/src/services/excelMappingService.ts b/backend-node/src/services/excelMappingService.ts
new file mode 100644
index 00000000..a63a027b
--- /dev/null
+++ b/backend-node/src/services/excelMappingService.ts
@@ -0,0 +1,283 @@
+import { getPool } from "../database/db";
+import { logger } from "../utils/logger";
+import crypto from "crypto";
+
+export interface ExcelMappingTemplate {
+ id?: number;
+ tableName: string;
+ excelColumns: string[];
+ excelColumnsHash: string;
+ columnMappings: Record; // { "엑셀컬럼": "시스템컬럼" }
+ companyCode: string;
+ createdDate?: Date;
+ updatedDate?: Date;
+}
+
+class ExcelMappingService {
+ /**
+ * 엑셀 컬럼 목록으로 해시 생성
+ * 정렬 후 MD5 해시 생성하여 동일한 컬럼 구조 식별
+ */
+ generateColumnsHash(columns: string[]): string {
+ // 컬럼 목록을 정렬하여 순서와 무관하게 동일한 해시 생성
+ const sortedColumns = [...columns].sort();
+ const columnsString = sortedColumns.join("|");
+ return crypto.createHash("md5").update(columnsString).digest("hex");
+ }
+
+ /**
+ * 엑셀 컬럼 구조로 매핑 템플릿 조회
+ * 동일한 컬럼 구조가 있으면 기존 매핑 반환
+ */
+ async findMappingByColumns(
+ tableName: string,
+ excelColumns: string[],
+ companyCode: string
+ ): Promise {
+ try {
+ const hash = this.generateColumnsHash(excelColumns);
+
+ logger.info("엑셀 매핑 템플릿 조회", {
+ tableName,
+ excelColumns,
+ hash,
+ companyCode,
+ });
+
+ const pool = getPool();
+
+ // 회사별 매핑 먼저 조회, 없으면 공통(*) 매핑 조회
+ let query: string;
+ let params: any[];
+
+ if (companyCode === "*") {
+ query = `
+ SELECT
+ id,
+ table_name as "tableName",
+ excel_columns as "excelColumns",
+ excel_columns_hash as "excelColumnsHash",
+ column_mappings as "columnMappings",
+ company_code as "companyCode",
+ created_date as "createdDate",
+ updated_date as "updatedDate"
+ FROM excel_mapping_template
+ WHERE table_name = $1
+ AND excel_columns_hash = $2
+ ORDER BY updated_date DESC
+ LIMIT 1
+ `;
+ params = [tableName, hash];
+ } else {
+ query = `
+ SELECT
+ id,
+ table_name as "tableName",
+ excel_columns as "excelColumns",
+ excel_columns_hash as "excelColumnsHash",
+ column_mappings as "columnMappings",
+ company_code as "companyCode",
+ created_date as "createdDate",
+ updated_date as "updatedDate"
+ FROM excel_mapping_template
+ WHERE table_name = $1
+ AND excel_columns_hash = $2
+ AND (company_code = $3 OR company_code = '*')
+ ORDER BY
+ CASE WHEN company_code = $3 THEN 0 ELSE 1 END,
+ updated_date DESC
+ LIMIT 1
+ `;
+ params = [tableName, hash, companyCode];
+ }
+
+ const result = await pool.query(query, params);
+
+ if (result.rows.length > 0) {
+ logger.info("기존 매핑 템플릿 발견", {
+ id: result.rows[0].id,
+ tableName,
+ });
+ return result.rows[0];
+ }
+
+ logger.info("매핑 템플릿 없음 - 새 구조", { tableName, hash });
+ return null;
+ } catch (error: any) {
+ logger.error(`매핑 템플릿 조회 실패: ${error.message}`, { error });
+ throw error;
+ }
+ }
+
+ /**
+ * 매핑 템플릿 저장 (UPSERT)
+ * 동일한 테이블+컬럼구조+회사코드가 있으면 업데이트, 없으면 삽입
+ */
+ async saveMappingTemplate(
+ tableName: string,
+ excelColumns: string[],
+ columnMappings: Record,
+ companyCode: string,
+ userId?: string
+ ): Promise {
+ try {
+ const hash = this.generateColumnsHash(excelColumns);
+
+ logger.info("엑셀 매핑 템플릿 저장 (UPSERT)", {
+ tableName,
+ excelColumns,
+ hash,
+ columnMappings,
+ companyCode,
+ });
+
+ const pool = getPool();
+
+ const query = `
+ INSERT INTO excel_mapping_template (
+ table_name,
+ excel_columns,
+ excel_columns_hash,
+ column_mappings,
+ company_code,
+ created_date,
+ updated_date
+ ) VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
+ ON CONFLICT (table_name, excel_columns_hash, company_code)
+ DO UPDATE SET
+ column_mappings = EXCLUDED.column_mappings,
+ updated_date = NOW()
+ RETURNING
+ id,
+ table_name as "tableName",
+ excel_columns as "excelColumns",
+ excel_columns_hash as "excelColumnsHash",
+ column_mappings as "columnMappings",
+ company_code as "companyCode",
+ created_date as "createdDate",
+ updated_date as "updatedDate"
+ `;
+
+ const result = await pool.query(query, [
+ tableName,
+ excelColumns,
+ hash,
+ JSON.stringify(columnMappings),
+ companyCode,
+ ]);
+
+ logger.info("매핑 템플릿 저장 완료", {
+ id: result.rows[0].id,
+ tableName,
+ hash,
+ });
+
+ return result.rows[0];
+ } catch (error: any) {
+ logger.error(`매핑 템플릿 저장 실패: ${error.message}`, { error });
+ throw error;
+ }
+ }
+
+ /**
+ * 테이블의 모든 매핑 템플릿 조회
+ */
+ async getMappingTemplates(
+ tableName: string,
+ companyCode: string
+ ): Promise {
+ try {
+ logger.info("테이블 매핑 템플릿 목록 조회", { tableName, companyCode });
+
+ const pool = getPool();
+
+ let query: string;
+ let params: any[];
+
+ if (companyCode === "*") {
+ query = `
+ SELECT
+ id,
+ table_name as "tableName",
+ excel_columns as "excelColumns",
+ excel_columns_hash as "excelColumnsHash",
+ column_mappings as "columnMappings",
+ company_code as "companyCode",
+ created_date as "createdDate",
+ updated_date as "updatedDate"
+ FROM excel_mapping_template
+ WHERE table_name = $1
+ ORDER BY updated_date DESC
+ `;
+ params = [tableName];
+ } else {
+ query = `
+ SELECT
+ id,
+ table_name as "tableName",
+ excel_columns as "excelColumns",
+ excel_columns_hash as "excelColumnsHash",
+ column_mappings as "columnMappings",
+ company_code as "companyCode",
+ created_date as "createdDate",
+ updated_date as "updatedDate"
+ FROM excel_mapping_template
+ WHERE table_name = $1
+ AND (company_code = $2 OR company_code = '*')
+ ORDER BY updated_date DESC
+ `;
+ params = [tableName, companyCode];
+ }
+
+ const result = await pool.query(query, params);
+
+ logger.info(`매핑 템플릿 ${result.rows.length}개 조회`, { tableName });
+
+ return result.rows;
+ } catch (error: any) {
+ logger.error(`매핑 템플릿 목록 조회 실패: ${error.message}`, { error });
+ throw error;
+ }
+ }
+
+ /**
+ * 매핑 템플릿 삭제
+ */
+ async deleteMappingTemplate(
+ id: number,
+ companyCode: string
+ ): Promise {
+ try {
+ logger.info("매핑 템플릿 삭제", { id, companyCode });
+
+ const pool = getPool();
+
+ let query: string;
+ let params: any[];
+
+ if (companyCode === "*") {
+ query = `DELETE FROM excel_mapping_template WHERE id = $1`;
+ params = [id];
+ } else {
+ query = `DELETE FROM excel_mapping_template WHERE id = $1 AND company_code = $2`;
+ params = [id, companyCode];
+ }
+
+ const result = await pool.query(query, params);
+
+ if (result.rowCount && result.rowCount > 0) {
+ logger.info("매핑 템플릿 삭제 완료", { id });
+ return true;
+ }
+
+ logger.warn("삭제할 매핑 템플릿 없음", { id, companyCode });
+ return false;
+ } catch (error: any) {
+ logger.error(`매핑 템플릿 삭제 실패: ${error.message}`, { error });
+ throw error;
+ }
+ }
+}
+
+export default new ExcelMappingService();
+
diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx
index 28be5688..97214a2a 100644
--- a/frontend/components/common/ExcelUploadModal.tsx
+++ b/frontend/components/common/ExcelUploadModal.tsx
@@ -19,7 +19,6 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
-import { Checkbox } from "@/components/ui/checkbox";
import { toast } from "sonner";
import {
Upload,
@@ -35,6 +34,7 @@ import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport";
import { DynamicFormApi } from "@/lib/api/dynamicForm";
import { getTableSchema, TableColumn } from "@/lib/api/tableSchema";
import { cn } from "@/lib/utils";
+import { findMappingByColumns, saveMappingTemplate } from "@/lib/api/excelMapping";
export interface ExcelUploadModalProps {
open: boolean;
@@ -66,12 +66,14 @@ export const ExcelUploadModal: React.FC = ({
const [file, setFile] = useState(null);
const [sheetNames, setSheetNames] = useState([]);
const [selectedSheet, setSelectedSheet] = useState("");
+ const [isDragOver, setIsDragOver] = useState(false);
const fileInputRef = useRef(null);
// 2단계: 범위 지정
- const [autoCreateColumn, setAutoCreateColumn] = useState(false);
- const [selectedCompany, setSelectedCompany] = useState("");
- const [selectedDataType, setSelectedDataType] = useState("");
+ // (더 이상 사용하지 않는 상태들 - 3단계로 이동)
+
+ // 3단계: 컬럼 매핑 + 매핑 템플릿 자동 적용
+ const [isAutoMappingLoaded, setIsAutoMappingLoaded] = useState(false);
const [detectedRange, setDetectedRange] = useState("");
const [previewData, setPreviewData] = useState[]>([]);
const [allData, setAllData] = useState[]>([]);
@@ -89,7 +91,11 @@ export const ExcelUploadModal: React.FC = ({
const handleFileChange = async (e: React.ChangeEvent) => {
const selectedFile = e.target.files?.[0];
if (!selectedFile) return;
+ await processFile(selectedFile);
+ };
+ // 파일 처리 공통 함수 (파일 선택 및 드래그 앤 드롭에서 공유)
+ const processFile = async (selectedFile: File) => {
const fileExtension = selectedFile.name.split(".").pop()?.toLowerCase();
if (!["xlsx", "xls", "csv"].includes(fileExtension || "")) {
toast.error("엑셀 파일만 업로드 가능합니다. (.xlsx, .xls, .csv)");
@@ -105,7 +111,7 @@ export const ExcelUploadModal: React.FC = ({
const data = await importFromExcel(selectedFile, sheets[0]);
setAllData(data);
- setDisplayData(data); // 전체 데이터를 미리보기에 표시 (스크롤 가능)
+ setDisplayData(data);
if (data.length > 0) {
const columns = Object.keys(data[0]);
@@ -122,6 +128,30 @@ export const ExcelUploadModal: React.FC = ({
}
};
+ // 드래그 앤 드롭 핸들러
+ const handleDragOver = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragOver(true);
+ };
+
+ const handleDragLeave = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragOver(false);
+ };
+
+ const handleDrop = async (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragOver(false);
+
+ const droppedFile = e.dataTransfer.files?.[0];
+ if (droppedFile) {
+ await processFile(droppedFile);
+ }
+ };
+
// 시트 변경 핸들러
const handleSheetChange = async (sheetName: string) => {
setSelectedSheet(sheetName);
@@ -201,6 +231,15 @@ export const ExcelUploadModal: React.FC = ({
}
}, [currentStep, tableName]);
+ // 테이블 생성 시 자동 생성되는 시스템 컬럼 (매핑에서 제외)
+ const AUTO_GENERATED_COLUMNS = [
+ "id", // ID
+ "created_date", // 생성일시
+ "updated_date", // 수정일시
+ "writer", // 작성자
+ "company_code", // 회사코드
+ ];
+
const loadTableSchema = async () => {
try {
console.log("🔍 테이블 스키마 로드 시작:", { tableName });
@@ -210,14 +249,41 @@ export const ExcelUploadModal: React.FC = ({
console.log("📊 테이블 스키마 응답:", response);
if (response.success && response.data) {
- console.log("✅ 시스템 컬럼 로드 완료:", response.data.columns);
- setSystemColumns(response.data.columns);
+ // 자동 생성 컬럼 제외
+ const filteredColumns = response.data.columns.filter(
+ (col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase())
+ );
+ console.log("✅ 시스템 컬럼 로드 완료 (자동 생성 컬럼 제외):", filteredColumns);
+ setSystemColumns(filteredColumns);
- const initialMappings: ColumnMapping[] = excelColumns.map((col) => ({
- excelColumn: col,
- systemColumn: null,
- }));
- setColumnMappings(initialMappings);
+ // 기존 매핑 템플릿 조회
+ console.log("🔍 매핑 템플릿 조회 중...", { tableName, excelColumns });
+ const mappingResponse = await findMappingByColumns(tableName, excelColumns);
+
+ if (mappingResponse.success && mappingResponse.data) {
+ // 저장된 매핑 템플릿이 있으면 자동 적용
+ console.log("✅ 기존 매핑 템플릿 발견:", mappingResponse.data);
+ const savedMappings = mappingResponse.data.columnMappings;
+
+ const appliedMappings: ColumnMapping[] = excelColumns.map((col) => ({
+ excelColumn: col,
+ systemColumn: savedMappings[col] || null,
+ }));
+ setColumnMappings(appliedMappings);
+ setIsAutoMappingLoaded(true);
+
+ const matchedCount = appliedMappings.filter((m) => m.systemColumn).length;
+ toast.success(`이전 매핑 템플릿이 적용되었습니다. (${matchedCount}개 컬럼)`);
+ } else {
+ // 매핑 템플릿이 없으면 초기 상태로 설정
+ console.log("ℹ️ 매핑 템플릿 없음 - 새 엑셀 구조");
+ const initialMappings: ColumnMapping[] = excelColumns.map((col) => ({
+ excelColumn: col,
+ systemColumn: null,
+ }));
+ setColumnMappings(initialMappings);
+ setIsAutoMappingLoaded(false);
+ }
} else {
console.error("❌ 테이블 스키마 로드 실패:", response);
}
@@ -343,6 +409,27 @@ export const ExcelUploadModal: React.FC = ({
toast.success(
`${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}`
);
+
+ // 매핑 템플릿 저장 (UPSERT - 자동 저장)
+ try {
+ const mappingsToSave: Record = {};
+ columnMappings.forEach((mapping) => {
+ mappingsToSave[mapping.excelColumn] = mapping.systemColumn;
+ });
+
+ console.log("💾 매핑 템플릿 저장 중...", { tableName, excelColumns, mappingsToSave });
+ const saveResult = await saveMappingTemplate(tableName, excelColumns, mappingsToSave);
+
+ if (saveResult.success) {
+ console.log("✅ 매핑 템플릿 저장 완료:", saveResult.data);
+ } else {
+ console.warn("⚠️ 매핑 템플릿 저장 실패:", saveResult.error);
+ }
+ } catch (error) {
+ console.warn("⚠️ 매핑 템플릿 저장 중 오류:", error);
+ // 매핑 템플릿 저장 실패해도 업로드는 성공이므로 에러 표시 안함
+ }
+
onSuccess?.();
} else {
toast.error("업로드에 실패했습니다.");
@@ -362,9 +449,7 @@ export const ExcelUploadModal: React.FC = ({
setFile(null);
setSheetNames([]);
setSelectedSheet("");
- setAutoCreateColumn(false);
- setSelectedCompany("");
- setSelectedDataType("");
+ setIsAutoMappingLoaded(false);
setDetectedRange("");
setPreviewData([]);
setAllData([]);
@@ -456,16 +541,46 @@ export const ExcelUploadModal: React.FC = ({
파일 선택 *
-
-
fileInputRef.current?.click()}
- className="h-8 flex-1 text-xs sm:h-10 sm:text-sm"
- >
-
- {file ? file.name : "파일 선택"}
-
+ {/* 드래그 앤 드롭 영역 */}
+
fileInputRef.current?.click()}
+ className={cn(
+ "mt-2 flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors",
+ isDragOver
+ ? "border-primary bg-primary/5"
+ : file
+ ? "border-green-500 bg-green-50"
+ : "border-muted-foreground/25 hover:border-primary hover:bg-muted/50"
+ )}
+ >
+ {file ? (
+ <>
+
+
{file.name}
+
+ 클릭하여 다른 파일 선택
+
+ >
+ ) : (
+ <>
+
+
+ {isDragOver ? "파일을 놓으세요" : "파일을 드래그하거나 클릭하여 선택"}
+
+
+ 지원 형식: .xlsx, .xls, .csv
+
+ >
+ )}
= ({
className="hidden"
/>
-
- 지원 형식: .xlsx, .xls, .csv
-
{sheetNames.length > 0 && (
@@ -510,67 +622,22 @@ export const ExcelUploadModal: React.FC = ({
{/* 2단계: 범위 지정 */}
{currentStep === 2 && (
- {/* 상단: 3개 드롭다운 가로 배치 */}
-
-
-
-
-
-
- {sheetNames.map((sheetName) => (
-
- {sheetName}
-
- ))}
-
-
-
-
-
-
-
-
-
- ABC 주식회사
-
-
- XYZ 상사
-
-
- 대한물산
-
-
-
-
-
-
-
-
-
-
- 유형 1
-
-
- 유형 2
-
-
-
-
-
- {/* 중간: 체크박스 + 버튼들 한 줄 배치 */}
-
-
-
setAutoCreateColumn(checked as boolean)}
- />
-
- 자동 거래처 열 생성
-
+ {/* 상단: 시트 선택 + 버튼들 */}
+
+
+ 시트:
+
+
+
+
+
+ {sheetNames.map((sheetName) => (
+
+ {sheetName}
+
+ ))}
+
+
@@ -751,6 +818,35 @@ export const ExcelUploadModal: React.FC = ({
))}
+
+ {/* 매핑 자동 저장 안내 */}
+ {isAutoMappingLoaded ? (
+
+
+
+
+
이전 매핑이 자동 적용됨
+
+ 동일한 엑셀 구조가 감지되어 이전에 저장된 매핑이 적용되었습니다.
+ 필요시 수정하면 업로드 시 자동으로 저장됩니다.
+
+
+
+
+ ) : (
+
+
+
+
+
새로운 엑셀 구조
+
+ 이 엑셀 구조는 처음입니다. 컬럼 매핑을 설정하면 업로드 시 자동으로 저장되어
+ 다음에 같은 구조의 엑셀을 업로드할 때 자동 적용됩니다.
+
+
+
+
+ )}
)}
diff --git a/frontend/lib/api/excelMapping.ts b/frontend/lib/api/excelMapping.ts
new file mode 100644
index 00000000..50b046ed
--- /dev/null
+++ b/frontend/lib/api/excelMapping.ts
@@ -0,0 +1,106 @@
+import { apiClient } from "./client";
+
+export interface ExcelMappingTemplate {
+ id?: number;
+ tableName: string;
+ excelColumns: string[];
+ excelColumnsHash: string;
+ columnMappings: Record
; // { "엑셀컬럼": "시스템컬럼" }
+ companyCode: string;
+ createdDate?: string;
+ updatedDate?: string;
+}
+
+export interface ApiResponse {
+ success: boolean;
+ data?: T;
+ message?: string;
+ error?: string;
+}
+
+/**
+ * 엑셀 컬럼 구조로 매핑 템플릿 조회
+ * 동일한 엑셀 컬럼 구조가 있으면 기존 매핑 반환
+ */
+export async function findMappingByColumns(
+ tableName: string,
+ excelColumns: string[]
+): Promise> {
+ try {
+ const response = await apiClient.post("/excel-mapping/find", {
+ tableName,
+ excelColumns,
+ });
+ return response.data;
+ } catch (error: any) {
+ console.error("매핑 템플릿 조회 실패:", error);
+ return {
+ success: false,
+ error: error.message || "매핑 템플릿 조회 실패",
+ };
+ }
+}
+
+/**
+ * 매핑 템플릿 저장 (UPSERT)
+ * 동일한 테이블+컬럼구조가 있으면 업데이트, 없으면 삽입
+ */
+export async function saveMappingTemplate(
+ tableName: string,
+ excelColumns: string[],
+ columnMappings: Record
+): Promise> {
+ try {
+ const response = await apiClient.post("/excel-mapping/save", {
+ tableName,
+ excelColumns,
+ columnMappings,
+ });
+ return response.data;
+ } catch (error: any) {
+ console.error("매핑 템플릿 저장 실패:", error);
+ return {
+ success: false,
+ error: error.message || "매핑 템플릿 저장 실패",
+ };
+ }
+}
+
+/**
+ * 테이블의 매핑 템플릿 목록 조회
+ */
+export async function getMappingTemplates(
+ tableName: string
+): Promise> {
+ try {
+ const response = await apiClient.get(
+ `/excel-mapping/list/${encodeURIComponent(tableName)}`
+ );
+ return response.data;
+ } catch (error: any) {
+ console.error("매핑 템플릿 목록 조회 실패:", error);
+ return {
+ success: false,
+ error: error.message || "매핑 템플릿 목록 조회 실패",
+ };
+ }
+}
+
+/**
+ * 매핑 템플릿 삭제
+ */
+export async function deleteMappingTemplate(
+ id: number
+): Promise> {
+ try {
+ const response = await apiClient.delete(`/excel-mapping/${id}`);
+ return response.data;
+ } catch (error: any) {
+ console.error("매핑 템플릿 삭제 실패:", error);
+ return {
+ success: false,
+ error: error.message || "매핑 템플릿 삭제 실패",
+ };
+ }
+}
+
From 83eb92cb277165ab3a5226651c00a4097ec75d16 Mon Sep 17 00:00:00 2001
From: kjs
Date: Thu, 8 Jan 2026 11:51:02 +0900
Subject: [PATCH 64/95] =?UTF-8?q?=EC=97=91=EC=85=80=20=EC=97=85=EB=A1=9C?=
=?UTF-8?q?=EB=93=9C=20=EB=8B=A8=EA=B3=84=20=ED=86=B5=ED=95=A9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../components/common/ExcelUploadModal.tsx | 468 +++++++++---------
1 file changed, 235 insertions(+), 233 deletions(-)
diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx
index 97214a2a..01c39351 100644
--- a/frontend/components/common/ExcelUploadModal.tsx
+++ b/frontend/components/common/ExcelUploadModal.tsx
@@ -18,7 +18,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
-import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import {
Upload,
@@ -62,29 +61,23 @@ export const ExcelUploadModal: React.FC = ({
}) => {
const [currentStep, setCurrentStep] = useState(1);
- // 1단계: 파일 선택
+ // 1단계: 파일 선택 & 미리보기
const [file, setFile] = useState(null);
const [sheetNames, setSheetNames] = useState([]);
const [selectedSheet, setSelectedSheet] = useState("");
const [isDragOver, setIsDragOver] = useState(false);
const fileInputRef = useRef(null);
-
- // 2단계: 범위 지정
- // (더 이상 사용하지 않는 상태들 - 3단계로 이동)
-
- // 3단계: 컬럼 매핑 + 매핑 템플릿 자동 적용
- const [isAutoMappingLoaded, setIsAutoMappingLoaded] = useState(false);
const [detectedRange, setDetectedRange] = useState("");
- const [previewData, setPreviewData] = useState[]>([]);
const [allData, setAllData] = useState[]>([]);
const [displayData, setDisplayData] = useState[]>([]);
- // 3단계: 컬럼 매핑
+ // 2단계: 컬럼 매핑 + 매핑 템플릿 자동 적용
+ const [isAutoMappingLoaded, setIsAutoMappingLoaded] = useState(false);
const [excelColumns, setExcelColumns] = useState([]);
const [systemColumns, setSystemColumns] = useState([]);
const [columnMappings, setColumnMappings] = useState([]);
- // 4단계: 확인
+ // 3단계: 확인
const [isUploading, setIsUploading] = useState(false);
// 파일 선택 핸들러
@@ -160,7 +153,7 @@ export const ExcelUploadModal: React.FC = ({
try {
const data = await importFromExcel(file, sheetName);
setAllData(data);
- setDisplayData(data); // 전체 데이터를 미리보기에 표시 (스크롤 가능)
+ setDisplayData(data);
if (data.length > 0) {
const columns = Object.keys(data[0]);
@@ -224,30 +217,30 @@ export const ExcelUploadModal: React.FC = ({
}
};
- // 테이블 스키마 가져오기
+ // 테이블 스키마 가져오기 (2단계 진입 시)
useEffect(() => {
- if (currentStep === 3 && tableName) {
+ if (currentStep === 2 && tableName) {
loadTableSchema();
}
}, [currentStep, tableName]);
// 테이블 생성 시 자동 생성되는 시스템 컬럼 (매핑에서 제외)
const AUTO_GENERATED_COLUMNS = [
- "id", // ID
- "created_date", // 생성일시
- "updated_date", // 수정일시
- "writer", // 작성자
- "company_code", // 회사코드
+ "id",
+ "created_date",
+ "updated_date",
+ "writer",
+ "company_code",
];
const loadTableSchema = async () => {
try {
console.log("🔍 테이블 스키마 로드 시작:", { tableName });
-
+
const response = await getTableSchema(tableName);
-
+
console.log("📊 테이블 스키마 응답:", response);
-
+
if (response.success && response.data) {
// 자동 생성 컬럼 제외
const filteredColumns = response.data.columns.filter(
@@ -259,19 +252,19 @@ export const ExcelUploadModal: React.FC = ({
// 기존 매핑 템플릿 조회
console.log("🔍 매핑 템플릿 조회 중...", { tableName, excelColumns });
const mappingResponse = await findMappingByColumns(tableName, excelColumns);
-
+
if (mappingResponse.success && mappingResponse.data) {
// 저장된 매핑 템플릿이 있으면 자동 적용
console.log("✅ 기존 매핑 템플릿 발견:", mappingResponse.data);
const savedMappings = mappingResponse.data.columnMappings;
-
+
const appliedMappings: ColumnMapping[] = excelColumns.map((col) => ({
excelColumn: col,
systemColumn: savedMappings[col] || null,
}));
setColumnMappings(appliedMappings);
setIsAutoMappingLoaded(true);
-
+
const matchedCount = appliedMappings.filter((m) => m.systemColumn).length;
toast.success(`이전 매핑 템플릿이 적용되었습니다. (${matchedCount}개 컬럼)`);
} else {
@@ -297,10 +290,11 @@ export const ExcelUploadModal: React.FC = ({
const handleAutoMapping = () => {
const newMappings = excelColumns.map((excelCol) => {
const normalizedExcelCol = excelCol.toLowerCase().trim();
-
+
// 1. 먼저 라벨로 매칭 시도
let matchedSystemCol = systemColumns.find(
- (sysCol) => sysCol.label && sysCol.label.toLowerCase().trim() === normalizedExcelCol
+ (sysCol) =>
+ sysCol.label && sysCol.label.toLowerCase().trim() === normalizedExcelCol
);
// 2. 라벨로 매칭되지 않으면 컬럼명으로 매칭 시도
@@ -325,9 +319,7 @@ export const ExcelUploadModal: React.FC = ({
const handleMappingChange = (excelColumn: string, systemColumn: string | null) => {
setColumnMappings((prev) =>
prev.map((mapping) =>
- mapping.excelColumn === excelColumn
- ? { ...mapping, systemColumn }
- : mapping
+ mapping.excelColumn === excelColumn ? { ...mapping, systemColumn } : mapping
)
);
};
@@ -339,12 +331,12 @@ export const ExcelUploadModal: React.FC = ({
return;
}
- if (currentStep === 2 && displayData.length === 0) {
+ if (currentStep === 1 && displayData.length === 0) {
toast.error("데이터가 없습니다.");
return;
}
- setCurrentStep((prev) => Math.min(prev + 1, 4));
+ setCurrentStep((prev) => Math.min(prev + 1, 3));
};
// 이전 단계
@@ -362,7 +354,7 @@ export const ExcelUploadModal: React.FC = ({
setIsUploading(true);
try {
- // allData를 사용하여 전체 데이터 업로드 (displayData는 미리보기용 10개만)
+ // allData를 사용하여 전체 데이터 업로드
const mappedData = allData.map((row) => {
const mappedRow: Record = {};
columnMappings.forEach((mapping) => {
@@ -376,7 +368,6 @@ export const ExcelUploadModal: React.FC = ({
// 빈 행 필터링: 모든 값이 비어있거나 undefined/null인 행 제외
const filteredData = mappedData.filter((row) => {
const values = Object.values(row);
- // 하나라도 유효한 값이 있는지 확인
return values.some((value) => {
if (value === undefined || value === null) return false;
if (typeof value === "string" && value.trim() === "") return false;
@@ -384,7 +375,9 @@ export const ExcelUploadModal: React.FC = ({
});
});
- console.log(`📊 엑셀 업로드: 전체 ${mappedData.length}행 중 유효한 ${filteredData.length}행`);
+ console.log(
+ `📊 엑셀 업로드: 전체 ${mappedData.length}행 중 유효한 ${filteredData.length}행`
+ );
let successCount = 0;
let failCount = 0;
@@ -416,10 +409,18 @@ export const ExcelUploadModal: React.FC = ({
columnMappings.forEach((mapping) => {
mappingsToSave[mapping.excelColumn] = mapping.systemColumn;
});
-
- console.log("💾 매핑 템플릿 저장 중...", { tableName, excelColumns, mappingsToSave });
- const saveResult = await saveMappingTemplate(tableName, excelColumns, mappingsToSave);
-
+
+ console.log("💾 매핑 템플릿 저장 중...", {
+ tableName,
+ excelColumns,
+ mappingsToSave,
+ });
+ const saveResult = await saveMappingTemplate(
+ tableName,
+ excelColumns,
+ mappingsToSave
+ );
+
if (saveResult.success) {
console.log("✅ 매핑 템플릿 저장 완료:", saveResult.data);
} else {
@@ -427,7 +428,6 @@ export const ExcelUploadModal: React.FC = ({
}
} catch (error) {
console.warn("⚠️ 매핑 템플릿 저장 중 오류:", error);
- // 매핑 템플릿 저장 실패해도 업로드는 성공이므로 에러 표시 안함
}
onSuccess?.();
@@ -451,7 +451,6 @@ export const ExcelUploadModal: React.FC = ({
setSelectedSheet("");
setIsAutoMappingLoaded(false);
setDetectedRange("");
- setPreviewData([]);
setAllData([]);
setDisplayData([]);
setExcelColumns([]);
@@ -479,17 +478,16 @@ export const ExcelUploadModal: React.FC = ({
엑셀 데이터 업로드
- 엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요. 모달 테두리를 드래그하여 크기를 조절할 수 있습니다.
+ 엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요.
- {/* 스텝 인디케이터 */}
+ {/* 스텝 인디케이터 (3단계) */}
{[
{ num: 1, label: "파일 선택" },
- { num: 2, label: "범위 지정" },
- { num: 3, label: "컬럼 매핑" },
- { num: 4, label: "확인" },
+ { num: 2, label: "컬럼 매핑" },
+ { num: 3, label: "확인" },
].map((step, index) => (
@@ -512,15 +510,13 @@ export const ExcelUploadModal: React.FC = ({
{step.label}
- {index < 3 && (
+ {index < 2 && (
= ({
{/* 스텝별 컨텐츠 */}
- {/* 1단계: 파일 선택 */}
+ {/* 1단계: 파일 선택 & 미리보기 (통합) */}
{currentStep === 1 && (
+ {/* 파일 선택 영역 */}
파일 선택 *
- {/* 드래그 앤 드롭 영역 */}
fileInputRef.current?.click()}
className={cn(
- "mt-2 flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors",
+ "mt-2 flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-4 transition-colors",
isDragOver
? "border-primary bg-primary/5"
: file
@@ -557,24 +553,32 @@ export const ExcelUploadModal: React.FC
= ({
)}
>
{file ? (
- <>
-
- {file.name}
-
- 클릭하여 다른 파일 선택
-
- >
+
+
+
+
{file.name}
+
+ 클릭하여 다른 파일 선택
+
+
+
) : (
<>
-
-
- {isDragOver ? "파일을 놓으세요" : "파일을 드래그하거나 클릭하여 선택"}
+
+
+ {isDragOver
+ ? "파일을 놓으세요"
+ : "파일을 드래그하거나 클릭하여 선택"}
지원 형식: .xlsx, .xls, .csv
@@ -592,163 +596,148 @@ export const ExcelUploadModal: React.FC = ({
- {sheetNames.length > 0 && (
-
-
- 시트 선택
-
-
-
-
-
-
- {sheetNames.map((sheetName) => (
-
-
- {sheetName}
-
- ))}
-
-
-
- )}
-
- )}
+ {/* 파일이 선택된 경우에만 미리보기 표시 */}
+ {file && displayData.length > 0 && (
+ <>
+ {/* 시트 선택 + 행/열 편집 버튼 */}
+
+
+
+ 시트:
+
+
+
+
+
+
+ {sheetNames.map((sheetName) => (
+
+ {sheetName}
+
+ ))}
+
+
+
- {/* 2단계: 범위 지정 */}
- {currentStep === 2 && (
-
- {/* 상단: 시트 선택 + 버튼들 */}
-
-
- 시트:
-
-
-
-
-
- {sheetNames.map((sheetName) => (
-
- {sheetName}
-
- ))}
-
-
-
+
+
+ 행
+
+
+ 행
+
+
+ 열
+
+
+ 열
+
+
+
-
-
-
- 행 추가
-
-
-
- 열 추가
-
-
-
- 행 삭제
-
-
-
- 열 삭제
-
-
-
+ {/* 감지된 범위 */}
+
+ 감지된 범위: {detectedRange}
+ ({displayData.length}개 행)
+
- {/* 하단: 감지된 범위 + 테이블 */}
-
- 감지된 범위: {detectedRange}
-
- 첫 행이 컬럼명, 데이터는 자동 감지됩니다
-
-
-
- {displayData.length > 0 && (
-
-
-
-
-
-
-
- {excelColumns.map((col, index) => (
-
- {String.fromCharCode(65 + index)}
-
- ))}
-
-
-
-
-
- 1
-
- {excelColumns.map((col) => (
-
- {col}
-
- ))}
-
- {displayData.map((row, rowIndex) => (
-
-
- {rowIndex + 2}
+ {/* 데이터 미리보기 테이블 */}
+
+
+
+
+
+ {excelColumns.map((col, index) => (
+
+ {String.fromCharCode(65 + index)}
+
+ ))}
+
+
+
+
+
+ 1
{excelColumns.map((col) => (
- {String(row[col] || "")}
+ {col}
))}
- ))}
-
-
-
+ {displayData.slice(0, 10).map((row, rowIndex) => (
+
+
+ {rowIndex + 2}
+
+ {excelColumns.map((col) => (
+
+ {String(row[col] || "")}
+
+ ))}
+
+ ))}
+ {displayData.length > 10 && (
+
+
+ ... 외 {displayData.length - 10}개 행
+
+
+ )}
+
+
+
+ >
)}
)}
- {/* 3단계: 컬럼 매핑 */}
- {currentStep === 3 && (
+ {/* 2단계: 컬럼 매핑 */}
+ {currentStep === 2 && (
{/* 상단: 제목 + 자동 매핑 버튼 */}
@@ -773,9 +762,12 @@ export const ExcelUploadModal: React.FC
= ({
시스템 컬럼
-
+
{columnMappings.map((mapping, index) => (
-
+
{mapping.excelColumn}
@@ -793,7 +785,9 @@ export const ExcelUploadModal: React.FC
= ({
{mapping.systemColumn
? (() => {
- const col = systemColumns.find(c => c.name === mapping.systemColumn);
+ const col = systemColumns.find(
+ (c) => c.name === mapping.systemColumn
+ );
return col?.label || mapping.systemColumn;
})()
: "매핑 안함"}
@@ -821,27 +815,27 @@ export const ExcelUploadModal: React.FC = ({
{/* 매핑 자동 저장 안내 */}
{isAutoMappingLoaded ? (
-
+
이전 매핑이 자동 적용됨
동일한 엑셀 구조가 감지되어 이전에 저장된 매핑이 적용되었습니다.
- 필요시 수정하면 업로드 시 자동으로 저장됩니다.
+ 수정하면 업로드 시 자동 저장됩니다.
) : (
-
+
새로운 엑셀 구조
- 이 엑셀 구조는 처음입니다. 컬럼 매핑을 설정하면 업로드 시 자동으로 저장되어
- 다음에 같은 구조의 엑셀을 업로드할 때 자동 적용됩니다.
+ 이 엑셀 구조는 처음입니다. 매핑을 설정하면 다음에 같은 구조의
+ 엑셀에 자동 적용됩니다.
@@ -850,8 +844,8 @@ export const ExcelUploadModal: React.FC
= ({
)}
- {/* 4단계: 확인 */}
- {currentStep === 4 && (
+ {/* 3단계: 확인 */}
+ {currentStep === 3 && (
업로드 요약
@@ -871,7 +865,7 @@ export const ExcelUploadModal: React.FC
= ({
모드: {" "}
{uploadMode === "insert"
- ? "삽입"
+ ? "신규 등록"
: uploadMode === "update"
? "업데이트"
: "Upsert"}
@@ -884,12 +878,17 @@ export const ExcelUploadModal: React.FC = ({
{columnMappings
.filter((m) => m.systemColumn)
- .map((mapping, index) => (
-
- {mapping.excelColumn} →{" "}
- {mapping.systemColumn}
-
- ))}
+ .map((mapping, index) => {
+ const col = systemColumns.find(
+ (c) => c.name === mapping.systemColumn
+ );
+ return (
+
+ {mapping.excelColumn} →{" "}
+ {col?.label || mapping.systemColumn}
+
+ );
+ })}
{columnMappings.filter((m) => m.systemColumn).length === 0 && (
매핑된 컬럼이 없습니다.
)}
@@ -902,7 +901,8 @@ export const ExcelUploadModal: React.FC
= ({
주의사항
- 업로드를 진행하면 데이터가 데이터베이스에 저장됩니다. 계속하시겠습니까?
+ 업로드를 진행하면 데이터가 데이터베이스에 저장됩니다.
+ 계속하시겠습니까?
@@ -920,10 +920,10 @@ export const ExcelUploadModal: React.FC = ({
>
{currentStep === 1 ? "취소" : "이전"}
- {currentStep < 4 ? (
+ {currentStep < 3 ? (
다음
@@ -931,10 +931,12 @@ export const ExcelUploadModal: React.FC = ({
) : (
m.systemColumn).length === 0}
+ disabled={
+ isUploading || columnMappings.filter((m) => m.systemColumn).length === 0
+ }
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
- {isUploading ? "업로드 중..." : "다음"}
+ {isUploading ? "업로드 중..." : "업로드"}
)}
From b61cb17aeafcd3d74a59752fe9ae8faa2dbcc7c4 Mon Sep 17 00:00:00 2001
From: kjs
Date: Thu, 8 Jan 2026 12:04:31 +0900
Subject: [PATCH 65/95] =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=B1=84=EC=9A=B0?=
=?UTF-8?q?=EA=B8=B0=20=ED=95=B8=EB=93=A4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../components/common/EditableSpreadsheet.tsx | 695 ++++++++++++++++++
.../components/common/ExcelUploadModal.tsx | 228 ++----
2 files changed, 762 insertions(+), 161 deletions(-)
create mode 100644 frontend/components/common/EditableSpreadsheet.tsx
diff --git a/frontend/components/common/EditableSpreadsheet.tsx b/frontend/components/common/EditableSpreadsheet.tsx
new file mode 100644
index 00000000..de9c827d
--- /dev/null
+++ b/frontend/components/common/EditableSpreadsheet.tsx
@@ -0,0 +1,695 @@
+"use client";
+
+import React, { useState, useRef, useEffect, useCallback } from "react";
+import { cn } from "@/lib/utils";
+
+interface EditableSpreadsheetProps {
+ columns: string[];
+ data: Record[];
+ onColumnsChange: (columns: string[]) => void;
+ onDataChange: (data: Record[]) => void;
+ maxHeight?: string;
+}
+
+/**
+ * 엑셀처럼 편집 가능한 스프레드시트 컴포넌트
+ * - 셀 클릭으로 편집
+ * - Tab/Enter로 다음 셀 이동
+ * - 마지막 행/열에서 자동 추가
+ * - 헤더(컬럼명)도 편집 가능
+ * - 자동 채우기 (드래그 핸들)
+ */
+export const EditableSpreadsheet: React.FC = ({
+ columns,
+ data,
+ onColumnsChange,
+ onDataChange,
+ maxHeight = "350px",
+}) => {
+ // 현재 편집 중인 셀 (row: -1은 헤더)
+ const [editingCell, setEditingCell] = useState<{
+ row: number;
+ col: number;
+ } | null>(null);
+ const [editValue, setEditValue] = useState("");
+
+ // 현재 선택된 셀 (편집 모드 아닐 때도 표시)
+ const [selectedCell, setSelectedCell] = useState<{
+ row: number;
+ col: number;
+ } | null>(null);
+
+ // 자동 채우기 드래그 상태
+ const [isDraggingFill, setIsDraggingFill] = useState(false);
+ const [fillPreviewEnd, setFillPreviewEnd] = useState(null);
+
+ const inputRef = useRef(null);
+ const tableRef = useRef(null);
+
+ // 셀 선택 (클릭만, 편집 아님)
+ const selectCell = useCallback((row: number, col: number) => {
+ setSelectedCell({ row, col });
+ }, []);
+
+ // 셀 편집 시작 (더블클릭 또는 타이핑 시작)
+ const startEditing = useCallback(
+ (row: number, col: number) => {
+ setEditingCell({ row, col });
+ setSelectedCell({ row, col });
+ if (row === -1) {
+ // 헤더 편집
+ setEditValue(columns[col] || "");
+ } else {
+ // 데이터 셀 편집
+ const colName = columns[col];
+ setEditValue(String(data[row]?.[colName] ?? ""));
+ }
+ },
+ [columns, data]
+ );
+
+ // 편집 완료
+ const finishEditing = useCallback(() => {
+ if (!editingCell) return;
+
+ const { row, col } = editingCell;
+
+ if (row === -1) {
+ // 헤더(컬럼명) 변경
+ const newColumns = [...columns];
+ const oldColName = newColumns[col];
+ const newColName = editValue.trim() || `Column${col + 1}`;
+
+ if (oldColName !== newColName) {
+ newColumns[col] = newColName;
+ onColumnsChange(newColumns);
+
+ // 데이터의 키도 함께 변경
+ const newData = data.map((rowData) => {
+ const newRowData: Record = {};
+ Object.keys(rowData).forEach((key) => {
+ if (key === oldColName) {
+ newRowData[newColName] = rowData[key];
+ } else {
+ newRowData[key] = rowData[key];
+ }
+ });
+ return newRowData;
+ });
+ onDataChange(newData);
+ }
+ } else {
+ // 데이터 셀 변경
+ const colName = columns[col];
+ const newData = [...data];
+ if (!newData[row]) {
+ newData[row] = {};
+ }
+ newData[row] = { ...newData[row], [colName]: editValue };
+ onDataChange(newData);
+ }
+
+ setEditingCell(null);
+ setEditValue("");
+ }, [editingCell, editValue, columns, data, onColumnsChange, onDataChange]);
+
+ // 다음 셀로 이동
+ const moveToNextCell = useCallback(
+ (direction: "right" | "down" | "left" | "up") => {
+ if (!editingCell) return;
+
+ finishEditing();
+
+ const { row, col } = editingCell;
+ let nextRow = row;
+ let nextCol = col;
+
+ switch (direction) {
+ case "right":
+ if (col < columns.length - 1) {
+ nextCol = col + 1;
+ } else {
+ // 마지막 열에서 Tab → 새 열 추가 (빈 헤더로)
+ const tempColId = `__temp_${Date.now()}`;
+ const newColumns = [...columns, ""];
+ onColumnsChange(newColumns);
+
+ // 모든 행에 새 컬럼 추가 (임시 키 사용)
+ const newData = data.map((rowData) => ({
+ ...rowData,
+ [tempColId]: "",
+ }));
+ onDataChange(newData);
+
+ nextCol = columns.length;
+ }
+ break;
+
+ case "down":
+ if (row === -1) {
+ nextRow = 0;
+ } else if (row < data.length - 1) {
+ nextRow = row + 1;
+ } else {
+ // 마지막 행에서 Enter → 새 행 추가
+ const newRow: Record = {};
+ columns.forEach((c) => {
+ newRow[c] = "";
+ });
+ onDataChange([...data, newRow]);
+ nextRow = data.length;
+ }
+ break;
+
+ case "left":
+ if (col > 0) {
+ nextCol = col - 1;
+ }
+ break;
+
+ case "up":
+ if (row > -1) {
+ nextRow = row - 1;
+ }
+ break;
+ }
+
+ // 다음 셀 편집 시작
+ setTimeout(() => {
+ startEditing(nextRow, nextCol);
+ }, 0);
+ },
+ [editingCell, columns, data, onColumnsChange, onDataChange, finishEditing, startEditing]
+ );
+
+ // 키보드 이벤트 처리
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ switch (e.key) {
+ case "Tab":
+ e.preventDefault();
+ moveToNextCell(e.shiftKey ? "left" : "right");
+ break;
+ case "Enter":
+ e.preventDefault();
+ moveToNextCell("down");
+ break;
+ case "Escape":
+ setEditingCell(null);
+ setEditValue("");
+ break;
+ case "ArrowUp":
+ if (!e.shiftKey) {
+ e.preventDefault();
+ moveToNextCell("up");
+ }
+ break;
+ case "ArrowDown":
+ if (!e.shiftKey) {
+ e.preventDefault();
+ moveToNextCell("down");
+ }
+ break;
+ case "ArrowLeft":
+ // 커서가 맨 앞이면 왼쪽 셀로
+ if (inputRef.current?.selectionStart === 0) {
+ e.preventDefault();
+ moveToNextCell("left");
+ }
+ break;
+ case "ArrowRight":
+ // 커서가 맨 뒤면 오른쪽 셀로
+ if (inputRef.current?.selectionStart === editValue.length) {
+ e.preventDefault();
+ moveToNextCell("right");
+ }
+ break;
+ }
+ },
+ [moveToNextCell, editValue]
+ );
+
+ // 편집 모드일 때 input에 포커스
+ useEffect(() => {
+ if (editingCell && inputRef.current) {
+ inputRef.current.focus();
+ inputRef.current.select();
+ }
+ }, [editingCell]);
+
+ // 외부 클릭 시 편집 종료
+ useEffect(() => {
+ const handleClickOutside = (e: MouseEvent) => {
+ if (tableRef.current && !tableRef.current.contains(e.target as Node)) {
+ finishEditing();
+ setSelectedCell(null);
+ }
+ };
+
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => document.removeEventListener("mousedown", handleClickOutside);
+ }, [finishEditing]);
+
+ // 행 삭제
+ const handleDeleteRow = (rowIndex: number) => {
+ const newData = data.filter((_, i) => i !== rowIndex);
+ onDataChange(newData);
+ };
+
+ // 열 삭제
+ const handleDeleteColumn = (colIndex: number) => {
+ if (columns.length <= 1) return;
+
+ const colName = columns[colIndex];
+ const newColumns = columns.filter((_, i) => i !== colIndex);
+ onColumnsChange(newColumns);
+
+ const newData = data.map((row) => {
+ const { [colName]: removed, ...rest } = row;
+ return rest;
+ });
+ onDataChange(newData);
+ };
+
+ // 컬럼 문자 (A, B, C, ...)
+ const getColumnLetter = (index: number): string => {
+ let letter = "";
+ let i = index;
+ while (i >= 0) {
+ letter = String.fromCharCode(65 + (i % 26)) + letter;
+ i = Math.floor(i / 26) - 1;
+ }
+ return letter;
+ };
+
+ // ============ 자동 채우기 로직 ============
+
+ // 값에서 마지막 숫자 패턴 추출 (예: "26-item-0005" → prefix: "26-item-", number: 5, suffix: "", numLength: 4)
+ const extractNumberPattern = (value: string): {
+ prefix: string;
+ number: number;
+ suffix: string;
+ numLength: number;
+ isZeroPadded: boolean;
+ } | null => {
+ // 숫자만 있는 경우
+ if (/^-?\d+(\.\d+)?$/.test(value)) {
+ const isZeroPadded = value.startsWith("0") && value.length > 1 && !value.includes(".");
+ return {
+ prefix: "",
+ number: parseFloat(value),
+ suffix: "",
+ numLength: value.replace("-", "").split(".")[0].length,
+ isZeroPadded
+ };
+ }
+
+ // 마지막 숫자 시퀀스를 찾기 (greedy하게 prefix를 찾음)
+ // 예: "26-item-0005" → prefix: "26-item-", number: "0005", suffix: ""
+ const match = value.match(/^(.*)(\d+)(\D*)$/);
+ if (match) {
+ const numStr = match[2];
+ const isZeroPadded = numStr.startsWith("0") && numStr.length > 1;
+ return {
+ prefix: match[1],
+ number: parseInt(numStr, 10),
+ suffix: match[3],
+ numLength: numStr.length,
+ isZeroPadded
+ };
+ }
+
+ return null;
+ };
+
+ // 날짜 패턴 인식
+ const extractDatePattern = (value: string): Date | null => {
+ // YYYY-MM-DD 형식
+ const dateMatch = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
+ if (dateMatch) {
+ const date = new Date(parseInt(dateMatch[1]), parseInt(dateMatch[2]) - 1, parseInt(dateMatch[3]));
+ if (!isNaN(date.getTime())) {
+ return date;
+ }
+ }
+ return null;
+ };
+
+ // 다음 값 생성
+ const generateNextValue = (sourceValue: string, step: number): string => {
+ // 빈 값이면 그대로
+ if (!sourceValue || sourceValue.trim() === "") {
+ return "";
+ }
+
+ // 날짜 패턴 체크
+ const datePattern = extractDatePattern(sourceValue);
+ if (datePattern) {
+ const newDate = new Date(datePattern);
+ newDate.setDate(newDate.getDate() + step);
+ const year = newDate.getFullYear();
+ const month = String(newDate.getMonth() + 1).padStart(2, "0");
+ const day = String(newDate.getDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
+ }
+
+ // 숫자 패턴 체크
+ const numberPattern = extractNumberPattern(sourceValue);
+ if (numberPattern) {
+ const newNumber = numberPattern.number + step;
+
+ // 음수 방지 (필요시)
+ const absNumber = Math.max(0, newNumber);
+
+ let numStr: string;
+ if (numberPattern.isZeroPadded) {
+ // 제로패딩 유지 (예: 0005 → 0006)
+ numStr = String(absNumber).padStart(numberPattern.numLength, "0");
+ } else {
+ numStr = String(absNumber);
+ }
+
+ return numberPattern.prefix + numStr + numberPattern.suffix;
+ }
+
+ // 패턴 없으면 복사
+ return sourceValue;
+ };
+
+ // 자동 채우기 드래그 시작
+ const handleFillDragStart = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ if (!selectedCell || selectedCell.row < 0) return;
+
+ setIsDraggingFill(true);
+ setFillPreviewEnd(selectedCell.row);
+ };
+
+ // 자동 채우기 드래그 중
+ const handleFillDragMove = useCallback((e: MouseEvent) => {
+ if (!isDraggingFill || !selectedCell || !tableRef.current) return;
+
+ const rows = tableRef.current.querySelectorAll("tbody tr");
+ const mouseY = e.clientY;
+
+ // 마우스 위치에 해당하는 행 찾기
+ for (let i = 0; i < rows.length - 1; i++) { // 마지막 행(추가 영역) 제외
+ const row = rows[i] as HTMLElement;
+ const rect = row.getBoundingClientRect();
+
+ if (mouseY >= rect.top && mouseY <= rect.bottom) {
+ setFillPreviewEnd(i);
+ break;
+ } else if (mouseY > rect.bottom && i === rows.length - 2) {
+ setFillPreviewEnd(i);
+ }
+ }
+ }, [isDraggingFill, selectedCell]);
+
+ // 자동 채우기 드래그 종료
+ const handleFillDragEnd = useCallback(() => {
+ if (!isDraggingFill || !selectedCell || fillPreviewEnd === null) {
+ setIsDraggingFill(false);
+ setFillPreviewEnd(null);
+ return;
+ }
+
+ const { row: startRow, col } = selectedCell;
+ const endRow = fillPreviewEnd;
+
+ if (startRow !== endRow && startRow >= 0) {
+ const colName = columns[col];
+ const sourceValue = String(data[startRow]?.[colName] ?? "");
+ const newData = [...data];
+
+ if (endRow > startRow) {
+ // 아래로 채우기
+ for (let i = startRow + 1; i <= endRow; i++) {
+ const step = i - startRow;
+ if (!newData[i]) {
+ newData[i] = {};
+ columns.forEach((c) => {
+ newData[i][c] = "";
+ });
+ }
+ newData[i] = {
+ ...newData[i],
+ [colName]: generateNextValue(sourceValue, step),
+ };
+ }
+ } else {
+ // 위로 채우기
+ for (let i = startRow - 1; i >= endRow; i--) {
+ const step = i - startRow;
+ if (!newData[i]) {
+ newData[i] = {};
+ columns.forEach((c) => {
+ newData[i][c] = "";
+ });
+ }
+ newData[i] = {
+ ...newData[i],
+ [colName]: generateNextValue(sourceValue, step),
+ };
+ }
+ }
+
+ onDataChange(newData);
+ }
+
+ setIsDraggingFill(false);
+ setFillPreviewEnd(null);
+ }, [isDraggingFill, selectedCell, fillPreviewEnd, columns, data, onDataChange]);
+
+ // 드래그 이벤트 리스너
+ useEffect(() => {
+ if (isDraggingFill) {
+ document.addEventListener("mousemove", handleFillDragMove);
+ document.addEventListener("mouseup", handleFillDragEnd);
+ return () => {
+ document.removeEventListener("mousemove", handleFillDragMove);
+ document.removeEventListener("mouseup", handleFillDragEnd);
+ };
+ }
+ }, [isDraggingFill, handleFillDragMove, handleFillDragEnd]);
+
+ // 셀이 자동 채우기 미리보기 범위에 있는지 확인
+ const isInFillPreview = (rowIndex: number, colIndex: number): boolean => {
+ if (!isDraggingFill || !selectedCell || fillPreviewEnd === null) return false;
+ if (colIndex !== selectedCell.col) return false;
+
+ const startRow = selectedCell.row;
+ const endRow = fillPreviewEnd;
+
+ if (endRow > startRow) {
+ return rowIndex > startRow && rowIndex <= endRow;
+ } else {
+ return rowIndex >= endRow && rowIndex < startRow;
+ }
+ };
+
+ return (
+