Merge branch 'feature/v2-unified-renewal' of http://39.117.244.52:3000/kjs/ERP-node into feature/v2-renewal

; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
This commit is contained in:
DDD1542 2026-02-05 14:08:38 +09:00
commit 9f3437d499
23 changed files with 3340 additions and 63 deletions

View File

@ -1261,5 +1261,56 @@ export const setRepresentativeFile = async (
}
};
/**
* (, )
*
*/
export const getFileInfo = async (req: Request, res: Response) => {
try {
const { objid } = req.params;
if (!objid) {
return res.status(400).json({
success: false,
message: "파일 ID가 필요합니다.",
});
}
// 파일 정보 조회
const fileRecord = await queryOne<any>(
`SELECT objid, real_file_name, file_size, file_ext, file_path, regdate, is_representative
FROM attach_file_info
WHERE objid = $1 AND status = 'ACTIVE'`,
[parseInt(objid)]
);
if (!fileRecord) {
return res.status(404).json({
success: false,
message: "파일을 찾을 수 없습니다.",
});
}
res.json({
success: true,
data: {
objid: fileRecord.objid.toString(),
realFileName: fileRecord.real_file_name,
fileSize: fileRecord.file_size,
fileExt: fileRecord.file_ext,
filePath: fileRecord.file_path,
regdate: fileRecord.regdate,
isRepresentative: fileRecord.is_representative,
},
});
} catch (error) {
console.error("파일 정보 조회 오류:", error);
res.status(500).json({
success: false,
message: "파일 정보 조회 중 오류가 발생했습니다.",
});
}
};
// Multer 미들웨어 export
export const uploadMiddleware = upload.array("files", 10); // 최대 10개 파일

View File

