From fa6c00b6bee58b0d532a672cfe76cab867c0890d Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 10 Dec 2025 17:41:41 +0900 Subject: [PATCH 01/30] =?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 02/30] =?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 b9ee860e710617faac3613dda5059170b8801767 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 11 Dec 2025 11:41:38 +0900 Subject: [PATCH 03/30] =?UTF-8?q?=EB=B6=84=ED=95=A0=20=ED=8C=A8=EB=84=90?= =?UTF-8?q?=20=EC=88=98=EC=A0=95,=EC=82=AD=EC=A0=9C=EB=B2=84=ED=8A=BC=20on?= =?UTF-8?q?,off=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SplitPanelLayoutComponent.tsx | 84 ++++++++++--------- 1 file changed, 45 insertions(+), 39 deletions(-) diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index ba911c3c..7753baa3 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -2150,11 +2150,14 @@ export const SplitPanelLayoutComponent: React.FC {col.label} ))} - {!isDesignMode && ( - - 작업 - - )} + {/* 수정 또는 삭제 버튼이 하나라도 활성화되어 있을 때만 작업 컬럼 표시 */} + {!isDesignMode && + ((componentConfig.rightPanel?.editButton?.enabled ?? true) || + (componentConfig.rightPanel?.deleteButton?.enabled ?? true)) && ( + + 작업 + + )} @@ -2172,40 +2175,43 @@ export const SplitPanelLayoutComponent: React.FC {formatCellValue(col.name, item[col.name], rightCategoryMappings, col.format)} ))} - {!isDesignMode && ( - -
- {(componentConfig.rightPanel?.editButton?.enabled ?? true) && ( - - )} - {(componentConfig.rightPanel?.deleteButton?.enabled ?? true) && ( - - )} -
- - )} + {/* 수정 또는 삭제 버튼이 하나라도 활성화되어 있을 때만 작업 셀 표시 */} + {!isDesignMode && + ((componentConfig.rightPanel?.editButton?.enabled ?? true) || + (componentConfig.rightPanel?.deleteButton?.enabled ?? true)) && ( + +
+ {(componentConfig.rightPanel?.editButton?.enabled ?? true) && ( + + )} + {(componentConfig.rightPanel?.deleteButton?.enabled ?? true) && ( + + )} +
+ + )} ); })} From fc5ffb03b2b644f997b02f4b555eb874c8b0ec72 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 11 Dec 2025 12:01:00 +0900 Subject: [PATCH 04/30] =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=A1=B0?= =?UTF-8?q?=EC=9D=B8=EC=BB=AC=EB=9F=BC=20=ED=91=9C=EC=8B=9C=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/entityJoinService.ts | 34 +++- .../SplitPanelLayoutComponent.tsx | 147 ++++++++++-------- 2 files changed, 107 insertions(+), 74 deletions(-) diff --git a/backend-node/src/services/entityJoinService.ts b/backend-node/src/services/entityJoinService.ts index 280051d0..5557d8b5 100644 --- a/backend-node/src/services/entityJoinService.ts +++ b/backend-node/src/services/entityJoinService.ts @@ -134,8 +134,8 @@ export class EntityJoinService { `🔧 기존 display_column 사용: ${column.column_name} → ${displayColumn}` ); } else { - // display_column이 "none"이거나 없는 경우 참조 테이블의 모든 컬럼 가져오기 - logger.info(`🔍 ${referenceTable}의 모든 컬럼 조회 중...`); + // display_column이 "none"이거나 없는 경우 참조 테이블의 표시용 컬럼 자동 감지 + logger.info(`🔍 ${referenceTable}의 표시 컬럼 자동 감지 중...`); // 참조 테이블의 모든 컬럼 이름 가져오기 const tableColumnsResult = await query<{ column_name: string }>( @@ -148,10 +148,34 @@ export class EntityJoinService { ); if (tableColumnsResult.length > 0) { - displayColumns = tableColumnsResult.map((col) => col.column_name); + const allColumns = tableColumnsResult.map((col) => col.column_name); + + // 🆕 표시용 컬럼 자동 감지 (우선순위 순서) + // 1. *_name 컬럼 (item_name, customer_name 등) + // 2. name 컬럼 + // 3. label 컬럼 + // 4. title 컬럼 + // 5. 참조 컬럼 (referenceColumn) + const nameColumn = allColumns.find( + (col) => col.endsWith("_name") && col !== "company_name" + ); + const simpleNameColumn = allColumns.find((col) => col === "name"); + const labelColumn = allColumns.find( + (col) => col === "label" || col.endsWith("_label") + ); + const titleColumn = allColumns.find((col) => col === "title"); + + // 우선순위에 따라 표시 컬럼 선택 + const displayColumn = + nameColumn || + simpleNameColumn || + labelColumn || + titleColumn || + referenceColumn; + displayColumns = [displayColumn]; + logger.info( - `✅ ${referenceTable}의 모든 컬럼 자동 포함 (${displayColumns.length}개):`, - displayColumns.join(", ") + `✅ ${referenceTable}의 표시 컬럼 자동 감지: ${displayColumn} (전체 ${allColumns.length}개 중)` ); } else { // 테이블 컬럼을 못 찾으면 기본값 사용 diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index 7753baa3..1df2a551 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -73,6 +73,67 @@ export const SplitPanelLayoutComponent: React.FC return true; }; + // 🆕 엔티티 조인 컬럼명 변환 헬퍼 + // "테이블명.컬럼명" 형식을 "원본컬럼_조인컬럼명" 형식으로 변환하여 데이터 접근 + const getEntityJoinValue = useCallback( + (item: any, columnName: string, entityColumnMap?: Record): any => { + // 직접 매칭 시도 + if (item[columnName] !== undefined) { + return item[columnName]; + } + + // "테이블명.컬럼명" 형식인 경우 (예: item_info.item_name) + if (columnName.includes(".")) { + const [tableName, fieldName] = columnName.split("."); + + // 🔍 디버깅: 첫 번째 아이템에서 키 목록 출력 + if (item && typeof item === "object") { + const keys = Object.keys(item); + const matchingKeys = keys.filter((k) => k.includes(fieldName)); + console.log(`🔍 getEntityJoinValue: columnName=${columnName}, fieldName=${fieldName}`); + console.log(" 전체 키 목록:", keys); + console.log(" 매칭 가능한 키들:", matchingKeys); + } + + // entityColumnMap에서 매핑 찾기 (예: item_info → item_code) + if (entityColumnMap && entityColumnMap[tableName]) { + const sourceColumn = entityColumnMap[tableName]; + const joinedColumnName = `${sourceColumn}_${fieldName}`; + if (item[joinedColumnName] !== undefined) { + return item[joinedColumnName]; + } + } + + // 모든 키에서 _fieldName으로 끝나는 것 찾기 + for (const key of Object.keys(item)) { + if (key.endsWith(`_${fieldName}`)) { + console.log(` ✅ 매칭됨: ${key} → ${item[key]}`); + return item[key]; + } + } + + // 🆕 엔티티 조인 기본 패턴: 테이블명.컬럼명 → 소스컬럼_name + // 예: item_info.item_name → item_code_name + // tableName에서 소스 컬럼 추론 (item_info → item_code, customer_mng → customer_id 등) + const inferredSourceColumn = tableName.replace("_info", "_code").replace("_mng", "_id"); + const defaultAliasKey = `${inferredSourceColumn}_name`; + if (item[defaultAliasKey] !== undefined) { + console.log(` ✅ 기본 별칭 매칭: ${defaultAliasKey} → ${item[defaultAliasKey]}`); + return item[defaultAliasKey]; + } + + // 테이블명_컬럼명 형식으로 시도 + const underscoreKey = `${tableName}_${fieldName}`; + if (item[underscoreKey] !== undefined) { + return item[underscoreKey]; + } + } + + return undefined; + }, + [], + ); + // TableOptions Context const { registerTable, unregisterTable } = useTableOptions(); const [leftFilters, setLeftFilters] = useState([]); @@ -1737,7 +1798,7 @@ export const SplitPanelLayoutComponent: React.FC > {formatCellValue( col.name, - item[col.name], + getEntityJoinValue(item, col.name), leftCategoryMappings, col.format, )} @@ -1796,7 +1857,12 @@ export const SplitPanelLayoutComponent: React.FC className="px-3 py-2 text-sm whitespace-nowrap text-gray-900" style={{ textAlign: col.align || "left" }} > - {formatCellValue(col.name, item[col.name], leftCategoryMappings, col.format)} + {formatCellValue( + col.name, + getEntityJoinValue(item, col.name), + leftCategoryMappings, + col.format, + )} ))} @@ -2172,7 +2238,12 @@ export const SplitPanelLayoutComponent: React.FC className="px-3 py-2 text-sm whitespace-nowrap text-gray-900" style={{ textAlign: col.align || "left" }} > - {formatCellValue(col.name, item[col.name], rightCategoryMappings, col.format)} + {formatCellValue( + col.name, + getEntityJoinValue(item, col.name), + rightCategoryMappings, + col.format, + )} ))} {/* 수정 또는 삭제 버튼이 하나라도 활성화되어 있을 때만 작업 셀 표시 */} @@ -2246,78 +2317,16 @@ export const SplitPanelLayoutComponent: React.FC firstValues = rightColumns .slice(0, summaryCount) .map((col) => { - // 🆕 엔티티 조인 컬럼 처리 (예: item_info.item_number → item_number 또는 item_id_name) - let value = item[col.name]; - if (value === undefined && col.name.includes(".")) { - const columnName = col.name.split(".").pop(); - // 1차: 컬럼명 그대로 (예: item_number) - value = item[columnName || ""]; - // 2차: item_info.item_number → item_id_name 또는 item_id_item_number 형식 확인 - if (value === undefined) { - const parts = col.name.split("."); - if (parts.length === 2) { - const refTable = parts[0]; // item_info - const refColumn = parts[1]; // item_number 또는 item_name - // FK 컬럼명 추론: item_info → item_id - const fkColumn = refTable.replace("_info", "").replace("_mng", "") + "_id"; - - // 백엔드에서 반환하는 별칭 패턴: - // 1) item_id_name (기본 referenceColumn) - // 2) item_id_item_name (추가 컬럼) - if ( - refColumn === refTable.replace("_info", "").replace("_mng", "") + "_number" || - refColumn === refTable.replace("_info", "").replace("_mng", "") + "_code" - ) { - // 기본 참조 컬럼 (item_number, customer_code 등) - const aliasKey = fkColumn + "_name"; - value = item[aliasKey]; - } else { - // 추가 컬럼 (item_name, customer_name 등) - const aliasKey = `${fkColumn}_${refColumn}`; - value = item[aliasKey]; - } - } - } - } + // 🆕 엔티티 조인 컬럼 처리 (getEntityJoinValue 사용) + const value = getEntityJoinValue(item, col.name); return [col.name, value, col.label] as [string, any, string]; }) .filter(([_, value]) => value !== null && value !== undefined && value !== ""); allValues = rightColumns .map((col) => { - // 🆕 엔티티 조인 컬럼 처리 - let value = item[col.name]; - if (value === undefined && col.name.includes(".")) { - const columnName = col.name.split(".").pop(); - // 1차: 컬럼명 그대로 - value = item[columnName || ""]; - // 2차: {fk_column}_name 또는 {fk_column}_{ref_column} 형식 확인 - if (value === undefined) { - const parts = col.name.split("."); - if (parts.length === 2) { - const refTable = parts[0]; // item_info - const refColumn = parts[1]; // item_number 또는 item_name - // FK 컬럼명 추론: item_info → item_id - const fkColumn = refTable.replace("_info", "").replace("_mng", "") + "_id"; - - // 백엔드에서 반환하는 별칭 패턴: - // 1) item_id_name (기본 referenceColumn) - // 2) item_id_item_name (추가 컬럼) - if ( - refColumn === refTable.replace("_info", "").replace("_mng", "") + "_number" || - refColumn === refTable.replace("_info", "").replace("_mng", "") + "_code" - ) { - // 기본 참조 컬럼 - const aliasKey = fkColumn + "_name"; - value = item[aliasKey]; - } else { - // 추가 컬럼 - const aliasKey = `${fkColumn}_${refColumn}`; - value = item[aliasKey]; - } - } - } - } + // 🆕 엔티티 조인 컬럼 처리 (getEntityJoinValue 사용) + const value = getEntityJoinValue(item, col.name); return [col.name, value, col.label] as [string, any, string]; }) .filter(([_, value]) => value !== null && value !== undefined && value !== ""); From f6e0e02ddfe831a5e5e0671caec75c38b63eb602 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 11 Dec 2025 13:15:09 +0900 Subject: [PATCH 05/30] =?UTF-8?q?=ED=9A=8C=EC=82=AC=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EB=A9=94=EB=89=B4=20=EA=B6=8C=ED=95=9C=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=EB=A7=81=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/services/adminService.ts | 78 +++++++++++-------- .../components/admin/RoleDetailManagement.tsx | 12 ++- 2 files changed, 56 insertions(+), 34 deletions(-) diff --git a/backend-node/src/services/adminService.ts b/backend-node/src/services/adminService.ts index c6ab17c6..5ca6b392 100644 --- a/backend-node/src/services/adminService.ts +++ b/backend-node/src/services/adminService.ts @@ -19,15 +19,21 @@ export class AdminService { // menuType에 따른 WHERE 조건 생성 const menuTypeCondition = - menuType !== undefined ? `MENU.MENU_TYPE = ${parseInt(menuType)}` : "1 = 1"; - + menuType !== undefined + ? `MENU.MENU_TYPE = ${parseInt(menuType)}` + : "1 = 1"; + // 메뉴 관리 화면인지 좌측 사이드바인지 구분 // includeInactive가 true거나 menuType이 undefined면 메뉴 관리 화면 const includeInactive = paramMap.includeInactive === true; const isManagementScreen = includeInactive || menuType === undefined; // 메뉴 관리 화면: 모든 상태의 메뉴 표시, 좌측 사이드바: active만 표시 - const statusCondition = isManagementScreen ? "1 = 1" : "MENU.STATUS = 'active'"; - const subStatusCondition = isManagementScreen ? "1 = 1" : "MENU_SUB.STATUS = 'active'"; + const statusCondition = isManagementScreen + ? "1 = 1" + : "MENU.STATUS = 'active'"; + const subStatusCondition = isManagementScreen + ? "1 = 1" + : "MENU_SUB.STATUS = 'active'"; // 1. 권한 그룹 기반 필터링 (좌측 사이드바인 경우만) let authFilter = ""; @@ -35,7 +41,11 @@ export class AdminService { let queryParams: any[] = [userLang]; let paramIndex = 2; - if (menuType !== undefined && userType !== "SUPER_ADMIN" && !isManagementScreen) { + if ( + menuType !== undefined && + userType !== "SUPER_ADMIN" && + !isManagementScreen + ) { // 좌측 사이드바 + SUPER_ADMIN이 아닌 경우만 권한 체크 const userRoleGroups = await query( ` @@ -56,45 +66,45 @@ export class AdminService { ); if (userType === "COMPANY_ADMIN") { - // 회사 관리자: 자기 회사 메뉴는 모두, 공통 메뉴는 권한 있는 것만 + // 회사 관리자: 권한 그룹 기반 필터링 적용 if (userRoleGroups.length > 0) { const roleObjids = userRoleGroups.map((rg: any) => rg.role_objid); - // 루트 메뉴: 회사 코드만 체크 (권한 체크 X) - authFilter = `AND MENU.COMPANY_CODE IN ($${paramIndex}, '*')`; + // 회사 관리자도 권한 그룹 설정에 따라 메뉴 필터링 + authFilter = ` + AND MENU.COMPANY_CODE IN ($${paramIndex}, '*') + AND EXISTS ( + SELECT 1 + FROM rel_menu_auth rma + WHERE rma.menu_objid = MENU.OBJID + AND rma.auth_objid = ANY($${paramIndex + 1}) + AND rma.read_yn = 'Y' + ) + `; queryParams.push(userCompanyCode); - const companyParamIndex = paramIndex; paramIndex++; - // 하위 메뉴: 회사 메뉴는 모두, 공통 메뉴는 권한 체크 + // 하위 메뉴도 권한 체크 unionFilter = ` - AND ( - MENU_SUB.COMPANY_CODE = $${companyParamIndex} - OR ( - MENU_SUB.COMPANY_CODE = '*' - AND EXISTS ( - SELECT 1 - FROM rel_menu_auth rma - WHERE rma.menu_objid = MENU_SUB.OBJID - AND rma.auth_objid = ANY($${paramIndex}) - AND rma.read_yn = 'Y' - ) - ) + AND MENU_SUB.COMPANY_CODE IN ($${paramIndex - 1}, '*') + AND EXISTS ( + SELECT 1 + FROM rel_menu_auth rma + WHERE rma.menu_objid = MENU_SUB.OBJID + AND rma.auth_objid = ANY($${paramIndex}) + AND rma.read_yn = 'Y' ) `; queryParams.push(roleObjids); paramIndex++; logger.info( - `✅ 회사 관리자: 회사 ${userCompanyCode} 메뉴 전체 + 권한 있는 공통 메뉴` + `✅ 회사 관리자: 권한 있는 메뉴만 (${roleObjids.length}개 그룹)` ); } else { - // 권한 그룹이 없는 회사 관리자: 자기 회사 메뉴만 - authFilter = `AND MENU.COMPANY_CODE = $${paramIndex}`; - unionFilter = `AND MENU_SUB.COMPANY_CODE = $${paramIndex}`; - queryParams.push(userCompanyCode); - paramIndex++; - logger.info( - `✅ 회사 관리자 (권한 그룹 없음): 회사 ${userCompanyCode} 메뉴만` + // 권한 그룹이 없는 회사 관리자: 메뉴 없음 + logger.warn( + `⚠️ 회사 관리자 ${userId}는 권한 그룹이 없어 메뉴가 표시되지 않습니다.` ); + return []; } } else { // 일반 사용자: 권한 그룹 필수 @@ -131,7 +141,11 @@ export class AdminService { return []; } } - } else if (menuType !== undefined && userType === "SUPER_ADMIN" && !isManagementScreen) { + } else if ( + menuType !== undefined && + userType === "SUPER_ADMIN" && + !isManagementScreen + ) { // 좌측 사이드바 + SUPER_ADMIN: 권한 그룹 체크 없이 모든 공통 메뉴 표시 logger.info(`✅ 최고 관리자는 권한 그룹 체크 없이 모든 공통 메뉴 표시`); // unionFilter는 비워둠 (하위 메뉴도 공통 메뉴만) @@ -167,7 +181,7 @@ export class AdminService { companyFilter = `AND MENU.COMPANY_CODE = $${paramIndex}`; queryParams.push(userCompanyCode); paramIndex++; - + // 하위 메뉴에도 회사 필터링 적용 (공통 메뉴 제외) if (unionFilter === "") { unionFilter = `AND MENU_SUB.COMPANY_CODE = $${paramIndex - 1}`; diff --git a/frontend/components/admin/RoleDetailManagement.tsx b/frontend/components/admin/RoleDetailManagement.tsx index 8f3d8fbb..27a6c07d 100644 --- a/frontend/components/admin/RoleDetailManagement.tsx +++ b/frontend/components/admin/RoleDetailManagement.tsx @@ -9,6 +9,7 @@ import { useRouter } from "next/navigation"; import { AlertCircle } from "lucide-react"; import { DualListBox } from "@/components/common/DualListBox"; import { MenuPermissionsTable } from "./MenuPermissionsTable"; +import { useMenu } from "@/contexts/MenuContext"; interface RoleDetailManagementProps { roleId: string; @@ -25,6 +26,7 @@ interface RoleDetailManagementProps { export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) { const { user: currentUser } = useAuth(); const router = useRouter(); + const { refreshMenus } = useMenu(); const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN"; @@ -178,6 +180,9 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) { if (response.success) { alert("멤버가 성공적으로 저장되었습니다."); loadMembers(); // 새로고침 + + // 사이드바 메뉴 새로고침 (현재 사용자가 영향받을 수 있음) + await refreshMenus(); } else { alert(response.message || "멤버 저장에 실패했습니다."); } @@ -187,7 +192,7 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) { } finally { setIsSavingMembers(false); } - }, [roleGroup, selectedUsers, loadMembers]); + }, [roleGroup, selectedUsers, loadMembers, refreshMenus]); // 메뉴 권한 저장 핸들러 const handleSavePermissions = useCallback(async () => { @@ -200,6 +205,9 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) { if (response.success) { alert("메뉴 권한이 성공적으로 저장되었습니다."); loadMenuPermissions(); // 새로고침 + + // 사이드바 메뉴 새로고침 (권한 변경 즉시 반영) + await refreshMenus(); } else { alert(response.message || "메뉴 권한 저장에 실패했습니다."); } @@ -209,7 +217,7 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) { } finally { setIsSavingPermissions(false); } - }, [roleGroup, menuPermissions, loadMenuPermissions]); + }, [roleGroup, menuPermissions, loadMenuPermissions, refreshMenus]); if (isLoading) { return ( From c486a31787f076f9634d3b2efda5663f3f001959 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Thu, 11 Dec 2025 13:48:34 +0900 Subject: [PATCH 06/30] =?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); From 215242b6763a6292942ee9c6d2731b91284964fd Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 11 Dec 2025 14:25:28 +0900 Subject: [PATCH 07/30] =?UTF-8?q?=EA=B2=80=EC=83=89=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=EB=B6=84=ED=95=A0=ED=8C=A8=EB=84=90=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=ED=95=A9=EC=82=B0=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/tableManagementService.ts | 302 +++++++++++--- .../screen/table-options/FilterPanel.tsx | 233 ++++++----- .../SplitPanelLayoutComponent.tsx | 387 +++++++++++++++--- frontend/types/table-options.ts | 24 +- 4 files changed, 729 insertions(+), 217 deletions(-) diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 781a9498..9a8623a0 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -798,7 +798,12 @@ export class TableManagementService { ); // 🔥 해당 컬럼을 사용하는 화면 레이아웃의 widgetType도 업데이트 - await this.syncScreenLayoutsInputType(tableName, columnName, inputType, companyCode); + await this.syncScreenLayoutsInputType( + tableName, + columnName, + inputType, + companyCode + ); // 🔥 캐시 무효화: 해당 테이블의 컬럼 캐시 삭제 const cacheKeyPattern = `${CacheKeys.TABLE_COLUMNS(tableName, 1, 1000)}_${companyCode}`; @@ -928,7 +933,11 @@ export class TableManagementService { `UPDATE screen_layouts SET properties = $1, component_type = $2 WHERE layout_id = $3`, - [JSON.stringify(updatedProperties), newComponentType, layout.layout_id] + [ + JSON.stringify(updatedProperties), + newComponentType, + layout.layout_id, + ] ); logger.info( @@ -1299,18 +1308,30 @@ export class TableManagementService { try { // 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위) if (typeof value === "string" && value.includes("|")) { - const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName); - + const columnInfo = await this.getColumnWebTypeInfo( + tableName, + columnName + ); + // 날짜 타입이면 날짜 범위로 처리 - if (columnInfo && (columnInfo.webType === "date" || columnInfo.webType === "datetime")) { + if ( + columnInfo && + (columnInfo.webType === "date" || columnInfo.webType === "datetime") + ) { return this.buildDateRangeCondition(columnName, value, paramIndex); } - + // 그 외 타입이면 다중선택(IN 조건)으로 처리 - const multiValues = value.split("|").filter((v: string) => v.trim() !== ""); + const multiValues = value + .split("|") + .filter((v: string) => v.trim() !== ""); if (multiValues.length > 0) { - const placeholders = multiValues.map((_: string, idx: number) => `$${paramIndex + idx}`).join(", "); - logger.info(`🔍 다중선택 필터 적용: ${columnName} IN (${multiValues.join(", ")})`); + const placeholders = multiValues + .map((_: string, idx: number) => `$${paramIndex + idx}`) + .join(", "); + logger.info( + `🔍 다중선택 필터 적용: ${columnName} IN (${multiValues.join(", ")})` + ); return { whereClause: `${columnName}::text IN (${placeholders})`, values: multiValues, @@ -1320,10 +1341,20 @@ export class TableManagementService { } // 🔧 날짜 범위 객체 {from, to} 체크 - if (typeof value === "object" && value !== null && ("from" in value || "to" in value)) { + if ( + typeof value === "object" && + value !== null && + ("from" in value || "to" in value) + ) { // 날짜 범위 객체는 그대로 전달 - const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName); - if (columnInfo && (columnInfo.webType === "date" || columnInfo.webType === "datetime")) { + const columnInfo = await this.getColumnWebTypeInfo( + tableName, + columnName + ); + if ( + columnInfo && + (columnInfo.webType === "date" || columnInfo.webType === "datetime") + ) { return this.buildDateRangeCondition(columnName, value, paramIndex); } } @@ -1356,9 +1387,10 @@ export class TableManagementService { // 컬럼 타입 정보 조회 const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName); - logger.info(`🔍 [buildAdvancedSearchCondition] ${tableName}.${columnName}`, - `webType=${columnInfo?.webType || 'NULL'}`, - `inputType=${columnInfo?.inputType || 'NULL'}`, + logger.info( + `🔍 [buildAdvancedSearchCondition] ${tableName}.${columnName}`, + `webType=${columnInfo?.webType || "NULL"}`, + `inputType=${columnInfo?.inputType || "NULL"}`, `actualValue=${JSON.stringify(actualValue)}`, `operator=${operator}` ); @@ -1464,16 +1496,20 @@ export class TableManagementService { // 문자열 형식의 날짜 범위 파싱 ("YYYY-MM-DD|YYYY-MM-DD") if (typeof value === "string" && value.includes("|")) { const [fromStr, toStr] = value.split("|"); - + if (fromStr && fromStr.trim() !== "") { // VARCHAR 컬럼을 DATE로 캐스팅하여 비교 - conditions.push(`${columnName}::date >= $${paramIndex + paramCount}::date`); + conditions.push( + `${columnName}::date >= $${paramIndex + paramCount}::date` + ); values.push(fromStr.trim()); paramCount++; } if (toStr && toStr.trim() !== "") { // 종료일은 해당 날짜의 23:59:59까지 포함 - conditions.push(`${columnName}::date <= $${paramIndex + paramCount}::date`); + conditions.push( + `${columnName}::date <= $${paramIndex + paramCount}::date` + ); values.push(toStr.trim()); paramCount++; } @@ -1482,17 +1518,21 @@ export class TableManagementService { else if (typeof value === "object" && value !== null) { if (value.from) { // VARCHAR 컬럼을 DATE로 캐스팅하여 비교 - conditions.push(`${columnName}::date >= $${paramIndex + paramCount}::date`); + conditions.push( + `${columnName}::date >= $${paramIndex + paramCount}::date` + ); values.push(value.from); paramCount++; } if (value.to) { // 종료일은 해당 날짜의 23:59:59까지 포함 - conditions.push(`${columnName}::date <= $${paramIndex + paramCount}::date`); + conditions.push( + `${columnName}::date <= $${paramIndex + paramCount}::date` + ); values.push(value.to); paramCount++; } - } + } // 단일 날짜 검색 else if (typeof value === "string" && value.trim() !== "") { conditions.push(`${columnName}::date = $${paramIndex}::date`); @@ -1658,9 +1698,11 @@ export class TableManagementService { paramCount: 0, }; } - + // IN 절로 여러 값 검색 - const placeholders = value.map((_, idx) => `$${paramIndex + idx}`).join(", "); + const placeholders = value + .map((_, idx) => `$${paramIndex + idx}`) + .join(", "); return { whereClause: `${columnName} IN (${placeholders})`, values: value, @@ -1776,20 +1818,25 @@ export class TableManagementService { [tableName, columnName] ); - logger.info(`🔍 [getColumnWebTypeInfo] ${tableName}.${columnName} 조회 결과:`, { - found: !!result, - web_type: result?.web_type, - input_type: result?.input_type, - }); + logger.info( + `🔍 [getColumnWebTypeInfo] ${tableName}.${columnName} 조회 결과:`, + { + found: !!result, + web_type: result?.web_type, + input_type: result?.input_type, + } + ); if (!result) { - logger.warn(`⚠️ [getColumnWebTypeInfo] 컬럼 정보 없음: ${tableName}.${columnName}`); + logger.warn( + `⚠️ [getColumnWebTypeInfo] 컬럼 정보 없음: ${tableName}.${columnName}` + ); return null; } // web_type이 없으면 input_type을 사용 (레거시 호환) const webType = result.web_type || result.input_type || ""; - + const columnInfo = { webType: webType, inputType: result.input_type || "", @@ -1799,7 +1846,9 @@ export class TableManagementService { displayColumn: result.display_column || undefined, }; - logger.info(`✅ [getColumnWebTypeInfo] 반환값: webType=${columnInfo.webType}, inputType=${columnInfo.inputType}`); + logger.info( + `✅ [getColumnWebTypeInfo] 반환값: webType=${columnInfo.webType}, inputType=${columnInfo.inputType}` + ); return columnInfo; } catch (error) { logger.error( @@ -1913,6 +1962,15 @@ export class TableManagementService { continue; } + // 🆕 조인 테이블 컬럼 (테이블명.컬럼명)은 기본 데이터 조회에서 제외 + // Entity 조인 조회에서만 처리됨 + if (column.includes(".")) { + logger.info( + `🔍 조인 테이블 컬럼 ${column} 기본 조회에서 제외 (Entity 조인에서 처리)` + ); + continue; + } + // 안전한 컬럼명 검증 (SQL 인젝션 방지) const safeColumn = column.replace(/[^a-zA-Z0-9_]/g, ""); @@ -2741,7 +2799,11 @@ export class TableManagementService { WHERE "${referenceColumn}" IS NOT NULL`; // 추가 필터 조건이 있으면 적용 (예: 특정 거래처의 품목만 제외) - if (filterColumn && filterValue !== undefined && filterValue !== null) { + if ( + filterColumn && + filterValue !== undefined && + filterValue !== null + ) { excludeSubquery += ` AND "${filterColumn}" = '${String(filterValue).replace(/'/g, "''")}'`; } @@ -2934,16 +2996,22 @@ export class TableManagementService { }), ]; + // 🆕 테이블명.컬럼명 형식도 Entity 검색으로 인식 + const hasJoinTableSearch = + options.search && + Object.keys(options.search).some((key) => key.includes(".")); + const hasEntitySearch = options.search && - Object.keys(options.search).some((key) => + (Object.keys(options.search).some((key) => allEntityColumns.includes(key) - ); + ) || + hasJoinTableSearch); if (hasEntitySearch) { const entitySearchKeys = options.search - ? Object.keys(options.search).filter((key) => - allEntityColumns.includes(key) + ? Object.keys(options.search).filter( + (key) => allEntityColumns.includes(key) || key.includes(".") ) : []; logger.info( @@ -2988,47 +3056,113 @@ export class TableManagementService { if (options.search) { for (const [key, value] of Object.entries(options.search)) { + // 검색값 추출 (객체 형태일 수 있음) + let searchValue = value; + if ( + typeof value === "object" && + value !== null && + "value" in value + ) { + searchValue = value.value; + } + + // 빈 값이면 스킵 + if ( + searchValue === "__ALL__" || + searchValue === "" || + searchValue === null || + searchValue === undefined + ) { + continue; + } + + const safeValue = String(searchValue).replace(/'/g, "''"); + + // 🆕 테이블명.컬럼명 형식 처리 (예: item_info.item_name) + if (key.includes(".")) { + const [refTable, refColumn] = key.split("."); + + // aliasMap에서 별칭 찾기 (테이블명:소스컬럼 형식) + let foundAlias: string | undefined; + for (const [aliasKey, alias] of aliasMap.entries()) { + if (aliasKey.startsWith(`${refTable}:`)) { + foundAlias = alias; + break; + } + } + + if (foundAlias) { + whereConditions.push( + `${foundAlias}.${refColumn}::text ILIKE '%${safeValue}%'` + ); + entitySearchColumns.push(`${key} (${refTable}.${refColumn})`); + logger.info( + `🎯 조인 테이블 검색: ${key} → ${refTable}.${refColumn} LIKE '%${safeValue}%' (별칭: ${foundAlias})` + ); + } else { + logger.warn( + `⚠️ 조인 테이블 검색 실패: ${key} - 별칭을 찾을 수 없음` + ); + } + continue; + } + const joinConfig = joinConfigs.find( (config) => config.aliasColumn === key ); if (joinConfig) { // 기본 Entity 조인 컬럼인 경우: 조인된 테이블의 표시 컬럼에서 검색 - const alias = aliasMap.get(joinConfig.referenceTable); + const aliasKey = `${joinConfig.referenceTable}:${joinConfig.sourceColumn}`; + const alias = aliasMap.get(aliasKey); whereConditions.push( - `${alias}.${joinConfig.displayColumn} ILIKE '%${value}%'` + `${alias}.${joinConfig.displayColumn} ILIKE '%${safeValue}%'` ); entitySearchColumns.push( `${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})` ); logger.info( - `🎯 Entity 조인 검색: ${key} → ${joinConfig.referenceTable}.${joinConfig.displayColumn} LIKE '%${value}%' (별칭: ${alias})` + `🎯 Entity 조인 검색: ${key} → ${joinConfig.referenceTable}.${joinConfig.displayColumn} LIKE '%${safeValue}%' (별칭: ${alias})` ); } else if (key === "writer_dept_code") { // writer_dept_code: user_info.dept_code에서 검색 - const userAlias = aliasMap.get("user_info"); - whereConditions.push( - `${userAlias}.dept_code ILIKE '%${value}%'` - ); - entitySearchColumns.push(`${key} (user_info.dept_code)`); - logger.info( - `🎯 추가 Entity 조인 검색: ${key} → user_info.dept_code LIKE '%${value}%' (별칭: ${userAlias})` + const userAliasKey = Array.from(aliasMap.keys()).find((k) => + k.startsWith("user_info:") ); + const userAlias = userAliasKey + ? aliasMap.get(userAliasKey) + : undefined; + if (userAlias) { + whereConditions.push( + `${userAlias}.dept_code ILIKE '%${safeValue}%'` + ); + entitySearchColumns.push(`${key} (user_info.dept_code)`); + logger.info( + `🎯 추가 Entity 조인 검색: ${key} → user_info.dept_code LIKE '%${safeValue}%' (별칭: ${userAlias})` + ); + } } else if (key === "company_code_status") { // company_code_status: company_info.status에서 검색 - const companyAlias = aliasMap.get("company_info"); - whereConditions.push( - `${companyAlias}.status ILIKE '%${value}%'` - ); - entitySearchColumns.push(`${key} (company_info.status)`); - logger.info( - `🎯 추가 Entity 조인 검색: ${key} → company_info.status LIKE '%${value}%' (별칭: ${companyAlias})` + const companyAliasKey = Array.from(aliasMap.keys()).find((k) => + k.startsWith("company_info:") ); + const companyAlias = companyAliasKey + ? aliasMap.get(companyAliasKey) + : undefined; + if (companyAlias) { + whereConditions.push( + `${companyAlias}.status ILIKE '%${safeValue}%'` + ); + entitySearchColumns.push(`${key} (company_info.status)`); + logger.info( + `🎯 추가 Entity 조인 검색: ${key} → company_info.status LIKE '%${safeValue}%' (별칭: ${companyAlias})` + ); + } } else { // 일반 컬럼인 경우: 메인 테이블에서 검색 - whereConditions.push(`main.${key} ILIKE '%${value}%'`); + whereConditions.push(`main.${key} ILIKE '%${safeValue}%'`); logger.info( - `🔍 일반 컬럼 검색: ${key} → main.${key} LIKE '%${value}%'` + `🔍 일반 컬럼 검색: ${key} → main.${key} LIKE '%${safeValue}%'` ); } } @@ -3168,6 +3302,59 @@ export class TableManagementService { } try { + // 🆕 조인 테이블 컬럼 검색 처리 (예: item_info.item_name) + if (columnName.includes(".")) { + const [refTable, refColumn] = columnName.split("."); + + // 검색값 추출 + let searchValue = value; + if (typeof value === "object" && value !== null && "value" in value) { + searchValue = value.value; + } + + if ( + searchValue === "__ALL__" || + searchValue === "" || + searchValue === null + ) { + continue; + } + + // 🔍 column_labels에서 해당 엔티티 설정 찾기 + // 예: item_info 테이블을 참조하는 컬럼 찾기 (item_code → item_info) + const entityColumnResult = await query<{ + column_name: string; + reference_table: string; + reference_column: string; + }>( + `SELECT column_name, reference_table, reference_column + FROM column_labels + WHERE table_name = $1 + AND input_type = 'entity' + AND reference_table = $2 + LIMIT 1`, + [tableName, refTable] + ); + + if (entityColumnResult.length > 0) { + // 조인 별칭 생성 (entityJoinService.ts와 동일한 패턴: 테이블명 앞 3글자) + const joinAlias = refTable.substring(0, 3); + + // 조인 테이블 컬럼으로 검색 조건 생성 + const safeValue = String(searchValue).replace(/'/g, "''"); + const condition = `${joinAlias}.${refColumn}::text ILIKE '%${safeValue}%'`; + + logger.info(`🔍 조인 테이블 검색 조건: ${condition}`); + conditions.push(condition); + } else { + logger.warn( + `⚠️ 조인 테이블 검색 실패: ${columnName} - 엔티티 설정을 찾을 수 없음` + ); + } + + continue; + } + // 고급 검색 조건 구성 const searchCondition = await this.buildAdvancedSearchCondition( tableName, @@ -4282,7 +4469,10 @@ export class TableManagementService { ); return result.length > 0; } catch (error) { - logger.error(`컬럼 존재 여부 확인 실패: ${tableName}.${columnName}`, error); + logger.error( + `컬럼 존재 여부 확인 실패: ${tableName}.${columnName}`, + error + ); return false; } } diff --git a/frontend/components/screen/table-options/FilterPanel.tsx b/frontend/components/screen/table-options/FilterPanel.tsx index 69395942..1b6104b6 100644 --- a/frontend/components/screen/table-options/FilterPanel.tsx +++ b/frontend/components/screen/table-options/FilterPanel.tsx @@ -10,17 +10,13 @@ import { } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Checkbox } from "@/components/ui/checkbox"; -import { Plus, X } from "lucide-react"; -import { TableFilter } from "@/types/table-options"; +import { Layers } from "lucide-react"; +import { TableFilter, GroupSumConfig } from "@/types/table-options"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; interface Props { isOpen: boolean; @@ -77,17 +73,37 @@ export const FilterPanel: React.FC = ({ isOpen, onClose, onFiltersApplied const [columnFilters, setColumnFilters] = useState([]); const [selectAll, setSelectAll] = useState(false); + // 🆕 그룹별 합산 설정 + const [groupSumEnabled, setGroupSumEnabled] = useState(false); + const [groupByColumn, setGroupByColumn] = useState(""); + // localStorage에서 저장된 필터 설정 불러오기 useEffect(() => { if (table?.columns && table?.tableName) { // 화면별로 독립적인 필터 설정 저장 - const storageKey = screenId + const storageKey = screenId ? `table_filters_${table.tableName}_screen_${screenId}` : `table_filters_${table.tableName}`; const savedFilters = localStorage.getItem(storageKey); - + + // 🆕 그룹핑 설정도 불러오기 + const groupSumKey = screenId + ? `table_groupsum_${table.tableName}_screen_${screenId}` + : `table_groupsum_${table.tableName}`; + const savedGroupSum = localStorage.getItem(groupSumKey); + + if (savedGroupSum) { + try { + const parsed = JSON.parse(savedGroupSum) as GroupSumConfig; + setGroupSumEnabled(parsed.enabled); + setGroupByColumn(parsed.groupByColumn || ""); + } catch (error) { + console.error("그룹핑 설정 불러오기 실패:", error); + } + } + let filters: ColumnFilterConfig[]; - + if (savedFilters) { try { const parsed = JSON.parse(savedFilters) as ColumnFilterConfig[]; @@ -96,13 +112,15 @@ export const FilterPanel: React.FC = ({ isOpen, onClose, onFiltersApplied .filter((col) => col.filterable !== false) .map((col) => { const saved = parsed.find((f) => f.columnName === col.columnName); - return saved || { - columnName: col.columnName, - columnLabel: col.columnLabel, - inputType: col.inputType || "text", - enabled: false, - filterType: mapInputTypeToFilterType(col.inputType || "text"), - }; + return ( + saved || { + columnName: col.columnName, + columnLabel: col.columnLabel, + inputType: col.inputType || "text", + enabled: false, + filterType: mapInputTypeToFilterType(col.inputType || "text"), + } + ); }); } catch (error) { console.error("저장된 필터 설정 불러오기 실패:", error); @@ -127,26 +145,20 @@ export const FilterPanel: React.FC = ({ isOpen, onClose, onFiltersApplied filterType: mapInputTypeToFilterType(col.inputType || "text"), })); } - + setColumnFilters(filters); } }, [table?.columns, table?.tableName]); // inputType을 filterType으로 매핑 - const mapInputTypeToFilterType = ( - inputType: string - ): "text" | "number" | "date" | "select" => { + const mapInputTypeToFilterType = (inputType: string): "text" | "number" | "date" | "select" => { if (inputType.includes("number") || inputType.includes("decimal")) { return "number"; } if (inputType.includes("date") || inputType.includes("time")) { return "date"; } - if ( - inputType.includes("select") || - inputType.includes("code") || - inputType.includes("category") - ) { + if (inputType.includes("select") || inputType.includes("code") || inputType.includes("category")) { return "select"; } return "text"; @@ -155,31 +167,20 @@ export const FilterPanel: React.FC = ({ isOpen, onClose, onFiltersApplied // 전체 선택/해제 const toggleSelectAll = (checked: boolean) => { setSelectAll(checked); - setColumnFilters((prev) => - prev.map((filter) => ({ ...filter, enabled: checked })) - ); + setColumnFilters((prev) => prev.map((filter) => ({ ...filter, enabled: checked }))); }; // 개별 필터 토글 const toggleFilter = (columnName: string) => { setColumnFilters((prev) => - prev.map((filter) => - filter.columnName === columnName - ? { ...filter, enabled: !filter.enabled } - : filter - ) + prev.map((filter) => (filter.columnName === columnName ? { ...filter, enabled: !filter.enabled } : filter)), ); }; // 필터 타입 변경 - const updateFilterType = ( - columnName: string, - filterType: "text" | "number" | "date" | "select" - ) => { + const updateFilterType = (columnName: string, filterType: "text" | "number" | "date" | "select") => { setColumnFilters((prev) => - prev.map((filter) => - filter.columnName === columnName ? { ...filter, filterType } : filter - ) + prev.map((filter) => (filter.columnName === columnName ? { ...filter, filterType } : filter)), ); }; @@ -198,44 +199,76 @@ export const FilterPanel: React.FC = ({ isOpen, onClose, onFiltersApplied // localStorage에 저장 (화면별로 독립적) if (table?.tableName) { - const storageKey = screenId + const storageKey = screenId ? `table_filters_${table.tableName}_screen_${screenId}` : `table_filters_${table.tableName}`; localStorage.setItem(storageKey, JSON.stringify(columnFilters)); + + // 🆕 그룹핑 설정 저장 + const groupSumKey = screenId + ? `table_groupsum_${table.tableName}_screen_${screenId}` + : `table_groupsum_${table.tableName}`; + + if (groupSumEnabled && groupByColumn) { + const selectedColumn = columnFilters.find((f) => f.columnName === groupByColumn); + const groupSumConfig: GroupSumConfig = { + enabled: true, + groupByColumn: groupByColumn, + groupByColumnLabel: selectedColumn?.columnLabel, + }; + localStorage.setItem(groupSumKey, JSON.stringify(groupSumConfig)); + table?.onGroupSumChange?.(groupSumConfig); + } else { + localStorage.removeItem(groupSumKey); + table?.onGroupSumChange?.(null); + } } table?.onFilterChange(activeFilters); - + // 콜백으로 활성화된 필터 정보 전달 onFiltersApplied?.(activeFilters); - + onClose(); }; // 초기화 (즉시 저장 및 적용) const clearFilters = () => { - const clearedFilters = columnFilters.map((filter) => ({ - ...filter, - enabled: false + const clearedFilters = columnFilters.map((filter) => ({ + ...filter, + enabled: false, })); - + setColumnFilters(clearedFilters); setSelectAll(false); - + + // 🆕 그룹핑 설정 초기화 + setGroupSumEnabled(false); + setGroupByColumn(""); + // localStorage에서 제거 (화면별로 독립적) if (table?.tableName) { - const storageKey = screenId + const storageKey = screenId ? `table_filters_${table.tableName}_screen_${screenId}` : `table_filters_${table.tableName}`; localStorage.removeItem(storageKey); + + // 🆕 그룹핑 설정도 제거 + const groupSumKey = screenId + ? `table_groupsum_${table.tableName}_screen_${screenId}` + : `table_groupsum_${table.tableName}`; + localStorage.removeItem(groupSumKey); } - + // 빈 필터 배열로 적용 table?.onFilterChange([]); - + + // 🆕 그룹핑 해제 + table?.onGroupSumChange?.(null); + // 콜백으로 빈 필터 정보 전달 onFiltersApplied?.([]); - + // 즉시 닫기 onClose(); }; @@ -246,9 +279,7 @@ export const FilterPanel: React.FC = ({ isOpen, onClose, onFiltersApplied - - 검색 필터 설정 - + 검색 필터 설정 검색 필터로 사용할 컬럼을 선택하세요. 선택한 컬럼의 검색 입력 필드가 표시됩니다. @@ -256,17 +287,12 @@ export const FilterPanel: React.FC = ({ isOpen, onClose, onFiltersApplied
{/* 전체 선택/해제 */} -
+
- - toggleSelectAll(checked as boolean) - } - /> + toggleSelectAll(checked as boolean)} /> 전체 선택/해제
-
+
{enabledCount} / {columnFilters.length}개
@@ -277,30 +303,21 @@ export const FilterPanel: React.FC = ({ isOpen, onClose, onFiltersApplied {columnFilters.map((filter) => (
{/* 체크박스 */} - toggleFilter(filter.columnName)} - /> + toggleFilter(filter.columnName)} /> {/* 컬럼 정보 */}
-
- {filter.columnLabel} -
-
- {filter.columnName} -
+
{filter.columnLabel}
+
{filter.columnName}
{/* 필터 타입 선택 */} + + + + + {columnFilters.map((filter) => ( + + {filter.columnLabel} + + ))} + + +
+ )} +
+ {/* 안내 메시지 */} -
+
검색 필터를 사용하려면 최소 1개 이상의 컬럼을 선택하세요
- -
); }; - diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index 1df2a551..e8014327 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -34,7 +34,7 @@ import { } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; import { useTableOptions } from "@/contexts/TableOptionsContext"; -import { TableFilter, ColumnVisibility } from "@/types/table-options"; +import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options"; import { useAuth } from "@/hooks/useAuth"; export interface SplitPanelLayoutComponentProps extends ComponentRendererProps { @@ -86,16 +86,29 @@ export const SplitPanelLayoutComponent: React.FC if (columnName.includes(".")) { const [tableName, fieldName] = columnName.split("."); - // 🔍 디버깅: 첫 번째 아이템에서 키 목록 출력 - if (item && typeof item === "object") { - const keys = Object.keys(item); - const matchingKeys = keys.filter((k) => k.includes(fieldName)); - console.log(`🔍 getEntityJoinValue: columnName=${columnName}, fieldName=${fieldName}`); - console.log(" 전체 키 목록:", keys); - console.log(" 매칭 가능한 키들:", matchingKeys); + // 🔍 엔티티 조인 컬럼 값 추출 + // 예: item_info.item_name, item_info.standard, item_info.unit + + // 1️⃣ 소스 컬럼 추론 (item_info → item_code, warehouse_info → warehouse_id 등) + const inferredSourceColumn = tableName.replace("_info", "_code").replace("_mng", "_id"); + + // 2️⃣ 정확한 키 매핑 시도: 소스컬럼_필드명 + // 예: item_code_item_name, item_code_standard, item_code_unit + const exactKey = `${inferredSourceColumn}_${fieldName}`; + if (item[exactKey] !== undefined) { + return item[exactKey]; } - // entityColumnMap에서 매핑 찾기 (예: item_info → item_code) + // 3️⃣ 별칭 패턴: 소스컬럼_name (기본 표시 컬럼용) + // 예: item_code_name (item_name의 별칭) + if (fieldName === "item_name" || fieldName === "name") { + const aliasKey = `${inferredSourceColumn}_name`; + if (item[aliasKey] !== undefined) { + return item[aliasKey]; + } + } + + // 4️⃣ entityColumnMap에서 매핑 찾기 (화면 설정에서 지정된 경우) if (entityColumnMap && entityColumnMap[tableName]) { const sourceColumn = entityColumnMap[tableName]; const joinedColumnName = `${sourceColumn}_${fieldName}`; @@ -104,25 +117,7 @@ export const SplitPanelLayoutComponent: React.FC } } - // 모든 키에서 _fieldName으로 끝나는 것 찾기 - for (const key of Object.keys(item)) { - if (key.endsWith(`_${fieldName}`)) { - console.log(` ✅ 매칭됨: ${key} → ${item[key]}`); - return item[key]; - } - } - - // 🆕 엔티티 조인 기본 패턴: 테이블명.컬럼명 → 소스컬럼_name - // 예: item_info.item_name → item_code_name - // tableName에서 소스 컬럼 추론 (item_info → item_code, customer_mng → customer_id 등) - const inferredSourceColumn = tableName.replace("_info", "_code").replace("_mng", "_id"); - const defaultAliasKey = `${inferredSourceColumn}_name`; - if (item[defaultAliasKey] !== undefined) { - console.log(` ✅ 기본 별칭 매칭: ${defaultAliasKey} → ${item[defaultAliasKey]}`); - return item[defaultAliasKey]; - } - - // 테이블명_컬럼명 형식으로 시도 + // 5️⃣ 테이블명_컬럼명 형식으로 시도 const underscoreKey = `${tableName}_${fieldName}`; if (item[underscoreKey] !== undefined) { return item[underscoreKey]; @@ -140,6 +135,7 @@ export const SplitPanelLayoutComponent: React.FC const [leftGrouping, setLeftGrouping] = useState([]); const [leftColumnVisibility, setLeftColumnVisibility] = useState([]); const [leftColumnOrder, setLeftColumnOrder] = useState([]); // 🔧 컬럼 순서 + const [leftGroupSumConfig, setLeftGroupSumConfig] = useState(null); // 🆕 그룹별 합산 설정 const [rightFilters, setRightFilters] = useState([]); const [rightGrouping, setRightGrouping] = useState([]); const [rightColumnVisibility, setRightColumnVisibility] = useState([]); @@ -186,6 +182,88 @@ export const SplitPanelLayoutComponent: React.FC const [leftWidth, setLeftWidth] = useState(splitRatio); const containerRef = React.useRef(null); + // 🆕 그룹별 합산된 데이터 계산 + const summedLeftData = useMemo(() => { + console.log("🔍 [그룹합산] leftGroupSumConfig:", leftGroupSumConfig); + + // 그룹핑이 비활성화되었거나 그룹 기준 컬럼이 없으면 원본 데이터 반환 + if (!leftGroupSumConfig?.enabled || !leftGroupSumConfig?.groupByColumn) { + console.log("🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환"); + return leftData; + } + + const groupByColumn = leftGroupSumConfig.groupByColumn; + const groupMap = new Map(); + + // 조인 컬럼인지 확인하고 실제 키 추론 + const getActualKey = (columnName: string, item: any): string => { + if (columnName.includes(".")) { + const [refTable, fieldName] = columnName.split("."); + const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id"); + const exactKey = `${inferredSourceColumn}_${fieldName}`; + console.log("🔍 [그룹합산] 조인 컬럼 키 변환:", { columnName, exactKey, hasKey: item[exactKey] !== undefined }); + if (item[exactKey] !== undefined) return exactKey; + if (fieldName === "item_name" || fieldName === "name") { + const aliasKey = `${inferredSourceColumn}_name`; + if (item[aliasKey] !== undefined) return aliasKey; + } + } + return columnName; + }; + + // 숫자 타입인지 확인하는 함수 + const isNumericValue = (value: any): boolean => { + if (value === null || value === undefined || value === "") return false; + const num = parseFloat(String(value)); + return !isNaN(num) && isFinite(num); + }; + + // 그룹핑 수행 + leftData.forEach((item) => { + const actualKey = getActualKey(groupByColumn, item); + const groupValue = String(item[actualKey] || item[groupByColumn] || ""); + + // 원본 ID 추출 (id, ID, 또는 첫 번째 값) + const originalId = item.id || item.ID || Object.values(item)[0]; + + if (!groupMap.has(groupValue)) { + // 첫 번째 항목을 기준으로 초기화 + 원본 ID 배열 + 원본 데이터 배열 + groupMap.set(groupValue, { + ...item, + _groupCount: 1, + _originalIds: [originalId], + _originalItems: [item], // 🆕 원본 데이터 전체 저장 + }); + } else { + const existing = groupMap.get(groupValue); + existing._groupCount += 1; + existing._originalIds.push(originalId); + existing._originalItems.push(item); // 🆕 원본 데이터 추가 + + // 모든 키에 대해 숫자면 합산 + Object.keys(item).forEach((key) => { + const value = item[key]; + if (isNumericValue(value) && key !== groupByColumn && !key.endsWith("_id") && !key.includes("code")) { + const numValue = parseFloat(String(value)); + const existingValue = parseFloat(String(existing[key] || 0)); + existing[key] = existingValue + numValue; + } + }); + + groupMap.set(groupValue, existing); + } + }); + + const result = Array.from(groupMap.values()); + console.log("🔗 [분할패널] 그룹별 합산 결과:", { + 원본개수: leftData.length, + 그룹개수: result.length, + 그룹기준: groupByColumn, + }); + + return result; + }, [leftData, leftGroupSumConfig]); + // 컴포넌트 스타일 // height 처리: 이미 px 단위면 그대로, 숫자면 px 추가 const getHeightValue = () => { @@ -494,14 +572,77 @@ export const SplitPanelLayoutComponent: React.FC // 🎯 필터 조건을 API에 전달 (entityJoinApi 사용) const filters = Object.keys(searchValues).length > 0 ? searchValues : undefined; + // 🆕 "테이블명.컬럼명" 형식의 조인 컬럼들을 additionalJoinColumns로 변환 + const configuredColumns = componentConfig.leftPanel?.columns || []; + const additionalJoinColumns: Array<{ + sourceTable: string; + sourceColumn: string; + referenceTable: string; + joinAlias: string; + }> = []; + + // 소스 컬럼 매핑 (item_info → item_code, warehouse_info → warehouse_id 등) + const sourceColumnMap: Record = {}; + + configuredColumns.forEach((col: any) => { + const colName = typeof col === "string" ? col : col.name || col.columnName; + if (colName && colName.includes(".")) { + const [refTable, refColumn] = colName.split("."); + // 소스 컬럼 추론 (item_info → item_code) + const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id"); + + // 이미 추가된 조인인지 확인 (동일 테이블, 동일 소스컬럼) + const existingJoin = additionalJoinColumns.find( + (j) => j.referenceTable === refTable && j.sourceColumn === inferredSourceColumn, + ); + + if (!existingJoin) { + // 새로운 조인 추가 (첫 번째 컬럼) + additionalJoinColumns.push({ + sourceTable: leftTableName, + sourceColumn: inferredSourceColumn, + referenceTable: refTable, + joinAlias: `${inferredSourceColumn}_${refColumn}`, + }); + sourceColumnMap[refTable] = inferredSourceColumn; + } + + // 추가 컬럼도 별도로 요청 (item_code_standard, item_code_unit 등) + // 단, 첫 번째 컬럼과 다른 경우만 + const existingAliases = additionalJoinColumns + .filter((j) => j.referenceTable === refTable) + .map((j) => j.joinAlias); + const newAlias = `${sourceColumnMap[refTable] || inferredSourceColumn}_${refColumn}`; + + if (!existingAliases.includes(newAlias)) { + additionalJoinColumns.push({ + sourceTable: leftTableName, + sourceColumn: sourceColumnMap[refTable] || inferredSourceColumn, + referenceTable: refTable, + joinAlias: newAlias, + }); + } + } + }); + + console.log("🔗 [분할패널] additionalJoinColumns:", additionalJoinColumns); + console.log("🔗 [분할패널] configuredColumns:", configuredColumns); + const result = await entityJoinApi.getTableDataWithJoins(leftTableName, { page: 1, size: 100, search: filters, // 필터 조건 전달 enableEntityJoin: true, // 엔티티 조인 활성화 dataFilter: componentConfig.leftPanel?.dataFilter, // 🆕 데이터 필터 전달 + additionalJoinColumns: additionalJoinColumns.length > 0 ? additionalJoinColumns : undefined, // 🆕 추가 조인 컬럼 }); + // 🔍 디버깅: API 응답 데이터의 키 확인 + if (result.data && result.data.length > 0) { + console.log("🔗 [분할패널] API 응답 첫 번째 데이터 키:", Object.keys(result.data[0])); + console.log("🔗 [분할패널] API 응답 첫 번째 데이터:", result.data[0]); + } + // 가나다순 정렬 (좌측 패널의 표시 컬럼 기준) const leftColumn = componentConfig.rightPanel?.relation?.leftColumn; if (leftColumn && result.data.length > 0) { @@ -527,6 +668,8 @@ export const SplitPanelLayoutComponent: React.FC } }, [ componentConfig.leftPanel?.tableName, + componentConfig.leftPanel?.columns, + componentConfig.leftPanel?.dataFilter, componentConfig.rightPanel?.relation?.leftColumn, isDesignMode, toast, @@ -563,6 +706,68 @@ export const SplitPanelLayoutComponent: React.FC const keys = componentConfig.rightPanel?.relation?.keys; const leftTable = componentConfig.leftPanel?.tableName; + // 🆕 그룹 합산된 항목인 경우: 원본 데이터들로 우측 패널 표시 + if (leftItem._originalItems && leftItem._originalItems.length > 0) { + console.log("🔗 [분할패널] 그룹 합산 항목 - 원본 개수:", leftItem._originalItems.length); + + // 정렬 기준 컬럼 (복합키의 leftColumn들) + const sortColumns = keys?.map((k: any) => k.leftColumn).filter(Boolean) || []; + console.log("🔗 [분할패널] 정렬 기준 컬럼:", sortColumns); + + // 정렬 함수 + const sortByKeys = (data: any[]) => { + if (sortColumns.length === 0) return data; + return [...data].sort((a, b) => { + for (const col of sortColumns) { + const aVal = String(a[col] || ""); + const bVal = String(b[col] || ""); + const cmp = aVal.localeCompare(bVal, "ko-KR"); + if (cmp !== 0) return cmp; + } + return 0; + }); + }; + + // 원본 데이터를 그대로 우측 패널에 표시 (이력 테이블과 동일 테이블인 경우) + if (leftTable === rightTableName) { + const sortedData = sortByKeys(leftItem._originalItems); + console.log("🔗 [분할패널] 동일 테이블 - 정렬된 원본 데이터:", sortedData.length); + setRightData(sortedData); + return; + } + + // 다른 테이블인 경우: 원본 ID들로 조회 + const { entityJoinApi } = await import("@/lib/api/entityJoin"); + const allResults: any[] = []; + + // 각 원본 항목에 대해 조회 + for (const originalItem of leftItem._originalItems) { + const searchConditions: Record = {}; + keys?.forEach((key: any) => { + if (key.leftColumn && key.rightColumn && originalItem[key.leftColumn] !== undefined) { + searchConditions[key.rightColumn] = originalItem[key.leftColumn]; + } + }); + + if (Object.keys(searchConditions).length > 0) { + const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { + search: searchConditions, + enableEntityJoin: true, + size: 1000, + }); + if (result.data) { + allResults.push(...result.data); + } + } + } + + // 정렬 적용 + const sortedResults = sortByKeys(allResults); + console.log("🔗 [분할패널] 그룹 합산 - 우측 패널 정렬된 데이터:", sortedResults.length); + setRightData(sortedResults); + return; + } + // 🆕 복합키 지원 if (keys && keys.length > 0 && leftTable) { // 복합키: 여러 조건으로 필터링 @@ -703,7 +908,28 @@ export const SplitPanelLayoutComponent: React.FC const uniqueValues = new Set(); leftData.forEach((item) => { - const value = item[columnName]; + // 🆕 조인 컬럼 처리 (item_info.standard → item_code_standard) + let value: any; + + if (columnName.includes(".")) { + // 조인 컬럼: getEntityJoinValue와 동일한 로직 적용 + const [refTable, fieldName] = columnName.split("."); + const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id"); + + // 정확한 키로 먼저 시도 + const exactKey = `${inferredSourceColumn}_${fieldName}`; + value = item[exactKey]; + + // 기본 별칭 패턴 시도 (item_code_name) + if (value === undefined && (fieldName === "item_name" || fieldName === "name")) { + const aliasKey = `${inferredSourceColumn}_name`; + value = item[aliasKey]; + } + } else { + // 일반 컬럼 + value = item[columnName]; + } + if (value !== null && value !== undefined && value !== "") { // _name 필드 우선 사용 (category/entity type) const displayValue = item[`${columnName}_name`] || value; @@ -727,6 +953,15 @@ export const SplitPanelLayoutComponent: React.FC const leftTableId = `split-panel-left-${component.id}`; // 🔧 화면에 표시되는 컬럼 사용 (columns 속성) const configuredColumns = componentConfig.leftPanel?.columns || []; + + // 🆕 설정에서 지정한 라벨 맵 생성 + const configuredLabels: Record = {}; + configuredColumns.forEach((col: any) => { + if (typeof col === "object" && col.name && col.label) { + configuredLabels[col.name] = col.label; + } + }); + const displayColumns = configuredColumns .map((col: any) => { if (typeof col === "string") return col; @@ -744,7 +979,8 @@ export const SplitPanelLayoutComponent: React.FC tableName: leftTableName, columns: displayColumns.map((col: string) => ({ columnName: col, - columnLabel: leftColumnLabels[col] || col, + // 🆕 우선순위: 1) 설정에서 지정한 라벨 2) DB 라벨 3) 컬럼명 + columnLabel: configuredLabels[col] || leftColumnLabels[col] || col, inputType: "text", visible: true, width: 150, @@ -756,6 +992,7 @@ export const SplitPanelLayoutComponent: React.FC onColumnVisibilityChange: setLeftColumnVisibility, onColumnOrderChange: setLeftColumnOrder, // 🔧 컬럼 순서 변경 콜백 추가 getColumnUniqueValues: getLeftColumnUniqueValues, // 🔧 고유값 가져오기 함수 추가 + onGroupSumChange: setLeftGroupSumConfig, // 🆕 그룹별 합산 설정 콜백 }); return () => unregisterTable(leftTableId); @@ -1712,16 +1949,25 @@ export const SplitPanelLayoutComponent: React.FC ) : ( (() => { + // 🆕 그룹별 합산된 데이터 사용 + const dataSource = summedLeftData; + console.log( + "🔍 [테이블모드 렌더링] dataSource 개수:", + dataSource.length, + "leftGroupSumConfig:", + leftGroupSumConfig, + ); + // 🔧 로컬 검색 필터 적용 const filteredData = leftSearchQuery - ? leftData.filter((item) => { + ? dataSource.filter((item) => { const searchLower = leftSearchQuery.toLowerCase(); return Object.entries(item).some(([key, value]) => { if (value === null || value === undefined) return false; return String(value).toLowerCase().includes(searchLower); }); }) - : leftData; + : dataSource; // 🔧 가시성 처리된 컬럼 사용 const columnsToShow = @@ -1917,16 +2163,25 @@ export const SplitPanelLayoutComponent: React.FC ) : ( (() => { + // 🆕 그룹별 합산된 데이터 사용 + const dataToDisplay = summedLeftData; + console.log( + "🔍 [렌더링] dataToDisplay 개수:", + dataToDisplay.length, + "leftGroupSumConfig:", + leftGroupSumConfig, + ); + // 검색 필터링 (클라이언트 사이드) const filteredLeftData = leftSearchQuery - ? leftData.filter((item) => { + ? dataToDisplay.filter((item) => { const searchLower = leftSearchQuery.toLowerCase(); return Object.entries(item).some(([key, value]) => { if (value === null || value === undefined) return false; return String(value).toLowerCase().includes(searchLower); }); }) - : leftData; + : dataToDisplay; // 재귀 렌더링 함수 const renderTreeItem = (item: any, index: number): React.ReactNode => { @@ -2174,23 +2429,53 @@ export const SplitPanelLayoutComponent: React.FC if (isTableMode) { // 테이블 모드 렌더링 const displayColumns = componentConfig.rightPanel?.columns || []; - const columnsToShow = - displayColumns.length > 0 - ? displayColumns.map((col) => ({ - ...col, - label: rightColumnLabels[col.name] || col.label || col.name, - format: col.format, // 🆕 포맷 설정 유지 - })) - : Object.keys(filteredData[0] || {}) - .filter((key) => shouldShowField(key)) - .slice(0, 5) - .map((key) => ({ - name: key, - label: rightColumnLabels[key] || key, - width: 150, - align: "left" as const, - format: undefined, // 🆕 기본값 - })); + + // 🆕 그룹 합산 모드일 때: 복합키 컬럼을 우선 표시 + const relationKeys = componentConfig.rightPanel?.relation?.keys || []; + const keyColumns = relationKeys.map((k: any) => k.leftColumn).filter(Boolean); + const isGroupedMode = selectedLeftItem?._originalItems?.length > 0; + + let columnsToShow: any[] = []; + + if (displayColumns.length > 0) { + // 설정된 컬럼 사용 + columnsToShow = displayColumns.map((col) => ({ + ...col, + label: rightColumnLabels[col.name] || col.label || col.name, + format: col.format, + })); + + // 🆕 그룹 합산 모드이고, 키 컬럼이 표시 목록에 없으면 맨 앞에 추가 + if (isGroupedMode && keyColumns.length > 0) { + const existingColNames = columnsToShow.map((c) => c.name); + const missingKeyColumns = keyColumns.filter((k: string) => !existingColNames.includes(k)); + + if (missingKeyColumns.length > 0) { + const keyColsToAdd = missingKeyColumns.map((colName: string) => ({ + name: colName, + label: rightColumnLabels[colName] || colName, + width: 120, + align: "left" as const, + format: undefined, + _isKeyColumn: true, // 구분용 플래그 + })); + columnsToShow = [...keyColsToAdd, ...columnsToShow]; + console.log("🔗 [우측패널] 그룹모드 - 키 컬럼 추가:", missingKeyColumns); + } + } + } else { + // 기본 컬럼 자동 생성 + columnsToShow = Object.keys(filteredData[0] || {}) + .filter((key) => shouldShowField(key)) + .slice(0, 5) + .map((key) => ({ + name: key, + label: rightColumnLabels[key] || key, + width: 150, + align: "left" as const, + format: undefined, + })); + } return (
diff --git a/frontend/types/table-options.ts b/frontend/types/table-options.ts index c9971710..5685f127 100644 --- a/frontend/types/table-options.ts +++ b/frontend/types/table-options.ts @@ -7,21 +7,21 @@ */ export interface TableFilter { columnName: string; - operator: - | "equals" - | "contains" - | "startsWith" - | "endsWith" - | "gt" - | "lt" - | "gte" - | "lte" - | "notEquals"; + operator: "equals" | "contains" | "startsWith" | "endsWith" | "gt" | "lt" | "gte" | "lte" | "notEquals"; value: string | number | boolean; filterType?: "text" | "number" | "date" | "select"; // 필터 입력 타입 width?: number; // 필터 입력 필드 너비 (px) } +/** + * 그룹별 합산 설정 + */ +export interface GroupSumConfig { + enabled: boolean; // 그룹핑 활성화 여부 + groupByColumn: string; // 그룹 기준 컬럼 + groupByColumnLabel?: string; // 그룹 기준 컬럼 라벨 (UI 표시용) +} + /** * 컬럼 표시 설정 */ @@ -60,7 +60,8 @@ export interface TableRegistration { onFilterChange: (filters: TableFilter[]) => void; onGroupChange: (groups: string[]) => void; onColumnVisibilityChange: (columns: ColumnVisibility[]) => void; - + onGroupSumChange?: (config: GroupSumConfig | null) => void; // 🆕 그룹별 합산 설정 변경 + // 데이터 조회 함수 (선택 타입 필터용) getColumnUniqueValues?: (columnName: string) => Promise>; } @@ -77,4 +78,3 @@ export interface TableOptionsContextValue { selectedTableId: string | null; setSelectedTableId: (tableId: string | null) => void; } - From a4cf11264db95cd77ebe5ba2f69d61647182ba9f Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 11 Dec 2025 14:32:15 +0900 Subject: [PATCH 08/30] =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=ED=95=A9=EC=82=B0=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../table-list/TableListComponent.tsx | 3006 +++++++++-------- 1 file changed, 1608 insertions(+), 1398 deletions(-) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 9fec8fc5..4c6c2dd7 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -47,18 +47,14 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Label } from "@/components/ui/label"; import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearchFilters"; import { SingleTableWithSticky } from "./SingleTableWithSticky"; import { CardModeRenderer } from "./CardModeRenderer"; import { TableOptionsModal } from "@/components/common/TableOptionsModal"; import { useTableOptions } from "@/contexts/TableOptionsContext"; -import { TableFilter, ColumnVisibility } from "@/types/table-options"; +import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options"; import { useAuth } from "@/hooks/useAuth"; import { useScreenContextOptional } from "@/contexts/ScreenContext"; import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext"; @@ -145,18 +141,18 @@ const debouncedApiCall = (key: string, fn: (...args: T) => P interface FilterCondition { id: string; column: string; - operator: - | "equals" - | "notEquals" - | "contains" - | "notContains" - | "startsWith" - | "endsWith" - | "greaterThan" - | "lessThan" - | "greaterOrEqual" - | "lessOrEqual" - | "isEmpty" + operator: + | "equals" + | "notEquals" + | "contains" + | "notContains" + | "startsWith" + | "endsWith" + | "greaterThan" + | "lessThan" + | "greaterOrEqual" + | "lessOrEqual" + | "isEmpty" | "isNotEmpty"; value: string; } @@ -299,7 +295,7 @@ export const TableListComponent: React.FC = ({ // 화면 컨텍스트 (데이터 제공자로 등록) const screenContext = useScreenContextOptional(); - + // 분할 패널 컨텍스트 (분할 패널 내부에서 데이터 수신자로 등록) const splitPanelContext = useSplitPanelContext(); // 🆕 ScreenContext에서 splitPanelPosition 가져오기 (중첩 화면에서도 작동) @@ -322,12 +318,12 @@ export const TableListComponent: React.FC = ({ newSearchValues[filter.columnName] = filter.value; } }); - + // console.log("🔍 [TableListComponent] filters → searchValues:", { // filters: filters.length, // searchValues: newSearchValues, // }); - + setSearchValues(newSearchValues); setCurrentPage(1); // 필터 변경 시 첫 페이지로 }, [filters]); @@ -342,7 +338,7 @@ export const TableListComponent: React.FC = ({ if (tableConfig.selectedTable && currentUserId) { const storageKey = `table_column_visibility_${tableConfig.selectedTable}_${currentUserId}`; const savedSettings = localStorage.getItem(storageKey); - + if (savedSettings) { try { const parsed = JSON.parse(savedSettings) as ColumnVisibility[]; @@ -357,11 +353,9 @@ export const TableListComponent: React.FC = ({ // columnVisibility 변경 시 컬럼 순서 및 가시성 적용 useEffect(() => { if (columnVisibility.length > 0) { - const newOrder = columnVisibility - .map((cv) => cv.columnName) - .filter((name) => name !== "__checkbox__"); // 체크박스 제외 + const newOrder = columnVisibility.map((cv) => cv.columnName).filter((name) => name !== "__checkbox__"); // 체크박스 제외 setColumnOrder(newOrder); - + // localStorage에 저장 (사용자별) if (tableConfig.selectedTable && currentUserId) { const storageKey = `table_column_visibility_${tableConfig.selectedTable}_${currentUserId}`; @@ -428,18 +422,18 @@ export const TableListComponent: React.FC = ({ const [data, setData] = useState[]>([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - + // 🆕 컬럼 헤더 필터 상태 (상단에서 선언) - const [headerFilters, setHeaderFilters] = useState>>({}); + const [headerFilters, setHeaderFilters] = useState>>({}); const [openFilterColumn, setOpenFilterColumn] = useState(null); // 🆕 Filter Builder (고급 필터) 관련 상태 - filteredData보다 먼저 정의해야 함 const [filterGroups, setFilterGroups] = useState([]); - + // 🆕 분할 패널에서 우측에 이미 추가된 항목 필터링 (좌측 테이블에만 적용) + 헤더 필터 const filteredData = useMemo(() => { let result = data; - + // 1. 분할 패널 좌측에 있고, 우측에 추가된 항목이 있는 경우 필터링 if (splitPanelPosition === "left" && splitPanelContext?.addedItemIds && splitPanelContext.addedItemIds.size > 0) { const addedIds = splitPanelContext.addedItemIds; @@ -448,17 +442,17 @@ export const TableListComponent: React.FC = ({ return !addedIds.has(rowId); }); } - + // 2. 헤더 필터 적용 (joinColumnMapping 사용 안 함 - 직접 컬럼명 사용) if (Object.keys(headerFilters).length > 0) { result = result.filter((row) => { return Object.entries(headerFilters).every(([columnName, values]) => { if (values.size === 0) return true; - + // 여러 가능한 컬럼명 시도 const cellValue = row[columnName] ?? row[columnName.toLowerCase()] ?? row[columnName.toUpperCase()]; const cellStr = cellValue !== null && cellValue !== undefined ? String(cellValue) : ""; - + return values.has(cellStr); }); }); @@ -469,11 +463,11 @@ export const TableListComponent: React.FC = ({ result = result.filter((row) => { return filterGroups.every((group) => { const validConditions = group.conditions.filter( - (c) => c.column && (c.operator === "isEmpty" || c.operator === "isNotEmpty" || c.value) + (c) => c.column && (c.operator === "isEmpty" || c.operator === "isNotEmpty" || c.value), ); if (validConditions.length === 0) return true; - const evaluateCondition = (value: any, condition: typeof group.conditions[0]): boolean => { + const evaluateCondition = (value: any, condition: (typeof group.conditions)[0]): boolean => { const strValue = value !== null && value !== undefined ? String(value).toLowerCase() : ""; const condValue = condition.value.toLowerCase(); @@ -515,7 +509,7 @@ export const TableListComponent: React.FC = ({ }); }); } - + return result; }, [data, splitPanelPosition, splitPanelContext?.addedItemIds, headerFilters, filterGroups]); @@ -561,9 +555,9 @@ export const TableListComponent: React.FC = ({ const tableContainerRef = useRef(null); // 🆕 인라인 셀 편집 관련 상태 - const [editingCell, setEditingCell] = useState<{ - rowIndex: number; - colIndex: number; + const [editingCell, setEditingCell] = useState<{ + rowIndex: number; + colIndex: number; columnName: string; originalValue: any; } | null>(null); @@ -572,13 +566,18 @@ export const TableListComponent: React.FC = ({ // 🆕 배치 편집 관련 상태 const [editMode, setEditMode] = useState<"immediate" | "batch">("immediate"); // 편집 모드 - const [pendingChanges, setPendingChanges] = useState>(new Map()); // key: `${rowIndex}-${columnName}` + const [pendingChanges, setPendingChanges] = useState< + Map< + string, + { + rowIndex: number; + columnName: string; + originalValue: any; + newValue: any; + primaryKeyValue: any; + } + > + >(new Map()); // key: `${rowIndex}-${columnName}` const [localEditedData, setLocalEditedData] = useState>>({}); // 로컬 수정 데이터 // 🆕 유효성 검사 관련 상태 @@ -610,6 +609,9 @@ export const TableListComponent: React.FC = ({ const [groupByColumns, setGroupByColumns] = useState([]); const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); + // 🆕 그룹별 합산 설정 상태 + const [groupSumConfig, setGroupSumConfig] = useState(null); + // 🆕 Master-Detail 관련 상태 const [expandedRows, setExpandedRows] = useState>(new Set()); // 확장된 행 키 목록 const [detailData, setDetailData] = useState>({}); // 상세 데이터 캐시 @@ -639,7 +641,9 @@ export const TableListComponent: React.FC = ({ // 🆕 Real-Time Updates 관련 상태 const [isRealTimeEnabled] = useState((tableConfig as any).realTimeUpdates ?? false); - const [wsConnectionStatus, setWsConnectionStatus] = useState<"connecting" | "connected" | "disconnected">("disconnected"); + const [wsConnectionStatus, setWsConnectionStatus] = useState<"connecting" | "connected" | "disconnected">( + "disconnected", + ); const wsRef = useRef(null); const reconnectTimeoutRef = useRef(null); @@ -670,7 +674,7 @@ export const TableListComponent: React.FC = ({ // 🆕 연결된 필터 처리 (셀렉트박스 등 다른 컴포넌트 값으로 필터링) useEffect(() => { const linkedFilters = tableConfig.linkedFilters; - + if (!linkedFilters || linkedFilters.length === 0 || !screenContext) { return; } @@ -689,7 +693,7 @@ export const TableListComponent: React.FC = ({ if (selectedData && selectedData.length > 0) { const sourceField = filter.sourceField || "value"; const value = selectedData[0][sourceField]; - + if (value !== linkedFilterValues[filter.targetColumn]) { newFilterValues[filter.targetColumn] = value; hasChanges = true; @@ -703,13 +707,13 @@ export const TableListComponent: React.FC = ({ if (hasChanges) { console.log("🔗 [TableList] 연결된 필터 값 변경:", newFilterValues); setLinkedFilterValues(newFilterValues); - + // searchValues에 연결된 필터 값 병합 - setSearchValues(prev => ({ + setSearchValues((prev) => ({ ...prev, - ...newFilterValues + ...newFilterValues, })); - + // 첫 페이지로 이동 setCurrentPage(1); } @@ -730,7 +734,7 @@ export const TableListComponent: React.FC = ({ const dataProvider: DataProvidable = { componentId: component.id, componentType: "table-list", - + getSelectedData: () => { // 🆕 필터링된 데이터에서 선택된 행만 반환 (우측에 추가된 항목 제외) const selectedData = filteredData.filter((row) => { @@ -739,12 +743,12 @@ export const TableListComponent: React.FC = ({ }); return selectedData; }, - + getAllData: () => { // 🆕 필터링된 데이터 반환 return filteredData; }, - + clearSelection: () => { setSelectedRows(new Set()); setIsAllSelected(false); @@ -755,7 +759,7 @@ export const TableListComponent: React.FC = ({ const dataReceiver: DataReceivable = { componentId: component.id, componentType: "table", - + receiveData: async (receivedData: any[], config: DataReceiverConfig) => { console.log("📥 TableList 데이터 수신:", { componentId: component.id, @@ -782,8 +786,8 @@ export const TableListComponent: React.FC = ({ case "merge": // 기존 데이터와 병합 (ID 기반) - const existingMap = new Map(data.map(item => [item.id, item])); - receivedData.forEach(item => { + const existingMap = new Map(data.map((item) => [item.id, item])); + receivedData.forEach((item) => { if (item.id && existingMap.has(item.id)) { // 기존 데이터 업데이트 existingMap.set(item.id, { ...existingMap.get(item.id), ...item }); @@ -799,7 +803,7 @@ export const TableListComponent: React.FC = ({ // 상태 업데이트 setData(newData); - + // 총 아이템 수 업데이트 setTotalItems(newData.length); @@ -809,7 +813,7 @@ export const TableListComponent: React.FC = ({ throw error; } }, - + getData: () => { return data; }, @@ -820,18 +824,19 @@ export const TableListComponent: React.FC = ({ if (screenContext && component.id) { screenContext.registerDataProvider(component.id, dataProvider); screenContext.registerDataReceiver(component.id, dataReceiver); - + return () => { screenContext.unregisterDataProvider(component.id); screenContext.unregisterDataReceiver(component.id); }; } }, [screenContext, component.id, data, selectedRows]); - + // 분할 패널 컨텍스트에 데이터 수신자로 등록 // useSplitPanelPosition 훅으로 위치 가져오기 (중첩된 화면에서도 작동) - const currentSplitPosition = splitPanelPosition || splitPanelContext?.getPositionByScreenId(screenId as number) || null; - + const currentSplitPosition = + splitPanelPosition || splitPanelContext?.getPositionByScreenId(screenId as number) || null; + useEffect(() => { if (splitPanelContext && component.id && currentSplitPosition) { const splitPanelReceiver = { @@ -843,7 +848,7 @@ export const TableListComponent: React.FC = ({ mode, position: currentSplitPosition, }); - + await dataReceiver.receiveData(incomingData, { targetComponentId: component.id, targetComponentType: "table-list", @@ -852,9 +857,9 @@ export const TableListComponent: React.FC = ({ }); }, }; - + splitPanelContext.registerReceiver(currentSplitPosition, component.id, splitPanelReceiver); - + return () => { splitPanelContext.unregisterReceiver(currentSplitPosition, component.id); }; @@ -863,11 +868,12 @@ export const TableListComponent: React.FC = ({ // 테이블 등록 (Context에 등록) const tableId = `table-list-${component.id}`; - + useEffect(() => { // tableConfig.columns를 직접 사용 (displayColumns는 비어있을 수 있음) - const columnsToRegister = (tableConfig.columns || []) - .filter((col) => col.visible !== false && col.columnName !== "__checkbox__"); + const columnsToRegister = (tableConfig.columns || []).filter( + (col) => col.visible !== false && col.columnName !== "__checkbox__", + ); if (!tableConfig.selectedTable || !columnsToRegister || columnsToRegister.length === 0) { return; @@ -884,7 +890,7 @@ export const TableListComponent: React.FC = ({ const meta = columnMeta[columnName]; const inputType = meta?.inputType || "text"; - + // 카테고리 타입인 경우 전체 정의된 값 조회 (백엔드 API) if (inputType === "category") { try { @@ -892,25 +898,23 @@ export const TableListComponent: React.FC = ({ tableName: tableConfig.selectedTable, columnName, }); - + // API 클라이언트 사용 (쿠키 인증 자동 처리) const { apiClient } = await import("@/lib/api/client"); - const response = await apiClient.get( - `/table-categories/${tableConfig.selectedTable}/${columnName}/values` - ); - + const response = await apiClient.get(`/table-categories/${tableConfig.selectedTable}/${columnName}/values`); + if (response.data.success && response.data.data) { const categoryOptions = response.data.data.map((item: any) => ({ - value: item.valueCode, // 카멜케이스 + value: item.valueCode, // 카멜케이스 label: item.valueLabel, // 카멜케이스 })); - + console.log("✅ [getColumnUniqueValues] 카테고리 전체 값:", { columnName, count: categoryOptions.length, options: categoryOptions, }); - + return categoryOptions; } else { console.warn("⚠️ [getColumnUniqueValues] 응답 형식 오류:", response.data); @@ -926,7 +930,7 @@ export const TableListComponent: React.FC = ({ // 에러 시 현재 데이터 기반으로 fallback } } - + // 일반 타입 또는 카테고리 조회 실패 시: 현재 데이터 기반 const isLabelType = ["category", "entity", "code"].includes(inputType); const labelField = isLabelType ? `${columnName}_name` : columnName; @@ -942,7 +946,7 @@ export const TableListComponent: React.FC = ({ // 현재 로드된 데이터에서 고유 값 추출 const uniqueValuesMap = new Map(); // value -> label - + data.forEach((row) => { const value = row[columnName]; if (value !== null && value !== undefined && value !== "") { @@ -990,6 +994,7 @@ export const TableListComponent: React.FC = ({ onGroupChange: setGrouping, onColumnVisibilityChange: setColumnVisibility, getColumnUniqueValues, // 고유 값 조회 함수 등록 + onGroupSumChange: setGroupSumConfig, // 🆕 그룹별 합산 설정 }; registerTable(registration); @@ -1006,7 +1011,7 @@ export const TableListComponent: React.FC = ({ columnWidths, tableLabel, data, // 데이터 자체가 변경되면 재등록 (고유 값 조회용) - totalItems, // 전체 항목 수가 변경되면 재등록 + totalItems, // 전체 항목 수가 변경되면 재등록 registerTable, unregisterTable, ]); @@ -1226,7 +1231,7 @@ export const TableListComponent: React.FC = ({ const cols = Object.entries(columnMeta) .filter(([_, meta]) => meta.inputType === "category") .map(([columnName, _]) => columnName); - + return cols; }, [columnMeta]); @@ -1251,7 +1256,7 @@ export const TableListComponent: React.FC = ({ // 🆕 엔티티 조인 컬럼 처리: "테이블명.컬럼명" 형태인지 확인 let targetTable = tableConfig.selectedTable; let targetColumn = columnName; - + if (columnName.includes(".")) { const parts = columnName.split("."); targetTable = parts[0]; // 조인된 테이블명 (예: item_info) @@ -1278,7 +1283,7 @@ export const TableListComponent: React.FC = ({ if (response.data.success && response.data.data && Array.isArray(response.data.data)) { const mapping: Record = {}; - + response.data.data.forEach((item: any) => { // valueCode를 문자열로 변환하여 키로 사용 const key = String(item.valueCode); @@ -1288,7 +1293,7 @@ export const TableListComponent: React.FC = ({ }; console.log(` 🔑 [${columnName}] "${key}" => "${item.valueLabel}" (색상: ${item.color})`); }); - + if (Object.keys(mapping).length > 0) { // 🆕 원래 컬럼명(item_info.material)으로 매핑 저장 mappings[columnName] = mapping; @@ -1321,33 +1326,35 @@ export const TableListComponent: React.FC = ({ // 🆕 엔티티 조인 컬럼의 inputType 정보 가져오기 및 카테고리 매핑 로드 // 1. "테이블명.컬럼명" 형태의 조인 컬럼 추출 - const joinedColumns = tableConfig.columns - ?.filter((col) => col.columnName?.includes(".")) - .map((col) => col.columnName) || []; - + const joinedColumns = + tableConfig.columns?.filter((col) => col.columnName?.includes(".")).map((col) => col.columnName) || []; + // 2. additionalJoinInfo가 있는 컬럼도 추출 (예: item_code_material → item_info.material) - const additionalJoinColumns = tableConfig.columns - ?.filter((col: any) => col.additionalJoinInfo?.referenceTable) - .map((col: any) => ({ - columnName: col.columnName, // 예: item_code_material - referenceTable: col.additionalJoinInfo.referenceTable, // 예: item_info - // joinAlias에서 실제 컬럼명 추출 (item_code_material → material) - actualColumn: col.additionalJoinInfo.joinAlias?.replace(`${col.additionalJoinInfo.sourceColumn}_`, '') || col.columnName, - })) || []; - + const additionalJoinColumns = + tableConfig.columns + ?.filter((col: any) => col.additionalJoinInfo?.referenceTable) + .map((col: any) => ({ + columnName: col.columnName, // 예: item_code_material + referenceTable: col.additionalJoinInfo.referenceTable, // 예: item_info + // joinAlias에서 실제 컬럼명 추출 (item_code_material → material) + actualColumn: + col.additionalJoinInfo.joinAlias?.replace(`${col.additionalJoinInfo.sourceColumn}_`, "") || + col.columnName, + })) || []; + console.log("🔍 [TableList] additionalJoinInfo 컬럼:", additionalJoinColumns); - + // 조인 테이블별로 그룹화 const joinedTableColumns: Record = {}; - + // "테이블명.컬럼명" 형태 처리 for (const joinedColumn of joinedColumns) { const parts = joinedColumn.split("."); if (parts.length !== 2) continue; - + const joinedTable = parts[0]; const joinedColumnName = parts[1]; - + if (!joinedTableColumns[joinedTable]) { joinedTableColumns[joinedTable] = []; } @@ -1356,7 +1363,7 @@ export const TableListComponent: React.FC = ({ actualColumn: joinedColumnName, }); } - + // additionalJoinInfo 형태 처리 for (const col of additionalJoinColumns) { if (!joinedTableColumns[col.referenceTable]) { @@ -1367,41 +1374,43 @@ export const TableListComponent: React.FC = ({ actualColumn: col.actualColumn, // 예: material }); } - + console.log("🔍 [TableList] 조인 테이블별 컬럼:", joinedTableColumns); - + // 조인된 테이블별로 inputType 정보 가져오기 const newJoinedColumnMeta: Record = {}; - + for (const [joinedTable, columns] of Object.entries(joinedTableColumns)) { try { // 조인 테이블의 컬럼 inputType 정보 가져오기 (이미 import된 tableTypeApi 사용) const inputTypes = await tableTypeApi.getColumnInputTypes(joinedTable); - + console.log(`📡 [TableList] 조인 테이블 inputType 로드 [${joinedTable}]:`, inputTypes); - + for (const col of columns) { const inputTypeInfo = inputTypes.find((it: any) => it.columnName === col.actualColumn); - + // 컬럼명 그대로 저장 (item_code_material 또는 item_info.material) newJoinedColumnMeta[col.columnName] = { inputType: inputTypeInfo?.inputType, }; - - console.log(` 🔗 [${col.columnName}] (실제: ${col.actualColumn}) inputType: ${inputTypeInfo?.inputType || "unknown"}`); - + + console.log( + ` 🔗 [${col.columnName}] (실제: ${col.actualColumn}) inputType: ${inputTypeInfo?.inputType || "unknown"}`, + ); + // inputType이 category인 경우 카테고리 매핑 로드 if (inputTypeInfo?.inputType === "category" && !mappings[col.columnName]) { try { console.log(`📡 [TableList] 조인 테이블 카테고리 로드 시도 [${col.columnName}]:`, { url: `/table-categories/${joinedTable}/${col.actualColumn}/values`, }); - + const response = await apiClient.get(`/table-categories/${joinedTable}/${col.actualColumn}/values`); - + if (response.data.success && response.data.data && Array.isArray(response.data.data)) { const mapping: Record = {}; - + response.data.data.forEach((item: any) => { const key = String(item.valueCode); mapping[key] = { @@ -1409,7 +1418,7 @@ export const TableListComponent: React.FC = ({ color: item.color, }; }); - + if (Object.keys(mapping).length > 0) { mappings[col.columnName] = mapping; console.log(`✅ [TableList] 조인 테이블 카테고리 매핑 로드 완료 [${col.columnName}]:`, { @@ -1426,7 +1435,7 @@ export const TableListComponent: React.FC = ({ console.error(`❌ [TableList] 조인 테이블 inputType 로드 실패 [${joinedTable}]:`, error); } } - + // 조인 컬럼 메타데이터 상태 업데이트 if (Object.keys(newJoinedColumnMeta).length > 0) { setJoinedColumnMeta(newJoinedColumnMeta); @@ -1438,7 +1447,7 @@ export const TableListComponent: React.FC = ({ mappingsKeys: Object.keys(mappings), mappings, }); - + if (Object.keys(mappings).length > 0) { setCategoryMappings(mappings); setCategoryMappingsKey((prev) => prev + 1); @@ -1452,7 +1461,12 @@ export const TableListComponent: React.FC = ({ }; loadCategoryMappings(); - }, [tableConfig.selectedTable, categoryColumns.length, JSON.stringify(categoryColumns), JSON.stringify(tableConfig.columns)]); // 더 명확한 의존성 + }, [ + tableConfig.selectedTable, + categoryColumns.length, + JSON.stringify(categoryColumns), + JSON.stringify(tableConfig.columns), + ]); // 더 명확한 의존성 // ======================================== // 데이터 가져오기 @@ -1464,7 +1478,7 @@ export const TableListComponent: React.FC = ({ isDesignMode, currentPage, }); - + if (!tableConfig.selectedTable || isDesignMode) { setData([]); setTotalPages(0); @@ -1481,34 +1495,35 @@ export const TableListComponent: React.FC = ({ const sortBy = sortColumn || undefined; const sortOrder = sortDirection; const search = searchTerm || undefined; - + // 🆕 연결 필터 값 가져오기 (분할 패널 내부일 때) let linkedFilterValues: Record = {}; let hasLinkedFiltersConfigured = false; // 연결 필터가 설정되어 있는지 여부 let hasSelectedLeftData = false; // 좌측에서 데이터가 선택되었는지 여부 - + console.log("🔍 [TableList] 분할 패널 컨텍스트 확인:", { hasSplitPanelContext: !!splitPanelContext, tableName: tableConfig.selectedTable, selectedLeftData: splitPanelContext?.selectedLeftData, linkedFilters: splitPanelContext?.linkedFilters, }); - + if (splitPanelContext) { // 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지) const linkedFiltersConfig = splitPanelContext.linkedFilters || []; hasLinkedFiltersConfigured = linkedFiltersConfig.some( - (filter) => filter.targetColumn?.startsWith(tableConfig.selectedTable + ".") || - filter.targetColumn === tableConfig.selectedTable + (filter) => + filter.targetColumn?.startsWith(tableConfig.selectedTable + ".") || + filter.targetColumn === tableConfig.selectedTable, ); - + // 좌측 데이터 선택 여부 확인 - hasSelectedLeftData = splitPanelContext.selectedLeftData && - Object.keys(splitPanelContext.selectedLeftData).length > 0; - + hasSelectedLeftData = + splitPanelContext.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0; + const allLinkedFilters = splitPanelContext.getLinkedFilterValues(); console.log("🔗 [TableList] 연결 필터 원본:", allLinkedFilters); - + // 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서) for (const [key, value] of Object.entries(allLinkedFilters)) { if (key.includes(".")) { @@ -1526,7 +1541,7 @@ export const TableListComponent: React.FC = ({ console.log("🔗 [TableList] 연결 필터 적용:", linkedFilterValues); } } - + // 🆕 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우 // → 빈 데이터 표시 (모든 데이터를 보여주지 않음) if (hasLinkedFiltersConfigured && !hasSelectedLeftData) { @@ -1536,7 +1551,7 @@ export const TableListComponent: React.FC = ({ setLoading(false); return; } - + // 검색 필터와 연결 필터 병합 const filters = { ...(Object.keys(searchValues).length > 0 ? searchValues : {}), @@ -1545,18 +1560,19 @@ export const TableListComponent: React.FC = ({ const hasFilters = Object.keys(filters).length > 0; // 🆕 REST API 데이터 소스 처리 - const isRestApiTable = tableConfig.selectedTable.startsWith("restapi_") || tableConfig.selectedTable.startsWith("_restapi_"); - + const isRestApiTable = + tableConfig.selectedTable.startsWith("restapi_") || tableConfig.selectedTable.startsWith("_restapi_"); + let response: any; - + if (isRestApiTable) { // REST API 데이터 소스인 경우 const connectionIdMatch = tableConfig.selectedTable.match(/restapi_(\d+)/); const connectionId = connectionIdMatch ? parseInt(connectionIdMatch[1]) : null; - + if (connectionId) { console.log("🌐 [TableList] REST API 데이터 소스 호출", { connectionId }); - + // REST API 연결 정보 가져오기 및 데이터 조회 const { ExternalRestApiConnectionAPI } = await import("@/lib/api/externalRestApiConnection"); const restApiData = await ExternalRestApiConnectionAPI.fetchData( @@ -1564,16 +1580,16 @@ export const TableListComponent: React.FC = ({ undefined, // endpoint - 연결 정보에서 가져옴 "response", // jsonPath - 기본값 response ); - + response = { data: restApiData.rows || [], total: restApiData.total || restApiData.rows?.length || 0, totalPages: Math.ceil((restApiData.total || restApiData.rows?.length || 0) / pageSize), }; - - console.log("✅ [TableList] REST API 응답:", { - dataLength: response.data.length, - total: response.total + + console.log("✅ [TableList] REST API 응답:", { + dataLength: response.data.length, + total: response.total, }); } else { throw new Error("REST API 연결 ID를 찾을 수 없습니다."); @@ -1604,114 +1620,114 @@ export const TableListComponent: React.FC = ({ console.log("🎯 [TableList] 화면별 엔티티 설정:", screenEntityConfigs); - // 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외) - let excludeFilterParam: any = undefined; - if (tableConfig.excludeFilter?.enabled) { - const excludeConfig = tableConfig.excludeFilter; - let filterValue: any = undefined; - - // 필터 값 소스에 따라 값 가져오기 (우선순위: formData > URL > 분할패널) - if (excludeConfig.filterColumn && excludeConfig.filterValueField) { - const fieldName = excludeConfig.filterValueField; - - // 1순위: props로 전달받은 formData에서 값 가져오기 (모달에서 사용) - if (propFormData && propFormData[fieldName]) { - filterValue = propFormData[fieldName]; - console.log("🔗 [TableList] formData에서 excludeFilter 값 가져오기:", { - field: fieldName, - value: filterValue, - }); - } - // 2순위: URL 파라미터에서 값 가져오기 - else if (typeof window !== "undefined") { - const urlParams = new URLSearchParams(window.location.search); - filterValue = urlParams.get(fieldName); - if (filterValue) { - console.log("🔗 [TableList] URL에서 excludeFilter 값 가져오기:", { + // 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외) + let excludeFilterParam: any = undefined; + if (tableConfig.excludeFilter?.enabled) { + const excludeConfig = tableConfig.excludeFilter; + let filterValue: any = undefined; + + // 필터 값 소스에 따라 값 가져오기 (우선순위: formData > URL > 분할패널) + if (excludeConfig.filterColumn && excludeConfig.filterValueField) { + const fieldName = excludeConfig.filterValueField; + + // 1순위: props로 전달받은 formData에서 값 가져오기 (모달에서 사용) + if (propFormData && propFormData[fieldName]) { + filterValue = propFormData[fieldName]; + console.log("🔗 [TableList] formData에서 excludeFilter 값 가져오기:", { field: fieldName, value: filterValue, }); } - } - // 3순위: 분할 패널 부모 데이터에서 값 가져오기 - if (!filterValue && splitPanelContext?.selectedLeftData) { - filterValue = splitPanelContext.selectedLeftData[fieldName]; - if (filterValue) { - console.log("🔗 [TableList] 분할패널에서 excludeFilter 값 가져오기:", { - field: fieldName, - value: filterValue, - }); + // 2순위: URL 파라미터에서 값 가져오기 + else if (typeof window !== "undefined") { + const urlParams = new URLSearchParams(window.location.search); + filterValue = urlParams.get(fieldName); + if (filterValue) { + console.log("🔗 [TableList] URL에서 excludeFilter 값 가져오기:", { + field: fieldName, + value: filterValue, + }); + } + } + // 3순위: 분할 패널 부모 데이터에서 값 가져오기 + if (!filterValue && splitPanelContext?.selectedLeftData) { + filterValue = splitPanelContext.selectedLeftData[fieldName]; + if (filterValue) { + console.log("🔗 [TableList] 분할패널에서 excludeFilter 값 가져오기:", { + field: fieldName, + value: filterValue, + }); + } } } - } - - if (filterValue || !excludeConfig.filterColumn) { - excludeFilterParam = { - enabled: true, - referenceTable: excludeConfig.referenceTable, - referenceColumn: excludeConfig.referenceColumn, - sourceColumn: excludeConfig.sourceColumn, - filterColumn: excludeConfig.filterColumn, - filterValue: filterValue, - }; - console.log("🚫 [TableList] 제외 필터 적용:", excludeFilterParam); - } - } - // 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원) - response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, { - page, - size: pageSize, - sortBy, - sortOrder, - search: hasFilters ? filters : undefined, - enableEntityJoin: true, - additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined, - screenEntityConfigs: Object.keys(screenEntityConfigs).length > 0 ? screenEntityConfigs : undefined, // 🎯 화면별 엔티티 설정 전달 - dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달 - excludeFilter: excludeFilterParam, // 🆕 제외 필터 전달 - }); - - // 실제 데이터의 item_number만 추출하여 중복 확인 - const itemNumbers = (response.data || []).map((item: any) => item.item_number); - const uniqueItemNumbers = [...new Set(itemNumbers)]; - - // console.log("✅ [TableList] API 응답 받음"); - // console.log(` - dataLength: ${response.data?.length || 0}`); - // console.log(` - total: ${response.total}`); - // console.log(` - itemNumbers: ${JSON.stringify(itemNumbers)}`); - // console.log(` - uniqueItemNumbers: ${JSON.stringify(uniqueItemNumbers)}`); - // console.log(` - isDuplicated: ${itemNumbers.length !== uniqueItemNumbers.length}`); - - setData(response.data || []); - setTotalPages(response.totalPages || 0); - setTotalItems(response.total || 0); - setError(null); - - // 🎯 Store에 필터 조건 저장 (엑셀 다운로드용) - // tableConfig.columns 사용 (visibleColumns는 이 시점에서 아직 정의되지 않을 수 있음) - const cols = (tableConfig.columns || []).filter((col) => col.visible !== false); - const labels: Record = {}; - cols.forEach((col) => { - labels[col.columnName] = columnLabels[col.columnName] || col.columnName; - }); - - tableDisplayStore.setTableData( - tableConfig.selectedTable, - response.data || [], - cols.map((col) => col.columnName), - sortBy, - sortOrder, - { - filterConditions: filters, - searchTerm: search, - visibleColumns: cols.map((col) => col.columnName), - columnLabels: labels, - currentPage: page, - pageSize: pageSize, - totalItems: response.total || 0, + if (filterValue || !excludeConfig.filterColumn) { + excludeFilterParam = { + enabled: true, + referenceTable: excludeConfig.referenceTable, + referenceColumn: excludeConfig.referenceColumn, + sourceColumn: excludeConfig.sourceColumn, + filterColumn: excludeConfig.filterColumn, + filterValue: filterValue, + }; + console.log("🚫 [TableList] 제외 필터 적용:", excludeFilterParam); + } } - ); + + // 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원) + response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, { + page, + size: pageSize, + sortBy, + sortOrder, + search: hasFilters ? filters : undefined, + enableEntityJoin: true, + additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined, + screenEntityConfigs: Object.keys(screenEntityConfigs).length > 0 ? screenEntityConfigs : undefined, // 🎯 화면별 엔티티 설정 전달 + dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달 + excludeFilter: excludeFilterParam, // 🆕 제외 필터 전달 + }); + + // 실제 데이터의 item_number만 추출하여 중복 확인 + const itemNumbers = (response.data || []).map((item: any) => item.item_number); + const uniqueItemNumbers = [...new Set(itemNumbers)]; + + // console.log("✅ [TableList] API 응답 받음"); + // console.log(` - dataLength: ${response.data?.length || 0}`); + // console.log(` - total: ${response.total}`); + // console.log(` - itemNumbers: ${JSON.stringify(itemNumbers)}`); + // console.log(` - uniqueItemNumbers: ${JSON.stringify(uniqueItemNumbers)}`); + // console.log(` - isDuplicated: ${itemNumbers.length !== uniqueItemNumbers.length}`); + + setData(response.data || []); + setTotalPages(response.totalPages || 0); + setTotalItems(response.total || 0); + setError(null); + + // 🎯 Store에 필터 조건 저장 (엑셀 다운로드용) + // tableConfig.columns 사용 (visibleColumns는 이 시점에서 아직 정의되지 않을 수 있음) + const cols = (tableConfig.columns || []).filter((col) => col.visible !== false); + const labels: Record = {}; + cols.forEach((col) => { + labels[col.columnName] = columnLabels[col.columnName] || col.columnName; + }); + + tableDisplayStore.setTableData( + tableConfig.selectedTable, + response.data || [], + cols.map((col) => col.columnName), + sortBy, + sortOrder, + { + filterConditions: filters, + searchTerm: search, + visibleColumns: cols.map((col) => col.columnName), + columnLabels: labels, + currentPage: page, + pageSize: pageSize, + totalItems: response.total || 0, + }, + ); } } catch (err: any) { console.error("데이터 가져오기 실패:", err); @@ -1779,10 +1795,13 @@ export const TableListComponent: React.FC = ({ if (tableConfig.selectedTable && userId) { const storageKey = `table_sort_state_${tableConfig.selectedTable}_${userId}`; try { - localStorage.setItem(storageKey, JSON.stringify({ - column: newSortColumn, - direction: newSortDirection - })); + localStorage.setItem( + storageKey, + JSON.stringify({ + column: newSortColumn, + direction: newSortDirection, + }), + ); console.log("💾 정렬 상태 저장:", { column: newSortColumn, direction: newSortDirection }); } catch (error) { console.error("❌ 정렬 상태 저장 실패:", error); @@ -1864,16 +1883,16 @@ export const TableListComponent: React.FC = ({ // 전역 저장소에 정렬된 데이터 저장 if (tableConfig.selectedTable) { - const cleanColumnOrder = ( - columnOrder.length > 0 ? columnOrder : cols.map((c) => c.columnName) - ).filter((col) => col !== "__checkbox__"); - + const cleanColumnOrder = (columnOrder.length > 0 ? columnOrder : cols.map((c) => c.columnName)).filter( + (col) => col !== "__checkbox__", + ); + // 컬럼 라벨 정보도 함께 저장 const labels: Record = {}; cols.forEach((col) => { labels[col.columnName] = columnLabels[col.columnName] || col.columnName; }); - + tableDisplayStore.setTableData( tableConfig.selectedTable, reorderedData, @@ -1952,7 +1971,7 @@ export const TableListComponent: React.FC = ({ originalData: row, additionalData: {}, })); - + useModalDataStore.getState().setData(tableConfig.selectedTable!, modalItems); console.log("✅ [TableList] modalDataStore에 데이터 저장:", { dataSourceId: tableConfig.selectedTable, @@ -1996,7 +2015,7 @@ export const TableListComponent: React.FC = ({ originalData: row, additionalData: {}, })); - + useModalDataStore.getState().setData(tableConfig.selectedTable!, modalItems); console.log("✅ [TableList] modalDataStore에 전체 데이터 저장:", { dataSourceId: tableConfig.selectedTable, @@ -2067,21 +2086,24 @@ export const TableListComponent: React.FC = ({ }; // 🆕 셀 더블클릭 핸들러 (편집 모드 진입) - visibleColumns 정의 후 사용 - const handleCellDoubleClick = useCallback((rowIndex: number, colIndex: number, columnName: string, value: any) => { - // 체크박스 컬럼은 편집 불가 - if (columnName === "__checkbox__") return; + const handleCellDoubleClick = useCallback( + (rowIndex: number, colIndex: number, columnName: string, value: any) => { + // 체크박스 컬럼은 편집 불가 + if (columnName === "__checkbox__") return; - // 🆕 편집 불가 컬럼 체크 - const column = visibleColumns.find((col) => col.columnName === columnName); - if (column?.editable === false) { - toast.warning(`'${column.displayName || columnName}' 컬럼은 편집할 수 없습니다.`); - return; - } + // 🆕 편집 불가 컬럼 체크 + const column = visibleColumns.find((col) => col.columnName === columnName); + if (column?.editable === false) { + toast.warning(`'${column.displayName || columnName}' 컬럼은 편집할 수 없습니다.`); + return; + } - setEditingCell({ rowIndex, colIndex, columnName, originalValue: value }); - setEditingValue(value !== null && value !== undefined ? String(value) : ""); - setFocusedCell({ rowIndex, colIndex }); - }, [visibleColumns]); + setEditingCell({ rowIndex, colIndex, columnName, originalValue: value }); + setEditingValue(value !== null && value !== undefined ? String(value) : ""); + setFocusedCell({ rowIndex, colIndex }); + }, + [visibleColumns], + ); // 🆕 편집 모드 진입 placeholder (실제 구현은 visibleColumns 정의 후) const startEditingRef = useRef<() => void>(() => {}); @@ -2089,25 +2111,25 @@ export const TableListComponent: React.FC = ({ // 🆕 각 컬럼의 고유값 목록 계산 const columnUniqueValues = useMemo(() => { const result: Record = {}; - + if (data.length === 0) return result; (tableConfig.columns || []).forEach((column: { columnName: string }) => { if (column.columnName === "__checkbox__") return; - + const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; const values = new Set(); - + data.forEach((row) => { const val = row[mappedColumnName]; if (val !== null && val !== undefined && val !== "") { values.add(String(val)); } }); - + result[column.columnName] = Array.from(values).sort(); }); - + return result; }, [data, tableConfig.columns, joinColumnMapping]); @@ -2116,13 +2138,13 @@ export const TableListComponent: React.FC = ({ setHeaderFilters((prev) => { const current = prev[columnName] || new Set(); const newSet = new Set(current); - + if (newSet.has(value)) { newSet.delete(value); } else { newSet.add(value); } - + return { ...prev, [columnName]: newSet }; }); }, []); @@ -2147,14 +2169,14 @@ export const TableListComponent: React.FC = ({ // 형식: { columnName: { type: 'sum' | 'avg' | 'count' | 'min' | 'max', label?: string } } const summaryConfig = useMemo(() => { const config: Record = {}; - + // tableConfig에서 summary 설정 읽기 if (tableConfig.summaries) { tableConfig.summaries.forEach((summary: { columnName: string; type: string; label?: string }) => { config[summary.columnName] = { type: summary.type, label: summary.label }; }); } - + return config; }, [tableConfig.summaries]); @@ -2308,7 +2330,16 @@ export const TableListComponent: React.FC = ({ } cancelEditing(); - }, [editingCell, editingValue, data, tableConfig.selectedTable, tableConfig.primaryKey, cancelEditing, editMode, pendingChanges.size]); + }, [ + editingCell, + editingValue, + data, + tableConfig.selectedTable, + tableConfig.primaryKey, + cancelEditing, + editMode, + pendingChanges.size, + ]); // 🆕 배치 저장: 모든 변경사항 한번에 저장 const saveBatchChanges = useCallback(async () => { @@ -2329,7 +2360,7 @@ export const TableListComponent: React.FC = ({ keyValue: change.primaryKeyValue, updateField: change.columnName, updateValue: change.newValue, - }) + }), ); await Promise.all(savePromises); @@ -2358,78 +2389,86 @@ export const TableListComponent: React.FC = ({ }, [pendingChanges.size]); // 🆕 특정 셀이 수정되었는지 확인 - const isCellModified = useCallback((rowIndex: number, columnName: string) => { - return pendingChanges.has(`${rowIndex}-${columnName}`); - }, [pendingChanges]); + const isCellModified = useCallback( + (rowIndex: number, columnName: string) => { + return pendingChanges.has(`${rowIndex}-${columnName}`); + }, + [pendingChanges], + ); // 🆕 수정된 셀 값 가져오기 (로컬 수정 데이터 우선) - const getDisplayValue = useCallback((row: any, rowIndex: number, columnName: string) => { - const localValue = localEditedData[rowIndex]?.[columnName]; - if (localValue !== undefined) { - return localValue; - } - return row[columnName]; - }, [localEditedData]); + const getDisplayValue = useCallback( + (row: any, rowIndex: number, columnName: string) => { + const localValue = localEditedData[rowIndex]?.[columnName]; + if (localValue !== undefined) { + return localValue; + } + return row[columnName]; + }, + [localEditedData], + ); // 🆕 유효성 검사 함수 - const validateValue = useCallback(( - value: any, - columnName: string, - row: any - ): string | null => { - // tableConfig.validation에서 컬럼별 규칙 가져오기 - const rules = (tableConfig as any).validation?.[columnName] as ValidationRule | undefined; - if (!rules) return null; + const validateValue = useCallback( + (value: any, columnName: string, row: any): string | null => { + // tableConfig.validation에서 컬럼별 규칙 가져오기 + const rules = (tableConfig as any).validation?.[columnName] as ValidationRule | undefined; + if (!rules) return null; - const strValue = value !== null && value !== undefined ? String(value) : ""; - const numValue = parseFloat(strValue); + const strValue = value !== null && value !== undefined ? String(value) : ""; + const numValue = parseFloat(strValue); - // 필수 검사 - if (rules.required && (!strValue || strValue.trim() === "")) { - return rules.customMessage || "필수 입력 항목입니다."; - } + // 필수 검사 + if (rules.required && (!strValue || strValue.trim() === "")) { + return rules.customMessage || "필수 입력 항목입니다."; + } - // 값이 비어있으면 다른 검사 스킵 (required가 아닌 경우) - if (!strValue || strValue.trim() === "") return null; + // 값이 비어있으면 다른 검사 스킵 (required가 아닌 경우) + if (!strValue || strValue.trim() === "") return null; - // 최소값 검사 - if (rules.min !== undefined && !isNaN(numValue) && numValue < rules.min) { - return rules.customMessage || `최소값은 ${rules.min}입니다.`; - } + // 최소값 검사 + if (rules.min !== undefined && !isNaN(numValue) && numValue < rules.min) { + return rules.customMessage || `최소값은 ${rules.min}입니다.`; + } - // 최대값 검사 - if (rules.max !== undefined && !isNaN(numValue) && numValue > rules.max) { - return rules.customMessage || `최대값은 ${rules.max}입니다.`; - } + // 최대값 검사 + if (rules.max !== undefined && !isNaN(numValue) && numValue > rules.max) { + return rules.customMessage || `최대값은 ${rules.max}입니다.`; + } - // 최소 길이 검사 - if (rules.minLength !== undefined && strValue.length < rules.minLength) { - return rules.customMessage || `최소 ${rules.minLength}자 이상 입력해주세요.`; - } + // 최소 길이 검사 + if (rules.minLength !== undefined && strValue.length < rules.minLength) { + return rules.customMessage || `최소 ${rules.minLength}자 이상 입력해주세요.`; + } - // 최대 길이 검사 - if (rules.maxLength !== undefined && strValue.length > rules.maxLength) { - return rules.customMessage || `최대 ${rules.maxLength}자까지 입력 가능합니다.`; - } + // 최대 길이 검사 + if (rules.maxLength !== undefined && strValue.length > rules.maxLength) { + return rules.customMessage || `최대 ${rules.maxLength}자까지 입력 가능합니다.`; + } - // 패턴 검사 - if (rules.pattern && !rules.pattern.test(strValue)) { - return rules.customMessage || "입력 형식이 올바르지 않습니다."; - } + // 패턴 검사 + if (rules.pattern && !rules.pattern.test(strValue)) { + return rules.customMessage || "입력 형식이 올바르지 않습니다."; + } - // 커스텀 검증 - if (rules.validate) { - const customError = rules.validate(value, row); - if (customError) return customError; - } + // 커스텀 검증 + if (rules.validate) { + const customError = rules.validate(value, row); + if (customError) return customError; + } - return null; - }, [tableConfig]); + return null; + }, + [tableConfig], + ); // 🆕 셀 유효성 에러 여부 확인 - const getCellValidationError = useCallback((rowIndex: number, columnName: string): string | null => { - return validationErrors.get(`${rowIndex}-${columnName}`) || null; - }, [validationErrors]); + const getCellValidationError = useCallback( + (rowIndex: number, columnName: string): string | null => { + return validationErrors.get(`${rowIndex}-${columnName}`) || null; + }, + [validationErrors], + ); // 🆕 유효성 검사 에러 설정 const setCellValidationError = useCallback((rowIndex: number, columnName: string, error: string | null) => { @@ -2451,141 +2490,158 @@ export const TableListComponent: React.FC = ({ }, []); // 🆕 Excel 내보내기 함수 - const exportToExcel = useCallback((exportAll: boolean = true) => { - try { - // 내보낼 데이터 선택 (선택된 행만 또는 전체) - let exportData: any[]; - if (exportAll) { - exportData = filteredData; - } else { - // 선택된 행만 내보내기 - exportData = filteredData.filter((row, index) => { - const rowKey = getRowKey(row, index); - return selectedRows.has(rowKey); - }); - } + const exportToExcel = useCallback( + (exportAll: boolean = true) => { + try { + // 내보낼 데이터 선택 (선택된 행만 또는 전체) + let exportData: any[]; + if (exportAll) { + exportData = filteredData; + } else { + // 선택된 행만 내보내기 + exportData = filteredData.filter((row, index) => { + const rowKey = getRowKey(row, index); + return selectedRows.has(rowKey); + }); + } - if (exportData.length === 0) { - toast.error(exportAll ? "내보낼 데이터가 없습니다." : "선택된 행이 없습니다."); - return; - } + if (exportData.length === 0) { + toast.error(exportAll ? "내보낼 데이터가 없습니다." : "선택된 행이 없습니다."); + return; + } - // 컬럼 정보 가져오기 (체크박스 제외) - const exportColumns = visibleColumns.filter((col) => col.columnName !== "__checkbox__"); - - // 헤더 행 생성 - const headers = exportColumns.map((col) => columnLabels[col.columnName] || col.columnName); - - // 데이터 행 생성 - const rows = exportData.map((row) => { - return exportColumns.map((col) => { - const mappedColumnName = joinColumnMapping[col.columnName] || col.columnName; - const value = row[mappedColumnName]; - - // 카테고리 매핑된 값 처리 - if (categoryMappings[col.columnName] && value !== null && value !== undefined) { - const mapping = categoryMappings[col.columnName][String(value)]; - if (mapping) { - return mapping.label; + // 컬럼 정보 가져오기 (체크박스 제외) + const exportColumns = visibleColumns.filter((col) => col.columnName !== "__checkbox__"); + + // 헤더 행 생성 + const headers = exportColumns.map((col) => columnLabels[col.columnName] || col.columnName); + + // 데이터 행 생성 + const rows = exportData.map((row) => { + return exportColumns.map((col) => { + const mappedColumnName = joinColumnMapping[col.columnName] || col.columnName; + const value = row[mappedColumnName]; + + // 카테고리 매핑된 값 처리 + if (categoryMappings[col.columnName] && value !== null && value !== undefined) { + const mapping = categoryMappings[col.columnName][String(value)]; + if (mapping) { + return mapping.label; + } } - } - - // null/undefined 처리 - if (value === null || value === undefined) { - return ""; - } - - return value; + + // null/undefined 처리 + if (value === null || value === undefined) { + return ""; + } + + return value; + }); }); - }); - // 워크시트 생성 - const wsData = [headers, ...rows]; - const ws = XLSX.utils.aoa_to_sheet(wsData); + // 워크시트 생성 + const wsData = [headers, ...rows]; + const ws = XLSX.utils.aoa_to_sheet(wsData); - // 컬럼 너비 자동 조정 - const colWidths = exportColumns.map((col, idx) => { - const headerLength = headers[idx]?.length || 10; - const maxDataLength = Math.max( - ...rows.map((row) => String(row[idx] ?? "").length) - ); - return { wch: Math.min(Math.max(headerLength, maxDataLength) + 2, 50) }; - }); - ws["!cols"] = colWidths; + // 컬럼 너비 자동 조정 + const colWidths = exportColumns.map((col, idx) => { + const headerLength = headers[idx]?.length || 10; + const maxDataLength = Math.max(...rows.map((row) => String(row[idx] ?? "").length)); + return { wch: Math.min(Math.max(headerLength, maxDataLength) + 2, 50) }; + }); + ws["!cols"] = colWidths; - // 워크북 생성 - const wb = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(wb, ws, tableLabel || "데이터"); + // 워크북 생성 + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, tableLabel || "데이터"); - // 파일명 생성 - const fileName = `${tableLabel || tableConfig.selectedTable || "export"}_${new Date().toISOString().split("T")[0]}.xlsx`; + // 파일명 생성 + const fileName = `${tableLabel || tableConfig.selectedTable || "export"}_${new Date().toISOString().split("T")[0]}.xlsx`; - // 파일 다운로드 - XLSX.writeFile(wb, fileName); + // 파일 다운로드 + XLSX.writeFile(wb, fileName); - toast.success(`${exportData.length}개 행이 Excel로 내보내기 되었습니다.`); - console.log("✅ Excel 내보내기 완료:", fileName); - } catch (error) { - console.error("❌ Excel 내보내기 실패:", error); - toast.error("Excel 내보내기 중 오류가 발생했습니다."); - } - }, [filteredData, selectedRows, visibleColumns, columnLabels, joinColumnMapping, categoryMappings, tableLabel, tableConfig.selectedTable, getRowKey]); + toast.success(`${exportData.length}개 행이 Excel로 내보내기 되었습니다.`); + console.log("✅ Excel 내보내기 완료:", fileName); + } catch (error) { + console.error("❌ Excel 내보내기 실패:", error); + toast.error("Excel 내보내기 중 오류가 발생했습니다."); + } + }, + [ + filteredData, + selectedRows, + visibleColumns, + columnLabels, + joinColumnMapping, + categoryMappings, + tableLabel, + tableConfig.selectedTable, + getRowKey, + ], + ); // 🆕 행 확장/축소 토글 - const toggleRowExpand = useCallback(async (rowKey: string, row: any) => { - setExpandedRows((prev) => { - const newSet = new Set(prev); - if (newSet.has(rowKey)) { - newSet.delete(rowKey); - } else { - newSet.add(rowKey); - // 상세 데이터 로딩 (아직 없는 경우) - if (!detailData[rowKey] && (tableConfig as any).masterDetail?.detailTable) { - loadDetailData(rowKey, row); + const toggleRowExpand = useCallback( + async (rowKey: string, row: any) => { + setExpandedRows((prev) => { + const newSet = new Set(prev); + if (newSet.has(rowKey)) { + newSet.delete(rowKey); + } else { + newSet.add(rowKey); + // 상세 데이터 로딩 (아직 없는 경우) + if (!detailData[rowKey] && (tableConfig as any).masterDetail?.detailTable) { + loadDetailData(rowKey, row); + } } - } - return newSet; - }); - }, [detailData, tableConfig]); + return newSet; + }); + }, + [detailData, tableConfig], + ); // 🆕 상세 데이터 로딩 - const loadDetailData = useCallback(async (rowKey: string, row: any) => { - const masterDetailConfig = (tableConfig as any).masterDetail; - if (!masterDetailConfig?.detailTable) return; + const loadDetailData = useCallback( + async (rowKey: string, row: any) => { + const masterDetailConfig = (tableConfig as any).masterDetail; + if (!masterDetailConfig?.detailTable) return; - try { - const { apiClient } = await import("@/lib/api/client"); - - // masterKey 값 가져오기 - const masterKeyField = masterDetailConfig.masterKey || "id"; - const masterKeyValue = row[masterKeyField]; - - // 상세 테이블에서 데이터 조회 - const response = await apiClient.post(`/table-management/tables/${masterDetailConfig.detailTable}/data`, { - page: 1, - size: 100, - search: { - [masterDetailConfig.detailKey || masterKeyField]: masterKeyValue, - }, - autoFilter: true, - }); + try { + const { apiClient } = await import("@/lib/api/client"); - const details = response.data?.data?.data || []; - - setDetailData((prev) => ({ - ...prev, - [rowKey]: details, - })); - - console.log("✅ 상세 데이터 로딩 완료:", { rowKey, count: details.length }); - } catch (error) { - console.error("❌ 상세 데이터 로딩 실패:", error); - setDetailData((prev) => ({ - ...prev, - [rowKey]: [], - })); - } - }, [tableConfig]); + // masterKey 값 가져오기 + const masterKeyField = masterDetailConfig.masterKey || "id"; + const masterKeyValue = row[masterKeyField]; + + // 상세 테이블에서 데이터 조회 + const response = await apiClient.post(`/table-management/tables/${masterDetailConfig.detailTable}/data`, { + page: 1, + size: 100, + search: { + [masterDetailConfig.detailKey || masterKeyField]: masterKeyValue, + }, + autoFilter: true, + }); + + const details = response.data?.data?.data || []; + + setDetailData((prev) => ({ + ...prev, + [rowKey]: details, + })); + + console.log("✅ 상세 데이터 로딩 완료:", { rowKey, count: details.length }); + } catch (error) { + console.error("❌ 상세 데이터 로딩 실패:", error); + setDetailData((prev) => ({ + ...prev, + [rowKey]: [], + })); + } + }, + [tableConfig], + ); // 🆕 모든 행 확장/축소 const expandAllRows = useCallback(() => { @@ -2605,22 +2661,22 @@ export const TableListComponent: React.FC = ({ if (!bands || bands.length === 0) return null; // 각 band의 시작 인덱스와 colspan 계산 - const bandInfo = bands.map((band) => { - const visibleBandColumns = band.columns.filter((colName) => - visibleColumns.some((vc) => vc.columnName === colName) - ); - - const startIndex = visibleColumns.findIndex( - (vc) => visibleBandColumns.includes(vc.columnName) - ); + const bandInfo = bands + .map((band) => { + const visibleBandColumns = band.columns.filter((colName) => + visibleColumns.some((vc) => vc.columnName === colName), + ); - return { - caption: band.caption, - columns: visibleBandColumns, - colSpan: visibleBandColumns.length, - startIndex, - }; - }).filter((b) => b.colSpan > 0); + const startIndex = visibleColumns.findIndex((vc) => visibleBandColumns.includes(vc.columnName)); + + return { + caption: band.caption, + columns: visibleBandColumns, + colSpan: visibleBandColumns.length, + startIndex, + }; + }) + .filter((b) => b.colSpan > 0); // Band에 포함되지 않은 컬럼 찾기 const bandedColumns = new Set(bands.flatMap((b) => b.columns)); @@ -2636,109 +2692,84 @@ export const TableListComponent: React.FC = ({ }, [tableConfig, visibleColumns]); // 🆕 Cascading Lookups: 연계 드롭다운 옵션 로딩 - const loadCascadingOptions = useCallback(async ( - columnName: string, - parentColumnName: string, - parentValue: any - ) => { - const cascadingConfig = (tableConfig as any).cascadingLookups?.[columnName]; - if (!cascadingConfig) return; + const loadCascadingOptions = useCallback( + async (columnName: string, parentColumnName: string, parentValue: any) => { + const cascadingConfig = (tableConfig as any).cascadingLookups?.[columnName]; + if (!cascadingConfig) return; - const cacheKey = `${columnName}_${parentValue}`; - - // 이미 로딩 중이면 스킵 - if (loadingCascading[cacheKey]) return; - - // 이미 캐시된 데이터가 있으면 스킵 - if (cascadingOptions[cacheKey]) return; + const cacheKey = `${columnName}_${parentValue}`; - setLoadingCascading((prev) => ({ ...prev, [cacheKey]: true })); + // 이미 로딩 중이면 스킵 + if (loadingCascading[cacheKey]) return; - try { - const { apiClient } = await import("@/lib/api/client"); - - // API에서 연계 옵션 로딩 - const response = await apiClient.post(`/table-management/tables/${cascadingConfig.sourceTable}/data`, { - page: 1, - size: 1000, - search: { - [cascadingConfig.parentKeyField || parentColumnName]: parentValue, - }, - autoFilter: true, - }); + // 이미 캐시된 데이터가 있으면 스킵 + if (cascadingOptions[cacheKey]) return; - const items = response.data?.data?.data || []; - const options = items.map((item: any) => ({ - value: item[cascadingConfig.valueField || "id"], - label: item[cascadingConfig.labelField || "name"], - })); + setLoadingCascading((prev) => ({ ...prev, [cacheKey]: true })); - setCascadingOptions((prev) => ({ - ...prev, - [cacheKey]: options, - })); + try { + const { apiClient } = await import("@/lib/api/client"); - console.log("✅ Cascading options 로딩 완료:", { columnName, parentValue, count: options.length }); - } catch (error) { - console.error("❌ Cascading options 로딩 실패:", error); - setCascadingOptions((prev) => ({ - ...prev, - [cacheKey]: [], - })); - } finally { - setLoadingCascading((prev) => ({ ...prev, [cacheKey]: false })); - } - }, [tableConfig, cascadingOptions, loadingCascading]); + // API에서 연계 옵션 로딩 + const response = await apiClient.post(`/table-management/tables/${cascadingConfig.sourceTable}/data`, { + page: 1, + size: 1000, + search: { + [cascadingConfig.parentKeyField || parentColumnName]: parentValue, + }, + autoFilter: true, + }); + + const items = response.data?.data?.data || []; + const options = items.map((item: any) => ({ + value: item[cascadingConfig.valueField || "id"], + label: item[cascadingConfig.labelField || "name"], + })); + + setCascadingOptions((prev) => ({ + ...prev, + [cacheKey]: options, + })); + + console.log("✅ Cascading options 로딩 완료:", { columnName, parentValue, count: options.length }); + } catch (error) { + console.error("❌ Cascading options 로딩 실패:", error); + setCascadingOptions((prev) => ({ + ...prev, + [cacheKey]: [], + })); + } finally { + setLoadingCascading((prev) => ({ ...prev, [cacheKey]: false })); + } + }, + [tableConfig, cascadingOptions, loadingCascading], + ); // 🆕 Cascading Lookups: 특정 컬럼의 옵션 가져오기 - const getCascadingOptions = useCallback((columnName: string, row: any): { value: string; label: string }[] => { - const cascadingConfig = (tableConfig as any).cascadingLookups?.[columnName]; - if (!cascadingConfig) return []; + const getCascadingOptions = useCallback( + (columnName: string, row: any): { value: string; label: string }[] => { + const cascadingConfig = (tableConfig as any).cascadingLookups?.[columnName]; + if (!cascadingConfig) return []; - const parentValue = row[cascadingConfig.parentColumn]; - if (parentValue === undefined || parentValue === null) return []; + const parentValue = row[cascadingConfig.parentColumn]; + if (parentValue === undefined || parentValue === null) return []; - const cacheKey = `${columnName}_${parentValue}`; - return cascadingOptions[cacheKey] || []; - }, [tableConfig, cascadingOptions]); + const cacheKey = `${columnName}_${parentValue}`; + return cascadingOptions[cacheKey] || []; + }, + [tableConfig, cascadingOptions], + ); - // 🆕 Virtual Scrolling: 보이는 행 범위 계산 - const virtualScrollInfo = useMemo(() => { - if (!isVirtualScrollEnabled || filteredData.length === 0) { - return { - startIndex: 0, - endIndex: filteredData.length, - visibleData: filteredData, - topSpacerHeight: 0, - bottomSpacerHeight: 0, - totalHeight: filteredData.length * ROW_HEIGHT, - }; - } - - const containerHeight = scrollContainerRef.current?.clientHeight || 600; - const totalRows = filteredData.length; - const totalHeight = totalRows * ROW_HEIGHT; - - // 현재 보이는 행 범위 계산 - const startIndex = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN); - const visibleRowCount = Math.ceil(containerHeight / ROW_HEIGHT) + OVERSCAN * 2; - const endIndex = Math.min(totalRows, startIndex + visibleRowCount); - - return { - startIndex, - endIndex, - visibleData: filteredData.slice(startIndex, endIndex), - topSpacerHeight: startIndex * ROW_HEIGHT, - bottomSpacerHeight: (totalRows - endIndex) * ROW_HEIGHT, - totalHeight, - }; - }, [isVirtualScrollEnabled, filteredData, scrollTop, ROW_HEIGHT, OVERSCAN]); + // 🆕 Virtual Scrolling: virtualScrollInfo는 displayData 정의 이후로 이동됨 (아래 참조) // 🆕 Virtual Scrolling: 스크롤 핸들러 - const handleVirtualScroll = useCallback((e: React.UIEvent) => { - if (!isVirtualScrollEnabled) return; - setScrollTop(e.currentTarget.scrollTop); - }, [isVirtualScrollEnabled]); + const handleVirtualScroll = useCallback( + (e: React.UIEvent) => { + if (!isVirtualScrollEnabled) return; + setScrollTop(e.currentTarget.scrollTop); + }, + [isVirtualScrollEnabled], + ); // 🆕 State Persistence: 통합 상태 저장 const saveTableState = useCallback(() => { @@ -2753,7 +2784,7 @@ export const TableListComponent: React.FC = ({ frozenColumns, showGridLines, headerFilters: Object.fromEntries( - Object.entries(headerFilters).map(([key, set]) => [key, Array.from(set as Set)]) + Object.entries(headerFilters).map(([key, set]) => [key, Array.from(set as Set)]), ), pageSize: localPageSize, timestamp: Date.now(), @@ -2765,7 +2796,18 @@ export const TableListComponent: React.FC = ({ } catch (error) { console.error("❌ 테이블 상태 저장 실패:", error); } - }, [tableStateKey, columnWidths, columnOrder, sortColumn, sortDirection, groupByColumns, frozenColumns, showGridLines, headerFilters, localPageSize]); + }, [ + tableStateKey, + columnWidths, + columnOrder, + sortColumn, + sortDirection, + groupByColumns, + frozenColumns, + showGridLines, + headerFilters, + localPageSize, + ]); // 🆕 State Persistence: 통합 상태 복원 const loadTableState = useCallback(() => { @@ -2776,7 +2818,7 @@ export const TableListComponent: React.FC = ({ if (!saved) return; const state = JSON.parse(saved); - + if (state.columnWidths) setColumnWidths(state.columnWidths); if (state.columnOrder) setColumnOrder(state.columnOrder); if (state.sortColumn !== undefined) setSortColumn(state.sortColumn); @@ -2828,7 +2870,8 @@ export const TableListComponent: React.FC = ({ const connectWebSocket = useCallback(() => { if (!isRealTimeEnabled || !tableConfig.selectedTable) return; - const wsUrl = (tableConfig as any).wsUrl || + const wsUrl = + (tableConfig as any).wsUrl || `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws/table/${tableConfig.selectedTable}`; try { @@ -2876,7 +2919,7 @@ export const TableListComponent: React.FC = ({ wsRef.current.onclose = () => { setWsConnectionStatus("disconnected"); console.log("🔌 WebSocket 연결 종료"); - + // 자동 재연결 (5초 후) if (isRealTimeEnabled) { reconnectTimeoutRef.current = setTimeout(() => { @@ -2921,14 +2964,23 @@ export const TableListComponent: React.FC = ({ }, 1000); // 1초 후 저장 (디바운스) return () => clearTimeout(timeoutId); - }, [columnWidths, columnOrder, sortColumn, sortDirection, groupByColumns, frozenColumns, showGridLines, headerFilters]); + }, [ + columnWidths, + columnOrder, + sortColumn, + sortDirection, + groupByColumns, + frozenColumns, + showGridLines, + headerFilters, + ]); // 🆕 Clipboard: 선택된 데이터 복사 const handleCopy = useCallback(async () => { try { // 선택된 행 데이터 가져오기 let copyData: any[]; - + if (selectedRows.size > 0) { // 선택된 행만 copyData = filteredData.filter((row, index) => { @@ -2955,15 +3007,17 @@ export const TableListComponent: React.FC = ({ const exportColumns = visibleColumns.filter((c) => c.columnName !== "__checkbox__"); const headers = exportColumns.map((c) => columnLabels[c.columnName] || c.columnName); const rows = copyData.map((row) => - exportColumns.map((c) => { - const value = row[c.columnName]; - return value !== null && value !== undefined ? String(value).replace(/\t/g, " ").replace(/\n/g, " ") : ""; - }).join("\t") + exportColumns + .map((c) => { + const value = row[c.columnName]; + return value !== null && value !== undefined ? String(value).replace(/\t/g, " ").replace(/\n/g, " ") : ""; + }) + .join("\t"), ); const tsvContent = [headers.join("\t"), ...rows].join("\n"); await navigator.clipboard.writeText(tsvContent); - + toast.success(`${copyData.length}행 복사됨`); console.log("✅ 클립보드 복사:", copyData.length, "행"); } catch (error) { @@ -3013,39 +3067,42 @@ export const TableListComponent: React.FC = ({ }, [contextMenu, closeContextMenu]); // 🆕 Search Panel: 통합 검색 실행 - const executeGlobalSearch = useCallback((term: string) => { - if (!term.trim()) { - setSearchHighlights(new Set()); - return; - } + const executeGlobalSearch = useCallback( + (term: string) => { + if (!term.trim()) { + setSearchHighlights(new Set()); + return; + } - const lowerTerm = term.toLowerCase(); - const highlights = new Set(); + const lowerTerm = term.toLowerCase(); + const highlights = new Set(); - filteredData.forEach((row, rowIndex) => { - visibleColumns.forEach((col, colIndex) => { - const value = row[col.columnName]; - if (value !== null && value !== undefined) { - const strValue = String(value).toLowerCase(); - if (strValue.includes(lowerTerm)) { - highlights.add(`${rowIndex}-${colIndex}`); + filteredData.forEach((row, rowIndex) => { + visibleColumns.forEach((col, colIndex) => { + const value = row[col.columnName]; + if (value !== null && value !== undefined) { + const strValue = String(value).toLowerCase(); + if (strValue.includes(lowerTerm)) { + highlights.add(`${rowIndex}-${colIndex}`); + } } - } + }); }); - }); - setSearchHighlights(highlights); - - // 첫 번째 검색 결과로 포커스 이동 - if (highlights.size > 0) { - const firstHighlight = Array.from(highlights)[0]; - const [rowIdx, colIdx] = firstHighlight.split("-").map(Number); - setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx }); - toast.success(`${highlights.size}개 검색 결과`); - } else { - toast.info("검색 결과가 없습니다"); - } - }, [filteredData, visibleColumns]); + setSearchHighlights(highlights); + + // 첫 번째 검색 결과로 포커스 이동 + if (highlights.size > 0) { + const firstHighlight = Array.from(highlights)[0]; + const [rowIdx, colIdx] = firstHighlight.split("-").map(Number); + setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx }); + toast.success(`${highlights.size}개 검색 결과`); + } else { + toast.info("검색 결과가 없습니다"); + } + }, + [filteredData, visibleColumns], + ); // 🆕 Search Panel: 다음 검색 결과로 이동 const goToNextSearchResult = useCallback(() => { @@ -3120,8 +3177,8 @@ export const TableListComponent: React.FC = ({ }, ], } - : group - ) + : group, + ), ); }, []); @@ -3134,8 +3191,8 @@ export const TableListComponent: React.FC = ({ ...group, conditions: group.conditions.filter((c) => c.id !== conditionId), } - : group - ) + : group, + ), ); }, []); @@ -3147,15 +3204,13 @@ export const TableListComponent: React.FC = ({ group.id === groupId ? { ...group, - conditions: group.conditions.map((c) => - c.id === conditionId ? { ...c, [field]: value } : c - ), + conditions: group.conditions.map((c) => (c.id === conditionId ? { ...c, [field]: value } : c)), } - : group - ) + : group, + ), ); }, - [] + [], ); // 🆕 Filter Builder: 그룹 추가 @@ -3184,9 +3239,7 @@ export const TableListComponent: React.FC = ({ // 🆕 Filter Builder: 그룹 로직 변경 const updateGroupLogic = useCallback((groupId: string, logic: "AND" | "OR") => { - setFilterGroups((prev) => - prev.map((group) => (group.id === groupId ? { ...group, logic } : group)) - ); + setFilterGroups((prev) => prev.map((group) => (group.id === groupId ? { ...group, logic } : group))); }, []); // 🆕 Filter Builder: 필터 적용 @@ -3255,7 +3308,7 @@ export const TableListComponent: React.FC = ({ // 모든 그룹이 AND로 연결됨 (그룹 간) return filterGroups.every((group) => { const validConditions = group.conditions.filter( - (c) => c.column && (c.operator === "isEmpty" || c.operator === "isNotEmpty" || c.value) + (c) => c.column && (c.operator === "isEmpty" || c.operator === "isNotEmpty" || c.value), ); if (validConditions.length === 0) return true; @@ -3266,29 +3319,35 @@ export const TableListComponent: React.FC = ({ } }); }, - [filterGroups, evaluateCondition] + [filterGroups, evaluateCondition], ); // 🆕 컬럼 드래그 시작 - const handleColumnDragStart = useCallback((e: React.DragEvent, index: number) => { - if (!isColumnDragEnabled) return; - - setDraggedColumnIndex(index); - e.dataTransfer.effectAllowed = "move"; - e.dataTransfer.setData("text/plain", `col-${index}`); - }, [isColumnDragEnabled]); + const handleColumnDragStart = useCallback( + (e: React.DragEvent, index: number) => { + if (!isColumnDragEnabled) return; + + setDraggedColumnIndex(index); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", `col-${index}`); + }, + [isColumnDragEnabled], + ); // 🆕 컬럼 드래그 오버 - const handleColumnDragOver = useCallback((e: React.DragEvent, index: number) => { - if (!isColumnDragEnabled || draggedColumnIndex === null) return; - - e.preventDefault(); - e.dataTransfer.dropEffect = "move"; - - if (index !== draggedColumnIndex) { - setDropTargetColumnIndex(index); - } - }, [isColumnDragEnabled, draggedColumnIndex]); + const handleColumnDragOver = useCallback( + (e: React.DragEvent, index: number) => { + if (!isColumnDragEnabled || draggedColumnIndex === null) return; + + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + + if (index !== draggedColumnIndex) { + setDropTargetColumnIndex(index); + } + }, + [isColumnDragEnabled, draggedColumnIndex], + ); // 🆕 컬럼 드래그 종료 const handleColumnDragEnd = useCallback(() => { @@ -3297,55 +3356,64 @@ export const TableListComponent: React.FC = ({ }, []); // 🆕 컬럼 드롭 - const handleColumnDrop = useCallback((e: React.DragEvent, targetIndex: number) => { - e.preventDefault(); - - if (!isColumnDragEnabled || draggedColumnIndex === null || draggedColumnIndex === targetIndex) { + const handleColumnDrop = useCallback( + (e: React.DragEvent, targetIndex: number) => { + e.preventDefault(); + + if (!isColumnDragEnabled || draggedColumnIndex === null || draggedColumnIndex === targetIndex) { + handleColumnDragEnd(); + return; + } + + // 컬럼 순서 변경 + const newOrder = [...(columnOrder.length > 0 ? columnOrder : visibleColumns.map((c) => c.columnName))]; + const [movedColumn] = newOrder.splice(draggedColumnIndex, 1); + newOrder.splice(targetIndex, 0, movedColumn); + + setColumnOrder(newOrder); + toast.info("컬럼 순서가 변경되었습니다."); + console.log("✅ 컬럼 순서 변경:", { from: draggedColumnIndex, to: targetIndex }); + handleColumnDragEnd(); - return; - } - - // 컬럼 순서 변경 - const newOrder = [...(columnOrder.length > 0 ? columnOrder : visibleColumns.map((c) => c.columnName))]; - const [movedColumn] = newOrder.splice(draggedColumnIndex, 1); - newOrder.splice(targetIndex, 0, movedColumn); - - setColumnOrder(newOrder); - toast.info("컬럼 순서가 변경되었습니다."); - console.log("✅ 컬럼 순서 변경:", { from: draggedColumnIndex, to: targetIndex }); - - handleColumnDragEnd(); - }, [isColumnDragEnabled, draggedColumnIndex, columnOrder, visibleColumns, handleColumnDragEnd]); + }, + [isColumnDragEnabled, draggedColumnIndex, columnOrder, visibleColumns, handleColumnDragEnd], + ); // 🆕 행 드래그 시작 - const handleRowDragStart = useCallback((e: React.DragEvent, index: number) => { - if (!isDragEnabled) return; - - setDraggedRowIndex(index); - e.dataTransfer.effectAllowed = "move"; - e.dataTransfer.setData("text/plain", String(index)); - - // 드래그 이미지 설정 (반투명) - const dragImage = e.currentTarget.cloneNode(true) as HTMLElement; - dragImage.style.opacity = "0.5"; - dragImage.style.position = "absolute"; - dragImage.style.top = "-1000px"; - document.body.appendChild(dragImage); - e.dataTransfer.setDragImage(dragImage, 0, 0); - setTimeout(() => document.body.removeChild(dragImage), 0); - }, [isDragEnabled]); + const handleRowDragStart = useCallback( + (e: React.DragEvent, index: number) => { + if (!isDragEnabled) return; + + setDraggedRowIndex(index); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", String(index)); + + // 드래그 이미지 설정 (반투명) + const dragImage = e.currentTarget.cloneNode(true) as HTMLElement; + dragImage.style.opacity = "0.5"; + dragImage.style.position = "absolute"; + dragImage.style.top = "-1000px"; + document.body.appendChild(dragImage); + e.dataTransfer.setDragImage(dragImage, 0, 0); + setTimeout(() => document.body.removeChild(dragImage), 0); + }, + [isDragEnabled], + ); // 🆕 행 드래그 오버 - const handleRowDragOver = useCallback((e: React.DragEvent, index: number) => { - if (!isDragEnabled || draggedRowIndex === null) return; - - e.preventDefault(); - e.dataTransfer.dropEffect = "move"; - - if (index !== draggedRowIndex) { - setDropTargetIndex(index); - } - }, [isDragEnabled, draggedRowIndex]); + const handleRowDragOver = useCallback( + (e: React.DragEvent, index: number) => { + if (!isDragEnabled || draggedRowIndex === null) return; + + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + + if (index !== draggedRowIndex) { + setDropTargetIndex(index); + } + }, + [isDragEnabled, draggedRowIndex], + ); // 🆕 행 드래그 종료 const handleRowDragEnd = useCallback(() => { @@ -3354,84 +3422,84 @@ export const TableListComponent: React.FC = ({ }, []); // 🆕 행 드롭 - const handleRowDrop = useCallback(async (e: React.DragEvent, targetIndex: number) => { - e.preventDefault(); - - if (!isDragEnabled || draggedRowIndex === null || draggedRowIndex === targetIndex) { - handleRowDragEnd(); - return; - } + const handleRowDrop = useCallback( + async (e: React.DragEvent, targetIndex: number) => { + e.preventDefault(); - try { - // 로컬 데이터 재정렬 - const newData = [...filteredData]; - const [movedRow] = newData.splice(draggedRowIndex, 1); - newData.splice(targetIndex, 0, movedRow); - - // 서버에 순서 저장 (order_index 필드가 있는 경우) - const orderField = (tableConfig as any).orderField || "order_index"; - const hasOrderField = newData[0] && orderField in newData[0]; - - if (hasOrderField && tableConfig.selectedTable) { - const { apiClient } = await import("@/lib/api/client"); - const primaryKeyField = tableConfig.primaryKey || "id"; - - // 영향받는 행들의 순서 업데이트 - const updates = newData.map((row, idx) => ({ - tableName: tableConfig.selectedTable, - keyField: primaryKeyField, - keyValue: row[primaryKeyField], - updateField: orderField, - updateValue: idx + 1, - })); - - // 배치 업데이트 - await Promise.all( - updates.map((update) => - apiClient.put(`/dynamic-form/update-field`, update) - ) - ); - - toast.success("순서가 변경되었습니다."); - setRefreshTrigger((prev) => prev + 1); - } else { - // 로컬에서만 순서 변경 (저장 안함) - toast.info("순서가 변경되었습니다. (로컬만)"); - } - - console.log("✅ 행 순서 변경:", { from: draggedRowIndex, to: targetIndex }); - } catch (error) { - console.error("❌ 행 순서 변경 실패:", error); - toast.error("순서 변경 중 오류가 발생했습니다."); - } - - handleRowDragEnd(); - }, [isDragEnabled, draggedRowIndex, filteredData, tableConfig, handleRowDragEnd]); - - // 🆕 PDF 내보내기 (인쇄용 HTML 생성) - const exportToPdf = useCallback((exportAll: boolean = true) => { - try { - // 내보낼 데이터 선택 - let exportData: any[]; - if (exportAll) { - exportData = filteredData; - } else { - exportData = filteredData.filter((row, index) => { - const rowKey = getRowKey(row, index); - return selectedRows.has(rowKey); - }); - } - - if (exportData.length === 0) { - toast.error(exportAll ? "내보낼 데이터가 없습니다." : "선택된 행이 없습니다."); + if (!isDragEnabled || draggedRowIndex === null || draggedRowIndex === targetIndex) { + handleRowDragEnd(); return; } - // 컬럼 정보 가져오기 (체크박스 제외) - const exportColumns = visibleColumns.filter((col) => col.columnName !== "__checkbox__"); - - // 인쇄용 HTML 생성 - const printContent = ` + try { + // 로컬 데이터 재정렬 + const newData = [...filteredData]; + const [movedRow] = newData.splice(draggedRowIndex, 1); + newData.splice(targetIndex, 0, movedRow); + + // 서버에 순서 저장 (order_index 필드가 있는 경우) + const orderField = (tableConfig as any).orderField || "order_index"; + const hasOrderField = newData[0] && orderField in newData[0]; + + if (hasOrderField && tableConfig.selectedTable) { + const { apiClient } = await import("@/lib/api/client"); + const primaryKeyField = tableConfig.primaryKey || "id"; + + // 영향받는 행들의 순서 업데이트 + const updates = newData.map((row, idx) => ({ + tableName: tableConfig.selectedTable, + keyField: primaryKeyField, + keyValue: row[primaryKeyField], + updateField: orderField, + updateValue: idx + 1, + })); + + // 배치 업데이트 + await Promise.all(updates.map((update) => apiClient.put(`/dynamic-form/update-field`, update))); + + toast.success("순서가 변경되었습니다."); + setRefreshTrigger((prev) => prev + 1); + } else { + // 로컬에서만 순서 변경 (저장 안함) + toast.info("순서가 변경되었습니다. (로컬만)"); + } + + console.log("✅ 행 순서 변경:", { from: draggedRowIndex, to: targetIndex }); + } catch (error) { + console.error("❌ 행 순서 변경 실패:", error); + toast.error("순서 변경 중 오류가 발생했습니다."); + } + + handleRowDragEnd(); + }, + [isDragEnabled, draggedRowIndex, filteredData, tableConfig, handleRowDragEnd], + ); + + // 🆕 PDF 내보내기 (인쇄용 HTML 생성) + const exportToPdf = useCallback( + (exportAll: boolean = true) => { + try { + // 내보낼 데이터 선택 + let exportData: any[]; + if (exportAll) { + exportData = filteredData; + } else { + exportData = filteredData.filter((row, index) => { + const rowKey = getRowKey(row, index); + return selectedRows.has(rowKey); + }); + } + + if (exportData.length === 0) { + toast.error(exportAll ? "내보낼 데이터가 없습니다." : "선택된 행이 없습니다."); + return; + } + + // 컬럼 정보 가져오기 (체크박스 제외) + const exportColumns = visibleColumns.filter((col) => col.columnName !== "__checkbox__"); + + // 인쇄용 HTML 생성 + const printContent = ` @@ -3467,68 +3535,90 @@ export const TableListComponent: React.FC = ({ - ${exportData.map((row) => ` + ${exportData + .map( + (row) => ` - ${exportColumns.map((col) => { - const mappedColumnName = joinColumnMapping[col.columnName] || col.columnName; - let value = row[mappedColumnName]; - - // 카테고리 매핑 - if (categoryMappings[col.columnName] && value !== null && value !== undefined) { - const mapping = categoryMappings[col.columnName][String(value)]; - if (mapping) value = mapping.label; - } - - const meta = columnMeta[col.columnName]; - const inputType = meta?.inputType || (col as any).inputType; - const isNumeric = inputType === "number" || inputType === "decimal"; - - return `${value ?? ""}`; - }).join("")} + ${exportColumns + .map((col) => { + const mappedColumnName = joinColumnMapping[col.columnName] || col.columnName; + let value = row[mappedColumnName]; + + // 카테고리 매핑 + if (categoryMappings[col.columnName] && value !== null && value !== undefined) { + const mapping = categoryMappings[col.columnName][String(value)]; + if (mapping) value = mapping.label; + } + + const meta = columnMeta[col.columnName]; + const inputType = meta?.inputType || (col as any).inputType; + const isNumeric = inputType === "number" || inputType === "decimal"; + + return `${value ?? ""}`; + }) + .join("")} - `).join("")} + `, + ) + .join("")} `; - // 새 창에서 인쇄 - const printWindow = window.open("", "_blank"); - if (printWindow) { - printWindow.document.write(printContent); - printWindow.document.close(); - printWindow.onload = () => { - printWindow.print(); - }; - toast.success("인쇄 창이 열렸습니다."); - } else { - toast.error("팝업이 차단되었습니다. 팝업을 허용해주세요."); + // 새 창에서 인쇄 + const printWindow = window.open("", "_blank"); + if (printWindow) { + printWindow.document.write(printContent); + printWindow.document.close(); + printWindow.onload = () => { + printWindow.print(); + }; + toast.success("인쇄 창이 열렸습니다."); + } else { + toast.error("팝업이 차단되었습니다. 팝업을 허용해주세요."); + } + } catch (error) { + console.error("❌ PDF 내보내기 실패:", error); + toast.error("PDF 내보내기 중 오류가 발생했습니다."); } - } catch (error) { - console.error("❌ PDF 내보내기 실패:", error); - toast.error("PDF 내보내기 중 오류가 발생했습니다."); - } - }, [filteredData, selectedRows, visibleColumns, columnLabels, joinColumnMapping, categoryMappings, columnMeta, tableLabel, tableConfig.selectedTable, getRowKey]); + }, + [ + filteredData, + selectedRows, + visibleColumns, + columnLabels, + joinColumnMapping, + categoryMappings, + columnMeta, + tableLabel, + tableConfig.selectedTable, + getRowKey, + ], + ); // 🆕 편집 중 키보드 핸들러 (간단 버전 - Tab 이동은 visibleColumns 정의 후 처리) - const handleEditKeyDown = useCallback((e: React.KeyboardEvent) => { - switch (e.key) { - case "Enter": - e.preventDefault(); - saveEditing(); - break; - case "Escape": - e.preventDefault(); - cancelEditing(); - break; - case "Tab": - e.preventDefault(); - saveEditing(); - // Tab 이동은 편집 저장 후 테이블 키보드 핸들러에서 처리 - break; - } - }, [saveEditing, cancelEditing]); + const handleEditKeyDown = useCallback( + (e: React.KeyboardEvent) => { + switch (e.key) { + case "Enter": + e.preventDefault(); + saveEditing(); + break; + case "Escape": + e.preventDefault(); + cancelEditing(); + break; + case "Tab": + e.preventDefault(); + saveEditing(); + // Tab 이동은 편집 저장 후 테이블 키보드 핸들러에서 처리 + break; + } + }, + [saveEditing, cancelEditing], + ); // 🆕 편집 입력 필드가 나타나면 자동 포커스 useEffect(() => { @@ -3545,9 +3635,9 @@ export const TableListComponent: React.FC = ({ useEffect(() => { if (focusedCell && tableContainerRef.current) { const focusedCellElement = tableContainerRef.current.querySelector( - `[data-row="${focusedCell.rowIndex}"][data-col="${focusedCell.colIndex}"]` + `[data-row="${focusedCell.rowIndex}"][data-col="${focusedCell.colIndex}"]`, ) as HTMLElement; - + if (focusedCellElement) { focusedCellElement.scrollIntoView({ block: "nearest", inline: "nearest" }); } @@ -3636,239 +3726,242 @@ export const TableListComponent: React.FC = ({ }, [visibleColumns.length, visibleColumns.map((c) => c.columnName).join(",")]); // 의존성 단순화 // 🆕 키보드 네비게이션 핸들러 (visibleColumns 정의 후에 배치) - const handleTableKeyDown = useCallback((e: React.KeyboardEvent) => { - // 편집 중일 때는 테이블 키보드 핸들러 무시 (편집 입력에서 처리) - if (editingCell) return; - - if (!focusedCell || data.length === 0) return; + const handleTableKeyDown = useCallback( + (e: React.KeyboardEvent) => { + // 편집 중일 때는 테이블 키보드 핸들러 무시 (편집 입력에서 처리) + if (editingCell) return; - const { rowIndex, colIndex } = focusedCell; - const maxRowIndex = data.length - 1; - const maxColIndex = visibleColumns.length - 1; + if (!focusedCell || data.length === 0) return; - switch (e.key) { - case "ArrowUp": - e.preventDefault(); - if (rowIndex > 0) { - setFocusedCell({ rowIndex: rowIndex - 1, colIndex }); - } - break; - case "ArrowDown": - e.preventDefault(); - if (rowIndex < maxRowIndex) { - setFocusedCell({ rowIndex: rowIndex + 1, colIndex }); - } - break; - case "ArrowLeft": - e.preventDefault(); - if (colIndex > 0) { - setFocusedCell({ rowIndex, colIndex: colIndex - 1 }); - } - break; - case "ArrowRight": - e.preventDefault(); - if (colIndex < maxColIndex) { - setFocusedCell({ rowIndex, colIndex: colIndex + 1 }); - } - break; - case "Enter": - e.preventDefault(); - // 현재 행 선택/해제 - const enterRow = data[rowIndex]; - if (enterRow) { - const rowKey = getRowKey(enterRow, rowIndex); - const isCurrentlySelected = selectedRows.has(rowKey); - handleRowSelection(rowKey, !isCurrentlySelected); - } - break; - case " ": // Space - e.preventDefault(); - // 체크박스 토글 - const spaceRow = data[rowIndex]; - if (spaceRow) { - const currentRowKey = getRowKey(spaceRow, rowIndex); - const isChecked = selectedRows.has(currentRowKey); - handleRowSelection(currentRowKey, !isChecked); - } - break; - case "F2": - // 🆕 F2: 편집 모드 진입 - e.preventDefault(); - { - const col = visibleColumns[colIndex]; - if (col && col.columnName !== "__checkbox__") { - // 🆕 편집 불가 컬럼 체크 - if (col.editable === false) { - toast.warning(`'${col.displayName || col.columnName}' 컬럼은 편집할 수 없습니다.`); - break; - } - const row = data[rowIndex]; - const mappedCol = joinColumnMapping[col.columnName] || col.columnName; - const val = row?.[mappedCol]; - setEditingCell({ - rowIndex, - colIndex, - columnName: col.columnName, - originalValue: val - }); - setEditingValue(val !== null && val !== undefined ? String(val) : ""); + const { rowIndex, colIndex } = focusedCell; + const maxRowIndex = data.length - 1; + const maxColIndex = visibleColumns.length - 1; + + switch (e.key) { + case "ArrowUp": + e.preventDefault(); + if (rowIndex > 0) { + setFocusedCell({ rowIndex: rowIndex - 1, colIndex }); } - } - break; - case "b": - case "B": - // 🆕 Ctrl+B: 배치 편집 모드 토글 - if (e.ctrlKey) { + break; + case "ArrowDown": e.preventDefault(); - setEditMode((prev) => { - const newMode = prev === "immediate" ? "batch" : "immediate"; - if (newMode === "immediate" && pendingChanges.size > 0) { - // 즉시 모드로 전환 시 저장되지 않은 변경사항 경고 - const confirmDiscard = window.confirm( - `저장되지 않은 ${pendingChanges.size}개의 변경사항이 있습니다. 취소하시겠습니까?` - ); - if (confirmDiscard) { - setPendingChanges(new Map()); - setLocalEditedData({}); - toast.info("배치 편집 모드 종료"); - return "immediate"; - } - return "batch"; - } - toast.info(newMode === "batch" ? "배치 편집 모드 시작 (Ctrl+B로 종료)" : "즉시 저장 모드"); - return newMode; - }); - } - break; - case "s": - case "S": - // 🆕 Ctrl+S: 배치 저장 - if (e.ctrlKey && editMode === "batch") { + if (rowIndex < maxRowIndex) { + setFocusedCell({ rowIndex: rowIndex + 1, colIndex }); + } + break; + case "ArrowLeft": e.preventDefault(); - saveBatchChanges(); - } - break; - case "c": - case "C": - // 🆕 Ctrl+C: 선택된 행/셀 복사 - if (e.ctrlKey) { - e.preventDefault(); - handleCopy(); - } - break; - case "v": - case "V": - // 🆕 Ctrl+V: 붙여넣기 (편집 중인 경우만) - if (e.ctrlKey && editingCell) { - // 기본 동작 허용 (input에서 처리) - } - break; - case "a": - case "A": - // 🆕 Ctrl+A: 전체 선택 - if (e.ctrlKey) { - e.preventDefault(); - handleSelectAllRows(); - } - break; - case "f": - case "F": - // 🆕 Ctrl+F: 통합 검색 패널 열기 - if (e.ctrlKey) { - e.preventDefault(); - setIsSearchPanelOpen(true); - } - break; - case "F3": - // 🆕 F3: 다음 검색 결과 / Shift+F3: 이전 검색 결과 - e.preventDefault(); - if (e.shiftKey) { - goToPrevSearchResult(); - } else { - goToNextSearchResult(); - } - break; - case "Home": - e.preventDefault(); - if (e.ctrlKey) { - // Ctrl+Home: 첫 번째 셀로 - setFocusedCell({ rowIndex: 0, colIndex: 0 }); - } else { - // Home: 현재 행의 첫 번째 셀로 - setFocusedCell({ rowIndex, colIndex: 0 }); - } - break; - case "End": - e.preventDefault(); - if (e.ctrlKey) { - // Ctrl+End: 마지막 셀로 - setFocusedCell({ rowIndex: maxRowIndex, colIndex: maxColIndex }); - } else { - // End: 현재 행의 마지막 셀로 - setFocusedCell({ rowIndex, colIndex: maxColIndex }); - } - break; - case "PageUp": - e.preventDefault(); - // 10행 위로 - setFocusedCell({ rowIndex: Math.max(0, rowIndex - 10), colIndex }); - break; - case "PageDown": - e.preventDefault(); - // 10행 아래로 - setFocusedCell({ rowIndex: Math.min(maxRowIndex, rowIndex + 10), colIndex }); - break; - case "Escape": - e.preventDefault(); - // 포커스 해제 - setFocusedCell(null); - break; - case "Tab": - e.preventDefault(); - if (e.shiftKey) { - // Shift+Tab: 이전 셀 if (colIndex > 0) { setFocusedCell({ rowIndex, colIndex: colIndex - 1 }); - } else if (rowIndex > 0) { - setFocusedCell({ rowIndex: rowIndex - 1, colIndex: maxColIndex }); } - } else { - // Tab: 다음 셀 + break; + case "ArrowRight": + e.preventDefault(); if (colIndex < maxColIndex) { setFocusedCell({ rowIndex, colIndex: colIndex + 1 }); - } else if (rowIndex < maxRowIndex) { - setFocusedCell({ rowIndex: rowIndex + 1, colIndex: 0 }); } - } - break; - default: - // 🆕 직접 타이핑으로 편집 모드 진입 (영문자, 숫자, 한글 등) - if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) { - const column = visibleColumns[colIndex]; - if (column && column.columnName !== "__checkbox__") { - // 🆕 편집 불가 컬럼 체크 - if (column.editable === false) { - toast.warning(`'${column.displayName || column.columnName}' 컬럼은 편집할 수 없습니다.`); - break; + break; + case "Enter": + e.preventDefault(); + // 현재 행 선택/해제 + const enterRow = data[rowIndex]; + if (enterRow) { + const rowKey = getRowKey(enterRow, rowIndex); + const isCurrentlySelected = selectedRows.has(rowKey); + handleRowSelection(rowKey, !isCurrentlySelected); + } + break; + case " ": // Space + e.preventDefault(); + // 체크박스 토글 + const spaceRow = data[rowIndex]; + if (spaceRow) { + const currentRowKey = getRowKey(spaceRow, rowIndex); + const isChecked = selectedRows.has(currentRowKey); + handleRowSelection(currentRowKey, !isChecked); + } + break; + case "F2": + // 🆕 F2: 편집 모드 진입 + e.preventDefault(); + { + const col = visibleColumns[colIndex]; + if (col && col.columnName !== "__checkbox__") { + // 🆕 편집 불가 컬럼 체크 + if (col.editable === false) { + toast.warning(`'${col.displayName || col.columnName}' 컬럼은 편집할 수 없습니다.`); + break; + } + const row = data[rowIndex]; + const mappedCol = joinColumnMapping[col.columnName] || col.columnName; + const val = row?.[mappedCol]; + setEditingCell({ + rowIndex, + colIndex, + columnName: col.columnName, + originalValue: val, + }); + setEditingValue(val !== null && val !== undefined ? String(val) : ""); } - e.preventDefault(); - // 편집 시작 (현재 키를 초기값으로) - const row = data[rowIndex]; - const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; - const value = row?.[mappedColumnName]; - - setEditingCell({ - rowIndex, - colIndex, - columnName: column.columnName, - originalValue: value - }); - setEditingValue(e.key); // 입력한 키로 시작 } - } - break; - } - }, [editingCell, focusedCell, data, visibleColumns, joinColumnMapping, selectedRows, getRowKey, handleRowSelection]); + break; + case "b": + case "B": + // 🆕 Ctrl+B: 배치 편집 모드 토글 + if (e.ctrlKey) { + e.preventDefault(); + setEditMode((prev) => { + const newMode = prev === "immediate" ? "batch" : "immediate"; + if (newMode === "immediate" && pendingChanges.size > 0) { + // 즉시 모드로 전환 시 저장되지 않은 변경사항 경고 + const confirmDiscard = window.confirm( + `저장되지 않은 ${pendingChanges.size}개의 변경사항이 있습니다. 취소하시겠습니까?`, + ); + if (confirmDiscard) { + setPendingChanges(new Map()); + setLocalEditedData({}); + toast.info("배치 편집 모드 종료"); + return "immediate"; + } + return "batch"; + } + toast.info(newMode === "batch" ? "배치 편집 모드 시작 (Ctrl+B로 종료)" : "즉시 저장 모드"); + return newMode; + }); + } + break; + case "s": + case "S": + // 🆕 Ctrl+S: 배치 저장 + if (e.ctrlKey && editMode === "batch") { + e.preventDefault(); + saveBatchChanges(); + } + break; + case "c": + case "C": + // 🆕 Ctrl+C: 선택된 행/셀 복사 + if (e.ctrlKey) { + e.preventDefault(); + handleCopy(); + } + break; + case "v": + case "V": + // 🆕 Ctrl+V: 붙여넣기 (편집 중인 경우만) + if (e.ctrlKey && editingCell) { + // 기본 동작 허용 (input에서 처리) + } + break; + case "a": + case "A": + // 🆕 Ctrl+A: 전체 선택 + if (e.ctrlKey) { + e.preventDefault(); + handleSelectAllRows(); + } + break; + case "f": + case "F": + // 🆕 Ctrl+F: 통합 검색 패널 열기 + if (e.ctrlKey) { + e.preventDefault(); + setIsSearchPanelOpen(true); + } + break; + case "F3": + // 🆕 F3: 다음 검색 결과 / Shift+F3: 이전 검색 결과 + e.preventDefault(); + if (e.shiftKey) { + goToPrevSearchResult(); + } else { + goToNextSearchResult(); + } + break; + case "Home": + e.preventDefault(); + if (e.ctrlKey) { + // Ctrl+Home: 첫 번째 셀로 + setFocusedCell({ rowIndex: 0, colIndex: 0 }); + } else { + // Home: 현재 행의 첫 번째 셀로 + setFocusedCell({ rowIndex, colIndex: 0 }); + } + break; + case "End": + e.preventDefault(); + if (e.ctrlKey) { + // Ctrl+End: 마지막 셀로 + setFocusedCell({ rowIndex: maxRowIndex, colIndex: maxColIndex }); + } else { + // End: 현재 행의 마지막 셀로 + setFocusedCell({ rowIndex, colIndex: maxColIndex }); + } + break; + case "PageUp": + e.preventDefault(); + // 10행 위로 + setFocusedCell({ rowIndex: Math.max(0, rowIndex - 10), colIndex }); + break; + case "PageDown": + e.preventDefault(); + // 10행 아래로 + setFocusedCell({ rowIndex: Math.min(maxRowIndex, rowIndex + 10), colIndex }); + break; + case "Escape": + e.preventDefault(); + // 포커스 해제 + setFocusedCell(null); + break; + case "Tab": + e.preventDefault(); + if (e.shiftKey) { + // Shift+Tab: 이전 셀 + if (colIndex > 0) { + setFocusedCell({ rowIndex, colIndex: colIndex - 1 }); + } else if (rowIndex > 0) { + setFocusedCell({ rowIndex: rowIndex - 1, colIndex: maxColIndex }); + } + } else { + // Tab: 다음 셀 + if (colIndex < maxColIndex) { + setFocusedCell({ rowIndex, colIndex: colIndex + 1 }); + } else if (rowIndex < maxRowIndex) { + setFocusedCell({ rowIndex: rowIndex + 1, colIndex: 0 }); + } + } + break; + default: + // 🆕 직접 타이핑으로 편집 모드 진입 (영문자, 숫자, 한글 등) + if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) { + const column = visibleColumns[colIndex]; + if (column && column.columnName !== "__checkbox__") { + // 🆕 편집 불가 컬럼 체크 + if (column.editable === false) { + toast.warning(`'${column.displayName || column.columnName}' 컬럼은 편집할 수 없습니다.`); + break; + } + e.preventDefault(); + // 편집 시작 (현재 키를 초기값으로) + const row = data[rowIndex]; + const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; + const value = row?.[mappedColumnName]; + + setEditingCell({ + rowIndex, + colIndex, + columnName: column.columnName, + originalValue: value, + }); + setEditingValue(e.key); // 입력한 키로 시작 + } + } + break; + } + }, + [editingCell, focusedCell, data, visibleColumns, joinColumnMapping, selectedRows, getRowKey, handleRowSelection], + ); const getColumnWidth = (column: ColumnConfig) => { if (column.columnName === "__checkbox__") return 50; @@ -3911,7 +4004,8 @@ export const TableListComponent: React.FC = ({ // 🎯 엔티티 컬럼 표시 설정이 있는 경우 - value가 null이어도 rowData에서 조합 가능 // 이 체크를 가장 먼저 수행 (null 체크보다 앞에) if (column.entityDisplayConfig && rowData) { - const displayColumns = column.entityDisplayConfig.displayColumns || (column.entityDisplayConfig as any).selectedColumns; + const displayColumns = + column.entityDisplayConfig.displayColumns || (column.entityDisplayConfig as any).selectedColumns; const separator = column.entityDisplayConfig.separator; if (displayColumns && displayColumns.length > 0) { @@ -3920,13 +4014,13 @@ export const TableListComponent: React.FC = ({ .map((colName: string) => { // 1. 먼저 직접 컬럼명으로 시도 (기본 테이블 컬럼인 경우) let cellValue = rowData[colName]; - + // 2. 없으면 ${sourceColumn}_${colName} 형식으로 시도 (조인 테이블 컬럼인 경우) if (cellValue === null || cellValue === undefined) { const joinedKey = `${column.columnName}_${colName}`; cellValue = rowData[joinedKey]; } - + if (cellValue === null || cellValue === undefined) return ""; return String(cellValue); }) @@ -3978,20 +4072,23 @@ export const TableListComponent: React.FC = ({ // 1. 원래 컬럼명 (item_info.material) // 2. 점(.) 뒤의 컬럼명만 (material) let mapping = categoryMappings[column.columnName]; - + if (!mapping && column.columnName.includes(".")) { const simpleColumnName = column.columnName.split(".").pop(); if (simpleColumnName) { mapping = categoryMappings[simpleColumnName]; } } - + const { Badge } = require("@/components/ui/badge"); // 다중 값 처리: 콤마로 구분된 값들을 분리 const valueStr = String(value); - const values = valueStr.includes(",") - ? valueStr.split(",").map(v => v.trim()).filter(v => v) + const values = valueStr.includes(",") + ? valueStr + .split(",") + .map((v) => v.trim()) + .filter((v) => v) : [valueStr]; // 단일 값인 경우 (기존 로직) @@ -4073,8 +4170,8 @@ export const TableListComponent: React.FC = ({ try { const date = new Date(value); const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); return `${year}-${month}-${day}`; } catch { return String(value); @@ -4116,8 +4213,8 @@ export const TableListComponent: React.FC = ({ try { const date = new Date(value); const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); return `${year}-${month}-${day}`; } catch { return value; @@ -4149,7 +4246,7 @@ export const TableListComponent: React.FC = ({ // 필터 설정 localStorage 키 생성 (화면별로 독립적) const filterSettingKey = useMemo(() => { if (!tableConfig.selectedTable) return null; - return screenId + return screenId ? `tableList_filterSettings_${tableConfig.selectedTable}_screen_${screenId}` : `tableList_filterSettings_${tableConfig.selectedTable}`; }, [tableConfig.selectedTable, screenId]); @@ -4157,7 +4254,7 @@ export const TableListComponent: React.FC = ({ // 그룹 설정 localStorage 키 생성 (화면별로 독립적) const groupSettingKey = useMemo(() => { if (!tableConfig.selectedTable) return null; - return screenId + return screenId ? `tableList_groupSettings_${tableConfig.selectedTable}_screen_${screenId}` : `tableList_groupSettings_${tableConfig.selectedTable}`; }, [tableConfig.selectedTable, screenId]); @@ -4338,15 +4435,15 @@ export const TableListComponent: React.FC = ({ // 카테고리/엔티티 타입인 경우 _name 필드 사용 const inputType = columnMeta?.[col]?.inputType; let displayValue = item[col]; - - if (inputType === 'category' || inputType === 'entity' || inputType === 'code') { + + if (inputType === "category" || inputType === "entity" || inputType === "code") { // _name 필드가 있으면 사용 (예: division_name, writer_name) const nameField = `${col}_name`; if (item[nameField] !== undefined && item[nameField] !== null) { displayValue = item[nameField]; } } - + const label = columnLabels[col] || col; return `${label}:${displayValue !== null && displayValue !== undefined ? displayValue : "-"}`; }); @@ -4366,20 +4463,18 @@ export const TableListComponent: React.FC = ({ // 🆕 그룹별 소계 계산 const groupSummary: Record = {}; - + // 숫자형 컬럼에 대해 소계 계산 (tableConfig.columns || []).forEach((col: { columnName: string }) => { if (col.columnName === "__checkbox__") return; - + const colMeta = columnMeta?.[col.columnName]; const inputType = colMeta?.inputType; const isNumeric = inputType === "number" || inputType === "decimal"; - + if (isNumeric) { - const values = items - .map((item) => parseFloat(item[col.columnName])) - .filter((v) => !isNaN(v)); - + const values = items.map((item) => parseFloat(item[col.columnName])).filter((v) => !isNaN(v)); + if (values.length > 0) { const sum = values.reduce((a, b) => a + b, 0); groupSummary[col.columnName] = { @@ -4401,6 +4496,114 @@ export const TableListComponent: React.FC = ({ }); }, [data, groupByColumns, columnLabels, columnMeta, tableConfig.columns]); + // 🆕 그룹별 합산된 데이터 계산 (FilterPanel에서 설정한 경우) + const summedData = useMemo(() => { + // 그룹핑이 비활성화되었거나 그룹 기준 컬럼이 없으면 원본 데이터 반환 + if (!groupSumConfig?.enabled || !groupSumConfig?.groupByColumn) { + return filteredData; + } + + console.log("🔍 [테이블리스트] 그룹합산 적용:", groupSumConfig); + + const groupByColumn = groupSumConfig.groupByColumn; + const groupMap = new Map(); + + // 조인 컬럼인지 확인하고 실제 키 추론 + const getActualKey = (columnName: string, item: any): string => { + if (columnName.includes(".")) { + const [refTable, fieldName] = columnName.split("."); + const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id"); + const exactKey = `${inferredSourceColumn}_${fieldName}`; + if (item[exactKey] !== undefined) return exactKey; + if (fieldName === "item_name" || fieldName === "name") { + const aliasKey = `${inferredSourceColumn}_name`; + if (item[aliasKey] !== undefined) return aliasKey; + } + } + return columnName; + }; + + // 숫자 타입인지 확인하는 함수 + const isNumericValue = (value: any): boolean => { + if (value === null || value === undefined || value === "") return false; + const num = parseFloat(String(value)); + return !isNaN(num) && isFinite(num); + }; + + // 그룹핑 수행 + filteredData.forEach((item) => { + const actualKey = getActualKey(groupByColumn, item); + const groupValue = String(item[actualKey] || item[groupByColumn] || ""); + + if (!groupMap.has(groupValue)) { + // 첫 번째 항목을 기준으로 초기화 + groupMap.set(groupValue, { ...item, _groupCount: 1 }); + } else { + const existing = groupMap.get(groupValue); + existing._groupCount += 1; + + // 모든 키에 대해 숫자면 합산 + Object.keys(item).forEach((key) => { + const value = item[key]; + if (isNumericValue(value) && key !== groupByColumn && !key.endsWith("_id") && !key.includes("code")) { + const numValue = parseFloat(String(value)); + const existingValue = parseFloat(String(existing[key] || 0)); + existing[key] = existingValue + numValue; + } + }); + + groupMap.set(groupValue, existing); + } + }); + + const result = Array.from(groupMap.values()); + console.log("🔗 [테이블리스트] 그룹별 합산 결과:", { + 원본개수: filteredData.length, + 그룹개수: result.length, + 그룹기준: groupByColumn, + }); + + return result; + }, [filteredData, groupSumConfig]); + + // 🆕 표시할 데이터: 합산 모드면 summedData, 아니면 filteredData + const displayData = useMemo(() => { + return groupSumConfig?.enabled ? summedData : filteredData; + }, [groupSumConfig?.enabled, summedData, filteredData]); + + // 🆕 Virtual Scrolling: 보이는 행 범위 계산 (displayData 정의 이후에 위치) + const virtualScrollInfo = useMemo(() => { + const dataSource = displayData; + if (!isVirtualScrollEnabled || dataSource.length === 0) { + return { + startIndex: 0, + endIndex: dataSource.length, + visibleData: dataSource, + topSpacerHeight: 0, + bottomSpacerHeight: 0, + totalHeight: dataSource.length * ROW_HEIGHT, + }; + } + + const containerHeight = scrollContainerRef.current?.clientHeight || 600; + const totalRows = dataSource.length; + const totalHeight = totalRows * ROW_HEIGHT; + + // 현재 보이는 행 범위 계산 + const startIndex = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN); + const visibleRowCount = Math.ceil(containerHeight / ROW_HEIGHT) + OVERSCAN * 2; + const endIndex = Math.min(totalRows, startIndex + visibleRowCount); + + return { + startIndex, + endIndex, + visibleData: dataSource.slice(startIndex, endIndex), + topSpacerHeight: startIndex * ROW_HEIGHT, + bottomSpacerHeight: (totalRows - endIndex) * ROW_HEIGHT, + totalHeight, + }; + }, [isVirtualScrollEnabled, displayData, scrollTop, ROW_HEIGHT, OVERSCAN]); + // 저장된 그룹 설정 불러오기 useEffect(() => { if (!groupSettingKey || visibleColumns.length === 0) return; @@ -4429,7 +4632,7 @@ export const TableListComponent: React.FC = ({ // sortColumn, // sortDirection, // }); - + if (!isDesignMode && tableConfig.selectedTable) { fetchTableDataDebounced(); } @@ -4475,43 +4678,45 @@ export const TableListComponent: React.FC = ({ }, [tableConfig.selectedTable, isDesignMode]); // 🎯 컬럼 너비 자동 계산 (내용 기반) - const calculateOptimalColumnWidth = useCallback((columnName: string, displayName: string): number => { - // 기본 너비 설정 - const MIN_WIDTH = 100; - const MAX_WIDTH = 400; - const PADDING = 48; // 좌우 패딩 + 여유 공간 - const HEADER_PADDING = 60; // 헤더 추가 여유 (정렬 아이콘 등) + const calculateOptimalColumnWidth = useCallback( + (columnName: string, displayName: string): number => { + // 기본 너비 설정 + const MIN_WIDTH = 100; + const MAX_WIDTH = 400; + const PADDING = 48; // 좌우 패딩 + 여유 공간 + const HEADER_PADDING = 60; // 헤더 추가 여유 (정렬 아이콘 등) - // 헤더 텍스트 너비 계산 (대략 8px per character) - const headerWidth = (displayName?.length || columnName.length) * 10 + HEADER_PADDING; + // 헤더 텍스트 너비 계산 (대략 8px per character) + const headerWidth = (displayName?.length || columnName.length) * 10 + HEADER_PADDING; - // 데이터 셀 너비 계산 (상위 50개 샘플링) - const sampleSize = Math.min(50, data.length); - let maxDataWidth = headerWidth; + // 데이터 셀 너비 계산 (상위 50개 샘플링) + const sampleSize = Math.min(50, data.length); + let maxDataWidth = headerWidth; - for (let i = 0; i < sampleSize; i++) { - const cellValue = data[i]?.[columnName]; - if (cellValue !== null && cellValue !== undefined) { - const cellText = String(cellValue); - // 숫자는 좁게, 텍스트는 넓게 계산 - const isNumber = !isNaN(Number(cellValue)) && cellValue !== ""; - const charWidth = isNumber ? 8 : 9; - const cellWidth = cellText.length * charWidth + PADDING; - maxDataWidth = Math.max(maxDataWidth, cellWidth); + for (let i = 0; i < sampleSize; i++) { + const cellValue = data[i]?.[columnName]; + if (cellValue !== null && cellValue !== undefined) { + const cellText = String(cellValue); + // 숫자는 좁게, 텍스트는 넓게 계산 + const isNumber = !isNaN(Number(cellValue)) && cellValue !== ""; + const charWidth = isNumber ? 8 : 9; + const cellWidth = cellText.length * charWidth + PADDING; + maxDataWidth = Math.max(maxDataWidth, cellWidth); + } } - } - // 최소/최대 범위 내로 제한 - return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, Math.ceil(maxDataWidth))); - }, [data]); + // 최소/최대 범위 내로 제한 + return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, Math.ceil(maxDataWidth))); + }, + [data], + ); // 🎯 localStorage에서 컬럼 너비 불러오기 및 초기 계산 useEffect(() => { if (!hasInitializedWidths.current && visibleColumns.length > 0 && data.length > 0) { const timer = setTimeout(() => { - const storageKey = tableConfig.selectedTable && userId - ? `table_column_widths_${tableConfig.selectedTable}_${userId}` - : null; + const storageKey = + tableConfig.selectedTable && userId ? `table_column_widths_${tableConfig.selectedTable}_${userId}` : null; // 1. localStorage에서 저장된 너비 불러오기 let savedWidths: Record = {}; @@ -4541,8 +4746,8 @@ export const TableListComponent: React.FC = ({ } else { // 저장된 너비가 없으면 자동 계산 const optimalWidth = calculateOptimalColumnWidth( - column.columnName, - columnLabels[column.columnName] || column.displayName + column.columnName, + columnLabels[column.columnName] || column.displayName, ); newWidths[column.columnName] = optimalWidth; hasAnyWidth = true; @@ -4618,24 +4823,14 @@ export const TableListComponent: React.FC = ({ {/* 🆕 내보내기 버튼 (Excel/PDF) */} -
Excel
- @@ -4651,12 +4846,7 @@ export const TableListComponent: React.FC = ({
PDF/인쇄
- @@ -4689,7 +4879,18 @@ export const TableListComponent: React.FC = ({
); - }, [tableConfig.pagination, tableConfig.toolbar?.showPaginationRefresh, isDesignMode, currentPage, totalPages, totalItems, loading, selectedRows.size, exportToExcel, exportToPdf]); + }, [ + tableConfig.pagination, + tableConfig.toolbar?.showPaginationRefresh, + isDesignMode, + currentPage, + totalPages, + totalItems, + loading, + selectedRows.size, + exportToExcel, + exportToPdf, + ]); // ======================================== // 렌더링 @@ -4793,7 +4994,7 @@ export const TableListComponent: React.FC = ({
{/* 편집 모드 토글 */} {(tableConfig.toolbar?.showEditMode ?? true) && ( -
+
{activeFilterCount > 0 && ( @@ -5001,15 +5198,13 @@ export const TableListComponent: React.FC = ({ {/* 🆕 배치 편집 툴바 */} {(editMode === "batch" || pendingChanges.size > 0) && ( -
+
- + 배치 편집 모드 {pendingChanges.size > 0 && ( - - {pendingChanges.size}개 변경사항 - + {pendingChanges.size}개 변경사항 )}
@@ -5065,7 +5260,7 @@ export const TableListComponent: React.FC = ({
= ({
= ({ {visibleColumns.map((column, colIdx) => { // 이 컬럼이 속한 band 찾기 const band = columnBandsInfo.bands.find( - (b) => b.columns.includes(column.columnName) && b.startIndex === colIdx + (b) => b.columns.includes(column.columnName) && b.startIndex === colIdx, ); - + // band의 첫 번째 컬럼인 경우에만 렌더링 if (band) { return ( {band.caption} ); } - + // band에 속하지 않은 컬럼 (개별 표시) - const isInAnyBand = columnBandsInfo.bands.some( - (b) => b.columns.includes(column.columnName) - ); + const isInAnyBand = columnBandsInfo.bands.some((b) => b.columns.includes(column.columnName)); if (!isInAnyBand) { return ( {columnLabels[column.columnName] || column.columnName} ); } - + // band의 중간 컬럼은 렌더링하지 않음 return null; })} @@ -5176,7 +5369,7 @@ export const TableListComponent: React.FC = ({ // 🆕 Column Reordering 상태 const isColumnDragging = draggedColumnIndex === columnIndex; const isColumnDropTarget = dropTargetColumnIndex === columnIndex; - + return ( = ({ "hover:bg-muted/70 cursor-pointer transition-colors", isFrozen && "sticky z-60 shadow-[2px_0_4px_rgba(0,0,0,0.1)]", // 🆕 Column Reordering 스타일 - isColumnDragEnabled && column.columnName !== "__checkbox__" && "cursor-grab active:cursor-grabbing", - isColumnDragging && "opacity-50 bg-primary/20", - isColumnDropTarget && "border-l-4 border-l-primary", + isColumnDragEnabled && + column.columnName !== "__checkbox__" && + "cursor-grab active:cursor-grabbing", + isColumnDragging && "bg-primary/20 opacity-50", + isColumnDropTarget && "border-l-primary border-l-4", )} style={{ textAlign: column.columnName === "__checkbox__" ? "center" : "center", @@ -5227,7 +5422,7 @@ export const TableListComponent: React.FC = ({ {/* 🆕 편집 불가 컬럼 표시 */} {column.editable === false && ( - + )} {columnLabels[column.columnName] || column.displayName} @@ -5235,75 +5430,82 @@ export const TableListComponent: React.FC = ({ {sortDirection === "asc" ? "↑" : "↓"} )} {/* 🆕 헤더 필터 버튼 */} - {tableConfig.headerFilter !== false && columnUniqueValues[column.columnName]?.length > 0 && ( - setOpenFilterColumn(open ? column.columnName : null)} - > - - - - e.stopPropagation()} + {tableConfig.headerFilter !== false && + columnUniqueValues[column.columnName]?.length > 0 && ( + setOpenFilterColumn(open ? column.columnName : null)} > -
-
- 필터: {columnLabels[column.columnName] || column.displayName} - {headerFilters[column.columnName]?.size > 0 && ( - - )} -
-
- {columnUniqueValues[column.columnName]?.slice(0, 50).map((val) => { - const isSelected = headerFilters[column.columnName]?.has(val); - return ( -
toggleHeaderFilter(column.columnName, val)} - > -
- {isSelected && } -
- {val || "(빈 값)"} -
+ + + + e.stopPropagation()} + > +
+
+ + 필터: {columnLabels[column.columnName] || column.displayName} + + {headerFilters[column.columnName]?.size > 0 && ( + + )} +
+
+ {columnUniqueValues[column.columnName]?.slice(0, 50).map((val) => { + const isSelected = headerFilters[column.columnName]?.has(val); + return ( +
toggleHeaderFilter(column.columnName, val)} + > +
+ {isSelected && } +
+ {val || "(빈 값)"} +
+ ); + })} + {(columnUniqueValues[column.columnName]?.length || 0) > 50 && ( +
+ ...외 {(columnUniqueValues[column.columnName]?.length || 0) - 50}개 +
+ )} +
-
- - - )} + + + )}
)} {/* 리사이즈 핸들 (체크박스 제외) */} @@ -5346,7 +5548,7 @@ export const TableListComponent: React.FC = ({ const finalWidth = Math.max(80, thElement.offsetWidth); setColumnWidths((prev) => { const newWidths = { ...prev, [column.columnName]: finalWidth }; - + // 🎯 localStorage에 컬럼 너비 저장 (사용자별) if (tableConfig.selectedTable && userId) { const storageKey = `table_column_widths_${tableConfig.selectedTable}_${userId}`; @@ -5356,7 +5558,7 @@ export const TableListComponent: React.FC = ({ console.error("컬럼 너비 저장 실패:", error); } } - + return newWidths; }); } @@ -5479,8 +5681,10 @@ export const TableListComponent: React.FC = ({ = ({ ))} {/* 🆕 그룹별 소계 행 */} {!isCollapsed && group.summary && Object.keys(group.summary).length > 0 && ( - + {visibleColumns.map((column, colIndex) => { const summary = group.summary?.[column.columnName]; const meta = columnMeta[column.columnName]; const inputType = meta?.inputType || (column as any).inputType; const isNumeric = inputType === "number" || inputType === "decimal"; - + if (colIndex === 0 && column.columnName === "__checkbox__") { return ( @@ -5523,15 +5727,17 @@ export const TableListComponent: React.FC = ({ ); } - + if (colIndex === 0 && column.columnName !== "__checkbox__") { return ( - 소계 ({group.count}건) + + 소계 ({group.count}건) + ); } - + if (summary) { return ( = ({ ); } - + return ; })} @@ -5560,193 +5766,208 @@ export const TableListComponent: React.FC = ({ )} - {/* 데이터 행 렌더링 */} - {(isVirtualScrollEnabled ? virtualScrollInfo.visibleData : filteredData).map((row, idx) => { + {/* 데이터 행 렌더링 - 🆕 합산 모드면 displayData 사용 */} + {(isVirtualScrollEnabled ? virtualScrollInfo.visibleData : displayData).map((row, idx) => { // Virtual Scrolling에서는 실제 인덱스 계산 const index = isVirtualScrollEnabled ? virtualScrollInfo.startIndex + idx : idx; const rowKey = getRowKey(row, index); const isRowSelected = selectedRows.has(rowKey); const isRowFocused = focusedCell?.rowIndex === index; - - // 🆕 Drag & Drop 상태 - const isDragging = draggedRowIndex === index; - const isDropTarget = dropTargetIndex === index; - - return ( - handleRowClick(row, index, e)} - role="row" - aria-selected={isRowSelected} - // 🆕 Drag & Drop 이벤트 - draggable={isDragEnabled} - onDragStart={(e) => handleRowDragStart(e, index)} - onDragOver={(e) => handleRowDragOver(e, index)} - onDragEnd={handleRowDragEnd} - onDrop={(e) => handleRowDrop(e, index)} - > - {visibleColumns.map((column, colIndex) => { - const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; - // 🆕 배치 편집: 로컬 수정 데이터 우선 표시 - const cellValue = editMode === "batch" - ? getDisplayValue(row, index, mappedColumnName) - : row[mappedColumnName]; - const meta = columnMeta[column.columnName]; - const inputType = meta?.inputType || column.inputType; - const isNumeric = inputType === "number" || inputType === "decimal"; + // 🆕 Drag & Drop 상태 + const isDragging = draggedRowIndex === index; + const isDropTarget = dropTargetIndex === index; - const isFrozen = frozenColumns.includes(column.columnName); - const frozenIndex = frozenColumns.indexOf(column.columnName); - - // 셀 포커스 상태 - const isCellFocused = focusedCell?.rowIndex === index && focusedCell?.colIndex === colIndex; - - // 🆕 배치 편집: 수정된 셀 여부 - const isModified = isCellModified(index, mappedColumnName); - - // 🆕 유효성 검사 에러 - const cellValidationError = getCellValidationError(index, mappedColumnName); - - // 🆕 검색 하이라이트 여부 - const isSearchHighlighted = searchHighlights.has(`${index}-${colIndex}`); + return ( + handleRowClick(row, index, e)} + role="row" + aria-selected={isRowSelected} + // 🆕 Drag & Drop 이벤트 + draggable={isDragEnabled} + onDragStart={(e) => handleRowDragStart(e, index)} + onDragOver={(e) => handleRowDragOver(e, index)} + onDragEnd={handleRowDragEnd} + onDrop={(e) => handleRowDrop(e, index)} + > + {visibleColumns.map((column, colIndex) => { + const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; + // 🆕 배치 편집: 로컬 수정 데이터 우선 표시 + const cellValue = + editMode === "batch" + ? getDisplayValue(row, index, mappedColumnName) + : row[mappedColumnName]; - // 틀고정된 컬럼의 left 위치 계산 - let leftPosition = 0; - if (isFrozen && frozenIndex > 0) { - for (let i = 0; i < frozenIndex; i++) { - const frozenCol = frozenColumns[i]; - const frozenColWidth = columnWidths[frozenCol] || 150; - leftPosition += frozenColWidth; + const meta = columnMeta[column.columnName]; + const inputType = meta?.inputType || column.inputType; + const isNumeric = inputType === "number" || inputType === "decimal"; + + const isFrozen = frozenColumns.includes(column.columnName); + const frozenIndex = frozenColumns.indexOf(column.columnName); + + // 셀 포커스 상태 + const isCellFocused = focusedCell?.rowIndex === index && focusedCell?.colIndex === colIndex; + + // 🆕 배치 편집: 수정된 셀 여부 + const isModified = isCellModified(index, mappedColumnName); + + // 🆕 유효성 검사 에러 + const cellValidationError = getCellValidationError(index, mappedColumnName); + + // 🆕 검색 하이라이트 여부 + const isSearchHighlighted = searchHighlights.has(`${index}-${colIndex}`); + + // 틀고정된 컬럼의 left 위치 계산 + let leftPosition = 0; + if (isFrozen && frozenIndex > 0) { + for (let i = 0; i < frozenIndex; i++) { + const frozenCol = frozenColumns[i]; + const frozenColWidth = columnWidths[frozenCol] || 150; + leftPosition += frozenColWidth; + } } - } - return ( - handleCellClick(index, colIndex, e)} - onDoubleClick={() => handleCellDoubleClick(index, colIndex, column.columnName, cellValue)} - onContextMenu={(e) => handleContextMenu(e, index, colIndex, row)} - role="gridcell" - tabIndex={isCellFocused ? 0 : -1} - > - {/* 🆕 인라인 편집 모드 */} - {editingCell?.rowIndex === index && editingCell?.colIndex === colIndex ? ( - // 🆕 Cascading Lookups: 드롭다운 또는 일반 입력 - (() => { - const cascadingConfig = (tableConfig as any).cascadingLookups?.[column.columnName]; - const options = cascadingConfig ? getCascadingOptions(column.columnName, row) : []; - - // 부모 값이 변경되면 옵션 로딩 - if (cascadingConfig && options.length === 0) { - const parentValue = row[cascadingConfig.parentColumn]; - if (parentValue !== undefined && parentValue !== null) { - loadCascadingOptions(column.columnName, cascadingConfig.parentColumn, parentValue); - } - } - - // 카테고리/코드 타입이거나 Cascading Lookup인 경우 드롭다운 - const colMeta = columnMeta[column.columnName]; - const isCategoryType = colMeta?.inputType === "category" || colMeta?.inputType === "code"; - const hasCategoryOptions = categoryMappings[column.columnName] && Object.keys(categoryMappings[column.columnName]).length > 0; - - if (cascadingConfig || (isCategoryType && hasCategoryOptions)) { - const selectOptions = cascadingConfig - ? options - : Object.entries(categoryMappings[column.columnName] || {}).map(([value, info]) => ({ - value, - label: info.label, - })); - - return ( - - ); - } - - // 일반 입력 필드 - return ( - setEditingValue(e.target.value)} - onKeyDown={handleEditKeyDown} - onBlur={saveEditing} - className="w-full h-full px-2 py-1 sm:px-4 sm:py-1.5 text-xs sm:text-sm border-2 border-primary bg-background focus:outline-none" - style={{ - textAlign: isNumeric ? "right" : column.align || "left", - }} - /> - ); - })() - ) : column.columnName === "__checkbox__" ? ( - renderCheckboxCell(row, index) - ) : ( - formatCellValue(cellValue, column, row) - )} - - ); - })} - - ); - })} + return ( + handleCellClick(index, colIndex, e)} + onDoubleClick={() => + handleCellDoubleClick(index, colIndex, column.columnName, cellValue) + } + onContextMenu={(e) => handleContextMenu(e, index, colIndex, row)} + role="gridcell" + tabIndex={isCellFocused ? 0 : -1} + > + {/* 🆕 인라인 편집 모드 */} + {editingCell?.rowIndex === index && editingCell?.colIndex === colIndex + ? // 🆕 Cascading Lookups: 드롭다운 또는 일반 입력 + (() => { + const cascadingConfig = (tableConfig as any).cascadingLookups?.[ + column.columnName + ]; + const options = cascadingConfig + ? getCascadingOptions(column.columnName, row) + : []; + + // 부모 값이 변경되면 옵션 로딩 + if (cascadingConfig && options.length === 0) { + const parentValue = row[cascadingConfig.parentColumn]; + if (parentValue !== undefined && parentValue !== null) { + loadCascadingOptions( + column.columnName, + cascadingConfig.parentColumn, + parentValue, + ); + } + } + + // 카테고리/코드 타입이거나 Cascading Lookup인 경우 드롭다운 + const colMeta = columnMeta[column.columnName]; + const isCategoryType = + colMeta?.inputType === "category" || colMeta?.inputType === "code"; + const hasCategoryOptions = + categoryMappings[column.columnName] && + Object.keys(categoryMappings[column.columnName]).length > 0; + + if (cascadingConfig || (isCategoryType && hasCategoryOptions)) { + const selectOptions = cascadingConfig + ? options + : Object.entries(categoryMappings[column.columnName] || {}).map( + ([value, info]) => ({ + value, + label: info.label, + }), + ); + + return ( + + ); + } + + // 일반 입력 필드 + return ( + setEditingValue(e.target.value)} + onKeyDown={handleEditKeyDown} + onBlur={saveEditing} + className="border-primary bg-background h-full w-full border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm" + style={{ + textAlign: isNumeric ? "right" : column.align || "left", + }} + /> + ); + })() + : column.columnName === "__checkbox__" + ? renderCheckboxCell(row, index) + : formatCellValue(cellValue, column, row)} + + ); + })} + + ); + })} {/* 🆕 Virtual Scrolling: Bottom Spacer */} {isVirtualScrollEnabled && virtualScrollInfo.bottomSpacerHeight > 0 && ( @@ -5759,7 +5980,7 @@ export const TableListComponent: React.FC = ({ {/* 🆕 데이터 요약 (Total Summaries) */} {summaryData && Object.keys(summaryData).length > 0 && ( - + {visibleColumns.map((column, colIndex) => { const summary = summaryData[column.columnName]; @@ -5932,24 +6153,20 @@ export const TableListComponent: React.FC = ({ closeContextMenu(); }} > - - 셀 복사 + 셀 복사 {/* 행 복사 */}
@@ -5961,9 +6178,7 @@ export const TableListComponent: React.FC = ({ return ( ); })()} @@ -6041,8 +6255,7 @@ export const TableListComponent: React.FC = ({ closeContextMenu(); }} > - - 행 삭제 + 행 삭제
@@ -6190,10 +6403,7 @@ export const TableListComponent: React.FC = ({ > 취소 - From 1d97bcaa9f9c0e88ad633246c9e7ccd94705258e Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 11 Dec 2025 14:34:42 +0900 Subject: [PATCH 09/30] =?UTF-8?q?=EB=B0=B0=ED=8F=AC=20=EC=98=A4=EB=A5=98?= =?UTF-8?q?=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cascadingAutoFillController.ts | 74 ++++++++++++++----- .../cascadingConditionController.ts | 67 +++++++++++++---- .../cascadingHierarchyController.ts | 44 ++++++++--- .../cascadingMutualExclusionController.ts | 60 +++++++++++---- .../cascadingRelationController.ts | 40 +++++++--- .../src/services/nodeFlowExecutionService.ts | 6 +- 6 files changed, 220 insertions(+), 71 deletions(-) diff --git a/backend-node/src/controllers/cascadingAutoFillController.ts b/backend-node/src/controllers/cascadingAutoFillController.ts index bf033880..4a2fa61f 100644 --- a/backend-node/src/controllers/cascadingAutoFillController.ts +++ b/backend-node/src/controllers/cascadingAutoFillController.ts @@ -3,7 +3,8 @@ * 마스터 선택 시 여러 필드 자동 입력 기능 */ -import { Request, Response } from "express"; +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; import { query, queryOne } from "../database/db"; import logger from "../utils/logger"; @@ -14,7 +15,10 @@ import logger from "../utils/logger"; /** * 자동 입력 그룹 목록 조회 */ -export const getAutoFillGroups = async (req: Request, res: Response) => { +export const getAutoFillGroups = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const companyCode = req.user?.companyCode || "*"; const { isActive } = req.query; @@ -47,7 +51,10 @@ export const getAutoFillGroups = async (req: Request, res: Response) => { const result = await query(sql, params); - logger.info("자동 입력 그룹 목록 조회", { count: result.length, companyCode }); + logger.info("자동 입력 그룹 목록 조회", { + count: result.length, + companyCode, + }); res.json({ success: true, @@ -66,7 +73,10 @@ export const getAutoFillGroups = async (req: Request, res: Response) => { /** * 자동 입력 그룹 상세 조회 (매핑 포함) */ -export const getAutoFillGroupDetail = async (req: Request, res: Response) => { +export const getAutoFillGroupDetail = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { groupCode } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -98,7 +108,10 @@ export const getAutoFillGroupDetail = async (req: Request, res: Response) => { WHERE group_code = $1 AND company_code = $2 ORDER BY sort_order, mapping_id `; - const mappingResult = await query(mappingSql, [groupCode, groupResult.company_code]); + const mappingResult = await query(mappingSql, [ + groupCode, + groupResult.company_code, + ]); logger.info("자동 입력 그룹 상세 조회", { groupCode, companyCode }); @@ -122,7 +135,9 @@ export const getAutoFillGroupDetail = async (req: Request, res: Response) => { /** * 그룹 코드 자동 생성 함수 */ -const generateAutoFillGroupCode = async (companyCode: string): Promise => { +const generateAutoFillGroupCode = async ( + companyCode: string +): Promise => { const prefix = "AF"; const result = await queryOne( `SELECT COUNT(*) as cnt FROM cascading_auto_fill_group WHERE company_code = $1`, @@ -136,7 +151,10 @@ const generateAutoFillGroupCode = async (companyCode: string): Promise = /** * 자동 입력 그룹 생성 */ -export const createAutoFillGroup = async (req: Request, res: Response) => { +export const createAutoFillGroup = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const companyCode = req.user?.companyCode || "*"; const userId = req.user?.userId || "system"; @@ -153,7 +171,8 @@ export const createAutoFillGroup = async (req: Request, res: Response) => { if (!groupName || !masterTable || !masterValueColumn) { return res.status(400).json({ success: false, - message: "필수 필드가 누락되었습니다. (groupName, masterTable, masterValueColumn)", + message: + "필수 필드가 누락되었습니다. (groupName, masterTable, masterValueColumn)", }); } @@ -224,7 +243,10 @@ export const createAutoFillGroup = async (req: Request, res: Response) => { /** * 자동 입력 그룹 수정 */ -export const updateAutoFillGroup = async (req: Request, res: Response) => { +export const updateAutoFillGroup = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { groupCode } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -333,7 +355,10 @@ export const updateAutoFillGroup = async (req: Request, res: Response) => { /** * 자동 입력 그룹 삭제 */ -export const deleteAutoFillGroup = async (req: Request, res: Response) => { +export const deleteAutoFillGroup = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { groupCode } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -382,7 +407,10 @@ export const deleteAutoFillGroup = async (req: Request, res: Response) => { * 마스터 옵션 목록 조회 * 자동 입력 그룹의 마스터 테이블에서 선택 가능한 옵션 목록 */ -export const getAutoFillMasterOptions = async (req: Request, res: Response) => { +export const getAutoFillMasterOptions = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { groupCode } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -436,7 +464,10 @@ export const getAutoFillMasterOptions = async (req: Request, res: Response) => { const optionsResult = await query(optionsSql, optionsParams); - logger.info("자동 입력 마스터 옵션 조회", { groupCode, count: optionsResult.length }); + logger.info("자동 입력 마스터 옵션 조회", { + groupCode, + count: optionsResult.length, + }); res.json({ success: true, @@ -456,7 +487,10 @@ export const getAutoFillMasterOptions = async (req: Request, res: Response) => { * 자동 입력 데이터 조회 * 마스터 값 선택 시 자동으로 입력할 데이터 조회 */ -export const getAutoFillData = async (req: Request, res: Response) => { +export const getAutoFillData = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { groupCode } = req.params; const { masterValue } = req.query; @@ -535,9 +569,10 @@ export const getAutoFillData = async (req: Request, res: Response) => { for (const mapping of mappings) { const sourceValue = dataResult?.[mapping.source_column]; - const finalValue = sourceValue !== null && sourceValue !== undefined - ? sourceValue - : mapping.default_value; + const finalValue = + sourceValue !== null && sourceValue !== undefined + ? sourceValue + : mapping.default_value; autoFillData[mapping.target_field] = finalValue; mappingInfo.push({ @@ -549,7 +584,11 @@ export const getAutoFillData = async (req: Request, res: Response) => { }); } - logger.info("자동 입력 데이터 조회", { groupCode, masterValue, fieldCount: mappingInfo.length }); + logger.info("자동 입력 데이터 조회", { + groupCode, + masterValue, + fieldCount: mappingInfo.length, + }); res.json({ success: true, @@ -565,4 +604,3 @@ export const getAutoFillData = async (req: Request, res: Response) => { }); } }; - diff --git a/backend-node/src/controllers/cascadingConditionController.ts b/backend-node/src/controllers/cascadingConditionController.ts index cf30a725..6cc89319 100644 --- a/backend-node/src/controllers/cascadingConditionController.ts +++ b/backend-node/src/controllers/cascadingConditionController.ts @@ -3,7 +3,8 @@ * 특정 필드 값에 따라 드롭다운 옵션을 필터링하는 기능 */ -import { Request, Response } from "express"; +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; import { query, queryOne } from "../database/db"; import logger from "../utils/logger"; @@ -14,7 +15,10 @@ import logger from "../utils/logger"; /** * 조건부 연쇄 규칙 목록 조회 */ -export const getConditions = async (req: Request, res: Response) => { +export const getConditions = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const companyCode = req.user?.companyCode || "*"; const { isActive, relationCode, relationType } = req.query; @@ -54,7 +58,10 @@ export const getConditions = async (req: Request, res: Response) => { const result = await query(sql, params); - logger.info("조건부 연쇄 규칙 목록 조회", { count: result.length, companyCode }); + logger.info("조건부 연쇄 규칙 목록 조회", { + count: result.length, + companyCode, + }); res.json({ success: true, @@ -62,7 +69,7 @@ export const getConditions = async (req: Request, res: Response) => { }); } catch (error: any) { console.error("조건부 연쇄 규칙 목록 조회 실패:", error); - logger.error("조건부 연쇄 규칙 목록 조회 실패", { + logger.error("조건부 연쇄 규칙 목록 조회 실패", { error: error.message, stack: error.stack, }); @@ -77,7 +84,10 @@ export const getConditions = async (req: Request, res: Response) => { /** * 조건부 연쇄 규칙 상세 조회 */ -export const getConditionDetail = async (req: Request, res: Response) => { +export const getConditionDetail = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { conditionId } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -118,7 +128,10 @@ export const getConditionDetail = async (req: Request, res: Response) => { /** * 조건부 연쇄 규칙 생성 */ -export const createCondition = async (req: Request, res: Response) => { +export const createCondition = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const companyCode = req.user?.companyCode || "*"; const { @@ -134,10 +147,18 @@ export const createCondition = async (req: Request, res: Response) => { } = req.body; // 필수 필드 검증 - if (!relationCode || !conditionName || !conditionField || !conditionValue || !filterColumn || !filterValues) { + if ( + !relationCode || + !conditionName || + !conditionField || + !conditionValue || + !filterColumn || + !filterValues + ) { return res.status(400).json({ success: false, - message: "필수 필드가 누락되었습니다. (relationCode, conditionName, conditionField, conditionValue, filterColumn, filterValues)", + message: + "필수 필드가 누락되었습니다. (relationCode, conditionName, conditionField, conditionValue, filterColumn, filterValues)", }); } @@ -164,7 +185,11 @@ export const createCondition = async (req: Request, res: Response) => { companyCode, ]); - logger.info("조건부 연쇄 규칙 생성", { conditionId: result?.condition_id, relationCode, companyCode }); + logger.info("조건부 연쇄 규칙 생성", { + conditionId: result?.condition_id, + relationCode, + companyCode, + }); res.status(201).json({ success: true, @@ -184,7 +209,10 @@ export const createCondition = async (req: Request, res: Response) => { /** * 조건부 연쇄 규칙 수정 */ -export const updateCondition = async (req: Request, res: Response) => { +export const updateCondition = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { conditionId } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -264,7 +292,10 @@ export const updateCondition = async (req: Request, res: Response) => { /** * 조건부 연쇄 규칙 삭제 */ -export const deleteCondition = async (req: Request, res: Response) => { +export const deleteCondition = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { conditionId } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -312,7 +343,10 @@ export const deleteCondition = async (req: Request, res: Response) => { * 조건에 따른 필터링된 옵션 조회 * 특정 관계 코드에 대해 조건 필드 값에 따라 필터링된 옵션 반환 */ -export const getFilteredOptions = async (req: Request, res: Response) => { +export const getFilteredOptions = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { relationCode } = req.params; const { conditionFieldValue, parentValue } = req.query; @@ -390,8 +424,12 @@ export const getFilteredOptions = async (req: Request, res: Response) => { // 조건부 필터 적용 if (matchedCondition) { - const filterValues = matchedCondition.filter_values.split(",").map((v: string) => v.trim()); - const placeholders = filterValues.map((_: any, i: number) => `$${optionsParamIndex + i}`).join(","); + const filterValues = matchedCondition.filter_values + .split(",") + .map((v: string) => v.trim()); + const placeholders = filterValues + .map((_: any, i: number) => `$${optionsParamIndex + i}`) + .join(","); optionsSql += ` AND ${matchedCondition.filter_column} IN (${placeholders})`; optionsParams.push(...filterValues); optionsParamIndex += filterValues.length; @@ -522,4 +560,3 @@ function evaluateCondition( return false; } } - diff --git a/backend-node/src/controllers/cascadingHierarchyController.ts b/backend-node/src/controllers/cascadingHierarchyController.ts index 59d243e2..e57efa09 100644 --- a/backend-node/src/controllers/cascadingHierarchyController.ts +++ b/backend-node/src/controllers/cascadingHierarchyController.ts @@ -3,7 +3,8 @@ * 국가 > 도시 > 구/군 같은 다단계 연쇄 드롭다운 관리 */ -import { Request, Response } from "express"; +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; import { query, queryOne } from "../database/db"; import logger from "../utils/logger"; @@ -14,7 +15,10 @@ import logger from "../utils/logger"; /** * 계층 그룹 목록 조회 */ -export const getHierarchyGroups = async (req: Request, res: Response) => { +export const getHierarchyGroups = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const companyCode = req.user?.companyCode || "*"; const { isActive, hierarchyType } = req.query; @@ -66,7 +70,10 @@ export const getHierarchyGroups = async (req: Request, res: Response) => { /** * 계층 그룹 상세 조회 (레벨 포함) */ -export const getHierarchyGroupDetail = async (req: Request, res: Response) => { +export const getHierarchyGroupDetail = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { groupCode } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -124,7 +131,9 @@ export const getHierarchyGroupDetail = async (req: Request, res: Response) => { /** * 계층 그룹 코드 자동 생성 함수 */ -const generateHierarchyGroupCode = async (companyCode: string): Promise => { +const generateHierarchyGroupCode = async ( + companyCode: string +): Promise => { const prefix = "HG"; const result = await queryOne( `SELECT COUNT(*) as cnt FROM cascading_hierarchy_group WHERE company_code = $1`, @@ -138,7 +147,10 @@ const generateHierarchyGroupCode = async (companyCode: string): Promise /** * 계층 그룹 생성 */ -export const createHierarchyGroup = async (req: Request, res: Response) => { +export const createHierarchyGroup = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const companyCode = req.user?.companyCode || "*"; const userId = req.user?.userId || "system"; @@ -280,7 +292,10 @@ export const createHierarchyGroup = async (req: Request, res: Response) => { /** * 계층 그룹 수정 */ -export const updateHierarchyGroup = async (req: Request, res: Response) => { +export const updateHierarchyGroup = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { groupCode } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -364,7 +379,10 @@ export const updateHierarchyGroup = async (req: Request, res: Response) => { /** * 계층 그룹 삭제 */ -export const deleteHierarchyGroup = async (req: Request, res: Response) => { +export const deleteHierarchyGroup = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { groupCode } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -423,7 +441,7 @@ export const deleteHierarchyGroup = async (req: Request, res: Response) => { /** * 레벨 추가 */ -export const addLevel = async (req: Request, res: Response) => { +export const addLevel = async (req: AuthenticatedRequest, res: Response) => { try { const { groupCode } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -506,7 +524,7 @@ export const addLevel = async (req: Request, res: Response) => { /** * 레벨 수정 */ -export const updateLevel = async (req: Request, res: Response) => { +export const updateLevel = async (req: AuthenticatedRequest, res: Response) => { try { const { levelId } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -600,7 +618,7 @@ export const updateLevel = async (req: Request, res: Response) => { /** * 레벨 삭제 */ -export const deleteLevel = async (req: Request, res: Response) => { +export const deleteLevel = async (req: AuthenticatedRequest, res: Response) => { try { const { levelId } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -647,7 +665,10 @@ export const deleteLevel = async (req: Request, res: Response) => { /** * 특정 레벨의 옵션 조회 */ -export const getLevelOptions = async (req: Request, res: Response) => { +export const getLevelOptions = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { groupCode, levelOrder } = req.params; const { parentValue } = req.query; @@ -749,4 +770,3 @@ export const getLevelOptions = async (req: Request, res: Response) => { }); } }; - diff --git a/backend-node/src/controllers/cascadingMutualExclusionController.ts b/backend-node/src/controllers/cascadingMutualExclusionController.ts index 8714c73b..b1cbeaa6 100644 --- a/backend-node/src/controllers/cascadingMutualExclusionController.ts +++ b/backend-node/src/controllers/cascadingMutualExclusionController.ts @@ -3,7 +3,8 @@ * 두 필드가 같은 값을 선택할 수 없도록 제한하는 기능 */ -import { Request, Response } from "express"; +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; import { query, queryOne } from "../database/db"; import logger from "../utils/logger"; @@ -14,7 +15,10 @@ import logger from "../utils/logger"; /** * 상호 배제 규칙 목록 조회 */ -export const getExclusions = async (req: Request, res: Response) => { +export const getExclusions = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const companyCode = req.user?.companyCode || "*"; const { isActive } = req.query; @@ -42,7 +46,10 @@ export const getExclusions = async (req: Request, res: Response) => { const result = await query(sql, params); - logger.info("상호 배제 규칙 목록 조회", { count: result.length, companyCode }); + logger.info("상호 배제 규칙 목록 조회", { + count: result.length, + companyCode, + }); res.json({ success: true, @@ -61,7 +68,10 @@ export const getExclusions = async (req: Request, res: Response) => { /** * 상호 배제 규칙 상세 조회 */ -export const getExclusionDetail = async (req: Request, res: Response) => { +export const getExclusionDetail = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { exclusionId } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -116,7 +126,10 @@ const generateExclusionCode = async (companyCode: string): Promise => { /** * 상호 배제 규칙 생성 */ -export const createExclusion = async (req: Request, res: Response) => { +export const createExclusion = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const companyCode = req.user?.companyCode || "*"; const { @@ -133,7 +146,8 @@ export const createExclusion = async (req: Request, res: Response) => { if (!exclusionName || !fieldNames || !sourceTable || !valueColumn) { return res.status(400).json({ success: false, - message: "필수 필드가 누락되었습니다. (exclusionName, fieldNames, sourceTable, valueColumn)", + message: + "필수 필드가 누락되었습니다. (exclusionName, fieldNames, sourceTable, valueColumn)", }); } @@ -195,7 +209,10 @@ export const createExclusion = async (req: Request, res: Response) => { /** * 상호 배제 규칙 수정 */ -export const updateExclusion = async (req: Request, res: Response) => { +export const updateExclusion = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { exclusionId } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -274,7 +291,10 @@ export const updateExclusion = async (req: Request, res: Response) => { /** * 상호 배제 규칙 삭제 */ -export const deleteExclusion = async (req: Request, res: Response) => { +export const deleteExclusion = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { exclusionId } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -322,7 +342,10 @@ export const deleteExclusion = async (req: Request, res: Response) => { * 상호 배제 검증 * 선택하려는 값이 다른 필드와 충돌하는지 확인 */ -export const validateExclusion = async (req: Request, res: Response) => { +export const validateExclusion = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { exclusionCode } = req.params; const { fieldValues } = req.body; // { "source_warehouse": "WH001", "target_warehouse": "WH002" } @@ -347,7 +370,9 @@ export const validateExclusion = async (req: Request, res: Response) => { } // 필드명 파싱 - const fields = exclusion.field_names.split(",").map((f: string) => f.trim()); + const fields = exclusion.field_names + .split(",") + .map((f: string) => f.trim()); // 필드 값 수집 const values: string[] = []; @@ -418,7 +443,10 @@ export const validateExclusion = async (req: Request, res: Response) => { * 필드에 대한 배제 옵션 조회 * 다른 필드에서 이미 선택한 값을 제외한 옵션 반환 */ -export const getExcludedOptions = async (req: Request, res: Response) => { +export const getExcludedOptions = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { exclusionCode } = req.params; const { currentField, selectedValues } = req.query; // selectedValues: 이미 선택된 값들 (콤마 구분) @@ -470,9 +498,14 @@ export const getExcludedOptions = async (req: Request, res: Response) => { // 이미 선택된 값 제외 if (selectedValues) { - const excludeValues = (selectedValues as string).split(",").map((v) => v.trim()).filter((v) => v); + const excludeValues = (selectedValues as string) + .split(",") + .map((v) => v.trim()) + .filter((v) => v); if (excludeValues.length > 0) { - const placeholders = excludeValues.map((_, i) => `$${optionsParamIndex + i}`).join(","); + const placeholders = excludeValues + .map((_, i) => `$${optionsParamIndex + i}`) + .join(","); optionsSql += ` AND ${exclusion.value_column} NOT IN (${placeholders})`; optionsParams.push(...excludeValues); } @@ -502,4 +535,3 @@ export const getExcludedOptions = async (req: Request, res: Response) => { }); } }; - diff --git a/backend-node/src/controllers/cascadingRelationController.ts b/backend-node/src/controllers/cascadingRelationController.ts index 3f7b5cb6..27f03c71 100644 --- a/backend-node/src/controllers/cascadingRelationController.ts +++ b/backend-node/src/controllers/cascadingRelationController.ts @@ -1,4 +1,5 @@ -import { Request, Response } from "express"; +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; import { getPool } from "../database/db"; import { logger } from "../utils/logger"; @@ -7,7 +8,10 @@ const pool = getPool(); /** * 연쇄 관계 목록 조회 */ -export const getCascadingRelations = async (req: Request, res: Response) => { +export const getCascadingRelations = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const companyCode = req.user?.companyCode || "*"; const { isActive } = req.query; @@ -86,7 +90,10 @@ export const getCascadingRelations = async (req: Request, res: Response) => { /** * 연쇄 관계 상세 조회 */ -export const getCascadingRelationById = async (req: Request, res: Response) => { +export const getCascadingRelationById = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { id } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -155,7 +162,7 @@ export const getCascadingRelationById = async (req: Request, res: Response) => { * 연쇄 관계 코드로 조회 */ export const getCascadingRelationByCode = async ( - req: Request, + req: AuthenticatedRequest, res: Response ) => { try { @@ -223,7 +230,10 @@ export const getCascadingRelationByCode = async ( /** * 연쇄 관계 생성 */ -export const createCascadingRelation = async (req: Request, res: Response) => { +export const createCascadingRelation = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const companyCode = req.user?.companyCode || "*"; const userId = req.user?.userId || "system"; @@ -350,7 +360,10 @@ export const createCascadingRelation = async (req: Request, res: Response) => { /** * 연쇄 관계 수정 */ -export const updateCascadingRelation = async (req: Request, res: Response) => { +export const updateCascadingRelation = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { id } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -474,7 +487,10 @@ export const updateCascadingRelation = async (req: Request, res: Response) => { /** * 연쇄 관계 삭제 */ -export const deleteCascadingRelation = async (req: Request, res: Response) => { +export const deleteCascadingRelation = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { id } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -536,7 +552,10 @@ export const deleteCascadingRelation = async (req: Request, res: Response) => { * 🆕 연쇄 관계로 부모 옵션 조회 (상위 선택 역할용) * parent_table에서 전체 옵션을 조회합니다. */ -export const getParentOptions = async (req: Request, res: Response) => { +export const getParentOptions = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { code } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -644,7 +663,10 @@ export const getParentOptions = async (req: Request, res: Response) => { * 연쇄 관계로 자식 옵션 조회 * 실제 연쇄 드롭다운에서 사용하는 API */ -export const getCascadingOptions = async (req: Request, res: Response) => { +export const getCascadingOptions = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { code } = req.params; const { parentValue } = req.query; diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index 461cd8d2..6f481198 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -3596,7 +3596,7 @@ export class NodeFlowExecutionService { // 계정 ID 우선순위: nodeAccountId > smtpConfigId > 첫 번째 활성 계정 let accountId = nodeAccountId || smtpConfigId; if (!accountId) { - const accounts = await mailAccountFileService.getAccounts(); + const accounts = await mailAccountFileService.getAllAccounts(); const activeAccount = accounts.find( (acc: any) => acc.status === "active" ); @@ -4216,7 +4216,7 @@ export class NodeFlowExecutionService { return this.evaluateFunction(func, sourceRow, targetRow, resultValues); case "condition": - return this.evaluateCondition( + return this.evaluateCaseCondition( condition, sourceRow, targetRow, @@ -4393,7 +4393,7 @@ export class NodeFlowExecutionService { /** * 조건 평가 (CASE WHEN ... THEN ... ELSE) */ - private static evaluateCondition( + private static evaluateCaseCondition( condition: any, sourceRow: any, targetRow: any, From b03132595c15fddb619e58761510326123893257 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 11 Dec 2025 14:38:57 +0900 Subject: [PATCH 10/30] =?UTF-8?q?=ED=8C=A8=ED=82=A4=EC=A7=80=20=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 2549a177..37ba05be 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -42,9 +42,9 @@ "@react-three/fiber": "^9.4.0", "@tanstack/react-query": "^5.86.0", "@tanstack/react-table": "^8.21.3", - "@tiptap/core": "^3.13.0", + "@tiptap/core": "^2.27.1", "@tiptap/extension-placeholder": "^2.27.1", - "@tiptap/pm": "^2.11.5", + "@tiptap/pm": "^2.27.1", "@tiptap/react": "^2.27.1", "@tiptap/starter-kit": "^2.27.1", "@turf/buffer": "^7.2.0", From ab9ddaa190665f624861f911cc9fed3f2ba5a2f4 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 11 Dec 2025 15:25:48 +0900 Subject: [PATCH 11/30] =?UTF-8?q?=EC=99=B8=EB=B6=80=20DB=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EB=81=8A=EA=B9=80=20=EC=98=A4=EB=A5=98=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/digitalTwinDataController.ts | 140 +++++++++++------- .../externalDbConnectionPoolService.ts | 82 +++++++++- 2 files changed, 159 insertions(+), 63 deletions(-) diff --git a/backend-node/src/controllers/digitalTwinDataController.ts b/backend-node/src/controllers/digitalTwinDataController.ts index 80cb8ccd..e80a44dc 100644 --- a/backend-node/src/controllers/digitalTwinDataController.ts +++ b/backend-node/src/controllers/digitalTwinDataController.ts @@ -1,43 +1,25 @@ import { Request, Response } from "express"; -import { pool, queryOne } from "../database/db"; import logger from "../utils/logger"; -import { PasswordEncryption } from "../utils/passwordEncryption"; -import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory"; +import { ExternalDbConnectionPoolService } from "../services/externalDbConnectionPoolService"; -// 외부 DB 커넥터를 가져오는 헬퍼 함수 +// 외부 DB 커넥터를 가져오는 헬퍼 함수 (연결 풀 사용) export async function getExternalDbConnector(connectionId: number) { - // 외부 DB 연결 정보 조회 - const connection = await queryOne( - `SELECT * FROM external_db_connections WHERE id = $1`, - [connectionId] - ); + const poolService = ExternalDbConnectionPoolService.getInstance(); - if (!connection) { - throw new Error(`외부 DB 연결 정보를 찾을 수 없습니다. ID: ${connectionId}`); - } - - // 패스워드 복호화 - const decryptedPassword = PasswordEncryption.decrypt(connection.password); - - // DB 연결 설정 - const config = { - host: connection.host, - port: connection.port, - user: connection.username, - password: decryptedPassword, - database: connection.database_name, + // 연결 풀 래퍼를 반환 (executeQuery 메서드를 가진 객체) + return { + executeQuery: async (sql: string, params?: any[]) => { + const result = await poolService.executeQuery(connectionId, sql, params); + return { rows: result }; + }, }; - - // DB 커넥터 생성 - return await DatabaseConnectorFactory.createConnector( - connection.db_type || "mariadb", - config, - connectionId - ); } // 동적 계층 구조 데이터 조회 (범용) -export const getHierarchyData = async (req: Request, res: Response): Promise => { +export const getHierarchyData = async ( + req: Request, + res: Response +): Promise => { try { const { externalDbConnectionId, hierarchyConfig } = req.body; @@ -48,7 +30,9 @@ export const getHierarchyData = async (req: Request, res: Response): Promise ({ level: l.level, count: l.data.length })), + levelCounts: result.levels.map((l: any) => ({ + level: l.level, + count: l.data.length, + })), }); return res.json({ @@ -112,22 +99,35 @@ export const getHierarchyData = async (req: Request, res: Response): Promise => { +export const getChildrenData = async ( + req: Request, + res: Response +): Promise => { try { - const { externalDbConnectionId, hierarchyConfig, parentLevel, parentKey } = req.body; + const { externalDbConnectionId, hierarchyConfig, parentLevel, parentKey } = + req.body; - if (!externalDbConnectionId || !hierarchyConfig || !parentLevel || !parentKey) { + if ( + !externalDbConnectionId || + !hierarchyConfig || + !parentLevel || + !parentKey + ) { return res.status(400).json({ success: false, message: "필수 파라미터가 누락되었습니다.", }); } - const connector = await getExternalDbConnector(Number(externalDbConnectionId)); + const connector = await getExternalDbConnector( + Number(externalDbConnectionId) + ); const config = JSON.parse(hierarchyConfig); // 다음 레벨 찾기 - const nextLevel = config.levels?.find((l: any) => l.level === parentLevel + 1); + const nextLevel = config.levels?.find( + (l: any) => l.level === parentLevel + 1 + ); if (!nextLevel) { return res.json({ @@ -168,7 +168,10 @@ export const getChildrenData = async (req: Request, res: Response): Promise => { +export const getWarehouses = async ( + req: Request, + res: Response +): Promise => { try { const { externalDbConnectionId, tableName } = req.query; @@ -186,7 +189,9 @@ export const getWarehouses = async (req: Request, res: Response): Promise => { +export const getAreas = async ( + req: Request, + res: Response +): Promise => { try { const { externalDbConnectionId, warehouseKey, tableName } = req.query; @@ -226,7 +234,9 @@ export const getAreas = async (req: Request, res: Response): Promise = }); } - const connector = await getExternalDbConnector(Number(externalDbConnectionId)); + const connector = await getExternalDbConnector( + Number(externalDbConnectionId) + ); const query = ` SELECT * FROM ${tableName} @@ -258,7 +268,10 @@ export const getAreas = async (req: Request, res: Response): Promise = }; // 위치 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지 -export const getLocations = async (req: Request, res: Response): Promise => { +export const getLocations = async ( + req: Request, + res: Response +): Promise => { try { const { externalDbConnectionId, areaKey, tableName } = req.query; @@ -269,7 +282,9 @@ export const getLocations = async (req: Request, res: Response): Promise => { +export const getMaterials = async ( + req: Request, + res: Response +): Promise => { try { - const { - externalDbConnectionId, - locaKey, + const { + externalDbConnectionId, + locaKey, tableName, keyColumn, locationKeyColumn, - layerColumn + layerColumn, } = req.query; - if (!externalDbConnectionId || !locaKey || !tableName || !locationKeyColumn) { + if ( + !externalDbConnectionId || + !locaKey || + !tableName || + !locationKeyColumn + ) { return res.status(400).json({ success: false, message: "필수 파라미터가 누락되었습니다.", }); } - const connector = await getExternalDbConnector(Number(externalDbConnectionId)); + const connector = await getExternalDbConnector( + Number(externalDbConnectionId) + ); // 동적 쿼리 생성 - const orderByClause = layerColumn ? `ORDER BY ${layerColumn}` : ''; + const orderByClause = layerColumn ? `ORDER BY ${layerColumn}` : ""; const query = ` SELECT * FROM ${tableName} WHERE ${locationKeyColumn} = '${locaKey}' @@ -356,7 +381,10 @@ export const getMaterials = async (req: Request, res: Response): Promise => { +export const getMaterialCounts = async ( + req: Request, + res: Response +): Promise => { try { const { externalDbConnectionId, locationKeys, tableName } = req.body; @@ -367,7 +395,9 @@ export const getMaterialCounts = async (req: Request, res: Response): Promise `'${key}'`).join(","); diff --git a/backend-node/src/services/externalDbConnectionPoolService.ts b/backend-node/src/services/externalDbConnectionPoolService.ts index 940787c3..975fafe5 100644 --- a/backend-node/src/services/externalDbConnectionPoolService.ts +++ b/backend-node/src/services/externalDbConnectionPoolService.ts @@ -113,6 +113,7 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper { lastUsedAt: Date; activeConnections = 0; maxConnections: number; + private isPoolClosed = false; constructor(config: ExternalDbConnection) { this.connectionId = config.id!; @@ -131,6 +132,9 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper { waitForConnections: true, queueLimit: 0, connectTimeout: (config.connection_timeout || 30) * 1000, + // 연결 유지 및 자동 재연결 설정 + enableKeepAlive: true, + keepAliveInitialDelay: 10000, // 10초마다 keep-alive 패킷 전송 ssl: config.ssl_enabled === "Y" ? { rejectUnauthorized: false } : undefined, }); @@ -149,15 +153,46 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper { `[${this.dbType.toUpperCase()}] 연결 반환 (${this.activeConnections}/${this.maxConnections})` ); }); + + // 연결 오류 이벤트 처리 + this.pool.on("error", (err) => { + logger.error(`[${this.dbType.toUpperCase()}] 연결 풀 오류:`, err); + // 연결이 닫힌 경우 플래그 설정 + if (err.message.includes("closed state")) { + this.isPoolClosed = true; + } + }); } async query(sql: string, params?: any[]): Promise { this.lastUsedAt = new Date(); - const [rows] = await this.pool.execute(sql, params); - return rows; + + // 연결 풀이 닫힌 상태인지 확인 + if (this.isPoolClosed) { + throw new Error("연결 풀이 닫힌 상태입니다. 재연결이 필요합니다."); + } + + try { + const [rows] = await this.pool.execute(sql, params); + return rows; + } catch (error: any) { + // 연결 닫힘 오류 감지 + if ( + error.message.includes("closed state") || + error.code === "PROTOCOL_CONNECTION_LOST" || + error.code === "ECONNRESET" + ) { + this.isPoolClosed = true; + logger.warn( + `[${this.dbType.toUpperCase()}] 연결 끊김 감지 (ID: ${this.connectionId})` + ); + } + throw error; + } } async disconnect(): Promise { + this.isPoolClosed = true; await this.pool.end(); logger.info( `[${this.dbType.toUpperCase()}] 연결 풀 종료 (ID: ${this.connectionId})` @@ -165,6 +200,10 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper { } isHealthy(): boolean { + // 연결 풀이 닫혔으면 비정상 + if (this.isPoolClosed) { + return false; + } return this.activeConnections < this.maxConnections; } } @@ -230,9 +269,11 @@ export class ExternalDbConnectionPoolService { ): Promise { logger.info(`🔧 새 연결 풀 생성 중 (ID: ${connectionId})...`); - // DB 연결 정보 조회 + // DB 연결 정보 조회 (실제 비밀번호 포함) const connectionResult = - await ExternalDbConnectionService.getConnectionById(connectionId); + await ExternalDbConnectionService.getConnectionByIdWithPassword( + connectionId + ); if (!connectionResult.success || !connectionResult.data) { throw new Error(`연결 정보를 찾을 수 없습니다 (ID: ${connectionId})`); @@ -296,16 +337,19 @@ export class ExternalDbConnectionPoolService { } /** - * 쿼리 실행 (자동으로 연결 풀 관리) + * 쿼리 실행 (자동으로 연결 풀 관리 + 재시도 로직) */ async executeQuery( connectionId: number, sql: string, - params?: any[] + params?: any[], + retryCount = 0 ): Promise { - const pool = await this.getPool(connectionId); + const MAX_RETRIES = 2; try { + const pool = await this.getPool(connectionId); + logger.debug( `📊 쿼리 실행 (ID: ${connectionId}): ${sql.substring(0, 100)}...` ); @@ -314,7 +358,29 @@ export class ExternalDbConnectionPoolService { `✅ 쿼리 완료 (ID: ${connectionId}), 결과: ${result.length}건` ); return result; - } catch (error) { + } catch (error: any) { + // 연결 끊김 오류인 경우 재시도 + const isConnectionError = + error.message?.includes("closed state") || + error.message?.includes("연결 풀이 닫힌 상태") || + error.code === "PROTOCOL_CONNECTION_LOST" || + error.code === "ECONNRESET" || + error.code === "ETIMEDOUT"; + + if (isConnectionError && retryCount < MAX_RETRIES) { + logger.warn( + `🔄 연결 오류 감지, 재시도 중... (${retryCount + 1}/${MAX_RETRIES}) (ID: ${connectionId})` + ); + + // 기존 풀 제거 후 새로 생성 + await this.removePool(connectionId); + + // 잠시 대기 후 재시도 + await new Promise((resolve) => setTimeout(resolve, 500)); + + return this.executeQuery(connectionId, sql, params, retryCount + 1); + } + logger.error(`❌ 쿼리 실행 실패 (ID: ${connectionId}):`, error); throw error; } From 308c78b067a2b06bdb86314b4e81b487639b651d Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 11 Dec 2025 15:29:25 +0900 Subject: [PATCH 12/30] =?UTF-8?q?=EB=B0=B0=ED=8F=AC=20=EC=98=A4=EB=A5=98?= =?UTF-8?q?=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/externalDbConnectionPoolService.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/backend-node/src/services/externalDbConnectionPoolService.ts b/backend-node/src/services/externalDbConnectionPoolService.ts index 975fafe5..73077ef1 100644 --- a/backend-node/src/services/externalDbConnectionPoolService.ts +++ b/backend-node/src/services/externalDbConnectionPoolService.ts @@ -153,15 +153,6 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper { `[${this.dbType.toUpperCase()}] 연결 반환 (${this.activeConnections}/${this.maxConnections})` ); }); - - // 연결 오류 이벤트 처리 - this.pool.on("error", (err) => { - logger.error(`[${this.dbType.toUpperCase()}] 연결 풀 오류:`, err); - // 연결이 닫힌 경우 플래그 설정 - if (err.message.includes("closed state")) { - this.isPoolClosed = true; - } - }); } async query(sql: string, params?: any[]): Promise { From a489e2c1553b3e24bb104b23e2efeaf108cdf001 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Thu, 11 Dec 2025 15:46:05 +0900 Subject: [PATCH 13/30] =?UTF-8?q?=EC=B2=A8=EB=B6=80=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/lib/api/file.ts | 7 ++-- .../file-upload/FileUploadComponent.tsx | 22 +++++++++++- .../table-list/TableListComponent.tsx | 34 ++++++++++++++++--- 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/frontend/lib/api/file.ts b/frontend/lib/api/file.ts index 318f26fb..4908b381 100644 --- a/frontend/lib/api/file.ts +++ b/frontend/lib/api/file.ts @@ -32,7 +32,7 @@ export const uploadFiles = async (params: { files: FileList | File[]; tableName?: string; fieldName?: string; - recordId?: string; + recordId?: string | number; docType?: string; docTypeName?: string; targetObjid?: string; @@ -43,6 +43,7 @@ export const uploadFiles = async (params: { columnName?: string; isVirtualFileColumn?: boolean; companyCode?: string; // 🔒 멀티테넌시: 회사 코드 + isRecordMode?: boolean; // 🆕 레코드 모드 플래그 }): Promise => { const formData = new FormData(); @@ -55,7 +56,7 @@ export const uploadFiles = async (params: { // 추가 파라미터들 추가 if (params.tableName) formData.append("tableName", params.tableName); if (params.fieldName) formData.append("fieldName", params.fieldName); - if (params.recordId) formData.append("recordId", params.recordId); + if (params.recordId) formData.append("recordId", String(params.recordId)); if (params.docType) formData.append("docType", params.docType); if (params.docTypeName) formData.append("docTypeName", params.docTypeName); if (params.targetObjid) formData.append("targetObjid", params.targetObjid); @@ -66,6 +67,8 @@ export const uploadFiles = async (params: { 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); // 🔒 멀티테넌시 + // 🆕 레코드 모드 플래그 추가 (백엔드에서 attachments 컬럼 자동 업데이트용) + if (params.isRecordMode !== undefined) formData.append("isRecordMode", params.isRecordMode.toString()); 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 805fe755..dd4f4c6b 100644 --- a/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx +++ b/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx @@ -603,10 +603,16 @@ const FileUploadComponent: React.FC = ({ targetObjid, }); + // 🔑 레코드 모드일 때는 effectiveTableName을 우선 사용 + // formData.linkedTable이 'screen_files' 같은 기본값일 수 있으므로 레코드 모드에서는 무시 + const finalLinkedTable = effectiveIsRecordMode + ? effectiveTableName + : (formData?.linkedTable || effectiveTableName); + const uploadData = { // 🎯 formData에서 백엔드 API 설정 가져오기 autoLink: formData?.autoLink || true, - linkedTable: formData?.linkedTable || effectiveTableName, + linkedTable: finalLinkedTable, recordId: effectiveRecordId || `temp_${component.id}`, columnName: effectiveColumnName, isVirtualFileColumn: formData?.isVirtualFileColumn || true, @@ -620,13 +626,27 @@ const FileUploadComponent: React.FC = ({ // 🆕 레코드 모드 플래그 isRecordMode: effectiveIsRecordMode, }; + + console.log("📤 [FileUploadComponent] uploadData 최종:", { + isRecordMode: effectiveIsRecordMode, + linkedTable: finalLinkedTable, + recordId: effectiveRecordId, + columnName: effectiveColumnName, + targetObjid, + }); + console.log("🚀 [FileUploadComponent] uploadFiles API 호출 직전:", { + filesCount: filesToUpload.length, + uploadData, + }); + const response = await uploadFiles({ files: filesToUpload, ...uploadData, }); + console.log("📥 [FileUploadComponent] uploadFiles API 응답:", response); if (response.success) { // FileUploadResponse 타입에 맞게 files 배열 사용 diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index f68b8383..a12bf494 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -1683,6 +1683,19 @@ export const TableListComponent: React.FC = ({ // console.log(` - uniqueItemNumbers: ${JSON.stringify(uniqueItemNumbers)}`); // console.log(` - isDuplicated: ${itemNumbers.length !== uniqueItemNumbers.length}`); + // 🔍 디버그: attachments 컬럼 데이터 확인 + if (response.data && response.data.length > 0) { + const firstRow = response.data[0]; + if ('attachments' in firstRow) { + console.log("📎 [TableList] attachments 데이터 확인:", { + tableName: tableConfig.selectedTable, + firstRowAttachments: firstRow.attachments, + type: typeof firstRow.attachments, + isArray: Array.isArray(firstRow.attachments), + }); + } + } + setData(response.data || []); setTotalPages(response.totalPages || 0); setTotalItems(response.total || 0); @@ -3971,17 +3984,30 @@ export const TableListComponent: React.FC = ({ } // 📎 첨부파일 타입: 파일 아이콘과 개수 표시 - if (inputType === "file" || inputType === "attachment" || column.columnName === "attachments") { + // 컬럼명이 'attachments'를 포함하거나, inputType이 file/attachment인 경우 + const isAttachmentColumn = + inputType === "file" || + inputType === "attachment" || + column.columnName === "attachments" || + column.columnName?.toLowerCase().includes("attachment") || + column.columnName?.toLowerCase().includes("file"); + + if (isAttachmentColumn) { // JSONB 배열 또는 JSON 문자열 파싱 let files: any[] = []; try { - if (typeof value === "string") { - files = JSON.parse(value); + if (typeof value === "string" && value.trim()) { + const parsed = JSON.parse(value); + files = Array.isArray(parsed) ? parsed : []; } else if (Array.isArray(value)) { files = value; + } else if (value && typeof value === "object") { + // 단일 객체인 경우 배열로 변환 + files = [value]; } - } catch { + } catch (e) { // 파싱 실패 시 빈 배열 + console.warn("📎 [TableList] 첨부파일 파싱 실패:", { columnName: column.columnName, value, error: e }); } if (!files || files.length === 0) { From 84bd1ce154f6672d64454e88392c32d02e72cf56 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Thu, 11 Dec 2025 15:50:28 +0900 Subject: [PATCH 14/30] =?UTF-8?q?=EC=B2=A8=EB=B6=80=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=EC=9C=BC=EB=A1=9C=20=EB=82=98=EC=98=A4?= =?UTF-8?q?=EA=B2=8C=20=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../table-list/TableListComponent.tsx | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index a12bf494..c9d41dce 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -4014,13 +4014,24 @@ export const TableListComponent: React.FC = ({ return -; } - // 파일 개수와 아이콘 표시 + // 파일 이름 표시 (여러 개면 쉼표로 구분) const { Paperclip } = require("lucide-react"); + const fileNames = files.map((f: any) => f.realFileName || f.real_file_name || f.name || "파일").join(", "); + return ( -
- - {files.length} - +
+ + + {fileNames} + + {files.length > 1 && ( + + ({files.length}) + + )}
); } From 30361e0f45b84b29ba2a83987093473c9dd93cfd Mon Sep 17 00:00:00 2001 From: leeheejin Date: Thu, 11 Dec 2025 16:07:16 +0900 Subject: [PATCH 15/30] =?UTF-8?q?=EB=8F=94=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EB=9C=A8=EB=8D=98=EA=B1=B0=20=EA=B3=A0=EC=B9=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/accordion-basic/AccordionBasicComponent.tsx | 3 +++ .../registry/components/divider-line/DividerLineComponent.tsx | 3 +++ .../lib/registry/components/text-input/TextInputComponent.tsx | 3 +++ 3 files changed, 9 insertions(+) diff --git a/frontend/lib/registry/components/accordion-basic/AccordionBasicComponent.tsx b/frontend/lib/registry/components/accordion-basic/AccordionBasicComponent.tsx index c25cb7bd..78951f7e 100644 --- a/frontend/lib/registry/components/accordion-basic/AccordionBasicComponent.tsx +++ b/frontend/lib/registry/components/accordion-basic/AccordionBasicComponent.tsx @@ -602,6 +602,9 @@ export const AccordionBasicComponent: React.FC = ( isInModal: _isInModal, isPreview: _isPreview, originalData: _originalData, + _originalData: __originalData, + _initialData: __initialData, + _groupedData: __groupedData, allComponents: _allComponents, selectedRows: _selectedRows, selectedRowsData: _selectedRowsData, diff --git a/frontend/lib/registry/components/divider-line/DividerLineComponent.tsx b/frontend/lib/registry/components/divider-line/DividerLineComponent.tsx index d2b61c90..ea4428ca 100644 --- a/frontend/lib/registry/components/divider-line/DividerLineComponent.tsx +++ b/frontend/lib/registry/components/divider-line/DividerLineComponent.tsx @@ -86,6 +86,9 @@ export const DividerLineComponent: React.FC = ({ isInModal: _isInModal, readonly: _readonly, originalData: _originalData, + _originalData: __originalData, + _initialData: __initialData, + _groupedData: __groupedData, allComponents: _allComponents, onUpdateLayout: _onUpdateLayout, selectedRows: _selectedRows, diff --git a/frontend/lib/registry/components/text-input/TextInputComponent.tsx b/frontend/lib/registry/components/text-input/TextInputComponent.tsx index 72dabb61..ad37f19f 100644 --- a/frontend/lib/registry/components/text-input/TextInputComponent.tsx +++ b/frontend/lib/registry/components/text-input/TextInputComponent.tsx @@ -224,6 +224,9 @@ export const TextInputComponent: React.FC = ({ isInModal: _isInModal, isPreview: _isPreview, originalData: _originalData, + _originalData: __originalData, + _initialData: __initialData, + _groupedData: __groupedData, allComponents: _allComponents, selectedRows: _selectedRows, selectedRowsData: _selectedRowsData, From 563acb7c0019cc463bc9605f96c256db8c2ad4dc Mon Sep 17 00:00:00 2001 From: leeheejin Date: Thu, 11 Dec 2025 16:09:58 +0900 Subject: [PATCH 16/30] =?UTF-8?q?=EB=B4=84=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EB=9C=A8=EB=8D=98=EA=B1=B0=20=EA=B3=A0=EC=B9=A82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/button-primary/ButtonPrimaryComponent.tsx | 3 +++ .../repeat-screen-modal/RepeatScreenModalComponent.tsx | 8 ++++++-- .../universal-form-modal/UniversalFormModalComponent.tsx | 4 +++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 1942d268..ae6a01d8 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -984,6 +984,9 @@ export const ButtonPrimaryComponent: React.FC = ({ flowSelectedStepId: _flowSelectedStepId, // 플로우 선택 스텝 ID 필터링 onFlowRefresh: _onFlowRefresh, // 플로우 새로고침 콜백 필터링 originalData: _originalData, // 부분 업데이트용 원본 데이터 필터링 + _originalData: __originalData, // DOM 필터링 + _initialData: __initialData, // DOM 필터링 + _groupedData: __groupedData, // DOM 필터링 refreshKey: _refreshKey, // 필터링 추가 isInModal: _isInModal, // 필터링 추가 mode: _mode, // 필터링 추가 diff --git a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx index 980bbfe9..4b4809a1 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx +++ b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx @@ -52,11 +52,15 @@ export function RepeatScreenModalComponent({ config, className, groupedData: propsGroupedData, // EditModal에서 전달받는 그룹 데이터 + // DynamicComponentRenderer에서 전달되는 props (DOM 전달 방지) + _initialData, + _originalData: _propsOriginalData, + _groupedData, ...props -}: RepeatScreenModalComponentProps) { +}: RepeatScreenModalComponentProps & { _initialData?: any; _originalData?: any; _groupedData?: any }) { // props에서도 groupedData를 추출 (DynamicWebTypeRenderer에서 전달될 수 있음) // DynamicComponentRenderer에서는 _groupedData로 전달됨 - const groupedData = propsGroupedData || (props as any).groupedData || (props as any)._groupedData; + const groupedData = propsGroupedData || (props as any).groupedData || _groupedData; const componentConfig = { ...config, ...component?.config, diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 89838c67..4ac31b12 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -126,11 +126,13 @@ export function UniversalFormModalComponent({ initialData: propInitialData, // DynamicComponentRenderer에서 전달되는 props (DOM 전달 방지를 위해 _ prefix 사용) _initialData, + _originalData, + _groupedData, onSave, onCancel, onChange, ...restProps // 나머지 props는 DOM에 전달하지 않음 -}: UniversalFormModalComponentProps & { _initialData?: any }) { +}: UniversalFormModalComponentProps & { _initialData?: any; _originalData?: any; _groupedData?: any }) { // initialData 우선순위: 직접 전달된 prop > DynamicComponentRenderer에서 전달된 prop const initialData = propInitialData || _initialData; // 설정 병합 From b208b0be34d750538f5950ef5f5f310155f14e90 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 11 Dec 2025 16:31:47 +0900 Subject: [PATCH 17/30] =?UTF-8?q?fix:=20tiptap=20=EB=B2=84=EC=A0=84=20?= =?UTF-8?q?=EC=B6=A9=EB=8F=8C=20=ED=95=B4=EA=B2=B0=20=EB=B0=8F=20legacy-pe?= =?UTF-8?q?er-deps=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package-lock.json | 38 ++++++++++++++++---------------------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6c2ec5f0..a0929370 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -34,9 +34,9 @@ "@react-three/fiber": "^9.4.0", "@tanstack/react-query": "^5.86.0", "@tanstack/react-table": "^8.21.3", - "@tiptap/core": "^3.13.0", + "@tiptap/core": "^2.27.1", "@tiptap/extension-placeholder": "^2.27.1", - "@tiptap/pm": "^2.11.5", + "@tiptap/pm": "^2.27.1", "@tiptap/react": "^2.27.1", "@tiptap/starter-kit": "^2.27.1", "@turf/buffer": "^7.2.0", @@ -3302,16 +3302,16 @@ } }, "node_modules/@tiptap/core": { - "version": "3.13.0", - "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.13.0.tgz", - "integrity": "sha512-iUelgiTMgPVMpY5ZqASUpk8mC8HuR9FWKaDzK27w9oWip9tuB54Z8mePTxNcQaSPb6ErzEaC8x8egrRt7OsdGQ==", + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz", + "integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/pm": "^3.13.0" + "@tiptap/pm": "^2.7.0" } }, "node_modules/@tiptap/extension-blockquote": { @@ -3700,19 +3700,6 @@ "url": "https://github.com/sponsors/ueberdosis" } }, - "node_modules/@tiptap/starter-kit/node_modules/@tiptap/core": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz", - "integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/pm": "^2.7.0" - } - }, "node_modules/@turf/along": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@turf/along/-/along-7.2.0.tgz", @@ -6084,7 +6071,7 @@ "version": "20.19.24", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz", "integrity": "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -6122,7 +6109,7 @@ "version": "19.2.2", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz", "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -12538,6 +12525,13 @@ "react-dom": ">=16" } }, + "node_modules/react-is": { + "version": "19.2.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.1.tgz", + "integrity": "sha512-L7BnWgRbMwzMAubQcS7sXdPdNLmKlucPlopgAzx7FtYbksWZgEWiuYM5x9T6UqS2Ne0rsgQTq5kY2SGqpzUkYA==", + "license": "MIT", + "peer": true + }, "node_modules/react-leaflet": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", @@ -14197,7 +14191,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unrs-resolver": { From 016b8f707bbf9af09c176c42b84eeb23cf7f4bcd Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 11 Dec 2025 18:40:39 +0900 Subject: [PATCH 18/30] =?UTF-8?q?=EB=B6=84=ED=95=A0=ED=8C=A8=EB=84=90=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EC=9D=B4=EB=8F=99=20=EA=B0=80=EB=8A=A5?= =?UTF-8?q?=ED=95=98=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/(main)/screens/[screenId]/page.tsx | 11 +- .../screen/InteractiveScreenViewer.tsx | 213 +++++----- .../components/screen/RealtimePreview.tsx | 202 +++++++-- .../screen/RealtimePreviewDynamic.tsx | 138 +++++- .../screen/SplitPanelAwareWrapper.tsx | 92 ++++ .../split-panel-layout/SplitPanelContext.tsx | 400 ++++++++++++++++++ .../SplitPanelLayoutComponent.tsx | 117 ++++- .../components/split-panel-layout/index.ts | 10 + 8 files changed, 1021 insertions(+), 162 deletions(-) create mode 100644 frontend/components/screen/SplitPanelAwareWrapper.tsx create mode 100644 frontend/lib/registry/components/split-panel-layout/SplitPanelContext.tsx diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index f556dae2..8510d627 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -21,6 +21,7 @@ import { useResponsive } from "@/lib/hooks/useResponsive"; // 🆕 반응형 감 import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; // 🆕 테이블 옵션 import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext"; // 🆕 높이 관리 import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 🆕 컴포넌트 간 통신 +import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; // 🆕 분할 패널 리사이즈 function ScreenViewPage() { const params = useParams(); @@ -307,10 +308,7 @@ function ScreenViewPage() { return ( -
+
{/* 레이아웃 준비 중 로딩 표시 */} {!layoutReady && (
@@ -358,7 +356,6 @@ function ScreenViewPage() { return isButton; }); - topLevelComponents.forEach((component) => { const isButton = (component.type === "component" && @@ -799,7 +796,9 @@ function ScreenViewPageWrapper() { return ( - + + + ); diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index e11b03a0..480b3ddd 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -50,6 +50,7 @@ import { cn } from "@/lib/utils"; import { useScreenPreview } from "@/contexts/ScreenPreviewContext"; import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; import { TableOptionsToolbar } from "./table-options/TableOptionsToolbar"; +import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; /** * 🔗 연쇄 드롭다운 래퍼 컴포넌트 @@ -2101,113 +2102,115 @@ export const InteractiveScreenViewer: React.FC = ( : component; return ( - -
- {/* 테이블 옵션 툴바 */} - - - {/* 메인 컨텐츠 */} -
- {/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */} - {shouldShowLabel && ( - - )} - - {/* 실제 위젯 - 상위에서 라벨을 렌더링했으므로 자식은 라벨 숨김 */} -
{renderInteractiveWidget(componentForRendering)}
-
-
- - {/* 개선된 검증 패널 (선택적 표시) */} - {showValidationPanel && enhancedValidation && ( -
- { - const success = await enhancedValidation.saveForm(); - if (success) { - toast.success("데이터가 성공적으로 저장되었습니다!"); - } - }} - canSave={enhancedValidation.canSave} - compact={true} - showDetails={false} - /> -
- )} - - {/* 모달 화면 */} - { - setPopupScreen(null); - setPopupFormData({}); // 팝업 닫을 때 formData도 초기화 - }}> - - - {popupScreen?.title || "상세 정보"} - + + +
+ {/* 테이블 옵션 툴바 */} + -
- {popupLoading ? ( -
-
화면을 불러오는 중...
-
- ) : popupLayout.length > 0 ? ( -
- {/* 팝업에서도 실제 위치와 크기로 렌더링 */} - {popupLayout.map((popupComponent) => ( -
- {/* 🎯 핵심 수정: 팝업 전용 formData 사용 */} - { - console.log("💾 팝업 formData 업데이트:", { - fieldName, - value, - valueType: typeof value, - prevFormData: popupFormData - }); - - setPopupFormData(prev => ({ - ...prev, - [fieldName]: value - })); - }} - /> -
- ))} -
- ) : ( -
-
화면 데이터가 없습니다.
-
+ {/* 메인 컨텐츠 */} +
+ {/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */} + {shouldShowLabel && ( + )} + + {/* 실제 위젯 - 상위에서 라벨을 렌더링했으므로 자식은 라벨 숨김 */} +
{renderInteractiveWidget(componentForRendering)}
- -
-
+
+ + {/* 개선된 검증 패널 (선택적 표시) */} + {showValidationPanel && enhancedValidation && ( +
+ { + const success = await enhancedValidation.saveForm(); + if (success) { + toast.success("데이터가 성공적으로 저장되었습니다!"); + } + }} + canSave={enhancedValidation.canSave} + compact={true} + showDetails={false} + /> +
+ )} + + {/* 모달 화면 */} + { + setPopupScreen(null); + setPopupFormData({}); // 팝업 닫을 때 formData도 초기화 + }}> + + + {popupScreen?.title || "상세 정보"} + + +
+ {popupLoading ? ( +
+
화면을 불러오는 중...
+
+ ) : popupLayout.length > 0 ? ( +
+ {/* 팝업에서도 실제 위치와 크기로 렌더링 */} + {popupLayout.map((popupComponent) => ( +
+ {/* 🎯 핵심 수정: 팝업 전용 formData 사용 */} + { + console.log("💾 팝업 formData 업데이트:", { + fieldName, + value, + valueType: typeof value, + prevFormData: popupFormData + }); + + setPopupFormData(prev => ({ + ...prev, + [fieldName]: value + })); + }} + /> +
+ ))} +
+ ) : ( +
+
화면 데이터가 없습니다.
+
+ )} +
+
+
+ + ); }; diff --git a/frontend/components/screen/RealtimePreview.tsx b/frontend/components/screen/RealtimePreview.tsx index f1ca6e7d..b58a6a1f 100644 --- a/frontend/components/screen/RealtimePreview.tsx +++ b/frontend/components/screen/RealtimePreview.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { ComponentData, WebType, isWidgetComponent, isContainerComponent } from "@/types"; import { isFileComponent } from "@/lib/utils/componentTypeUtils"; import { Input } from "@/components/ui/input"; @@ -14,6 +14,7 @@ import { FileUpload } from "./widgets/FileUpload"; import { useAuth } from "@/hooks/useAuth"; import { DynamicWebTypeRenderer, WebTypeRegistry } from "@/lib/registry"; import { DataTableTemplate } from "@/components/screen/templates/DataTableTemplate"; +import { useSplitPanel } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; import { Database, Type, @@ -110,8 +111,8 @@ const renderArea = (component: ComponentData, children?: React.ReactNode) => { }; // 동적 웹 타입 위젯 렌더링 컴포넌트 -const WidgetRenderer: React.FC<{ - component: ComponentData; +const WidgetRenderer: React.FC<{ + component: ComponentData; isDesignMode?: boolean; sortBy?: string; sortOrder?: "asc" | "desc"; @@ -253,22 +254,23 @@ export const RealtimePreviewDynamic: React.FC = ({ // 플로우 위젯의 실제 높이 측정 useEffect(() => { - const isFlowWidget = type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget"); - + const isFlowWidget = + type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget"); + if (isFlowWidget && contentRef.current) { const measureHeight = () => { if (contentRef.current) { // getBoundingClientRect()로 실제 렌더링된 높이 측정 const rect = contentRef.current.getBoundingClientRect(); const measured = rect.height; - + // scrollHeight도 함께 확인하여 더 큰 값 사용 const scrollHeight = contentRef.current.scrollHeight; const rawHeight = Math.max(measured, scrollHeight); - + // 40px 단위로 올림 const finalHeight = Math.ceil(rawHeight / 40) * 40; - + if (finalHeight > 0 && Math.abs(finalHeight - (actualHeight || 0)) > 10) { setActualHeight(finalHeight); } @@ -400,12 +402,118 @@ export const RealtimePreviewDynamic: React.FC = ({ }, [component.id, fileUpdateTrigger]); // 컴포넌트 스타일 계산 - const isFlowWidget = type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget"); + const isFlowWidget = + type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget"); const isSectionPaper = type === "component" && (component as any).componentConfig?.type === "section-paper"; - + const positionX = position?.x || 0; const positionY = position?.y || 0; + // 🆕 분할 패널 리사이즈 Context + const { getAdjustedX, getOverlappingSplitPanel } = useSplitPanel(); + + // 버튼 컴포넌트인지 확인 (분할 패널 위치 조정 대상) + const componentType = (component as any).componentType || ""; + const componentId = (component as any).componentId || ""; + const widgetType = (component as any).widgetType || ""; + + const isButtonComponent = + (type === "widget" && widgetType === "button") || + (type === "component" && + (["button-primary", "button-secondary"].includes(componentType) || + ["button-primary", "button-secondary"].includes(componentId))); + + // 디버깅: 모든 컴포넌트의 타입 정보 출력 (버튼 관련만) + if (componentType.includes("button") || componentId.includes("button") || widgetType.includes("button")) { + console.log("🔘 [RealtimePreview] 버튼 컴포넌트 발견:", { + id: component.id, + type, + componentType, + componentId, + widgetType, + isButtonComponent, + positionX, + positionY, + }); + } + + // 🆕 분할 패널 위 버튼 위치 자동 조정 + const { adjustedPositionX, isOnSplitPanel, isDraggingSplitPanel } = useMemo(() => { + // 버튼이 아니거나 분할 패널 컴포넌트 자체인 경우 조정하지 않음 + const isSplitPanelComponent = + type === "component" && + ["split-panel-layout", "split-panel-layout2"].includes((component as any).componentType || ""); + + if (!isButtonComponent || isSplitPanelComponent) { + return { adjustedPositionX: positionX, isOnSplitPanel: false, isDraggingSplitPanel: false }; + } + + const componentWidth = size?.width || 100; + const componentHeight = size?.height || 40; + + // 분할 패널 위에 있는지 확인 + const overlap = getOverlappingSplitPanel(positionX, positionY, componentWidth, componentHeight); + + // 디버깅: 버튼이 분할 패널 위에 있는지 확인 + if (isButtonComponent) { + console.log("🔍 [RealtimePreview] 버튼 분할 패널 감지:", { + componentId: component.id, + componentType: (component as any).componentType, + positionX, + positionY, + componentWidth, + componentHeight, + hasOverlap: !!overlap, + isInLeftPanel: overlap?.isInLeftPanel, + panelInfo: overlap + ? { + panelId: overlap.panelId, + panelX: overlap.panel.x, + panelY: overlap.panel.y, + panelWidth: overlap.panel.width, + leftWidthPercent: overlap.panel.leftWidthPercent, + initialLeftWidthPercent: overlap.panel.initialLeftWidthPercent, + } + : null, + }); + } + + if (!overlap || !overlap.isInLeftPanel) { + // 분할 패널 위에 없거나 우측 패널 위에 있음 + return { + adjustedPositionX: positionX, + isOnSplitPanel: !!overlap, + isDraggingSplitPanel: overlap?.panel.isDragging ?? false, + }; + } + + // 좌측 패널 위에 있음 - 위치 조정 + const adjusted = getAdjustedX(positionX, positionY, componentWidth, componentHeight); + + console.log("✅ [RealtimePreview] 버튼 위치 조정 적용:", { + componentId: component.id, + originalX: positionX, + adjustedX: adjusted, + delta: adjusted - positionX, + }); + + return { + adjustedPositionX: adjusted, + isOnSplitPanel: true, + isDraggingSplitPanel: overlap.panel.isDragging, + }; + }, [ + positionX, + positionY, + size?.width, + size?.height, + isButtonComponent, + type, + component, + getAdjustedX, + getOverlappingSplitPanel, + ]); + // 너비 결정 로직: style.width (퍼센트) > 조건부 100% > size.width (픽셀) const getWidth = () => { // 1순위: style.width가 있으면 우선 사용 (퍼센트 값) @@ -437,23 +545,27 @@ export const RealtimePreviewDynamic: React.FC = ({ const componentStyle = { position: "absolute" as const, ...style, // 먼저 적용하고 - left: positionX, + left: adjustedPositionX, // 🆕 분할 패널 위 버튼은 조정된 X 좌표 사용 top: positionY, width: getWidth(), // 우선순위에 따른 너비 height: getHeight(), // 우선순위에 따른 높이 zIndex: position?.z || 1, // right 속성 강제 제거 right: undefined, + // 🆕 분할 패널 드래그 중에는 트랜지션 없이 즉시 이동 + transition: + isOnSplitPanel && isButtonComponent ? (isDraggingSplitPanel ? "none" : "left 0.1s ease-out") : undefined, }; // 선택된 컴포넌트 스타일 // Section Paper는 자체적으로 선택 상태 테두리를 처리하므로 outline 제거 - const selectionStyle = isSelected && !isSectionPaper - ? { - outline: "2px solid rgb(59, 130, 246)", - outlineOffset: "2px", - } - : {}; + const selectionStyle = + isSelected && !isSectionPaper + ? { + outline: "2px solid rgb(59, 130, 246)", + outlineOffset: "2px", + } + : {}; const handleClick = (e: React.MouseEvent) => { // 컴포넌트 영역 내에서만 클릭 이벤트 처리 @@ -481,10 +593,7 @@ export const RealtimePreviewDynamic: React.FC = ({ onDragEnd={handleDragEnd} > {/* 컴포넌트 타입별 렌더링 */} -
+
{/* 영역 타입 */} {type === "area" && renderArea(component, children)} @@ -549,16 +658,16 @@ export const RealtimePreviewDynamic: React.FC = ({ return (
- +
); })()} {/* 탭 컴포넌트 타입 */} - {(type === "tabs" || (type === "component" && ((component as any).componentType === "tabs-widget" || (component as any).componentId === "tabs-widget"))) && + {(type === "tabs" || + (type === "component" && + ((component as any).componentType === "tabs-widget" || + (component as any).componentId === "tabs-widget"))) && (() => { console.log("🎯 탭 컴포넌트 조건 충족:", { type, @@ -590,9 +699,7 @@ export const RealtimePreviewDynamic: React.FC = ({ {tab.label || `탭 ${index + 1}`} {tab.screenName && ( - - ({tab.screenName}) - + ({tab.screenName}) )} ))} @@ -632,28 +739,29 @@ export const RealtimePreviewDynamic: React.FC = ({ )} {/* 컴포넌트 타입 - 레지스트리 기반 렌더링 (Section Paper, Section Card 등) */} - {type === "component" && (() => { - const { DynamicComponentRenderer } = require("@/lib/registry/DynamicComponentRenderer"); - return ( - - {children} - - ); - })()} + {type === "component" && + (() => { + const { DynamicComponentRenderer } = require("@/lib/registry/DynamicComponentRenderer"); + return ( + + {children} + + ); + })()} {/* 위젯 타입 - 동적 렌더링 (파일 컴포넌트 제외) */} {type === "widget" && !isFileComponent(component) && (
- void; } @@ -262,14 +263,145 @@ export const RealtimePreviewDynamic: React.FC = ({ } : component; + // 🆕 분할 패널 리사이즈 Context + const splitPanelContext = useSplitPanel(); + + // 버튼 컴포넌트인지 확인 (분할 패널 위치 조정 대상) + const componentType = (component as any).componentType || ""; + const componentId = (component as any).componentId || ""; + const widgetType = (component as any).widgetType || ""; + + const isButtonComponent = + (type === "widget" && widgetType === "button") || + (type === "component" && + (["button-primary", "button-secondary"].includes(componentType) || + ["button-primary", "button-secondary"].includes(componentId))); + + // 🆕 버튼이 처음 렌더링될 때의 분할 패널 정보를 기억 (기준점) + const initialPanelRatioRef = React.useRef(null); + const initialPanelIdRef = React.useRef(null); + // 버튼이 좌측 패널에 속하는지 여부 (한번 설정되면 유지) + const isInLeftPanelRef = React.useRef(null); + + // 🆕 분할 패널 위 버튼 위치 자동 조정 + const calculateButtonPosition = () => { + // 버튼이 아니거나 분할 패널 컴포넌트 자체인 경우 조정하지 않음 + const isSplitPanelComponent = + type === "component" && ["split-panel-layout", "split-panel-layout2"].includes(componentType); + + if (!isButtonComponent || isSplitPanelComponent) { + return { adjustedPositionX: position.x, isOnSplitPanel: false, isDraggingSplitPanel: false }; + } + + const componentWidth = size?.width || 100; + const componentHeight = size?.height || 40; + + // 분할 패널 위에 있는지 확인 (원래 위치 기준) + const overlap = splitPanelContext.getOverlappingSplitPanel(position.x, position.y, componentWidth, componentHeight); + + // 분할 패널 위에 없으면 기준점 초기화 + if (!overlap) { + if (initialPanelIdRef.current !== null) { + initialPanelRatioRef.current = null; + initialPanelIdRef.current = null; + isInLeftPanelRef.current = null; + } + return { + adjustedPositionX: position.x, + isOnSplitPanel: false, + isDraggingSplitPanel: false, + }; + } + + const { panel } = overlap; + + // 🆕 초기 기준 비율 및 좌측 패널 소속 여부 설정 (처음 한 번만) + if (initialPanelIdRef.current !== overlap.panelId) { + initialPanelRatioRef.current = panel.leftWidthPercent; + initialPanelIdRef.current = overlap.panelId; + + // 초기 배치 시 좌측 패널에 있는지 확인 (초기 비율 기준으로 계산) + // 현재 비율이 아닌, 버튼 원래 위치가 초기 좌측 패널 영역 안에 있었는지 판단 + const initialLeftPanelWidth = (panel.width * panel.leftWidthPercent) / 100; + const componentCenterX = position.x + componentWidth / 2; + const relativeX = componentCenterX - panel.x; + const wasInLeftPanel = relativeX < initialLeftPanelWidth; + + isInLeftPanelRef.current = wasInLeftPanel; + console.log("📌 [버튼 기준점 설정]:", { + componentId: component.id, + panelId: overlap.panelId, + initialRatio: panel.leftWidthPercent, + isInLeftPanel: wasInLeftPanel, + buttonCenterX: componentCenterX, + leftPanelWidth: initialLeftPanelWidth, + }); + } + + // 좌측 패널 소속이 아니면 조정하지 않음 (초기 배치 기준) + if (!isInLeftPanelRef.current) { + return { + adjustedPositionX: position.x, + isOnSplitPanel: true, + isDraggingSplitPanel: panel.isDragging, + }; + } + + // 초기 기준 비율 (버튼이 처음 배치될 때의 비율) + const baseRatio = initialPanelRatioRef.current ?? panel.leftWidthPercent; + + // 기준 비율 대비 현재 비율로 분할선 위치 계산 + const baseDividerX = panel.x + (panel.width * baseRatio) / 100; // 초기 분할선 위치 + const currentDividerX = panel.x + (panel.width * panel.leftWidthPercent) / 100; // 현재 분할선 위치 + + // 분할선 이동량 (px) + const dividerDelta = currentDividerX - baseDividerX; + + // 변화가 없으면 원래 위치 반환 + if (Math.abs(dividerDelta) < 1) { + return { + adjustedPositionX: position.x, + isOnSplitPanel: true, + isDraggingSplitPanel: panel.isDragging, + }; + } + + // 🆕 버튼도 분할선과 같은 양만큼 이동 + // 분할선이 왼쪽으로 100px 이동하면, 버튼도 왼쪽으로 100px 이동 + const adjustedX = position.x + dividerDelta; + + console.log("📍 [버튼 위치 조정]:", { + componentId: component.id, + originalX: position.x, + adjustedX, + dividerDelta, + baseRatio, + currentRatio: panel.leftWidthPercent, + baseDividerX, + currentDividerX, + isDragging: panel.isDragging, + }); + + return { + adjustedPositionX: adjustedX, + isOnSplitPanel: true, + isDraggingSplitPanel: panel.isDragging, + }; + }; + + const { adjustedPositionX, isOnSplitPanel, isDraggingSplitPanel } = calculateButtonPosition(); + const baseStyle = { - left: `${position.x}px`, + left: `${adjustedPositionX}px`, // 🆕 조정된 X 좌표 사용 top: `${position.y}px`, ...componentStyle, // componentStyle 전체 적용 (DynamicComponentRenderer에서 이미 size가 변환됨) width: getWidth(), // getWidth() 우선 (table-list 등 특수 케이스) height: getHeight(), // getHeight() 우선 (flow-widget 등 특수 케이스) zIndex: component.type === "layout" ? 1 : position.z || 2, right: undefined, + // 🆕 분할 패널 드래그 중에는 트랜지션 없이 즉시 이동 + transition: + isOnSplitPanel && isButtonComponent ? (isDraggingSplitPanel ? "none" : "left 0.1s ease-out") : undefined, }; // 크기 정보는 필요시에만 디버깅 (개발 중 문제 발생 시 주석 해제) diff --git a/frontend/components/screen/SplitPanelAwareWrapper.tsx b/frontend/components/screen/SplitPanelAwareWrapper.tsx new file mode 100644 index 00000000..9dae37a6 --- /dev/null +++ b/frontend/components/screen/SplitPanelAwareWrapper.tsx @@ -0,0 +1,92 @@ +"use client"; + +import React, { useMemo } from "react"; +import { useSplitPanel } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; + +interface SplitPanelAwareWrapperProps { + children: React.ReactNode; + componentX: number; + componentY: number; + componentWidth: number; + componentHeight: number; + componentType?: string; + style?: React.CSSProperties; + className?: string; +} + +/** + * 분할 패널 드래그 리사이즈에 따라 컴포넌트 위치를 자동 조정하는 래퍼 + * + * 동작 방식: + * 1. 컴포넌트가 분할 패널의 좌측 영역 위에 있는지 감지 + * 2. 좌측 영역 위에 있으면, 드래그 핸들 이동량만큼 X 좌표를 조정 + * 3. 우측 영역이나 분할 패널 외부에 있으면 원래 위치 유지 + */ +export const SplitPanelAwareWrapper: React.FC = ({ + children, + componentX, + componentY, + componentWidth, + componentHeight, + componentType, + style, + className, +}) => { + const { getAdjustedX, getOverlappingSplitPanel } = useSplitPanel(); + + // 분할 패널 위에 있는지 확인 및 조정된 X 좌표 계산 + const { adjustedX, isInLeftPanel, isDragging } = useMemo(() => { + const overlap = getOverlappingSplitPanel(componentX, componentY, componentWidth, componentHeight); + + if (!overlap) { + // 분할 패널 위에 없음 + return { adjustedX: componentX, isInLeftPanel: false, isDragging: false }; + } + + if (!overlap.isInLeftPanel) { + // 우측 패널 위에 있음 - 원래 위치 유지 + return { adjustedX: componentX, isInLeftPanel: false, isDragging: overlap.panel.isDragging }; + } + + // 좌측 패널 위에 있음 - 위치 조정 + const adjusted = getAdjustedX(componentX, componentY, componentWidth, componentHeight); + + return { + adjustedX: adjusted, + isInLeftPanel: true, + isDragging: overlap.panel.isDragging, + }; + }, [componentX, componentY, componentWidth, componentHeight, getAdjustedX, getOverlappingSplitPanel]); + + // 조정된 스타일 + const adjustedStyle: React.CSSProperties = { + ...style, + position: "absolute", + left: `${adjustedX}px`, + top: `${componentY}px`, + width: componentWidth, + height: componentHeight, + // 드래그 중에는 트랜지션 없이 즉시 이동, 드래그 끝나면 부드럽게 + transition: isDragging ? "none" : "left 0.1s ease-out", + }; + + // 디버그 로깅 (개발 중에만) + // if (isInLeftPanel) { + // console.log(`📍 [SplitPanelAwareWrapper] 위치 조정:`, { + // componentType, + // originalX: componentX, + // adjustedX, + // delta: adjustedX - componentX, + // isInLeftPanel, + // isDragging, + // }); + // } + + return ( +
+ {children} +
+ ); +}; + +export default SplitPanelAwareWrapper; diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelContext.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelContext.tsx new file mode 100644 index 00000000..fe3a9327 --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelContext.tsx @@ -0,0 +1,400 @@ +"use client"; + +import React, { createContext, useContext, useState, useCallback, useRef, useMemo } from "react"; + +/** + * SplitPanelResize Context 타입 정의 + * 분할 패널의 드래그 리사이즈 상태를 외부 컴포넌트(버튼 등)와 공유하기 위한 Context + * + * 주의: contexts/SplitPanelContext.tsx는 데이터 전달용 Context이고, + * 이 Context는 드래그 리사이즈 시 버튼 위치 조정을 위한 별도 Context입니다. + */ + +/** + * 분할 패널 정보 (컴포넌트 좌표 기준) + */ +export interface SplitPanelInfo { + id: string; + // 분할 패널의 좌표 (스크린 캔버스 기준, px) + x: number; + y: number; + width: number; + height: number; + // 좌측 패널 비율 (0-100) + leftWidthPercent: number; + // 초기 좌측 패널 비율 (드래그 시작 시점) + initialLeftWidthPercent: number; + // 드래그 중 여부 + isDragging: boolean; +} + +export interface SplitPanelResizeContextValue { + // 등록된 분할 패널들 + splitPanels: Map; + + // 분할 패널 등록/해제/업데이트 + registerSplitPanel: (id: string, info: Omit) => void; + unregisterSplitPanel: (id: string) => void; + updateSplitPanel: (id: string, updates: Partial) => void; + + // 컴포넌트가 어떤 분할 패널의 좌측 영역 위에 있는지 확인 + // 반환값: { panelId, offsetX } 또는 null + getOverlappingSplitPanel: ( + componentX: number, + componentY: number, + componentWidth: number, + componentHeight: number, + ) => { panelId: string; panel: SplitPanelInfo; isInLeftPanel: boolean } | null; + + // 컴포넌트의 조정된 X 좌표 계산 + // 분할 패널 좌측 영역 위에 있으면, 드래그에 따라 조정된 X 좌표 반환 + getAdjustedX: (componentX: number, componentY: number, componentWidth: number, componentHeight: number) => number; + + // 레거시 호환 (단일 분할 패널용) + leftWidthPercent: number; + containerRect: DOMRect | null; + dividerX: number; + isDragging: boolean; + splitPanelId: string | null; + updateLeftWidth: (percent: number) => void; + updateContainerRect: (rect: DOMRect | null) => void; + updateDragging: (dragging: boolean) => void; +} + +// Context 생성 +const SplitPanelResizeContext = createContext(null); + +/** + * SplitPanelResize Context Provider + * 스크린 빌더 레벨에서 감싸서 사용 + */ +export const SplitPanelProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + // 등록된 분할 패널들 + const splitPanelsRef = useRef>(new Map()); + const [, forceUpdate] = useState(0); + + // 레거시 호환용 상태 + const [legacyLeftWidthPercent, setLegacyLeftWidthPercent] = useState(30); + const [legacyContainerRect, setLegacyContainerRect] = useState(null); + const [legacyIsDragging, setLegacyIsDragging] = useState(false); + const [legacySplitPanelId, setLegacySplitPanelId] = useState(null); + + // 분할 패널 등록 + const registerSplitPanel = useCallback((id: string, info: Omit) => { + splitPanelsRef.current.set(id, { id, ...info }); + setLegacySplitPanelId(id); + setLegacyLeftWidthPercent(info.leftWidthPercent); + forceUpdate((n) => n + 1); + }, []); + + // 분할 패널 해제 + const unregisterSplitPanel = useCallback( + (id: string) => { + splitPanelsRef.current.delete(id); + if (legacySplitPanelId === id) { + setLegacySplitPanelId(null); + } + forceUpdate((n) => n + 1); + }, + [legacySplitPanelId], + ); + + // 분할 패널 업데이트 + const updateSplitPanel = useCallback((id: string, updates: Partial) => { + const panel = splitPanelsRef.current.get(id); + if (panel) { + const updatedPanel = { ...panel, ...updates }; + splitPanelsRef.current.set(id, updatedPanel); + + // 레거시 호환 상태 업데이트 + if (updates.leftWidthPercent !== undefined) { + setLegacyLeftWidthPercent(updates.leftWidthPercent); + } + if (updates.isDragging !== undefined) { + setLegacyIsDragging(updates.isDragging); + } + + forceUpdate((n) => n + 1); + } + }, []); + + /** + * 컴포넌트가 어떤 분할 패널의 좌측 영역 위에 있는지 확인 + */ + const getOverlappingSplitPanel = useCallback( + ( + componentX: number, + componentY: number, + componentWidth: number, + componentHeight: number, + ): { panelId: string; panel: SplitPanelInfo; isInLeftPanel: boolean } | null => { + for (const [panelId, panel] of splitPanelsRef.current) { + // 컴포넌트의 중심점 + const componentCenterX = componentX + componentWidth / 2; + const componentCenterY = componentY + componentHeight / 2; + + // 컴포넌트가 분할 패널 영역 내에 있는지 확인 + const isInPanelX = componentCenterX >= panel.x && componentCenterX <= panel.x + panel.width; + const isInPanelY = componentCenterY >= panel.y && componentCenterY <= panel.y + panel.height; + + if (isInPanelX && isInPanelY) { + // 좌측 패널의 현재 너비 (px) + const leftPanelWidth = (panel.width * panel.leftWidthPercent) / 100; + // 좌측 패널 경계 (분할 패널 기준 상대 좌표) + const dividerX = panel.x + leftPanelWidth; + + // 컴포넌트 중심이 좌측 패널 내에 있는지 확인 + const isInLeftPanel = componentCenterX < dividerX; + + return { panelId, panel, isInLeftPanel }; + } + } + return null; + }, + [], + ); + + /** + * 컴포넌트의 조정된 X 좌표 계산 + * 분할 패널 좌측 영역 위에 있으면, 드래그에 따라 조정된 X 좌표 반환 + * + * 핵심 로직: + * - 버튼의 원래 X 좌표가 초기 좌측 패널 너비 내에서 어느 비율에 있는지 계산 + * - 드래그로 좌측 패널 너비가 바뀌면, 같은 비율을 유지하도록 X 좌표 조정 + */ + const getAdjustedX = useCallback( + (componentX: number, componentY: number, componentWidth: number, componentHeight: number): number => { + const overlap = getOverlappingSplitPanel(componentX, componentY, componentWidth, componentHeight); + + if (!overlap || !overlap.isInLeftPanel) { + // 분할 패널 위에 없거나, 우측 패널 위에 있으면 원래 위치 유지 + return componentX; + } + + const { panel } = overlap; + + // 초기 좌측 패널 너비 (설정된 splitRatio 기준) + const initialLeftPanelWidth = (panel.width * panel.initialLeftWidthPercent) / 100; + // 현재 좌측 패널 너비 (드래그로 변경된 값) + const currentLeftPanelWidth = (panel.width * panel.leftWidthPercent) / 100; + + // 변화가 없으면 원래 위치 반환 + if (Math.abs(initialLeftPanelWidth - currentLeftPanelWidth) < 1) { + return componentX; + } + + // 컴포넌트의 분할 패널 내 상대 X 좌표 + const relativeX = componentX - panel.x; + + // 좌측 패널 내에서의 비율 (0~1) + const ratioInLeftPanel = relativeX / initialLeftPanelWidth; + + // 조정된 상대 X 좌표 = 원래 비율 * 현재 좌측 패널 너비 + const adjustedRelativeX = ratioInLeftPanel * currentLeftPanelWidth; + + // 절대 X 좌표로 변환 + const adjustedX = panel.x + adjustedRelativeX; + + console.log("📍 [SplitPanel] 버튼 위치 조정:", { + componentX, + panelX: panel.x, + relativeX, + initialLeftPanelWidth, + currentLeftPanelWidth, + ratioInLeftPanel, + adjustedX, + delta: adjustedX - componentX, + }); + + return adjustedX; + }, + [getOverlappingSplitPanel], + ); + + // 레거시 호환 - dividerX 계산 + const legacyDividerX = legacyContainerRect ? (legacyContainerRect.width * legacyLeftWidthPercent) / 100 : 0; + + // 레거시 호환 함수들 + const updateLeftWidth = useCallback((percent: number) => { + setLegacyLeftWidthPercent(percent); + // 첫 번째 분할 패널 업데이트 + const firstPanelId = splitPanelsRef.current.keys().next().value; + if (firstPanelId) { + const panel = splitPanelsRef.current.get(firstPanelId); + if (panel) { + splitPanelsRef.current.set(firstPanelId, { ...panel, leftWidthPercent: percent }); + } + } + forceUpdate((n) => n + 1); + }, []); + + const updateContainerRect = useCallback((rect: DOMRect | null) => { + setLegacyContainerRect(rect); + }, []); + + const updateDragging = useCallback((dragging: boolean) => { + setLegacyIsDragging(dragging); + // 첫 번째 분할 패널 업데이트 + const firstPanelId = splitPanelsRef.current.keys().next().value; + if (firstPanelId) { + const panel = splitPanelsRef.current.get(firstPanelId); + if (panel) { + // 드래그 시작 시 초기 비율 저장 + const updates: Partial = { isDragging: dragging }; + if (dragging) { + updates.initialLeftWidthPercent = panel.leftWidthPercent; + } + splitPanelsRef.current.set(firstPanelId, { ...panel, ...updates }); + } + } + forceUpdate((n) => n + 1); + }, []); + + const value = useMemo( + () => ({ + splitPanels: splitPanelsRef.current, + registerSplitPanel, + unregisterSplitPanel, + updateSplitPanel, + getOverlappingSplitPanel, + getAdjustedX, + // 레거시 호환 + leftWidthPercent: legacyLeftWidthPercent, + containerRect: legacyContainerRect, + dividerX: legacyDividerX, + isDragging: legacyIsDragging, + splitPanelId: legacySplitPanelId, + updateLeftWidth, + updateContainerRect, + updateDragging, + }), + [ + registerSplitPanel, + unregisterSplitPanel, + updateSplitPanel, + getOverlappingSplitPanel, + getAdjustedX, + legacyLeftWidthPercent, + legacyContainerRect, + legacyDividerX, + legacyIsDragging, + legacySplitPanelId, + updateLeftWidth, + updateContainerRect, + updateDragging, + ], + ); + + return {children}; +}; + +/** + * SplitPanelResize Context 사용 훅 + * 분할 패널의 드래그 리사이즈 상태를 구독합니다. + */ +export const useSplitPanel = (): SplitPanelResizeContextValue => { + const context = useContext(SplitPanelResizeContext); + + // Context가 없으면 기본값 반환 (Provider 외부에서 사용 시) + if (!context) { + return { + splitPanels: new Map(), + registerSplitPanel: () => {}, + unregisterSplitPanel: () => {}, + updateSplitPanel: () => {}, + getOverlappingSplitPanel: () => null, + getAdjustedX: (x) => x, + leftWidthPercent: 30, + containerRect: null, + dividerX: 0, + isDragging: false, + splitPanelId: null, + updateLeftWidth: () => {}, + updateContainerRect: () => {}, + updateDragging: () => {}, + }; + } + + return context; +}; + +/** + * 컴포넌트의 조정된 위치를 계산하는 훅 + * 분할 패널 좌측 영역 위에 있으면, 드래그에 따라 X 좌표가 조정됨 + * + * @param componentX - 컴포넌트의 X 좌표 (px) + * @param componentY - 컴포넌트의 Y 좌표 (px) + * @param componentWidth - 컴포넌트 너비 (px) + * @param componentHeight - 컴포넌트 높이 (px) + * @returns 조정된 X 좌표와 관련 정보 + */ +export const useAdjustedComponentPosition = ( + componentX: number, + componentY: number, + componentWidth: number, + componentHeight: number, +) => { + const context = useSplitPanel(); + + const adjustedX = context.getAdjustedX(componentX, componentY, componentWidth, componentHeight); + const overlap = context.getOverlappingSplitPanel(componentX, componentY, componentWidth, componentHeight); + + return { + adjustedX, + isInSplitPanel: !!overlap, + isInLeftPanel: overlap?.isInLeftPanel ?? false, + isDragging: overlap?.panel.isDragging ?? false, + panelId: overlap?.panelId ?? null, + }; +}; + +/** + * 버튼 등 외부 컴포넌트에서 분할 패널 좌측 영역 내 위치를 계산하는 훅 (레거시 호환) + */ +export const useAdjustedPosition = (originalXPercent: number) => { + const { leftWidthPercent, containerRect, dividerX, isDragging } = useSplitPanel(); + + const isInLeftPanel = originalXPercent <= leftWidthPercent; + const adjustedXPercent = isInLeftPanel ? (originalXPercent / 100) * leftWidthPercent : originalXPercent; + const adjustedXPx = containerRect ? (containerRect.width * adjustedXPercent) / 100 : 0; + + return { + adjustedXPercent, + adjustedXPx, + isInLeftPanel, + isDragging, + dividerX, + containerRect, + leftWidthPercent, + }; +}; + +/** + * 버튼이 좌측 패널 위에 배치되었을 때, 드래그에 따라 위치가 조정되는 스타일을 반환하는 훅 (레거시 호환) + */ +export const useSplitPanelAwarePosition = ( + initialLeftPercent: number, + options?: { + followDivider?: boolean; + offset?: number; + }, +) => { + const { leftWidthPercent, containerRect, dividerX, isDragging } = useSplitPanel(); + const { followDivider = false, offset = 0 } = options || {}; + + if (followDivider) { + return { + left: containerRect ? `${dividerX + offset}px` : `${leftWidthPercent}%`, + transition: isDragging ? "none" : "left 0.15s ease-out", + }; + } + + const adjustedLeft = (initialLeftPercent / 100) * leftWidthPercent; + + return { + left: `${adjustedLeft}%`, + transition: isDragging ? "none" : "left 0.15s ease-out", + }; +}; + +export default SplitPanelResizeContext; diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index e8014327..9d1d0811 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useCallback, useEffect, useMemo } from "react"; +import React, { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { ComponentRendererProps } from "../../types"; import { SplitPanelLayoutConfig } from "./types"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -36,6 +36,7 @@ import { Label } from "@/components/ui/label"; import { useTableOptions } from "@/contexts/TableOptionsContext"; import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options"; import { useAuth } from "@/hooks/useAuth"; +import { useSplitPanel } from "./SplitPanelContext"; export interface SplitPanelLayoutComponentProps extends ComponentRendererProps { // 추가 props @@ -182,6 +183,120 @@ export const SplitPanelLayoutComponent: React.FC const [leftWidth, setLeftWidth] = useState(splitRatio); const containerRef = React.useRef(null); + // 🆕 SplitPanel Resize Context 연동 (버튼 등 외부 컴포넌트와 드래그 리사이즈 상태 공유) + const splitPanelContext = useSplitPanel(); + const { + registerSplitPanel: ctxRegisterSplitPanel, + unregisterSplitPanel: ctxUnregisterSplitPanel, + updateSplitPanel: ctxUpdateSplitPanel, + } = splitPanelContext; + const splitPanelId = `split-panel-${component.id}`; + + // 디버깅: Context 연결 상태 확인 + console.log("🔗 [SplitPanelLayout] Context 연결 상태:", { + componentId: component.id, + splitPanelId, + hasRegisterFunc: typeof ctxRegisterSplitPanel === "function", + splitPanelsSize: splitPanelContext.splitPanels?.size ?? "없음", + }); + + // Context에 분할 패널 등록 (좌표 정보 포함) - 마운트 시 1회만 실행 + const ctxRegisterRef = useRef(ctxRegisterSplitPanel); + const ctxUnregisterRef = useRef(ctxUnregisterSplitPanel); + ctxRegisterRef.current = ctxRegisterSplitPanel; + ctxUnregisterRef.current = ctxUnregisterSplitPanel; + + useEffect(() => { + // 컴포넌트의 위치와 크기 정보 + const panelX = component.position?.x || 0; + const panelY = component.position?.y || 0; + const panelWidth = component.size?.width || component.style?.width || 800; + const panelHeight = component.size?.height || component.style?.height || 600; + + const panelInfo = { + x: panelX, + y: panelY, + width: typeof panelWidth === "number" ? panelWidth : parseInt(String(panelWidth)) || 800, + height: typeof panelHeight === "number" ? panelHeight : parseInt(String(panelHeight)) || 600, + leftWidthPercent: splitRatio, // 초기값은 splitRatio 사용 + initialLeftWidthPercent: splitRatio, + isDragging: false, + }; + + console.log("📦 [SplitPanelLayout] Context에 분할 패널 등록:", { + splitPanelId, + panelInfo, + }); + + ctxRegisterRef.current(splitPanelId, panelInfo); + + return () => { + console.log("📦 [SplitPanelLayout] Context에서 분할 패널 해제:", splitPanelId); + ctxUnregisterRef.current(splitPanelId); + }; + // 마운트/언마운트 시에만 실행, 위치/크기 변경은 별도 업데이트로 처리 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [splitPanelId]); + + // 위치/크기 변경 시 Context 업데이트 (등록 후) + const ctxUpdateRef = useRef(ctxUpdateSplitPanel); + ctxUpdateRef.current = ctxUpdateSplitPanel; + + useEffect(() => { + const panelX = component.position?.x || 0; + const panelY = component.position?.y || 0; + const panelWidth = component.size?.width || component.style?.width || 800; + const panelHeight = component.size?.height || component.style?.height || 600; + + ctxUpdateRef.current(splitPanelId, { + x: panelX, + y: panelY, + width: typeof panelWidth === "number" ? panelWidth : parseInt(String(panelWidth)) || 800, + height: typeof panelHeight === "number" ? panelHeight : parseInt(String(panelHeight)) || 600, + }); + }, [ + splitPanelId, + component.position?.x, + component.position?.y, + component.size?.width, + component.size?.height, + component.style?.width, + component.style?.height, + ]); + + // leftWidth 변경 시 Context 업데이트 + useEffect(() => { + ctxUpdateRef.current(splitPanelId, { leftWidthPercent: leftWidth }); + }, [leftWidth, splitPanelId]); + + // 드래그 상태 변경 시 Context 업데이트 + // 이전 드래그 상태를 추적하여 드래그 종료 시점을 감지 + const prevIsDraggingRef = useRef(false); + + useEffect(() => { + const wasJustDragging = prevIsDraggingRef.current && !isDragging; + + if (isDragging) { + // 드래그 시작 시: 현재 비율을 초기 비율로 저장 + ctxUpdateRef.current(splitPanelId, { + isDragging: true, + initialLeftWidthPercent: leftWidth, + }); + } else if (wasJustDragging) { + // 드래그 종료 시: 최종 비율을 초기 비율로 업데이트 (버튼 위치 고정) + ctxUpdateRef.current(splitPanelId, { + isDragging: false, + initialLeftWidthPercent: leftWidth, + }); + console.log("🛑 [SplitPanelLayout] 드래그 종료 - 버튼 위치 고정:", { + splitPanelId, + finalLeftWidthPercent: leftWidth, + }); + } + + prevIsDraggingRef.current = isDragging; + }, [isDragging, splitPanelId, leftWidth]); + // 🆕 그룹별 합산된 데이터 계산 const summedLeftData = useMemo(() => { console.log("🔍 [그룹합산] leftGroupSumConfig:", leftGroupSumConfig); diff --git a/frontend/lib/registry/components/split-panel-layout/index.ts b/frontend/lib/registry/components/split-panel-layout/index.ts index 080eab8a..9bbb56cb 100644 --- a/frontend/lib/registry/components/split-panel-layout/index.ts +++ b/frontend/lib/registry/components/split-panel-layout/index.ts @@ -58,3 +58,13 @@ export type { SplitPanelLayoutConfig } from "./types"; // 컴포넌트 내보내기 export { SplitPanelLayoutComponent } from "./SplitPanelLayoutComponent"; export { SplitPanelLayoutRenderer } from "./SplitPanelLayoutRenderer"; + +// Resize Context 내보내기 (버튼 등 외부 컴포넌트에서 분할 패널 드래그 리사이즈 상태 활용) +export { + SplitPanelProvider, + useSplitPanel, + useAdjustedPosition, + useSplitPanelAwarePosition, + useAdjustedComponentPosition, +} from "./SplitPanelContext"; +export type { SplitPanelResizeContextValue, SplitPanelInfo } from "./SplitPanelContext"; From 0ee49b77aed778b7981efd67a31e4ff14586c4c7 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 12 Dec 2025 10:44:59 +0900 Subject: [PATCH 19/30] =?UTF-8?q?=EC=84=A4=EB=B9=84=20=ED=92=88=EB=AA=A9?= =?UTF-8?q?=20=ED=95=98=EB=82=98=EB=A7=8C=20=EC=B6=94=EA=B0=80=EB=90=98?= =?UTF-8?q?=EB=8A=94=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/common/ScreenModal.tsx | 79 ++++++++++++++++------ frontend/lib/utils/buttonActions.ts | 79 ++++++++++++++++++---- 2 files changed, 124 insertions(+), 34 deletions(-) diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 4da781e6..811249a7 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -1,13 +1,7 @@ "use client"; import React, { useState, useEffect, useRef } from "react"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, -} from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic"; @@ -183,15 +177,66 @@ export const ScreenModal: React.FC = ({ className }) => { setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용) } else { // 🆕 신규 등록 모드: 분할 패널 부모 데이터가 있으면 미리 설정 - // 1순위: 이벤트로 전달된 splitPanelParentData (탭 안에서 열린 모달) - // 2순위: splitPanelContext에서 직접 가져온 데이터 (분할 패널 내에서 열린 모달) - const parentData = + // 🔧 중요: 신규 등록 시에는 연결 필드(equipment_code 등)만 전달해야 함 + // 모든 필드를 전달하면 동일한 컬럼명이 있을 때 부모 값이 들어가는 문제 발생 + // 예: 설비의 manufacturer가 소모품의 manufacturer로 들어감 + + // parentDataMapping에서 명시된 필드만 추출 + const parentDataMapping = splitPanelContext?.parentDataMapping || []; + + // 부모 데이터 소스 + const rawParentData = splitPanelParentData && Object.keys(splitPanelParentData).length > 0 ? splitPanelParentData - : splitPanelContext?.getMappedParentData() || {}; + : splitPanelContext?.selectedLeftData || {}; + + // 🔧 신규 등록 모드에서는 연결에 필요한 필드만 전달 + const parentData: Record = {}; + + // 필수 연결 필드: company_code (멀티테넌시) + if (rawParentData.company_code) { + parentData.company_code = rawParentData.company_code; + } + + // parentDataMapping에 정의된 필드만 전달 + for (const mapping of parentDataMapping) { + const sourceValue = rawParentData[mapping.sourceColumn]; + if (sourceValue !== undefined && sourceValue !== null) { + parentData[mapping.targetColumn] = sourceValue; + console.log( + `🔗 [ScreenModal] 매핑 필드 전달: ${mapping.sourceColumn} → ${mapping.targetColumn} = ${sourceValue}`, + ); + } + } + + // parentDataMapping이 비어있으면 연결 필드 자동 감지 (equipment_code, xxx_code, xxx_id 패턴) + if (parentDataMapping.length === 0) { + const linkFieldPatterns = ["_code", "_id"]; + const excludeFields = [ + "id", + "company_code", + "created_date", + "updated_date", + "created_at", + "updated_at", + "writer", + ]; + + for (const [key, value] of Object.entries(rawParentData)) { + if (excludeFields.includes(key)) continue; + if (value === undefined || value === null) continue; + + // 연결 필드 패턴 확인 + const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern)); + if (isLinkField) { + parentData[key] = value; + console.log(`🔗 [ScreenModal] 연결 필드 자동 감지: ${key} = ${value}`); + } + } + } if (Object.keys(parentData).length > 0) { - console.log("🔗 [ScreenModal] 분할 패널 부모 데이터 초기값 설정:", parentData); + console.log("🔗 [ScreenModal] 분할 패널 부모 데이터 초기값 설정 (연결 필드만):", parentData); setFormData(parentData); } else { setFormData({}); @@ -604,19 +649,15 @@ export const ScreenModal: React.FC = ({ className }) => {
{modalState.title} {modalState.description && !loading && ( - - {modalState.description} - + {modalState.description} )} {loading && ( - - {loading ? "화면을 불러오는 중입니다..." : ""} - + {loading ? "화면을 불러오는 중입니다..." : ""} )}
-
+
{loading ? (
diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 1ced2836..e7da5833 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -681,13 +681,52 @@ export class ButtonActionExecutor { console.log("📦 최종 formData:", JSON.stringify(formData, null, 2)); // 🆕 분할 패널 부모 데이터 병합 (좌측 화면에서 선택된 데이터) - const splitPanelData = context.splitPanelParentData || {}; - if (Object.keys(splitPanelData).length > 0) { - console.log("🔗 [handleSave] 분할 패널 부모 데이터 병합:", splitPanelData); + // 🔧 중요: 신규 등록 시에는 연결 필드(equipment_code 등)만 병합해야 함 + // 모든 필드를 병합하면 동일한 컬럼명이 있을 때 부모 값이 들어가는 문제 발생 + // 예: 설비의 manufacturer가 소모품의 manufacturer로 들어감 + const rawSplitPanelData = context.splitPanelParentData || {}; + + // INSERT 모드에서는 연결에 필요한 필드만 추출 + const cleanedSplitPanelData: Record = {}; + + // 필수 연결 필드: company_code (멀티테넌시) + if (rawSplitPanelData.company_code) { + cleanedSplitPanelData.company_code = rawSplitPanelData.company_code; + } + + // 연결 필드 패턴으로 자동 감지 (equipment_code, xxx_code, xxx_id 패턴) + const linkFieldPatterns = ["_code", "_id"]; + const excludeFields = [ + "id", + "company_code", + "created_date", + "updated_date", + "created_at", + "updated_at", + "writer", + "created_by", + "updated_by", + ]; + + for (const [key, value] of Object.entries(rawSplitPanelData)) { + if (excludeFields.includes(key)) continue; + if (value === undefined || value === null) continue; + + // 연결 필드 패턴 확인 + const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern)); + if (isLinkField) { + cleanedSplitPanelData[key] = value; + console.log(`🔗 [handleSave] INSERT 모드 - 연결 필드만 병합: ${key} = ${value}`); + } + } + + if (Object.keys(rawSplitPanelData).length > 0) { + console.log("🧹 [handleSave] 원본 분할 패널 부모 데이터:", Object.keys(rawSplitPanelData)); + console.log("🧹 [handleSave] 정리된 분할 패널 부모 데이터 (연결 필드만):", cleanedSplitPanelData); } const dataWithUserInfo = { - ...splitPanelData, // 분할 패널 부모 데이터 먼저 적용 + ...cleanedSplitPanelData, // 정리된 분할 패널 부모 데이터 먼저 적용 ...formData, // 폼 데이터가 우선 (덮어쓰기 가능) writer: formData.writer || writerValue, // ✅ 입력값 우선, 없으면 userId created_by: writerValue, // created_by는 항상 로그인한 사람 @@ -695,6 +734,12 @@ export class ButtonActionExecutor { company_code: formData.company_code || companyCodeValue, // ✅ 입력값 우선, 없으면 user.companyCode }; + // 🔧 formData에서도 id 제거 (신규 INSERT이므로) + if ("id" in dataWithUserInfo && !formData.id) { + console.log("🗑️ [handleSave] INSERT 모드 - dataWithUserInfo에서 id 제거:", dataWithUserInfo.id); + delete dataWithUserInfo.id; + } + // _numberingRuleId 필드 제거 (실제 저장하지 않음) for (const key of Object.keys(dataWithUserInfo)) { if (key.endsWith("_numberingRuleId")) { @@ -1578,14 +1623,16 @@ export class ButtonActionExecutor { /** * 모달 액션 처리 + * 🔧 modal 액션은 항상 신규 등록(INSERT) 모드로 동작 + * edit 액션만 수정(UPDATE) 모드로 동작해야 함 */ private static async handleModal(config: ButtonActionConfig, context: ButtonActionContext): Promise { // 모달 열기 로직 - console.log("모달 열기:", { + console.log("모달 열기 (신규 등록 모드):", { title: config.modalTitle, size: config.modalSize, targetScreenId: config.targetScreenId, - selectedRowsData: context.selectedRowsData, + // 🔧 selectedRowsData는 modal 액션에서 사용하지 않음 (신규 등록이므로) }); if (config.targetScreenId) { @@ -1602,10 +1649,11 @@ export class ButtonActionExecutor { } } - // 🆕 선택된 행 데이터 수집 - const selectedData = context.selectedRowsData || []; - console.log("📦 [handleModal] 선택된 데이터:", selectedData); - console.log("📦 [handleModal] 분할 패널 부모 데이터:", context.splitPanelParentData); + // 🔧 modal 액션은 신규 등록이므로 selectedData를 전달하지 않음 + // selectedData가 있으면 ScreenModal에서 originalData로 인식하여 UPDATE 모드로 동작하게 됨 + // edit 액션만 selectedData/editData를 사용하여 UPDATE 모드로 동작 + console.log("📦 [handleModal] 신규 등록 모드 - selectedData 전달하지 않음"); + console.log("📦 [handleModal] 분할 패널 부모 데이터 (초기값으로 사용):", context.splitPanelParentData); // 전역 모달 상태 업데이트를 위한 이벤트 발생 const modalEvent = new CustomEvent("openScreenModal", { @@ -1614,10 +1662,11 @@ export class ButtonActionExecutor { title: config.modalTitle || "화면", description: description, size: config.modalSize || "md", - // 🆕 선택된 행 데이터 전달 - selectedData: selectedData, - selectedIds: selectedData.map((row: any) => row.id).filter(Boolean), - // 🆕 분할 패널 부모 데이터 전달 (탭 안 모달에서 사용) + // 🔧 신규 등록이므로 selectedData/selectedIds를 전달하지 않음 + // edit 액션에서만 이 데이터를 사용 + selectedData: [], + selectedIds: [], + // 🆕 분할 패널 부모 데이터 전달 (탭 안 모달에서 초기값으로 사용) splitPanelParentData: context.splitPanelParentData || {}, }, }); @@ -2663,7 +2712,7 @@ export class ButtonActionExecutor { const { executeNodeFlow } = await import("@/lib/api/nodeFlows"); // 데이터 소스 준비 - let sourceData: any = context.formData || {}; + const sourceData: any = context.formData || {}; // repeat-screen-modal 데이터가 있으면 병합 const repeatScreenModalKeys = Object.keys(context.formData || {}).filter((key) => From 3a6af2fb71c971bab9b275c39326efb8fbcb6ba5 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 12 Dec 2025 13:50:33 +0900 Subject: [PATCH 20/30] =?UTF-8?q?=EB=B6=84=ED=95=A0=ED=8C=A8=EB=84=90=20?= =?UTF-8?q?=EC=A1=B0=EC=9D=B8=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SplitPanelLayoutComponent.tsx | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index 9d1d0811..ac5cc8d1 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -100,6 +100,13 @@ export const SplitPanelLayoutComponent: React.FC return item[exactKey]; } + // 🆕 2-1️⃣ item_id 패턴 시도 (백엔드가 item_id_xxx 형식으로 반환하는 경우) + // 예: item_info.item_name → item_id_item_name + const idPatternKey = `${tableName.replace("_info", "_id").replace("_mng", "_id")}_${fieldName}`; + if (item[idPatternKey] !== undefined) { + return item[idPatternKey]; + } + // 3️⃣ 별칭 패턴: 소스컬럼_name (기본 표시 컬럼용) // 예: item_code_name (item_name의 별칭) if (fieldName === "item_name" || fieldName === "name") { @@ -107,6 +114,11 @@ export const SplitPanelLayoutComponent: React.FC if (item[aliasKey] !== undefined) { return item[aliasKey]; } + // 🆕 item_id_name 패턴도 시도 + const idAliasKey = `${tableName.replace("_info", "_id").replace("_mng", "_id")}_name`; + if (item[idAliasKey] !== undefined) { + return item[idAliasKey]; + } } // 4️⃣ entityColumnMap에서 매핑 찾기 (화면 설정에서 지정된 경우) @@ -1023,7 +1035,7 @@ export const SplitPanelLayoutComponent: React.FC const uniqueValues = new Set(); leftData.forEach((item) => { - // 🆕 조인 컬럼 처리 (item_info.standard → item_code_standard) + // 🆕 조인 컬럼 처리 (item_info.standard → item_code_standard 또는 item_id_standard) let value: any; if (columnName.includes(".")) { @@ -1035,10 +1047,21 @@ export const SplitPanelLayoutComponent: React.FC const exactKey = `${inferredSourceColumn}_${fieldName}`; value = item[exactKey]; - // 기본 별칭 패턴 시도 (item_code_name) + // 🆕 item_id 패턴 시도 + if (value === undefined) { + const idPatternKey = `${refTable.replace("_info", "_id").replace("_mng", "_id")}_${fieldName}`; + value = item[idPatternKey]; + } + + // 기본 별칭 패턴 시도 (item_code_name 또는 item_id_name) if (value === undefined && (fieldName === "item_name" || fieldName === "name")) { const aliasKey = `${inferredSourceColumn}_name`; value = item[aliasKey]; + // item_id_name 패턴도 시도 + if (value === undefined) { + const idAliasKey = `${refTable.replace("_info", "_id").replace("_mng", "_id")}_name`; + value = item[idAliasKey]; + } } } else { // 일반 컬럼 From 722718b7edce1dd36a1650f75b4496bab86605ed Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 12 Dec 2025 14:37:24 +0900 Subject: [PATCH 21/30] =?UTF-8?q?=EC=84=A4=EB=B9=84=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=95=88?= =?UTF-8?q?=EB=84=98=EC=96=B4=EC=98=A4=EB=8A=94=20=ED=98=84=EC=83=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/screen/EditModal.tsx | 62 +++---- .../button-primary/ButtonPrimaryComponent.tsx | 159 ++++++++++-------- .../UniversalFormModalComponent.tsx | 75 ++++++--- 3 files changed, 165 insertions(+), 131 deletions(-) diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index e955fddc..85e502a0 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -62,7 +62,7 @@ export const EditModal: React.FC = ({ className }) => { // 폼 데이터 상태 (편집 데이터로 초기화됨) const [formData, setFormData] = useState>({}); const [originalData, setOriginalData] = useState>({}); - + // 🆕 그룹 데이터 상태 (같은 order_no의 모든 품목) const [groupData, setGroupData] = useState[]>([]); const [originalGroupData, setOriginalGroupData] = useState[]>([]); @@ -118,7 +118,8 @@ export const EditModal: React.FC = ({ className }) => { // 전역 모달 이벤트 리스너 useEffect(() => { const handleOpenEditModal = (event: CustomEvent) => { - const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName, isCreateMode } = event.detail; + const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName, isCreateMode } = + event.detail; setModalState({ isOpen: true, @@ -136,8 +137,8 @@ export const EditModal: React.FC = ({ className }) => { setFormData(editData || {}); // 🆕 isCreateMode가 true이면 originalData를 빈 객체로 설정 (INSERT 모드) // originalData가 비어있으면 INSERT, 있으면 UPDATE로 처리됨 - setOriginalData(isCreateMode ? {} : (editData || {})); - + setOriginalData(isCreateMode ? {} : editData || {}); + if (isCreateMode) { console.log("[EditModal] 생성 모드로 열림, 초기값:", editData); } @@ -170,7 +171,7 @@ export const EditModal: React.FC = ({ className }) => { useEffect(() => { if (modalState.isOpen && modalState.screenId) { loadScreenData(modalState.screenId); - + // 🆕 그룹 데이터 조회 (groupByColumns가 있는 경우) if (modalState.groupByColumns && modalState.groupByColumns.length > 0 && modalState.tableName) { loadGroupData(); @@ -308,7 +309,7 @@ export const EditModal: React.FC = ({ className }) => { // universal-form-modal 등에서 자체 저장 완료 후 호출된 경우 스킵 if (saveData?._saveCompleted) { console.log("[EditModal] 자체 저장 완료된 컴포넌트에서 호출됨 - 저장 스킵"); - + // 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침) if (modalState.onSave) { try { @@ -317,7 +318,7 @@ export const EditModal: React.FC = ({ className }) => { console.error("onSave 콜백 에러:", callbackError); } } - + handleClose(); return; } @@ -342,13 +343,13 @@ export const EditModal: React.FC = ({ className }) => { // 🆕 날짜 필드 정규화 함수 (YYYY-MM-DD 형식으로 변환) const normalizeDateField = (value: any): string | null => { if (!value) return null; - + // ISO 8601 형식 (2025-11-26T00:00:00.000Z) 또는 Date 객체 if (value instanceof Date || typeof value === "string") { try { const date = new Date(value); if (isNaN(date.getTime())) return null; - + // YYYY-MM-DD 형식으로 변환 const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); @@ -359,7 +360,7 @@ export const EditModal: React.FC = ({ className }) => { return null; } } - + return null; }; @@ -380,7 +381,7 @@ export const EditModal: React.FC = ({ className }) => { const insertData: Record = { ...currentData }; console.log("📦 [신규 품목] 복사 직후 insertData:", insertData); console.log("📋 [신규 품목] insertData 키 목록:", Object.keys(insertData)); - + delete insertData.id; // id는 자동 생성되므로 제거 // 🆕 날짜 필드 정규화 (YYYY-MM-DD 형식으로 변환) @@ -464,9 +465,7 @@ export const EditModal: React.FC = ({ className }) => { for (const currentData of groupData) { if (currentData.id) { // id 기반 매칭 (인덱스 기반 X) - const originalItemData = originalGroupData.find( - (orig) => orig.id === currentData.id - ); + const originalItemData = originalGroupData.find((orig) => orig.id === currentData.id); if (!originalItemData) { console.warn(`원본 데이터를 찾을 수 없습니다 (id: ${currentData.id})`); @@ -476,13 +475,13 @@ export const EditModal: React.FC = ({ className }) => { // 🆕 값 정규화 함수 (타입 통일) const normalizeValue = (val: any, fieldName?: string): any => { if (val === null || val === undefined || val === "") return null; - + // 날짜 필드인 경우 YYYY-MM-DD 형식으로 정규화 if (fieldName && dateFields.includes(fieldName)) { const normalizedDate = normalizeDateField(val); return normalizedDate; } - + if (typeof val === "string" && !isNaN(Number(val))) { // 숫자로 변환 가능한 문자열은 숫자로 return Number(val); @@ -539,9 +538,7 @@ export const EditModal: React.FC = ({ className }) => { // 3️⃣ 삭제된 품목 제거 (원본에는 있지만 현재 데이터에는 없는 항목) const currentIds = new Set(groupData.map((item) => item.id).filter(Boolean)); - const deletedItems = originalGroupData.filter( - (orig) => orig.id && !currentIds.has(orig.id) - ); + const deletedItems = originalGroupData.filter((orig) => orig.id && !currentIds.has(orig.id)); for (const deletedItem of deletedItems) { console.log("🗑️ 품목 삭제:", deletedItem); @@ -549,7 +546,7 @@ export const EditModal: React.FC = ({ className }) => { try { const response = await dynamicFormApi.deleteFormDataFromTable( deletedItem.id, - screenData.screenInfo.tableName + screenData.screenInfo.tableName, ); if (response.success) { @@ -592,11 +589,11 @@ export const EditModal: React.FC = ({ className }) => { // originalData가 비어있으면 INSERT, 있으면 UPDATE const isCreateMode = Object.keys(originalData).length === 0; - + if (isCreateMode) { // INSERT 모드 console.log("[EditModal] INSERT 모드 - 새 데이터 생성:", formData); - + const response = await dynamicFormApi.saveFormData({ screenId: modalState.screenId!, tableName: screenData.screenInfo.tableName, @@ -701,10 +698,7 @@ export const EditModal: React.FC = ({ className }) => { return ( - +
{modalState.title || "데이터 수정"} @@ -717,7 +711,7 @@ export const EditModal: React.FC = ({ className }) => {
-
+
{loading ? (
@@ -751,7 +745,6 @@ export const EditModal: React.FC = ({ className }) => { }, }; - const groupedDataProp = groupData.length > 0 ? groupData : undefined; // 🔑 첨부파일 컴포넌트가 행(레코드) 단위로 파일을 저장할 수 있도록 tableName 추가 @@ -760,7 +753,7 @@ export const EditModal: React.FC = ({ className }) => { tableName: screenData.screenInfo?.tableName, // 테이블명 추가 screenId: modalState.screenId, // 화면 ID 추가 }; - + // 🔍 디버깅: enrichedFormData 확인 console.log("🔑 [EditModal] enrichedFormData 생성:", { "screenData.screenInfo": screenData.screenInfo, @@ -775,6 +768,7 @@ export const EditModal: React.FC = ({ className }) => { component={adjustedComponent} allComponents={screenData.components} formData={enrichedFormData} + originalData={originalData} // 🆕 원본 데이터 전달 (수정 모드에서 UniversalFormModal 초기화용) onFormDataChange={(fieldName, value) => { // 🆕 그룹 데이터가 있으면 처리 if (groupData.length > 0) { @@ -787,14 +781,14 @@ export const EditModal: React.FC = ({ className }) => { prev.map((item) => ({ ...item, [fieldName]: value, - })) + })), ); } } else { - setFormData((prev) => ({ - ...prev, - [fieldName]: value, - })); + setFormData((prev) => ({ + ...prev, + [fieldName]: value, + })); } }} screenInfo={{ diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index ae6a01d8..b453729d 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -47,7 +47,7 @@ export interface ButtonPrimaryComponentProps extends ComponentRendererProps { // 테이블 선택된 행 정보 (다중 선택 액션용) selectedRows?: any[]; selectedRowsData?: any[]; - + // 테이블 정렬 정보 (엑셀 다운로드용) sortBy?: string; sortOrder?: "asc" | "desc"; @@ -57,10 +57,10 @@ export interface ButtonPrimaryComponentProps extends ComponentRendererProps { // 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용) flowSelectedData?: any[]; flowSelectedStepId?: number | null; - + // 🆕 같은 화면의 모든 컴포넌트 (TableList 자동 감지용) allComponents?: any[]; - + // 🆕 부모창에서 전달된 그룹 데이터 (모달에서 부모 데이터 접근용) groupedData?: Record[]; } @@ -109,11 +109,11 @@ export const ButtonPrimaryComponent: React.FC = ({ const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트 // 🆕 ScreenContext에서 splitPanelPosition 가져오기 (중첩 화면에서도 작동) const splitPanelPosition = screenContext?.splitPanelPosition; - + // 🆕 tableName이 props로 전달되지 않으면 ScreenContext에서 가져오기 const effectiveTableName = tableName || screenContext?.tableName; const effectiveScreenId = screenId || screenContext?.screenId; - + // 🆕 props에서 onSave 추출 (명시적으로 선언되지 않은 경우 ...props에서 추출) const propsOnSave = (props as any).onSave as (() => Promise) | undefined; const finalOnSave = onSave || propsOnSave; @@ -169,10 +169,10 @@ export const ButtonPrimaryComponent: React.FC = ({ if (!shouldFetchStatus) return; let isMounted = true; - + const fetchStatus = async () => { if (!isMounted) return; - + try { const response = await apiClient.post(`/table-management/tables/${statusTableName}/data`, { page: 1, @@ -180,12 +180,12 @@ export const ButtonPrimaryComponent: React.FC = ({ search: { [statusKeyField]: userId }, autoFilter: true, }); - + if (!isMounted) return; - + const rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || []; const firstRow = Array.isArray(rows) ? rows[0] : null; - + if (response.data?.success && firstRow) { const newStatus = firstRow[statusFieldName]; if (newStatus !== vehicleStatus) { @@ -206,10 +206,10 @@ export const ButtonPrimaryComponent: React.FC = ({ // 즉시 실행 setStatusLoading(true); fetchStatus(); - + // 2초마다 갱신 const interval = setInterval(fetchStatus, 2000); - + return () => { isMounted = false; clearInterval(interval); @@ -219,22 +219,22 @@ export const ButtonPrimaryComponent: React.FC = ({ // 버튼 비활성화 조건 계산 const isOperationButtonDisabled = useMemo(() => { const actionConfig = component.componentConfig?.action; - + if (actionConfig?.type !== "operation_control") return false; // 1. 출발지/도착지 필수 체크 if (actionConfig?.requireLocationFields) { const departureField = actionConfig.trackingDepartureField || "departure"; const destinationField = actionConfig.trackingArrivalField || "destination"; - + const departure = formData?.[departureField]; const destination = formData?.[destinationField]; - - // console.log("🔍 [ButtonPrimary] 출발지/도착지 체크:", { - // departureField, destinationField, departure, destination, - // buttonLabel: component.label + + // console.log("🔍 [ButtonPrimary] 출발지/도착지 체크:", { + // departureField, destinationField, departure, destination, + // buttonLabel: component.label // }); - + if (!departure || departure === "" || !destination || destination === "") { // console.log("🚫 [ButtonPrimary] 출발지/도착지 미선택 → 비활성화:", component.label); return true; @@ -246,20 +246,20 @@ export const ButtonPrimaryComponent: React.FC = ({ const statusField = actionConfig.statusCheckField || "status"; // API 조회 결과를 우선 사용 (실시간 DB 상태 반영) const currentStatus = vehicleStatus || formData?.[statusField]; - + const conditionType = actionConfig.statusConditionType || "enableOn"; const conditionValues = (actionConfig.statusConditionValues || "") .split(",") .map((v: string) => v.trim()) .filter((v: string) => v); - // console.log("🔍 [ButtonPrimary] 상태 조건 체크:", { + // console.log("🔍 [ButtonPrimary] 상태 조건 체크:", { // statusField, // formDataStatus: formData?.[statusField], // apiStatus: vehicleStatus, - // currentStatus, - // conditionType, - // conditionValues, + // currentStatus, + // conditionType, + // conditionValues, // buttonLabel: component.label, // }); @@ -274,7 +274,7 @@ export const ButtonPrimaryComponent: React.FC = ({ // console.log("🚫 [ButtonPrimary] 상태값 없음 → 비활성화:", component.label); return true; } - + if (conditionValues.length > 0) { if (conditionType === "enableOn") { // 이 상태일 때만 활성화 @@ -539,7 +539,7 @@ export const ButtonPrimaryComponent: React.FC = ({ */ const handleTransferDataAction = async (actionConfig: any) => { const dataTransferConfig = actionConfig.dataTransfer; - + if (!dataTransferConfig) { toast.error("데이터 전달 설정이 없습니다."); return; @@ -553,15 +553,15 @@ export const ButtonPrimaryComponent: React.FC = ({ try { // 1. 소스 컴포넌트에서 데이터 가져오기 let sourceProvider = screenContext.getDataProvider(dataTransferConfig.sourceComponentId); - + // 🆕 소스 컴포넌트를 찾을 수 없으면, 현재 화면에서 테이블 리스트 자동 탐색 // (조건부 컨테이너의 다른 섹션으로 전환했을 때 이전 컴포넌트 ID가 남아있는 경우 대응) if (!sourceProvider) { console.log(`⚠️ [ButtonPrimary] 지정된 소스 컴포넌트를 찾을 수 없음: ${dataTransferConfig.sourceComponentId}`); console.log(`🔍 [ButtonPrimary] 현재 화면에서 DataProvider 자동 탐색...`); - + const allProviders = screenContext.getAllDataProviders(); - + // 테이블 리스트 우선 탐색 for (const [id, provider] of allProviders) { if (provider.componentType === "table-list") { @@ -570,16 +570,18 @@ export const ButtonPrimaryComponent: React.FC = ({ break; } } - + // 테이블 리스트가 없으면 첫 번째 DataProvider 사용 if (!sourceProvider && allProviders.size > 0) { const firstEntry = allProviders.entries().next().value; if (firstEntry) { sourceProvider = firstEntry[1]; - console.log(`✅ [ButtonPrimary] 첫 번째 DataProvider 사용: ${firstEntry[0]} (${sourceProvider.componentType})`); + console.log( + `✅ [ButtonPrimary] 첫 번째 DataProvider 사용: ${firstEntry[0]} (${sourceProvider.componentType})`, + ); } } - + if (!sourceProvider) { toast.error("데이터를 제공할 수 있는 컴포넌트를 찾을 수 없습니다."); return; @@ -587,12 +589,12 @@ export const ButtonPrimaryComponent: React.FC = ({ } const rawSourceData = sourceProvider.getSelectedData(); - + // 🆕 배열이 아닌 경우 배열로 변환 - const sourceData = Array.isArray(rawSourceData) ? rawSourceData : (rawSourceData ? [rawSourceData] : []); - + const sourceData = Array.isArray(rawSourceData) ? rawSourceData : rawSourceData ? [rawSourceData] : []; + console.log("📦 소스 데이터:", { rawSourceData, sourceData, isArray: Array.isArray(rawSourceData) }); - + if (!sourceData || sourceData.length === 0) { toast.warning("선택된 데이터가 없습니다."); return; @@ -600,31 +602,32 @@ export const ButtonPrimaryComponent: React.FC = ({ // 1.5. 추가 데이터 소스 처리 (예: 조건부 컨테이너의 카테고리 값) let additionalData: Record = {}; - + // 방법 1: additionalSources 설정에서 가져오기 if (dataTransferConfig.additionalSources && Array.isArray(dataTransferConfig.additionalSources)) { for (const additionalSource of dataTransferConfig.additionalSources) { const additionalProvider = screenContext.getDataProvider(additionalSource.componentId); - + if (additionalProvider) { const additionalValues = additionalProvider.getSelectedData(); - + if (additionalValues && additionalValues.length > 0) { // 첫 번째 값 사용 (조건부 컨테이너는 항상 1개) const firstValue = additionalValues[0]; - + // fieldName이 지정되어 있으면 그 필드만 추출 if (additionalSource.fieldName) { - additionalData[additionalSource.fieldName] = firstValue[additionalSource.fieldName] || firstValue.condition || firstValue; + additionalData[additionalSource.fieldName] = + firstValue[additionalSource.fieldName] || firstValue.condition || firstValue; } else { // fieldName이 없으면 전체 객체 병합 additionalData = { ...additionalData, ...firstValue }; } - + console.log("📦 추가 데이터 수집 (additionalSources):", { sourceId: additionalSource.componentId, fieldName: additionalSource.fieldName, - value: additionalData[additionalSource.fieldName || 'all'], + value: additionalData[additionalSource.fieldName || "all"], }); } } @@ -639,7 +642,7 @@ export const ButtonPrimaryComponent: React.FC = ({ const conditionalValue = formData.__conditionalContainerValue; const conditionalLabel = formData.__conditionalContainerLabel; const controlField = formData.__conditionalContainerControlField; // 🆕 제어 필드명 직접 사용 - + // 🆕 controlField가 있으면 그것을 필드명으로 사용 (자동 매핑!) if (controlField) { additionalData[controlField] = conditionalValue; @@ -651,7 +654,7 @@ export const ButtonPrimaryComponent: React.FC = ({ } else { // controlField가 없으면 기존 방식: formData에서 같은 값을 가진 키 찾기 for (const [key, value] of Object.entries(formData)) { - if (value === conditionalValue && !key.startsWith('__')) { + if (value === conditionalValue && !key.startsWith("__")) { additionalData[key] = conditionalValue; console.log("📦 조건부 컨테이너 값 자동 포함:", { fieldName: key, @@ -661,12 +664,12 @@ export const ButtonPrimaryComponent: React.FC = ({ break; } } - + // 못 찾았으면 기본 필드명 사용 - if (!Object.keys(additionalData).some(k => !k.startsWith('__'))) { - additionalData['condition_type'] = conditionalValue; + if (!Object.keys(additionalData).some((k) => !k.startsWith("__"))) { + additionalData["condition_type"] = conditionalValue; console.log("📦 조건부 컨테이너 값 (기본 필드명):", { - fieldName: 'condition_type', + fieldName: "condition_type", value: conditionalValue, }); } @@ -698,7 +701,7 @@ export const ButtonPrimaryComponent: React.FC = ({ // 4. 매핑 규칙 적용 + 추가 데이터 병합 const mappedData = sourceData.map((row) => { const mappedRow = applyMappingRules(row, dataTransferConfig.mappingRules || []); - + // 추가 데이터를 모든 행에 포함 return { ...mappedRow, @@ -718,7 +721,7 @@ export const ButtonPrimaryComponent: React.FC = ({ if (dataTransferConfig.targetType === "component") { // 같은 화면의 컴포넌트로 전달 const targetReceiver = screenContext.getDataReceiver(dataTransferConfig.targetComponentId); - + if (!targetReceiver) { toast.error(`타겟 컴포넌트를 찾을 수 없습니다: ${dataTransferConfig.targetComponentId}`); return; @@ -730,7 +733,7 @@ export const ButtonPrimaryComponent: React.FC = ({ mode: dataTransferConfig.mode || "append", mappingRules: dataTransferConfig.mappingRules || [], }); - + toast.success(`${sourceData.length}개 항목이 전달되었습니다.`); } else if (dataTransferConfig.targetType === "splitPanel") { // 🆕 분할 패널의 반대편 화면으로 전달 @@ -738,17 +741,18 @@ export const ButtonPrimaryComponent: React.FC = ({ toast.error("분할 패널 컨텍스트를 찾을 수 없습니다. 이 버튼이 분할 패널 내부에 있는지 확인하세요."); return; } - + // 🆕 useSplitPanelPosition 훅으로 위치 가져오기 (중첩된 화면에서도 작동) - // screenId로 찾는 것은 직접 임베드된 화면에서만 작동하므로, + // screenId로 찾는 것은 직접 임베드된 화면에서만 작동하므로, // SplitPanelPositionProvider로 전달된 위치를 우선 사용 - const currentPosition = splitPanelPosition || (screenId ? splitPanelContext.getPositionByScreenId(screenId) : null); - + const currentPosition = + splitPanelPosition || (screenId ? splitPanelContext.getPositionByScreenId(screenId) : null); + if (!currentPosition) { toast.error("분할 패널 내 위치를 확인할 수 없습니다. screenId: " + screenId); return; } - + console.log("📦 분할 패널 데이터 전달:", { currentPosition, splitPanelPositionFromHook: splitPanelPosition, @@ -756,14 +760,14 @@ export const ButtonPrimaryComponent: React.FC = ({ leftScreenId: splitPanelContext.leftScreenId, rightScreenId: splitPanelContext.rightScreenId, }); - + const result = await splitPanelContext.transferToOtherSide( currentPosition, mappedData, dataTransferConfig.targetComponentId, // 특정 컴포넌트 지정 (선택사항) - dataTransferConfig.mode || "append" + dataTransferConfig.mode || "append", ); - + if (result.success) { toast.success(result.message); } else { @@ -782,7 +786,6 @@ export const ButtonPrimaryComponent: React.FC = ({ if (dataTransferConfig.clearAfterTransfer) { sourceProvider.clearSelection(); } - } catch (error: any) { console.error("❌ 데이터 전달 실패:", error); toast.error(error.message || "데이터 전달 중 오류가 발생했습니다."); @@ -816,16 +819,20 @@ export const ButtonPrimaryComponent: React.FC = ({ // 2. groupedData (부모창에서 모달로 전달된 데이터) // 3. modalDataStore (분할 패널 등에서 선택한 데이터) let effectiveSelectedRowsData = selectedRowsData; - + // groupedData가 있으면 우선 사용 (모달에서 부모 데이터 접근) - if ((!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) && groupedData && groupedData.length > 0) { + if ( + (!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) && + groupedData && + groupedData.length > 0 + ) { effectiveSelectedRowsData = groupedData; console.log("🔗 [ButtonPrimaryComponent] groupedData에서 부모창 데이터 가져옴:", { count: groupedData.length, data: groupedData, }); } - + // modalDataStore에서 선택된 데이터 가져오기 (분할 패널 등에서 선택한 데이터) if ((!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) && effectiveTableName) { try { @@ -833,11 +840,17 @@ export const ButtonPrimaryComponent: React.FC = ({ const dataRegistry = useModalDataStore.getState().dataRegistry; const modalData = dataRegistry[effectiveTableName]; if (modalData && modalData.length > 0) { - effectiveSelectedRowsData = modalData; + // modalDataStore는 {id, originalData, additionalData} 형태로 저장됨 + // originalData를 추출하여 실제 행 데이터를 가져옴 + effectiveSelectedRowsData = modalData.map((item: any) => { + // originalData가 있으면 그것을 사용, 없으면 item 자체 사용 (하위 호환성) + return item.originalData || item; + }); console.log("🔗 [ButtonPrimaryComponent] modalDataStore에서 선택된 데이터 가져옴:", { tableName: effectiveTableName, count: modalData.length, - data: modalData, + rawData: modalData, + extractedData: effectiveSelectedRowsData, }); } } catch (error) { @@ -847,7 +860,8 @@ export const ButtonPrimaryComponent: React.FC = ({ // 삭제 액션인데 선택된 데이터가 없으면 경고 메시지 표시하고 중단 const hasDataToDelete = - (effectiveSelectedRowsData && effectiveSelectedRowsData.length > 0) || (flowSelectedData && flowSelectedData.length > 0); + (effectiveSelectedRowsData && effectiveSelectedRowsData.length > 0) || + (flowSelectedData && flowSelectedData.length > 0); if (processedConfig.action.type === "delete" && !hasDataToDelete) { toast.warning("삭제할 항목을 먼저 선택해주세요."); @@ -1064,15 +1078,14 @@ export const ButtonPrimaryComponent: React.FC = ({ alignItems: "center", justifyContent: "center", // 🔧 크기에 따른 패딩 조정 - padding: - componentConfig.size === "sm" ? "0 0.75rem" : componentConfig.size === "lg" ? "0 1.25rem" : "0 1rem", + padding: componentConfig.size === "sm" ? "0 0.75rem" : componentConfig.size === "lg" ? "0 1.25rem" : "0 1rem", margin: "0", lineHeight: "1.25", boxShadow: finalDisabled ? "none" : "0 1px 2px 0 rgba(0, 0, 0, 0.05)", // 디자인 모드와 인터랙티브 모드 모두에서 사용자 스타일 적용 (width/height 제외) - ...(component.style ? Object.fromEntries( - Object.entries(component.style).filter(([key]) => key !== 'width' && key !== 'height') - ) : {}), + ...(component.style + ? Object.fromEntries(Object.entries(component.style).filter(([key]) => key !== "width" && key !== "height")) + : {}), }; const buttonContent = processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼"; @@ -1094,7 +1107,7 @@ export const ButtonPrimaryComponent: React.FC = ({