From 7b0bbc91c8133cf529d081cc763cac024eba74a6 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 4 Nov 2025 15:32:49 +0900 Subject: [PATCH 01/59] =?UTF-8?q?=EB=B6=84=ED=95=A0=20=ED=8C=A8=EB=84=90?= =?UTF-8?q?=20=EB=84=88=EB=B9=84=20=EC=A1=B0=EC=A0=88=20=EC=95=88=EB=90=98?= =?UTF-8?q?=EB=8A=94=EA=B1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../split-panel-layout/SplitPanelLayoutComponent.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index b9875736..7506ee65 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -57,7 +57,7 @@ export const SplitPanelLayoutComponent: React.FC ? { // 반응형 모드: position relative, 그리드 컨테이너가 제공하는 크기 사용 position: "relative", - // width 제거 - 그리드 컬럼이 결정 + width: "100%", // 🆕 부모 컨테이너 너비에 맞춤 height: `${component.style?.height || 600}px`, border: "1px solid #e5e7eb", } @@ -66,7 +66,7 @@ export const SplitPanelLayoutComponent: React.FC position: "absolute", left: `${component.style?.positionX || 0}px`, top: `${component.style?.positionY || 0}px`, - width: `${component.style?.width || 1000}px`, + width: "100%", // 🆕 부모 컨테이너 너비에 맞춤 (그리드 기반) height: `${component.style?.height || 600}px`, zIndex: component.style?.positionZ || 1, cursor: isDesignMode ? "pointer" : "default", @@ -272,7 +272,7 @@ export const SplitPanelLayoutComponent: React.FC onClick?.(e); } }} - className={`flex overflow-hidden rounded-lg bg-white shadow-sm ${isPreview ? "w-full" : ""}`} + className="flex w-full overflow-hidden rounded-lg bg-white shadow-sm" > {/* 좌측 패널 */}
Date: Tue, 4 Nov 2025 15:52:41 +0900 Subject: [PATCH 02/59] =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=98=A4=EB=A5=98=EB=82=98?= =?UTF-8?q?=EB=8A=94=EA=B1=B0=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/screen/panels/FileComponentConfigPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/screen/panels/FileComponentConfigPanel.tsx b/frontend/components/screen/panels/FileComponentConfigPanel.tsx index 8db01ad0..f14c861f 100644 --- a/frontend/components/screen/panels/FileComponentConfigPanel.tsx +++ b/frontend/components/screen/panels/FileComponentConfigPanel.tsx @@ -12,7 +12,7 @@ import { Plus, X, Upload, File, Image, FileText, Download, Trash2 } from "lucide import { Button } from "@/components/ui/button"; import { FileInfo, FileUploadResponse } from "@/lib/registry/components/file-upload/types"; import { uploadFiles, downloadFile, deleteFile } from "@/lib/api/file"; -import { formatFileSize } from "@/lib/utils"; +import { formatFileSize, cn } from "@/lib/utils"; import { toast } from "sonner"; interface FileComponentConfigPanelProps { From 01e03dedbfad766772aa2de316c003b25beca10c Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 4 Nov 2025 16:26:53 +0900 Subject: [PATCH 03/59] =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=86=92?= =?UTF-8?q?=EC=9D=B4=20=EC=A1=B0=EC=A0=88=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/screen/RealtimePreviewDynamic.tsx | 15 ++++++++++++--- .../file-upload/FileUploadComponent.tsx | 3 ++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index 72739e71..129d2487 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -222,9 +222,11 @@ export const RealtimePreviewDynamic: React.FC = ({ return `${actualHeight}px`; } - // 1순위: style.height가 있으면 우선 사용 + // 1순위: style.height가 있으면 우선 사용 (문자열 그대로 또는 숫자+px) if (componentStyle?.height) { - return componentStyle.height; + return typeof componentStyle.height === 'number' + ? `${componentStyle.height}px` + : componentStyle.height; } // 2순위: size.height (픽셀) @@ -232,7 +234,14 @@ export const RealtimePreviewDynamic: React.FC = ({ return `${Math.max(size?.height || 200, 200)}px`; } - return `${size?.height || 40}px`; + // 3순위: size.height가 있으면 사용 + if (size?.height) { + return typeof size.height === 'number' + ? `${size.height}px` + : size.height; + } + + return "40px"; }; const baseStyle = { diff --git a/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx b/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx index 1af4b869..5eea6d60 100644 --- a/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx +++ b/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx @@ -901,6 +901,8 @@ const FileUploadComponent: React.FC = ({
= ({
{/* 파일 업로드 영역 - 주석처리 */} {/* {!isDesignMode && ( From 958aeb2d539f9cde83b621c54e0047f07b1b069a Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 4 Nov 2025 17:32:46 +0900 Subject: [PATCH 04/59] =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EC=AA=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../file-upload/FileManagerModal.tsx | 29 +- .../file-upload/FileUploadComponent.tsx | 298 ++++++++++-------- .../registry/components/file-upload/types.ts | 3 + 3 files changed, 194 insertions(+), 136 deletions(-) diff --git a/frontend/lib/registry/components/file-upload/FileManagerModal.tsx b/frontend/lib/registry/components/file-upload/FileManagerModal.tsx index f8c76ca0..cfed8223 100644 --- a/frontend/lib/registry/components/file-upload/FileManagerModal.tsx +++ b/frontend/lib/registry/components/file-upload/FileManagerModal.tsx @@ -17,7 +17,8 @@ import { Music, Archive, Presentation, - X + X, + Star } from "lucide-react"; import { formatFileSize } from "@/lib/utils"; import { FileViewerModal } from "./FileViewerModal"; @@ -30,6 +31,7 @@ interface FileManagerModalProps { onFileDownload: (file: FileInfo) => void; onFileDelete: (file: FileInfo) => void; onFileView: (file: FileInfo) => void; + onSetRepresentative?: (file: FileInfo) => void; // 대표 이미지 설정 콜백 config: FileUploadConfig; isDesignMode?: boolean; } @@ -42,6 +44,7 @@ export const FileManagerModal: React.FC = ({ onFileDownload, onFileDelete, onFileView, + onSetRepresentative, config, isDesignMode = false, }) => { @@ -228,14 +231,32 @@ export const FileManagerModal: React.FC = ({ {getFileIcon(file.fileExt)}
-

- {file.realFileName} -

+
+ + {file.realFileName} + + {file.isRepresentative && ( + + 대표 + + )} +

{formatFileSize(file.fileSize)} • {file.fileExt.toUpperCase()}

+ {onSetRepresentative && ( + + )} + return ( + <> + {isImage && representativeImageUrl ? ( +
+ {representativeFile.realFileName}
-
- - {uploadedFiles.length > 0 ? ( -
- {uploadedFiles.map((file) => ( -
-
{getFileIcon(file.fileExt)}
- handleFileView(file)} - style={{ textShadow: "none" }} - > - {file.realFileName} - - - {formatFileSize(file.fileSize)} - -
- ))} -
- 💡 파일명 클릭으로 미리보기 또는 "전체 자세히보기"로 파일 관리 -
+ ) : isImage && !representativeImageUrl ? ( +
+
+

이미지 로딩 중...

) : ( -
- -

- 업로드된 파일이 없습니다 -

-

- 상세설정에서 파일을 업로드하세요 +

+ {getFileIcon(representativeFile.fileExt)} +

+ {representativeFile.realFileName}

+ + 대표 파일 +
)} -
+ + {/* 우측 하단 자세히보기 버튼 */} +
+ +
+ + ); + })() : ( +
+ +

업로드된 파일이 없습니다

+
)} - - {/* 도움말 텍스트 */} - {safeComponentConfig.helperText && ( -

{safeComponentConfig.helperText}

- )}
{/* 파일뷰어 모달 */} @@ -1098,6 +1131,7 @@ const FileUploadComponent: React.FC = ({ onFileDownload={handleFileDownload} onFileDelete={handleFileDelete} onFileView={handleFileView} + onSetRepresentative={handleSetRepresentative} config={safeComponentConfig} isDesignMode={isDesignMode} /> diff --git a/frontend/lib/registry/components/file-upload/types.ts b/frontend/lib/registry/components/file-upload/types.ts index 15eceab7..109561b4 100644 --- a/frontend/lib/registry/components/file-upload/types.ts +++ b/frontend/lib/registry/components/file-upload/types.ts @@ -30,6 +30,9 @@ export interface FileInfo { type?: string; // docType과 동일 uploadedAt?: string; // regdate와 동일 _file?: File; // 로컬 파일 객체 (업로드 전) + + // 대표 이미지 설정 + isRepresentative?: boolean; // 대표 이미지로 설정 여부 } /** From 10c7c9a0b1cf821deaa080c22f7954941e5437cc Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 4 Nov 2025 17:44:10 +0900 Subject: [PATCH 05/59] =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EB=86=92=EC=9D=B4=20=EC=A1=B0=EC=A0=88=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/screen/ScreenDesigner.tsx | 8 ++++---- .../screen/panels/PropertiesPanel.tsx | 14 +++++++------- .../screen/panels/UnifiedPropertiesPanel.tsx | 6 +++--- frontend/lib/utils/gridUtils.ts | 18 +++++++++--------- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index ed180314..d057930f 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -610,16 +610,16 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const columnIndex = Math.round(effectiveX / (columnWidth + (gap || 16))); const snappedX = padding + columnIndex * (columnWidth + (gap || 16)); - // Y 좌표는 20px 단위로 스냅 + // Y 좌표는 10px 단위로 스냅 const effectiveY = newComp.position.y - padding; - const rowIndex = Math.round(effectiveY / 20); - const snappedY = padding + rowIndex * 20; + const rowIndex = Math.round(effectiveY / 10); + const snappedY = padding + rowIndex * 10; // 크기도 외부 격자와 동일하게 스냅 const fullColumnWidth = columnWidth + (gap || 16); // 외부 격자와 동일한 크기 const widthInColumns = Math.max(1, Math.round(newComp.size.width / fullColumnWidth)); const snappedWidth = widthInColumns * fullColumnWidth - (gap || 16); // gap 제거하여 실제 컴포넌트 크기 - const snappedHeight = Math.max(40, Math.round(newComp.size.height / 20) * 20); + const snappedHeight = Math.max(10, Math.round(newComp.size.height / 10) * 10); newComp.position = { x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보 diff --git a/frontend/components/screen/panels/PropertiesPanel.tsx b/frontend/components/screen/panels/PropertiesPanel.tsx index 88643c60..277c6e9c 100644 --- a/frontend/components/screen/panels/PropertiesPanel.tsx +++ b/frontend/components/screen/panels/PropertiesPanel.tsx @@ -961,27 +961,27 @@ const PropertiesPanelComponent: React.FC = ({
{ - const rows = Math.max(1, Math.min(20, Number(e.target.value))); - const newHeight = rows * 40; + const units = Math.max(1, Math.min(100, Number(e.target.value))); + const newHeight = units * 10; setLocalInputs((prev) => ({ ...prev, height: newHeight.toString() })); onUpdateProperty("size.height", newHeight); }} className="flex-1" /> - 행 = {localInputs.height || 40}px + 단위 = {localInputs.height || 10}px

- 1행 = 40px (현재 {Math.round((localInputs.height || 40) / 40)}행) - 내부 콘텐츠에 맞춰 늘어남 + 1단위 = 10px (현재 {Math.round((localInputs.height || 10) / 10)}단위) - 내부 콘텐츠에 맞춰 늘어남

diff --git a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx index 01716bb0..f2e50db8 100644 --- a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx +++ b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx @@ -364,11 +364,11 @@ export const UnifiedPropertiesPanel: React.FC = ({ value={selectedComponent.size?.height || 0} onChange={(e) => { const value = parseInt(e.target.value) || 0; - const roundedValue = Math.max(40, Math.round(value / 40) * 40); + const roundedValue = Math.max(10, Math.round(value / 10) * 10); handleUpdate("size.height", roundedValue); }} - step={40} - placeholder="40" + step={10} + placeholder="10" className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }} style={{ fontSize: "12px" }} diff --git a/frontend/lib/utils/gridUtils.ts b/frontend/lib/utils/gridUtils.ts index b23c0ec0..419937f0 100644 --- a/frontend/lib/utils/gridUtils.ts +++ b/frontend/lib/utils/gridUtils.ts @@ -44,7 +44,7 @@ export function snapToGrid(position: Position, gridInfo: GridInfo, gridSettings: // 격자 셀 크기 (컬럼 너비 + 간격을 하나의 격자 단위로 계산) const cellWidth = columnWidth + gap; - const cellHeight = Math.max(40, gap * 2); // 행 높이를 더 크게 설정 + const cellHeight = 10; // 행 높이 10px 단위로 고정 // 패딩을 제외한 상대 위치 const relativeX = position.x - padding; @@ -92,9 +92,9 @@ export function snapSizeToGrid(size: Size, gridInfo: GridInfo, gridSettings: Gri const snappedWidth = gridColumns * columnWidth + (gridColumns - 1) * gap; - // 높이는 동적 행 높이 단위로 스냅 - const rowHeight = Math.max(20, gap); - const snappedHeight = Math.max(40, Math.round(size.height / rowHeight) * rowHeight); + // 높이는 10px 단위로 스냅 + const rowHeight = 10; + const snappedHeight = Math.max(10, Math.round(size.height / rowHeight) * rowHeight); console.log( `📏 크기 스냅: ${size.width}px → ${snappedWidth}px (${gridColumns}컬럼, 컬럼너비:${columnWidth}px, 간격:${gap}px)`, @@ -175,7 +175,7 @@ export function generateGridLines( // 격자 셀 크기 (스냅 로직과 동일하게) const cellWidth = columnWidth + gap; - const cellHeight = Math.max(40, gap * 2); + const cellHeight = 10; // 행 높이 10px 단위로 고정 // 세로 격자선 const verticalLines: number[] = []; @@ -254,8 +254,8 @@ export function alignGroupChildrenToGrid( const columnIndex = Math.round(effectiveX / (columnWidth + gap)); const snappedX = padding + columnIndex * (columnWidth + gap); - // Y 좌표는 동적 행 높이 단위로 스냅 - const rowHeight = Math.max(20, gap); + // Y 좌표는 10px 단위로 스냅 + const rowHeight = 10; const effectiveY = child.position.y - padding; const rowIndex = Math.round(effectiveY / rowHeight); const snappedY = padding + rowIndex * rowHeight; @@ -264,7 +264,7 @@ export function alignGroupChildrenToGrid( const fullColumnWidth = columnWidth + gap; // 외부 격자와 동일한 크기 const widthInColumns = Math.max(1, Math.round(child.size.width / fullColumnWidth)); const snappedWidth = widthInColumns * fullColumnWidth - gap; // gap 제거하여 실제 컴포넌트 크기 - const snappedHeight = Math.max(40, Math.round(child.size.height / rowHeight) * rowHeight); + const snappedHeight = Math.max(10, Math.round(child.size.height / rowHeight) * rowHeight); const snappedChild = { ...child, @@ -310,7 +310,7 @@ export function calculateOptimalGroupSize( gridSettings: GridSettings, ): Size { if (children.length === 0) { - return { width: gridInfo.columnWidth * 2, height: 40 * 2 }; + return { width: gridInfo.columnWidth * 2, height: 10 * 4 }; } console.log("📏 calculateOptimalGroupSize 시작:", { From f4fd1184cd40ecc4960634b6849d3a4e10d2cc4e Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 4 Nov 2025 17:48:22 +0900 Subject: [PATCH 06/59] =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B0=84=EA=B2=A9=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../table-list/TableListComponent.tsx | 7 ++-- .../table-list/TableListConfigPanel.tsx | 35 +++++++++++++++++++ .../registry/components/table-list/index.ts | 1 + .../registry/components/table-list/types.ts | 2 ++ 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 46c03aef..08607e92 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -1169,7 +1169,7 @@ export const TableListComponent: React.FC = ({
)} -
+
= ({ )} {/* 테이블 컨테이너 */} -
+
{/* 스크롤 영역 */}
= ({
)} + + {/* 필터 간격 설정 */} + {config.filter?.enabled && ( +
+
+

필터 간격

+
+
+
+ + { + const value = Math.max(0, Math.min(200, parseInt(e.target.value) || 40)); + handleChange("filter", { + ...config.filter, + bottomSpacing: value, + }); + }} + min={0} + max={200} + step={10} + placeholder="40" + className="h-8 text-xs" + /> +

+ 기본값: 40px (0-200px 범위, 10px 단위 권장) +

+
+
+ )}
); diff --git a/frontend/lib/registry/components/table-list/index.ts b/frontend/lib/registry/components/table-list/index.ts index cc54085b..9a68a3c4 100644 --- a/frontend/lib/registry/components/table-list/index.ts +++ b/frontend/lib/registry/components/table-list/index.ts @@ -72,6 +72,7 @@ export const TableListDefinition = createComponentDefinition({ filter: { enabled: true, filters: [], // 사용자가 설정할 필터 목록 + bottomSpacing: 40, // 필터와 리스트 사이 간격 (px) }, // 액션 설정 diff --git a/frontend/lib/registry/components/table-list/types.ts b/frontend/lib/registry/components/table-list/types.ts index f1422205..053d6fb1 100644 --- a/frontend/lib/registry/components/table-list/types.ts +++ b/frontend/lib/registry/components/table-list/types.ts @@ -116,6 +116,8 @@ export interface FilterConfig { referenceColumn?: string; displayColumn?: string; }>; + // 필터와 리스트 사이 간격 (px 단위, 기본: 40) + bottomSpacing?: number; } /** From acaa3414d275d3df1bdc3f477d75b5bb71797c09 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 4 Nov 2025 17:57:28 +0900 Subject: [PATCH 07/59] =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=ED=9A=8C=EC=82=AC=EB=B3=84=EB=A1=9C=20=EB=B3=B4?= =?UTF-8?q?=EC=9D=B4=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 --- .../src/controllers/fileController.ts | 126 +++++++++++++----- frontend/lib/api/file.ts | 2 + .../file-upload/FileUploadComponent.tsx | 113 ++++++++-------- 3 files changed, 154 insertions(+), 87 deletions(-) diff --git a/backend-node/src/controllers/fileController.ts b/backend-node/src/controllers/fileController.ts index d138bce3..dfceca89 100644 --- a/backend-node/src/controllers/fileController.ts +++ b/backend-node/src/controllers/fileController.ts @@ -232,13 +232,20 @@ export const uploadFiles = async ( // 자동 연결 로직 - target_objid 자동 생성 let finalTargetObjid = targetObjid; - if (autoLink === "true" && linkedTable && recordId) { + + // 🔑 템플릿 파일(screen_files:)이나 temp_ 파일은 autoLink 무시 + const isTemplateFile = targetObjid && (targetObjid.startsWith('screen_files:') || targetObjid.startsWith('temp_')); + + if (!isTemplateFile && autoLink === "true" && linkedTable && recordId) { // 가상 파일 컬럼의 경우 컬럼명도 포함한 target_objid 생성 if (isVirtualFileColumn === "true" && columnName) { finalTargetObjid = `${linkedTable}:${recordId}:${columnName}`; } else { finalTargetObjid = `${linkedTable}:${recordId}`; } + console.log("📎 autoLink 적용:", { original: targetObjid, final: finalTargetObjid }); + } else if (isTemplateFile) { + console.log("🎨 템플릿 파일이므로 targetObjid 유지:", targetObjid); } const savedFiles = []; @@ -363,6 +370,38 @@ export const deleteFile = async ( const { objid } = req.params; const { writer = "system" } = req.body; + // 🔒 멀티테넌시: 현재 사용자의 회사 코드 + const companyCode = req.user?.companyCode; + + // 파일 정보 조회 + const fileRecord = await queryOne( + `SELECT * FROM attach_file_info WHERE objid = $1`, + [parseInt(objid)] + ); + + if (!fileRecord) { + res.status(404).json({ + success: false, + message: "파일을 찾을 수 없습니다.", + }); + return; + } + + // 🔒 멀티테넌시: 회사 코드 일치 여부 확인 (최고 관리자 제외) + if (companyCode !== "*" && fileRecord.company_code !== companyCode) { + console.warn("⚠️ 다른 회사 파일 삭제 시도:", { + userId: req.user?.userId, + userCompanyCode: companyCode, + fileCompanyCode: fileRecord.company_code, + objid, + }); + res.status(403).json({ + success: false, + message: "접근 권한이 없습니다.", + }); + return; + } + // 파일 상태를 DELETED로 변경 (논리적 삭제) await query( "UPDATE attach_file_info SET status = $1 WHERE objid = $2", @@ -510,6 +549,9 @@ export const getComponentFiles = async ( const { screenId, componentId, tableName, recordId, columnName } = req.query; + // 🔒 멀티테넌시: 현재 사용자의 회사 코드 가져오기 + const companyCode = req.user?.companyCode; + console.log("📂 [getComponentFiles] API 호출:", { screenId, componentId, @@ -517,6 +559,7 @@ export const getComponentFiles = async ( recordId, columnName, user: req.user?.userId, + companyCode, // 🔒 멀티테넌시 로그 }); if (!screenId || !componentId) { @@ -534,32 +577,16 @@ export const getComponentFiles = async ( templateTargetObjid, }); - // 모든 파일 조회해서 실제 저장된 target_objid 패턴 확인 - const allFiles = await query( - `SELECT target_objid, real_file_name, regdate - FROM attach_file_info - WHERE status = $1 - ORDER BY regdate DESC - LIMIT 10`, - ["ACTIVE"] - ); - console.log( - "🗂️ [getComponentFiles] 최근 저장된 파일들의 target_objid:", - allFiles.map((f) => ({ - target_objid: f.target_objid, - name: f.real_file_name, - })) - ); - + // 🔒 멀티테넌시: 회사별 필터링 추가 const templateFiles = await query( `SELECT * FROM attach_file_info - WHERE target_objid = $1 AND status = $2 + WHERE target_objid = $1 AND status = $2 AND company_code = $3 ORDER BY regdate DESC`, - [templateTargetObjid, "ACTIVE"] + [templateTargetObjid, "ACTIVE", companyCode] ); console.log( - "📁 [getComponentFiles] 템플릿 파일 결과:", + "📁 [getComponentFiles] 템플릿 파일 결과 (회사별 필터링):", templateFiles.length ); @@ -567,11 +594,12 @@ export const getComponentFiles = async ( let dataFiles: any[] = []; if (tableName && recordId && columnName) { const dataTargetObjid = `${tableName}:${recordId}:${columnName}`; + // 🔒 멀티테넌시: 회사별 필터링 추가 dataFiles = await query( `SELECT * FROM attach_file_info - WHERE target_objid = $1 AND status = $2 + WHERE target_objid = $1 AND status = $2 AND company_code = $3 ORDER BY regdate DESC`, - [dataTargetObjid, "ACTIVE"] + [dataTargetObjid, "ACTIVE", companyCode] ); } @@ -643,6 +671,9 @@ export const previewFile = async ( const { objid } = req.params; const { serverFilename } = req.query; + // 🔒 멀티테넌시: 현재 사용자의 회사 코드 + const companyCode = req.user?.companyCode; + const fileRecord = await queryOne( "SELECT * FROM attach_file_info WHERE objid = $1 LIMIT 1", [parseInt(objid)] @@ -656,13 +687,28 @@ export const previewFile = async ( return; } + // 🔒 멀티테넌시: 회사 코드 일치 여부 확인 (최고 관리자 제외) + if (companyCode !== "*" && fileRecord.company_code !== companyCode) { + console.warn("⚠️ 다른 회사 파일 접근 시도:", { + userId: req.user?.userId, + userCompanyCode: companyCode, + fileCompanyCode: fileRecord.company_code, + objid, + }); + res.status(403).json({ + success: false, + message: "접근 권한이 없습니다.", + }); + return; + } + // 파일 경로에서 회사코드와 날짜 폴더 추출 const filePathParts = fileRecord.file_path!.split("/"); - let companyCode = filePathParts[2] || "DEFAULT"; + let fileCompanyCode = filePathParts[2] || "DEFAULT"; // company_* 처리 (실제 회사 코드로 변환) - if (companyCode === "company_*") { - companyCode = "company_*"; // 실제 디렉토리명 유지 + if (fileCompanyCode === "company_*") { + fileCompanyCode = "company_*"; // 실제 디렉토리명 유지 } const fileName = fileRecord.saved_file_name!; @@ -674,7 +720,7 @@ export const previewFile = async ( } const companyUploadDir = getCompanyUploadDir( - companyCode, + fileCompanyCode, dateFolder || undefined ); const filePath = path.join(companyUploadDir, fileName); @@ -762,6 +808,9 @@ export const downloadFile = async ( try { const { objid } = req.params; + // 🔒 멀티테넌시: 현재 사용자의 회사 코드 + const companyCode = req.user?.companyCode; + const fileRecord = await queryOne( `SELECT * FROM attach_file_info WHERE objid = $1`, [parseInt(objid)] @@ -775,13 +824,28 @@ export const downloadFile = async ( return; } + // 🔒 멀티테넌시: 회사 코드 일치 여부 확인 (최고 관리자 제외) + if (companyCode !== "*" && fileRecord.company_code !== companyCode) { + console.warn("⚠️ 다른 회사 파일 다운로드 시도:", { + userId: req.user?.userId, + userCompanyCode: companyCode, + fileCompanyCode: fileRecord.company_code, + objid, + }); + res.status(403).json({ + success: false, + message: "접근 권한이 없습니다.", + }); + return; + } + // 파일 경로에서 회사코드와 날짜 폴더 추출 (예: /uploads/company_*/2025/09/05/timestamp_filename.ext) const filePathParts = fileRecord.file_path!.split("/"); - let companyCode = filePathParts[2] || "DEFAULT"; // /uploads/company_*/2025/09/05/filename.ext에서 company_* 추출 + let fileCompanyCode = filePathParts[2] || "DEFAULT"; // /uploads/company_*/2025/09/05/filename.ext에서 company_* 추출 // company_* 처리 (실제 회사 코드로 변환) - if (companyCode === "company_*") { - companyCode = "company_*"; // 실제 디렉토리명 유지 + if (fileCompanyCode === "company_*") { + fileCompanyCode = "company_*"; // 실제 디렉토리명 유지 } const fileName = fileRecord.saved_file_name!; @@ -794,7 +858,7 @@ export const downloadFile = async ( } const companyUploadDir = getCompanyUploadDir( - companyCode, + fileCompanyCode, dateFolder || undefined ); const filePath = path.join(companyUploadDir, fileName); diff --git a/frontend/lib/api/file.ts b/frontend/lib/api/file.ts index 70564f5b..8cba4e60 100644 --- a/frontend/lib/api/file.ts +++ b/frontend/lib/api/file.ts @@ -42,6 +42,7 @@ export const uploadFiles = async (params: { autoLink?: boolean; columnName?: string; isVirtualFileColumn?: boolean; + companyCode?: string; // 🔒 멀티테넌시: 회사 코드 }): Promise => { const formData = new FormData(); @@ -64,6 +65,7 @@ export const uploadFiles = async (params: { if (params.autoLink !== undefined) formData.append("autoLink", params.autoLink.toString()); if (params.columnName) formData.append("columnName", params.columnName); if (params.isVirtualFileColumn !== undefined) formData.append("isVirtualFileColumn", params.isVirtualFileColumn.toString()); + if (params.companyCode) formData.append("companyCode", params.companyCode); // 🔒 멀티테넌시 const response = await apiClient.post("/files/upload", formData, { headers: { diff --git a/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx b/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx index b68cf529..f79bb12b 100644 --- a/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx +++ b/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx @@ -204,24 +204,37 @@ const FileUploadComponent: React.FC = ({ // 템플릿 파일과 데이터 파일을 조회하는 함수 const loadComponentFiles = useCallback(async () => { - if (!component?.id) return; + if (!component?.id) return false; try { - let screenId = - formData?.screenId || - (typeof window !== "undefined" && window.location.pathname.includes("/screens/") - ? parseInt(window.location.pathname.split("/screens/")[1]) - : null); - - // 디자인 모드인 경우 기본 화면 ID 사용 - if (!screenId && isDesignMode) { - screenId = 40; // 기본 화면 ID - console.log("📂 디자인 모드: 기본 화면 ID 사용 (40)"); + // 1. formData에서 screenId 가져오기 + let screenId = formData?.screenId; + + // 2. URL에서 screenId 추출 (/screens/:id 패턴) + if (!screenId && typeof window !== "undefined") { + const pathname = window.location.pathname; + const screenMatch = pathname.match(/\/screens\/(\d+)/); + if (screenMatch) { + screenId = parseInt(screenMatch[1]); + console.log("📂 URL에서 화면 ID 추출:", screenId); + } } + // 3. 디자인 모드인 경우 임시 화면 ID 사용 + if (!screenId && isDesignMode) { + screenId = 999999; // 디자인 모드 임시 ID + console.log("📂 디자인 모드: 임시 화면 ID 사용 (999999)"); + } + + // 4. 화면 ID가 없으면 컴포넌트 ID만으로 조회 시도 if (!screenId) { - console.log("📂 화면 ID 없음, 기존 파일 로직 사용"); - return false; // 기존 로직 사용 + console.warn("⚠️ 화면 ID 없음, 컴포넌트 ID만으로 파일 조회:", { + componentId: component.id, + pathname: window.location.pathname, + formData: formData, + }); + // screenId를 0으로 설정하여 컴포넌트 ID로만 조회 + screenId = 0; } const params = { @@ -229,7 +242,7 @@ const FileUploadComponent: React.FC = ({ componentId: component.id, tableName: formData?.tableName || component.tableName, recordId: formData?.id, - columnName: component.columnName, + columnName: component.columnName || component.id, // 🔑 columnName이 없으면 component.id 사용 }; console.log("📂 컴포넌트 파일 조회:", params); @@ -319,7 +332,7 @@ const FileUploadComponent: React.FC = ({ return false; // 기존 로직 사용 }, [component.id, component.tableName, component.columnName, formData?.screenId, formData?.tableName, formData?.id]); - // 컴포넌트 파일 동기화 + // 컴포넌트 파일 동기화 (DB 우선, localStorage는 보조) useEffect(() => { const componentFiles = (component as any)?.uploadedFiles || []; const lastUpdate = (component as any)?.lastFileUpdate; @@ -332,15 +345,15 @@ const FileUploadComponent: React.FC = ({ currentUploadedFiles: uploadedFiles.length, }); - // 먼저 새로운 템플릿 파일 조회 시도 - loadComponentFiles().then((useNewLogic) => { - if (useNewLogic) { - console.log("✅ 새로운 템플릿 파일 로직 사용"); - return; // 새로운 로직이 성공했으면 기존 로직 스킵 + // 🔒 항상 DB에서 최신 파일 목록을 조회 (멀티테넌시 격리) + loadComponentFiles().then((dbLoadSuccess) => { + if (dbLoadSuccess) { + console.log("✅ DB에서 파일 로드 성공 (멀티테넌시 적용)"); + return; // DB 로드 성공 시 localStorage 무시 } - // 기존 로직 사용 - console.log("📂 기존 파일 로직 사용"); + // DB 로드 실패 시에만 기존 로직 사용 (하위 호환성) + console.log("📂 DB 로드 실패, 기존 로직 사용"); // 전역 상태에서 최신 파일 정보 가져오기 const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {}; @@ -358,34 +371,6 @@ const FileUploadComponent: React.FC = ({ lastUpdate: lastUpdate, }); - // localStorage에서 백업 파일 복원 (새로고침 시 중요!) - try { - const backupKey = `fileUpload_${component.id}`; - const backupFiles = localStorage.getItem(backupKey); - if (backupFiles) { - const parsedFiles = JSON.parse(backupFiles); - if (parsedFiles.length > 0 && currentFiles.length === 0) { - console.log("🔄 localStorage에서 파일 복원:", { - componentId: component.id, - restoredFiles: parsedFiles.length, - files: parsedFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName })), - }); - setUploadedFiles(parsedFiles); - - // 전역 상태에도 복원 - if (typeof window !== "undefined") { - (window as any).globalFileState = { - ...(window as any).globalFileState, - [component.id]: parsedFiles, - }; - } - return; - } - } - } catch (e) { - console.warn("localStorage 백업 복원 실패:", e); - } - // 최신 파일과 현재 파일 비교 if (JSON.stringify(currentFiles) !== JSON.stringify(uploadedFiles)) { console.log("🔄 useEffect에서 파일 목록 변경 감지:", { @@ -535,24 +520,39 @@ const FileUploadComponent: React.FC = ({ // targetObjid 생성 - 템플릿 vs 데이터 파일 구분 const tableName = formData?.tableName || component.tableName || "default_table"; const recordId = formData?.id; - const screenId = formData?.screenId; const columnName = component.columnName || component.id; + + // screenId 추출 (우선순위: formData > URL) + let screenId = formData?.screenId; + if (!screenId && typeof window !== "undefined") { + const pathname = window.location.pathname; + const screenMatch = pathname.match(/\/screens\/(\d+)/); + if (screenMatch) { + screenId = parseInt(screenMatch[1]); + } + } let targetObjid; - if (recordId && tableName) { - // 실제 데이터 파일 + // 우선순위: 1) 실제 데이터 (recordId가 숫자/문자열이고 temp_가 아님) > 2) 템플릿 (screenId) > 3) 기본값 + const isRealRecord = recordId && typeof recordId !== 'undefined' && !String(recordId).startsWith('temp_'); + + if (isRealRecord && tableName) { + // 실제 데이터 파일 (진짜 레코드 ID가 있을 때만) targetObjid = `${tableName}:${recordId}:${columnName}`; console.log("📁 실제 데이터 파일 업로드:", targetObjid); } else if (screenId) { - // 템플릿 파일 - targetObjid = `screen_${screenId}:${component.id}`; - console.log("🎨 템플릿 파일 업로드:", targetObjid); + // 🔑 템플릿 파일 (백엔드 조회 형식과 동일하게) + targetObjid = `screen_files:${screenId}:${component.id}:${columnName}`; + console.log("🎨 템플릿 파일 업로드:", { targetObjid, screenId, componentId: component.id, columnName }); } else { // 기본값 (화면관리에서 사용) targetObjid = `temp_${component.id}`; console.log("📝 기본 파일 업로드:", targetObjid); } + // 🔒 현재 사용자의 회사 코드 가져오기 (멀티테넌시 격리) + const userCompanyCode = (window as any).__user__?.companyCode; + const uploadData = { // 🎯 formData에서 백엔드 API 설정 가져오기 autoLink: formData?.autoLink || true, @@ -562,6 +562,7 @@ const FileUploadComponent: React.FC = ({ isVirtualFileColumn: formData?.isVirtualFileColumn || true, docType: component.fileConfig?.docType || "DOCUMENT", docTypeName: component.fileConfig?.docTypeName || "일반 문서", + companyCode: userCompanyCode, // 🔒 멀티테넌시: 회사 코드 명시적 전달 // 호환성을 위한 기존 필드들 tableName: tableName, fieldName: columnName, From 63b6e894356faa2859ee5f2f213b5d2ae92b80b7 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 4 Nov 2025 18:02:20 +0900 Subject: [PATCH 08/59] =?UTF-8?q?=EB=94=94=EB=B2=84=EA=B9=85=EC=9A=A9=20co?= =?UTF-8?q?nsole.log=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/fileController.ts | 3 - .../file-upload/FileUploadComponent.tsx | 77 ------------------- 2 files changed, 80 deletions(-) diff --git a/backend-node/src/controllers/fileController.ts b/backend-node/src/controllers/fileController.ts index dfceca89..2feb6bfd 100644 --- a/backend-node/src/controllers/fileController.ts +++ b/backend-node/src/controllers/fileController.ts @@ -243,9 +243,6 @@ export const uploadFiles = async ( } else { finalTargetObjid = `${linkedTable}:${recordId}`; } - console.log("📎 autoLink 적용:", { original: targetObjid, final: finalTargetObjid }); - } else if (isTemplateFile) { - console.log("🎨 템플릿 파일이므로 targetObjid 유지:", targetObjid); } const savedFiles = []; diff --git a/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx b/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx index f79bb12b..2ae20180 100644 --- a/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx +++ b/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx @@ -148,12 +148,6 @@ const FileUploadComponent: React.FC = ({ // 현재 컴포넌트와 일치하고 화면설계 모드에서 온 이벤트인 경우 if (event.detail.componentId === component.id && event.detail.source === "designMode") { - console.log("✅✅✅ 화면설계 모드 → 실제 화면 파일 동기화 시작:", { - componentId: component.id, - filesCount: event.detail.files?.length || 0, - action: event.detail.action, - }); - // 파일 상태 업데이트 const newFiles = event.detail.files || []; setUploadedFiles(newFiles); @@ -216,14 +210,12 @@ const FileUploadComponent: React.FC = ({ const screenMatch = pathname.match(/\/screens\/(\d+)/); if (screenMatch) { screenId = parseInt(screenMatch[1]); - console.log("📂 URL에서 화면 ID 추출:", screenId); } } // 3. 디자인 모드인 경우 임시 화면 ID 사용 if (!screenId && isDesignMode) { screenId = 999999; // 디자인 모드 임시 ID - console.log("📂 디자인 모드: 임시 화면 ID 사용 (999999)"); } // 4. 화면 ID가 없으면 컴포넌트 ID만으로 조회 시도 @@ -245,18 +237,9 @@ const FileUploadComponent: React.FC = ({ columnName: component.columnName || component.id, // 🔑 columnName이 없으면 component.id 사용 }; - console.log("📂 컴포넌트 파일 조회:", params); - const response = await getComponentFiles(params); if (response.success) { - console.log("📁 파일 조회 결과:", { - templateFiles: response.templateFiles.length, - dataFiles: response.dataFiles.length, - totalFiles: response.totalFiles.length, - summary: response.summary, - actualFiles: response.totalFiles, - }); // 파일 데이터 형식 통일 const formattedFiles = response.totalFiles.map((file: any) => ({ @@ -271,7 +254,6 @@ const FileUploadComponent: React.FC = ({ ...file, })); - console.log("📁 형식 변환된 파일 데이터:", formattedFiles); // 🔄 localStorage의 기존 파일과 서버 파일 병합 let finalFiles = formattedFiles; @@ -287,13 +269,6 @@ const FileUploadComponent: React.FC = ({ finalFiles = [...formattedFiles, ...additionalFiles]; - console.log("🔄 파일 병합 완료:", { - 서버파일: formattedFiles.length, - 로컬파일: parsedBackupFiles.length, - 추가파일: additionalFiles.length, - 최종파일: finalFiles.length, - 최종파일목록: finalFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName })), - }); } } catch (e) { console.warn("파일 병합 중 오류:", e); @@ -319,7 +294,6 @@ const FileUploadComponent: React.FC = ({ try { const backupKey = `fileUpload_${component.id}`; localStorage.setItem(backupKey, JSON.stringify(finalFiles)); - console.log("💾 localStorage 백업 업데이트 완료:", finalFiles.length); } catch (e) { console.warn("localStorage 백업 업데이트 실패:", e); } @@ -348,12 +322,10 @@ const FileUploadComponent: React.FC = ({ // 🔒 항상 DB에서 최신 파일 목록을 조회 (멀티테넌시 격리) loadComponentFiles().then((dbLoadSuccess) => { if (dbLoadSuccess) { - console.log("✅ DB에서 파일 로드 성공 (멀티테넌시 적용)"); return; // DB 로드 성공 시 localStorage 무시 } // DB 로드 실패 시에만 기존 로직 사용 (하위 호환성) - console.log("📂 DB 로드 실패, 기존 로직 사용"); // 전역 상태에서 최신 파일 정보 가져오기 const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {}; @@ -362,23 +334,9 @@ const FileUploadComponent: React.FC = ({ // 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성) const currentFiles = globalFiles.length > 0 ? globalFiles : componentFiles; - console.log("🔄 FileUploadComponent 파일 동기화:", { - componentId: component.id, - componentFiles: componentFiles.length, - globalFiles: globalFiles.length, - currentFiles: currentFiles.length, - uploadedFiles: uploadedFiles.length, - lastUpdate: lastUpdate, - }); // 최신 파일과 현재 파일 비교 if (JSON.stringify(currentFiles) !== JSON.stringify(uploadedFiles)) { - console.log("🔄 useEffect에서 파일 목록 변경 감지:", { - currentFiles: currentFiles.length, - uploadedFiles: uploadedFiles.length, - currentFilesData: currentFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName })), - uploadedFilesData: uploadedFiles.map((f) => ({ objid: f.objid, name: f.realFileName })), - }); setUploadedFiles(currentFiles); setForceUpdate((prev) => prev + 1); } @@ -476,28 +434,15 @@ const FileUploadComponent: React.FC = ({ const duplicates: string[] = []; const uniqueFiles: File[] = []; - console.log("🔍 중복 파일 체크:", { - uploadedFiles: uploadedFiles.length, - existingFileNames: existingFileNames, - newFiles: files.map((f) => f.name.toLowerCase()), - }); - files.forEach((file) => { const fileName = file.name.toLowerCase(); if (existingFileNames.includes(fileName)) { duplicates.push(file.name); - console.log("❌ 중복 파일 발견:", file.name); } else { uniqueFiles.push(file); - console.log("✅ 새로운 파일:", file.name); } }); - console.log("🔍 중복 체크 결과:", { - duplicates: duplicates, - uniqueFiles: uniqueFiles.map((f) => f.name), - }); - if (duplicates.length > 0) { toast.error(`중복된 파일이 있습니다: ${duplicates.join(", ")}`, { description: "같은 이름의 파일이 이미 업로드되어 있습니다.", @@ -543,7 +488,6 @@ const FileUploadComponent: React.FC = ({ } else if (screenId) { // 🔑 템플릿 파일 (백엔드 조회 형식과 동일하게) targetObjid = `screen_files:${screenId}:${component.id}:${columnName}`; - console.log("🎨 템플릿 파일 업로드:", { targetObjid, screenId, componentId: component.id, columnName }); } else { // 기본값 (화면관리에서 사용) targetObjid = `temp_${component.id}`; @@ -569,30 +513,16 @@ const FileUploadComponent: React.FC = ({ targetObjid: targetObjid, // InteractiveDataTable 호환을 위한 targetObjid 추가 }; - console.log("📤 파일 업로드 시작:", { - originalFiles: files.length, - filesToUpload: filesToUpload.length, - files: filesToUpload.map((f) => ({ name: f.name, size: f.size })), - uploadData, - }); const response = await uploadFiles({ files: filesToUpload, ...uploadData, }); - console.log("📤 파일 업로드 API 응답:", response); if (response.success) { // FileUploadResponse 타입에 맞게 files 배열 사용 const fileData = response.files || (response as any).data || []; - console.log("📁 파일 데이터 확인:", { - hasFiles: !!response.files, - hasData: !!(response as any).data, - fileDataLength: fileData.length, - fileData: fileData, - responseKeys: Object.keys(response), - }); if (fileData.length === 0) { throw new Error("업로드된 파일 데이터를 받지 못했습니다."); @@ -617,15 +547,8 @@ const FileUploadComponent: React.FC = ({ ...file, })); - console.log("📁 변환된 파일 데이터:", newFiles); const updatedFiles = [...uploadedFiles, ...newFiles]; - console.log("🔄 파일 상태 업데이트:", { - 이전파일수: uploadedFiles.length, - 새파일수: newFiles.length, - 총파일수: updatedFiles.length, - updatedFiles: updatedFiles.map((f) => ({ objid: f.objid, name: f.realFileName })), - }); setUploadedFiles(updatedFiles); setUploadStatus("success"); From 82ff18e38841516f5200c806fe64846f923bcacb Mon Sep 17 00:00:00 2001 From: leeheejin Date: Tue, 4 Nov 2025 18:31:26 +0900 Subject: [PATCH 09/59] =?UTF-8?q?=ED=96=89=20=EC=9D=B4=EB=8F=99=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=ED=95=A0=EB=8B=B9=ED=95=9C=20=EC=83=81?= =?UTF-8?q?=ED=99=A9=EC=97=90=EC=84=9C=EB=8F=84=20=EA=B0=80=EB=8A=A5?= =?UTF-8?q?=ED=95=98=EA=B2=8C,=20=EC=BD=94=EB=93=9C=EB=B3=91=ED=95=A9=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=EC=95=A1=EC=85=98=EC=97=90=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 --- backend-node/src/app.ts | 4 +- .../src/controllers/adminController.ts | 81 + .../src/controllers/codeMergeController.ts | 282 ++ backend-node/src/routes/adminRoutes.ts | 4 + backend-node/src/routes/codeMergeRoutes.ts | 35 + docs/품목정보.html | 3915 +++++++++++++++++ .../app/(main)/screens/[screenId]/page.tsx | 31 +- .../components/common/ExcelUploadModal.tsx | 905 +++- .../components/screen/RealtimePreview.tsx | 15 +- .../screen/RealtimePreviewDynamic.tsx | 11 + .../config-panels/ButtonConfigPanel.tsx | 48 + frontend/lib/api/tableSchema.ts | 45 + .../lib/hooks/useEntityJoinOptimization.ts | 2 +- .../lib/registry/DynamicComponentRenderer.tsx | 16 +- .../registry/components/WidgetRenderer.tsx | 1 + .../button-primary/ButtonPrimaryComponent.tsx | 12 + .../table-list/SingleTableWithSticky.tsx | 42 +- .../table-list/TableListComponent.tsx | 181 +- frontend/lib/utils/buttonActions.ts | 270 +- 19 files changed, 5655 insertions(+), 245 deletions(-) create mode 100644 backend-node/src/controllers/codeMergeController.ts create mode 100644 backend-node/src/routes/codeMergeRoutes.ts create mode 100644 docs/품목정보.html create mode 100644 frontend/lib/api/tableSchema.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 131b9e1a..fd0f1ea8 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -64,8 +64,8 @@ import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRou import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리 import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회 import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리 -import numberingRuleController from "./controllers/numberingRuleController"; // 채번 규칙 관리 import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리 +import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -224,8 +224,8 @@ app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리 app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회 app.use("/api/roles", roleRoutes); // 권한 그룹 관리 -app.use("/api/numbering-rules", numberingRuleController); // 채번 규칙 관리 app.use("/api/departments", departmentRoutes); // 부서 관리 +app.use("/api/code-merge", codeMergeRoutes); // 코드 병합 // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index f79aec69..f2378fe1 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -3084,3 +3084,84 @@ export const resetUserPassword = async ( }); } }; + +/** + * 테이블 스키마 조회 (엑셀 업로드 컬럼 매핑용) + */ +export async function getTableSchema( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const companyCode = req.user?.companyCode; + + if (!tableName) { + res.status(400).json({ + success: false, + message: "테이블명이 필요합니다.", + }); + return; + } + + logger.info("테이블 스키마 조회", { tableName, companyCode }); + + // information_schema에서 컬럼 정보 가져오기 + const schemaQuery = ` + SELECT + column_name, + data_type, + is_nullable, + column_default, + character_maximum_length, + numeric_precision, + numeric_scale + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = $1 + ORDER BY ordinal_position + `; + + const columns = await query(schemaQuery, [tableName]); + + if (columns.length === 0) { + res.status(404).json({ + success: false, + message: `테이블 '${tableName}'을 찾을 수 없습니다.`, + }); + return; + } + + // 컬럼 정보를 간단한 형태로 변환 + const columnList = columns.map((col: any) => ({ + name: col.column_name, + type: col.data_type, + nullable: col.is_nullable === "YES", + default: col.column_default, + maxLength: col.character_maximum_length, + precision: col.numeric_precision, + scale: col.numeric_scale, + })); + + logger.info(`테이블 스키마 조회 성공: ${columnList.length}개 컬럼`); + + res.json({ + success: true, + message: "테이블 스키마 조회 성공", + data: { + tableName, + columns: columnList, + }, + }); + } catch (error) { + logger.error("테이블 스키마 조회 중 오류 발생:", error); + res.status(500).json({ + success: false, + message: "테이블 스키마 조회 중 오류가 발생했습니다.", + error: { + code: "TABLE_SCHEMA_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +} diff --git a/backend-node/src/controllers/codeMergeController.ts b/backend-node/src/controllers/codeMergeController.ts new file mode 100644 index 00000000..e7658253 --- /dev/null +++ b/backend-node/src/controllers/codeMergeController.ts @@ -0,0 +1,282 @@ +import { Request, Response } from "express"; +import pool from "../database/db"; +import { logger } from "../utils/logger"; + +interface AuthenticatedRequest extends Request { + user?: { + userId: string; + userName: string; + companyCode: string; + }; +} + +/** + * 코드 병합 - 모든 관련 테이블에 적용 + * 데이터(레코드)는 삭제하지 않고, 컬럼 값만 변경 + */ +export async function mergeCodeAllTables( + req: AuthenticatedRequest, + res: Response +): Promise { + const { columnName, oldValue, newValue } = req.body; + const companyCode = req.user?.companyCode; + + try { + // 입력값 검증 + if (!columnName || !oldValue || !newValue) { + res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (columnName, oldValue, newValue)", + }); + return; + } + + if (!companyCode) { + res.status(401).json({ + success: false, + message: "인증 정보가 없습니다.", + }); + return; + } + + // 같은 값으로 병합 시도 방지 + if (oldValue === newValue) { + res.status(400).json({ + success: false, + message: "기존 값과 새 값이 동일합니다.", + }); + return; + } + + logger.info("코드 병합 시작", { + columnName, + oldValue, + newValue, + companyCode, + userId: req.user?.userId, + }); + + // PostgreSQL 함수 호출 + const result = await pool.query( + "SELECT * FROM merge_code_all_tables($1, $2, $3, $4)", + [columnName, oldValue, newValue, companyCode] + ); + + // 결과 처리 (pool.query 반환 타입 처리) + const affectedTables = Array.isArray(result) ? result : (result.rows || []); + const totalRows = affectedTables.reduce( + (sum, row) => sum + parseInt(row.rows_updated || 0), + 0 + ); + + logger.info("코드 병합 완료", { + columnName, + oldValue, + newValue, + affectedTablesCount: affectedTables.length, + totalRowsUpdated: totalRows, + }); + + res.json({ + success: true, + message: `코드 병합 완료: ${oldValue} → ${newValue}`, + data: { + columnName, + oldValue, + newValue, + affectedTables: affectedTables.map((row) => ({ + tableName: row.table_name, + rowsUpdated: parseInt(row.rows_updated), + })), + totalRowsUpdated: totalRows, + }, + }); + } catch (error: any) { + logger.error("코드 병합 실패:", { + error: error.message, + stack: error.stack, + columnName, + oldValue, + newValue, + }); + + res.status(500).json({ + success: false, + message: "코드 병합 중 오류가 발생했습니다.", + error: { + code: "CODE_MERGE_ERROR", + details: error.message, + }, + }); + } +} + +/** + * 특정 컬럼을 가진 테이블 목록 조회 + */ +export async function getTablesWithColumn( + req: AuthenticatedRequest, + res: Response +): Promise { + const { columnName } = req.params; + + try { + if (!columnName) { + res.status(400).json({ + success: false, + message: "컬럼명이 필요합니다.", + }); + return; + } + + logger.info("컬럼을 가진 테이블 목록 조회", { columnName }); + + const query = ` + SELECT DISTINCT t.table_name + FROM information_schema.columns c + JOIN information_schema.tables t + ON c.table_name = t.table_name + WHERE c.column_name = $1 + AND t.table_schema = 'public' + AND t.table_type = 'BASE TABLE' + AND EXISTS ( + SELECT 1 FROM information_schema.columns c2 + WHERE c2.table_name = t.table_name + AND c2.column_name = 'company_code' + ) + ORDER BY t.table_name + `; + + const result = await pool.query(query, [columnName]); + + logger.info(`컬럼을 가진 테이블 조회 완료: ${result.rows.length}개`); + + res.json({ + success: true, + message: "테이블 목록 조회 성공", + data: { + columnName, + tables: result.rows.map((row) => row.table_name), + count: result.rows.length, + }, + }); + } catch (error: any) { + logger.error("테이블 목록 조회 실패:", error); + + res.status(500).json({ + success: false, + message: "테이블 목록 조회 중 오류가 발생했습니다.", + error: { + code: "TABLE_LIST_ERROR", + details: error.message, + }, + }); + } +} + +/** + * 코드 병합 미리보기 (실제 실행 없이 영향받을 데이터 확인) + */ +export async function previewCodeMerge( + req: AuthenticatedRequest, + res: Response +): Promise { + const { columnName, oldValue } = req.body; + const companyCode = req.user?.companyCode; + + try { + if (!columnName || !oldValue) { + res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (columnName, oldValue)", + }); + return; + } + + if (!companyCode) { + res.status(401).json({ + success: false, + message: "인증 정보가 없습니다.", + }); + return; + } + + logger.info("코드 병합 미리보기", { columnName, oldValue, companyCode }); + + // 해당 컬럼을 가진 테이블 찾기 + const tablesQuery = ` + SELECT DISTINCT t.table_name + FROM information_schema.columns c + JOIN information_schema.tables t + ON c.table_name = t.table_name + WHERE c.column_name = $1 + AND t.table_schema = 'public' + AND t.table_type = 'BASE TABLE' + AND EXISTS ( + SELECT 1 FROM information_schema.columns c2 + WHERE c2.table_name = t.table_name + AND c2.column_name = 'company_code' + ) + `; + + const tablesResult = await pool.query(tablesQuery, [columnName]); + + // 각 테이블에서 영향받을 행 수 계산 + const preview = []; + const tableRows = Array.isArray(tablesResult) ? tablesResult : (tablesResult.rows || []); + + for (const row of tableRows) { + const tableName = row.table_name; + + // 동적 SQL 생성 (테이블명과 컬럼명은 파라미터 바인딩 불가) + // SQL 인젝션 방지: 테이블명과 컬럼명은 information_schema에서 검증된 값 + const countQuery = `SELECT COUNT(*) as count FROM "${tableName}" WHERE "${columnName}" = $1 AND company_code = $2`; + + try { + const countResult = await pool.query(countQuery, [oldValue, companyCode]); + const count = parseInt(countResult.rows[0].count); + + if (count > 0) { + preview.push({ + tableName, + affectedRows: count, + }); + } + } catch (error: any) { + logger.warn(`테이블 ${tableName} 조회 실패:`, error.message); + // 테이블 접근 실패 시 건너뛰기 + continue; + } + } + + const totalRows = preview.reduce((sum, item) => sum + item.affectedRows, 0); + + logger.info("코드 병합 미리보기 완료", { + tablesCount: preview.length, + totalRows, + }); + + res.json({ + success: true, + message: "코드 병합 미리보기 완료", + data: { + columnName, + oldValue, + preview, + totalAffectedRows: totalRows, + }, + }); + } catch (error: any) { + logger.error("코드 병합 미리보기 실패:", error); + + res.status(500).json({ + success: false, + message: "코드 병합 미리보기 중 오류가 발생했습니다.", + error: { + code: "PREVIEW_ERROR", + details: error.message, + }, + }); + } +} + diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index ccca89b0..c9449e94 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -24,6 +24,7 @@ import { deleteCompany, // 회사 삭제 getUserLocale, setUserLocale, + getTableSchema, // 테이블 스키마 조회 } from "../controllers/adminController"; import { authenticateToken } from "../middleware/authMiddleware"; @@ -67,4 +68,7 @@ router.delete("/companies/:companyCode", deleteCompany); // 회사 삭제 router.get("/user-locale", getUserLocale); router.post("/user-locale", setUserLocale); +// 테이블 스키마 API (엑셀 업로드 컬럼 매핑용) +router.get("/tables/:tableName/schema", getTableSchema); + export default router; diff --git a/backend-node/src/routes/codeMergeRoutes.ts b/backend-node/src/routes/codeMergeRoutes.ts new file mode 100644 index 00000000..78cbd3e1 --- /dev/null +++ b/backend-node/src/routes/codeMergeRoutes.ts @@ -0,0 +1,35 @@ +import express from "express"; +import { + mergeCodeAllTables, + getTablesWithColumn, + previewCodeMerge, +} from "../controllers/codeMergeController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = express.Router(); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +/** + * POST /api/code-merge/merge-all-tables + * 코드 병합 실행 (모든 관련 테이블에 적용) + * Body: { columnName, oldValue, newValue } + */ +router.post("/merge-all-tables", mergeCodeAllTables); + +/** + * GET /api/code-merge/tables-with-column/:columnName + * 특정 컬럼을 가진 테이블 목록 조회 + */ +router.get("/tables-with-column/:columnName", getTablesWithColumn); + +/** + * POST /api/code-merge/preview + * 코드 병합 미리보기 (실제 실행 없이 영향받을 데이터 확인) + * Body: { columnName, oldValue } + */ +router.post("/preview", previewCodeMerge); + +export default router; + diff --git a/docs/품목정보.html b/docs/품목정보.html new file mode 100644 index 00000000..1df8a673 --- /dev/null +++ b/docs/품목정보.html @@ -0,0 +1,3915 @@ + + + + + + 품목 기본정보 + + + + + + + + + + + + +
+ +
+ + +
+
+
+
+ 총 15개 +
+ +
+
+
+ + + + + +
+
+
+
+
+ + +
+
+
+

⚙️ 옵션 설정

+ +
+
+ + + +
+
+ +
+ +
+
+ + + + + + + + +
+ + +
+ + + + + + + + + + + + + + + + + + diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 5a4b3352..0c9a681b 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -40,6 +40,11 @@ export default function ScreenViewPage() { // 테이블에서 선택된 행 데이터 (버튼 액션에 전달) const [selectedRowsData, setSelectedRowsData] = useState([]); + // 테이블 정렬 정보 (엑셀 다운로드용) + const [tableSortBy, setTableSortBy] = useState(); + const [tableSortOrder, setTableSortOrder] = useState<"asc" | "desc">("asc"); + const [tableColumnOrder, setTableColumnOrder] = useState(); + // 플로우에서 선택된 데이터 (버튼 액션에 전달) const [flowSelectedData, setFlowSelectedData] = useState([]); const [flowSelectedStepId, setFlowSelectedStepId] = useState(null); @@ -425,9 +430,16 @@ export default function ScreenViewPage() { userName={userName} companyCode={companyCode} selectedRowsData={selectedRowsData} - onSelectedRowsChange={(_, selectedData) => { + sortBy={tableSortBy} + sortOrder={tableSortOrder} + columnOrder={tableColumnOrder} + onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder) => { console.log("🔍 화면에서 선택된 행 데이터:", selectedData); + console.log("📊 정렬 정보:", { sortBy, sortOrder, columnOrder }); setSelectedRowsData(selectedData); + setTableSortBy(sortBy); + setTableSortOrder(sortOrder || "asc"); + setTableColumnOrder(columnOrder); }} flowSelectedData={flowSelectedData} flowSelectedStepId={flowSelectedStepId} @@ -479,9 +491,16 @@ export default function ScreenViewPage() { userName={userName} companyCode={companyCode} selectedRowsData={selectedRowsData} - onSelectedRowsChange={(_, selectedData) => { + sortBy={tableSortBy} + sortOrder={tableSortOrder} + columnOrder={tableColumnOrder} + onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder) => { console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData); + console.log("📊 정렬 정보 (자식):", { sortBy, sortOrder, columnOrder }); setSelectedRowsData(selectedData); + setTableSortBy(sortBy); + setTableSortOrder(sortOrder || "asc"); + setTableColumnOrder(columnOrder); }} refreshKey={tableRefreshKey} onRefresh={() => { @@ -613,8 +632,14 @@ export default function ScreenViewPage() { userName={userName} companyCode={companyCode} selectedRowsData={selectedRowsData} - onSelectedRowsChange={(_, selectedData) => { + sortBy={tableSortBy} + sortOrder={tableSortOrder} + columnOrder={tableColumnOrder} + onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder) => { setSelectedRowsData(selectedData); + setTableSortBy(sortBy); + setTableSortOrder(sortOrder || "asc"); + setTableColumnOrder(columnOrder); }} flowSelectedData={flowSelectedData} flowSelectedStepId={flowSelectedStepId} diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index 6f909357..9c28e28c 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useRef } from "react"; +import React, { useState, useRef, useEffect } from "react"; import { Dialog, DialogContent, @@ -19,10 +19,23 @@ import { SelectValue, } from "@/components/ui/select"; import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; import { toast } from "sonner"; -import { Upload, FileSpreadsheet, AlertCircle, CheckCircle2 } from "lucide-react"; +import { + Upload, + FileSpreadsheet, + AlertCircle, + CheckCircle2, + Plus, + Minus, + ArrowRight, + Save, + Zap, +} from "lucide-react"; 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"; export interface ExcelUploadModalProps { open: boolean; @@ -33,6 +46,17 @@ export interface ExcelUploadModalProps { onSuccess?: () => void; } +interface ColumnMapping { + excelColumn: string; + systemColumn: string | null; +} + +interface UploadConfig { + name: string; + type: string; + mappings: ColumnMapping[]; +} + export const ExcelUploadModal: React.FC = ({ open, onOpenChange, @@ -41,19 +65,38 @@ export const ExcelUploadModal: React.FC = ({ keyColumn, onSuccess, }) => { + const [currentStep, setCurrentStep] = useState(1); + + // 1단계: 파일 선택 const [file, setFile] = useState(null); const [sheetNames, setSheetNames] = useState([]); const [selectedSheet, setSelectedSheet] = useState(""); - const [isUploading, setIsUploading] = useState(false); - const [previewData, setPreviewData] = useState[]>([]); const fileInputRef = useRef(null); + // 2단계: 범위 지정 + const [autoCreateColumn, setAutoCreateColumn] = useState(false); + const [selectedCompany, setSelectedCompany] = useState(""); + const [selectedDataType, setSelectedDataType] = useState(""); + const [detectedRange, setDetectedRange] = useState(""); + const [previewData, setPreviewData] = useState[]>([]); + const [allData, setAllData] = useState[]>([]); + const [displayData, setDisplayData] = useState[]>([]); + + // 3단계: 컬럼 매핑 + const [excelColumns, setExcelColumns] = useState([]); + const [systemColumns, setSystemColumns] = useState([]); + const [columnMappings, setColumnMappings] = useState([]); + const [configName, setConfigName] = useState(""); + const [configType, setConfigType] = useState(""); + + // 4단계: 확인 + const [isUploading, setIsUploading] = useState(false); + // 파일 선택 핸들러 const handleFileChange = async (e: React.ChangeEvent) => { const selectedFile = e.target.files?.[0]; if (!selectedFile) return; - // 파일 확장자 검증 const fileExtension = selectedFile.name.split(".").pop()?.toLowerCase(); if (!["xlsx", "xls", "csv"].includes(fileExtension || "")) { toast.error("엑셀 파일만 업로드 가능합니다. (.xlsx, .xls, .csv)"); @@ -63,14 +106,20 @@ export const ExcelUploadModal: React.FC = ({ setFile(selectedFile); try { - // 시트 목록 가져오기 const sheets = await getExcelSheetNames(selectedFile); setSheetNames(sheets); setSelectedSheet(sheets[0] || ""); - // 미리보기 데이터 로드 (첫 5행) const data = await importFromExcel(selectedFile, sheets[0]); - setPreviewData(data.slice(0, 5)); + setAllData(data); + setDisplayData(data.slice(0, 10)); + + if (data.length > 0) { + const columns = Object.keys(data[0]); + const lastCol = String.fromCharCode(64 + columns.length); + setDetectedRange(`A1:${lastCol}${data.length + 1}`); + setExcelColumns(columns); + } toast.success(`파일이 선택되었습니다: ${selectedFile.name}`); } catch (error) { @@ -83,124 +132,223 @@ export const ExcelUploadModal: React.FC = ({ // 시트 변경 핸들러 const handleSheetChange = async (sheetName: string) => { setSelectedSheet(sheetName); - if (!file) return; try { const data = await importFromExcel(file, sheetName); - setPreviewData(data.slice(0, 5)); + setAllData(data); + setDisplayData(data.slice(0, 10)); + + if (data.length > 0) { + const columns = Object.keys(data[0]); + const lastCol = String.fromCharCode(64 + columns.length); + setDetectedRange(`A1:${lastCol}${data.length + 1}`); + setExcelColumns(columns); + } } catch (error) { console.error("시트 읽기 오류:", error); toast.error("시트를 읽는 중 오류가 발생했습니다."); } }; - // 업로드 핸들러 - const handleUpload = async () => { - if (!file) { + // 행 추가 + const handleAddRow = () => { + const newRow: Record = {}; + excelColumns.forEach((col) => { + newRow[col] = ""; + }); + setDisplayData([...displayData, newRow]); + toast.success("행이 추가되었습니다."); + }; + + // 행 삭제 + const handleRemoveRow = () => { + if (displayData.length > 1) { + setDisplayData(displayData.slice(0, -1)); + toast.success("마지막 행이 삭제되었습니다."); + } else { + toast.error("최소 1개의 행이 필요합니다."); + } + }; + + // 열 추가 + const handleAddColumn = () => { + const newColName = `Column${excelColumns.length + 1}`; + setExcelColumns([...excelColumns, newColName]); + setDisplayData( + displayData.map((row) => ({ + ...row, + [newColName]: "", + })) + ); + toast.success("열이 추가되었습니다."); + }; + + // 열 삭제 + const handleRemoveColumn = () => { + if (excelColumns.length > 1) { + const lastCol = excelColumns[excelColumns.length - 1]; + setExcelColumns(excelColumns.slice(0, -1)); + setDisplayData( + displayData.map((row) => { + const { [lastCol]: removed, ...rest } = row; + return rest; + }) + ); + toast.success("마지막 열이 삭제되었습니다."); + } else { + toast.error("최소 1개의 열이 필요합니다."); + } + }; + + // 테이블 스키마 가져오기 + useEffect(() => { + if (currentStep === 3 && tableName) { + loadTableSchema(); + } + }, [currentStep, tableName]); + + const loadTableSchema = async () => { + try { + console.log("🔍 테이블 스키마 로드 시작:", { tableName }); + + const response = await getTableSchema(tableName); + + console.log("📊 테이블 스키마 응답:", response); + + if (response.success && response.data) { + console.log("✅ 시스템 컬럼 로드 완료:", response.data.columns); + setSystemColumns(response.data.columns); + + const initialMappings: ColumnMapping[] = excelColumns.map((col) => ({ + excelColumn: col, + systemColumn: null, + })); + setColumnMappings(initialMappings); + } else { + console.error("❌ 테이블 스키마 로드 실패:", response); + } + } catch (error) { + console.error("❌ 테이블 스키마 로드 실패:", error); + toast.error("테이블 스키마를 불러올 수 없습니다."); + } + }; + + // 자동 매핑 + const handleAutoMapping = () => { + const newMappings = excelColumns.map((excelCol) => { + const matchedSystemCol = systemColumns.find( + (sysCol) => sysCol.name.toLowerCase() === excelCol.toLowerCase() + ); + + return { + excelColumn: excelCol, + systemColumn: matchedSystemCol ? matchedSystemCol.name : null, + }; + }); + + setColumnMappings(newMappings); + const matchedCount = newMappings.filter((m) => m.systemColumn).length; + toast.success(`${matchedCount}개 컬럼이 자동 매핑되었습니다.`); + }; + + // 컬럼 매핑 변경 + const handleMappingChange = (excelColumn: string, systemColumn: string | null) => { + setColumnMappings((prev) => + prev.map((mapping) => + mapping.excelColumn === excelColumn + ? { ...mapping, systemColumn } + : mapping + ) + ); + }; + + // 설정 저장 + const handleSaveConfig = () => { + if (!configName.trim()) { + toast.error("거래처명을 입력해주세요."); + return; + } + + const config: UploadConfig = { + name: configName, + type: configType, + mappings: columnMappings, + }; + + const savedConfigs = JSON.parse( + localStorage.getItem("excelUploadConfigs") || "[]" + ); + savedConfigs.push(config); + localStorage.setItem("excelUploadConfigs", JSON.stringify(savedConfigs)); + + toast.success("설정이 저장되었습니다."); + }; + + // 다음 단계 + const handleNext = () => { + if (currentStep === 1 && !file) { toast.error("파일을 선택해주세요."); return; } - if (!tableName) { - toast.error("테이블명이 지정되지 않았습니다."); + if (currentStep === 2 && displayData.length === 0) { + toast.error("데이터가 없습니다."); + return; + } + + setCurrentStep((prev) => Math.min(prev + 1, 4)); + }; + + // 이전 단계 + const handlePrevious = () => { + setCurrentStep((prev) => Math.max(prev - 1, 1)); + }; + + // 업로드 핸들러 + const handleUpload = async () => { + if (!file || !tableName) { + toast.error("필수 정보가 누락되었습니다."); return; } setIsUploading(true); try { - // 엑셀 데이터 읽기 - const data = await importFromExcel(file, selectedSheet); - - console.log("📤 엑셀 업로드 시작:", { - tableName, - uploadMode, - rowCount: data.length, + const mappedData = displayData.map((row) => { + const mappedRow: Record = {}; + columnMappings.forEach((mapping) => { + if (mapping.systemColumn) { + mappedRow[mapping.systemColumn] = row[mapping.excelColumn]; + } + }); + return mappedRow; }); - // 업로드 모드에 따라 처리 let successCount = 0; let failCount = 0; - for (const row of data) { + for (const row of mappedData) { try { if (uploadMode === "insert") { - // 삽입 모드 const formData = { screenId: 0, tableName, data: row }; const result = await DynamicFormApi.saveFormData(formData); if (result.success) { successCount++; } else { - console.error("저장 실패:", result.message, row); failCount++; } - } else if (uploadMode === "update" && keyColumn) { - // 업데이트 모드 - const keyValue = row[keyColumn]; - if (keyValue) { - await DynamicFormApi.updateFormDataPartial(tableName, keyValue, row); - successCount++; - } else { - failCount++; - } - } else if (uploadMode === "upsert" && keyColumn) { - // Upsert 모드 (있으면 업데이트, 없으면 삽입) - const keyValue = row[keyColumn]; - if (keyValue) { - try { - const updateResult = await DynamicFormApi.updateFormDataPartial(tableName, keyValue, row); - if (!updateResult.success) { - // 업데이트 실패 시 삽입 시도 - const formData = { screenId: 0, tableName, data: row }; - const insertResult = await DynamicFormApi.saveFormData(formData); - if (insertResult.success) { - successCount++; - } else { - console.error("Upsert 실패:", insertResult.message, row); - failCount++; - } - } else { - successCount++; - } - } catch { - const formData = { screenId: 0, tableName, data: row }; - const insertResult = await DynamicFormApi.saveFormData(formData); - if (insertResult.success) { - successCount++; - } else { - console.error("Upsert 실패:", insertResult.message, row); - failCount++; - } - } - } else { - const formData = { screenId: 0, tableName, data: row }; - const result = await DynamicFormApi.saveFormData(formData); - if (result.success) { - successCount++; - } else { - console.error("저장 실패:", result.message, row); - failCount++; - } - } } } catch (error) { - console.error("행 처리 오류:", row, error); failCount++; } } - console.log("✅ 엑셀 업로드 완료:", { - successCount, - failCount, - totalCount: data.length, - }); - if (successCount > 0) { - toast.success(`${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}`); - // onSuccess 내부에서 closeModal이 호출되므로 여기서는 호출하지 않음 + toast.success( + `${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}` + ); onSuccess?.(); - // onOpenChange(false); // 제거: onSuccess에서 이미 모달을 닫음 } else { toast.error("업로드에 실패했습니다."); } @@ -212,114 +360,492 @@ export const ExcelUploadModal: React.FC = ({ } }; + // 모달 닫기 시 초기화 + useEffect(() => { + if (!open) { + setCurrentStep(1); + setFile(null); + setSheetNames([]); + setSelectedSheet(""); + setAutoCreateColumn(false); + setSelectedCompany(""); + setSelectedDataType(""); + setDetectedRange(""); + setPreviewData([]); + setAllData([]); + setDisplayData([]); + setExcelColumns([]); + setSystemColumns([]); + setColumnMappings([]); + setConfigName(""); + setConfigType(""); + } + }, [open]); + return ( - + - 엑셀 파일 업로드 + + + 엑셀 데이터 업로드 + - 엑셀 파일을 선택하여 데이터를 업로드하세요. + 엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요. -
- {/* 파일 선택 */} -
- -
- - -
-

- 지원 형식: .xlsx, .xls, .csv -

-
+ {/* 스텝 인디케이터 */} +
+ {[ + { num: 1, label: "파일 선택" }, + { num: 2, label: "범위 지정" }, + { num: 3, label: "컬럼 매핑" }, + { num: 4, label: "확인" }, + ].map((step, index) => ( + +
+
step.num + ? "bg-success text-white" + : "bg-muted text-muted-foreground" + )} + > + {currentStep > step.num ? ( + + ) : ( + step.num + )} +
+ + {step.label} + +
+ {index < 3 && ( +
step.num ? "bg-success" : "bg-muted" + )} + /> + )} + + ))} +
- {/* 시트 선택 */} - {sheetNames.length > 0 && ( -
- - + {/* 스텝별 컨텐츠 */} +
+ {/* 1단계: 파일 선택 */} + {currentStep === 1 && ( +
+
+ +
+ + +
+

+ 지원 형식: .xlsx, .xls, .csv +

+
+ + {sheetNames.length > 0 && ( +
+ + +
+ )}
)} - {/* 업로드 모드 정보 */} -
-
- -
-

업로드 모드: {uploadMode === "insert" ? "삽입" : uploadMode === "update" ? "업데이트" : "Upsert"}

-

- {uploadMode === "insert" && "새로운 데이터로 삽입됩니다."} - {uploadMode === "update" && `기존 데이터를 업데이트합니다. (키: ${keyColumn})`} - {uploadMode === "upsert" && `있으면 업데이트, 없으면 삽입합니다. (키: ${keyColumn})`} -

-
-
-
+ {/* 2단계: 범위 지정 */} + {currentStep === 2 && ( +
+ {/* 상단: 3개 드롭다운 가로 배치 */} +
+ - {/* 미리보기 */} - {previewData.length > 0 && ( -
- -
- - - - {Object.keys(previewData[0]).map((key) => ( - + + {excelColumns.map((col) => ( + + ))} + + ))} + +
- {key} + + + + + + {/* 중간: 체크박스 + 버튼들 한 줄 배치 */} +
+
+ setAutoCreateColumn(checked as boolean)} + /> + +
+ +
+ + + + +
+
+ + {/* 하단: 감지된 범위 + 테이블 */} +
+ 감지된 범위: {detectedRange} + + 첫 행이 컬럼명, 데이터는 자동 감지됩니다 + +
+ + {displayData.length > 0 && ( +
+ + + + - ))} - - - - {previewData.map((row, index) => ( - - {Object.values(row).map((value, i) => ( - + ))} + + + + + + {excelColumns.map((col) => ( + ))} - ))} - -
+
- {String(value)} + {excelColumns.map((col, index) => ( + + {String.fromCharCode(65 + index)} +
+ 1 + + {col}
+ {displayData.map((row, rowIndex) => ( +
+ {rowIndex + 2} + + {String(row[col] || "")} +
+
+ )} +
+ )} + + {/* 3단계: 컬럼 매핑 - 3단 레이아웃 */} + {currentStep === 3 && ( +
+ {/* 왼쪽: 컬럼 매핑 설정 제목 + 자동 매핑 버튼 */} +
+
+

컬럼 매핑 설정

+ +
-
- - 총 {previewData.length}개 행 (미리보기) + + {/* 중앙: 매핑 리스트 */} +
+
+
엑셀 컬럼
+
+
시스템 컬럼
+
+ +
+ {columnMappings.map((mapping, index) => ( +
+
+ {mapping.excelColumn} +
+ + +
+ ))} +
+
+ + {/* 오른쪽: 현재 설정 저장 */} +
+
+ +

현재 설정 저장

+
+
+
+ + setConfigName(e.target.value)} + placeholder="거래처 선택" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + setConfigType(e.target.value)} + placeholder="유형을 입력하세요 (예: 원자재)" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ +
+
+
+ )} + + {/* 4단계: 확인 */} + {currentStep === 4 && ( +
+
+

업로드 요약

+
+

+ 파일: {file?.name} +

+

+ 시트: {selectedSheet} +

+

+ 데이터 행: {displayData.length}개 +

+

+ 테이블: {tableName} +

+

+ 모드:{" "} + {uploadMode === "insert" + ? "삽입" + : uploadMode === "update" + ? "업데이트" + : "Upsert"} +

+
+
+ +
+

컬럼 매핑

+
+ {columnMappings + .filter((m) => m.systemColumn) + .map((mapping, index) => ( +

+ {mapping.excelColumn} →{" "} + {mapping.systemColumn} +

+ ))} + {columnMappings.filter((m) => m.systemColumn).length === 0 && ( +

매핑된 컬럼이 없습니다.

+ )} +
+
+ +
+
+ +
+

주의사항

+

+ 업로드를 진행하면 데이터가 데이터베이스에 저장됩니다. 계속하시겠습니까? +

+
+
)} @@ -328,22 +854,31 @@ export const ExcelUploadModal: React.FC = ({ - + {currentStep < 4 ? ( + + ) : ( + + )}
); }; - diff --git a/frontend/components/screen/RealtimePreview.tsx b/frontend/components/screen/RealtimePreview.tsx index 906d5ad6..777facef 100644 --- a/frontend/components/screen/RealtimePreview.tsx +++ b/frontend/components/screen/RealtimePreview.tsx @@ -63,6 +63,10 @@ interface RealtimePreviewProps { children?: React.ReactNode; // 그룹 내 자식 컴포넌트들 // 플로우 선택 데이터 전달용 onFlowSelectedDataChange?: (selectedData: any[], stepId: number | null) => void; + // 테이블 정렬 정보 전달용 + sortBy?: string; + sortOrder?: "asc" | "desc"; + [key: string]: any; // 추가 props 허용 } // 영역 레이아웃에 따른 아이콘 반환 @@ -225,6 +229,9 @@ export const RealtimePreviewDynamic: React.FC = ({ onGroupToggle, children, onFlowSelectedDataChange, + sortBy, + sortOrder, + ...restProps }) => { const { user } = useAuth(); const { type, id, position, size, style = {} } = component; @@ -545,7 +552,13 @@ export const RealtimePreviewDynamic: React.FC = ({ {/* 위젯 타입 - 동적 렌더링 (파일 컴포넌트 제외) */} {type === "widget" && !isFileComponent(component) && (
- +
)} diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index 1f11182f..329e09bb 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -54,6 +54,11 @@ interface RealtimePreviewProps { // 폼 데이터 관련 props formData?: Record; onFormDataChange?: (fieldName: string, value: any) => void; + + // 테이블 정렬 정보 + sortBy?: string; + sortOrder?: "asc" | "desc"; + columnOrder?: string[]; } // 동적 위젯 타입 아이콘 (레지스트리에서 조회) @@ -109,6 +114,9 @@ export const RealtimePreviewDynamic: React.FC = ({ onFlowSelectedDataChange, refreshKey, onRefresh, + sortBy, + sortOrder, + columnOrder, flowRefreshKey, onFlowRefresh, formData, @@ -395,6 +403,9 @@ export const RealtimePreviewDynamic: React.FC = ({ onFlowRefresh={onFlowRefresh} formData={formData} onFormDataChange={onFormDataChange} + sortBy={sortBy} + sortOrder={sortOrder} + columnOrder={columnOrder} />
diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index 5e1471ca..359063eb 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -270,6 +270,7 @@ export const ButtonConfigPanel: React.FC = ({ 엑셀 다운로드 엑셀 업로드 바코드 스캔 + 코드 병합
@@ -838,6 +839,53 @@ export const ButtonConfigPanel: React.FC = ({ )} + {/* 코드 병합 액션 설정 */} + {(component.componentConfig?.action?.type || "save") === "code_merge" && ( +
+

🔀 코드 병합 설정

+ +
+ + onUpdateProperty("componentConfig.action.mergeColumnName", e.target.value)} + className="h-8 text-xs" + /> +

+ 병합할 컬럼명 (예: item_code). 이 컬럼이 있는 모든 테이블에 병합이 적용됩니다. +

+
+ +
+
+ +

영향받을 테이블과 행 수를 미리 확인합니다

+
+ onUpdateProperty("componentConfig.action.mergeShowPreview", checked)} + /> +
+ +
+

+ 사용 방법: +
+ 1. 테이블에서 병합할 두 개의 행을 선택합니다 +
+ 2. 이 버튼을 클릭하면 병합 방향을 선택할 수 있습니다 +
+ 3. 데이터는 삭제되지 않고, 컬럼 값만 변경됩니다 +

+
+
+ )} + {/* 제어 기능 섹션 */}
diff --git a/frontend/lib/api/tableSchema.ts b/frontend/lib/api/tableSchema.ts new file mode 100644 index 00000000..6e04187f --- /dev/null +++ b/frontend/lib/api/tableSchema.ts @@ -0,0 +1,45 @@ +import { apiClient } from "./client"; + +export interface TableColumn { + name: string; + type: string; + nullable: boolean; + default: string | null; + maxLength: number | null; + precision: number | null; + scale: number | null; +} + +export interface TableSchemaResponse { + success: boolean; + message: string; + data: { + tableName: string; + columns: TableColumn[]; + }; +} + +/** + * 테이블 스키마 조회 (엑셀 업로드 컬럼 매핑용) + */ +export async function getTableSchema( + tableName: string +): Promise { + try { + const response = await apiClient.get( + `/admin/tables/${tableName}/schema` + ); + return response.data; + } catch (error: any) { + console.error("테이블 스키마 조회 실패:", error); + return { + success: false, + message: error.response?.data?.message || "테이블 스키마 조회 실패", + data: { + tableName, + columns: [], + }, + }; + } +} + diff --git a/frontend/lib/hooks/useEntityJoinOptimization.ts b/frontend/lib/hooks/useEntityJoinOptimization.ts index 1446d1c3..3e11342f 100644 --- a/frontend/lib/hooks/useEntityJoinOptimization.ts +++ b/frontend/lib/hooks/useEntityJoinOptimization.ts @@ -142,7 +142,7 @@ export function useEntityJoinOptimization(columnMeta: Record => { if (!preloadCommonCodes) return; - console.log("🚀 공통 코드 프리로딩 시작"); + // console.log("🚀 공통 코드 프리로딩 시작"); // 현재 테이블의 코드 카테고리와 공통 카테고리 합치기 const allCategories = [...new Set([...codeCategories, ...commonCodeCategories])]; diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 1faae13f..cdb81291 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -29,7 +29,10 @@ export interface ComponentRenderer { // 테이블 선택된 행 정보 (다중 선택 액션용) selectedRows?: any[]; selectedRowsData?: any[]; - onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void; + onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[]) => void; + // 테이블 정렬 정보 (엑셀 다운로드용) + sortBy?: string; + sortOrder?: "asc" | "desc"; // 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용) flowSelectedData?: any[]; flowSelectedStepId?: number | null; @@ -101,7 +104,11 @@ export interface DynamicComponentRendererProps { // 테이블 선택된 행 정보 (다중 선택 액션용) selectedRows?: any[]; selectedRowsData?: any[]; - onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void; + onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[]) => void; + // 테이블 정렬 정보 (엑셀 다운로드용) + sortBy?: string; + sortOrder?: "asc" | "desc"; + columnOrder?: string[]; // 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용) flowSelectedData?: any[]; flowSelectedStepId?: number | null; @@ -191,6 +198,8 @@ export const DynamicComponentRenderer: React.FC = selectedRows, selectedRowsData, onSelectedRowsChange, + sortBy, // 🆕 정렬 컬럼 + sortOrder, // 🆕 정렬 방향 flowSelectedData, flowSelectedStepId, onFlowSelectedDataChange, @@ -278,6 +287,9 @@ export const DynamicComponentRenderer: React.FC = selectedRows, selectedRowsData, onSelectedRowsChange, + // 테이블 정렬 정보 전달 + sortBy, + sortOrder, // 플로우 선택된 데이터 정보 전달 flowSelectedData, flowSelectedStepId, diff --git a/frontend/lib/registry/components/WidgetRenderer.tsx b/frontend/lib/registry/components/WidgetRenderer.tsx index b30488a7..395a8618 100644 --- a/frontend/lib/registry/components/WidgetRenderer.tsx +++ b/frontend/lib/registry/components/WidgetRenderer.tsx @@ -49,6 +49,7 @@ const WidgetRenderer: ComponentRenderer = ({ component, ...props }) => { value: undefined, // 미리보기이므로 값은 없음 readonly: readonly, isDesignMode: true, // 디자인 모드임을 명시 + ...props, // 모든 추가 props 전달 (sortBy, sortOrder 등) }} config={widget.webTypeConfig} /> diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 5bf11eec..a2c584af 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -42,6 +42,11 @@ export interface ButtonPrimaryComponentProps extends ComponentRendererProps { // 테이블 선택된 행 정보 (다중 선택 액션용) selectedRows?: any[]; selectedRowsData?: any[]; + + // 테이블 정렬 정보 (엑셀 다운로드용) + sortBy?: string; + sortOrder?: "asc" | "desc"; + columnOrder?: string[]; // 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용) flowSelectedData?: any[]; @@ -74,6 +79,9 @@ export const ButtonPrimaryComponent: React.FC = ({ onRefresh, onClose, onFlowRefresh, + sortBy, // 🆕 정렬 컬럼 + sortOrder, // 🆕 정렬 방향 + columnOrder, // 🆕 컬럼 순서 selectedRows, selectedRowsData, flowSelectedData, @@ -405,6 +413,10 @@ export const ButtonPrimaryComponent: React.FC = ({ // 테이블 선택된 행 정보 추가 selectedRows, selectedRowsData, + // 테이블 정렬 정보 추가 + sortBy, // 🆕 정렬 컬럼 + sortOrder, // 🆕 정렬 방향 + columnOrder, // 🆕 컬럼 순서 // 플로우 선택된 데이터 정보 추가 flowSelectedData, flowSelectedStepId, diff --git a/frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx b/frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx index d429fbf4..b7abeb7f 100644 --- a/frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx +++ b/frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx @@ -8,41 +8,53 @@ import { cn } from "@/lib/utils"; import { ColumnConfig } from "./types"; interface SingleTableWithStickyProps { - visibleColumns: ColumnConfig[]; + visibleColumns?: ColumnConfig[]; + columns?: ColumnConfig[]; data: Record[]; columnLabels: Record; sortColumn: string | null; sortDirection: "asc" | "desc"; - tableConfig: any; - isDesignMode: boolean; - isAllSelected: boolean; - handleSort: (columnName: string) => void; - handleSelectAll: (checked: boolean) => void; - handleRowClick: (row: any) => void; - renderCheckboxCell: (row: any, index: number) => React.ReactNode; + tableConfig?: any; + isDesignMode?: boolean; + isAllSelected?: boolean; + handleSort?: (columnName: string) => void; + onSort?: (columnName: string) => void; + handleSelectAll?: (checked: boolean) => void; + handleRowClick?: (row: any) => void; + renderCheckboxCell?: (row: any, index: number) => React.ReactNode; + renderCheckboxHeader?: () => React.ReactNode; formatCellValue: (value: any, format?: string, columnName?: string, rowData?: Record) => string; getColumnWidth: (column: ColumnConfig) => number; containerWidth?: string; // 컨테이너 너비 설정 + loading?: boolean; + error?: string | null; } export const SingleTableWithSticky: React.FC = ({ visibleColumns, + columns, data, columnLabels, sortColumn, sortDirection, tableConfig, - isDesignMode, - isAllSelected, + isDesignMode = false, + isAllSelected = false, handleSort, + onSort, handleSelectAll, handleRowClick, renderCheckboxCell, + renderCheckboxHeader, formatCellValue, getColumnWidth, containerWidth, + loading = false, + error = null, }) => { - const checkboxConfig = tableConfig.checkbox || {}; + const checkboxConfig = tableConfig?.checkbox || {}; + const actualColumns = visibleColumns || columns || []; + const sortHandler = onSort || handleSort || (() => {}); return (
= ({ } > - {visibleColumns.map((column, colIndex) => { + {actualColumns.map((column, colIndex) => { // 왼쪽 고정 컬럼들의 누적 너비 계산 - const leftFixedWidth = visibleColumns + const leftFixedWidth = actualColumns .slice(0, colIndex) .filter((col) => col.fixed === "left") .reduce((sum, col) => sum + getColumnWidth(col), 0); // 오른쪽 고정 컬럼들의 누적 너비 계산 - const rightFixedColumns = visibleColumns.filter((col) => col.fixed === "right"); + const rightFixedColumns = actualColumns.filter((col) => col.fixed === "right"); const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName); const rightFixedWidth = rightFixedIndex >= 0 @@ -115,7 +127,7 @@ export const SingleTableWithSticky: React.FC = ({ ...(column.fixed === "left" && { left: leftFixedWidth }), ...(column.fixed === "right" && { right: rightFixedWidth }), }} - onClick={() => column.sortable && handleSort(column.columnName)} + onClick={() => column.sortable && sortHandler(column.columnName)} >
{column.columnName === "__checkbox__" ? ( diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 46c03aef..d5152319 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -138,7 +138,8 @@ export interface TableListComponentProps { onRefresh?: () => void; onClose?: () => void; screenId?: string; - onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void; + userId?: string; // 사용자 ID (컬럼 순서 저장용) + onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[]) => void; onConfigChange?: (config: any) => void; refreshKey?: number; } @@ -163,6 +164,7 @@ export const TableListComponent: React.FC = ({ onConfigChange, refreshKey, tableName, + userId, }) => { // ======================================== // 설정 및 스타일 @@ -178,18 +180,7 @@ export const TableListComponent: React.FC = ({ let finalSelectedTable = componentConfig?.selectedTable || component.config?.selectedTable || config?.selectedTable || tableName; - console.log("🔍 TableListComponent 초기화:", { - componentConfigSelectedTable: componentConfig?.selectedTable, - componentConfigSelectedTableType: typeof componentConfig?.selectedTable, - componentConfigSelectedTable2: component.config?.selectedTable, - componentConfigSelectedTable2Type: typeof component.config?.selectedTable, - configSelectedTable: config?.selectedTable, - configSelectedTableType: typeof config?.selectedTable, - screenTableName: tableName, - screenTableNameType: typeof tableName, - finalSelectedTable, - finalSelectedTableType: typeof finalSelectedTable, - }); + // 디버그 로그 제거 (성능 최적화) // 객체인 경우 tableName 속성 추출 시도 if (typeof finalSelectedTable === "object" && finalSelectedTable !== null) { @@ -200,12 +191,7 @@ export const TableListComponent: React.FC = ({ tableConfig.selectedTable = finalSelectedTable; - console.log( - "✅ 최종 tableConfig.selectedTable:", - tableConfig.selectedTable, - "타입:", - typeof tableConfig.selectedTable, - ); + // 디버그 로그 제거 (성능 최적화) const buttonColor = component.style?.labelColor || "#212121"; const buttonTextColor = component.config?.buttonTextColor || "#ffffff"; @@ -262,6 +248,10 @@ export const TableListComponent: React.FC = ({ const [isDragging, setIsDragging] = useState(false); const [draggedRowIndex, setDraggedRowIndex] = useState(null); const [columnWidths, setColumnWidths] = useState>({}); + const [draggedColumnIndex, setDraggedColumnIndex] = useState(null); + const [dragOverColumnIndex, setDragOverColumnIndex] = useState(null); + const [refreshTrigger, setRefreshTrigger] = useState(0); + const [columnOrder, setColumnOrder] = useState([]); const columnRefs = useRef>({}); const [isAllSelected, setIsAllSelected] = useState(false); const hasInitializedWidths = useRef(false); @@ -390,10 +380,10 @@ export const TableListComponent: React.FC = ({ return; } - // 테이블명 확인 로그 - console.log("🔍 fetchTableDataInternal - selectedTable:", tableConfig.selectedTable); - console.log("🔍 selectedTable 타입:", typeof tableConfig.selectedTable); - console.log("🔍 전체 tableConfig:", tableConfig); + // 테이블명 확인 로그 (개발 시에만) + // console.log("🔍 fetchTableDataInternal - selectedTable:", tableConfig.selectedTable); + // console.log("🔍 selectedTable 타입:", typeof tableConfig.selectedTable); + // console.log("🔍 전체 tableConfig:", tableConfig); setLoading(true); setError(null); @@ -488,11 +478,43 @@ export const TableListComponent: React.FC = ({ }; const handleSort = (column: string) => { + console.log("🔄 정렬 클릭:", { column, currentSortColumn: sortColumn, currentSortDirection: sortDirection }); + + let newSortColumn = column; + let newSortDirection: "asc" | "desc" = "asc"; + if (sortColumn === column) { - setSortDirection(sortDirection === "asc" ? "desc" : "asc"); + newSortDirection = sortDirection === "asc" ? "desc" : "asc"; + setSortDirection(newSortDirection); } else { setSortColumn(column); setSortDirection("asc"); + newSortColumn = column; + newSortDirection = "asc"; + } + + console.log("📊 새로운 정렬 정보:", { newSortColumn, newSortDirection }); + console.log("🔍 onSelectedRowsChange 존재 여부:", !!onSelectedRowsChange); + + // 정렬 변경 시 선택 정보와 함께 정렬 정보도 전달 + if (onSelectedRowsChange) { + const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index))); + console.log("✅ 정렬 정보 전달:", { + selectedRowsCount: selectedRows.size, + selectedRowsDataCount: selectedRowsData.length, + sortBy: newSortColumn, + sortOrder: newSortDirection, + columnOrder: columnOrder.length > 0 ? columnOrder : undefined + }); + onSelectedRowsChange( + Array.from(selectedRows), + selectedRowsData, + newSortColumn, + newSortDirection, + columnOrder.length > 0 ? columnOrder : undefined + ); + } else { + console.warn("⚠️ onSelectedRowsChange 콜백이 없습니다!"); } }; @@ -530,7 +552,7 @@ export const TableListComponent: React.FC = ({ const selectedRowsData = data.filter((row, index) => newSelectedRows.has(getRowKey(row, index))); if (onSelectedRowsChange) { - onSelectedRowsChange(Array.from(newSelectedRows), selectedRowsData); + onSelectedRowsChange(Array.from(newSelectedRows), selectedRowsData, sortColumn || undefined, sortDirection); } if (onFormDataChange) { onFormDataChange({ @@ -551,7 +573,7 @@ export const TableListComponent: React.FC = ({ setIsAllSelected(true); if (onSelectedRowsChange) { - onSelectedRowsChange(Array.from(newSelectedRows), data); + onSelectedRowsChange(Array.from(newSelectedRows), data, sortColumn || undefined, sortDirection); } if (onFormDataChange) { onFormDataChange({ @@ -564,7 +586,7 @@ export const TableListComponent: React.FC = ({ setIsAllSelected(false); if (onSelectedRowsChange) { - onSelectedRowsChange([], []); + onSelectedRowsChange([], [], sortColumn || undefined, sortDirection); } if (onFormDataChange) { onFormDataChange({ selectedRows: [], selectedRowsData: [] }); @@ -588,6 +610,58 @@ export const TableListComponent: React.FC = ({ setDraggedRowIndex(null); }; + // 컬럼 드래그앤드롭 핸들러 + const handleColumnDragStart = (e: React.DragEvent, columnIndex: number) => { + console.log("🔄 컬럼 드래그 시작:", columnIndex); + setDraggedColumnIndex(columnIndex); + e.dataTransfer.effectAllowed = "move"; + }; + + const handleColumnDragOver = (e: React.DragEvent, columnIndex: number) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + if (draggedColumnIndex !== null && draggedColumnIndex !== columnIndex) { + setDragOverColumnIndex(columnIndex); + } + }; + + const handleColumnDrop = (e: React.DragEvent, dropColumnIndex: number) => { + e.preventDefault(); + console.log("📥 컬럼 드롭:", { from: draggedColumnIndex, to: dropColumnIndex }); + + if (draggedColumnIndex === null || draggedColumnIndex === dropColumnIndex) { + setDraggedColumnIndex(null); + setDragOverColumnIndex(null); + return; + } + + // 컬럼 순서 변경 + const newColumns = [...visibleColumns]; + const [draggedColumn] = newColumns.splice(draggedColumnIndex, 1); + newColumns.splice(dropColumnIndex, 0, draggedColumn); + + console.log("✅ 컬럼 순서 변경 완료:", newColumns.map(c => c.columnName)); + + // 로컬 스토리지에 저장 (사용자별 설정) + const userKey = userId || 'guest'; + const storageKey = `table_column_order_${tableConfig.selectedTable}_${userKey}`; + const newColumnOrder = newColumns.map(c => c.columnName); + localStorage.setItem(storageKey, JSON.stringify(newColumnOrder)); + console.log("💾 컬럼 순서 저장:", { storageKey, columnOrder: newColumnOrder }); + + // 상태 직접 업데이트 - React가 즉시 리렌더링하도록 + setColumnOrder(newColumnOrder); + console.log("🔄 columnOrder 상태 업데이트:", newColumnOrder); + + setDraggedColumnIndex(null); + setDragOverColumnIndex(null); + }; + + const handleColumnDragEnd = () => { + setDraggedColumnIndex(null); + setDragOverColumnIndex(null); + }; + const handleClick = (e: React.MouseEvent) => { e.stopPropagation(); onClick?.(); @@ -619,8 +693,26 @@ export const TableListComponent: React.FC = ({ } } + // columnOrder 상태가 있으면 그 순서대로 정렬 + if (columnOrder.length > 0) { + const orderedCols = columnOrder + .map(colName => cols.find(c => c.columnName === colName)) + .filter(Boolean) as ColumnConfig[]; + + // columnOrder에 없는 새로운 컬럼들 추가 + const remainingCols = cols.filter(c => !columnOrder.includes(c.columnName)); + + console.log("🔄 columnOrder 기반 정렬:", { + columnOrder, + orderedColsCount: orderedCols.length, + remainingColsCount: remainingCols.length + }); + + return [...orderedCols, ...remainingCols]; + } + return cols.sort((a, b) => (a.order || 0) - (b.order || 0)); - }, [tableConfig.columns, tableConfig.checkbox]); + }, [tableConfig.columns, tableConfig.checkbox, columnOrder]); const getColumnWidth = (column: ColumnConfig) => { if (column.columnName === "__checkbox__") return 50; @@ -1178,6 +1270,11 @@ export const TableListComponent: React.FC = ({ sortColumn={sortColumn} sortDirection={sortDirection} onSort={handleSort} + tableConfig={tableConfig} + isDesignMode={isDesignMode} + isAllSelected={isAllSelected} + handleSelectAll={handleSelectAll} + handleRowClick={handleRowClick} columnLabels={columnLabels} renderCheckboxHeader={renderCheckboxHeader} renderCheckboxCell={renderCheckboxCell} @@ -1289,10 +1386,30 @@ export const TableListComponent: React.FC = ({ (columnRefs.current[column.columnName] = el)} + draggable={!isDesignMode && column.columnName !== "__checkbox__"} + onDragStart={(e) => { + if (column.columnName !== "__checkbox__") { + handleColumnDragStart(e, columnIndex); + } + }} + onDragOver={(e) => { + if (column.columnName !== "__checkbox__") { + handleColumnDragOver(e, columnIndex); + } + }} + onDrop={(e) => { + if (column.columnName !== "__checkbox__") { + handleColumnDrop(e, columnIndex); + } + }} + onDragEnd={handleColumnDragEnd} className={cn( "relative h-10 text-xs font-bold text-foreground/90 overflow-hidden text-ellipsis whitespace-nowrap select-none sm:h-12 sm:text-sm", column.columnName === "__checkbox__" ? "px-0 py-2" : "px-2 py-2 sm:px-6 sm:py-3", - column.sortable && "cursor-pointer hover:bg-muted/70 transition-colors" + (column.sortable !== false && column.columnName !== "__checkbox__") && "cursor-pointer hover:bg-muted/70 transition-colors", + !isDesignMode && column.columnName !== "__checkbox__" && "cursor-move", + draggedColumnIndex === columnIndex && "opacity-50", + dragOverColumnIndex === columnIndex && "bg-primary/20" )} style={{ textAlign: column.columnName === "__checkbox__" ? "center" : "center", @@ -1303,7 +1420,9 @@ export const TableListComponent: React.FC = ({ }} onClick={() => { if (isResizing.current) return; - if (column.sortable) handleSort(column.columnName); + if (column.sortable !== false && column.columnName !== "__checkbox__") { + handleSort(column.columnName); + } }} > {column.columnName === "__checkbox__" ? ( @@ -1311,7 +1430,7 @@ export const TableListComponent: React.FC = ({ ) : (
{columnLabels[column.columnName] || column.displayName} - {column.sortable && sortColumn === column.columnName && ( + {column.sortable !== false && sortColumn === column.columnName && ( {sortDirection === "asc" ? "↑" : "↓"} )}
diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 753deea5..03d56aed 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -20,7 +20,8 @@ export type ButtonActionType = | "view_table_history" // 테이블 이력 보기 | "excel_download" // 엑셀 다운로드 | "excel_upload" // 엑셀 업로드 - | "barcode_scan"; // 바코드 스캔 + | "barcode_scan" // 바코드 스캔 + | "code_merge"; // 코드 병합 /** * 버튼 액션 설정 @@ -73,6 +74,10 @@ export interface ButtonActionConfig { barcodeTargetField?: string; // 스캔 결과를 입력할 필드명 barcodeFormat?: "all" | "1d" | "2d"; // 바코드 포맷 (기본: "all") barcodeAutoSubmit?: boolean; // 스캔 후 자동 제출 여부 + + // 코드 병합 관련 + mergeColumnName?: string; // 병합할 컬럼명 (예: "item_code") + mergeShowPreview?: boolean; // 병합 전 미리보기 표시 여부 (기본: true) } /** @@ -101,8 +106,11 @@ export interface ButtonActionContext { // 제어 실행을 위한 추가 정보 buttonId?: string; - userId?: string; - companyCode?: string; + + // 테이블 정렬 정보 (엑셀 다운로드용) + sortBy?: string; // 정렬 컬럼명 + sortOrder?: "asc" | "desc"; // 정렬 방향 + columnOrder?: string[]; // 컬럼 순서 (사용자가 드래그앤드롭으로 변경한 순서) } /** @@ -147,6 +155,9 @@ export class ButtonActionExecutor { case "barcode_scan": return await this.handleBarcodeScan(config, context); + case "code_merge": + return await this.handleCodeMerge(config, context); + default: console.warn(`지원되지 않는 액션 타입: ${config.type}`); return false; @@ -1688,17 +1699,57 @@ export class ButtonActionExecutor { if (context.selectedRowsData && context.selectedRowsData.length > 0) { dataToExport = context.selectedRowsData; console.log("✅ 선택된 행 데이터 사용:", dataToExport.length); + + // 선택된 행도 정렬 적용 + if (context.sortBy) { + console.log("🔄 선택된 행 데이터 정렬 적용:", { + sortBy: context.sortBy, + sortOrder: context.sortOrder, + }); + + dataToExport = [...dataToExport].sort((a, b) => { + const aVal = a[context.sortBy!]; + const bVal = b[context.sortBy!]; + + // null/undefined 처리 + if (aVal == null && bVal == null) return 0; + if (aVal == null) return 1; + if (bVal == null) return -1; + + // 숫자 비교 + const aNum = Number(aVal); + const bNum = Number(bVal); + if (!isNaN(aNum) && !isNaN(bNum)) { + return context.sortOrder === "desc" ? bNum - aNum : aNum - bNum; + } + + // 문자열 비교 + const aStr = String(aVal); + const bStr = String(bVal); + const comparison = aStr.localeCompare(bStr); + return context.sortOrder === "desc" ? -comparison : comparison; + }); + + console.log("✅ 정렬 완료:", { + firstRow: dataToExport[0], + lastRow: dataToExport[dataToExport.length - 1], + }); + } } // 2순위: 테이블 전체 데이터 (API 호출) else if (context.tableName) { console.log("🔄 테이블 전체 데이터 조회 중...", context.tableName); + console.log("📊 정렬 정보:", { + sortBy: context.sortBy, + sortOrder: context.sortOrder, + }); try { const { dynamicFormApi } = await import("@/lib/api/dynamicForm"); const response = await dynamicFormApi.getTableData(context.tableName, { page: 1, pageSize: 10000, // 최대 10,000개 행 - sortBy: "id", // 기본 정렬: id 컬럼 - sortOrder: "asc", // 오름차순 + sortBy: context.sortBy || "id", // 화면 정렬 또는 기본 정렬 + sortOrder: context.sortOrder || "asc", // 화면 정렬 방향 또는 오름차순 }); console.log("📦 API 응답 구조:", { @@ -1763,12 +1814,43 @@ export class ButtonActionExecutor { const sheetName = config.excelSheetName || "Sheet1"; const includeHeaders = config.excelIncludeHeaders !== false; + // 🆕 컬럼 순서 재정렬 (사용자가 드래그앤드롭으로 변경한 순서 적용) + if (context.columnOrder && context.columnOrder.length > 0 && dataToExport.length > 0) { + console.log("🔄 컬럼 순서 재정렬:", context.columnOrder); + + dataToExport = dataToExport.map((row: any) => { + const reorderedRow: any = {}; + + // 1. columnOrder에 있는 컬럼들을 순서대로 추가 + context.columnOrder!.forEach((colName: string) => { + if (colName in row) { + reorderedRow[colName] = row[colName]; + } + }); + + // 2. columnOrder에 없는 나머지 컬럼들 추가 (끝에 배치) + Object.keys(row).forEach((key) => { + if (!(key in reorderedRow)) { + reorderedRow[key] = row[key]; + } + }); + + return reorderedRow; + }); + + console.log("✅ 컬럼 순서 재정렬 완료:", { + originalColumns: Object.keys(dataToExport[0] || {}), + reorderedColumns: Object.keys(dataToExport[0] || {}), + }); + } + console.log("📥 엑셀 다운로드 실행:", { fileName, sheetName, includeHeaders, dataCount: dataToExport.length, firstRow: dataToExport[0], + columnOrder: context.columnOrder, }); // 엑셀 다운로드 실행 @@ -1892,6 +1974,177 @@ export class ButtonActionExecutor { } } + /** + * 코드 병합 액션 처리 + */ + private static async handleCodeMerge(config: ButtonActionConfig, context: ButtonActionContext): Promise { + try { + console.log("🔀 코드 병합 액션 실행:", { config, context }); + + // 선택된 행 데이터 확인 + const selectedRows = context.selectedRowsData || context.flowSelectedData; + if (!selectedRows || selectedRows.length !== 2) { + toast.error("병합할 두 개의 항목을 선택해주세요."); + return false; + } + + // 병합할 컬럼명 확인 + const columnName = config.mergeColumnName; + if (!columnName) { + toast.error("병합할 컬럼명이 설정되지 않았습니다."); + return false; + } + + // 두 개의 선택된 행에서 컬럼 값 추출 + const [row1, row2] = selectedRows; + const value1 = row1[columnName]; + const value2 = row2[columnName]; + + if (!value1 || !value2) { + toast.error(`선택한 항목에 "${columnName}" 값이 없습니다.`); + return false; + } + + if (value1 === value2) { + toast.error("같은 값은 병합할 수 없습니다."); + return false; + } + + // 병합 방향 선택 모달 표시 + const confirmed = await new Promise<{ confirmed: boolean; oldValue: string; newValue: string }>((resolve) => { + const modalHtml = ` +
+
+

코드 병합 방향 선택

+

어느 코드로 병합하시겠습니까?

+ +
+ + + +
+ +
+ +
+
+
+ `; + + const modalContainer = document.createElement("div"); + modalContainer.innerHTML = modalHtml; + document.body.appendChild(modalContainer); + + const option1Btn = modalContainer.querySelector("#merge-option-1") as HTMLButtonElement; + const option2Btn = modalContainer.querySelector("#merge-option-2") as HTMLButtonElement; + const cancelBtn = modalContainer.querySelector("#merge-cancel") as HTMLButtonElement; + + // 호버 효과 + [option1Btn, option2Btn].forEach((btn) => { + btn.addEventListener("mouseenter", () => { + btn.style.borderColor = "#3b82f6"; + btn.style.background = "#eff6ff"; + }); + btn.addEventListener("mouseleave", () => { + btn.style.borderColor = "#e5e7eb"; + btn.style.background = "white"; + }); + }); + + option1Btn.addEventListener("click", () => { + document.body.removeChild(modalContainer); + resolve({ confirmed: true, oldValue: value2, newValue: value1 }); + }); + + option2Btn.addEventListener("click", () => { + document.body.removeChild(modalContainer); + resolve({ confirmed: true, oldValue: value1, newValue: value2 }); + }); + + cancelBtn.addEventListener("click", () => { + document.body.removeChild(modalContainer); + resolve({ confirmed: false, oldValue: "", newValue: "" }); + }); + }); + + if (!confirmed.confirmed) { + return false; + } + + const { oldValue, newValue } = confirmed; + + // 미리보기 표시 (옵션) + if (config.mergeShowPreview !== false) { + const { apiClient } = await import("@/lib/api/client"); + + const previewResponse = await apiClient.post("/code-merge/preview", { + columnName, + oldValue, + }); + + if (previewResponse.data.success) { + const preview = previewResponse.data.data; + const totalRows = preview.totalAffectedRows; + + const confirmMerge = confirm( + `⚠️ 코드 병합 확인\n\n` + + `${oldValue} → ${newValue}\n\n` + + `영향받는 데이터:\n` + + `- 테이블 수: ${preview.preview.length}개\n` + + `- 총 행 수: ${totalRows}개\n\n` + + `데이터는 삭제되지 않고, "${columnName}" 컬럼 값만 변경됩니다.\n\n` + + `계속하시겠습니까?` + ); + + if (!confirmMerge) { + return false; + } + } + } + + // 병합 실행 + toast.loading("코드 병합 중...", { duration: Infinity }); + + const { apiClient } = await import("@/lib/api/client"); + + const response = await apiClient.post("/code-merge/merge-all-tables", { + columnName, + oldValue, + newValue, + }); + + toast.dismiss(); + + if (response.data.success) { + const data = response.data.data; + toast.success( + `코드 병합 완료!\n` + + `${data.affectedTables.length}개 테이블, ${data.totalRowsUpdated}개 행 업데이트` + ); + + // 화면 새로고침 + context.onRefresh?.(); + context.onFlowRefresh?.(); + + return true; + } else { + toast.error(response.data.message || "코드 병합에 실패했습니다."); + return false; + } + } catch (error: any) { + console.error("❌ 코드 병합 실패:", error); + toast.dismiss(); + toast.error(error.response?.data?.message || "코드 병합 중 오류가 발생했습니다."); + return false; + } + } + /** * 폼 데이터 유효성 검사 */ @@ -1981,4 +2234,11 @@ export const DEFAULT_BUTTON_ACTIONS: Record Date: Wed, 5 Nov 2025 10:23:00 +0900 Subject: [PATCH 10/59] =?UTF-8?q?=EC=97=91=EC=85=80=20=EB=8B=A4=EC=9A=B4?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/(main)/screens/[screenId]/page.tsx | 12 +- .../components/screen/RealtimePreview.tsx | 15 +- .../lib/registry/DynamicComponentRenderer.tsx | 8 +- .../button-primary/ButtonPrimaryComponent.tsx | 3 + .../table-list/TableListComponent.tsx | 242 +++++++++++++++++- frontend/lib/utils/buttonActions.ts | 71 ++++- frontend/stores/tableDisplayStore.ts | 110 ++++++++ 7 files changed, 447 insertions(+), 14 deletions(-) create mode 100644 frontend/stores/tableDisplayStore.ts diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 0c9a681b..741a5175 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -44,6 +44,7 @@ export default function ScreenViewPage() { const [tableSortBy, setTableSortBy] = useState(); const [tableSortOrder, setTableSortOrder] = useState<"asc" | "desc">("asc"); const [tableColumnOrder, setTableColumnOrder] = useState(); + const [tableDisplayData, setTableDisplayData] = useState([]); // 화면에 표시된 데이터 (컬럼 순서 포함) // 플로우에서 선택된 데이터 (버튼 액션에 전달) const [flowSelectedData, setFlowSelectedData] = useState([]); @@ -433,13 +434,16 @@ export default function ScreenViewPage() { sortBy={tableSortBy} sortOrder={tableSortOrder} columnOrder={tableColumnOrder} - onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder) => { + tableDisplayData={tableDisplayData} + onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder, tableDisplayData) => { console.log("🔍 화면에서 선택된 행 데이터:", selectedData); console.log("📊 정렬 정보:", { sortBy, sortOrder, columnOrder }); + console.log("📊 화면 표시 데이터:", { count: tableDisplayData?.length, firstRow: tableDisplayData?.[0] }); setSelectedRowsData(selectedData); setTableSortBy(sortBy); setTableSortOrder(sortOrder || "asc"); setTableColumnOrder(columnOrder); + setTableDisplayData(tableDisplayData || []); }} flowSelectedData={flowSelectedData} flowSelectedStepId={flowSelectedStepId} @@ -494,13 +498,16 @@ export default function ScreenViewPage() { sortBy={tableSortBy} sortOrder={tableSortOrder} columnOrder={tableColumnOrder} - onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder) => { + tableDisplayData={tableDisplayData} + onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder, tableDisplayData) => { console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData); console.log("📊 정렬 정보 (자식):", { sortBy, sortOrder, columnOrder }); + console.log("📊 화면 표시 데이터 (자식):", { count: tableDisplayData?.length, firstRow: tableDisplayData?.[0] }); setSelectedRowsData(selectedData); setTableSortBy(sortBy); setTableSortOrder(sortOrder || "asc"); setTableColumnOrder(columnOrder); + setTableDisplayData(tableDisplayData || []); }} refreshKey={tableRefreshKey} onRefresh={() => { @@ -631,6 +638,7 @@ export default function ScreenViewPage() { userId={user?.userId} userName={userName} companyCode={companyCode} + tableDisplayData={tableDisplayData} selectedRowsData={selectedRowsData} sortBy={tableSortBy} sortOrder={tableSortOrder} diff --git a/frontend/components/screen/RealtimePreview.tsx b/frontend/components/screen/RealtimePreview.tsx index 777facef..097e6c71 100644 --- a/frontend/components/screen/RealtimePreview.tsx +++ b/frontend/components/screen/RealtimePreview.tsx @@ -66,6 +66,7 @@ interface RealtimePreviewProps { // 테이블 정렬 정보 전달용 sortBy?: string; sortOrder?: "asc" | "desc"; + tableDisplayData?: any[]; // 🆕 화면 표시 데이터 [key: string]: any; // 추가 props 허용 } @@ -109,7 +110,14 @@ const renderArea = (component: ComponentData, children?: React.ReactNode) => { }; // 동적 웹 타입 위젯 렌더링 컴포넌트 -const WidgetRenderer: React.FC<{ component: ComponentData; isDesignMode?: boolean }> = ({ component, isDesignMode = false }) => { +const WidgetRenderer: React.FC<{ + component: ComponentData; + isDesignMode?: boolean; + sortBy?: string; + sortOrder?: "asc" | "desc"; + tableDisplayData?: any[]; + [key: string]: any; +}> = ({ component, isDesignMode = false, sortBy, sortOrder, tableDisplayData, ...restProps }) => { // 위젯 컴포넌트가 아닌 경우 빈 div 반환 if (!isWidgetComponent(component)) { return
위젯이 아닙니다
; @@ -158,6 +166,9 @@ const WidgetRenderer: React.FC<{ component: ComponentData; isDesignMode?: boolea readonly: readonly, isDesignMode, isInteractive: !isDesignMode, + sortBy, // 🆕 정렬 정보 + sortOrder, // 🆕 정렬 방향 + tableDisplayData, // 🆕 화면 표시 데이터 }} config={widget.webTypeConfig} /> @@ -231,6 +242,7 @@ export const RealtimePreviewDynamic: React.FC = ({ onFlowSelectedDataChange, sortBy, sortOrder, + tableDisplayData, // 🆕 화면 표시 데이터 ...restProps }) => { const { user } = useAuth(); @@ -557,6 +569,7 @@ export const RealtimePreviewDynamic: React.FC = ({ isDesignMode={isDesignMode} sortBy={sortBy} sortOrder={sortOrder} + tableDisplayData={tableDisplayData} {...restProps} />
diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index cdb81291..2c646138 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -29,10 +29,11 @@ export interface ComponentRenderer { // 테이블 선택된 행 정보 (다중 선택 액션용) selectedRows?: any[]; selectedRowsData?: any[]; - onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[]) => void; + onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[], tableDisplayData?: any[]) => void; // 테이블 정렬 정보 (엑셀 다운로드용) sortBy?: string; sortOrder?: "asc" | "desc"; + tableDisplayData?: any[]; // 🆕 화면 표시 데이터 // 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용) flowSelectedData?: any[]; flowSelectedStepId?: number | null; @@ -104,11 +105,12 @@ export interface DynamicComponentRendererProps { // 테이블 선택된 행 정보 (다중 선택 액션용) selectedRows?: any[]; selectedRowsData?: any[]; - onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[]) => void; + onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[], tableDisplayData?: any[]) => void; // 테이블 정렬 정보 (엑셀 다운로드용) sortBy?: string; sortOrder?: "asc" | "desc"; columnOrder?: string[]; + tableDisplayData?: any[]; // 🆕 화면 표시 데이터 // 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용) flowSelectedData?: any[]; flowSelectedStepId?: number | null; @@ -200,6 +202,7 @@ export const DynamicComponentRenderer: React.FC = onSelectedRowsChange, sortBy, // 🆕 정렬 컬럼 sortOrder, // 🆕 정렬 방향 + tableDisplayData, // 🆕 화면 표시 데이터 flowSelectedData, flowSelectedStepId, onFlowSelectedDataChange, @@ -290,6 +293,7 @@ export const DynamicComponentRenderer: React.FC = // 테이블 정렬 정보 전달 sortBy, sortOrder, + tableDisplayData, // 🆕 화면 표시 데이터 // 플로우 선택된 데이터 정보 전달 flowSelectedData, flowSelectedStepId, diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index a2c584af..db4e150e 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -47,6 +47,7 @@ export interface ButtonPrimaryComponentProps extends ComponentRendererProps { sortBy?: string; sortOrder?: "asc" | "desc"; columnOrder?: string[]; + tableDisplayData?: any[]; // 화면에 표시된 데이터 (컬럼 순서 포함) // 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용) flowSelectedData?: any[]; @@ -82,6 +83,7 @@ export const ButtonPrimaryComponent: React.FC = ({ sortBy, // 🆕 정렬 컬럼 sortOrder, // 🆕 정렬 방향 columnOrder, // 🆕 컬럼 순서 + tableDisplayData, // 🆕 화면에 표시된 데이터 selectedRows, selectedRowsData, flowSelectedData, @@ -417,6 +419,7 @@ export const ButtonPrimaryComponent: React.FC = ({ sortBy, // 🆕 정렬 컬럼 sortOrder, // 🆕 정렬 방향 columnOrder, // 🆕 컬럼 순서 + tableDisplayData, // 🆕 화면에 표시된 데이터 // 플로우 선택된 데이터 정보 추가 flowSelectedData, flowSelectedStepId, diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index d5152319..fad0a5c4 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -25,6 +25,7 @@ import { import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; +import { tableDisplayStore } from "@/stores/tableDisplayStore"; import { Dialog, DialogContent, @@ -139,7 +140,7 @@ export interface TableListComponentProps { onClose?: () => void; screenId?: string; userId?: string; // 사용자 ID (컬럼 순서 저장용) - onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[]) => void; + onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[], tableDisplayData?: any[]) => void; onConfigChange?: (config: any) => void; refreshKey?: number; } @@ -266,6 +267,62 @@ export const TableListComponent: React.FC = ({ const [groupByColumns, setGroupByColumns] = useState([]); const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); + // 🆕 초기 로드 시 localStorage에서 컬럼 순서 불러오기 + useEffect(() => { + if (!tableConfig.selectedTable || !userId) return; + + const userKey = userId || 'guest'; + const storageKey = `table_column_order_${tableConfig.selectedTable}_${userKey}`; + const savedOrder = localStorage.getItem(storageKey); + + if (savedOrder) { + try { + const parsedOrder = JSON.parse(savedOrder); + console.log("📂 localStorage에서 컬럼 순서 불러오기:", { storageKey, columnOrder: parsedOrder }); + setColumnOrder(parsedOrder); + + // 부모 컴포넌트에 초기 컬럼 순서 전달 + if (onSelectedRowsChange && parsedOrder.length > 0) { + console.log("✅ 초기 컬럼 순서 전달:", parsedOrder); + + // 초기 데이터도 함께 전달 (컬럼 순서대로 재정렬) + const initialData = data.map((row: any) => { + const reordered: any = {}; + parsedOrder.forEach((colName: string) => { + if (colName in row) { + reordered[colName] = row[colName]; + } + }); + // 나머지 컬럼 추가 + Object.keys(row).forEach((key) => { + if (!(key in reordered)) { + reordered[key] = row[key]; + } + }); + return reordered; + }); + + console.log("📊 초기 화면 표시 데이터 전달:", { count: initialData.length, firstRow: initialData[0] }); + + // 전역 저장소에 데이터 저장 + if (tableConfig.selectedTable) { + tableDisplayStore.setTableData( + tableConfig.selectedTable, + initialData, + parsedOrder.filter(col => col !== '__checkbox__'), + sortColumn, + sortDirection + ); + } + + onSelectedRowsChange([], [], sortColumn, sortDirection, parsedOrder, initialData); + } + } catch (error) { + console.error("❌ 컬럼 순서 파싱 실패:", error); + } + } + }, [tableConfig.selectedTable, userId, data.length]); // data.length 추가 (데이터 로드 후 실행) + const { optimizedConvertCode } = useEntityJoinOptimization(columnMeta, { enableBatchLoading: true, preloadCommonCodes: true, @@ -499,20 +556,78 @@ export const TableListComponent: React.FC = ({ // 정렬 변경 시 선택 정보와 함께 정렬 정보도 전달 if (onSelectedRowsChange) { const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index))); + + // 1단계: 데이터를 정렬 + const sortedData = [...data].sort((a, b) => { + const aVal = a[newSortColumn]; + const bVal = b[newSortColumn]; + + // null/undefined 처리 + if (aVal == null && bVal == null) return 0; + if (aVal == null) return 1; + if (bVal == null) return -1; + + // 숫자 비교 + const aNum = Number(aVal); + const bNum = Number(bVal); + if (!isNaN(aNum) && !isNaN(bNum)) { + return newSortDirection === "desc" ? bNum - aNum : aNum - bNum; + } + + // 문자열 비교 + const aStr = String(aVal); + const bStr = String(bVal); + const comparison = aStr.localeCompare(bStr); + return newSortDirection === "desc" ? -comparison : comparison; + }); + + // 2단계: 정렬된 데이터를 컬럼 순서대로 재정렬 + const reorderedData = sortedData.map((row: any) => { + const reordered: any = {}; + visibleColumns.forEach((col) => { + if (col.columnName in row) { + reordered[col.columnName] = row[col.columnName]; + } + }); + // 나머지 컬럼 추가 + Object.keys(row).forEach((key) => { + if (!(key in reordered)) { + reordered[key] = row[key]; + } + }); + return reordered; + }); + console.log("✅ 정렬 정보 전달:", { selectedRowsCount: selectedRows.size, selectedRowsDataCount: selectedRowsData.length, sortBy: newSortColumn, sortOrder: newSortDirection, - columnOrder: columnOrder.length > 0 ? columnOrder : undefined + columnOrder: columnOrder.length > 0 ? columnOrder : undefined, + tableDisplayDataCount: reorderedData.length, + firstRowAfterSort: reorderedData[0]?.[newSortColumn], + lastRowAfterSort: reorderedData[reorderedData.length - 1]?.[newSortColumn] }); onSelectedRowsChange( Array.from(selectedRows), selectedRowsData, newSortColumn, newSortDirection, - columnOrder.length > 0 ? columnOrder : undefined + columnOrder.length > 0 ? columnOrder : undefined, + reorderedData ); + + // 전역 저장소에 정렬된 데이터 저장 + if (tableConfig.selectedTable) { + const cleanColumnOrder = (columnOrder.length > 0 ? columnOrder : visibleColumns.map(c => c.columnName)).filter(col => col !== '__checkbox__'); + tableDisplayStore.setTableData( + tableConfig.selectedTable, + reorderedData, + cleanColumnOrder, + newSortColumn, + newSortDirection + ); + } } else { console.warn("⚠️ onSelectedRowsChange 콜백이 없습니다!"); } @@ -653,6 +768,55 @@ export const TableListComponent: React.FC = ({ setColumnOrder(newColumnOrder); console.log("🔄 columnOrder 상태 업데이트:", newColumnOrder); + // 컬럼 순서 변경을 부모 컴포넌트에 전달 (엑셀 다운로드용) + if (onSelectedRowsChange) { + const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index))); + + // 화면에 표시된 데이터를 새로운 컬럼 순서대로 재정렬 + const reorderedData = data.map((row: any) => { + const reordered: any = {}; + newColumns.forEach((col) => { + if (col.columnName in row) { + reordered[col.columnName] = row[col.columnName]; + } + }); + // 나머지 컬럼 추가 + Object.keys(row).forEach((key) => { + if (!(key in reordered)) { + reordered[key] = row[key]; + } + }); + return reordered; + }); + + console.log("✅ 컬럼 순서 변경 정보 전달:", { + columnOrder: newColumnOrder, + sortBy: sortColumn, + sortOrder: sortDirection, + reorderedDataCount: reorderedData.length + }); + onSelectedRowsChange( + Array.from(selectedRows), + selectedRowsData, + sortColumn, + sortDirection, + newColumnOrder, + reorderedData + ); + + // 전역 저장소에 컬럼 순서 변경된 데이터 저장 + if (tableConfig.selectedTable) { + const cleanColumnOrder = newColumnOrder.filter(col => col !== '__checkbox__'); + tableDisplayStore.setTableData( + tableConfig.selectedTable, + reorderedData, + cleanColumnOrder, + sortColumn, + sortDirection + ); + } + } + setDraggedColumnIndex(null); setDragOverColumnIndex(null); }; @@ -714,6 +878,78 @@ export const TableListComponent: React.FC = ({ return cols.sort((a, b) => (a.order || 0) - (b.order || 0)); }, [tableConfig.columns, tableConfig.checkbox, columnOrder]); + // 🆕 visibleColumns가 변경될 때마다 현재 컬럼 순서를 부모에게 전달 + const lastColumnOrderRef = useRef(""); + + useEffect(() => { + console.log("🔍 [컬럼 순서 전달 useEffect] 실행됨:", { + hasCallback: !!onSelectedRowsChange, + visibleColumnsLength: visibleColumns.length, + visibleColumnsNames: visibleColumns.map(c => c.columnName), + }); + + if (!onSelectedRowsChange) { + console.warn("⚠️ onSelectedRowsChange 콜백이 없습니다!"); + return; + } + + if (visibleColumns.length === 0) { + console.warn("⚠️ visibleColumns가 비어있습니다!"); + return; + } + + const currentColumnOrder = visibleColumns + .map(col => col.columnName) + .filter(name => name !== "__checkbox__"); // 체크박스 컬럼 제외 + + console.log("🔍 [컬럼 순서] 체크박스 제외 후:", currentColumnOrder); + + // 컬럼 순서가 실제로 변경되었을 때만 전달 (무한 루프 방지) + const columnOrderString = currentColumnOrder.join(","); + console.log("🔍 [컬럼 순서] 비교:", { + current: columnOrderString, + last: lastColumnOrderRef.current, + isDifferent: columnOrderString !== lastColumnOrderRef.current, + }); + + if (columnOrderString === lastColumnOrderRef.current) { + console.log("⏭️ 컬럼 순서 변경 없음, 전달 스킵"); + return; + } + + lastColumnOrderRef.current = columnOrderString; + console.log("📊 현재 화면 컬럼 순서 전달:", currentColumnOrder); + + // 선택된 행 데이터 가져오기 + const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index))); + + // 화면에 표시된 데이터를 컬럼 순서대로 재정렬 + const reorderedData = data.map((row: any) => { + const reordered: any = {}; + visibleColumns.forEach((col) => { + if (col.columnName in row) { + reordered[col.columnName] = row[col.columnName]; + } + }); + // 나머지 컬럼 추가 + Object.keys(row).forEach((key) => { + if (!(key in reordered)) { + reordered[key] = row[key]; + } + }); + return reordered; + }); + + onSelectedRowsChange( + Array.from(selectedRows), + selectedRowsData, + sortColumn, + sortDirection, + currentColumnOrder, + reorderedData + ); + }, [visibleColumns.length, visibleColumns.map(c => c.columnName).join(",")]); // 의존성 단순화 + const getColumnWidth = (column: ColumnConfig) => { if (column.columnName === "__checkbox__") return 50; if (column.width) return column.width; diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 03d56aed..b3263a6c 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -1689,6 +1689,17 @@ export class ButtonActionExecutor { private static async handleExcelDownload(config: ButtonActionConfig, context: ButtonActionContext): Promise { try { console.log("📥 엑셀 다운로드 시작:", { config, context }); + console.log("🔍 context.columnOrder 확인:", { + hasColumnOrder: !!context.columnOrder, + columnOrderLength: context.columnOrder?.length, + columnOrder: context.columnOrder, + }); + console.log("🔍 context.tableDisplayData 확인:", { + hasTableDisplayData: !!context.tableDisplayData, + tableDisplayDataLength: context.tableDisplayData?.length, + tableDisplayDataFirstRow: context.tableDisplayData?.[0], + tableDisplayDataColumns: context.tableDisplayData?.[0] ? Object.keys(context.tableDisplayData[0]) : [], + }); // 동적 import로 엑셀 유틸리티 로드 const { exportToExcel } = await import("@/lib/utils/excelExport"); @@ -1736,8 +1747,38 @@ export class ButtonActionExecutor { }); } } - // 2순위: 테이블 전체 데이터 (API 호출) + // 2순위: 화면 표시 데이터 (컬럼 순서 포함, 정렬 적용됨) + else if (context.tableDisplayData && context.tableDisplayData.length > 0) { + dataToExport = context.tableDisplayData; + console.log("✅ 화면 표시 데이터 사용 (context):", { + count: dataToExport.length, + firstRow: dataToExport[0], + columns: Object.keys(dataToExport[0] || {}), + }); + } + // 2.5순위: 전역 저장소에서 화면 표시 데이터 조회 else if (context.tableName) { + const { tableDisplayStore } = await import("@/stores/tableDisplayStore"); + const storedData = tableDisplayStore.getTableData(context.tableName); + + if (storedData && storedData.data.length > 0) { + dataToExport = storedData.data; + console.log("✅ 화면 표시 데이터 사용 (전역 저장소):", { + tableName: context.tableName, + count: dataToExport.length, + firstRow: dataToExport[0], + lastRow: dataToExport[dataToExport.length - 1], + columns: Object.keys(dataToExport[0] || {}), + columnOrder: storedData.columnOrder, + sortBy: storedData.sortBy, + sortOrder: storedData.sortOrder, + // 정렬 컬럼의 첫/마지막 값 확인 + firstSortValue: storedData.sortBy ? dataToExport[0]?.[storedData.sortBy] : undefined, + lastSortValue: storedData.sortBy ? dataToExport[dataToExport.length - 1]?.[storedData.sortBy] : undefined, + }); + } + // 3순위: 테이블 전체 데이터 (API 호출) + else { console.log("🔄 테이블 전체 데이터 조회 중...", context.tableName); console.log("📊 정렬 정보:", { sortBy: context.sortBy, @@ -1773,6 +1814,7 @@ export class ButtonActionExecutor { } catch (error) { console.error("❌ 테이블 데이터 조회 실패:", error); } + } } // 4순위: 폼 데이터 else if (context.formData && Object.keys(context.formData).length > 0) { @@ -1814,15 +1856,26 @@ export class ButtonActionExecutor { const sheetName = config.excelSheetName || "Sheet1"; const includeHeaders = config.excelIncludeHeaders !== false; - // 🆕 컬럼 순서 재정렬 (사용자가 드래그앤드롭으로 변경한 순서 적용) - if (context.columnOrder && context.columnOrder.length > 0 && dataToExport.length > 0) { - console.log("🔄 컬럼 순서 재정렬:", context.columnOrder); + // 🆕 컬럼 순서 재정렬 (화면에 표시된 순서대로) + let columnOrder: string[] | undefined = context.columnOrder; + + // columnOrder가 없으면 tableDisplayData에서 추출 시도 + if (!columnOrder && context.tableDisplayData && context.tableDisplayData.length > 0) { + columnOrder = Object.keys(context.tableDisplayData[0]); + console.log("📊 tableDisplayData에서 컬럼 순서 추출:", columnOrder); + } + + if (columnOrder && columnOrder.length > 0 && dataToExport.length > 0) { + console.log("🔄 컬럼 순서 재정렬 시작:", { + columnOrder, + originalColumns: Object.keys(dataToExport[0] || {}), + }); dataToExport = dataToExport.map((row: any) => { const reorderedRow: any = {}; // 1. columnOrder에 있는 컬럼들을 순서대로 추가 - context.columnOrder!.forEach((colName: string) => { + columnOrder!.forEach((colName: string) => { if (colName in row) { reorderedRow[colName] = row[colName]; } @@ -1839,9 +1892,15 @@ export class ButtonActionExecutor { }); console.log("✅ 컬럼 순서 재정렬 완료:", { - originalColumns: Object.keys(dataToExport[0] || {}), reorderedColumns: Object.keys(dataToExport[0] || {}), }); + } else { + console.log("⏭️ 컬럼 순서 재정렬 스킵:", { + hasColumnOrder: !!columnOrder, + columnOrderLength: columnOrder?.length, + hasTableDisplayData: !!context.tableDisplayData, + dataToExportLength: dataToExport.length, + }); } console.log("📥 엑셀 다운로드 실행:", { diff --git a/frontend/stores/tableDisplayStore.ts b/frontend/stores/tableDisplayStore.ts new file mode 100644 index 00000000..570f41f0 --- /dev/null +++ b/frontend/stores/tableDisplayStore.ts @@ -0,0 +1,110 @@ +/** + * 테이블 화면 표시 데이터 전역 저장소 + * 엑셀 다운로드 등에서 현재 화면에 표시된 데이터에 접근하기 위함 + */ + +interface TableDisplayState { + data: any[]; + columnOrder: string[]; + sortBy: string | null; + sortOrder: "asc" | "desc"; + tableName: string; +} + +class TableDisplayStore { + private state: Map = new Map(); + private listeners: Set<() => void> = new Set(); + + /** + * 테이블 표시 데이터 저장 + * @param tableName 테이블명 + * @param data 화면에 표시된 데이터 + * @param columnOrder 컬럼 순서 + * @param sortBy 정렬 컬럼 + * @param sortOrder 정렬 방향 + */ + setTableData( + tableName: string, + data: any[], + columnOrder: string[], + sortBy: string | null, + sortOrder: "asc" | "desc" + ) { + this.state.set(tableName, { + data, + columnOrder, + sortBy, + sortOrder, + tableName, + }); + + console.log("📦 [TableDisplayStore] 데이터 저장:", { + tableName, + dataCount: data.length, + columnOrderLength: columnOrder.length, + sortBy, + sortOrder, + firstRow: data[0], + }); + + this.notifyListeners(); + } + + /** + * 테이블 표시 데이터 조회 + * @param tableName 테이블명 + */ + getTableData(tableName: string): TableDisplayState | undefined { + const state = this.state.get(tableName); + + console.log("📤 [TableDisplayStore] 데이터 조회:", { + tableName, + found: !!state, + dataCount: state?.data.length, + }); + + return state; + } + + /** + * 모든 테이블 데이터 조회 + */ + getAllTableData(): Map { + return new Map(this.state); + } + + /** + * 테이블 데이터 삭제 + * @param tableName 테이블명 + */ + clearTableData(tableName: string) { + this.state.delete(tableName); + this.notifyListeners(); + } + + /** + * 모든 데이터 삭제 + */ + clearAll() { + this.state.clear(); + this.notifyListeners(); + } + + /** + * 변경 리스너 등록 + */ + subscribe(listener: () => void) { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + private notifyListeners() { + this.listeners.forEach((listener) => listener()); + } +} + +// 싱글톤 인스턴스 +export const tableDisplayStore = new TableDisplayStore(); + From 573a300a4a6aebb8d2ab9b2a069bc4c11587d9d6 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 5 Nov 2025 15:23:57 +0900 Subject: [PATCH 11/59] =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20?= =?UTF-8?q?=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 --- backend-node/src/app.ts | 2 + .../tableCategoryValueController.ts | 246 ++ .../src/routes/tableCategoryValueRoutes.ts | 50 + backend-node/src/services/dataService.ts | 257 +- .../src/services/tableCategoryValueService.ts | 497 ++++ backend-node/src/types/tableCategoryValue.ts | 48 + .../screen/InteractiveScreenViewerDynamic.tsx | 2 +- .../components/screen/RealtimePreview.tsx | 2 +- .../screen/RealtimePreviewDynamic.tsx | 3 +- .../screen/panels/UnifiedPropertiesPanel.tsx | 7 +- .../screen/widgets/CategoryWidget.tsx | 81 + .../screen/widgets/types/ButtonWidget.tsx | 2 +- .../table-category/CategoryColumnList.tsx | 187 ++ .../table-category/CategoryValueAddDialog.tsx | 170 ++ .../CategoryValueEditDialog.tsx | 175 ++ .../table-category/CategoryValueManager.tsx | 378 +++ frontend/constants/tableManagement.ts | 7 + frontend/lib/api/tableCategoryValue.ts | 128 + .../lib/registry/DynamicComponentRenderer.tsx | 6 + .../registry/components/category-manager.tsx | 69 + .../CategoryManagerConfigPanel.tsx | 117 + .../CategoryManagerRenderer.tsx | 76 + frontend/lib/registry/components/index.ts | 1 + frontend/types/input-types.ts | 20 +- frontend/types/tableCategoryValue.ts | 48 + frontend/types/unified-core.ts | 3 +- 동적_테이블_접근_시스템_개선_완료.md | 377 +++ 카테고리_관리_컴포넌트_구현_계획서.md | 2320 +++++++++++++++++ 카테고리_시스템_구현_계획서.md | 1524 +++++++++++ 카테고리_시스템_재구현_계획서.md | 666 +++++ 카테고리_시스템_재구현_완료_보고서.md | 629 +++++ 카테고리_시스템_최종_완료_보고서.md | 483 ++++ 카테고리_컴포넌트_DB_호환성_분석.md | 361 +++ 카테고리_컴포넌트_구현_완료.md | 471 ++++ 카테고리_타입_구현_완료.md | 295 +++ 35 files changed, 9577 insertions(+), 131 deletions(-) create mode 100644 backend-node/src/controllers/tableCategoryValueController.ts create mode 100644 backend-node/src/routes/tableCategoryValueRoutes.ts create mode 100644 backend-node/src/services/tableCategoryValueService.ts create mode 100644 backend-node/src/types/tableCategoryValue.ts create mode 100644 frontend/components/screen/widgets/CategoryWidget.tsx create mode 100644 frontend/components/table-category/CategoryColumnList.tsx create mode 100644 frontend/components/table-category/CategoryValueAddDialog.tsx create mode 100644 frontend/components/table-category/CategoryValueEditDialog.tsx create mode 100644 frontend/components/table-category/CategoryValueManager.tsx create mode 100644 frontend/lib/api/tableCategoryValue.ts create mode 100644 frontend/lib/registry/components/category-manager.tsx create mode 100644 frontend/lib/registry/components/category-manager/CategoryManagerConfigPanel.tsx create mode 100644 frontend/lib/registry/components/category-manager/CategoryManagerRenderer.tsx create mode 100644 frontend/types/tableCategoryValue.ts create mode 100644 동적_테이블_접근_시스템_개선_완료.md create mode 100644 카테고리_관리_컴포넌트_구현_계획서.md create mode 100644 카테고리_시스템_구현_계획서.md create mode 100644 카테고리_시스템_재구현_계획서.md create mode 100644 카테고리_시스템_재구현_완료_보고서.md create mode 100644 카테고리_시스템_최종_완료_보고서.md create mode 100644 카테고리_컴포넌트_DB_호환성_분석.md create mode 100644 카테고리_컴포넌트_구현_완료.md create mode 100644 카테고리_타입_구현_완료.md diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 131b9e1a..c5af1bfe 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -66,6 +66,7 @@ import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변 import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리 import numberingRuleController from "./controllers/numberingRuleController"; // 채번 규칙 관리 import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리 +import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -226,6 +227,7 @@ app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 app.use("/api/roles", roleRoutes); // 권한 그룹 관리 app.use("/api/numbering-rules", numberingRuleController); // 채번 규칙 관리 app.use("/api/departments", departmentRoutes); // 부서 관리 +app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리 // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); diff --git a/backend-node/src/controllers/tableCategoryValueController.ts b/backend-node/src/controllers/tableCategoryValueController.ts new file mode 100644 index 00000000..75837300 --- /dev/null +++ b/backend-node/src/controllers/tableCategoryValueController.ts @@ -0,0 +1,246 @@ +import { Request, Response } from "express"; +import tableCategoryValueService from "../services/tableCategoryValueService"; +import { logger } from "../utils/logger"; + +/** + * 테이블의 카테고리 컬럼 목록 조회 + */ +export const getCategoryColumns = async (req: Request, res: Response) => { + try { + const companyCode = req.user!.companyCode; + const { tableName } = req.params; + + const columns = await tableCategoryValueService.getCategoryColumns( + tableName, + companyCode + ); + + return res.json({ + success: true, + data: columns, + }); + } catch (error: any) { + logger.error(`카테고리 컬럼 조회 실패: ${error.message}`); + return res.status(500).json({ + success: false, + message: "카테고리 컬럼 조회 중 오류가 발생했습니다", + error: error.message, + }); + } +}; + +/** + * 카테고리 값 목록 조회 (메뉴 스코프 적용) + */ +export const getCategoryValues = async (req: Request, res: Response) => { + try { + const companyCode = req.user!.companyCode; + const { tableName, columnName } = req.params; + const menuId = parseInt(req.query.menuId as string, 10); + const includeInactive = req.query.includeInactive === "true"; + + if (!menuId || isNaN(menuId)) { + return res.status(400).json({ + success: false, + message: "menuId 파라미터가 필요합니다", + }); + } + + const values = await tableCategoryValueService.getCategoryValues( + tableName, + columnName, + menuId, + companyCode, + includeInactive + ); + + return res.json({ + success: true, + data: values, + }); + } catch (error: any) { + logger.error(`카테고리 값 조회 실패: ${error.message}`); + return res.status(500).json({ + success: false, + message: "카테고리 값 조회 중 오류가 발생했습니다", + error: error.message, + }); + } +}; + +/** + * 카테고리 값 추가 + */ +export const addCategoryValue = async (req: Request, res: Response) => { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const value = req.body; + + const newValue = await tableCategoryValueService.addCategoryValue( + value, + companyCode, + userId + ); + + return res.status(201).json({ + success: true, + data: newValue, + }); + } catch (error: any) { + logger.error(`카테고리 값 추가 실패: ${error.message}`); + return res.status(500).json({ + success: false, + message: error.message || "카테고리 값 추가 중 오류가 발생했습니다", + error: error.message, + }); + } +}; + +/** + * 카테고리 값 수정 + */ +export const updateCategoryValue = async (req: Request, res: Response) => { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const valueId = parseInt(req.params.valueId); + const updates = req.body; + + if (isNaN(valueId)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 값 ID입니다", + }); + } + + const updatedValue = await tableCategoryValueService.updateCategoryValue( + valueId, + updates, + companyCode, + userId + ); + + return res.json({ + success: true, + data: updatedValue, + }); + } catch (error: any) { + logger.error(`카테고리 값 수정 실패: ${error.message}`); + return res.status(500).json({ + success: false, + message: "카테고리 값 수정 중 오류가 발생했습니다", + error: error.message, + }); + } +}; + +/** + * 카테고리 값 삭제 + */ +export const deleteCategoryValue = async (req: Request, res: Response) => { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const valueId = parseInt(req.params.valueId); + + if (isNaN(valueId)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 값 ID입니다", + }); + } + + await tableCategoryValueService.deleteCategoryValue( + valueId, + companyCode, + userId + ); + + return res.json({ + success: true, + message: "카테고리 값이 삭제되었습니다", + }); + } catch (error: any) { + logger.error(`카테고리 값 삭제 실패: ${error.message}`); + return res.status(500).json({ + success: false, + message: error.message || "카테고리 값 삭제 중 오류가 발생했습니다", + error: error.message, + }); + } +}; + +/** + * 카테고리 값 일괄 삭제 + */ +export const bulkDeleteCategoryValues = async ( + req: Request, + res: Response +) => { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { valueIds } = req.body; + + if (!Array.isArray(valueIds) || valueIds.length === 0) { + return res.status(400).json({ + success: false, + message: "삭제할 값 ID 목록이 필요합니다", + }); + } + + await tableCategoryValueService.bulkDeleteCategoryValues( + valueIds, + companyCode, + userId + ); + + return res.json({ + success: true, + message: `${valueIds.length}개의 카테고리 값이 삭제되었습니다`, + }); + } catch (error: any) { + logger.error(`카테고리 값 일괄 삭제 실패: ${error.message}`); + return res.status(500).json({ + success: false, + message: "카테고리 값 일괄 삭제 중 오류가 발생했습니다", + error: error.message, + }); + } +}; + +/** + * 카테고리 값 순서 변경 + */ +export const reorderCategoryValues = async (req: Request, res: Response) => { + try { + const companyCode = req.user!.companyCode; + const { orderedValueIds } = req.body; + + if (!Array.isArray(orderedValueIds) || orderedValueIds.length === 0) { + return res.status(400).json({ + success: false, + message: "순서 정보가 필요합니다", + }); + } + + await tableCategoryValueService.reorderCategoryValues( + orderedValueIds, + companyCode + ); + + return res.json({ + success: true, + message: "카테고리 값 순서가 변경되었습니다", + }); + } catch (error: any) { + logger.error(`카테고리 값 순서 변경 실패: ${error.message}`); + return res.status(500).json({ + success: false, + message: "카테고리 값 순서 변경 중 오류가 발생했습니다", + error: error.message, + }); + } +}; + diff --git a/backend-node/src/routes/tableCategoryValueRoutes.ts b/backend-node/src/routes/tableCategoryValueRoutes.ts new file mode 100644 index 00000000..cc2ba05f --- /dev/null +++ b/backend-node/src/routes/tableCategoryValueRoutes.ts @@ -0,0 +1,50 @@ +import { Router } from "express"; +import * as tableCategoryValueController from "../controllers/tableCategoryValueController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +// 테이블의 카테고리 컬럼 목록 조회 +router.get( + "/:tableName/columns", + tableCategoryValueController.getCategoryColumns +); + +// 카테고리 값 목록 조회 +router.get( + "/:tableName/:columnName/values", + tableCategoryValueController.getCategoryValues +); + +// 카테고리 값 추가 +router.post("/values", tableCategoryValueController.addCategoryValue); + +// 카테고리 값 수정 +router.put( + "/values/:valueId", + tableCategoryValueController.updateCategoryValue +); + +// 카테고리 값 삭제 +router.delete( + "/values/:valueId", + tableCategoryValueController.deleteCategoryValue +); + +// 카테고리 값 일괄 삭제 +router.post( + "/values/bulk-delete", + tableCategoryValueController.bulkDeleteCategoryValues +); + +// 카테고리 값 순서 변경 +router.post( + "/values/reorder", + tableCategoryValueController.reorderCategoryValues +); + +export default router; + diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index 3de082d7..462ebb4d 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -1,3 +1,18 @@ +/** + * 동적 데이터 서비스 + * + * 주요 특징: + * 1. 화이트리스트 제거 - 모든 테이블에 동적으로 접근 가능 + * 2. 블랙리스트 방식 - 시스템 중요 테이블만 접근 금지 + * 3. 자동 회사별 필터링 - company_code 컬럼 자동 감지 및 필터 적용 + * 4. SQL 인젝션 방지 - 정규식 기반 테이블명/컬럼명 검증 + * + * 보안: + * - 테이블명은 영문, 숫자, 언더스코어만 허용 + * - 시스템 테이블(pg_*, information_schema 등) 접근 금지 + * - company_code 컬럼이 있는 테이블은 자동으로 회사별 격리 + * - 최고 관리자(company_code = "*")만 전체 데이터 조회 가능 + */ import { query, queryOne } from "../database/db"; interface GetTableDataParams { @@ -17,65 +32,72 @@ interface ServiceResponse { } /** - * 안전한 테이블명 목록 (화이트리스트) - * SQL 인젝션 방지를 위해 허용된 테이블만 접근 가능 + * 접근 금지 테이블 목록 (블랙리스트) + * 시스템 중요 테이블 및 보안상 접근 금지할 테이블 */ -const ALLOWED_TABLES = [ - "company_mng", - "user_info", - "dept_info", - "code_info", - "code_category", - "menu_info", - "approval", - "approval_kind", - "board", - "comm_code", - "product_mng", - "part_mng", - "material_mng", - "order_mng_master", - "inventory_mng", - "contract_mgmt", - "project_mgmt", - "screen_definitions", - "screen_layouts", - "layout_standards", - "component_standards", - "web_type_standards", - "button_action_standards", - "template_standards", - "grid_standards", - "style_templates", - "multi_lang_key_master", - "multi_lang_text", - "language_master", - "table_labels", - "column_labels", - "dynamic_form_data", - "work_history", // 작업 이력 테이블 - "delivery_status", // 배송 현황 테이블 +const BLOCKED_TABLES = [ + "pg_catalog", + "pg_statistic", + "pg_database", + "pg_user", + "information_schema", + "session_tokens", // 세션 토큰 테이블 + "password_history", // 패스워드 이력 ]; /** - * 회사별 필터링이 필요한 테이블 목록 + * 테이블 이름 검증 정규식 + * SQL 인젝션 방지: 영문, 숫자, 언더스코어만 허용 */ -const COMPANY_FILTERED_TABLES = [ - "company_mng", - "user_info", - "dept_info", - "approval", - "board", - "product_mng", - "part_mng", - "material_mng", - "order_mng_master", - "inventory_mng", - "contract_mgmt", - "project_mgmt", -]; +const TABLE_NAME_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/; class DataService { + /** + * 테이블 접근 검증 (공통 메서드) + */ + private async validateTableAccess( + tableName: string + ): Promise<{ valid: boolean; error?: ServiceResponse }> { + // 1. 테이블명 형식 검증 (SQL 인젝션 방지) + if (!TABLE_NAME_REGEX.test(tableName)) { + return { + valid: false, + error: { + success: false, + message: `유효하지 않은 테이블명입니다: ${tableName}`, + error: "INVALID_TABLE_NAME", + }, + }; + } + + // 2. 블랙리스트 검증 + if (BLOCKED_TABLES.includes(tableName)) { + return { + valid: false, + error: { + success: false, + message: `접근이 금지된 테이블입니다: ${tableName}`, + error: "TABLE_ACCESS_DENIED", + }, + }; + } + + // 3. 테이블 존재 여부 확인 + const tableExists = await this.checkTableExists(tableName); + if (!tableExists) { + return { + valid: false, + error: { + success: false, + message: `테이블을 찾을 수 없습니다: ${tableName}`, + error: "TABLE_NOT_FOUND", + }, + }; + } + + return { valid: true }; + } + /** * 테이블 데이터 조회 */ @@ -92,23 +114,10 @@ class DataService { } = params; try { - // 테이블명 화이트리스트 검증 - if (!ALLOWED_TABLES.includes(tableName)) { - return { - success: false, - message: `접근이 허용되지 않은 테이블입니다: ${tableName}`, - error: "TABLE_NOT_ALLOWED", - }; - } - - // 테이블 존재 여부 확인 - const tableExists = await this.checkTableExists(tableName); - if (!tableExists) { - return { - success: false, - message: `테이블을 찾을 수 없습니다: ${tableName}`, - error: "TABLE_NOT_FOUND", - }; + // 테이블 접근 검증 + const validation = await this.validateTableAccess(tableName); + if (!validation.valid) { + return validation.error!; } // 동적 SQL 쿼리 생성 @@ -119,13 +128,14 @@ class DataService { // WHERE 조건 생성 const whereConditions: string[] = []; - // 회사별 필터링 추가 - if (COMPANY_FILTERED_TABLES.includes(tableName) && userCompany) { - // 슈퍼관리자(*)가 아닌 경우에만 회사 필터 적용 - if (userCompany !== "*") { + // 4. 회사별 필터링 자동 적용 (company_code 컬럼이 있는 경우) + if (userCompany && userCompany !== "*") { + const hasCompanyCode = await this.checkColumnExists(tableName, "company_code"); + if (hasCompanyCode) { whereConditions.push(`company_code = $${paramIndex}`); queryParams.push(userCompany); paramIndex++; + console.log(`🏢 회사별 필터링 적용: ${tableName}.company_code = ${userCompany}`); } } @@ -213,13 +223,10 @@ class DataService { */ async getTableColumns(tableName: string): Promise> { try { - // 테이블명 화이트리스트 검증 - if (!ALLOWED_TABLES.includes(tableName)) { - return { - success: false, - message: `접근이 허용되지 않은 테이블입니다: ${tableName}`, - error: "TABLE_NOT_ALLOWED", - }; + // 테이블 접근 검증 + const validation = await this.validateTableAccess(tableName); + if (!validation.valid) { + return validation.error!; } const columns = await this.getTableColumnsSimple(tableName); @@ -276,6 +283,31 @@ class DataService { } } + /** + * 특정 컬럼 존재 여부 확인 + */ + private async checkColumnExists( + tableName: string, + columnName: string + ): Promise { + try { + const result = await query<{ exists: boolean }>( + `SELECT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = $1 + AND column_name = $2 + )`, + [tableName, columnName] + ); + + return result[0]?.exists || false; + } catch (error) { + console.error("컬럼 존재 확인 오류:", error); + return false; + } + } + /** * 테이블 컬럼 정보 조회 (간단 버전) */ @@ -324,13 +356,10 @@ class DataService { id: string | number ): Promise> { try { - // 테이블명 화이트리스트 검증 - if (!ALLOWED_TABLES.includes(tableName)) { - return { - success: false, - message: `접근이 허용되지 않은 테이블입니다: ${tableName}`, - error: "TABLE_NOT_ALLOWED", - }; + // 테이블 접근 검증 + const validation = await this.validateTableAccess(tableName); + if (!validation.valid) { + return validation.error!; } // Primary Key 컬럼 찾기 @@ -383,21 +412,16 @@ class DataService { leftValue?: string | number ): Promise> { try { - // 테이블명 화이트리스트 검증 - if (!ALLOWED_TABLES.includes(leftTable)) { - return { - success: false, - message: `접근이 허용되지 않은 테이블입니다: ${leftTable}`, - error: "TABLE_NOT_ALLOWED", - }; + // 왼쪽 테이블 접근 검증 + const leftValidation = await this.validateTableAccess(leftTable); + if (!leftValidation.valid) { + return leftValidation.error!; } - if (!ALLOWED_TABLES.includes(rightTable)) { - return { - success: false, - message: `접근이 허용되지 않은 테이블입니다: ${rightTable}`, - error: "TABLE_NOT_ALLOWED", - }; + // 오른쪽 테이블 접근 검증 + const rightValidation = await this.validateTableAccess(rightTable); + if (!rightValidation.valid) { + return rightValidation.error!; } let queryText = ` @@ -440,13 +464,10 @@ class DataService { data: Record ): Promise> { try { - // 테이블명 화이트리스트 검증 - if (!ALLOWED_TABLES.includes(tableName)) { - return { - success: false, - message: `접근이 허용되지 않은 테이블입니다: ${tableName}`, - error: "TABLE_NOT_ALLOWED", - }; + // 테이블 접근 검증 + const validation = await this.validateTableAccess(tableName); + if (!validation.valid) { + return validation.error!; } const columns = Object.keys(data); @@ -485,13 +506,10 @@ class DataService { data: Record ): Promise> { try { - // 테이블명 화이트리스트 검증 - if (!ALLOWED_TABLES.includes(tableName)) { - return { - success: false, - message: `접근이 허용되지 않은 테이블입니다: ${tableName}`, - error: "TABLE_NOT_ALLOWED", - }; + // 테이블 접근 검증 + const validation = await this.validateTableAccess(tableName); + if (!validation.valid) { + return validation.error!; } // Primary Key 컬럼 찾기 @@ -554,13 +572,10 @@ class DataService { id: string | number ): Promise> { try { - // 테이블명 화이트리스트 검증 - if (!ALLOWED_TABLES.includes(tableName)) { - return { - success: false, - message: `접근이 허용되지 않은 테이블입니다: ${tableName}`, - error: "TABLE_NOT_ALLOWED", - }; + // 테이블 접근 검증 + const validation = await this.validateTableAccess(tableName); + if (!validation.valid) { + return validation.error!; } // Primary Key 컬럼 찾기 diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts new file mode 100644 index 00000000..a459e24b --- /dev/null +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -0,0 +1,497 @@ +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; +import { + TableCategoryValue, + CategoryColumn, +} from "../types/tableCategoryValue"; + +class TableCategoryValueService { + /** + * 메뉴의 형제 메뉴 ID 목록 조회 + * (같은 부모를 가진 메뉴들) + */ + async getSiblingMenuIds(menuId: number): Promise { + try { + const pool = getPool(); + + // 1. 현재 메뉴의 부모 ID 조회 (menu_info는 objid와 parent_obj_id 사용) + const parentQuery = ` + SELECT parent_obj_id FROM menu_info WHERE objid = $1 + `; + const parentResult = await pool.query(parentQuery, [menuId]); + + if (parentResult.rows.length === 0) { + logger.warn(`메뉴 ID ${menuId}를 찾을 수 없습니다`); + return [menuId]; + } + + const parentId = parentResult.rows[0].parent_obj_id; + + // 최상위 메뉴인 경우 (parent_obj_id가 null 또는 0) + if (!parentId || parentId === 0) { + logger.info(`메뉴 ${menuId}는 최상위 메뉴입니다`); + return [menuId]; + } + + // 2. 같은 부모를 가진 형제 메뉴들 조회 + const siblingsQuery = ` + SELECT objid FROM menu_info WHERE parent_obj_id = $1 + `; + const siblingsResult = await pool.query(siblingsQuery, [parentId]); + + const siblingIds = siblingsResult.rows.map((row) => Number(row.objid)); + + logger.info(`메뉴 ${menuId}의 형제 메뉴 ${siblingIds.length}개 조회`, { + menuId, + parentId, + siblings: siblingIds, + }); + + return siblingIds; + } catch (error: any) { + logger.error(`형제 메뉴 조회 실패: ${error.message}`); + // 에러 시 현재 메뉴만 반환 + return [menuId]; + } + } + /** + * 테이블의 카테고리 타입 컬럼 목록 조회 + */ + async getCategoryColumns( + tableName: string, + companyCode: string + ): Promise { + try { + logger.info("카테고리 컬럼 목록 조회", { tableName, companyCode }); + + const pool = getPool(); + const query = ` + SELECT + tc.table_name AS "tableName", + tc.column_name AS "columnName", + tc.column_name AS "columnLabel", + COUNT(cv.value_id) AS "valueCount" + FROM table_type_columns tc + LEFT JOIN table_column_category_values cv + ON tc.table_name = cv.table_name + AND tc.column_name = cv.column_name + AND cv.is_active = true + AND (cv.company_code = $2 OR cv.company_code = '*') + WHERE tc.table_name = $1 + AND tc.input_type = 'category' + GROUP BY tc.table_name, tc.column_name, tc.display_order + ORDER BY tc.display_order, tc.column_name + `; + + const result = await pool.query(query, [tableName, companyCode]); + + logger.info(`카테고리 컬럼 ${result.rows.length}개 조회 완료`, { + tableName, + companyCode, + }); + + return result.rows; + } catch (error: any) { + logger.error(`카테고리 컬럼 조회 실패: ${error.message}`); + throw error; + } + } + + /** + * 특정 컬럼의 카테고리 값 목록 조회 (메뉴 스코프 적용) + */ + async getCategoryValues( + tableName: string, + columnName: string, + menuId: number, + companyCode: string, + includeInactive: boolean = false + ): Promise { + try { + logger.info("카테고리 값 목록 조회", { + tableName, + columnName, + menuId, + companyCode, + includeInactive, + }); + + // 1. 메뉴 스코프 확인: 형제 메뉴들의 카테고리도 포함 + const siblingMenuIds = await this.getSiblingMenuIds(menuId); + + const pool = getPool(); + let query = ` + SELECT + value_id AS "valueId", + table_name AS "tableName", + column_name AS "columnName", + value_code AS "valueCode", + value_label AS "valueLabel", + value_order AS "valueOrder", + parent_value_id AS "parentValueId", + depth, + description, + color, + icon, + is_active AS "isActive", + is_default AS "isDefault", + menu_objid AS "menuId", + company_code AS "companyCode", + created_at AS "createdAt", + updated_at AS "updatedAt", + created_by AS "createdBy", + updated_by AS "updatedBy" + FROM table_column_category_values + WHERE table_name = $1 + AND column_name = $2 + AND menu_objid = ANY($3) + AND (company_code = $4 OR company_code = '*') + `; + + const params: any[] = [tableName, columnName, siblingMenuIds, companyCode]; + + if (!includeInactive) { + query += ` AND is_active = true`; + } + + query += ` ORDER BY value_order, value_label`; + + const result = await pool.query(query, params); + + // 계층 구조로 변환 + const values = this.buildHierarchy(result.rows); + + logger.info(`카테고리 값 ${result.rows.length}개 조회 완료`, { + tableName, + columnName, + menuId, + siblingMenuIds, + }); + + return values; + } catch (error: any) { + logger.error(`카테고리 값 조회 실패: ${error.message}`); + throw error; + } + } + + /** + * 카테고리 값 추가 + */ + async addCategoryValue( + value: TableCategoryValue, + companyCode: string, + userId: string + ): Promise { + const pool = getPool(); + + try { + // 중복 코드 체크 + const duplicateQuery = ` + SELECT value_id + FROM table_column_category_values + WHERE table_name = $1 + AND column_name = $2 + AND value_code = $3 + AND (company_code = $4 OR company_code = '*') + `; + + const duplicateResult = await pool.query(duplicateQuery, [ + value.tableName, + value.columnName, + value.valueCode, + companyCode, + ]); + + if (duplicateResult.rows.length > 0) { + throw new Error("이미 존재하는 코드입니다"); + } + + const insertQuery = ` + INSERT INTO table_column_category_values ( + table_name, column_name, value_code, value_label, value_order, + parent_value_id, depth, description, color, icon, + is_active, is_default, menu_objid, company_code, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + RETURNING + value_id AS "valueId", + table_name AS "tableName", + column_name AS "columnName", + value_code AS "valueCode", + value_label AS "valueLabel", + value_order AS "valueOrder", + parent_value_id AS "parentValueId", + depth, + description, + color, + icon, + is_active AS "isActive", + is_default AS "isDefault", + menu_objid AS "menuId", + company_code AS "companyCode", + created_at AS "createdAt", + created_by AS "createdBy" + `; + + const result = await pool.query(insertQuery, [ + value.tableName, + value.columnName, + value.valueCode, + value.valueLabel, + value.valueOrder || 0, + value.parentValueId || null, + value.depth || 1, + value.description || null, + value.color || null, + value.icon || null, + value.isActive !== false, + value.isDefault || false, + value.menuId, // menuId 추가 + companyCode, + userId, + ]); + + logger.info("카테고리 값 추가 완료", { + valueId: result.rows[0].valueId, + tableName: value.tableName, + columnName: value.columnName, + }); + + return result.rows[0]; + } catch (error: any) { + logger.error(`카테고리 값 추가 실패: ${error.message}`); + throw error; + } + } + + /** + * 카테고리 값 수정 + */ + async updateCategoryValue( + valueId: number, + updates: Partial, + companyCode: string, + userId: string + ): Promise { + const pool = getPool(); + + try { + const setClauses: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (updates.valueLabel !== undefined) { + setClauses.push(`value_label = $${paramIndex++}`); + values.push(updates.valueLabel); + } + + if (updates.valueOrder !== undefined) { + setClauses.push(`value_order = $${paramIndex++}`); + values.push(updates.valueOrder); + } + + if (updates.description !== undefined) { + setClauses.push(`description = $${paramIndex++}`); + values.push(updates.description); + } + + if (updates.color !== undefined) { + setClauses.push(`color = $${paramIndex++}`); + values.push(updates.color); + } + + if (updates.icon !== undefined) { + setClauses.push(`icon = $${paramIndex++}`); + values.push(updates.icon); + } + + if (updates.isActive !== undefined) { + setClauses.push(`is_active = $${paramIndex++}`); + values.push(updates.isActive); + } + + if (updates.isDefault !== undefined) { + setClauses.push(`is_default = $${paramIndex++}`); + values.push(updates.isDefault); + } + + setClauses.push(`updated_at = NOW()`); + setClauses.push(`updated_by = $${paramIndex++}`); + values.push(userId); + + values.push(valueId, companyCode); + + const updateQuery = ` + UPDATE table_column_category_values + SET ${setClauses.join(", ")} + WHERE value_id = $${paramIndex++} + AND (company_code = $${paramIndex++} OR company_code = '*') + RETURNING + value_id AS "valueId", + table_name AS "tableName", + column_name AS "columnName", + value_code AS "valueCode", + value_label AS "valueLabel", + value_order AS "valueOrder", + description, + color, + icon, + is_active AS "isActive", + is_default AS "isDefault", + menu_objid AS "menuId", + updated_at AS "updatedAt", + updated_by AS "updatedBy" + `; + + const result = await pool.query(updateQuery, values); + + if (result.rowCount === 0) { + throw new Error("카테고리 값을 찾을 수 없습니다"); + } + + logger.info("카테고리 값 수정 완료", { valueId, companyCode }); + + return result.rows[0]; + } catch (error: any) { + logger.error(`카테고리 값 수정 실패: ${error.message}`); + throw error; + } + } + + /** + * 카테고리 값 삭제 (비활성화) + */ + async deleteCategoryValue( + valueId: number, + companyCode: string, + userId: string + ): Promise { + const pool = getPool(); + + try { + // 하위 값 체크 + const checkQuery = ` + SELECT COUNT(*) as count + FROM table_column_category_values + WHERE parent_value_id = $1 + AND (company_code = $2 OR company_code = '*') + AND is_active = true + `; + + const checkResult = await pool.query(checkQuery, [valueId, companyCode]); + + if (parseInt(checkResult.rows[0].count) > 0) { + throw new Error("하위 카테고리 값이 있어 삭제할 수 없습니다"); + } + + // 비활성화 + const deleteQuery = ` + UPDATE table_column_category_values + SET is_active = false, updated_at = NOW(), updated_by = $3 + WHERE value_id = $1 + AND (company_code = $2 OR company_code = '*') + `; + + await pool.query(deleteQuery, [valueId, companyCode, userId]); + + logger.info("카테고리 값 삭제(비활성화) 완료", { + valueId, + companyCode, + }); + } catch (error: any) { + logger.error(`카테고리 값 삭제 실패: ${error.message}`); + throw error; + } + } + + /** + * 카테고리 값 일괄 삭제 + */ + async bulkDeleteCategoryValues( + valueIds: number[], + companyCode: string, + userId: string + ): Promise { + const pool = getPool(); + + try { + const deleteQuery = ` + UPDATE table_column_category_values + SET is_active = false, updated_at = NOW(), updated_by = $3 + WHERE value_id = ANY($1::int[]) + AND (company_code = $2 OR company_code = '*') + `; + + await pool.query(deleteQuery, [valueIds, companyCode, userId]); + + logger.info("카테고리 값 일괄 삭제 완료", { + count: valueIds.length, + companyCode, + }); + } catch (error: any) { + logger.error(`카테고리 값 일괄 삭제 실패: ${error.message}`); + throw error; + } + } + + /** + * 카테고리 값 순서 변경 + */ + async reorderCategoryValues( + orderedValueIds: number[], + companyCode: string + ): Promise { + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + for (let i = 0; i < orderedValueIds.length; i++) { + const updateQuery = ` + UPDATE table_column_category_values + SET value_order = $1, updated_at = NOW() + WHERE value_id = $2 + AND (company_code = $3 OR company_code = '*') + `; + + await client.query(updateQuery, [ + i + 1, + orderedValueIds[i], + companyCode, + ]); + } + + await client.query("COMMIT"); + + logger.info("카테고리 값 순서 변경 완료", { + count: orderedValueIds.length, + companyCode, + }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error(`카테고리 값 순서 변경 실패: ${error.message}`); + throw error; + } finally { + client.release(); + } + } + + /** + * 계층 구조 변환 헬퍼 + */ + private buildHierarchy( + values: TableCategoryValue[], + parentId: number | null = null + ): TableCategoryValue[] { + return values + .filter((v) => v.parentValueId === parentId) + .map((v) => ({ + ...v, + children: this.buildHierarchy(values, v.valueId!), + })); + } +} + +export default new TableCategoryValueService(); + diff --git a/backend-node/src/types/tableCategoryValue.ts b/backend-node/src/types/tableCategoryValue.ts new file mode 100644 index 00000000..ee1c4c2f --- /dev/null +++ b/backend-node/src/types/tableCategoryValue.ts @@ -0,0 +1,48 @@ +/** + * 테이블 컬럼별 카테고리 값 타입 정의 + */ + +export interface TableCategoryValue { + valueId?: number; + tableName: string; + columnName: string; + + // 값 정보 + valueCode: string; + valueLabel: string; + valueOrder?: number; + + // 계층 구조 + parentValueId?: number; + depth?: number; + + // 추가 정보 + description?: string; + color?: string; + icon?: string; + isActive?: boolean; + isDefault?: boolean; + + // 하위 항목 (조회 시) + children?: TableCategoryValue[]; + + // 메뉴 스코프 + menuId: number; + + // 멀티테넌시 + companyCode?: string; + + // 메타 + createdAt?: string; + updatedAt?: string; + createdBy?: string; + updatedBy?: string; +} + +export interface CategoryColumn { + tableName: string; + columnName: string; + columnLabel: string; + valueCount?: number; // 값 개수 +} + diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index c3e09f2e..882dfd70 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -615,7 +615,7 @@ export const InteractiveScreenViewerDynamic: React.FC = ({ const isFlowWidget = type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget"); // 높이 결정 로직 - let finalHeight = size?.height || 40; + let finalHeight = size?.height || 10; if (isFlowWidget && actualHeight) { finalHeight = actualHeight; } diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index 1f11182f..af733601 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -271,7 +271,8 @@ export const RealtimePreviewDynamic: React.FC = ({ return `${Math.max(size?.height || 200, 200)}px`; } - return `${size?.height || 40}px`; + // size.height가 있으면 그대로 사용, 없으면 최소 10px + return `${size?.height || 10}px`; }; const baseStyle = { diff --git a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx index f2e50db8..cc06b555 100644 --- a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx +++ b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx @@ -364,14 +364,13 @@ export const UnifiedPropertiesPanel: React.FC = ({ value={selectedComponent.size?.height || 0} onChange={(e) => { const value = parseInt(e.target.value) || 0; - const roundedValue = Math.max(10, Math.round(value / 10) * 10); - handleUpdate("size.height", roundedValue); + // 최소값 제한 없이, 1px 단위로 조절 가능 + handleUpdate("size.height", Math.max(1, value)); }} - step={10} + step={1} placeholder="10" className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }} - style={{ fontSize: "12px" }} />
diff --git a/frontend/components/screen/widgets/CategoryWidget.tsx b/frontend/components/screen/widgets/CategoryWidget.tsx new file mode 100644 index 00000000..685340b5 --- /dev/null +++ b/frontend/components/screen/widgets/CategoryWidget.tsx @@ -0,0 +1,81 @@ +"use client"; + +import React, { useState } from "react"; +import { CategoryColumnList } from "@/components/table-category/CategoryColumnList"; +import { CategoryValueManager } from "@/components/table-category/CategoryValueManager"; + +interface CategoryWidgetProps { + widgetId: string; + menuId?: number; // 현재 화면의 menuId (선택사항) + tableName: string; // 현재 화면의 테이블 + selectedScreen?: any; // 화면 정보 전체 (menuId 추출용) +} + +/** + * 카테고리 관리 위젯 (좌우 분할) + * - 좌측: 현재 테이블의 카테고리 타입 컬럼 목록 + * - 우측: 선택된 컬럼의 카테고리 값 관리 + */ +export function CategoryWidget({ + widgetId, + menuId: propMenuId, + tableName, + selectedScreen, +}: CategoryWidgetProps) { + const [selectedColumn, setSelectedColumn] = useState<{ + columnName: string; + columnLabel: string; + } | null>(null); + + // menuId 추출: props > selectedScreen > 기본값(1) + const menuId = + propMenuId || + selectedScreen?.menuId || + selectedScreen?.menu_id || + 1; // 기본값 + + // menuId가 없으면 경고 메시지 표시 + if (!menuId || menuId === 1) { + console.warn("⚠️ CategoryWidget: menuId가 제공되지 않아 기본값(1)을 사용합니다", { + propMenuId, + selectedScreen, + }); + } + + return ( +
+ {/* 좌측: 카테고리 컬럼 리스트 (30%) */} +
+ + setSelectedColumn({ columnName, columnLabel }) + } + /> +
+ + {/* 우측: 카테고리 값 관리 (70%) */} +
+ {selectedColumn ? ( + + ) : ( +
+
+

+ 좌측에서 관리할 카테고리 컬럼을 선택하세요 +

+
+
+ )} +
+
+ ); +} + diff --git a/frontend/components/screen/widgets/types/ButtonWidget.tsx b/frontend/components/screen/widgets/types/ButtonWidget.tsx index 6bc9e1ff..808cf5d0 100644 --- a/frontend/components/screen/widgets/types/ButtonWidget.tsx +++ b/frontend/components/screen/widgets/types/ButtonWidget.tsx @@ -30,7 +30,7 @@ export const ButtonWidget: React.FC = ({ type="button" onClick={handleClick} disabled={disabled || readonly} - className={`rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors duration-200 hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-300 disabled:text-gray-500 ${className || ""} `} + className={`flex items-center justify-center rounded-md bg-blue-600 px-4 text-sm font-medium text-white transition-colors duration-200 hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-300 disabled:text-gray-500 ${className || ""} `} style={{ ...style, width: "100%", diff --git a/frontend/components/table-category/CategoryColumnList.tsx b/frontend/components/table-category/CategoryColumnList.tsx new file mode 100644 index 00000000..3cc8cb11 --- /dev/null +++ b/frontend/components/table-category/CategoryColumnList.tsx @@ -0,0 +1,187 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { apiClient } from "@/lib/api/client"; +import { FolderTree, Loader2 } from "lucide-react"; + +interface CategoryColumn { + columnName: string; + columnLabel: string; + inputType: string; +} + +interface CategoryColumnListProps { + tableName: string; + menuId: number; + selectedColumn: string | null; + onColumnSelect: (columnName: string, columnLabel: string) => void; +} + +/** + * 카테고리 컬럼 목록 (좌측 패널) + * - 현재 테이블에서 input_type='category'인 컬럼들을 표시 + */ +export function CategoryColumnList({ + tableName, + menuId, + selectedColumn, + onColumnSelect, +}: CategoryColumnListProps) { + const [columns, setColumns] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + loadCategoryColumns(); + }, [tableName, menuId]); + + const loadCategoryColumns = async () => { + setIsLoading(true); + try { + // table_type_columns에서 input_type = 'category'인 컬럼 조회 + const response = await apiClient.get( + `/table-management/tables/${tableName}/columns` + ); + + console.log("🔍 테이블 컬럼 API 응답:", { + tableName, + response: response.data, + type: typeof response.data, + isArray: Array.isArray(response.data), + }); + + // API 응답 구조 파싱 (여러 가능성 대응) + let allColumns: any[] = []; + + if (Array.isArray(response.data)) { + // response.data가 직접 배열인 경우 + allColumns = response.data; + } else if (response.data.data && response.data.data.columns && Array.isArray(response.data.data.columns)) { + // response.data.data.columns가 배열인 경우 (table-management API) + allColumns = response.data.data.columns; + } else if (response.data.data && Array.isArray(response.data.data)) { + // response.data.data가 배열인 경우 + allColumns = response.data.data; + } else if (response.data.columns && Array.isArray(response.data.columns)) { + // response.data.columns가 배열인 경우 + allColumns = response.data.columns; + } else { + console.warn("⚠️ 예상하지 못한 API 응답 구조:", response.data); + allColumns = []; + } + + console.log("🔍 파싱된 컬럼 목록:", { + totalColumns: allColumns.length, + sample: allColumns.slice(0, 3), + }); + + // category 타입만 필터링 + const categoryColumns = allColumns.filter( + (col: any) => col.inputType === "category" || col.input_type === "category" + ); + + console.log("✅ 카테고리 컬럼:", { + count: categoryColumns.length, + columns: categoryColumns.map((c: any) => ({ + name: c.columnName || c.column_name, + type: c.inputType || c.input_type, + })), + }); + + setColumns( + categoryColumns.map((col: any) => ({ + columnName: col.columnName || col.column_name, + columnLabel: col.columnLabel || col.column_label || col.displayName || col.columnName || col.column_name, + inputType: col.inputType || col.input_type, + })) + ); + + // 첫 번째 컬럼 자동 선택 + if (categoryColumns.length > 0 && !selectedColumn) { + const firstCol = categoryColumns[0]; + const colName = firstCol.columnName || firstCol.column_name; + const colLabel = firstCol.columnLabel || firstCol.column_label || firstCol.displayName || colName; + onColumnSelect(colName, colLabel); + } + } catch (error) { + console.error("❌ 카테고리 컬럼 조회 실패:", error); + setColumns([]); + } finally { + setIsLoading(false); + } + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (columns.length === 0) { + return ( +
+

카테고리 컬럼

+
+ +

+ 카테고리 타입 컬럼이 없습니다 +

+

+ 테이블 타입 관리에서 컬럼의 입력 타입을 '카테고리'로 + 설정하세요 +

+
+
+ ); + } + + return ( +
+
+

카테고리 컬럼

+

+ 관리할 카테고리 컬럼을 선택하세요 +

+
+ +
+ {columns.map((column) => ( +
+ onColumnSelect( + column.columnName, + column.columnLabel || column.columnName + ) + } + className={`cursor-pointer rounded-lg border p-4 transition-all ${ + selectedColumn === column.columnName + ? "border-primary bg-primary/10 shadow-sm" + : "hover:bg-muted/50" + }`} + > +
+ +
+

+ {column.columnLabel || column.columnName} +

+

+ {column.columnName} +

+
+
+
+ ))} +
+
+ ); +} + diff --git a/frontend/components/table-category/CategoryValueAddDialog.tsx b/frontend/components/table-category/CategoryValueAddDialog.tsx new file mode 100644 index 00000000..b511ae7a --- /dev/null +++ b/frontend/components/table-category/CategoryValueAddDialog.tsx @@ -0,0 +1,170 @@ +"use client"; + +import React, { useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Checkbox } from "@/components/ui/checkbox"; +import { TableCategoryValue } from "@/types/tableCategoryValue"; + +interface CategoryValueAddDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onAdd: (value: TableCategoryValue) => void; + columnLabel: string; +} + +export const CategoryValueAddDialog: React.FC< + CategoryValueAddDialogProps +> = ({ open, onOpenChange, onAdd, columnLabel }) => { + const [valueCode, setValueCode] = useState(""); + const [valueLabel, setValueLabel] = useState(""); + const [description, setDescription] = useState(""); + const [color, setColor] = useState("#3b82f6"); + const [isDefault, setIsDefault] = useState(false); + + const handleSubmit = () => { + if (!valueCode || !valueLabel) { + return; + } + + onAdd({ + tableName: "", + columnName: "", + valueCode: valueCode.toUpperCase(), + valueLabel, + description, + color, + isDefault, + }); + + // 초기화 + setValueCode(""); + setValueLabel(""); + setDescription(""); + setColor("#3b82f6"); + setIsDefault(false); + }; + + return ( + + + + + 새 카테고리 값 추가 + + + {columnLabel}에 새로운 값을 추가합니다 + + + +
+
+ + setValueCode(e.target.value.toUpperCase())} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 영문 대문자와 언더스코어만 사용 (DB 저장값) +

+
+ +
+ + setValueLabel(e.target.value)} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 사용자에게 표시될 이름 +

+
+ +
+ +