@ -2344,6 +2344,8 @@ export async function getTableEntityRelations(
*
* table_type_columns에서 reference_table이
* FK .
*
* 우선순위: 현재 company_code > ('*')
*/
export async function getReferencedByTables(
req: AuthenticatedRequest,
@ -2351,9 +2353,11 @@ export async function getReferencedByTables(
): Promise<void> {
try {
const { tableName } = req.params;
// 현재 사용자의 회사 코드 (없으면 '*' 사용)
const userCompanyCode = req.user?.companyCode || "*";
logger.info(
`=== 테이블 참조 관계 조회 시작: ${tableName} 을 참조하는 테이블 ===`
`=== 테이블 참조 관계 조회 시작: ${tableName} 을 참조하는 테이블 (회사코드: ${userCompanyCode}) ===`
);
if (!tableName) {
@ -2371,23 +2375,41 @@ export async function getReferencedByTables(
// table_type_columns에서 reference_table이 현재 테이블인 레코드 조회
// input_type이 'entity'인 것만 조회 (실제 FK 관계)
// 우선순위: 현재 사용자의 company_code > 공통('*')
// ROW_NUMBER를 사용해서 같은 테이블/컬럼 조합에서 회사코드 우선순위로 하나만 선택
const sqlQuery = `
WITH ranked AS (
SELECT
ttc.table_name,
ttc.column_name,
ttc.column_label,
ttc.reference_table,
ttc.reference_column,
ttc.display_column,
ttc.company_code,
ROW_NUMBER() OVER (
PARTITION BY ttc.table_name, ttc.column_name
ORDER BY CASE WHEN ttc.company_code = $2 THEN 1 ELSE 2 END
) as rn
FROM table_type_columns ttc
WHERE ttc.reference_table = $1
AND ttc.input_type = 'entity'
AND ttc.company_code IN ($2, '*')
)
SELECT DISTINCT
ttc.table_name,
ttc.column_name,
ttc.column_label,
ttc.reference_table,
ttc.reference_column,
ttc.display_column,
ttc.table_name as table_label
FROM table_type_columns ttc
WHERE ttc.reference_table = $1
AND ttc.input_type = 'entity'
AND ttc.company_code = '*'
ORDER BY ttc.table_name, ttc.column_name
table_name,
column_name,
column_label,
reference_table,
reference_column,
display_column,
table_name as table_label
FROM ranked
WHERE rn = 1
ORDER BY table_name, column_name
`;
const result = await query(sqlQuery, [tableName]);
const result = await query(sqlQuery, [tableName, userCompanyCode]);
const referencedByTables = result.map((row: any) => ({
tableName: row.table_name,
@ -2400,7 +2422,7 @@ export async function getReferencedByTables(
}));
logger.info(
`테이블 참조 관계 조회 완료: ${referencedByTables.length}개 발견`
`테이블 참조 관계 조회 완료: ${referencedByTables.length}개 발견 (회사코드: ${userCompanyCode})`
);
const response: ApiResponse<any> = {

View File

@ -11,6 +11,7 @@ import {
generateTempToken,
getFileByToken,
setRepresentativeFile,
getFileInfo,
} from "../controllers/fileController";
import { authenticateToken } from "../middleware/authMiddleware";
@ -31,6 +32,13 @@ router.get("/public/:token", getFileByToken);
*/
router.get("/preview/:objid", previewFile);
/**
* @route GET /api/files/info/:objid
* @desc (, ) -
* @access Public
*/
router.get("/info/:objid", getFileInfo);
// 모든 파일 API는 인증 필요
router.use(authenticateToken);

View File

@ -43,7 +43,7 @@ import {
} from "lucide-react";
import { tableTypeApi } from "@/lib/api/screen";
import { commonCodeApi } from "@/lib/api/commonCode";
import { apiClient, getCurrentUser, UserInfo } from "@/lib/api/client";
import { apiClient, getCurrentUser, UserInfo, getFullImageUrl } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen-legacy-backup";
import { cn } from "@/lib/utils";
@ -2224,6 +2224,37 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
// 파일 타입 컬럼 처리 (가상 파일 컬럼 포함)
const isFileColumn = actualWebType === "file" || column.columnName === "file_path" || column.isVirtualFileColumn;
// 🖼️ 이미지 타입 컬럼: 썸네일로 표시
const isImageColumn = actualWebType === "image" || actualWebType === "img";
if (isImageColumn && value) {
// value가 objid (숫자 또는 숫자 문자열)인 경우 파일 API URL 사용
// 🔑 download 대신 preview 사용 (공개 접근 허용)
const isObjid = /^\d+$/.test(String(value));
const imageUrl = isObjid
? `/api/files/preview/${value}`
: getFullImageUrl(String(value));
return (
<div className="flex justify-center">
<img
src={imageUrl}
alt="이미지"
className="h-10 w-10 rounded object-cover cursor-pointer hover:opacity-80 transition-opacity"
style={{ maxWidth: "40px", maxHeight: "40px" }}
onClick={(e) => {
e.stopPropagation();
// 이미지 클릭 시 크게 보기 (새 탭에서 열기)
window.open(imageUrl, "_blank");
}}
onError={(e) => {
// 이미지 로드 실패 시 기본 아이콘 표시
(e.target as HTMLImageElement).style.display = "none";
}}
/>
</div>
);
}
// 파일 타입 컬럼은 파일 아이콘으로 표시 (컬럼별 파일 관리)
if (isFileColumn && rowData) {
// 현재 행의 기본키 값 가져오기

View File

@ -263,6 +263,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
definitionName: definition.name,
hasConfigPanel: !!definition.configPanel,
currentConfig,
defaultSort: currentConfig?.defaultSort, // 🔍 defaultSort 확인
});
// 🔧 ConfigPanelWrapper를 인라인 함수 대신 직접 JSX 반환 (리마운트 방지)
@ -1074,8 +1075,15 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
onChange={(newConfig) => {
console.log("🔧 [V2PropertiesPanel] DynamicConfigPanel onChange:", {
componentId: selectedComponent.id,
newConfigKeys: Object.keys(newConfig),
defaultSort: newConfig.defaultSort,
newConfig,
});
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지
Object.entries(newConfig).forEach(([key, value]) => {
console.log(` -> handleUpdate: componentConfig.${key} =`, value);
handleUpdate(`componentConfig.${key}`, value);
});
}}

View File

@ -124,7 +124,8 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_'));
const recordTableName = formData?.tableName || tableName;
const recordId = formData?.id;
const effectiveColumnName = isRecordMode ? 'attachments' : (columnName || id || 'attachments');
// 🔑 columnName 우선 사용 (실제 DB 컬럼명), 없으면 id, 최후에 attachments
const effectiveColumnName = columnName || id || 'attachments';
// 레코드용 targetObjid 생성
const getRecordTargetObjid = useCallback(() => {
@ -471,13 +472,21 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
// 폼 데이터 업데이트 - 부모 컴포넌트 시그니처에 맞게 (fieldName, value) 형식
if (onFormDataChange && targetColumn) {
// 🔑 단일 파일: 첫 번째 objid만 전달 (DB 컬럼에 저장될 값)
// 복수 파일: 콤마 구분 문자열로 전달
const formValue = config.multiple
? fileIds.join(',')
: (fileIds[0] || '');
console.log("📝 [V2Media] formData 업데이트:", {
columnName: targetColumn,
fileIds,
formValue,
isMultiple: config.multiple,
isRecordMode: effectiveIsRecordMode,
});
// (fieldName: string, value: any) 형식으로 호출
onFormDataChange(targetColumn, fileIds);
onFormDataChange(targetColumn, formValue);
}
// 그리드 파일 상태 새로고침 이벤트 발생
@ -601,12 +610,19 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
// 폼 데이터 업데이트 - 부모 컴포넌트 시그니처에 맞게 (fieldName, value) 형식
if (onFormDataChange && targetColumn) {
// 🔑 단일 파일: 첫 번째 objid만 전달 (DB 컬럼에 저장될 값)
// 복수 파일: 콤마 구분 문자열로 전달
const formValue = config.multiple
? fileIds.join(',')
: (fileIds[0] || '');
console.log("🗑️ [V2Media] 삭제 후 formData 업데이트:", {
columnName: targetColumn,
fileIds,
formValue,
});
// (fieldName: string, value: any) 형식으로 호출
onFormDataChange(targetColumn, fileIds);
onFormDataChange(targetColumn, formValue);
}
toast.success(`${fileName} 삭제 완료`);

View File

@ -298,3 +298,31 @@ export const setRepresentativeFile = async (objid: string): Promise<{
throw new Error("대표 파일 설정에 실패했습니다.");
}
};
/**
* (, objid로 )
*/
export const getFileInfoByObjid = async (objid: string): Promise<{
success: boolean;
data?: {
objid: string;
realFileName: string;
fileSize: number;
fileExt: string;
filePath: string;
regdate: string;
isRepresentative: boolean;
};
message?: string;
}> => {
try {
const response = await apiClient.get(`/files/info/${objid}`);
return response.data;
} catch (error) {
console.error("파일 정보 조회 오류:", error);
return {
success: false,
message: "파일 정보 조회에 실패했습니다.",
};
}
};

View File

@ -14,22 +14,23 @@ import { FileUploadConfig } from "./types";
*/
export const FileUploadDefinition = createComponentDefinition({
id: "file-upload",
name: "파일 업로드",
nameEng: "FileUpload Component",
description: "파일 업로드를 위한 파일 선택 컴포넌트",
name: "파일 업로드 (레거시)",
nameEng: "FileUpload Component (Legacy)",
description: "파일 업로드를 위한 파일 선택 컴포넌트 (레거시)",
category: ComponentCategory.INPUT,
webType: "file",
component: FileUploadComponent,
defaultConfig: {
placeholder: "입력하세요",
},
defaultSize: { width: 350, height: 240 }, // 40 * 6 (파일 선택 + 목록 표시)
defaultSize: { width: 350, height: 240 },
configPanel: FileUploadConfigPanel,
icon: "Edit",
tags: [],
version: "1.0.0",
author: "개발팀",
documentation: "https://docs.example.com/components/file-upload",
hidden: true, // v2-file-upload 사용으로 패널에서 숨김
});
// 타입 내보내기

View File

@ -13,9 +13,9 @@ import { ImageWidgetConfigPanel } from "./ImageWidgetConfigPanel";
*/
export const ImageWidgetDefinition = createComponentDefinition({
id: "image-widget",
name: "이미지 위젯",
nameEng: "Image Widget",
description: "이미지 표시 및 업로드",
name: "이미지 위젯 (레거시)",
nameEng: "Image Widget (Legacy)",
description: "이미지 표시 및 업로드 (레거시)",
category: ComponentCategory.INPUT,
webType: "image",
component: ImageWidget,
@ -32,6 +32,7 @@ export const ImageWidgetDefinition = createComponentDefinition({
version: "1.0.0",
author: "개발팀",
documentation: "https://docs.example.com/components/image-widget",
hidden: true, // v2-file-upload 사용으로 패널에서 숨김
});
// 컴포넌트 내보내기

View File

@ -111,6 +111,7 @@ import "./v2-timeline-scheduler/TimelineSchedulerRenderer"; // 타임라인 스
import "./v2-input/V2InputRenderer"; // V2 통합 입력 컴포넌트
import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트
import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트
import "./v2-file-upload/V2FileUploadRenderer"; // V2 파일 업로드 컴포넌트
/**
*

View File

@ -0,0 +1,529 @@
"use client";
import React, { useState, useRef } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { FileInfo, FileUploadConfig } from "./types";
import {
Upload,
Download,
Trash2,
Eye,
File,
FileText,
Image as ImageIcon,
Video,
Music,
Archive,
Presentation,
X,
Star,
ZoomIn,
ZoomOut,
RotateCcw,
} from "lucide-react";
import { formatFileSize } from "@/lib/utils";
import { FileViewerModal } from "./FileViewerModal";
interface FileManagerModalProps {
isOpen: boolean;
onClose: () => void;
uploadedFiles: FileInfo[];
onFileUpload: (files: File[]) => Promise<void>;
onFileDownload: (file: FileInfo) => void;
onFileDelete: (file: FileInfo) => void;
onFileView: (file: FileInfo) => void;
onSetRepresentative?: (file: FileInfo) => void; // 대표 이미지 설정 콜백
config: FileUploadConfig;
isDesignMode?: boolean;
}
export const FileManagerModal: React.FC<FileManagerModalProps> = ({
isOpen,
onClose,
uploadedFiles,
onFileUpload,
onFileDownload,
onFileDelete,
onFileView,
onSetRepresentative,
config,
isDesignMode = false,
}) => {
const [dragOver, setDragOver] = useState(false);
const [uploading, setUploading] = useState(false);
const [viewerFile, setViewerFile] = useState<FileInfo | null>(null);
const [isViewerOpen, setIsViewerOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<FileInfo | null>(null); // 선택된 파일 (좌측 미리보기용)
const [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null); // 이미지 미리보기 URL
const [zoomLevel, setZoomLevel] = useState(1); // 🔍 확대/축소 레벨
const [imagePosition, setImagePosition] = useState({ x: 0, y: 0 }); // 🖱️ 이미지 위치
const [isDragging, setIsDragging] = useState(false); // 🖱️ 드래그 중 여부
const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); // 🖱️ 드래그 시작 위치
const fileInputRef = useRef<HTMLInputElement>(null);
const imageContainerRef = useRef<HTMLDivElement>(null);
// 파일 아이콘 가져오기
const getFileIcon = (fileExt: string) => {
const ext = fileExt.toLowerCase();
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) {
return <ImageIcon className="w-5 h-5 text-blue-500" />;
} else if (['pdf', 'doc', 'docx', 'txt', 'rtf'].includes(ext)) {
return <FileText className="w-5 h-5 text-red-500" />;
} else if (['xls', 'xlsx', 'csv'].includes(ext)) {
return <FileText className="w-5 h-5 text-green-500" />;
} else if (['ppt', 'pptx'].includes(ext)) {
return <Presentation className="w-5 h-5 text-orange-500" />;
} else if (['mp4', 'avi', 'mov', 'webm'].includes(ext)) {
return <Video className="w-5 h-5 text-purple-500" />;
} else if (['mp3', 'wav', 'ogg'].includes(ext)) {
return <Music className="w-5 h-5 text-pink-500" />;
} else if (['zip', 'rar', '7z'].includes(ext)) {
return <Archive className="w-5 h-5 text-yellow-500" />;
} else {
return <File className="w-5 h-5 text-gray-500" />;
}
};
// 파일 업로드 핸들러
const handleFileUpload = async (files: FileList | File[]) => {
if (!files || files.length === 0) return;
setUploading(true);
try {
const fileArray = Array.from(files);
await onFileUpload(fileArray);
console.log('✅ FileManagerModal: 파일 업로드 완료');
} catch (error) {
console.error('❌ FileManagerModal: 파일 업로드 오류:', error);
} finally {
setUploading(false);
console.log('🔄 FileManagerModal: 업로드 상태 초기화');
}
};
// 드래그 앤 드롭 핸들러
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
if (config.disabled || isDesignMode) return;
const files = e.dataTransfer.files;
handleFileUpload(files);
};
// 파일 선택 핸들러
const handleFileSelect = () => {
if (config.disabled || isDesignMode) return;
fileInputRef.current?.click();
};
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files) {
handleFileUpload(files);
}
// 입력값 초기화
e.target.value = '';
};
// 파일 뷰어 핸들러
const handleFileViewInternal = (file: FileInfo) => {
setViewerFile(file);
setIsViewerOpen(true);
};
const handleViewerClose = () => {
setIsViewerOpen(false);
setViewerFile(null);
};
// 파일 클릭 시 미리보기 로드
const handleFileClick = async (file: FileInfo) => {
setSelectedFile(file);
setZoomLevel(1); // 🔍 파일 선택 시 확대/축소 레벨 초기화
setImagePosition({ x: 0, y: 0 }); // 🖱️ 이미지 위치 초기화
// 이미지 파일인 경우 미리보기 로드
// 🔑 점(.)을 제거하고 확장자만 비교
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'];
const ext = file.fileExt.toLowerCase().replace('.', '');
if (imageExtensions.includes(ext) || file.isImage) {
try {
// 🔑 이미 previewUrl이 있으면 바로 사용
if (file.previewUrl) {
setPreviewImageUrl(file.previewUrl);
return;
}
// 이전 Blob URL 해제
if (previewImageUrl) {
URL.revokeObjectURL(previewImageUrl);
}
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(`/files/preview/${file.objid}`, {
responseType: 'blob'
});
const blob = new Blob([response.data]);
const blobUrl = URL.createObjectURL(blob);
setPreviewImageUrl(blobUrl);
} catch (error) {
console.error("이미지 로드 실패:", error);
// 🔑 에러 발생 시에도 previewUrl이 있으면 사용
if (file.previewUrl) {
setPreviewImageUrl(file.previewUrl);
} else {
setPreviewImageUrl(null);
}
}
} else {
setPreviewImageUrl(null);
}
};
// 컴포넌트 언마운트 시 Blob URL 해제
React.useEffect(() => {
return () => {
if (previewImageUrl) {
URL.revokeObjectURL(previewImageUrl);
}
};
}, [previewImageUrl]);
// 🔑 모달이 열릴 때 첫 번째 파일을 자동으로 선택하고 확대/축소 레벨 초기화
React.useEffect(() => {
if (isOpen) {
setZoomLevel(1); // 🔍 모달 열릴 때 확대/축소 레벨 초기화
setImagePosition({ x: 0, y: 0 }); // 🖱️ 이미지 위치 초기화
if (uploadedFiles.length > 0 && !selectedFile) {
const firstFile = uploadedFiles[0];
handleFileClick(firstFile);
}
}
}, [isOpen, uploadedFiles, selectedFile]);
// 🖱️ 마우스 드래그 핸들러
const handleMouseDown = (e: React.MouseEvent) => {
if (zoomLevel > 1) {
setIsDragging(true);
setDragStart({ x: e.clientX - imagePosition.x, y: e.clientY - imagePosition.y });
}
};
const handleMouseMove = (e: React.MouseEvent) => {
if (isDragging && zoomLevel > 1) {
setImagePosition({
x: e.clientX - dragStart.x,
y: e.clientY - dragStart.y,
});
}
};
const handleMouseUp = () => {
setIsDragging(false);
};
// 🔍 확대/축소 레벨이 1로 돌아가면 위치도 초기화
React.useEffect(() => {
if (zoomLevel <= 1) {
setImagePosition({ x: 0, y: 0 });
}
}, [zoomLevel]);
return (
<>
<Dialog open={isOpen} onOpenChange={() => {}}>
<DialogContent className="max-w-[95vw] w-[1400px] max-h-[90vh] overflow-hidden [&>button]:hidden">
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<DialogTitle className="text-lg font-semibold">
({uploadedFiles.length})
</DialogTitle>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 hover:bg-gray-100"
onClick={onClose}
title="닫기"
>
<X className="w-4 h-4" />
</Button>
</DialogHeader>
<div className="flex flex-col space-y-3 h-[75vh]">
{/* 파일 업로드 영역 - 높이 축소 */}
{!isDesignMode && (
<div
className={`
border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors
${dragOver ? 'border-blue-400 bg-blue-50' : 'border-gray-300'}
${config.disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-gray-400'}
${uploading ? 'opacity-75' : ''}
`}
onClick={() => {
if (!config.disabled && !isDesignMode) {
fileInputRef.current?.click();
}
}}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
ref={fileInputRef}
type="file"
multiple={config.multiple}
accept={config.accept}
onChange={handleFileInputChange}
className="hidden"
disabled={config.disabled}
/>
{uploading ? (
<div className="flex items-center justify-center gap-2">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
<span className="text-sm text-blue-600 font-medium"> ...</span>
</div>
) : (
<div className="flex items-center justify-center gap-3">
<Upload className="h-6 w-6 text-gray-400" />
<p className="text-sm font-medium text-gray-700">
</p>
</div>
)}
</div>
)}
{/* 좌우 분할 레이아웃 - 좌측 넓게, 우측 고정 너비 */}
<div className="flex-1 flex gap-4 min-h-0">
{/* 좌측: 이미지 미리보기 (확대/축소 가능) */}
<div className="flex-1 border border-gray-200 rounded-lg bg-gray-900 flex flex-col overflow-hidden relative">
{/* 확대/축소 컨트롤 */}
{selectedFile && previewImageUrl && (
<div className="absolute top-3 left-3 z-10 flex items-center gap-1 bg-black/60 rounded-lg p-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-white hover:bg-white/20"
onClick={() => setZoomLevel(prev => Math.max(0.25, prev - 0.25))}
disabled={zoomLevel <= 0.25}
>
<ZoomOut className="h-4 w-4" />
</Button>
<span className="text-white text-xs min-w-[50px] text-center">
{Math.round(zoomLevel * 100)}%
</span>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-white hover:bg-white/20"
onClick={() => setZoomLevel(prev => Math.min(4, prev + 0.25))}
disabled={zoomLevel >= 4}
>
<ZoomIn className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-white hover:bg-white/20"
onClick={() => setZoomLevel(1)}
>
<RotateCcw className="h-4 w-4" />
</Button>
</div>
)}
{/* 이미지 미리보기 영역 - 마우스 휠로 확대/축소, 드래그로 이동 */}
<div
ref={imageContainerRef}
className={`flex-1 flex items-center justify-center overflow-hidden p-4 ${
zoomLevel > 1 ? (isDragging ? 'cursor-grabbing' : 'cursor-grab') : 'cursor-zoom-in'
}`}
onWheel={(e) => {
if (selectedFile && previewImageUrl) {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
setZoomLevel(prev => Math.min(4, Math.max(0.25, prev + delta)));
}
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
{selectedFile && previewImageUrl ? (
<img
src={previewImageUrl}
alt={selectedFile.realFileName}
className="transition-transform duration-100 select-none"
style={{
transform: `translate(${imagePosition.x}px, ${imagePosition.y}px) scale(${zoomLevel})`,
transformOrigin: 'center center',
}}
draggable={false}
/>
) : selectedFile ? (
<div className="flex flex-col items-center text-gray-400">
{getFileIcon(selectedFile.fileExt)}
<p className="mt-2 text-sm"> </p>
</div>
) : (
<div className="flex flex-col items-center text-gray-400">
<ImageIcon className="w-16 h-16 mb-2" />
<p className="text-sm"> </p>
</div>
)}
</div>
{/* 파일 정보 바 */}
{selectedFile && (
<div className="bg-black/60 text-white text-xs px-3 py-2 text-center truncate">
{selectedFile.realFileName}
</div>
)}
</div>
{/* 우측: 파일 목록 (고정 너비) */}
<div className="w-[400px] shrink-0 border border-gray-200 rounded-lg overflow-hidden flex flex-col">
<div className="p-3 border-b border-gray-200 bg-gray-50">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-700">
</h3>
{uploadedFiles.length > 0 && (
<Badge variant="secondary" className="text-xs">
{formatFileSize(uploadedFiles.reduce((sum, file) => sum + file.fileSize, 0))}
</Badge>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto p-3">
{uploadedFiles.length > 0 ? (
<div className="space-y-2">
{uploadedFiles.map((file) => (
<div
key={file.objid}
className={`
flex items-center space-x-3 p-2 rounded-lg transition-colors cursor-pointer
${selectedFile?.objid === file.objid ? 'bg-blue-50 border border-blue-200' : 'bg-gray-50 hover:bg-gray-100'}
`}
onClick={() => handleFileClick(file)}
>
<div className="flex-shrink-0">
{getFileIcon(file.fileExt)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 truncate">
{file.realFileName}
</span>
{file.isRepresentative && (
<Badge variant="default" className="h-5 px-1.5 text-xs">
</Badge>
)}
</div>
<p className="text-xs text-gray-500">
{formatFileSize(file.fileSize)} {file.fileExt.toUpperCase()}
</p>
</div>
<div className="flex items-center space-x-1">
{onSetRepresentative && (
<Button
variant={file.isRepresentative ? "default" : "ghost"}
size="sm"
className="h-7 w-7 p-0"
onClick={(e) => {
e.stopPropagation();
onSetRepresentative(file);
}}
title={file.isRepresentative ? "현재 대표 파일" : "대표 파일로 설정"}
>
<Star className={`w-3 h-3 ${file.isRepresentative ? "fill-white" : ""}`} />
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={(e) => {
e.stopPropagation();
handleFileViewInternal(file);
}}
title="미리보기"
>
<Eye className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={(e) => {
e.stopPropagation();
onFileDownload(file);
}}
title="다운로드"
>
<Download className="w-3 h-3" />
</Button>
{!isDesignMode && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-red-500 hover:text-red-700"
onClick={(e) => {
e.stopPropagation();
onFileDelete(file);
}}
title="삭제"
>
<Trash2 className="w-3 h-3" />
</Button>
)}
</div>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-gray-500">
<File className="w-12 h-12 mb-3 text-gray-300" />
<p className="text-sm font-medium text-gray-600"> </p>
<p className="text-xs text-gray-500 mt-1">
{isDesignMode ? '디자인 모드에서는 파일을 업로드할 수 없습니다' : '위의 영역에 파일을 업로드하세요'}
</p>
</div>
)}
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
{/* 파일 뷰어 모달 */}
<FileViewerModal
file={viewerFile}
isOpen={isViewerOpen}
onClose={handleViewerClose}
onDownload={onFileDownload}
onDelete={!isDesignMode ? onFileDelete : undefined}
/>
</>
);
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,287 @@
"use client";
import React, { useMemo, useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { FileUploadConfig } from "./types";
import { V2FileUploadDefaultConfig } from "./config";
export interface FileUploadConfigPanelProps {
config: FileUploadConfig;
onChange: (config: Partial<FileUploadConfig>) => void;
screenTableName?: string;
}
/**
* V2 FileUpload
* UI
*/
export const FileUploadConfigPanel: React.FC<FileUploadConfigPanelProps> = ({
config: propConfig,
onChange,
screenTableName,
}) => {
// config 안전하게 초기화 (useMemo)
const config = useMemo(() => ({
...V2FileUploadDefaultConfig,
...propConfig,
}), [propConfig]);
// 핸들러
const handleChange = useCallback(<K extends keyof FileUploadConfig>(
key: K,
value: FileUploadConfig[K]
) => {
onChange({ [key]: value });
}, [onChange]);
// 파일 크기를 MB 단위로 변환
const maxSizeMB = useMemo(() => {
return (config.maxSize || 10 * 1024 * 1024) / (1024 * 1024);
}, [config.maxSize]);
const handleMaxSizeChange = useCallback((value: string) => {
const mb = parseFloat(value) || 10;
handleChange("maxSize", mb * 1024 * 1024);
}, [handleChange]);
return (
<div className="space-y-4 p-4">
<div className="text-sm font-medium text-muted-foreground">
V2
</div>
<Separator />
{/* 기본 설정 */}
<div className="space-y-3">
<Label className="text-xs font-semibold uppercase text-muted-foreground">
</Label>
<div className="space-y-2">
<Label htmlFor="placeholder" className="text-xs"></Label>
<Input
id="placeholder"
value={config.placeholder || ""}
onChange={(e) => handleChange("placeholder", e.target.value)}
placeholder="파일을 선택하세요"
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label htmlFor="accept" className="text-xs"> </Label>
<Select
value={config.accept || "*/*"}
onValueChange={(value) => handleChange("accept", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="파일 형식 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="*/*"> </SelectItem>
<SelectItem value="image/*"></SelectItem>
<SelectItem value=".pdf,.doc,.docx,.xls,.xlsx"></SelectItem>
<SelectItem value="image/*,.pdf"> + PDF</SelectItem>
<SelectItem value=".zip,.rar,.7z"> </SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label htmlFor="maxSize" className="text-xs"> (MB)</Label>
<Input
id="maxSize"
type="number"
min={1}
max={100}
value={maxSizeMB}
onChange={(e) => handleMaxSizeChange(e.target.value)}
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxFiles" className="text-xs"> </Label>
<Input
id="maxFiles"
type="number"
min={1}
max={50}
value={config.maxFiles || 10}
onChange={(e) => handleChange("maxFiles", parseInt(e.target.value) || 10)}
className="h-8 text-xs"
/>
</div>
</div>
</div>
<Separator />
{/* 동작 설정 */}
<div className="space-y-3">
<Label className="text-xs font-semibold uppercase text-muted-foreground">
</Label>
<div className="flex items-center space-x-2">
<Checkbox
id="multiple"
checked={config.multiple !== false}
onCheckedChange={(checked) => handleChange("multiple", checked as boolean)}
/>
<Label htmlFor="multiple" className="text-xs"> </Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="allowDelete"
checked={config.allowDelete !== false}
onCheckedChange={(checked) => handleChange("allowDelete", checked as boolean)}
/>
<Label htmlFor="allowDelete" className="text-xs"> </Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="allowDownload"
checked={config.allowDownload !== false}
onCheckedChange={(checked) => handleChange("allowDownload", checked as boolean)}
/>
<Label htmlFor="allowDownload" className="text-xs"> </Label>
</div>
</div>
<Separator />
{/* 표시 설정 */}
<div className="space-y-3">
<Label className="text-xs font-semibold uppercase text-muted-foreground">
</Label>
<div className="flex items-center space-x-2">
<Checkbox
id="showPreview"
checked={config.showPreview !== false}
onCheckedChange={(checked) => handleChange("showPreview", checked as boolean)}
/>
<Label htmlFor="showPreview" className="text-xs"> </Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showFileList"
checked={config.showFileList !== false}
onCheckedChange={(checked) => handleChange("showFileList", checked as boolean)}
/>
<Label htmlFor="showFileList" className="text-xs"> </Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showFileSize"
checked={config.showFileSize !== false}
onCheckedChange={(checked) => handleChange("showFileSize", checked as boolean)}
/>
<Label htmlFor="showFileSize" className="text-xs"> </Label>
</div>
</div>
<Separator />
{/* 상태 설정 */}
<div className="space-y-3">
<Label className="text-xs font-semibold uppercase text-muted-foreground">
</Label>
<div className="flex items-center space-x-2">
<Checkbox
id="required"
checked={config.required || false}
onCheckedChange={(checked) => handleChange("required", checked as boolean)}
/>
<Label htmlFor="required" className="text-xs"> </Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="readonly"
checked={config.readonly || false}
onCheckedChange={(checked) => handleChange("readonly", checked as boolean)}
/>
<Label htmlFor="readonly" className="text-xs"> </Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="disabled"
checked={config.disabled || false}
onCheckedChange={(checked) => handleChange("disabled", checked as boolean)}
/>
<Label htmlFor="disabled" className="text-xs"></Label>
</div>
</div>
<Separator />
{/* 스타일 설정 */}
<div className="space-y-3">
<Label className="text-xs font-semibold uppercase text-muted-foreground">
</Label>
<div className="space-y-2">
<Label htmlFor="variant" className="text-xs"> </Label>
<Select
value={config.variant || "default"}
onValueChange={(value) => handleChange("variant", value as "default" | "outlined" | "filled")}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="스타일 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default"></SelectItem>
<SelectItem value="outlined"></SelectItem>
<SelectItem value="filled"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="size" className="text-xs"></Label>
<Select
value={config.size || "md"}
onValueChange={(value) => handleChange("size", value as "sm" | "md" | "lg")}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="크기 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm"></SelectItem>
<SelectItem value="md"></SelectItem>
<SelectItem value="lg"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 도움말 */}
<div className="space-y-2">
<Label htmlFor="helperText" className="text-xs"></Label>
<Input
id="helperText"
value={config.helperText || ""}
onChange={(e) => handleChange("helperText", e.target.value)}
placeholder="파일 업로드에 대한 안내 문구"
className="h-8 text-xs"
/>
</div>
</div>
);
};

View File

@ -0,0 +1,543 @@
"use client";
import React, { useState, useEffect } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { FileInfo } from "./types";
import { Download, X, AlertTriangle, FileText, Trash2, ExternalLink } from "lucide-react";
import { formatFileSize } from "@/lib/utils";
import { API_BASE_URL } from "@/lib/api/client";
// Office 문서 렌더링을 위한 CDN 라이브러리 로드
const loadOfficeLibrariesFromCDN = async () => {
if (typeof window === "undefined") return { XLSX: null, mammoth: null };
try {
// XLSX 라이브러리가 이미 로드되어 있는지 확인
if (!(window as any).XLSX) {
await new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = "https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js";
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
// mammoth 라이브러리가 이미 로드되어 있는지 확인
if (!(window as any).mammoth) {
await new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = "https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.4.2/mammoth.browser.min.js";
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
return {
XLSX: (window as any).XLSX,
mammoth: (window as any).mammoth,
};
} catch (error) {
console.error("Office 라이브러리 CDN 로드 실패:", error);
return { XLSX: null, mammoth: null };
}
};
interface FileViewerModalProps {
file: FileInfo | null;
isOpen: boolean;
onClose: () => void;
onDownload?: (file: FileInfo) => void;
onDelete?: (file: FileInfo) => void;
}
/**
*
*/
export const FileViewerModal: React.FC<FileViewerModalProps> = ({ file, isOpen, onClose, onDownload, onDelete }) => {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [previewError, setPreviewError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [renderedContent, setRenderedContent] = useState<string | null>(null);
// Office 문서를 CDN 라이브러리로 렌더링하는 함수
const renderOfficeDocument = async (blob: Blob, fileExt: string, fileName: string) => {
try {
setIsLoading(true);
// CDN에서 라이브러리 로드
const { XLSX, mammoth } = await loadOfficeLibrariesFromCDN();
if (fileExt === "docx" && mammoth) {
// Word 문서 렌더링
const arrayBuffer = await blob.arrayBuffer();
const result = await mammoth.convertToHtml({ arrayBuffer });
const htmlContent = `
<div>
<h4 style="margin: 0 0 15px 0; color: #333; font-size: 16px;">📄 ${fileName}</h4>
<div class="word-content" style="max-height: 500px; overflow-y: auto; padding: 20px; background: white; border: 1px solid #ddd; border-radius: 5px; line-height: 1.6; font-family: 'Times New Roman', serif;">
${result.value || "내용을 읽을 수 없습니다."}
</div>
</div>
`;
setRenderedContent(htmlContent);
return true;
} else if (["xlsx", "xls"].includes(fileExt) && XLSX) {
// Excel 문서 렌더링
const arrayBuffer = await blob.arrayBuffer();
const workbook = XLSX.read(arrayBuffer, { type: "array" });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const html = XLSX.utils.sheet_to_html(worksheet, {
table: { className: "excel-table" },
});
const htmlContent = `
<div>
<h4 style="margin: 0 0 10px 0; color: #333; font-size: 16px;">📊 ${fileName}</h4>
<p style="margin: 0 0 15px 0; color: #666; font-size: 14px;">시트: ${sheetName}</p>
<div style="max-height: 400px; overflow: auto; border: 1px solid #ddd; border-radius: 5px;">
<style>
.excel-table { border-collapse: collapse; width: 100%; }
.excel-table td, .excel-table th { border: 1px solid #ddd; padding: 8px; text-align: left; font-size: 12px; }
.excel-table th { background-color: #f5f5f5; font-weight: bold; }
</style>
${html}
</div>
</div>
`;
setRenderedContent(htmlContent);
return true;
} else if (fileExt === "doc") {
// .doc 파일은 .docx로 변환 안내
const htmlContent = `
<div style="text-align: center; padding: 40px;">
<h3 style="color: #333; margin-bottom: 15px;">📄 ${fileName}</h3>
<p style="color: #666; margin-bottom: 10px;">.doc .docx로 .</p>
<p style="color: #666; font-size: 14px;">(.docx )</p>
</div>
`;
setRenderedContent(htmlContent);
return true;
} else if (["ppt", "pptx"].includes(fileExt)) {
// PowerPoint는 미리보기 불가 안내
const htmlContent = `
<div style="text-align: center; padding: 40px;">
<h3 style="color: #333; margin-bottom: 15px;">📑 ${fileName}</h3>
<p style="color: #666; margin-bottom: 10px;">PowerPoint .</p>
<p style="color: #666; font-size: 14px;"> .</p>
</div>
`;
setRenderedContent(htmlContent);
return true;
}
return false; // 지원하지 않는 형식
} catch (error) {
console.error("Office 문서 렌더링 오류:", error);
const htmlContent = `
<div style="color: red; text-align: center; padding: 20px;">
Office .<br>
.
</div>
`;
setRenderedContent(htmlContent);
return true; // 오류 메시지라도 표시
} finally {
setIsLoading(false);
}
};
// 파일이 변경될 때마다 미리보기 URL 생성
useEffect(() => {
if (!file || !isOpen) {
setPreviewUrl(null);
setPreviewError(null);
setRenderedContent(null);
return;
}
setIsLoading(true);
setPreviewError(null);
// 로컬 파일인 경우
if (file._file) {
const url = URL.createObjectURL(file._file);
setPreviewUrl(url);
setIsLoading(false);
return () => URL.revokeObjectURL(url);
}
let cleanup: (() => void) | undefined;
// 서버 파일인 경우 - 미리보기 API 호출
const generatePreviewUrl = async () => {
try {
const fileExt = file.fileExt.toLowerCase();
// 미리보기 지원 파일 타입 정의
const imageExtensions = ["jpg", "jpeg", "png", "gif", "webp", "svg"];
const documentExtensions = [
"pdf",
"doc",
"docx",
"xls",
"xlsx",
"ppt",
"pptx",
"rtf",
"odt",
"ods",
"odp",
"hwp",
"hwpx",
"hwpml",
"hcdt",
"hpt",
"pages",
"numbers",
"keynote",
];
const textExtensions = ["txt", "md", "json", "xml", "csv"];
const mediaExtensions = ["mp4", "webm", "ogg", "mp3", "wav"];
const supportedExtensions = [...imageExtensions, ...documentExtensions, ...textExtensions, ...mediaExtensions];
if (supportedExtensions.includes(fileExt)) {
// 이미지나 PDF는 인증된 요청으로 Blob 생성
if (imageExtensions.includes(fileExt) || fileExt === "pdf") {
try {
// 인증된 요청으로 파일 데이터 가져오기
const response = await fetch(`${API_BASE_URL}/files/preview/${file.objid}`, {
headers: {
Authorization: `Bearer ${localStorage.getItem("authToken")}`,
},
});
if (response.ok) {
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
setPreviewUrl(blobUrl);
// 컴포넌트 언마운트 시 URL 정리를 위해 cleanup 함수 저장
cleanup = () => URL.revokeObjectURL(blobUrl);
} else {
throw new Error(`HTTP ${response.status}`);
}
} catch (error) {
console.error("파일 미리보기 로드 실패:", error);
setPreviewError("파일을 불러올 수 없습니다. 권한을 확인해주세요.");
}
} else if (documentExtensions.includes(fileExt)) {
// Office 문서는 OnlyOffice 또는 안정적인 뷰어 사용
try {
const response = await fetch(`${API_BASE_URL}/files/preview/${file.objid}`, {
headers: {
Authorization: `Bearer ${localStorage.getItem("authToken")}`,
},
});
if (response.ok) {
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
// Office 문서를 위한 특별한 처리 - CDN 라이브러리 사용
if (["doc", "docx", "xls", "xlsx", "ppt", "pptx"].includes(fileExt)) {
// CDN 라이브러리로 클라이언트 사이드 렌더링 시도
try {
const renderSuccess = await renderOfficeDocument(blob, fileExt, file.realFileName);
if (!renderSuccess) {
// 렌더링 실패 시 Blob URL 사용
setPreviewUrl(blobUrl);
}
} catch (error) {
console.error("Office 문서 렌더링 중 오류:", error);
// 오류 발생 시 Blob URL 사용
setPreviewUrl(blobUrl);
}
} else {
// 기타 문서는 직접 Blob URL 사용
setPreviewUrl(blobUrl);
}
return () => URL.revokeObjectURL(blobUrl); // Cleanup function
} else {
throw new Error(`HTTP ${response.status}`);
}
} catch (error) {
console.error("Office 문서 로드 실패:", error);
// 오류 발생 시 다운로드 옵션 제공
setPreviewError(`${fileExt.toUpperCase()} 문서를 미리보기할 수 없습니다. 다운로드하여 확인해주세요.`);
}
} else {
// 기타 파일은 다운로드 URL 사용
const url = `${API_BASE_URL.replace("/api", "")}/api/files/download/${file.objid}`;
setPreviewUrl(url);
}
} else {
// 지원하지 않는 파일 타입
setPreviewError(`${file.fileExt.toUpperCase()} 파일은 미리보기를 지원하지 않습니다.`);
}
} catch (error) {
console.error("미리보기 URL 생성 오류:", error);
setPreviewError("미리보기를 불러오는데 실패했습니다.");
} finally {
setIsLoading(false);
}
};
generatePreviewUrl();
// cleanup 함수 반환
return () => {
if (cleanup) {
cleanup();
}
};
}, [file, isOpen]);
if (!file) return null;
// 파일 타입별 미리보기 컴포넌트
const renderPreview = () => {
if (isLoading) {
return (
<div className="flex h-96 items-center justify-center">
<div className="h-12 w-12 animate-spin rounded-full border-b-2 border-blue-600"></div>
</div>
);
}
if (previewError) {
return (
<div className="flex h-96 flex-col items-center justify-center">
<AlertTriangle className="mb-4 h-16 w-16 text-yellow-500" />
<p className="mb-2 text-lg font-medium"> </p>
<p className="text-center text-sm">{previewError}</p>
<Button variant="outline" onClick={() => onDownload?.(file)} className="mt-4">
<Download className="mr-2 h-4 w-4" />
</Button>
</div>
);
}
const fileExt = file.fileExt.toLowerCase();
// 이미지 파일
if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(fileExt)) {
return (
<div className="flex max-h-96 items-center justify-center overflow-hidden">
<img
src={previewUrl || ""}
alt={file.realFileName}
className="max-h-full max-w-full rounded-lg object-contain shadow-lg"
onError={(e) => {
console.error("이미지 로드 오류:", previewUrl, e);
setPreviewError("이미지를 불러올 수 없습니다. 파일이 손상되었거나 서버에서 접근할 수 없습니다.");
}}
onLoad={() => {
console.log("이미지 로드 성공:", previewUrl);
}}
/>
</div>
);
}
// 텍스트 파일
if (["txt", "md", "json", "xml", "csv"].includes(fileExt)) {
return (
<div className="h-96 overflow-auto">
<iframe
src={previewUrl || ""}
className="h-full w-full rounded-lg border"
onError={() => setPreviewError("텍스트 파일을 불러올 수 없습니다.")}
/>
</div>
);
}
// PDF 파일 - 브라우저 기본 뷰어 사용
if (fileExt === "pdf") {
return (
<div className="h-[600px] overflow-auto rounded-lg border bg-gray-50">
<object
data={previewUrl || ""}
type="application/pdf"
className="h-full w-full rounded-lg"
title="PDF Viewer"
>
<iframe src={previewUrl || ""} className="h-full w-full rounded-lg" title="PDF Viewer Fallback">
<div className="flex h-full flex-col items-center justify-center p-8">
<FileText className="mb-4 h-16 w-16 text-gray-400" />
<p className="mb-2 text-lg font-medium">PDF를 </p>
<p className="mb-4 text-center text-sm text-gray-600">
PDF . .
</p>
<Button variant="outline" onClick={() => onDownload?.(file)}>
<Download className="mr-2 h-4 w-4" />
PDF
</Button>
</div>
</iframe>
</object>
</div>
);
}
// Office 문서 - 모든 Office 문서는 다운로드 권장
if (
[
"doc",
"docx",
"xls",
"xlsx",
"ppt",
"pptx",
"hwp",
"hwpx",
"hwpml",
"hcdt",
"hpt",
"pages",
"numbers",
"keynote",
].includes(fileExt)
) {
// Office 문서 안내 메시지 표시
return (
<div className="relative flex h-96 flex-col items-center justify-center overflow-auto rounded-lg border bg-gradient-to-br from-blue-50 to-indigo-50 p-8">
<FileText className="mb-6 h-20 w-20 text-blue-500" />
<h3 className="mb-2 text-xl font-semibold text-gray-800">Office </h3>
<p className="mb-6 max-w-md text-center text-sm text-gray-600">
{fileExt === "docx" || fileExt === "doc"
? "Word 문서"
: fileExt === "xlsx" || fileExt === "xls"
? "Excel 문서"
: fileExt === "pptx" || fileExt === "ppt"
? "PowerPoint 문서"
: "Office 문서"}
.
<br />
.
</p>
<div className="flex gap-3">
<Button onClick={() => onDownload?.(file)} size="lg" className="shadow-md">
<Download className="mr-2 h-5 w-5" />
</Button>
</div>
</div>
);
}
// 비디오 파일
if (["mp4", "webm", "ogg"].includes(fileExt)) {
return (
<div className="flex items-center justify-center">
<video controls className="max-h-96 w-full" onError={() => setPreviewError("비디오를 재생할 수 없습니다.")}>
<source src={previewUrl || ""} type={`video/${fileExt}`} />
</video>
</div>
);
}
// 오디오 파일
if (["mp3", "wav", "ogg"].includes(fileExt)) {
return (
<div className="flex h-96 flex-col items-center justify-center">
<div className="mb-6 flex h-32 w-32 items-center justify-center rounded-full bg-gray-100">
<svg className="h-16 w-16 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM15.657 6.343a1 1 0 011.414 0A9.972 9.972 0 0119 12a9.972 9.972 0 01-1.929 5.657 1 1 0 11-1.414-1.414A7.971 7.971 0 0017 12c0-1.594-.471-3.078-1.343-4.343a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 12a5.984 5.984 0 01-.757 2.829 1 1 0 01-1.415-1.414A3.987 3.987 0 0013 12a3.988 3.988 0 00-.172-1.171 1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</div>
<audio controls className="w-full max-w-md" onError={() => setPreviewError("오디오를 재생할 수 없습니다.")}>
<source src={previewUrl || ""} type={`audio/${fileExt}`} />
</audio>
</div>
);
}
// 기타 파일 타입
return (
<div className="flex h-96 flex-col items-center justify-center">
<FileText className="mb-4 h-16 w-16 text-gray-400" />
<p className="mb-2 text-lg font-medium"> </p>
<p className="mb-4 text-center text-sm">{file.fileExt.toUpperCase()} .</p>
<Button variant="outline" onClick={() => onDownload?.(file)}>
<Download className="mr-2 h-4 w-4" />
</Button>
</div>
);
};
return (
<Dialog open={isOpen} onOpenChange={() => {}}>
<DialogContent className="max-h-[90vh] max-w-4xl overflow-hidden [&>button]:hidden">
<DialogHeader>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<DialogTitle className="truncate text-lg font-semibold">{file.realFileName}</DialogTitle>
<Badge variant="secondary" className="text-xs">
{file.fileExt.toUpperCase()}
</Badge>
</div>
</div>
<DialogDescription>
: {formatFileSize(file.fileSize || file.size || 0)} | : {file.fileExt.toUpperCase()}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden">{renderPreview()}</div>
{/* 파일 정보 및 액션 버튼 */}
<div className="mt-2 flex items-center space-x-4 text-sm text-gray-500">
<span>: {formatFileSize(file.fileSize || file.size || 0)}</span>
{(file.uploadedAt || file.regdate) && (
<span>: {new Date(file.uploadedAt || file.regdate || "").toLocaleString()}</span>
)}
</div>
<div className="flex justify-end space-x-2 border-t pt-4">
<Button variant="outline" size="sm" onClick={() => onDownload?.(file)}>
<Download className="mr-2 h-4 w-4" />
</Button>
{onDelete && (
<Button
variant="outline"
size="sm"
className="text-red-600 hover:bg-red-50 hover:text-red-700"
onClick={() => onDelete(file)}
>
<Trash2 className="mr-2 h-4 w-4" />
</Button>
)}
<Button variant="outline" size="sm" onClick={onClose}>
<X className="mr-2 h-4 w-4" />
</Button>
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,56 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { V2FileUploadDefinition } from "./index";
import { FileUploadComponent } from "./FileUploadComponent";
/**
* V2 FileUpload
*
*/
export class V2FileUploadRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = V2FileUploadDefinition;
render(): React.ReactElement {
return <FileUploadComponent {...this.props} renderer={this} />;
}
/**
*
*/
// file 타입 특화 속성 처리
protected getFileUploadProps() {
const baseProps = this.getWebTypeProps();
// file 타입에 특화된 추가 속성들
return {
...baseProps,
// 여기에 file 타입 특화 속성들 추가
};
}
// 값 변경 처리
protected handleValueChange = (value: any) => {
this.updateComponent({ value });
};
// 포커스 처리
protected handleFocus = () => {
// 포커스 로직
};
// 블러 처리
protected handleBlur = () => {
// 블러 로직
};
}
// 자동 등록 실행
V2FileUploadRenderer.registerSelf();
// Hot Reload 지원 (개발 모드)
if (process.env.NODE_ENV === "development") {
V2FileUploadRenderer.enableHotReload();
}

View File

@ -0,0 +1,62 @@
"use client";
import { FileUploadConfig } from "./types";
/**
* V2 FileUpload
*/
export const V2FileUploadDefaultConfig: FileUploadConfig = {
placeholder: "파일을 선택하세요",
multiple: true,
accept: "*/*",
maxSize: 10 * 1024 * 1024, // 10MB
maxFiles: 10,
// 공통 기본값
disabled: false,
required: false,
readonly: false,
variant: "default",
size: "md",
// V2 추가 설정 기본값
showPreview: true,
showFileList: true,
showFileSize: true,
allowDelete: true,
allowDownload: true,
};
/**
* V2 FileUpload
*
*/
export const V2FileUploadConfigSchema = {
placeholder: { type: "string", default: "파일을 선택하세요" },
multiple: { type: "boolean", default: true },
accept: { type: "string", default: "*/*" },
maxSize: { type: "number", default: 10 * 1024 * 1024 },
maxFiles: { type: "number", default: 10 },
// 공통 스키마
disabled: { type: "boolean", default: false },
required: { type: "boolean", default: false },
readonly: { type: "boolean", default: false },
variant: {
type: "enum",
values: ["default", "outlined", "filled"],
default: "default"
},
size: {
type: "enum",
values: ["sm", "md", "lg"],
default: "md"
},
// V2 추가 설정 스키마
showPreview: { type: "boolean", default: true },
showFileList: { type: "boolean", default: true },
showFileSize: { type: "boolean", default: true },
allowDelete: { type: "boolean", default: true },
allowDownload: { type: "boolean", default: true },
};

View File

@ -0,0 +1,46 @@
"use client";
import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import type { WebType } from "@/types/screen";
import { FileUploadComponent } from "./FileUploadComponent";
import { FileUploadConfigPanel } from "./FileUploadConfigPanel";
import { FileUploadConfig } from "./types";
/**
* V2 FileUpload
* V2
*/
export const V2FileUploadDefinition = createComponentDefinition({
id: "v2-file-upload",
name: "파일 업로드",
nameEng: "V2 FileUpload Component",
description: "V2 파일 업로드를 위한 파일 선택 컴포넌트",
category: ComponentCategory.INPUT,
webType: "file",
component: FileUploadComponent,
defaultConfig: {
placeholder: "파일을 선택하세요",
multiple: true,
accept: "*/*",
maxSize: 10 * 1024 * 1024, // 10MB
},
defaultSize: { width: 350, height: 240 },
configPanel: FileUploadConfigPanel,
icon: "Upload",
tags: ["file", "upload", "attachment", "v2"],
version: "2.0.0",
author: "개발팀",
documentation: "https://docs.example.com/components/v2-file-upload",
});
// 타입 내보내기
export type { FileUploadConfig, FileInfo, FileUploadProps, FileUploadStatus, FileUploadResponse } from "./types";
// 컴포넌트 내보내기
export { FileUploadComponent } from "./FileUploadComponent";
export { V2FileUploadRenderer } from "./V2FileUploadRenderer";
// 기본 export
export default V2FileUploadDefinition;

View File

@ -0,0 +1,114 @@
"use client";
import { ComponentConfig } from "@/types/component";
/**
* (AttachedFileInfo와 )
*/
export interface FileInfo {
// AttachedFileInfo 기본 속성들
objid: string;
savedFileName: string;
realFileName: string;
fileSize: number;
fileExt: string;
filePath: string;
docType?: string;
docTypeName?: string;
targetObjid: string;
parentTargetObjid?: string;
companyCode?: string;
writer?: string;
regdate?: string;
status?: string;
// 추가 호환성 속성들
path?: string; // filePath와 동일
name?: string; // realFileName과 동일
id?: string; // objid와 동일
size?: number; // fileSize와 동일
type?: string; // docType과 동일
uploadedAt?: string; // regdate와 동일
_file?: File; // 로컬 파일 객체 (업로드 전)
// 대표 이미지 설정
isRepresentative?: boolean; // 대표 이미지로 설정 여부
}
/**
* V2 FileUpload
*/
export interface FileUploadConfig extends ComponentConfig {
// file 관련 설정
placeholder?: string;
multiple?: boolean;
accept?: string;
maxSize?: number; // bytes
maxFiles?: number; // 최대 파일 수
// 공통 설정
disabled?: boolean;
required?: boolean;
readonly?: boolean;
helperText?: string;
// 스타일 관련
variant?: "default" | "outlined" | "filled";
size?: "sm" | "md" | "lg";
// V2 추가 설정
showPreview?: boolean; // 미리보기 표시 여부
showFileList?: boolean; // 파일 목록 표시 여부
showFileSize?: boolean; // 파일 크기 표시 여부
allowDelete?: boolean; // 삭제 허용 여부
allowDownload?: boolean; // 다운로드 허용 여부
// 이벤트 관련
onChange?: (value: any) => void;
onFocus?: () => void;
onBlur?: () => void;
onClick?: () => void;
onFileUpload?: (files: FileInfo[]) => void;
onFileDelete?: (fileId: string) => void;
onFileDownload?: (file: FileInfo) => void;
}
/**
* V2 FileUpload Props
*/
export interface FileUploadProps {
id?: string;
name?: string;
value?: any;
config?: FileUploadConfig;
className?: string;
style?: React.CSSProperties;
// 파일 관련
uploadedFiles?: FileInfo[];
// 이벤트 핸들러
onChange?: (value: any) => void;
onFocus?: () => void;
onBlur?: () => void;
onClick?: () => void;
onFileUpload?: (files: FileInfo[]) => void;
onFileDelete?: (fileId: string) => void;
onFileDownload?: (file: FileInfo) => void;
}
/**
*
*/
export type FileUploadStatus = 'idle' | 'uploading' | 'success' | 'error';
/**
*
*/
export interface FileUploadResponse {
success: boolean;
data?: FileInfo[];
files?: FileInfo[];
message?: string;
error?: string;
}

View File

@ -30,6 +30,15 @@ export class V2MediaRenderer extends AutoRegisteringComponentRenderer {
const columnName = component.columnName;
const tableName = component.tableName || this.props.tableName;
// 🔍 디버깅: 컴포넌트 정보 로깅
console.log("📸 [V2MediaRenderer] 컴포넌트 정보:", {
componentId: component.id,
columnName: columnName,
tableName: tableName,
formDataId: formData?.id,
formDataTableName: formData?.tableName,
});
// V1 file-upload에서 사용하는 형태로 설정 매핑
const mediaType = config.mediaType || config.type || this.getMediaTypeFromWebType(component.webType);
@ -58,17 +67,14 @@ export class V2MediaRenderer extends AutoRegisteringComponentRenderer {
componentConfig: legacyComponentConfig,
};
// onFormDataChange 래퍼: 레거시 컴포넌트는 객체를 전달하므로 변환 필요
const handleFormDataChange = (data: any) => {
// onFormDataChange 래퍼: FileUploadComponent는 (fieldName, value) 형태로 호출함
const handleFormDataChange = (fieldName: string, value: any) => {
if (onFormDataChange) {
// 레거시 컴포넌트는 { [columnName]: value } 형태로 전달
// 부모는 (fieldName, value) 형태를 기대
Object.entries(data).forEach(([key, value]) => {
// __attachmentsUpdate 같은 메타 데이터는 건너뛰기
if (!key.startsWith("__")) {
onFormDataChange(key, value);
}
});
// 메타 데이터(__로 시작하는 키)는 건너뛰기
if (!fieldName.startsWith("__")) {
console.log("📸 [V2MediaRenderer] formData 업데이트:", { fieldName, value });
onFormDataChange(fieldName, value);
}
}
};

View File

@ -85,7 +85,7 @@ interface GroupedData {
// 캐시 및 유틸리티
// ========================================
const tableColumnCache = new Map<string, { columns: any[]; timestamp: number }>();
const tableColumnCache = new Map<string, { columns: any[]; inputTypes?: any[]; timestamp: number }>();
const tableInfoCache = new Map<string, { tables: any[]; timestamp: number }>();
const TABLE_CACHE_TTL = 5 * 60 * 1000; // 5분
@ -1010,7 +1010,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// unregisterTable 함수는 의존성이 없어 안정적임
]);
// 🎯 초기 로드 시 localStorage에서 정렬 상태 불러오기
// 🎯 초기 로드 시 localStorage에서 정렬 상태 불러오기 (없으면 defaultSort 적용)
useEffect(() => {
if (!tableConfig.selectedTable || !userId || hasInitializedSort.current) return;
@ -1024,12 +1024,21 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
setSortColumn(column);
setSortDirection(direction);
hasInitializedSort.current = true;
return;
}
} catch (error) {
// 정렬 상태 복원 실패
}
}
}, [tableConfig.selectedTable, userId]);
// localStorage에 저장된 정렬이 없으면 defaultSort 설정 적용
if (tableConfig.defaultSort?.columnName) {
console.log("📊 기본 정렬 설정 적용:", tableConfig.defaultSort);
setSortColumn(tableConfig.defaultSort.columnName);
setSortDirection(tableConfig.defaultSort.direction || "asc");
hasInitializedSort.current = true;
}
}, [tableConfig.selectedTable, tableConfig.defaultSort, userId]);
// 🆕 초기 로드 시 localStorage에서 컬럼 순서 불러오기
useEffect(() => {
@ -1130,6 +1139,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
});
}
// 🔍 디버깅: 캐시 사용 시 로그
console.log("📊 [TableListComponent] 캐시에서 inputTypes 로드:", {
tableName: tableConfig.selectedTable,
cacheKey: cacheKey,
hasInputTypes: !!cached.inputTypes,
inputTypesLength: cached.inputTypes?.length || 0,
imageInputType: inputTypeMap["image"],
cacheAge: Date.now() - cached.timestamp,
});
cached.columns.forEach((col: any) => {
labels[col.columnName] = col.displayName || col.comment || col.columnName;
meta[col.columnName] = {
@ -1153,6 +1172,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
inputTypeMap[col.columnName] = col.inputType;
});
// 🔍 디버깅: inputTypes 확인
console.log("📊 [TableListComponent] inputTypes 조회 결과:", {
tableName: tableConfig.selectedTable,
inputTypes: inputTypes,
inputTypeMap: inputTypeMap,
imageColumn: inputTypes.find((col: any) => col.columnName === "image"),
});
tableColumnCache.set(cacheKey, {
columns,
inputTypes,
@ -1470,8 +1497,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
try {
const page = tableConfig.pagination?.currentPage || currentPage;
const pageSize = localPageSize;
const sortBy = sortColumn || undefined;
const sortOrder = sortDirection;
// 🆕 sortColumn이 없으면 defaultSort 설정을 fallback으로 사용
const sortBy = sortColumn || tableConfig.defaultSort?.columnName || undefined;
const sortOrder = sortColumn ? sortDirection : (tableConfig.defaultSort?.direction || sortDirection);
const search = searchTerm || undefined;
// 🆕 연결 필터 값 가져오기 (분할 패널 내부일 때)
@ -4051,19 +4079,44 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선)
const inputType = meta?.inputType || column.inputType;
// 🔍 디버깅: image 컬럼인 경우 로그 출력
if (column.columnName === "image") {
console.log("🖼️ [formatCellValue] image 컬럼 처리:", {
columnName: column.columnName,
value: value,
meta: meta,
inputType: inputType,
columnInputType: column.inputType,
});
}
// 🖼️ 이미지 타입: 작은 썸네일 표시
if (inputType === "image" && value && typeof value === "string") {
const imageUrl = getFullImageUrl(value);
if (inputType === "image" && value) {
// value가 objid (숫자 또는 숫자 문자열)인 경우 파일 API URL 사용
// 🔑 download 대신 preview 사용 (공개 접근 허용)
const strValue = String(value);
const isObjid = /^\d+$/.test(strValue);
const imageUrl = isObjid
? `/api/files/preview/${strValue}`
: getFullImageUrl(strValue);
return (
<img
src={imageUrl}
alt="이미지"
className="h-10 w-10 rounded object-cover"
onError={(e) => {
e.currentTarget.src =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Crect width='40' height='40' fill='%23f3f4f6'/%3E%3C/svg%3E";
}}
/>
<div className="flex justify-center">
<img
src={imageUrl}
alt="이미지"
className="h-10 w-10 rounded object-cover cursor-pointer hover:opacity-80 transition-opacity"
style={{ maxWidth: "40px", maxHeight: "40px" }}
onClick={(e) => {
e.stopPropagation();
// 이미지 클릭 시 새 탭에서 크게 보기
window.open(imageUrl, "_blank");
}}
onError={(e) => {
// 이미지 로드 실패 시 기본 아이콘 표시
(e.target as HTMLImageElement).style.display = "none";
}}
/>
</div>
);
}

View File

@ -319,7 +319,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
const handleChange = (key: keyof TableListConfig, value: any) => {
// 기존 config와 병합하여 전달 (다른 속성 손실 방지)
onChange({ ...config, [key]: value });
const newConfig = { ...config, [key]: value };
console.log("📊 TableListConfigPanel handleChange:", { key, value, newConfig });
onChange(newConfig);
};
const handleNestedChange = (parentKey: keyof TableListConfig, childKey: string, value: any) => {
@ -884,6 +886,67 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</div>
</div>
{/* 기본 정렬 설정 */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"> </h3>
<p className="text-muted-foreground text-[10px]"> </p>
</div>
<hr className="border-border" />
<div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="defaultSortColumn" className="text-xs">
</Label>
<select
id="defaultSortColumn"
value={config.defaultSort?.columnName || ""}
onChange={(e) => {
if (e.target.value) {
handleChange("defaultSort", {
columnName: e.target.value,
direction: config.defaultSort?.direction || "asc",
});
} else {
handleChange("defaultSort", undefined);
}
}}
className="h-8 w-full rounded-md border px-2 text-xs"
>
<option value=""> </option>
{availableColumns.map((col) => (
<option key={col.columnName} value={col.columnName}>
{col.label || col.columnName}
</option>
))}
</select>
</div>
{config.defaultSort?.columnName && (
<div className="space-y-1">
<Label htmlFor="defaultSortDirection" className="text-xs">
</Label>
<select
id="defaultSortDirection"
value={config.defaultSort?.direction || "asc"}
onChange={(e) =>
handleChange("defaultSort", {
...config.defaultSort,
columnName: config.defaultSort?.columnName || "",
direction: e.target.value as "asc" | "desc",
})
}
className="h-8 w-full rounded-md border px-2 text-xs"
>
<option value="asc"> (AZ, 19)</option>
<option value="desc"> (ZA, 91)</option>
</select>
</div>
)}
</div>
</div>
{/* 가로 스크롤 및 컬럼 고정 */}
<div className="space-y-3">
<div>

View File

@ -278,6 +278,12 @@ export interface TableListConfig extends ComponentConfig {
autoLoad: boolean;
refreshInterval?: number; // 초 단위
// 🆕 기본 정렬 설정
defaultSort?: {
columnName: string; // 정렬할 컬럼명
direction: "asc" | "desc"; // 정렬 방향
};
// 🆕 툴바 버튼 표시 설정
toolbar?: ToolbarConfig;

View File

@ -107,18 +107,18 @@ export const WEB_TYPE_V2_MAPPING: Record<string, V2ComponentMapping> = {
config: { mode: "dropdown", source: "category" },
},
// 파일/이미지 → 레거시 file-upload (안정적인 파일 업로드)
// 파일/이미지 → V2 파일 업로드
file: {
componentType: "file-upload",
config: { maxFileCount: 10, accept: "*/*" },
componentType: "v2-file-upload",
config: { multiple: true, accept: "*/*", maxFiles: 10 },
},
image: {
componentType: "file-upload",
config: { maxFileCount: 1, accept: "image/*" },
componentType: "v2-file-upload",
config: { multiple: false, accept: "image/*", maxFiles: 1, showPreview: true },
},
img: {
componentType: "file-upload",
config: { maxFileCount: 1, accept: "image/*" },
componentType: "v2-file-upload",
config: { multiple: false, accept: "image/*", maxFiles: 1, showPreview: true },
},
// 버튼은 V2 컴포넌트에서 제외 (기존 버튼 시스템 사용)
@ -157,9 +157,9 @@ export const WEB_TYPE_COMPONENT_MAPPING: Record<string, string> = {
code: "v2-select",
entity: "v2-select",
category: "v2-select",
file: "file-upload",
image: "file-upload",
img: "file-upload",
file: "v2-file-upload",
image: "v2-file-upload",
img: "v2-file-upload",
button: "button-primary",
label: "v2-input",
};