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.
This commit is contained in:
kjs
2026-02-11 14:45:23 +09:00
parent 308f05ca07
commit eac2fa63b1
6 changed files with 263 additions and 162 deletions

View File

@@ -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<FileUploadComponentProps> = ({
}
}, []);
// 파일 업로드 처리
// 백엔드 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<FileUploadComponentProps> = ({
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<FileUploadComponentProps> = ({
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<FileUploadComponentProps> = ({
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<FileUploadComponentProps> = ({
[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 (
<div
style={{
...componentStyle,
width: "100%", // 🆕 부모 컨테이너 너비에 맞춤
height: "100%", // 🆕 부모 컨테이너 높이에 맞춤
border: "none !important",
boxShadow: "none !important",
outline: "none !important",
backgroundColor: "transparent !important",
padding: "0px !important",
borderRadius: "0px !important",
marginBottom: "8px !important",
width: "100%",
height: "100%",
// 🔧 !important 제거 - 커스텀 스타일이 없을 때만 기본값 적용
border: hasCustomBorder ? undefined : "none",
boxShadow: "none",
outline: "none",
backgroundColor: hasCustomBackground ? undefined : "transparent",
padding: "0px",
borderRadius: hasCustomRadius ? undefined : "0px",
marginBottom: "8px",
}}
className={`${className} file-upload-container`}
>
@@ -1014,15 +1052,15 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
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<FileUploadComponentProps> = ({
)}
<div
className="border-border bg-card relative flex h-full w-full flex-col rounded-lg border overflow-hidden"
className={cn(
"relative flex h-full w-full flex-col overflow-hidden",
// 커스텀 테두리가 없을 때만 기본 테두리 표시
!hasCustomBorder && "border-border rounded-lg border",
// 커스텀 배경이 없을 때만 기본 배경 표시
!hasCustomBackground && "bg-card",
)}
>
{/* 대표 이미지 전체 화면 표시 */}
{uploadedFiles.length > 0 ? (() => {