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:
commit
9f3437d499
|
|
@ -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
|
// Multer 미들웨어 export
|
||||||
export const uploadMiddleware = upload.array("files", 10); // 최대 10개 파일
|
export const uploadMiddleware = upload.array("files", 10); // 최대 10개 파일
|
||||||
|
|
|
||||||
|
|
@ -2344,6 +2344,8 @@ export async function getTableEntityRelations(
|
||||||
*
|
*
|
||||||
* table_type_columns에서 reference_table이 현재 테이블인 레코드를 찾아서
|
* table_type_columns에서 reference_table이 현재 테이블인 레코드를 찾아서
|
||||||
* 해당 테이블과 FK 컬럼 정보를 반환합니다.
|
* 해당 테이블과 FK 컬럼 정보를 반환합니다.
|
||||||
|
*
|
||||||
|
* 우선순위: 현재 사용자의 company_code > 공통('*')
|
||||||
*/
|
*/
|
||||||
export async function getReferencedByTables(
|
export async function getReferencedByTables(
|
||||||
req: AuthenticatedRequest,
|
req: AuthenticatedRequest,
|
||||||
|
|
@ -2351,9 +2353,11 @@ export async function getReferencedByTables(
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { tableName } = req.params;
|
const { tableName } = req.params;
|
||||||
|
// 현재 사용자의 회사 코드 (없으면 '*' 사용)
|
||||||
|
const userCompanyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`=== 테이블 참조 관계 조회 시작: ${tableName} 을 참조하는 테이블 ===`
|
`=== 테이블 참조 관계 조회 시작: ${tableName} 을 참조하는 테이블 (회사코드: ${userCompanyCode}) ===`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!tableName) {
|
if (!tableName) {
|
||||||
|
|
@ -2371,23 +2375,41 @@ export async function getReferencedByTables(
|
||||||
|
|
||||||
// table_type_columns에서 reference_table이 현재 테이블인 레코드 조회
|
// table_type_columns에서 reference_table이 현재 테이블인 레코드 조회
|
||||||
// input_type이 'entity'인 것만 조회 (실제 FK 관계)
|
// input_type이 'entity'인 것만 조회 (실제 FK 관계)
|
||||||
|
// 우선순위: 현재 사용자의 company_code > 공통('*')
|
||||||
|
// ROW_NUMBER를 사용해서 같은 테이블/컬럼 조합에서 회사코드 우선순위로 하나만 선택
|
||||||
const sqlQuery = `
|
const sqlQuery = `
|
||||||
SELECT DISTINCT
|
WITH ranked AS (
|
||||||
|
SELECT
|
||||||
ttc.table_name,
|
ttc.table_name,
|
||||||
ttc.column_name,
|
ttc.column_name,
|
||||||
ttc.column_label,
|
ttc.column_label,
|
||||||
ttc.reference_table,
|
ttc.reference_table,
|
||||||
ttc.reference_column,
|
ttc.reference_column,
|
||||||
ttc.display_column,
|
ttc.display_column,
|
||||||
ttc.table_name as table_label
|
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
|
FROM table_type_columns ttc
|
||||||
WHERE ttc.reference_table = $1
|
WHERE ttc.reference_table = $1
|
||||||
AND ttc.input_type = 'entity'
|
AND ttc.input_type = 'entity'
|
||||||
AND ttc.company_code = '*'
|
AND ttc.company_code IN ($2, '*')
|
||||||
ORDER BY ttc.table_name, ttc.column_name
|
)
|
||||||
|
SELECT DISTINCT
|
||||||
|
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) => ({
|
const referencedByTables = result.map((row: any) => ({
|
||||||
tableName: row.table_name,
|
tableName: row.table_name,
|
||||||
|
|
@ -2400,7 +2422,7 @@ export async function getReferencedByTables(
|
||||||
}));
|
}));
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`테이블 참조 관계 조회 완료: ${referencedByTables.length}개 발견`
|
`테이블 참조 관계 조회 완료: ${referencedByTables.length}개 발견 (회사코드: ${userCompanyCode})`
|
||||||
);
|
);
|
||||||
|
|
||||||
const response: ApiResponse<any> = {
|
const response: ApiResponse<any> = {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
generateTempToken,
|
generateTempToken,
|
||||||
getFileByToken,
|
getFileByToken,
|
||||||
setRepresentativeFile,
|
setRepresentativeFile,
|
||||||
|
getFileInfo,
|
||||||
} from "../controllers/fileController";
|
} from "../controllers/fileController";
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
|
@ -31,6 +32,13 @@ router.get("/public/:token", getFileByToken);
|
||||||
*/
|
*/
|
||||||
router.get("/preview/:objid", previewFile);
|
router.get("/preview/:objid", previewFile);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/files/info/:objid
|
||||||
|
* @desc 파일 정보 조회 (메타데이터만, 파일 내용 없음) - 공개 접근 허용
|
||||||
|
* @access Public
|
||||||
|
*/
|
||||||
|
router.get("/info/:objid", getFileInfo);
|
||||||
|
|
||||||
// 모든 파일 API는 인증 필요
|
// 모든 파일 API는 인증 필요
|
||||||
router.use(authenticateToken);
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
import { commonCodeApi } from "@/lib/api/commonCode";
|
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 { useAuth } from "@/hooks/useAuth";
|
||||||
import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen-legacy-backup";
|
import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen-legacy-backup";
|
||||||
import { cn } from "@/lib/utils";
|
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 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) {
|
if (isFileColumn && rowData) {
|
||||||
// 현재 행의 기본키 값 가져오기
|
// 현재 행의 기본키 값 가져오기
|
||||||
|
|
|
||||||
|
|
@ -263,6 +263,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
definitionName: definition.name,
|
definitionName: definition.name,
|
||||||
hasConfigPanel: !!definition.configPanel,
|
hasConfigPanel: !!definition.configPanel,
|
||||||
currentConfig,
|
currentConfig,
|
||||||
|
defaultSort: currentConfig?.defaultSort, // 🔍 defaultSort 확인
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🔧 ConfigPanelWrapper를 인라인 함수 대신 직접 JSX 반환 (리마운트 방지)
|
// 🔧 ConfigPanelWrapper를 인라인 함수 대신 직접 JSX 반환 (리마운트 방지)
|
||||||
|
|
@ -1074,8 +1075,15 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
|
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
|
||||||
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
|
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
|
||||||
onChange={(newConfig) => {
|
onChange={(newConfig) => {
|
||||||
|
console.log("🔧 [V2PropertiesPanel] DynamicConfigPanel onChange:", {
|
||||||
|
componentId: selectedComponent.id,
|
||||||
|
newConfigKeys: Object.keys(newConfig),
|
||||||
|
defaultSort: newConfig.defaultSort,
|
||||||
|
newConfig,
|
||||||
|
});
|
||||||
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지
|
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지
|
||||||
Object.entries(newConfig).forEach(([key, value]) => {
|
Object.entries(newConfig).forEach(([key, value]) => {
|
||||||
|
console.log(` -> handleUpdate: componentConfig.${key} =`, value);
|
||||||
handleUpdate(`componentConfig.${key}`, value);
|
handleUpdate(`componentConfig.${key}`, value);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,8 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
|
||||||
const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_'));
|
const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_'));
|
||||||
const recordTableName = formData?.tableName || tableName;
|
const recordTableName = formData?.tableName || tableName;
|
||||||
const recordId = formData?.id;
|
const recordId = formData?.id;
|
||||||
const effectiveColumnName = isRecordMode ? 'attachments' : (columnName || id || 'attachments');
|
// 🔑 columnName 우선 사용 (실제 DB 컬럼명), 없으면 id, 최후에 attachments
|
||||||
|
const effectiveColumnName = columnName || id || 'attachments';
|
||||||
|
|
||||||
// 레코드용 targetObjid 생성
|
// 레코드용 targetObjid 생성
|
||||||
const getRecordTargetObjid = useCallback(() => {
|
const getRecordTargetObjid = useCallback(() => {
|
||||||
|
|
@ -471,13 +472,21 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
|
||||||
|
|
||||||
// 폼 데이터 업데이트 - 부모 컴포넌트 시그니처에 맞게 (fieldName, value) 형식
|
// 폼 데이터 업데이트 - 부모 컴포넌트 시그니처에 맞게 (fieldName, value) 형식
|
||||||
if (onFormDataChange && targetColumn) {
|
if (onFormDataChange && targetColumn) {
|
||||||
|
// 🔑 단일 파일: 첫 번째 objid만 전달 (DB 컬럼에 저장될 값)
|
||||||
|
// 복수 파일: 콤마 구분 문자열로 전달
|
||||||
|
const formValue = config.multiple
|
||||||
|
? fileIds.join(',')
|
||||||
|
: (fileIds[0] || '');
|
||||||
|
|
||||||
console.log("📝 [V2Media] formData 업데이트:", {
|
console.log("📝 [V2Media] formData 업데이트:", {
|
||||||
columnName: targetColumn,
|
columnName: targetColumn,
|
||||||
fileIds,
|
fileIds,
|
||||||
|
formValue,
|
||||||
|
isMultiple: config.multiple,
|
||||||
isRecordMode: effectiveIsRecordMode,
|
isRecordMode: effectiveIsRecordMode,
|
||||||
});
|
});
|
||||||
// (fieldName: string, value: any) 형식으로 호출
|
// (fieldName: string, value: any) 형식으로 호출
|
||||||
onFormDataChange(targetColumn, fileIds);
|
onFormDataChange(targetColumn, formValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 그리드 파일 상태 새로고침 이벤트 발생
|
// 그리드 파일 상태 새로고침 이벤트 발생
|
||||||
|
|
@ -601,12 +610,19 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
|
||||||
|
|
||||||
// 폼 데이터 업데이트 - 부모 컴포넌트 시그니처에 맞게 (fieldName, value) 형식
|
// 폼 데이터 업데이트 - 부모 컴포넌트 시그니처에 맞게 (fieldName, value) 형식
|
||||||
if (onFormDataChange && targetColumn) {
|
if (onFormDataChange && targetColumn) {
|
||||||
|
// 🔑 단일 파일: 첫 번째 objid만 전달 (DB 컬럼에 저장될 값)
|
||||||
|
// 복수 파일: 콤마 구분 문자열로 전달
|
||||||
|
const formValue = config.multiple
|
||||||
|
? fileIds.join(',')
|
||||||
|
: (fileIds[0] || '');
|
||||||
|
|
||||||
console.log("🗑️ [V2Media] 삭제 후 formData 업데이트:", {
|
console.log("🗑️ [V2Media] 삭제 후 formData 업데이트:", {
|
||||||
columnName: targetColumn,
|
columnName: targetColumn,
|
||||||
fileIds,
|
fileIds,
|
||||||
|
formValue,
|
||||||
});
|
});
|
||||||
// (fieldName: string, value: any) 형식으로 호출
|
// (fieldName: string, value: any) 형식으로 호출
|
||||||
onFormDataChange(targetColumn, fileIds);
|
onFormDataChange(targetColumn, formValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success(`${fileName} 삭제 완료`);
|
toast.success(`${fileName} 삭제 완료`);
|
||||||
|
|
|
||||||
|
|
@ -298,3 +298,31 @@ export const setRepresentativeFile = async (objid: string): Promise<{
|
||||||
throw new Error("대표 파일 설정에 실패했습니다.");
|
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: "파일 정보 조회에 실패했습니다.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -14,22 +14,23 @@ import { FileUploadConfig } from "./types";
|
||||||
*/
|
*/
|
||||||
export const FileUploadDefinition = createComponentDefinition({
|
export const FileUploadDefinition = createComponentDefinition({
|
||||||
id: "file-upload",
|
id: "file-upload",
|
||||||
name: "파일 업로드",
|
name: "파일 업로드 (레거시)",
|
||||||
nameEng: "FileUpload Component",
|
nameEng: "FileUpload Component (Legacy)",
|
||||||
description: "파일 업로드를 위한 파일 선택 컴포넌트",
|
description: "파일 업로드를 위한 파일 선택 컴포넌트 (레거시)",
|
||||||
category: ComponentCategory.INPUT,
|
category: ComponentCategory.INPUT,
|
||||||
webType: "file",
|
webType: "file",
|
||||||
component: FileUploadComponent,
|
component: FileUploadComponent,
|
||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
placeholder: "입력하세요",
|
placeholder: "입력하세요",
|
||||||
},
|
},
|
||||||
defaultSize: { width: 350, height: 240 }, // 40 * 6 (파일 선택 + 목록 표시)
|
defaultSize: { width: 350, height: 240 },
|
||||||
configPanel: FileUploadConfigPanel,
|
configPanel: FileUploadConfigPanel,
|
||||||
icon: "Edit",
|
icon: "Edit",
|
||||||
tags: [],
|
tags: [],
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
author: "개발팀",
|
author: "개발팀",
|
||||||
documentation: "https://docs.example.com/components/file-upload",
|
documentation: "https://docs.example.com/components/file-upload",
|
||||||
|
hidden: true, // v2-file-upload 사용으로 패널에서 숨김
|
||||||
});
|
});
|
||||||
|
|
||||||
// 타입 내보내기
|
// 타입 내보내기
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,9 @@ import { ImageWidgetConfigPanel } from "./ImageWidgetConfigPanel";
|
||||||
*/
|
*/
|
||||||
export const ImageWidgetDefinition = createComponentDefinition({
|
export const ImageWidgetDefinition = createComponentDefinition({
|
||||||
id: "image-widget",
|
id: "image-widget",
|
||||||
name: "이미지 위젯",
|
name: "이미지 위젯 (레거시)",
|
||||||
nameEng: "Image Widget",
|
nameEng: "Image Widget (Legacy)",
|
||||||
description: "이미지 표시 및 업로드",
|
description: "이미지 표시 및 업로드 (레거시)",
|
||||||
category: ComponentCategory.INPUT,
|
category: ComponentCategory.INPUT,
|
||||||
webType: "image",
|
webType: "image",
|
||||||
component: ImageWidget,
|
component: ImageWidget,
|
||||||
|
|
@ -32,6 +32,7 @@ export const ImageWidgetDefinition = createComponentDefinition({
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
author: "개발팀",
|
author: "개발팀",
|
||||||
documentation: "https://docs.example.com/components/image-widget",
|
documentation: "https://docs.example.com/components/image-widget",
|
||||||
|
hidden: true, // v2-file-upload 사용으로 패널에서 숨김
|
||||||
});
|
});
|
||||||
|
|
||||||
// 컴포넌트 내보내기
|
// 컴포넌트 내보내기
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,7 @@ import "./v2-timeline-scheduler/TimelineSchedulerRenderer"; // 타임라인 스
|
||||||
import "./v2-input/V2InputRenderer"; // V2 통합 입력 컴포넌트
|
import "./v2-input/V2InputRenderer"; // V2 통합 입력 컴포넌트
|
||||||
import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트
|
import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트
|
||||||
import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트
|
import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트
|
||||||
|
import "./v2-file-upload/V2FileUploadRenderer"; // V2 파일 업로드 컴포넌트
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 컴포넌트 초기화 함수
|
* 컴포넌트 초기화 함수
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -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 },
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,15 @@ export class V2MediaRenderer extends AutoRegisteringComponentRenderer {
|
||||||
const columnName = component.columnName;
|
const columnName = component.columnName;
|
||||||
const tableName = component.tableName || this.props.tableName;
|
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에서 사용하는 형태로 설정 매핑
|
// V1 file-upload에서 사용하는 형태로 설정 매핑
|
||||||
const mediaType = config.mediaType || config.type || this.getMediaTypeFromWebType(component.webType);
|
const mediaType = config.mediaType || config.type || this.getMediaTypeFromWebType(component.webType);
|
||||||
|
|
||||||
|
|
@ -58,17 +67,14 @@ export class V2MediaRenderer extends AutoRegisteringComponentRenderer {
|
||||||
componentConfig: legacyComponentConfig,
|
componentConfig: legacyComponentConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
// onFormDataChange 래퍼: 레거시 컴포넌트는 객체를 전달하므로 변환 필요
|
// onFormDataChange 래퍼: FileUploadComponent는 (fieldName, value) 형태로 호출함
|
||||||
const handleFormDataChange = (data: any) => {
|
const handleFormDataChange = (fieldName: string, value: any) => {
|
||||||
if (onFormDataChange) {
|
if (onFormDataChange) {
|
||||||
// 레거시 컴포넌트는 { [columnName]: value } 형태로 전달
|
// 메타 데이터(__로 시작하는 키)는 건너뛰기
|
||||||
// 부모는 (fieldName, value) 형태를 기대
|
if (!fieldName.startsWith("__")) {
|
||||||
Object.entries(data).forEach(([key, value]) => {
|
console.log("📸 [V2MediaRenderer] formData 업데이트:", { fieldName, value });
|
||||||
// __attachmentsUpdate 같은 메타 데이터는 건너뛰기
|
onFormDataChange(fieldName, value);
|
||||||
if (!key.startsWith("__")) {
|
|
||||||
onFormDataChange(key, value);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 tableInfoCache = new Map<string, { tables: any[]; timestamp: number }>();
|
||||||
const TABLE_CACHE_TTL = 5 * 60 * 1000; // 5분
|
const TABLE_CACHE_TTL = 5 * 60 * 1000; // 5분
|
||||||
|
|
||||||
|
|
@ -1010,7 +1010,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
// unregisterTable 함수는 의존성이 없어 안정적임
|
// unregisterTable 함수는 의존성이 없어 안정적임
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 🎯 초기 로드 시 localStorage에서 정렬 상태 불러오기
|
// 🎯 초기 로드 시 localStorage에서 정렬 상태 불러오기 (없으면 defaultSort 적용)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!tableConfig.selectedTable || !userId || hasInitializedSort.current) return;
|
if (!tableConfig.selectedTable || !userId || hasInitializedSort.current) return;
|
||||||
|
|
||||||
|
|
@ -1024,12 +1024,21 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
setSortColumn(column);
|
setSortColumn(column);
|
||||||
setSortDirection(direction);
|
setSortDirection(direction);
|
||||||
hasInitializedSort.current = true;
|
hasInitializedSort.current = true;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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에서 컬럼 순서 불러오기
|
// 🆕 초기 로드 시 localStorage에서 컬럼 순서 불러오기
|
||||||
useEffect(() => {
|
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) => {
|
cached.columns.forEach((col: any) => {
|
||||||
labels[col.columnName] = col.displayName || col.comment || col.columnName;
|
labels[col.columnName] = col.displayName || col.comment || col.columnName;
|
||||||
meta[col.columnName] = {
|
meta[col.columnName] = {
|
||||||
|
|
@ -1153,6 +1172,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
inputTypeMap[col.columnName] = col.inputType;
|
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, {
|
tableColumnCache.set(cacheKey, {
|
||||||
columns,
|
columns,
|
||||||
inputTypes,
|
inputTypes,
|
||||||
|
|
@ -1470,8 +1497,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
try {
|
try {
|
||||||
const page = tableConfig.pagination?.currentPage || currentPage;
|
const page = tableConfig.pagination?.currentPage || currentPage;
|
||||||
const pageSize = localPageSize;
|
const pageSize = localPageSize;
|
||||||
const sortBy = sortColumn || undefined;
|
// 🆕 sortColumn이 없으면 defaultSort 설정을 fallback으로 사용
|
||||||
const sortOrder = sortDirection;
|
const sortBy = sortColumn || tableConfig.defaultSort?.columnName || undefined;
|
||||||
|
const sortOrder = sortColumn ? sortDirection : (tableConfig.defaultSort?.direction || sortDirection);
|
||||||
const search = searchTerm || undefined;
|
const search = searchTerm || undefined;
|
||||||
|
|
||||||
// 🆕 연결 필터 값 가져오기 (분할 패널 내부일 때)
|
// 🆕 연결 필터 값 가져오기 (분할 패널 내부일 때)
|
||||||
|
|
@ -4051,19 +4079,44 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
// inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선)
|
// inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선)
|
||||||
const inputType = meta?.inputType || column.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") {
|
if (inputType === "image" && value) {
|
||||||
const imageUrl = getFullImageUrl(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 (
|
return (
|
||||||
|
<div className="flex justify-center">
|
||||||
<img
|
<img
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
alt="이미지"
|
alt="이미지"
|
||||||
className="h-10 w-10 rounded object-cover"
|
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) => {
|
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";
|
(e.target as HTMLImageElement).style.display = "none";
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -319,7 +319,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
|
|
||||||
const handleChange = (key: keyof TableListConfig, value: any) => {
|
const handleChange = (key: keyof TableListConfig, value: any) => {
|
||||||
// 기존 config와 병합하여 전달 (다른 속성 손실 방지)
|
// 기존 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) => {
|
const handleNestedChange = (parentKey: keyof TableListConfig, childKey: string, value: any) => {
|
||||||
|
|
@ -884,6 +886,67 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</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">오름차순 (A→Z, 1→9)</option>
|
||||||
|
<option value="desc">내림차순 (Z→A, 9→1)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 가로 스크롤 및 컬럼 고정 */}
|
{/* 가로 스크롤 및 컬럼 고정 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -278,6 +278,12 @@ export interface TableListConfig extends ComponentConfig {
|
||||||
autoLoad: boolean;
|
autoLoad: boolean;
|
||||||
refreshInterval?: number; // 초 단위
|
refreshInterval?: number; // 초 단위
|
||||||
|
|
||||||
|
// 🆕 기본 정렬 설정
|
||||||
|
defaultSort?: {
|
||||||
|
columnName: string; // 정렬할 컬럼명
|
||||||
|
direction: "asc" | "desc"; // 정렬 방향
|
||||||
|
};
|
||||||
|
|
||||||
// 🆕 툴바 버튼 표시 설정
|
// 🆕 툴바 버튼 표시 설정
|
||||||
toolbar?: ToolbarConfig;
|
toolbar?: ToolbarConfig;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -107,18 +107,18 @@ export const WEB_TYPE_V2_MAPPING: Record<string, V2ComponentMapping> = {
|
||||||
config: { mode: "dropdown", source: "category" },
|
config: { mode: "dropdown", source: "category" },
|
||||||
},
|
},
|
||||||
|
|
||||||
// 파일/이미지 → 레거시 file-upload (안정적인 파일 업로드)
|
// 파일/이미지 → V2 파일 업로드
|
||||||
file: {
|
file: {
|
||||||
componentType: "file-upload",
|
componentType: "v2-file-upload",
|
||||||
config: { maxFileCount: 10, accept: "*/*" },
|
config: { multiple: true, accept: "*/*", maxFiles: 10 },
|
||||||
},
|
},
|
||||||
image: {
|
image: {
|
||||||
componentType: "file-upload",
|
componentType: "v2-file-upload",
|
||||||
config: { maxFileCount: 1, accept: "image/*" },
|
config: { multiple: false, accept: "image/*", maxFiles: 1, showPreview: true },
|
||||||
},
|
},
|
||||||
img: {
|
img: {
|
||||||
componentType: "file-upload",
|
componentType: "v2-file-upload",
|
||||||
config: { maxFileCount: 1, accept: "image/*" },
|
config: { multiple: false, accept: "image/*", maxFiles: 1, showPreview: true },
|
||||||
},
|
},
|
||||||
|
|
||||||
// 버튼은 V2 컴포넌트에서 제외 (기존 버튼 시스템 사용)
|
// 버튼은 V2 컴포넌트에서 제외 (기존 버튼 시스템 사용)
|
||||||
|
|
@ -157,9 +157,9 @@ export const WEB_TYPE_COMPONENT_MAPPING: Record<string, string> = {
|
||||||
code: "v2-select",
|
code: "v2-select",
|
||||||
entity: "v2-select",
|
entity: "v2-select",
|
||||||
category: "v2-select",
|
category: "v2-select",
|
||||||
file: "file-upload",
|
file: "v2-file-upload",
|
||||||
image: "file-upload",
|
image: "v2-file-upload",
|
||||||
img: "file-upload",
|
img: "v2-file-upload",
|
||||||
button: "button-primary",
|
button: "button-primary",
|
||||||
label: "v2-input",
|
label: "v2-input",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue