From eac2fa63b16df05c9862b3b98ff05df35005b8ea Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 11 Feb 2026 14:45:23 +0900 Subject: [PATCH] feat: Enhance input and select components with custom styling support - Added support for custom border, background, and text styles in V2Input and V2Select components, allowing for greater flexibility in styling based on user-defined configurations. - Updated the input and select components to conditionally apply styles based on the presence of custom properties, improving the overall user experience and visual consistency. - Refactored the FileUploadComponent to handle chunked file uploads, enhancing the file upload process by allowing multiple files to be uploaded in batches, improving performance and user feedback during uploads. --- frontend/components/ui/input.tsx | 2 +- frontend/components/v2/V2Input.tsx | 28 +- frontend/components/v2/V2Select.tsx | 26 +- .../ButtonPrimaryComponent.tsx | 16 +- .../v2-file-upload/FileUploadComponent.tsx | 336 ++++++++++-------- .../v2-text-display/TextDisplayComponent.tsx | 17 +- 6 files changed, 263 insertions(+), 162 deletions(-) diff --git a/frontend/components/ui/input.tsx b/frontend/components/ui/input.tsx index fcfd16cf..f6c5e4ea 100644 --- a/frontend/components/ui/input.tsx +++ b/frontend/components/ui/input.tsx @@ -55,7 +55,7 @@ const Input = React.forwardRef( type={type} data-slot="input" className={cn( - "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-10 w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50", + "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-10 w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-sm transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className, diff --git a/frontend/components/v2/V2Input.tsx b/frontend/components/v2/V2Input.tsx index d8457adb..17183050 100644 --- a/frontend/components/v2/V2Input.tsx +++ b/frontend/components/v2/V2Input.tsx @@ -947,6 +947,21 @@ export const V2Input = forwardRef((props, ref) => const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4; const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2; // 라벨 높이 + 여백 + // 🔧 커스텀 스타일 감지 (StyleEditor에서 설정한 테두리/배경/텍스트 스타일) + // RealtimePreview 래퍼가 외부 div에 스타일을 적용하지만, + // 내부 input/textarea가 자체 Tailwind 테두리를 가지므로 이를 제거하여 외부 스타일이 보이도록 함 + const hasCustomBorder = !!(style?.borderWidth || style?.borderColor || style?.borderStyle || style?.border); + const hasCustomBackground = !!style?.backgroundColor; + const hasCustomRadius = !!style?.borderRadius; + + // 텍스트 스타일 오버라이드 (CSS 상속으로 내부 input에 전달) + const customTextStyle: React.CSSProperties = {}; + if (style?.color) customTextStyle.color = style.color; + if (style?.fontSize) customTextStyle.fontSize = style.fontSize; + if (style?.fontWeight) customTextStyle.fontWeight = style.fontWeight; + if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"]; + const hasCustomText = Object.keys(customTextStyle).length > 0; + return (
((props, ref) => {required && *} )} -
+
{renderInput()}
diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index c4bd0925..c7ea8c94 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -947,6 +947,19 @@ export const V2Select = forwardRef( const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4; const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2; + // 🔧 커스텀 스타일 감지 (StyleEditor에서 설정한 테두리/배경/텍스트 스타일) + const hasCustomBorder = !!(style?.borderWidth || style?.borderColor || style?.borderStyle || style?.border); + const hasCustomBackground = !!style?.backgroundColor; + const hasCustomRadius = !!style?.borderRadius; + + // 텍스트 스타일 오버라이드 (CSS 상속) + const customTextStyle: React.CSSProperties = {}; + if (style?.color) customTextStyle.color = style.color; + if (style?.fontSize) customTextStyle.fontSize = style.fontSize; + if (style?.fontWeight) customTextStyle.fontWeight = style.fontWeight; + if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"]; + const hasCustomText = Object.keys(customTextStyle).length > 0; + return (
( {required && *} )} -
+
{renderSelect()}
diff --git a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx index f8b154d6..5516a4bf 100644 --- a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx @@ -1283,13 +1283,17 @@ export const ButtonPrimaryComponent: React.FC = ({ width: buttonWidth, height: buttonHeight, minHeight: "32px", // 🔧 최소 높이를 32px로 줄임 - border: "none", - borderRadius: "0.5rem", + // 🔧 커스텀 테두리 스타일 (StyleEditor에서 설정한 값 우선) + border: style?.border || (style?.borderWidth ? undefined : "none"), + borderWidth: style?.borderWidth || undefined, + borderStyle: (style?.borderStyle as React.CSSProperties["borderStyle"]) || undefined, + borderColor: style?.borderColor || undefined, + borderRadius: style?.borderRadius || "0.5rem", backgroundColor: finalDisabled ? "#e5e7eb" : buttonColor, - color: finalDisabled ? "#9ca3af" : buttonTextColor, // 🔧 webTypeConfig.textColor 지원 - // 🔧 크기 설정 적용 (sm/md/lg) - fontSize: componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem", - fontWeight: "600", + color: finalDisabled ? "#9ca3af" : (style?.color || buttonTextColor), // 🔧 StyleEditor 텍스트 색상도 지원 + // 🔧 크기 설정 적용 (sm/md/lg), StyleEditor fontSize 우선 + fontSize: style?.fontSize || (componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem"), + fontWeight: style?.fontWeight || "600", cursor: finalDisabled ? "not-allowed" : "pointer", outline: "none", boxSizing: "border-box", diff --git a/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx b/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx index 42b81edd..fc39458a 100644 --- a/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx +++ b/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx @@ -5,7 +5,7 @@ import { Badge } from "@/components/ui/badge"; import { toast } from "sonner"; import { uploadFiles, downloadFile, deleteFile, getComponentFiles, getFileInfoByObjid, getFilePreviewUrl } from "@/lib/api/file"; import { GlobalFileManager } from "@/lib/api/globalFile"; -import { formatFileSize } from "@/lib/utils"; +import { formatFileSize, cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { FileViewerModal } from "./FileViewerModal"; import { FileManagerModal } from "./FileManagerModal"; @@ -513,7 +513,10 @@ const FileUploadComponent: React.FC = ({ } }, []); - // 파일 업로드 처리 + // 백엔드 multer 제한에 맞춘 1회 요청당 최대 파일 수 + const CHUNK_SIZE = 10; + + // 파일 업로드 처리 (10개 초과 시 자동 분할 업로드) const handleFileUpload = useCallback( async (files: File[]) => { if (!files.length) return; @@ -548,7 +551,17 @@ const FileUploadComponent: React.FC = ({ const filesToUpload = uniqueFiles.length > 0 ? uniqueFiles : files; setUploadStatus("uploading"); - toast.loading("파일을 업로드하는 중...", { id: "file-upload" }); + + // 분할 업로드 여부 판단 + const totalFiles = filesToUpload.length; + const totalChunks = Math.ceil(totalFiles / CHUNK_SIZE); + const isChunked = totalChunks > 1; + + if (isChunked) { + toast.loading(`파일 업로드 준비 중... (총 ${totalFiles}개, ${totalChunks}회 분할)`, { id: "file-upload" }); + } else { + toast.loading("파일을 업로드하는 중...", { id: "file-upload" }); + } try { // 🔑 레코드 모드 우선 사용 @@ -585,13 +598,11 @@ const FileUploadComponent: React.FC = ({ const userCompanyCode = user?.companyCode || (window as any).__user__?.companyCode; // 🔑 레코드 모드일 때는 effectiveTableName을 우선 사용 - // formData.linkedTable이 'screen_files' 같은 기본값일 수 있으므로 레코드 모드에서는 무시 const finalLinkedTable = effectiveIsRecordMode ? effectiveTableName : (formData?.linkedTable || effectiveTableName); const uploadData = { - // 🎯 formData에서 백엔드 API 설정 가져오기 autoLink: formData?.autoLink || true, linkedTable: finalLinkedTable, recordId: effectiveRecordId || `temp_${component.id}`, @@ -599,143 +610,163 @@ const FileUploadComponent: React.FC = ({ isVirtualFileColumn: formData?.isVirtualFileColumn || true, docType: component.fileConfig?.docType || "DOCUMENT", docTypeName: component.fileConfig?.docTypeName || "일반 문서", - companyCode: userCompanyCode, // 🔒 멀티테넌시: 회사 코드 명시적 전달 - // 호환성을 위한 기존 필드들 + companyCode: userCompanyCode, tableName: effectiveTableName, fieldName: effectiveColumnName, - targetObjid: targetObjid, // InteractiveDataTable 호환을 위한 targetObjid 추가 - // 🆕 레코드 모드 플래그 + targetObjid: targetObjid, isRecordMode: effectiveIsRecordMode, }; - - const response = await uploadFiles({ - files: filesToUpload, - ...uploadData, - }); - if (response.success) { - // FileUploadResponse 타입에 맞게 files 배열 사용 - const fileData = response.files || (response as any).data || []; + // 🔄 파일을 CHUNK_SIZE(10개)씩 나눠서 순차 업로드 + const allNewFiles: any[] = []; + let failedChunks = 0; - if (fileData.length === 0) { - throw new Error("업로드된 파일 데이터를 받지 못했습니다."); + for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { + const start = chunkIndex * CHUNK_SIZE; + const end = Math.min(start + CHUNK_SIZE, totalFiles); + const chunk = filesToUpload.slice(start, end); + + // 분할 업로드 시 진행 상태 토스트 업데이트 + if (isChunked) { + toast.loading( + `업로드 중... ${chunkIndex + 1}/${totalChunks} 배치 (${start + 1}~${end}번째 파일)`, + { id: "file-upload" } + ); } - const newFiles = fileData.map((file: any) => ({ - objid: file.objid || file.id, - savedFileName: file.saved_file_name || file.savedFileName, - realFileName: file.real_file_name || file.realFileName || file.name, - fileSize: file.file_size || file.fileSize || file.size, - fileExt: file.file_ext || file.fileExt || file.extension, - filePath: file.file_path || file.filePath || file.path, - docType: file.doc_type || file.docType, - docTypeName: file.doc_type_name || file.docTypeName, - targetObjid: file.target_objid || file.targetObjid, - parentTargetObjid: file.parent_target_objid || file.parentTargetObjid, - companyCode: file.company_code || file.companyCode, - writer: file.writer, - regdate: file.regdate, - status: file.status || "ACTIVE", - uploadedAt: new Date().toISOString(), - ...file, - })); - - - const updatedFiles = [...uploadedFiles, ...newFiles]; - - setUploadedFiles(updatedFiles); - setUploadStatus("success"); - - // localStorage 백업 (레코드별 고유 키 사용) try { - const backupKey = getUniqueKey(); - localStorage.setItem(backupKey, JSON.stringify(updatedFiles)); - } catch (e) { - console.warn("localStorage 백업 실패:", e); + const response = await uploadFiles({ + files: chunk, + ...uploadData, + }); + + if (response.success) { + const fileData = response.files || (response as any).data || []; + const chunkFiles = fileData.map((file: any) => ({ + objid: file.objid || file.id, + savedFileName: file.saved_file_name || file.savedFileName, + realFileName: file.real_file_name || file.realFileName || file.name, + fileSize: file.file_size || file.fileSize || file.size, + fileExt: file.file_ext || file.fileExt || file.extension, + filePath: file.file_path || file.filePath || file.path, + docType: file.doc_type || file.docType, + docTypeName: file.doc_type_name || file.docTypeName, + targetObjid: file.target_objid || file.targetObjid, + parentTargetObjid: file.parent_target_objid || file.parentTargetObjid, + companyCode: file.company_code || file.companyCode, + writer: file.writer, + regdate: file.regdate, + status: file.status || "ACTIVE", + uploadedAt: new Date().toISOString(), + ...file, + })); + allNewFiles.push(...chunkFiles); + } else { + console.error(`❌ ${chunkIndex + 1}번째 배치 업로드 실패:`, response); + failedChunks++; + } + } catch (chunkError) { + console.error(`❌ ${chunkIndex + 1}번째 배치 업로드 오류:`, chunkError); + failedChunks++; } + } - // 전역 상태 업데이트 (모든 파일 컴포넌트 동기화) - if (typeof window !== "undefined") { - // 전역 파일 상태 업데이트 (레코드별 고유 키 사용) - const globalFileState = (window as any).globalFileState || {}; - const uniqueKey = getUniqueKey(); - globalFileState[uniqueKey] = updatedFiles; - (window as any).globalFileState = globalFileState; + // 모든 배치 처리 완료 후 결과 처리 + if (allNewFiles.length === 0) { + throw new Error("업로드된 파일 데이터를 받지 못했습니다."); + } - // 🌐 전역 파일 저장소에 새 파일 등록 (페이지 간 공유용) - GlobalFileManager.registerFiles(newFiles, { - uploadPage: window.location.pathname, + const updatedFiles = [...uploadedFiles, ...allNewFiles]; + + setUploadedFiles(updatedFiles); + setUploadStatus("success"); + + // localStorage 백업 (레코드별 고유 키 사용) + try { + const backupKey = getUniqueKey(); + localStorage.setItem(backupKey, JSON.stringify(updatedFiles)); + } catch (e) { + console.warn("localStorage 백업 실패:", e); + } + + // 전역 상태 업데이트 (모든 파일 컴포넌트 동기화) + if (typeof window !== "undefined") { + const globalFileState = (window as any).globalFileState || {}; + const uniqueKey = getUniqueKey(); + globalFileState[uniqueKey] = updatedFiles; + (window as any).globalFileState = globalFileState; + + GlobalFileManager.registerFiles(allNewFiles, { + uploadPage: window.location.pathname, + componentId: component.id, + screenId: formData?.screenId, + recordId: recordId, + }); + + const syncEvent = new CustomEvent("globalFileStateChanged", { + detail: { componentId: component.id, - screenId: formData?.screenId, - recordId: recordId, // 🆕 레코드 ID 추가 - }); + eventColumnName: columnName, + uniqueKey: uniqueKey, + recordId: recordId, + files: updatedFiles, + fileCount: updatedFiles.length, + timestamp: Date.now(), + }, + }); + window.dispatchEvent(syncEvent); + } - // 모든 파일 컴포넌트에 동기화 이벤트 발생 - // 🆕 columnName 추가하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분 - const syncEvent = new CustomEvent("globalFileStateChanged", { - detail: { - componentId: component.id, - eventColumnName: columnName, // 🆕 컬럼명 추가 - uniqueKey: uniqueKey, // 🆕 고유 키 추가 - recordId: recordId, // 🆕 레코드 ID 추가 - files: updatedFiles, - fileCount: updatedFiles.length, - timestamp: Date.now(), - }, - }); - window.dispatchEvent(syncEvent); - } - - // 컴포넌트 업데이트 - if (onUpdate) { - const timestamp = Date.now(); - onUpdate({ - uploadedFiles: updatedFiles, - lastFileUpdate: timestamp, - }); - } else { - console.warn("⚠️ onUpdate 콜백이 없습니다!"); - } - - // 🆕 이미지/파일 컬럼에 objid 저장 (formData 업데이트) - if (onFormDataChange && effectiveColumnName) { - // 🎯 이미지/파일 타입 컬럼: 첫 번째 파일의 objid를 저장 (그리드에서 표시용) - // 단일 파일인 경우 단일 값, 복수 파일인 경우 콤마 구분 문자열 - const fileObjids = updatedFiles.map(file => file.objid); - const columnValue = fileConfig.multiple - ? fileObjids.join(',') // 복수 파일: 콤마 구분 - : (fileObjids[0] || ''); // 단일 파일: 첫 번째 파일 ID - - // onFormDataChange를 (fieldName, value) 형태로 호출 (SaveModal 호환) - onFormDataChange(effectiveColumnName, columnValue); - } - - // 그리드 파일 상태 새로고침 이벤트 발생 - if (typeof window !== "undefined") { - const refreshEvent = new CustomEvent("refreshFileStatus", { - detail: { - tableName: effectiveTableName, - recordId: effectiveRecordId, - columnName: effectiveColumnName, - targetObjid: targetObjid, - fileCount: updatedFiles.length, - }, - }); - window.dispatchEvent(refreshEvent); - } - - // 컴포넌트 설정 콜백 - if (safeComponentConfig.onFileUpload) { - safeComponentConfig.onFileUpload(newFiles); - } - - // 성공 시 토스트 처리 - setUploadStatus("idle"); - toast.dismiss("file-upload"); - toast.success(`${newFiles.length}개 파일 업로드 완료`); + // 컴포넌트 업데이트 + if (onUpdate) { + const timestamp = Date.now(); + onUpdate({ + uploadedFiles: updatedFiles, + lastFileUpdate: timestamp, + }); } else { - console.error("❌ 파일 업로드 실패:", response); - throw new Error(response.message || (response as any).error || "파일 업로드에 실패했습니다."); + console.warn("⚠️ onUpdate 콜백이 없습니다!"); + } + + // 이미지/파일 컬럼에 objid 저장 (formData 업데이트) + if (onFormDataChange && effectiveColumnName) { + const fileObjids = updatedFiles.map(file => file.objid); + const columnValue = fileConfig.multiple + ? fileObjids.join(',') + : (fileObjids[0] || ''); + onFormDataChange(effectiveColumnName, columnValue); + } + + // 그리드 파일 상태 새로고침 이벤트 발생 + if (typeof window !== "undefined") { + const refreshEvent = new CustomEvent("refreshFileStatus", { + detail: { + tableName: effectiveTableName, + recordId: effectiveRecordId, + columnName: effectiveColumnName, + targetObjid: targetObjid, + fileCount: updatedFiles.length, + }, + }); + window.dispatchEvent(refreshEvent); + } + + // 컴포넌트 설정 콜백 + if (safeComponentConfig.onFileUpload) { + safeComponentConfig.onFileUpload(allNewFiles); + } + + // 성공/부분 성공 토스트 처리 + setUploadStatus("idle"); + toast.dismiss("file-upload"); + + if (failedChunks > 0) { + toast.warning( + `${allNewFiles.length}개 업로드 완료, 일부 파일 실패`, + { description: "일부 파일이 업로드되지 않았습니다. 다시 시도해주세요." } + ); + } else { + toast.success(`${allNewFiles.length}개 파일 업로드 완료`); } } catch (error) { console.error("파일 업로드 오류:", error); @@ -991,19 +1022,26 @@ const FileUploadComponent: React.FC = ({ [safeComponentConfig.readonly, safeComponentConfig.disabled, handleFileSelect, onClick], ); + // 🔧 커스텀 스타일 감지 (StyleEditor에서 설정한 값) + const customStyle = component.style || {}; + const hasCustomBorder = !!(customStyle.borderWidth || customStyle.borderColor || customStyle.borderStyle || customStyle.border); + const hasCustomBackground = !!customStyle.backgroundColor; + const hasCustomRadius = !!customStyle.borderRadius; + return (
@@ -1014,15 +1052,15 @@ const FileUploadComponent: React.FC = ({ position: "absolute", top: "-20px", left: "0px", - fontSize: "12px", - color: "rgb(107, 114, 128)", - fontWeight: "400", - background: "transparent !important", - border: "none !important", - boxShadow: "none !important", - outline: "none !important", - padding: "0px !important", - margin: "0px !important" + fontSize: customStyle.labelFontSize || "12px", + color: customStyle.labelColor || "rgb(107, 114, 128)", + fontWeight: customStyle.labelFontWeight || "400", + background: "transparent", + border: "none", + boxShadow: "none", + outline: "none", + padding: "0px", + margin: "0px" }} > {component.label} @@ -1033,7 +1071,13 @@ const FileUploadComponent: React.FC = ({ )}
{/* 대표 이미지 전체 화면 표시 */} {uploadedFiles.length > 0 ? (() => { diff --git a/frontend/lib/registry/components/v2-text-display/TextDisplayComponent.tsx b/frontend/lib/registry/components/v2-text-display/TextDisplayComponent.tsx index 78157d3e..fe66b458 100644 --- a/frontend/lib/registry/components/v2-text-display/TextDisplayComponent.tsx +++ b/frontend/lib/registry/components/v2-text-display/TextDisplayComponent.tsx @@ -56,16 +56,19 @@ export const TextDisplayComponent: React.FC = ({ // DOM props 필터링 (React 관련 props 제거) const domProps = filterDOMProps(props); + // 🔧 StyleEditor(component.style) 값 우선, 없으면 componentConfig 폴백 + const customStyle = component.style || {}; + // 텍스트 스타일 계산 const textStyle: React.CSSProperties = { - fontSize: componentConfig.fontSize || "14px", - fontWeight: componentConfig.fontWeight || "normal", - color: componentConfig.color || "#212121", - textAlign: componentConfig.textAlign || "left", - backgroundColor: componentConfig.backgroundColor || "transparent", + fontSize: customStyle.fontSize || componentConfig.fontSize || "14px", + fontWeight: customStyle.fontWeight || componentConfig.fontWeight || "normal", + color: customStyle.color || componentConfig.color || "#212121", + textAlign: (customStyle.textAlign || componentConfig.textAlign || "left") as React.CSSProperties["textAlign"], + backgroundColor: customStyle.backgroundColor || componentConfig.backgroundColor || "transparent", padding: componentConfig.padding || "0", - borderRadius: componentConfig.borderRadius || "0", - border: componentConfig.border || "none", + borderRadius: customStyle.borderRadius || componentConfig.borderRadius || "0", + border: customStyle.border || (customStyle.borderWidth ? `${customStyle.borderWidth} ${customStyle.borderStyle || "solid"} ${customStyle.borderColor || "transparent"}` : componentConfig.border || "none"), width: "100%", height: "100%", display: "flex",