From fa6c00b6bee58b0d532a672cfe76cab867c0890d Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 10 Dec 2025 17:41:41 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EC=9E=98=EB=A6=AC?= =?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/EditModal.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 2a3050fc..3815fc71 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -678,12 +678,13 @@ export const EditModal: React.FC = ({ className }) => { } // 화면관리에서 설정한 크기 = 컨텐츠 영역 크기 - // 실제 모달 크기 = 컨텐츠 + 헤더 + gap + padding + // 실제 모달 크기 = 컨텐츠 + 헤더 + gap + padding + 라벨 공간 const headerHeight = 52; // DialogHeader (타이틀 + border-b + py-3) const dialogGap = 16; // DialogContent gap-4 const extraPadding = 24; // 추가 여백 (안전 마진) + const labelSpace = 30; // 입력 필드 위 라벨 공간 (-top-6 = 24px + 여유) - const totalHeight = screenDimensions.height + headerHeight + dialogGap + extraPadding; + const totalHeight = screenDimensions.height + headerHeight + dialogGap + extraPadding + labelSpace; return { className: "overflow-hidden p-0", @@ -729,7 +730,7 @@ export const EditModal: React.FC = ({ className }) => { className="relative bg-white" style={{ width: screenDimensions?.width || 800, - height: screenDimensions?.height || 600, + height: (screenDimensions?.height || 600) + 30, // 라벨 공간 추가 transformOrigin: "center center", maxWidth: "100%", maxHeight: "100%", @@ -739,13 +740,14 @@ export const EditModal: React.FC = ({ className }) => { // 컴포넌트 위치를 offset만큼 조정 const offsetX = screenDimensions?.offsetX || 0; const offsetY = screenDimensions?.offsetY || 0; + const labelSpace = 30; // 라벨 공간 (입력 필드 위 -top-6 라벨용) const adjustedComponent = { ...component, position: { ...component.position, x: parseFloat(component.position?.x?.toString() || "0") - offsetX, - y: parseFloat(component.position?.y?.toString() || "0") - offsetY, + y: parseFloat(component.position?.y?.toString() || "0") - offsetY + labelSpace, // 라벨 공간 추가 }, }; From d09c8e0787a1613117d2dc21fdf671b2d3a3548b Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 10 Dec 2025 18:38:16 +0900 Subject: [PATCH 2/3] =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=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 | 94 ++++++ frontend/components/screen/EditModal.tsx | 9 +- .../file-upload/FileUploadComponent.tsx | 276 ++++++++++++++---- .../table-list/TableListComponent.tsx | 29 ++ 4 files changed, 353 insertions(+), 55 deletions(-) diff --git a/backend-node/src/controllers/fileController.ts b/backend-node/src/controllers/fileController.ts index b1e31e3b..fe3d5cfd 100644 --- a/backend-node/src/controllers/fileController.ts +++ b/backend-node/src/controllers/fileController.ts @@ -341,6 +341,50 @@ export const uploadFiles = async ( }); } + // 🆕 레코드 모드: 해당 행의 attachments 컬럼 자동 업데이트 + const isRecordMode = req.body.isRecordMode === "true" || req.body.isRecordMode === true; + if (isRecordMode && linkedTable && recordId && columnName) { + try { + // 해당 레코드의 모든 첨부파일 조회 + const allFiles = await query( + `SELECT objid, real_file_name, file_size, file_ext, file_path, regdate + FROM attach_file_info + WHERE target_objid = $1 AND status = 'ACTIVE' + ORDER BY regdate DESC`, + [finalTargetObjid] + ); + + // attachments JSONB 형태로 변환 + const attachmentsJson = allFiles.map((f: any) => ({ + objid: f.objid.toString(), + realFileName: f.real_file_name, + fileSize: Number(f.file_size), + fileExt: f.file_ext, + filePath: f.file_path, + regdate: f.regdate?.toISOString(), + })); + + // 해당 테이블의 attachments 컬럼 업데이트 + // 🔒 멀티테넌시: company_code 필터 추가 + await query( + `UPDATE ${linkedTable} + SET ${columnName} = $1::jsonb, updated_date = NOW() + WHERE id = $2 AND company_code = $3`, + [JSON.stringify(attachmentsJson), recordId, companyCode] + ); + + console.log("📎 [레코드 모드] attachments 컬럼 자동 업데이트:", { + tableName: linkedTable, + recordId: recordId, + columnName: columnName, + fileCount: attachmentsJson.length, + }); + } catch (updateError) { + // attachments 컬럼 업데이트 실패해도 파일 업로드는 성공으로 처리 + console.warn("⚠️ attachments 컬럼 업데이트 실패 (무시):", updateError); + } + } + res.json({ success: true, message: `${files.length}개 파일 업로드 완료`, @@ -405,6 +449,56 @@ export const deleteFile = async ( ["DELETED", parseInt(objid)] ); + // 🆕 레코드 모드: 해당 행의 attachments 컬럼 자동 업데이트 + const targetObjid = fileRecord.target_objid; + if (targetObjid && !targetObjid.startsWith('screen_files:') && !targetObjid.startsWith('temp_')) { + // targetObjid 파싱: tableName:recordId:columnName 형식 + const parts = targetObjid.split(':'); + if (parts.length >= 3) { + const [tableName, recordId, columnName] = parts; + + try { + // 해당 레코드의 남은 첨부파일 조회 + const remainingFiles = await query( + `SELECT objid, real_file_name, file_size, file_ext, file_path, regdate + FROM attach_file_info + WHERE target_objid = $1 AND status = 'ACTIVE' + ORDER BY regdate DESC`, + [targetObjid] + ); + + // attachments JSONB 형태로 변환 + const attachmentsJson = remainingFiles.map((f: any) => ({ + objid: f.objid.toString(), + realFileName: f.real_file_name, + fileSize: Number(f.file_size), + fileExt: f.file_ext, + filePath: f.file_path, + regdate: f.regdate?.toISOString(), + })); + + // 해당 테이블의 attachments 컬럼 업데이트 + // 🔒 멀티테넌시: company_code 필터 추가 + await query( + `UPDATE ${tableName} + SET ${columnName} = $1::jsonb, updated_date = NOW() + WHERE id = $2 AND company_code = $3`, + [JSON.stringify(attachmentsJson), recordId, fileRecord.company_code] + ); + + console.log("📎 [파일 삭제] attachments 컬럼 자동 업데이트:", { + tableName, + recordId, + columnName, + remainingFiles: attachmentsJson.length, + }); + } catch (updateError) { + // attachments 컬럼 업데이트 실패해도 파일 삭제는 성공으로 처리 + console.warn("⚠️ attachments 컬럼 업데이트 실패 (무시):", updateError); + } + } + } + res.json({ success: true, message: "파일이 삭제되었습니다.", diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 3815fc71..9dcb58bf 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -761,12 +761,19 @@ export const EditModal: React.FC = ({ className }) => { }); } + // 🔑 첨부파일 컴포넌트가 행(레코드) 단위로 파일을 저장할 수 있도록 tableName 추가 + const enrichedFormData = { + ...(groupData.length > 0 ? groupData[0] : formData), + tableName: screenData.screenInfo?.tableName, // 테이블명 추가 + screenId: modalState.screenId, // 화면 ID 추가 + }; + return ( 0 ? groupData[0] : formData} + formData={enrichedFormData} onFormDataChange={(fieldName, value) => { // 🆕 그룹 데이터가 있으면 처리 if (groupData.length > 0) { diff --git a/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx b/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx index 8dda7864..dc77ac93 100644 --- a/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx +++ b/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx @@ -10,6 +10,7 @@ import { apiClient } from "@/lib/api/client"; import { FileViewerModal } from "./FileViewerModal"; import { FileManagerModal } from "./FileManagerModal"; import { FileUploadConfig, FileInfo, FileUploadStatus, FileUploadResponse } from "./types"; +import { useAuth } from "@/hooks/useAuth"; import { Upload, File, @@ -92,6 +93,9 @@ const FileUploadComponent: React.FC = ({ onDragEnd, onUpdate, }) => { + // 🔑 인증 정보 가져오기 + const { user } = useAuth(); + const [uploadedFiles, setUploadedFiles] = useState([]); const [uploadStatus, setUploadStatus] = useState("idle"); const [dragOver, setDragOver] = useState(false); @@ -102,28 +106,86 @@ const FileUploadComponent: React.FC = ({ const [representativeImageUrl, setRepresentativeImageUrl] = useState(null); const fileInputRef = useRef(null); + // 🔑 레코드 모드 판단: formData에 id가 있으면 특정 행에 연결된 파일 관리 + const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_')); + const recordTableName = formData?.tableName || component.tableName; + const recordId = formData?.id; + const columnName = component.columnName || component.id || 'attachments'; + + // 🔑 레코드 모드용 targetObjid 생성 + const getRecordTargetObjid = useCallback(() => { + if (isRecordMode && recordTableName && recordId) { + return `${recordTableName}:${recordId}:${columnName}`; + } + return null; + }, [isRecordMode, recordTableName, recordId, columnName]); + + // 🔑 레코드별 고유 키 생성 (localStorage, 전역 상태용) + const getUniqueKey = useCallback(() => { + if (isRecordMode && recordTableName && recordId) { + // 레코드 모드: 테이블명:레코드ID:컴포넌트ID 형태로 고유 키 생성 + return `fileUpload_${recordTableName}_${recordId}_${component.id}`; + } + // 기본 모드: 컴포넌트 ID만 사용 + return `fileUpload_${component.id}`; + }, [isRecordMode, recordTableName, recordId, component.id]); + + // 🔍 디버깅: 레코드 모드 상태 로깅 + useEffect(() => { + console.log("📎 [FileUploadComponent] 모드 확인:", { + isRecordMode, + recordTableName, + recordId, + columnName, + targetObjid: getRecordTargetObjid(), + uniqueKey: getUniqueKey(), + formDataKeys: formData ? Object.keys(formData) : [], + }); + }, [isRecordMode, recordTableName, recordId, columnName, getRecordTargetObjid, getUniqueKey, formData]); + + // 🆕 레코드 ID 변경 시 파일 목록 초기화 및 새로 로드 + const prevRecordIdRef = useRef(null); + useEffect(() => { + if (prevRecordIdRef.current !== recordId) { + console.log("📎 [FileUploadComponent] 레코드 ID 변경 감지:", { + prev: prevRecordIdRef.current, + current: recordId, + isRecordMode, + }); + prevRecordIdRef.current = recordId; + + // 레코드 모드에서 레코드 ID가 변경되면 파일 목록 초기화 + if (isRecordMode) { + setUploadedFiles([]); + } + } + }, [recordId, isRecordMode]); + // 컴포넌트 마운트 시 즉시 localStorage에서 파일 복원 useEffect(() => { if (!component?.id) return; try { - const backupKey = `fileUpload_${component.id}`; + // 🔑 레코드별 고유 키 사용 + const backupKey = getUniqueKey(); const backupFiles = localStorage.getItem(backupKey); if (backupFiles) { const parsedFiles = JSON.parse(backupFiles); if (parsedFiles.length > 0) { console.log("🚀 컴포넌트 마운트 시 파일 즉시 복원:", { + uniqueKey: backupKey, componentId: component.id, + recordId: recordId, 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, + [backupKey]: parsedFiles, }; } } @@ -131,7 +193,7 @@ const FileUploadComponent: React.FC = ({ } catch (e) { console.warn("컴포넌트 마운트 시 파일 복원 실패:", e); } - }, [component.id]); // component.id가 변경될 때만 실행 + }, [component.id, getUniqueKey, recordId]); // 레코드별 고유 키 변경 시 재실행 // 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너 useEffect(() => { @@ -152,12 +214,14 @@ const FileUploadComponent: React.FC = ({ const newFiles = event.detail.files || []; setUploadedFiles(newFiles); - // localStorage 백업 업데이트 + // localStorage 백업 업데이트 (레코드별 고유 키 사용) try { - const backupKey = `fileUpload_${component.id}`; + const backupKey = getUniqueKey(); localStorage.setItem(backupKey, JSON.stringify(newFiles)); console.log("💾 화면설계 모드 동기화 후 localStorage 백업 업데이트:", { + uniqueKey: backupKey, componentId: component.id, + recordId: recordId, fileCount: newFiles.length, }); } catch (e) { @@ -201,6 +265,16 @@ const FileUploadComponent: React.FC = ({ if (!component?.id) return false; try { + // 🔑 레코드 모드: 해당 행의 파일만 조회 + if (isRecordMode && recordTableName && recordId) { + console.log("📂 [FileUploadComponent] 레코드 모드 파일 조회:", { + tableName: recordTableName, + recordId: recordId, + columnName: columnName, + targetObjid: getRecordTargetObjid(), + }); + } + // 1. formData에서 screenId 가져오기 let screenId = formData?.screenId; @@ -232,11 +306,13 @@ const FileUploadComponent: React.FC = ({ const params = { screenId, componentId: component.id, - tableName: formData?.tableName || component.tableName, - recordId: formData?.id, - columnName: component.columnName || component.id, // 🔑 columnName이 없으면 component.id 사용 + tableName: recordTableName || formData?.tableName || component.tableName, + recordId: recordId || formData?.id, + columnName: columnName, // 🔑 레코드 모드에서 사용하는 columnName }; + console.log("📂 [FileUploadComponent] 파일 조회 파라미터:", params); + const response = await getComponentFiles(params); if (response.success) { @@ -255,11 +331,11 @@ const FileUploadComponent: React.FC = ({ })); - // 🔄 localStorage의 기존 파일과 서버 파일 병합 + // 🔄 localStorage의 기존 파일과 서버 파일 병합 (레코드별 고유 키 사용) let finalFiles = formattedFiles; + const uniqueKey = getUniqueKey(); try { - const backupKey = `fileUpload_${component.id}`; - const backupFiles = localStorage.getItem(backupKey); + const backupFiles = localStorage.getItem(uniqueKey); if (backupFiles) { const parsedBackupFiles = JSON.parse(backupFiles); @@ -268,7 +344,12 @@ const FileUploadComponent: React.FC = ({ const additionalFiles = parsedBackupFiles.filter((f: any) => !serverObjIds.has(f.objid)); finalFiles = [...formattedFiles, ...additionalFiles]; - + console.log("📂 [FileUploadComponent] 파일 병합 완료:", { + uniqueKey, + serverFiles: formattedFiles.length, + localFiles: parsedBackupFiles.length, + finalFiles: finalFiles.length, + }); } } catch (e) { console.warn("파일 병합 중 오류:", e); @@ -276,11 +357,11 @@ const FileUploadComponent: React.FC = ({ setUploadedFiles(finalFiles); - // 전역 상태에도 저장 + // 전역 상태에도 저장 (레코드별 고유 키 사용) if (typeof window !== "undefined") { (window as any).globalFileState = { ...(window as any).globalFileState, - [component.id]: finalFiles, + [uniqueKey]: finalFiles, }; // 🌐 전역 파일 저장소에 등록 (페이지 간 공유용) @@ -288,12 +369,12 @@ const FileUploadComponent: React.FC = ({ uploadPage: window.location.pathname, componentId: component.id, screenId: formData?.screenId, + recordId: recordId, }); - // localStorage 백업도 병합된 파일로 업데이트 + // localStorage 백업도 병합된 파일로 업데이트 (레코드별 고유 키 사용) try { - const backupKey = `fileUpload_${component.id}`; - localStorage.setItem(backupKey, JSON.stringify(finalFiles)); + localStorage.setItem(uniqueKey, JSON.stringify(finalFiles)); } catch (e) { console.warn("localStorage 백업 업데이트 실패:", e); } @@ -304,7 +385,7 @@ const FileUploadComponent: React.FC = ({ console.error("파일 조회 오류:", error); } return false; // 기존 로직 사용 - }, [component.id, component.tableName, component.columnName, formData?.screenId, formData?.tableName, formData?.id]); + }, [component.id, component.tableName, component.columnName, formData?.screenId, formData?.tableName, formData?.id, getUniqueKey, recordId, isRecordMode, recordTableName, columnName]); // 컴포넌트 파일 동기화 (DB 우선, localStorage는 보조) useEffect(() => { @@ -316,6 +397,8 @@ const FileUploadComponent: React.FC = ({ componentFiles: componentFiles.length, formData: formData, screenId: formData?.screenId, + tableName: formData?.tableName, // 🔍 테이블명 확인 + recordId: formData?.id, // 🔍 레코드 ID 확인 currentUploadedFiles: uploadedFiles.length, }); @@ -371,9 +454,9 @@ const FileUploadComponent: React.FC = ({ setUploadedFiles(files); setForceUpdate((prev) => prev + 1); - // localStorage 백업도 업데이트 + // localStorage 백업도 업데이트 (레코드별 고유 키 사용) try { - const backupKey = `fileUpload_${component.id}`; + const backupKey = getUniqueKey(); localStorage.setItem(backupKey, JSON.stringify(files)); } catch (e) { console.warn("localStorage 백업 실패:", e); @@ -462,10 +545,10 @@ const FileUploadComponent: React.FC = ({ toast.loading("파일을 업로드하는 중...", { id: "file-upload" }); try { - // targetObjid 생성 - 템플릿 vs 데이터 파일 구분 - const tableName = formData?.tableName || component.tableName || "default_table"; - const recordId = formData?.id; - const columnName = component.columnName || component.id; + // 🔑 레코드 모드 우선 사용 + const effectiveTableName = recordTableName || formData?.tableName || component.tableName || "default_table"; + const effectiveRecordId = recordId || formData?.id; + const effectiveColumnName = columnName; // screenId 추출 (우선순위: formData > URL) let screenId = formData?.screenId; @@ -478,39 +561,56 @@ const FileUploadComponent: React.FC = ({ } let targetObjid; - // 우선순위: 1) 실제 데이터 (recordId가 숫자/문자열이고 temp_가 아님) > 2) 템플릿 (screenId) > 3) 기본값 - const isRealRecord = recordId && typeof recordId !== 'undefined' && !String(recordId).startsWith('temp_'); + // 🔑 레코드 모드 판단 개선 + const effectiveIsRecordMode = isRecordMode || (effectiveRecordId && !String(effectiveRecordId).startsWith('temp_')); - if (isRealRecord && tableName) { - // 실제 데이터 파일 (진짜 레코드 ID가 있을 때만) - targetObjid = `${tableName}:${recordId}:${columnName}`; - console.log("📁 실제 데이터 파일 업로드:", targetObjid); + if (effectiveIsRecordMode && effectiveTableName && effectiveRecordId) { + // 🎯 레코드 모드: 특정 행에 파일 연결 + targetObjid = `${effectiveTableName}:${effectiveRecordId}:${effectiveColumnName}`; + console.log("📁 [레코드 모드] 파일 업로드:", { + targetObjid, + tableName: effectiveTableName, + recordId: effectiveRecordId, + columnName: effectiveColumnName, + }); } else if (screenId) { // 🔑 템플릿 파일 (백엔드 조회 형식과 동일하게) - targetObjid = `screen_files:${screenId}:${component.id}:${columnName}`; + targetObjid = `screen_files:${screenId}:${component.id}:${effectiveColumnName}`; + console.log("📝 [템플릿 모드] 파일 업로드:", targetObjid); } else { // 기본값 (화면관리에서 사용) targetObjid = `temp_${component.id}`; - console.log("📝 기본 파일 업로드:", targetObjid); + console.log("📝 [기본 모드] 파일 업로드:", targetObjid); } // 🔒 현재 사용자의 회사 코드 가져오기 (멀티테넌시 격리) - const userCompanyCode = (window as any).__user__?.companyCode; + const userCompanyCode = user?.companyCode || (window as any).__user__?.companyCode; + + console.log("📤 [FileUploadComponent] 파일 업로드 준비:", { + userCompanyCode, + isRecordMode: effectiveIsRecordMode, + tableName: effectiveTableName, + recordId: effectiveRecordId, + columnName: effectiveColumnName, + targetObjid, + }); const uploadData = { // 🎯 formData에서 백엔드 API 설정 가져오기 autoLink: formData?.autoLink || true, - linkedTable: formData?.linkedTable || tableName, - recordId: formData?.recordId || recordId || `temp_${component.id}`, - columnName: formData?.columnName || columnName, + linkedTable: formData?.linkedTable || effectiveTableName, + recordId: effectiveRecordId || `temp_${component.id}`, + columnName: effectiveColumnName, isVirtualFileColumn: formData?.isVirtualFileColumn || true, docType: component.fileConfig?.docType || "DOCUMENT", docTypeName: component.fileConfig?.docTypeName || "일반 문서", companyCode: userCompanyCode, // 🔒 멀티테넌시: 회사 코드 명시적 전달 // 호환성을 위한 기존 필드들 - tableName: tableName, - fieldName: columnName, + tableName: effectiveTableName, + fieldName: effectiveColumnName, targetObjid: targetObjid, // InteractiveDataTable 호환을 위한 targetObjid 추가 + // 🆕 레코드 모드 플래그 + isRecordMode: effectiveIsRecordMode, }; @@ -553,9 +653,9 @@ const FileUploadComponent: React.FC = ({ setUploadedFiles(updatedFiles); setUploadStatus("success"); - // localStorage 백업 + // localStorage 백업 (레코드별 고유 키 사용) try { - const backupKey = `fileUpload_${component.id}`; + const backupKey = getUniqueKey(); localStorage.setItem(backupKey, JSON.stringify(updatedFiles)); } catch (e) { console.warn("localStorage 백업 실패:", e); @@ -563,9 +663,10 @@ const FileUploadComponent: React.FC = ({ // 전역 상태 업데이트 (모든 파일 컴포넌트 동기화) if (typeof window !== "undefined") { - // 전역 파일 상태 업데이트 + // 전역 파일 상태 업데이트 (레코드별 고유 키 사용) const globalFileState = (window as any).globalFileState || {}; - globalFileState[component.id] = updatedFiles; + const uniqueKey = getUniqueKey(); + globalFileState[uniqueKey] = updatedFiles; (window as any).globalFileState = globalFileState; // 🌐 전역 파일 저장소에 새 파일 등록 (페이지 간 공유용) @@ -573,12 +674,15 @@ const FileUploadComponent: React.FC = ({ uploadPage: window.location.pathname, componentId: component.id, screenId: formData?.screenId, + recordId: recordId, // 🆕 레코드 ID 추가 }); // 모든 파일 컴포넌트에 동기화 이벤트 발생 const syncEvent = new CustomEvent("globalFileStateChanged", { detail: { componentId: component.id, + uniqueKey: uniqueKey, // 🆕 고유 키 추가 + recordId: recordId, // 🆕 레코드 ID 추가 files: updatedFiles, fileCount: updatedFiles.length, timestamp: Date.now(), @@ -612,22 +716,54 @@ const FileUploadComponent: React.FC = ({ console.warn("⚠️ onUpdate 콜백이 없습니다!"); } + // 🆕 레코드 모드: attachments 컬럼 동기화 (formData 업데이트) + if (effectiveIsRecordMode && onFormDataChange) { + // 파일 정보를 간소화하여 attachments 컬럼에 저장할 형태로 변환 + const attachmentsData = updatedFiles.map(file => ({ + objid: file.objid, + realFileName: file.realFileName, + fileSize: file.fileSize, + fileExt: file.fileExt, + filePath: file.filePath, + regdate: file.regdate || new Date().toISOString(), + })); + + console.log("📎 [레코드 모드] attachments 컬럼 동기화:", { + tableName: effectiveTableName, + recordId: effectiveRecordId, + columnName: effectiveColumnName, + fileCount: attachmentsData.length, + }); + + // onFormDataChange를 통해 부모 컴포넌트에 attachments 업데이트 알림 + onFormDataChange({ + [effectiveColumnName]: attachmentsData, + // 🆕 백엔드에서 attachments 컬럼 업데이트를 위한 메타 정보 + __attachmentsUpdate: { + tableName: effectiveTableName, + recordId: effectiveRecordId, + columnName: effectiveColumnName, + files: attachmentsData, + } + }); + } + // 그리드 파일 상태 새로고침 이벤트 발생 if (typeof window !== "undefined") { const refreshEvent = new CustomEvent("refreshFileStatus", { detail: { - tableName: tableName, - recordId: recordId, - columnName: columnName, + tableName: effectiveTableName, + recordId: effectiveRecordId, + columnName: effectiveColumnName, targetObjid: targetObjid, fileCount: updatedFiles.length, }, }); window.dispatchEvent(refreshEvent); console.log("🔄 그리드 파일 상태 새로고침 이벤트 발생:", { - tableName, - recordId, - columnName, + tableName: effectiveTableName, + recordId: effectiveRecordId, + columnName: effectiveColumnName, targetObjid, fileCount: updatedFiles.length, }); @@ -705,9 +841,9 @@ const FileUploadComponent: React.FC = ({ const updatedFiles = uploadedFiles.filter((f) => f.objid !== fileId); setUploadedFiles(updatedFiles); - // localStorage 백업 업데이트 + // localStorage 백업 업데이트 (레코드별 고유 키 사용) try { - const backupKey = `fileUpload_${component.id}`; + const backupKey = getUniqueKey(); localStorage.setItem(backupKey, JSON.stringify(updatedFiles)); } catch (e) { console.warn("localStorage 백업 업데이트 실패:", e); @@ -715,15 +851,18 @@ const FileUploadComponent: React.FC = ({ // 전역 상태 업데이트 (모든 파일 컴포넌트 동기화) if (typeof window !== "undefined") { - // 전역 파일 상태 업데이트 + // 전역 파일 상태 업데이트 (레코드별 고유 키 사용) const globalFileState = (window as any).globalFileState || {}; - globalFileState[component.id] = updatedFiles; + const uniqueKey = getUniqueKey(); + globalFileState[uniqueKey] = updatedFiles; (window as any).globalFileState = globalFileState; // 모든 파일 컴포넌트에 동기화 이벤트 발생 const syncEvent = new CustomEvent("globalFileStateChanged", { detail: { componentId: component.id, + uniqueKey: uniqueKey, // 🆕 고유 키 추가 + recordId: recordId, // 🆕 레코드 ID 추가 files: updatedFiles, fileCount: updatedFiles.length, timestamp: Date.now(), @@ -749,13 +888,42 @@ const FileUploadComponent: React.FC = ({ }); } + // 🆕 레코드 모드: attachments 컬럼 동기화 (파일 삭제 후) + if (isRecordMode && onFormDataChange && recordTableName && recordId) { + const attachmentsData = updatedFiles.map(f => ({ + objid: f.objid, + realFileName: f.realFileName, + fileSize: f.fileSize, + fileExt: f.fileExt, + filePath: f.filePath, + regdate: f.regdate || new Date().toISOString(), + })); + + console.log("📎 [레코드 모드] 파일 삭제 후 attachments 동기화:", { + tableName: recordTableName, + recordId: recordId, + columnName: columnName, + remainingFiles: attachmentsData.length, + }); + + onFormDataChange({ + [columnName]: attachmentsData, + __attachmentsUpdate: { + tableName: recordTableName, + recordId: recordId, + columnName: columnName, + files: attachmentsData, + } + }); + } + toast.success(`${fileName} 삭제 완료`); } catch (error) { console.error("파일 삭제 오류:", error); toast.error("파일 삭제에 실패했습니다."); } }, - [uploadedFiles, onUpdate, component.id], + [uploadedFiles, onUpdate, component.id, isRecordMode, onFormDataChange, recordTableName, recordId, columnName, getUniqueKey], ); // 대표 이미지 Blob URL 로드 diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 9fec8fc5..f68b8383 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -3970,6 +3970,35 @@ export const TableListComponent: React.FC = ({ ); } + // 📎 첨부파일 타입: 파일 아이콘과 개수 표시 + if (inputType === "file" || inputType === "attachment" || column.columnName === "attachments") { + // JSONB 배열 또는 JSON 문자열 파싱 + let files: any[] = []; + try { + if (typeof value === "string") { + files = JSON.parse(value); + } else if (Array.isArray(value)) { + files = value; + } + } catch { + // 파싱 실패 시 빈 배열 + } + + if (!files || files.length === 0) { + return -; + } + + // 파일 개수와 아이콘 표시 + const { Paperclip } = require("lucide-react"); + return ( +
+ + {files.length} + +
+ ); + } + // 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원, 다중 값 지원) if (inputType === "category") { if (!value) return ""; From c486a31787f076f9634d3b2efda5663f3f001959 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Thu, 11 Dec 2025 13:48:34 +0900 Subject: [PATCH 3/3] =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EB=A1=9C=EC=A7=81=20=EC=A4=91=EA=B0=84=EC=A0=80?= =?UTF-8?q?=EC=9E=A5(=EB=8B=A4=EB=93=AC=EA=B8=B0=ED=95=98=EB=A9=B4?= =?UTF-8?q?=EB=90=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/controllers/fileController.ts | 14 ++++++++++++++ backend-node/src/services/dynamicFormService.ts | 15 ++++++++++++++- frontend/components/screen/EditModal.tsx | 8 ++++++++ .../file-upload/FileUploadComponent.tsx | 12 ++++++++++-- 4 files changed, 46 insertions(+), 3 deletions(-) diff --git a/backend-node/src/controllers/fileController.ts b/backend-node/src/controllers/fileController.ts index fe3d5cfd..d4e8d0cf 100644 --- a/backend-node/src/controllers/fileController.ts +++ b/backend-node/src/controllers/fileController.ts @@ -343,6 +343,20 @@ export const uploadFiles = async ( // 🆕 레코드 모드: 해당 행의 attachments 컬럼 자동 업데이트 const isRecordMode = req.body.isRecordMode === "true" || req.body.isRecordMode === true; + + // 🔍 디버깅: 레코드 모드 조건 확인 + console.log("🔍 [파일 업로드] 레코드 모드 조건 확인:", { + isRecordMode, + linkedTable, + recordId, + columnName, + finalTargetObjid, + "req.body.isRecordMode": req.body.isRecordMode, + "req.body.linkedTable": req.body.linkedTable, + "req.body.recordId": req.body.recordId, + "req.body.columnName": req.body.columnName, + }); + if (isRecordMode && linkedTable && recordId && columnName) { try { // 해당 레코드의 모든 첨부파일 조회 diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 99d6257c..205cd217 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -865,6 +865,9 @@ export class DynamicFormService { return `${key} = $${index + 1}::numeric`; } else if (dataType === 'boolean') { return `${key} = $${index + 1}::boolean`; + } else if (dataType === 'jsonb' || dataType === 'json') { + // 🆕 JSONB/JSON 타입은 명시적 캐스팅 + return `${key} = $${index + 1}::jsonb`; } else { // 문자열 타입은 캐스팅 불필요 return `${key} = $${index + 1}`; @@ -872,7 +875,17 @@ export class DynamicFormService { }) .join(", "); - const values: any[] = Object.values(changedFields); + // 🆕 JSONB 타입 값은 JSON 문자열로 변환 + const values: any[] = Object.keys(changedFields).map((key) => { + const value = changedFields[key]; + const dataType = columnTypes[key]; + + // JSONB/JSON 타입이고 배열/객체인 경우 JSON 문자열로 변환 + if ((dataType === 'jsonb' || dataType === 'json') && (Array.isArray(value) || (typeof value === 'object' && value !== null))) { + return JSON.stringify(value); + } + return value; + }); values.push(id); // WHERE 조건용 ID 추가 // 🔑 Primary Key 타입에 맞게 캐스팅 diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 9dcb58bf..0a87db3f 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -767,6 +767,14 @@ export const EditModal: React.FC = ({ className }) => { tableName: screenData.screenInfo?.tableName, // 테이블명 추가 screenId: modalState.screenId, // 화면 ID 추가 }; + + // 🔍 디버깅: enrichedFormData 확인 + console.log("🔑 [EditModal] enrichedFormData 생성:", { + "screenData.screenInfo": screenData.screenInfo, + "screenData.screenInfo?.tableName": screenData.screenInfo?.tableName, + "enrichedFormData.tableName": enrichedFormData.tableName, + "enrichedFormData.id": enrichedFormData.id, + }); return ( = ({ const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_')); const recordTableName = formData?.tableName || component.tableName; const recordId = formData?.id; - const columnName = component.columnName || component.id || 'attachments'; + // 🔑 컬럼명 결정: 레코드 모드에서는 무조건 'attachments' 사용 + // component.columnName이나 component.id는 '파일_업로드' 같은 한글 라벨일 수 있어서 DB 컬럼명으로 부적합 + // 레코드 모드가 아닐 때만 component.columnName 또는 component.id 사용 + const columnName = isRecordMode ? 'attachments' : (component.columnName || component.id || 'attachments'); // 🔑 레코드 모드용 targetObjid 생성 const getRecordTargetObjid = useCallback(() => { @@ -140,8 +143,13 @@ const FileUploadComponent: React.FC = ({ targetObjid: getRecordTargetObjid(), uniqueKey: getUniqueKey(), formDataKeys: formData ? Object.keys(formData) : [], + // 🔍 추가 디버깅: 어디서 tableName이 오는지 확인 + "formData.tableName": formData?.tableName, + "component.tableName": component.tableName, + "component.columnName": component.columnName, + "component.id": component.id, }); - }, [isRecordMode, recordTableName, recordId, columnName, getRecordTargetObjid, getUniqueKey, formData]); + }, [isRecordMode, recordTableName, recordId, columnName, getRecordTargetObjid, getUniqueKey, formData, component.tableName, component.columnName, component.id]); // 🆕 레코드 ID 변경 시 파일 목록 초기화 및 새로 로드 const prevRecordIdRef = useRef(null);