From 0b38f349aab64d4e311f76a4e88f8890d89b2ac5 Mon Sep 17 00:00:00 2001 From: kjs Date: Sat, 6 Sep 2025 00:16:27 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EC=B2=A8=EB=B6=80=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=97=B0=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/fileController.ts | 148 +++- backend-node/src/routes/fileRoutes.ts | 8 + .../screen/InteractiveDataTable.tsx | 651 ++++++++++++++---- frontend/components/screen/ScreenDesigner.tsx | 2 + .../screen/panels/DataTableConfigPanel.tsx | 77 ++- .../screen/panels/DetailSettingsPanel.tsx | 17 +- .../panels/FileComponentConfigPanel.tsx | 125 +++- .../components/screen/widgets/FileUpload.tsx | 106 ++- frontend/lib/api/file.ts | 26 + frontend/types/screen.ts | 21 +- 10 files changed, 1015 insertions(+), 166 deletions(-) diff --git a/backend-node/src/controllers/fileController.ts b/backend-node/src/controllers/fileController.ts index 9652d0de..98430546 100644 --- a/backend-node/src/controllers/fileController.ts +++ b/backend-node/src/controllers/fileController.ts @@ -62,21 +62,45 @@ const upload = multer({ fileSize: 50 * 1024 * 1024, // 50MB 제한 }, fileFilter: (req, file, cb) => { - // 파일 타입 검증 - const allowedTypes = [ + // 프론트엔드에서 전송된 accept 정보 확인 + const acceptHeader = req.body?.accept; + console.log("🔍 파일 타입 검증:", { + fileName: file.originalname, + mimeType: file.mimetype, + acceptFromFrontend: acceptHeader, + }); + + // 프론트엔드에서 */* 또는 * 허용한 경우 모든 파일 허용 + if ( + acceptHeader && + (acceptHeader.includes("*/*") || acceptHeader.includes("*")) + ) { + console.log("✅ 와일드카드 허용: 모든 파일 타입 허용"); + cb(null, true); + return; + } + + // 기본 허용 파일 타입 + const defaultAllowedTypes = [ "image/jpeg", "image/png", "image/gif", + "text/html", // HTML 파일 추가 + "text/plain", // 텍스트 파일 추가 "application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/zip", // ZIP 파일 추가 + "application/x-zip-compressed", // ZIP 파일 (다른 MIME 타입) ]; - if (allowedTypes.includes(file.mimetype)) { + if (defaultAllowedTypes.includes(file.mimetype)) { + console.log("✅ 기본 허용 파일 타입:", file.mimetype); cb(null, true); } else { + console.log("❌ 허용되지 않는 파일 타입:", file.mimetype); cb(new Error("허용되지 않는 파일 타입입니다.")); } }, @@ -116,11 +140,27 @@ export const uploadFiles = async ( } const files = req.files as Express.Multer.File[]; + + // 파라미터 확인 및 로깅 + console.log("📤 파일 업로드 요청 수신:", { + filesCount: files?.length || 0, + bodyKeys: Object.keys(req.body), + fullBody: req.body, // 전체 body 내용 확인 + }); + const { docType = "DOCUMENT", docTypeName = "일반 문서", targetObjid, parentTargetObjid, + // 테이블 연결 정보 (새로 추가) + linkedTable, + linkedField, + recordId, + autoLink, + // 가상 파일 컬럼 정보 + columnName, + isVirtualFileColumn, } = req.body; // 회사코드와 작성자 정보 결정 (우선순위: 요청 body > 사용자 토큰 정보 > 기본값) @@ -128,6 +168,26 @@ export const uploadFiles = async ( req.body.companyCode || (req.user as any)?.companyCode || "DEFAULT"; const writer = req.body.writer || (req.user as any)?.userId || "system"; + // 자동 연결 로직 - target_objid 자동 생성 + let finalTargetObjid = targetObjid; + if (autoLink === "true" && linkedTable && recordId) { + // 가상 파일 컬럼의 경우 컬럼명도 포함한 target_objid 생성 + if (isVirtualFileColumn === "true" && columnName) { + finalTargetObjid = `${linkedTable}:${recordId}:${columnName}`; + } else { + finalTargetObjid = `${linkedTable}:${recordId}`; + } + + console.log("🔗 자동 연결 활성화:", { + linkedTable, + linkedField, + recordId, + columnName, + isVirtualFileColumn, + generatedTargetObjid: finalTargetObjid, + }); + } + console.log("🔍 사용자 정보 결정:", { bodyCompanyCode: req.body.companyCode, userCompanyCode: (req.user as any)?.companyCode, @@ -185,7 +245,7 @@ export const uploadFiles = async ( generateUUID().replace(/-/g, "").substring(0, 15), 16 ), - target_objid: targetObjid, + target_objid: finalTargetObjid, saved_file_name: file.filename, real_file_name: file.originalname, doc_type: docType, @@ -290,6 +350,86 @@ export const deleteFile = async ( } }; +/** + * 테이블 연결된 파일 조회 + */ +export const getLinkedFiles = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { tableName, recordId } = req.params; + + console.log("📎 연결된 파일 조회 요청:", { + tableName, + recordId, + }); + + // target_objid 생성 (테이블명:레코드ID 형식) + const baseTargetObjid = `${tableName}:${recordId}`; + + console.log("🔍 파일 조회 쿼리:", { + tableName, + recordId, + baseTargetObjid, + queryPattern: `${baseTargetObjid}%`, + }); + + // 기본 target_objid와 파일 컬럼 패턴 모두 조회 (tableName:recordId% 패턴) + const files = await prisma.attach_file_info.findMany({ + where: { + target_objid: { + startsWith: baseTargetObjid, // tableName:recordId로 시작하는 모든 파일 + }, + status: "ACTIVE", + }, + orderBy: { + regdate: "desc", + }, + }); + + console.log("📁 조회된 파일 목록:", { + foundFiles: files.length, + targetObjids: files.map((f) => f.target_objid), + }); + + const fileList = files.map((file: any) => ({ + objid: file.objid.toString(), + savedFileName: file.saved_file_name, + realFileName: file.real_file_name, + fileSize: Number(file.file_size), + fileExt: file.file_ext, + filePath: file.file_path, + docType: file.doc_type, + docTypeName: file.doc_type_name, + targetObjid: file.target_objid, + parentTargetObjid: file.parent_target_objid, + writer: file.writer, + regdate: file.regdate?.toISOString(), + status: file.status, + })); + + console.log("✅ 연결된 파일 조회 완료:", { + baseTargetObjid, + fileCount: fileList.length, + }); + + res.json({ + success: true, + files: fileList, + totalCount: fileList.length, + targetObjid: baseTargetObjid, // 기준 target_objid 반환 + }); + } catch (error) { + console.error("연결된 파일 조회 오류:", error); + res.status(500).json({ + success: false, + message: "연결된 파일 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } +}; + /** * 파일 목록 조회 */ diff --git a/backend-node/src/routes/fileRoutes.ts b/backend-node/src/routes/fileRoutes.ts index 3a99b53d..0770b8b2 100644 --- a/backend-node/src/routes/fileRoutes.ts +++ b/backend-node/src/routes/fileRoutes.ts @@ -4,6 +4,7 @@ import { deleteFile, getFileList, downloadFile, + getLinkedFiles, uploadMiddleware, } from "../controllers/fileController"; import { authenticateToken } from "../middleware/authMiddleware"; @@ -28,6 +29,13 @@ router.post("/upload", uploadMiddleware, uploadFiles); */ router.get("/", getFileList); +/** + * @route GET /api/files/linked/:tableName/:recordId + * @desc 테이블 연결된 파일 조회 + * @access Private + */ +router.get("/linked/:tableName/:recordId", getLinkedFiles); + /** * @route DELETE /api/files/:objid * @desc 파일 삭제 (논리적 삭제) diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index ace54aef..4f9ab6e1 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -35,24 +35,42 @@ import { ZoomIn, ZoomOut, RotateCw, + Folder, + FolderOpen, } from "lucide-react"; import { tableTypeApi } from "@/lib/api/screen"; import { getCurrentUser, UserInfo } from "@/lib/api/client"; -import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen"; +import { DataTableComponent, DataTableColumn, DataTableFilter, AttachedFileInfo } from "@/types/screen"; import { cn } from "@/lib/utils"; -import { downloadFile } from "@/lib/api/file"; +import { downloadFile, getLinkedFiles } from "@/lib/api/file"; import { toast } from "sonner"; +import { FileUpload } from "@/components/screen/widgets/FileUpload"; -// 파일 데이터 타입 정의 +// 파일 데이터 타입 정의 (AttachedFileInfo와 호환) interface FileInfo { - id: string; - name: string; - size: number; - type: string; - extension: string; - uploadedAt: string; - lastModified: string; - serverFilename?: string; // 서버에 저장된 파일명 (다운로드용) + // AttachedFileInfo 기본 속성들 + objid: string; + savedFileName: string; + realFileName: string; + fileSize: number; + fileExt: string; + filePath: string; + docType: string; + docTypeName: string; + targetObjid: string; + parentTargetObjid?: string; + companyCode: string; + writer: string; + regdate: string; + status: string; + + // 추가 호환성 속성들 + path?: string; // filePath와 동일 + name?: string; // realFileName과 동일 + id?: string; // objid와 동일 + size?: number; // fileSize와 동일 + type?: string; // docType과 동일 + uploadedAt?: string; // regdate와 동일 } interface FileColumnData { @@ -94,6 +112,112 @@ export const InteractiveDataTable: React.FC = ({ const [zoom, setZoom] = useState(1); const [rotation, setRotation] = useState(0); + // 파일 관리 상태 + const [fileStatusMap, setFileStatusMap] = useState>({}); // 행별 파일 상태 + const [showFileManagementModal, setShowFileManagementModal] = useState(false); + const [selectedRowForFiles, setSelectedRowForFiles] = useState | null>(null); + const [selectedColumnForFiles, setSelectedColumnForFiles] = useState(null); // 선택된 컬럼 정보 + const [linkedFiles, setLinkedFiles] = useState([]); + + // 파일 상태 확인 함수 + const checkFileStatus = useCallback( + async (rowData: Record) => { + if (!component.tableName) return; + + // 첫 번째 컬럼을 기본키로 사용 (실제로는 더 정교한 로직 필요) + const primaryKeyField = Object.keys(rowData)[0]; // 임시로 첫 번째 컬럼 사용 + const recordId = rowData[primaryKeyField]; + + if (!recordId) return; + + try { + const response = await getLinkedFiles(component.tableName, recordId); + const hasFiles = response.files && response.files.length > 0; + const fileCount = response.files ? response.files.length : 0; + + return { hasFiles, fileCount, files: response.files || [] }; + } catch (error) { + console.error("파일 상태 확인 오류:", error); + return { hasFiles: false, fileCount: 0, files: [] }; + } + }, + [component.tableName], + ); + + // 파일 폴더 아이콘 클릭 핸들러 (전체 행 파일 관리) + const handleFileIconClick = useCallback( + async (rowData: Record) => { + const fileStatus = await checkFileStatus(rowData); + if (fileStatus) { + setSelectedRowForFiles(rowData); + setLinkedFiles(fileStatus.files); + setShowFileManagementModal(true); + } + }, + [checkFileStatus], + ); + + // 컬럼별 파일 상태 확인 + const checkColumnFileStatus = useCallback( + async (rowData: Record, column: DataTableColumn) => { + if (!component.tableName) return null; + + const primaryKeyField = Object.keys(rowData)[0]; + const recordId = rowData[primaryKeyField]; + if (!recordId) return null; + + try { + // 가상 파일 컬럼의 경우: tableName:recordId:columnName 형태로 target_objid 생성 + const targetObjid = column.isVirtualFileColumn + ? `${component.tableName}:${recordId}:${column.columnName}` + : `${component.tableName}:${recordId}`; + + const response = await getLinkedFiles(component.tableName, recordId); + + // 가상 파일 컬럼의 경우 해당 컬럼의 파일만 필터링 + let files = response.files || []; + if (column.isVirtualFileColumn) { + // 현재 컬럼명으로 먼저 시도 + files = files.filter( + (file: any) => file.targetObjid === targetObjid || file.targetObjid?.endsWith(`:${column.columnName}`), // target_objid → targetObjid + ); + + // 파일이 없는 경우 fallback: 모든 파일 컬럼 패턴 시도 + if (files.length === 0) { + // 해당 테이블:레코드의 모든 파일 컬럼 파일들을 가져옴 + files = (response.files || []).filter( + (file: any) => file.targetObjid?.startsWith(`${component.tableName}:${recordId}:file_column_`), // target_objid → targetObjid + ); + } + } + + const hasFiles = files.length > 0; + const fileCount = files.length; + + return { hasFiles, fileCount, files, targetObjid }; + } catch (error) { + console.error("컬럼별 파일 상태 확인 오류:", error); + return { hasFiles: false, fileCount: 0, files: [], targetObjid: null }; + } + }, + [component.tableName], + ); + + // 컬럼별 파일 클릭 핸들러 + const handleColumnFileClick = useCallback( + async (rowData: Record, column: DataTableColumn) => { + // 컬럼별 파일 상태 확인 + const fileStatus = await checkColumnFileStatus(rowData, column); + setSelectedRowForFiles(rowData); + setSelectedColumnForFiles(column); // 선택된 컬럼 정보 저장 + setLinkedFiles(fileStatus?.files || []); + setShowFileManagementModal(true); + + // TODO: 모달에 컬럼 정보 전달하여 해당 컬럼 전용 파일 업로드 가능하게 하기 + }, + [checkColumnFileStatus], + ); + // 이미지 미리보기 핸들러들 const handlePreviewImage = useCallback((fileInfo: FileInfo) => { setPreviewImage(fileInfo); @@ -200,25 +324,92 @@ export const InteractiveDataTable: React.FC = ({ setLoading(true); try { - console.log("🔍 테이블 데이터 조회:", { - tableName: component.tableName, - page, - pageSize, - searchParams, - }); - const result = await tableTypeApi.getTableData(component.tableName, { page, size: pageSize, search: searchParams, }); - console.log("✅ 테이블 데이터 조회 결과:", result); - setData(result.data); setTotal(result.total); setTotalPages(result.totalPages); setCurrentPage(result.page); + + // 각 행의 파일 상태 확인 (전체 행 + 가상 파일 컬럼별) + const fileStatusPromises = result.data.map(async (rowData: Record) => { + const primaryKeyField = Object.keys(rowData)[0]; + const recordId = rowData[primaryKeyField]; + + if (!recordId) return { rowKey: recordId, statuses: {} }; + + try { + const fileResponse = await getLinkedFiles(component.tableName, recordId); + const allFiles = fileResponse.files || []; + + // 전체 행에 대한 파일 상태 + const rowStatus = { + hasFiles: allFiles.length > 0, + fileCount: allFiles.length, + }; + + // 가상 파일 컬럼별 파일 상태 + const columnStatuses: Record = {}; + + // 가상 파일 컬럼 찾기 + const virtualFileColumns = component.columns.filter((col) => col.isVirtualFileColumn); + + virtualFileColumns.forEach((column) => { + // 해당 컬럼의 파일만 필터링 (targetObjid로 수정) + let columnFiles = allFiles.filter((file: any) => file.targetObjid?.endsWith(`:${column.columnName}`)); + + // fallback: 컬럼명으로 찾지 못한 경우 모든 파일 컬럼 파일 포함 + if (columnFiles.length === 0) { + columnFiles = allFiles.filter((file: any) => + file.targetObjid?.startsWith(`${component.tableName}:${recordId}:file_column_`), + ); + } + + const columnKey = `${recordId}_${column.columnName}`; + columnStatuses[columnKey] = { + hasFiles: columnFiles.length > 0, + fileCount: columnFiles.length, + }; + }); + + return { + rowKey: recordId, + statuses: { + [recordId]: rowStatus, // 전체 행 상태 + ...columnStatuses, // 컬럼별 상태 + }, + }; + } catch { + // 에러 시 기본값 + const defaultStatuses: Record = { + [recordId]: { hasFiles: false, fileCount: 0 }, + }; + + // 가상 파일 컬럼에 대해서도 기본값 설정 + const virtualFileColumns = component.columns.filter((col) => col.isVirtualFileColumn); + virtualFileColumns.forEach((column) => { + const columnKey = `${recordId}_${column.columnName}`; + defaultStatuses[columnKey] = { hasFiles: false, fileCount: 0 }; + }); + + return { rowKey: recordId, statuses: defaultStatuses }; + } + }); + + // 파일 상태 업데이트 + Promise.all(fileStatusPromises).then((results) => { + const statusMap: Record = {}; + + results.forEach((result) => { + Object.assign(statusMap, result.statuses); + }); + + setFileStatusMap(statusMap); + }); } catch (error) { console.error("❌ 테이블 데이터 조회 실패:", error); setData([]); @@ -251,10 +442,8 @@ export const InteractiveDataTable: React.FC = ({ useEffect(() => { const fetchTableColumns = async () => { try { - console.log("🔄 테이블 컬럼 정보 로드 시작:", component.tableName); const columns = await tableTypeApi.getColumns(component.tableName); setTableColumns(columns); - console.log("✅ 테이블 컬럼 정보 로드 완료:", columns); } catch (error) { console.error("테이블 컬럼 정보 로드 실패:", error); } @@ -272,7 +461,6 @@ export const InteractiveDataTable: React.FC = ({ // 검색 실행 const handleSearch = useCallback(() => { - console.log("🔍 검색 실행:", searchValues); loadData(1, searchValues); }, [searchValues, loadData]); @@ -512,8 +700,6 @@ export const InteractiveDataTable: React.FC = ({ } else { handleAddFormChange(columnName, fileNames); } - - console.log("✅ 파일 업로드 완료:", validFiles); } catch (error) { console.error("파일 업로드 실패:", error); alert("파일 업로드에 실패했습니다."); @@ -1280,25 +1466,15 @@ export const InteractiveDataTable: React.FC = ({ } }; - // 파일 모달 열기 - const openFileModal = (fileData: FileColumnData, column: DataTableColumn) => { - setCurrentFileData(fileData); - setCurrentFileColumn(column); - setShowFileModal(true); - }; - // 파일 다운로드 const handleDownloadFile = useCallback(async (fileInfo: FileInfo) => { try { - console.log("📥 파일 다운로드 시작:", fileInfo); - - // serverFilename이 없는 경우 파일 경로에서 추출 시도 - const serverFilename = fileInfo.serverFilename || (fileInfo.path ? fileInfo.path.split("/").pop() : null); + // savedFileName이 없는 경우 파일 경로에서 추출 시도 + const serverFilename = fileInfo.savedFileName || (fileInfo.path ? fileInfo.path.split("/").pop() : null); if (!serverFilename) { // _file 속성이 있는 경우 로컬 파일로 다운로드 if ((fileInfo as any)._file) { - console.log("📁 로컬 파일 다운로드 시도:", fileInfo.name); try { const file = (fileInfo as any)._file; @@ -1309,12 +1485,6 @@ export const InteractiveDataTable: React.FC = ({ return; } - console.log("📁 유효한 파일 객체 확인됨:", { - name: file.name || fileInfo.name, - size: file.size, - type: file.type, - }); - const url = URL.createObjectURL(file); const link = document.createElement("a"); link.href = url; @@ -1352,108 +1522,50 @@ export const InteractiveDataTable: React.FC = ({ }, []); // 셀 값 포맷팅 - const formatCellValue = (value: any, column: DataTableColumn): React.ReactNode => { - if (value === null || value === undefined) return ""; + const formatCellValue = (value: any, column: DataTableColumn, rowData?: Record): React.ReactNode => { + // 가상 파일 컬럼의 경우 value가 없어도 파일 아이콘을 표시해야 함 + if (!column.isVirtualFileColumn && (value === null || value === undefined)) return ""; - // 디버깅을 위한 로그 추가 - if (column.columnName === "file_path") { - console.log("📊 formatCellValue (file_path 컬럼):", { - columnName: column.columnName, - widgetType: column.widgetType, - value: value, - valueType: typeof value, - fullColumn: column, - }); - } + // 파일 타입 컬럼 처리 (가상 파일 컬럼 포함) + const isFileColumn = + column.widgetType === "file" || column.columnName === "file_path" || column.isVirtualFileColumn; - // file_path 컬럼은 강제로 파일 타입으로 처리 (임시 해결책) - const isFileColumn = column.widgetType === "file" || column.columnName === "file_path"; + // 파일 타입 컬럼은 파일 아이콘으로 표시 (컬럼별 파일 관리) + if (isFileColumn && rowData) { + // 현재 행의 기본키 값 가져오기 + const primaryKeyField = Object.keys(rowData)[0]; + const recordId = rowData[primaryKeyField]; - // file_path 컬럼도 파일 타입으로 처리 - if (isFileColumn) { - console.log("🗂️ 파일 타입 컬럼 처리 중:", value); - if (value) { - try { - let fileData; + // 해당 컬럼에 대한 파일 상태 확인 + const columnFileKey = `${recordId}_${column.columnName}`; + const columnFileStatus = fileStatusMap[columnFileKey]; + const hasFiles = columnFileStatus?.hasFiles || false; + const fileCount = columnFileStatus?.fileCount || 0; - // 파일 경로 문자열인지 확인 (/uploads/로 시작하는 경우) - if (typeof value === "string" && value.startsWith("/uploads/")) { - // 파일 경로 문자열인 경우 단일 파일로 처리 - const fileName = value.split("/").pop() || "파일"; - const fileExt = fileName.split(".").pop()?.toLowerCase() || ""; - fileData = { - files: [ - { - name: fileName.replace(/^\d+_/, ""), // 타임스탬프 제거 - path: value, - objid: Date.now().toString(), // 임시 objid - size: 0, // 크기 정보 없음 - type: - fileExt === "jpg" || fileExt === "jpeg" - ? "image/jpeg" - : fileExt === "png" - ? "image/png" - : fileExt === "gif" - ? "image/gif" - : fileExt === "pdf" - ? "application/pdf" - : "application/octet-stream", - extension: fileExt, - regdate: new Date().toISOString(), // 등록일 추가 - writer: "시스템", // 기본 등록자 - }, - ], - totalCount: 1, - totalSize: 0, - regdate: new Date().toISOString(), // 파일 데이터 전체에도 등록일 추가 - }; - } else { - // JSON 문자열이면 파싱 - fileData = typeof value === "string" ? JSON.parse(value) : value; - - // regdate가 없는 경우 기본값 설정 - if (!fileData.regdate) { - fileData.regdate = new Date().toISOString(); - } - - // 개별 파일들에도 regdate와 writer가 없는 경우 추가 - if (fileData.files && Array.isArray(fileData.files)) { - fileData.files.forEach((file: any) => { - if (!file.regdate) { - file.regdate = new Date().toISOString(); - } - if (!file.writer) { - file.writer = "시스템"; - } - }); - } - } - - console.log("📁 파싱된 파일 데이터:", fileData); - - if (fileData?.files && Array.isArray(fileData.files) && fileData.files.length > 0) { - return ( -
- - - {(fileData.totalSize / 1024 / 1024).toFixed(1)}MB - + return ( +
+ +
+ ); } switch (column.widgetType) { @@ -1614,13 +1726,26 @@ export const InteractiveDataTable: React.FC = ({ {column.label} ))} + {/* 기본 파일 컬럼은 가상 파일 컬럼이 있으면 완전히 숨김 */} + {!visibleColumns.some((col) => col.widgetType === "file") && ( + +
+ + 파일 +
+
+ )} {loading ? ( col.widgetType === "file") ? 1 : 0) + } className="h-32 text-center" >
@@ -1643,15 +1768,54 @@ export const InteractiveDataTable: React.FC = ({ )} {visibleColumns.map((column: DataTableColumn) => ( - {formatCellValue(row[column.columnName], column)} + {formatCellValue(row[column.columnName], column, row)} ))} + {/* 기본 파일 셀은 가상 파일 컬럼이 있으면 완전히 숨김 */} + {!visibleColumns.some((col) => col.widgetType === "file") && ( + + {(() => { + const primaryKeyField = Object.keys(row)[0]; + const recordId = row[primaryKeyField]; + const fileStatus = fileStatusMap[recordId]; + const hasFiles = fileStatus?.hasFiles || false; + const fileCount = fileStatus?.fileCount || 0; + + return ( + + ); + })()} + + )} )) ) : ( col.widgetType === "file") ? 1 : 0) + } className="h-32 text-center" >
@@ -2032,7 +2196,7 @@ export const InteractiveDataTable: React.FC = ({
{previewImage && ( {previewImage.name} = ({ )} + + {/* 파일 관리 모달 */} + + + + + + 파일 관리 + {selectedRowForFiles && ( + + {Object.keys(selectedRowForFiles)[0]}: {selectedRowForFiles[Object.keys(selectedRowForFiles)[0]]} + + )} + + + {linkedFiles.length > 0 + ? `${linkedFiles.length}개의 파일이 연결되어 있습니다.` + : "연결된 파일이 없습니다. 새 파일을 업로드하세요."} + + + +
+ {/* 기존 파일 목록 */} + {linkedFiles.length > 0 && ( +
+

연결된 파일

+ {linkedFiles.map((file: any, index: number) => ( +
+
+ +
+
{file.realFileName}
+
+ {(Number(file.fileSize) / 1024 / 1024).toFixed(2)} MB • {file.docTypeName} + {file.regdate && • {new Date(file.regdate).toLocaleString("ko-KR")}} + {file.writer && • {file.writer}} +
+
+
+
+ {file.fileExt && ["jpg", "jpeg", "png", "gif"].includes(file.fileExt.toLowerCase()) && ( + + )} + +
+
+ ))} +
+ )} + + {/* 파일 업로드 섹션 */} +
+
+

{selectedColumnForFiles?.label || "파일"} 업로드

+ {selectedColumnForFiles?.isVirtualFileColumn && ( + + {selectedColumnForFiles.fileColumnConfig?.docTypeName || "문서"} + + )} +
+ + {selectedRowForFiles && selectedColumnForFiles && component.tableName && ( +
+ { + // 모달에서는 컴포넌트 업데이트가 필요 없으므로 빈 함수 제공 + }} + onFileUpload={async () => { + // 파일 업로드 완료 후 연결된 파일 목록 새로고침 + if (selectedRowForFiles && selectedColumnForFiles) { + const result = await checkColumnFileStatus(selectedRowForFiles, selectedColumnForFiles); + if (result) { + setLinkedFiles(result.files); + + // 파일 상태 맵도 업데이트 + const primaryKeyField = Object.keys(selectedRowForFiles)[0]; + const recordId = selectedRowForFiles[primaryKeyField]; + const columnFileKey = `${recordId}_${selectedColumnForFiles.columnName}`; + + setFileStatusMap((prev) => { + const newFileStatusMap = { + ...prev, + [columnFileKey]: { + hasFiles: result.hasFiles, + fileCount: result.fileCount, + }, + }; + return newFileStatusMap; + }); + + // 전체 테이블의 해당 컬럼 파일 상태도 강제 새로고침 + setTimeout(() => { + // 테이블 데이터 새로고침을 위해 loadData 호출 + if (data && data.length > 0) { + // 현재 데이터를 그대로 사용하되 파일 상태만 새로고침 + const refreshPromises = data.map(async (row) => { + const pk = Object.keys(row)[0]; + const rowId = row[pk]; + const fileKey = `${rowId}_${selectedColumnForFiles.columnName}`; + + const columnStatus = await checkColumnFileStatus(row, selectedColumnForFiles); + if (columnStatus) { + setFileStatusMap((prev) => ({ + ...prev, + [fileKey]: { + hasFiles: columnStatus.hasFiles, + fileCount: columnStatus.fileCount, + }, + })); + } + }); + + Promise.all(refreshPromises); + } + }, 100); + } + } + }} + /> +
+ )} +
+
+ + + + +
+
); }; diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 674f29be..242a1ca2 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -3010,6 +3010,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD onUpdateProperty={(componentId: string, path: string, value: any) => { updateComponentProperty(componentId, path, value); }} + currentTable={tables.length > 0 ? tables[0] : undefined} + currentTableName={selectedScreen?.tableName} /> diff --git a/frontend/components/screen/panels/DataTableConfigPanel.tsx b/frontend/components/screen/panels/DataTableConfigPanel.tsx index 95f9cd46..b2c7eb79 100644 --- a/frontend/components/screen/panels/DataTableConfigPanel.tsx +++ b/frontend/components/screen/panels/DataTableConfigPanel.tsx @@ -1061,6 +1061,71 @@ export const DataTableConfigPanel: React.FC = ({ [selectedTable, component.columns, component.filters, onUpdateComponent], ); + // 가상 파일 컬럼 추가 + const addVirtualFileColumn = useCallback(() => { + const fileColumnCount = component.columns.filter((col) => col.isVirtualFileColumn).length; + const newColumnName = `file_column_${fileColumnCount + 1}`; // 순차적 번호 사용 + + const newColumn: DataTableColumn = { + id: generateComponentId(), + columnName: newColumnName, + label: `파일 컬럼 ${fileColumnCount + 1}`, + widgetType: "file", + gridColumns: 2, + visible: true, + filterable: false, // 파일 컬럼은 필터링 불가 + sortable: false, // 파일 컬럼은 정렬 불가 + searchable: false, // 파일 컬럼은 검색 불가 + isVirtualFileColumn: true, // 가상 파일 컬럼 표시 + fileColumnConfig: { + docType: "DOCUMENT", + docTypeName: "일반 문서", + maxFiles: 5, + accept: ["*/*"], + }, + }; + + console.log("📁 가상 파일 컬럼 추가:", { + columnName: newColumn.columnName, + label: newColumn.label, + isVirtualFileColumn: newColumn.isVirtualFileColumn, + }); + + // 로컬 상태에 새 컬럼 입력값 추가 + setLocalColumnInputs((prev) => ({ + ...prev, + [newColumn.id]: newColumn.label, + })); + + // 로컬 체크박스 상태에 새 컬럼 추가 + setLocalColumnCheckboxes((prev) => ({ + ...prev, + [newColumn.id]: { + visible: newColumn.visible, + sortable: newColumn.sortable, + searchable: newColumn.searchable, + }, + })); + + // 로컬 그리드 컬럼 상태에 새 컬럼 추가 + setLocalColumnGridColumns((prev) => ({ + ...prev, + [newColumn.id]: newColumn.gridColumns, + })); + + // 컬럼 업데이트 + const updates: Partial = { + columns: [...component.columns, newColumn], + }; + + onUpdateComponent(updates); + + // 컬럼 추가 후 컬럼 탭으로 자동 이동 + setActiveTab("columns"); + + console.log("✅ 가상 파일 컬럼 추가 완료"); + }, [component.columns, onUpdateComponent]); + return (
@@ -1459,6 +1524,14 @@ export const DataTableConfigPanel: React.FC = ({

테이블 컬럼 설정

{component.columns.length}개 + + {/* 파일 컬럼 추가 버튼 */} + + + {/* 기존 DB 컬럼 추가 */} {selectedTable && (() => { const availableColumns = selectedTable.columns.filter( @@ -1468,7 +1541,7 @@ export const DataTableConfigPanel: React.FC = ({ return availableColumns.length > 0 ? ( +
✅ 현재 화면의 테이블이 자동으로 설정됩니다
+
+ +
+ + +
✅ 테이블의 기본키가 자동으로 감지됩니다
+
+ +
+ 💡 이 설정을 활성화하면 파일이 현재 레코드와 자동으로 연결됩니다. +
+ {currentTableName && localInputs.linkedField ? ( + <> + 예: {currentTableName} 테이블의 {localInputs.linkedField}가 "값123"인 레코드에 파일을 업로드하면 +
+ target_objid가 "{currentTableName}:값123"로 설정됩니다. + + ) : ( + <>테이블과 기본키 정보가 자동으로 설정되면 연결 예시가 표시됩니다. + )} +
+ + )} +
+
); diff --git a/frontend/components/screen/widgets/FileUpload.tsx b/frontend/components/screen/widgets/FileUpload.tsx index b268773e..961c0fe6 100644 --- a/frontend/components/screen/widgets/FileUpload.tsx +++ b/frontend/components/screen/widgets/FileUpload.tsx @@ -8,7 +8,8 @@ import { useAuth } from "@/hooks/useAuth"; interface FileUploadProps { component: FileComponent; - onUpdateComponent: (updates: Partial) => void; + onUpdateComponent?: (updates: Partial) => void; + onFileUpload?: (files: AttachedFileInfo[]) => void; // 파일 업로드 완료 콜백 userInfo?: any; // 사용자 정보 (선택적) } @@ -16,7 +17,7 @@ interface FileUploadProps { * 독립적인 File 컴포넌트 * attach_file_info 테이블 기반 파일 관리 */ -export function FileUpload({ component, onUpdateComponent, userInfo }: FileUploadProps) { +export function FileUpload({ component, onUpdateComponent, onFileUpload, userInfo }: FileUploadProps) { const [isDragOver, setIsDragOver] = useState(false); const [uploadQueue, setUploadQueue] = useState([]); const [localUploadedFiles, setLocalUploadedFiles] = useState(component.uploadedFiles || []); @@ -110,16 +111,43 @@ export function FileUpload({ component, onUpdateComponent, userInfo }: FileUploa // 파일 확장자 검증 const isFileTypeAllowed = (file: File): boolean => { const fileName = file.name.toLowerCase(); - return fileConfig.accept.some((accept) => { - if (accept.startsWith(".")) { - return fileName.endsWith(accept); + + console.log("🔍 파일 타입 검증:", { + fileName: file.name, + fileType: file.type, + acceptRules: fileConfig.accept, + }); + + const result = fileConfig.accept.some((accept) => { + // 모든 파일 허용 (와일드카드) + if (accept === "*/*" || accept === "*") { + console.log("✅ 와일드카드 매칭:", accept); + return true; } + + // 확장자 기반 검증 (.jpg, .png 등) + if (accept.startsWith(".")) { + const matches = fileName.endsWith(accept.toLowerCase()); + console.log(`${matches ? "✅" : "❌"} 확장자 검증:`, accept, "→", matches); + return matches; + } + + // MIME 타입 기반 검증 (image/*, text/* 등) if (accept.includes("/*")) { const type = accept.split("/")[0]; - return file.type.startsWith(type); + const matches = file.type.startsWith(type); + console.log(`${matches ? "✅" : "❌"} MIME 타입 검증:`, accept, "→", matches); + return matches; } - return file.type === accept; + + // 정확한 MIME 타입 매칭 (image/jpeg, application/pdf 등) + const matches = file.type === accept; + console.log(`${matches ? "✅" : "❌"} 정확한 MIME 매칭:`, accept, "→", matches); + return matches; }); + + console.log(`🎯 최종 검증 결과:`, result); + return result; }; // 파일 선택 핸들러 @@ -184,9 +212,18 @@ export function FileUpload({ component, onUpdateComponent, userInfo }: FileUploa setUploadQueue((prev) => [...prev, ...validFiles]); if (fileConfig.autoUpload) { - console.log("🚀 자동 업로드 시작"); + console.log("🚀 자동 업로드 시작:", { + autoUpload: fileConfig.autoUpload, + filesCount: validFiles.length, + fileNames: validFiles.map((f) => f.name), + }); // 자동 업로드 실행 validFiles.forEach(uploadFile); + } else { + console.log("⏸️ 자동 업로드 비활성화:", { + autoUpload: fileConfig.autoUpload, + filesCount: validFiles.length, + }); } } else { console.log("❌ 업로드할 유효한 파일이 없음"); @@ -270,6 +307,45 @@ export function FileUpload({ component, onUpdateComponent, userInfo }: FileUploa formData.append("writer", "system"); } + // 프론트엔드 파일 타입 설정을 백엔드로 전송 + if (fileConfig.accept && fileConfig.accept.length > 0) { + const acceptString = fileConfig.accept.join(","); + formData.append("accept", acceptString); + console.log("✅ 허용 파일 타입 추가:", acceptString); + } + + // 자동 연결 정보 추가 + if (fileConfig.autoLink) { + formData.append("autoLink", "true"); + console.log("✅ 자동 연결 활성화: true"); + + if (fileConfig.linkedTable) { + formData.append("linkedTable", fileConfig.linkedTable); + console.log("✅ 연결 테이블 추가:", fileConfig.linkedTable); + } + + if (fileConfig.linkedField) { + formData.append("linkedField", fileConfig.linkedField); + console.log("✅ 연결 필드 추가:", fileConfig.linkedField); + } + + if (fileConfig.recordId) { + formData.append("recordId", fileConfig.recordId); + console.log("✅ 레코드 ID 추가:", fileConfig.recordId); + } + + // 가상 파일 컬럼 정보 추가 + if (fileConfig.isVirtualFileColumn) { + formData.append("isVirtualFileColumn", "true"); + console.log("✅ 가상 파일 컬럼 활성화: true"); + + if (fileConfig.columnName) { + formData.append("columnName", fileConfig.columnName); + console.log("✅ 컬럼명 추가:", fileConfig.columnName); + } + } + } + // FormData 내용 디버깅 console.log("📋 FormData 내용 확인:"); for (const [key, value] of formData.entries()) { @@ -357,9 +433,17 @@ export function FileUpload({ component, onUpdateComponent, userInfo }: FileUploa // 로컬 상태 업데이트 setLocalUploadedFiles(updatedFiles); - onUpdateComponent({ - uploadedFiles: updatedFiles, - }); + // 컴포넌트 업데이트 (옵셔널) + if (onUpdateComponent) { + onUpdateComponent({ + uploadedFiles: updatedFiles, + }); + } + + // 파일 업로드 완료 콜백 호출 (모달에서 사용) + if (onFileUpload) { + onFileUpload(updatedFiles); + } // 업로드 큐에서 제거 setUploadQueue((prev) => prev.filter((f) => f !== file)); diff --git a/frontend/lib/api/file.ts b/frontend/lib/api/file.ts index f5792d48..bcf51f1d 100644 --- a/frontend/lib/api/file.ts +++ b/frontend/lib/api/file.ts @@ -144,3 +144,29 @@ export const uploadFilesAndCreateData = async (files: FileList) => { throw error; } }; + +/** + * 테이블 연결된 파일 조회 + */ +export const getLinkedFiles = async ( + tableName: string, + recordId: string, +): Promise<{ + success: boolean; + files: any[]; + totalCount: number; + targetObjid: string; +}> => { + try { + console.log("📎 연결된 파일 조회:", { tableName, recordId }); + + const response = await apiClient.get(`/files/linked/${tableName}/${recordId}`); + + console.log("✅ 연결된 파일 조회 성공:", response.data); + + return response.data; + } catch (error) { + console.error("연결된 파일 조회 오류:", error); + throw new Error("연결된 파일 조회에 실패했습니다."); + } +}; diff --git a/frontend/types/screen.ts b/frontend/types/screen.ts index 4d3af836..ae0f8194 100644 --- a/frontend/types/screen.ts +++ b/frontend/types/screen.ts @@ -226,6 +226,16 @@ export interface FileComponent extends BaseComponent { targetObjid?: string; // 연결된 주 객체 ID (예: 계약 ID, 프로젝트 ID) parentTargetObjid?: string; // 부모 객체 ID (계층 구조용) + // 테이블 연결 설정 (새로 추가) + linkedTable?: string; // 연결할 테이블명 (예: company_mng, user_info) + linkedField?: string; // 연결할 필드명 (예: emp_id, user_id) + autoLink?: boolean; // 자동 연결 여부 (현재 레코드와 자동 연결) + recordId?: string; // 연결할 레코드 ID + + // 가상 파일 컬럼 전용 설정 + columnName?: string; // 가상 파일 컬럼명 (tableName:recordId:columnName 형태로 target_objid 생성) + isVirtualFileColumn?: boolean; // 가상 파일 컬럼 여부 + // UI 설정 showPreview: boolean; // 미리보기 표시 여부 showProgress: boolean; // 업로드 진행률 표시 @@ -283,7 +293,7 @@ export interface WidgetComponent extends BaseComponent { // 데이터 테이블 컬럼 설정 export interface DataTableColumn { id: string; - columnName: string; // 실제 DB 컬럼명 + columnName: string; // 실제 DB 컬럼명 (가상 컬럼의 경우 고유 식별자) label: string; // 화면에 표시될 라벨 widgetType: WebType; // 컬럼의 데이터 타입 gridColumns: number; // 그리드에서 차지할 컬럼 수 (1-12) @@ -292,6 +302,15 @@ export interface DataTableColumn { sortable: boolean; // 정렬 가능 여부 searchable: boolean; // 검색 대상 여부 webTypeConfig?: WebTypeConfig; // 컬럼별 상세 설정 + + // 가상 파일 컬럼 관련 속성 + isVirtualFileColumn?: boolean; // 가상 파일 컬럼인지 여부 + fileColumnConfig?: { + docType?: string; // 문서 타입 (CONTRACT, DRAWING, PHOTO 등) + docTypeName?: string; // 문서 타입 표시명 + maxFiles?: number; // 최대 파일 개수 + accept?: string[]; // 허용 파일 타입 + }; } // 데이터 테이블 필터 설정