From 7b0bbc91c8133cf529d081cc763cac024eba74a6 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 4 Nov 2025 15:32:49 +0900 Subject: [PATCH 01/11] =?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/11] =?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/11] =?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/11] =?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 acaa3414d275d3df1bdc3f477d75b5bb71797c09 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 4 Nov 2025 17:57:28 +0900 Subject: [PATCH 05/11] =?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 06/11] =?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 8489ff03c2041c14d985d7e29f26214dab3523de Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 5 Nov 2025 15:39:02 +0900 Subject: [PATCH 07/11] =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/fileController.ts | 5 +- .../admin/dashboard/CanvasElement.tsx | 107 +++++++--- .../file-upload/FileManagerModal.tsx | 186 +++++++++++++----- 3 files changed, 215 insertions(+), 83 deletions(-) diff --git a/backend-node/src/controllers/fileController.ts b/backend-node/src/controllers/fileController.ts index 2feb6bfd..78193960 100644 --- a/backend-node/src/controllers/fileController.ts +++ b/backend-node/src/controllers/fileController.ts @@ -767,8 +767,9 @@ export const previewFile = async ( mimeType = "application/octet-stream"; } - // CORS 헤더 설정 (더 포괄적으로) - res.setHeader("Access-Control-Allow-Origin", "*"); + // CORS 헤더 설정 (credentials 모드에서는 구체적인 origin 필요) + const origin = req.headers.origin || "http://localhost:9771"; + res.setHeader("Access-Control-Allow-Origin", origin); res.setHeader( "Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS" diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 90eec9ca..09ddfe5c 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -26,80 +26,108 @@ import { // 위젯 동적 임포트 const WeatherWidget = dynamic(() => import("@/components/dashboard/widgets/WeatherWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); const ExchangeWidget = dynamic(() => import("@/components/dashboard/widgets/ExchangeWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); const CalculatorWidget = dynamic(() => import("@/components/dashboard/widgets/CalculatorWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); const VehicleStatusWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleStatusWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); const VehicleListWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleListWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); const VehicleMapOnlyWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleMapOnlyWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); // 범용 지도 위젯 (차량, 창고, 고객 등 모든 위치 위젯 통합) const MapSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/MapSummaryWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); // 🧪 테스트용 지도 위젯 (REST API 지원) const MapTestWidget = dynamic(() => import("@/components/dashboard/widgets/MapTestWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); // 🧪 테스트용 지도 위젯 V2 (다중 데이터 소스) const MapTestWidgetV2 = dynamic(() => import("@/components/dashboard/widgets/MapTestWidgetV2"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); // 🧪 테스트용 차트 위젯 (다중 데이터 소스) const ChartTestWidget = dynamic(() => import("@/components/dashboard/widgets/ChartTestWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); const ListTestWidget = dynamic( () => import("@/components/dashboard/widgets/ListTestWidget").then((mod) => ({ default: mod.ListTestWidget })), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }, ); const CustomMetricTestWidget = dynamic(() => import("@/components/dashboard/widgets/CustomMetricTestWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); const RiskAlertTestWidget = dynamic(() => import("@/components/dashboard/widgets/RiskAlertTestWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); // 범용 상태 요약 위젯 (차량, 배송 등 모든 상태 위젯 통합) const StatusSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/StatusSummaryWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); // 범용 목록 위젯 (차량, 기사, 제품 등 모든 목록 위젯 통합) - 다른 분 작업 중, 임시 주석 @@ -128,22 +156,30 @@ const StatusSummaryWidget = dynamic(() => import("@/components/dashboard/widgets const RiskAlertWidget = dynamic(() => import("@/components/dashboard/widgets/RiskAlertWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); const TaskWidget = dynamic(() => import("@/components/dashboard/widgets/TaskWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); const BookingAlertWidget = dynamic(() => import("@/components/dashboard/widgets/BookingAlertWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); const DocumentWidget = dynamic(() => import("@/components/dashboard/widgets/DocumentWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); // 시계 위젯 임포트 @@ -160,25 +196,33 @@ import { Button } from "@/components/ui/button"; // 야드 관리 3D 위젯 const YardManagement3DWidget = dynamic(() => import("./widgets/YardManagement3DWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); // 작업 이력 위젯 const WorkHistoryWidget = dynamic(() => import("@/components/dashboard/widgets/WorkHistoryWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); // 커스텀 통계 카드 위젯 const CustomStatsWidget = dynamic(() => import("@/components/dashboard/widgets/CustomStatsWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); // 사용자 커스텀 카드 위젯 const CustomMetricWidget = dynamic(() => import("@/components/dashboard/widgets/CustomMetricWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () => ( +
로딩 중...
+ ), }); interface CanvasElementProps { @@ -758,7 +802,7 @@ export function CanvasElement({
{element.customTitle || element.title} + {element.customTitle || element.title} ) : null}
@@ -817,7 +861,7 @@ export function CanvasElement({ -
- {/* 파일 업로드 영역 */} +
+ {/* 파일 업로드 영역 - 높이 축소 */} {!isDesignMode && (
{ + if (!config.disabled && !isDesignMode) { + fileInputRef.current?.click(); + } + }} onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} @@ -186,47 +231,71 @@ export const FileManagerModal: React.FC = ({ /> {uploading ? ( -
-
- 업로드 중... +
+
+ 업로드 중...
) : ( -
- -

+

+ +

파일을 드래그하거나 클릭하여 업로드하세요

-

- {config.accept && `지원 형식: ${config.accept}`} - {config.maxSize && ` • 최대 ${formatFileSize(config.maxSize)}`} - {config.multiple && ' • 여러 파일 선택 가능'} -

)}
)} - {/* 파일 목록 */} -
-
-
-

- 업로드된 파일 -

- {uploadedFiles.length > 0 && ( - - 총 {formatFileSize(uploadedFiles.reduce((sum, file) => sum + file.fileSize, 0))} - - )} + {/* 좌우 분할 레이아웃 */} +
+ {/* 좌측: 이미지 미리보기 */} +
+ {selectedFile && previewImageUrl ? ( + {selectedFile.realFileName} + ) : selectedFile ? ( +
+ {getFileIcon(selectedFile.fileExt)} +

미리보기 불가능

+
+ ) : ( +
+ +

파일을 선택하면 미리보기가 표시됩니다

+
+ )} +
+ + {/* 우측: 파일 목록 */} +
+
+
+

+ 업로드된 파일 +

+ {uploadedFiles.length > 0 && ( + + 총 {formatFileSize(uploadedFiles.reduce((sum, file) => sum + file.fileSize, 0))} + + )} +
- {uploadedFiles.length > 0 ? ( -
- {uploadedFiles.map((file) => ( -
+
+ {uploadedFiles.length > 0 ? ( +
+ {uploadedFiles.map((file) => ( +
handleFileClick(file)} + >
{getFileIcon(file.fileExt)}
@@ -250,40 +319,52 @@ export const FileManagerModal: React.FC = ({ )} {!isDesignMode && ( )}
@@ -291,17 +372,18 @@ export const FileManagerModal: React.FC = ({ ))}
) : ( -
- -

업로드된 파일이 없습니다

-

- {isDesignMode ? '디자인 모드에서는 파일을 업로드할 수 없습니다' : '위의 영역에 파일을 드래그하거나 클릭하여 업로드하세요'} +

+ +

업로드된 파일이 없습니다

+

+ {isDesignMode ? '디자인 모드에서는 파일을 업로드할 수 없습니다' : '위의 영역에 파일을 업로드하세요'}

)}
+
From df779ac04cfbb9508bb992047dceb1dbf9fe6222 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 5 Nov 2025 15:50:29 +0900 Subject: [PATCH 08/11] =?UTF-8?q?=EB=8C=80=ED=91=9C=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=A0=80=EC=9E=A5=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/fileController.ts | 64 +++++++++++++++++++ backend-node/src/routes/fileRoutes.ts | 8 +++ frontend/lib/api/file.ts | 16 +++++ .../file-upload/FileUploadComponent.tsx | 57 ++++++----------- 4 files changed, 108 insertions(+), 37 deletions(-) diff --git a/backend-node/src/controllers/fileController.ts b/backend-node/src/controllers/fileController.ts index 78193960..b1e31e3b 100644 --- a/backend-node/src/controllers/fileController.ts +++ b/backend-node/src/controllers/fileController.ts @@ -616,6 +616,7 @@ export const getComponentFiles = async ( regdate: file.regdate?.toISOString(), status: file.status, isTemplate, // 템플릿 파일 여부 표시 + isRepresentative: file.is_representative || false, // 대표 파일 여부 }); const formattedTemplateFiles = templateFiles.map((file) => @@ -1088,5 +1089,68 @@ export const getFileByToken = async (req: Request, res: Response) => { } }; +/** + * 대표 파일 설정 + */ +export const setRepresentativeFile = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { objid } = req.params; + const companyCode = req.user?.companyCode; + + // 파일 존재 여부 및 권한 확인 + const fileRecord = await queryOne( + `SELECT * FROM attach_file_info WHERE objid = $1 AND status = $2`, + [parseInt(objid), "ACTIVE"] + ); + + if (!fileRecord) { + res.status(404).json({ + success: false, + message: "파일을 찾을 수 없습니다.", + }); + return; + } + + // 멀티테넌시: 회사 코드 확인 + if (companyCode !== "*" && fileRecord.company_code !== companyCode) { + res.status(403).json({ + success: false, + message: "접근 권한이 없습니다.", + }); + return; + } + + // 같은 target_objid의 다른 파일들의 is_representative를 false로 설정 + await query( + `UPDATE attach_file_info + SET is_representative = false + WHERE target_objid = $1 AND objid != $2`, + [fileRecord.target_objid, parseInt(objid)] + ); + + // 선택한 파일을 대표 파일로 설정 + await query( + `UPDATE attach_file_info + SET is_representative = true + WHERE objid = $1`, + [parseInt(objid)] + ); + + res.json({ + success: true, + message: "대표 파일이 설정되었습니다.", + }); + } catch (error) { + console.error("대표 파일 설정 오류:", error); + res.status(500).json({ + success: false, + message: "대표 파일 설정 중 오류가 발생했습니다.", + }); + } +}; + // Multer 미들웨어 export export const uploadMiddleware = upload.array("files", 10); // 최대 10개 파일 diff --git a/backend-node/src/routes/fileRoutes.ts b/backend-node/src/routes/fileRoutes.ts index e62d479a..64f02d14 100644 --- a/backend-node/src/routes/fileRoutes.ts +++ b/backend-node/src/routes/fileRoutes.ts @@ -10,6 +10,7 @@ import { uploadMiddleware, generateTempToken, getFileByToken, + setRepresentativeFile, } from "../controllers/fileController"; import { authenticateToken } from "../middleware/authMiddleware"; @@ -84,4 +85,11 @@ router.get("/download/:objid", downloadFile); */ router.post("/temp-token/:objid", generateTempToken); +/** + * @route PUT /api/files/representative/:objid + * @desc 대표 파일 설정 + * @access Private + */ +router.put("/representative/:objid", setRepresentativeFile); + export default router; diff --git a/frontend/lib/api/file.ts b/frontend/lib/api/file.ts index 8cba4e60..318f26fb 100644 --- a/frontend/lib/api/file.ts +++ b/frontend/lib/api/file.ts @@ -249,3 +249,19 @@ export const getDirectFileUrl = (filePath: string): string => { const baseUrl = process.env.NEXT_PUBLIC_API_URL?.replace("/api", "") || ""; return `${baseUrl}${filePath}`; }; + +/** + * 대표 파일 설정 + */ +export const setRepresentativeFile = async (objid: string): Promise<{ + success: boolean; + message: string; +}> => { + try { + const response = await apiClient.put(`/files/representative/${objid}`); + return response.data; + } catch (error) { + console.error("대표 파일 설정 오류:", error); + throw new Error("대표 파일 설정에 실패했습니다."); + } +}; diff --git a/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx b/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx index 2ae20180..f5eefe3c 100644 --- a/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx +++ b/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx @@ -802,50 +802,33 @@ const FileUploadComponent: React.FC = ({ // 대표 이미지 설정 핸들러 const handleSetRepresentative = useCallback( - (file: FileInfo) => { - const updatedFiles = uploadedFiles.map((f) => ({ - ...f, - isRepresentative: f.objid === file.objid, - })); - - setUploadedFiles(updatedFiles); - - // 대표 이미지 로드 - loadRepresentativeImage(file); - - // localStorage 백업 + async (file: FileInfo) => { try { - const backupKey = `fileUpload_${component.id}`; - localStorage.setItem(backupKey, JSON.stringify(updatedFiles)); - console.log("📌 대표 파일 설정 완료:", { + // API 호출하여 DB에 대표 파일 설정 + const { setRepresentativeFile } = await import("@/lib/api/file"); + await setRepresentativeFile(file.objid); + + // 상태 업데이트 + const updatedFiles = uploadedFiles.map((f) => ({ + ...f, + isRepresentative: f.objid === file.objid, + })); + + setUploadedFiles(updatedFiles); + + // 대표 이미지 로드 + loadRepresentativeImage(file); + + console.log("✅ 대표 파일 설정 완료:", { componentId: component.id, representativeFile: file.realFileName, + objid: file.objid, }); } catch (e) { - console.warn("localStorage 저장 실패:", e); + console.error("❌ 대표 파일 설정 실패:", e); } - - // 전역 상태 동기화 - if (typeof window !== "undefined") { - (window as any).globalFileState = { - ...(window as any).globalFileState, - [component.id]: updatedFiles, - }; - - // 실시간 동기화 이벤트 발송 - const syncEvent = new CustomEvent("fileStateChanged", { - detail: { - componentId: component.id, - files: updatedFiles, - action: "setRepresentative", - }, - }); - window.dispatchEvent(syncEvent); - } - - toast.success(`${file.realFileName}을(를) 대표 파일로 설정했습니다.`); }, - [uploadedFiles, component.id, loadRepresentativeImage], + [uploadedFiles, component.id, loadRepresentativeImage] ); // uploadedFiles 변경 시 대표 이미지 로드 From ba934168f0ea412ac2134eef0a0b2244da2b4e62 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 5 Nov 2025 15:52:17 +0900 Subject: [PATCH 09/11] =?UTF-8?q?=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/(main)/main/page.tsx | 2 +- frontend/constants/auth.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/app/(main)/main/page.tsx b/frontend/app/(main)/main/page.tsx index 3784fb06..45f75f67 100644 --- a/frontend/app/(main)/main/page.tsx +++ b/frontend/app/(main)/main/page.tsx @@ -15,7 +15,7 @@ export default function MainPage() {
-

Vexolor에 오신 것을 환영합니다!

+

Vexplor에 오신 것을 환영합니다!

제품 수명 주기 관리 시스템을 통해 효율적인 업무를 시작하세요.

Node.js diff --git a/frontend/constants/auth.ts b/frontend/constants/auth.ts index 047f1dbb..49d9b480 100644 --- a/frontend/constants/auth.ts +++ b/frontend/constants/auth.ts @@ -18,7 +18,7 @@ export const AUTH_CONFIG = { export const UI_CONFIG = { COMPANY_NAME: "VEXPLOR", COPYRIGHT: "© 2024 VEXPLOR. All rights reserved.", - POWERED_BY: "Powered by Vexolor", + POWERED_BY: "Powered by Vexplor", } as const; export const FORM_VALIDATION = { From 8b03f3a4953a960e890bd6f8f3891571678056cd Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 5 Nov 2025 16:18:00 +0900 Subject: [PATCH 10/11] =?UTF-8?q?=EB=B6=84=ED=95=A0=20=ED=8C=A8=EB=84=90?= =?UTF-8?q?=20=EB=86=92=EC=9D=B4=20=EC=A1=B0=EC=A0=95=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screen/RealtimePreviewDynamic.tsx | 15 +++++++- .../lib/registry/DynamicComponentRenderer.tsx | 16 +++++++- .../SplitPanelLayoutComponent.tsx | 38 ++++++++++++------- 3 files changed, 53 insertions(+), 16 deletions(-) diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index ace56096..3e2a34dd 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -291,6 +291,19 @@ export const RealtimePreviewDynamic: React.FC = ({ return "40px"; }; + // layout 타입 컴포넌트인지 확인 + const isLayoutComponent = component.type === "layout" || + (component.componentConfig as any)?.type?.includes("layout"); + + // layout 컴포넌트는 component 객체에 style.height 추가 + const enhancedComponent = isLayoutComponent ? { + ...component, + style: { + ...component.style, + height: getHeight(), + } + } : component; + const baseStyle = { left: `${position.x}px`, top: `${position.y}px`, @@ -384,7 +397,7 @@ export const RealtimePreviewDynamic: React.FC = ({ style={{ width: "100%", maxWidth: "100%" }} > = // 렌더러 props 구성 // component.style에서 height 제거 (RealtimePreviewDynamic에서 size.height로 처리) + // 단, layout 타입 컴포넌트(split-panel-layout 등)는 height 유지 + const isLayoutComponent = + component.type === "layout" || + componentType === "split-panel-layout" || + componentType?.includes("layout"); + + console.log("🔍 [DynamicComponentRenderer] 높이 처리:", { + componentId: component.id, + componentType, + isLayoutComponent, + hasHeight: !!component.style?.height, + height: component.style?.height + }); + const { height: _height, ...styleWithoutHeight } = component.style || {}; // 숨김 값 추출 @@ -256,7 +270,7 @@ export const DynamicComponentRenderer: React.FC = onDragEnd, size: component.size || newComponent.defaultSize, position: component.position, - style: styleWithoutHeight, + style: isLayoutComponent ? component.style : styleWithoutHeight, // 레이아웃은 height 유지 config: component.componentConfig, componentConfig: component.componentConfig, value: currentValue, // formData에서 추출한 현재 값 전달 diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index 7506ee65..3da7ce27 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -53,12 +53,20 @@ export const SplitPanelLayoutComponent: React.FC const containerRef = React.useRef(null); // 컴포넌트 스타일 + // height 처리: 이미 px 단위면 그대로, 숫자면 px 추가 + const getHeightValue = () => { + const height = component.style?.height; + if (!height) return "600px"; + if (typeof height === "string") return height; // 이미 '540px' 형태 + return `${height}px`; // 숫자면 px 추가 + }; + const componentStyle: React.CSSProperties = isPreview ? { // 반응형 모드: position relative, 그리드 컨테이너가 제공하는 크기 사용 position: "relative", width: "100%", // 🆕 부모 컨테이너 너비에 맞춤 - height: `${component.style?.height || 600}px`, + height: getHeightValue(), border: "1px solid #e5e7eb", } : { @@ -67,7 +75,7 @@ export const SplitPanelLayoutComponent: React.FC left: `${component.style?.positionX || 0}px`, top: `${component.style?.positionY || 0}px`, width: "100%", // 🆕 부모 컨테이너 너비에 맞춤 (그리드 기반) - height: `${component.style?.height || 600}px`, + height: getHeightValue(), zIndex: component.style?.positionZ || 1, cursor: isDesignMode ? "pointer" : "default", border: isSelected ? "2px solid #3b82f6" : "1px solid #e5e7eb", @@ -257,29 +265,31 @@ export const SplitPanelLayoutComponent: React.FC return (
{ if (isDesignMode) { e.stopPropagation(); onClick?.(e); } }} - className="flex w-full overflow-hidden rounded-lg bg-white shadow-sm" + className="w-full overflow-hidden rounded-lg bg-white shadow-sm" > {/* 좌측 패널 */}
- +
@@ -304,9 +314,9 @@ export const SplitPanelLayoutComponent: React.FC
)}
- + {/* 좌측 데이터 목록 */} -
+
{isDesignMode ? ( // 디자인 모드: 샘플 데이터 <> @@ -413,10 +423,10 @@ export const SplitPanelLayoutComponent: React.FC {/* 우측 패널 */}
- +
@@ -441,7 +451,7 @@ export const SplitPanelLayoutComponent: React.FC
)}
- + {/* 우측 데이터 */} {isLoadingRight ? ( // 로딩 중 From 4a1900bdfad35afede99f785af7a70c140cde35a Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 5 Nov 2025 16:53:21 +0900 Subject: [PATCH 11/11] =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20=EC=97=90=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/common/DeleteConfirmModal.tsx | 6 +-- .../components/screen/CopyScreenModal.tsx | 26 +++++----- .../components/screen/MenuAssignmentModal.tsx | 8 +-- frontend/components/screen/ScreenList.tsx | 50 +++++++++++-------- 4 files changed, 46 insertions(+), 44 deletions(-) diff --git a/frontend/components/common/DeleteConfirmModal.tsx b/frontend/components/common/DeleteConfirmModal.tsx index 864bb265..5f279137 100644 --- a/frontend/components/common/DeleteConfirmModal.tsx +++ b/frontend/components/common/DeleteConfirmModal.tsx @@ -6,10 +6,10 @@ import { AlertDialogAction, AlertDialogCancel, AlertDialogContent, - Alert - Alert AlertDialogHeader, - Alert + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, } from "@/components/ui/alert-dialog"; import { Loader2 } from "lucide-react"; diff --git a/frontend/components/screen/CopyScreenModal.tsx b/frontend/components/screen/CopyScreenModal.tsx index e04e6a62..454d5805 100644 --- a/frontend/components/screen/CopyScreenModal.tsx +++ b/frontend/components/screen/CopyScreenModal.tsx @@ -2,12 +2,12 @@ import React, { useState, useEffect } from "react"; import { - Dialog, - DialogContent, - - - DialogHeader, - + ResizableDialog, + ResizableDialogContent, + ResizableDialogHeader, + ResizableDialogTitle, + ResizableDialogDescription, + ResizableDialogFooter, } from "@/components/ui/resizable-dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -101,17 +101,17 @@ export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopyS }; return ( - - - + + + 화면 복사 - + {sourceScreen?.screenName} 화면을 복사합니다. 화면 구성도 함께 복사됩니다. - +
{/* 원본 화면 정보 */} @@ -186,7 +186,7 @@ export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopyS )} - -
+ + ); } diff --git a/frontend/components/screen/MenuAssignmentModal.tsx b/frontend/components/screen/MenuAssignmentModal.tsx index 945a4e73..d6b79e80 100644 --- a/frontend/components/screen/MenuAssignmentModal.tsx +++ b/frontend/components/screen/MenuAssignmentModal.tsx @@ -4,10 +4,6 @@ import React, { useState, useEffect, useRef } from "react"; import { Dialog, DialogContent, - - - DialogHeader, - } from "@/components/ui/resizable-dialog"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -550,7 +546,7 @@ export const MenuAssignmentModal: React.FC = ({ )}
- +
- + diff --git a/frontend/components/screen/ScreenList.tsx b/frontend/components/screen/ScreenList.tsx index 7ce4160d..9b3b5f3f 100644 --- a/frontend/components/screen/ScreenList.tsx +++ b/frontend/components/screen/ScreenList.tsx @@ -17,15 +17,21 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, - AlertResizableDialogContent, - AlertResizableDialogDescription, + AlertDialogContent, + AlertDialogDescription, AlertDialogFooter, - AlertResizableDialogHeader, + AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; -import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { + ResizableDialog, + ResizableDialogContent, + ResizableDialogHeader, + ResizableDialogTitle, + ResizableDialogFooter +} from "@/components/ui/resizable-dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette, RotateCcw, Trash } from "lucide-react"; import { ScreenDefinition } from "@/types/screen"; @@ -1056,11 +1062,11 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr {/* 화면 편집 다이얼로그 */} - - - - 화면 정보 편집 - + + + + 화면 정보 편집 +
@@ -1097,23 +1103,23 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
- + - -
-
+
+ + {/* 화면 미리보기 다이얼로그 */} - - - - 화면 미리보기 - {screenToPreview?.screenName} - + + + + 화면 미리보기 - {screenToPreview?.screenName} +
{isLoadingPreview ? (
@@ -1351,7 +1357,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
)}
- + @@ -1359,9 +1365,9 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr 편집 모드로 전환 - -
-
+ + +
); }