Compare commits
No commits in common. "9994a47e547e9869eda22f6a404e3bd4d8b126a1" and "7ec5a438d450df44f50b09abf3e0beaf48bf4974" have entirely different histories.
9994a47e54
...
7ec5a438d4
|
|
@ -1261,56 +1261,5 @@ 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,8 +2344,6 @@ 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,
|
||||||
|
|
@ -2353,11 +2351,9 @@ 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} 을 참조하는 테이블 (회사코드: ${userCompanyCode}) ===`
|
`=== 테이블 참조 관계 조회 시작: ${tableName} 을 참조하는 테이블 ===`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!tableName) {
|
if (!tableName) {
|
||||||
|
|
@ -2375,41 +2371,23 @@ 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 = `
|
||||||
WITH ranked AS (
|
|
||||||
SELECT
|
|
||||||
ttc.table_name,
|
|
||||||
ttc.column_name,
|
|
||||||
ttc.column_label,
|
|
||||||
ttc.reference_table,
|
|
||||||
ttc.reference_column,
|
|
||||||
ttc.display_column,
|
|
||||||
ttc.company_code,
|
|
||||||
ROW_NUMBER() OVER (
|
|
||||||
PARTITION BY ttc.table_name, ttc.column_name
|
|
||||||
ORDER BY CASE WHEN ttc.company_code = $2 THEN 1 ELSE 2 END
|
|
||||||
) as rn
|
|
||||||
FROM table_type_columns ttc
|
|
||||||
WHERE ttc.reference_table = $1
|
|
||||||
AND ttc.input_type = 'entity'
|
|
||||||
AND ttc.company_code IN ($2, '*')
|
|
||||||
)
|
|
||||||
SELECT DISTINCT
|
SELECT DISTINCT
|
||||||
table_name,
|
ttc.table_name,
|
||||||
column_name,
|
ttc.column_name,
|
||||||
column_label,
|
ttc.column_label,
|
||||||
reference_table,
|
ttc.reference_table,
|
||||||
reference_column,
|
ttc.reference_column,
|
||||||
display_column,
|
ttc.display_column,
|
||||||
table_name as table_label
|
ttc.table_name as table_label
|
||||||
FROM ranked
|
FROM table_type_columns ttc
|
||||||
WHERE rn = 1
|
WHERE ttc.reference_table = $1
|
||||||
ORDER BY table_name, column_name
|
AND ttc.input_type = 'entity'
|
||||||
|
AND ttc.company_code = '*'
|
||||||
|
ORDER BY ttc.table_name, ttc.column_name
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = await query(sqlQuery, [tableName, userCompanyCode]);
|
const result = await query(sqlQuery, [tableName]);
|
||||||
|
|
||||||
const referencedByTables = result.map((row: any) => ({
|
const referencedByTables = result.map((row: any) => ({
|
||||||
tableName: row.table_name,
|
tableName: row.table_name,
|
||||||
|
|
@ -2422,7 +2400,7 @@ export async function getReferencedByTables(
|
||||||
}));
|
}));
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`테이블 참조 관계 조회 완료: ${referencedByTables.length}개 발견 (회사코드: ${userCompanyCode})`
|
`테이블 참조 관계 조회 완료: ${referencedByTables.length}개 발견`
|
||||||
);
|
);
|
||||||
|
|
||||||
const response: ApiResponse<any> = {
|
const response: ApiResponse<any> = {
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import {
|
||||||
generateTempToken,
|
generateTempToken,
|
||||||
getFileByToken,
|
getFileByToken,
|
||||||
setRepresentativeFile,
|
setRepresentativeFile,
|
||||||
getFileInfo,
|
|
||||||
} from "../controllers/fileController";
|
} from "../controllers/fileController";
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
|
@ -32,13 +31,6 @@ 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, getFullImageUrl } from "@/lib/api/client";
|
import { apiClient, getCurrentUser, UserInfo } 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,37 +2224,6 @@ 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,7 +263,6 @@ 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 반환 (리마운트 방지)
|
||||||
|
|
@ -1060,15 +1059,8 @@ 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,8 +124,7 @@ 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;
|
||||||
// 🔑 columnName 우선 사용 (실제 DB 컬럼명), 없으면 id, 최후에 attachments
|
const effectiveColumnName = isRecordMode ? 'attachments' : (columnName || id || 'attachments');
|
||||||
const effectiveColumnName = columnName || id || 'attachments';
|
|
||||||
|
|
||||||
// 레코드용 targetObjid 생성
|
// 레코드용 targetObjid 생성
|
||||||
const getRecordTargetObjid = useCallback(() => {
|
const getRecordTargetObjid = useCallback(() => {
|
||||||
|
|
@ -472,21 +471,13 @@ 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, formValue);
|
onFormDataChange(targetColumn, fileIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 그리드 파일 상태 새로고침 이벤트 발생
|
// 그리드 파일 상태 새로고침 이벤트 발생
|
||||||
|
|
@ -610,19 +601,12 @@ 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, formValue);
|
onFormDataChange(targetColumn, fileIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success(`${fileName} 삭제 완료`);
|
toast.success(`${fileName} 삭제 완료`);
|
||||||
|
|
|
||||||
|
|
@ -298,31 +298,3 @@ 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,23 +14,22 @@ import { FileUploadConfig } from "./types";
|
||||||
*/
|
*/
|
||||||
export const FileUploadDefinition = createComponentDefinition({
|
export const FileUploadDefinition = createComponentDefinition({
|
||||||
id: "file-upload",
|
id: "file-upload",
|
||||||
name: "파일 업로드 (레거시)",
|
name: "파일 업로드",
|
||||||
nameEng: "FileUpload Component (Legacy)",
|
nameEng: "FileUpload Component",
|
||||||
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 },
|
defaultSize: { width: 350, height: 240 }, // 40 * 6 (파일 선택 + 목록 표시)
|
||||||
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 (Legacy)",
|
nameEng: "Image Widget",
|
||||||
description: "이미지 표시 및 업로드 (레거시)",
|
description: "이미지 표시 및 업로드",
|
||||||
category: ComponentCategory.INPUT,
|
category: ComponentCategory.INPUT,
|
||||||
webType: "image",
|
webType: "image",
|
||||||
component: ImageWidget,
|
component: ImageWidget,
|
||||||
|
|
@ -32,7 +32,6 @@ 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,7 +111,6 @@ 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 파일 업로드 컴포넌트
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 컴포넌트 초기화 함수
|
* 컴포넌트 초기화 함수
|
||||||
|
|
|
||||||
|
|
@ -1,529 +0,0 @@
|
||||||
"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
|
|
@ -1,287 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,543 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
"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();
|
|
||||||
}
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
"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 },
|
|
||||||
};
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
"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;
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
"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,15 +30,6 @@ 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);
|
||||||
|
|
||||||
|
|
@ -67,14 +58,17 @@ export class V2MediaRenderer extends AutoRegisteringComponentRenderer {
|
||||||
componentConfig: legacyComponentConfig,
|
componentConfig: legacyComponentConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
// onFormDataChange 래퍼: FileUploadComponent는 (fieldName, value) 형태로 호출함
|
// onFormDataChange 래퍼: 레거시 컴포넌트는 객체를 전달하므로 변환 필요
|
||||||
const handleFormDataChange = (fieldName: string, value: any) => {
|
const handleFormDataChange = (data: any) => {
|
||||||
if (onFormDataChange) {
|
if (onFormDataChange) {
|
||||||
// 메타 데이터(__로 시작하는 키)는 건너뛰기
|
// 레거시 컴포넌트는 { [columnName]: value } 형태로 전달
|
||||||
if (!fieldName.startsWith("__")) {
|
// 부모는 (fieldName, value) 형태를 기대
|
||||||
console.log("📸 [V2MediaRenderer] formData 업데이트:", { fieldName, value });
|
Object.entries(data).forEach(([key, value]) => {
|
||||||
onFormDataChange(fieldName, value);
|
// __attachmentsUpdate 같은 메타 데이터는 건너뛰기
|
||||||
}
|
if (!key.startsWith("__")) {
|
||||||
|
onFormDataChange(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ interface GroupedData {
|
||||||
// 캐시 및 유틸리티
|
// 캐시 및 유틸리티
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
const tableColumnCache = new Map<string, { columns: any[]; inputTypes?: any[]; timestamp: number }>();
|
const tableColumnCache = new Map<string, { columns: 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에서 정렬 상태 불러오기 (없으면 defaultSort 적용)
|
// 🎯 초기 로드 시 localStorage에서 정렬 상태 불러오기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!tableConfig.selectedTable || !userId || hasInitializedSort.current) return;
|
if (!tableConfig.selectedTable || !userId || hasInitializedSort.current) return;
|
||||||
|
|
||||||
|
|
@ -1024,21 +1024,12 @@ 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(() => {
|
||||||
|
|
@ -1139,16 +1130,6 @@ 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] = {
|
||||||
|
|
@ -1172,14 +1153,6 @@ 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,
|
||||||
|
|
@ -1497,9 +1470,8 @@ 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;
|
||||||
// 🆕 sortColumn이 없으면 defaultSort 설정을 fallback으로 사용
|
const sortBy = sortColumn || undefined;
|
||||||
const sortBy = sortColumn || tableConfig.defaultSort?.columnName || undefined;
|
const sortOrder = sortDirection;
|
||||||
const sortOrder = sortColumn ? sortDirection : (tableConfig.defaultSort?.direction || sortDirection);
|
|
||||||
const search = searchTerm || undefined;
|
const search = searchTerm || undefined;
|
||||||
|
|
||||||
// 🆕 연결 필터 값 가져오기 (분할 패널 내부일 때)
|
// 🆕 연결 필터 값 가져오기 (분할 패널 내부일 때)
|
||||||
|
|
@ -4079,44 +4051,19 @@ 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) {
|
if (inputType === "image" && value && typeof value === "string") {
|
||||||
// value가 objid (숫자 또는 숫자 문자열)인 경우 파일 API URL 사용
|
const imageUrl = getFullImageUrl(value);
|
||||||
// 🔑 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"
|
onError={(e) => {
|
||||||
style={{ maxWidth: "40px", maxHeight: "40px" }}
|
e.currentTarget.src =
|
||||||
onClick={(e) => {
|
"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.stopPropagation();
|
}}
|
||||||
// 이미지 클릭 시 새 탭에서 크게 보기
|
/>
|
||||||
window.open(imageUrl, "_blank");
|
|
||||||
}}
|
|
||||||
onError={(e) => {
|
|
||||||
// 이미지 로드 실패 시 기본 아이콘 표시
|
|
||||||
(e.target as HTMLImageElement).style.display = "none";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -319,9 +319,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
|
|
||||||
const handleChange = (key: keyof TableListConfig, value: any) => {
|
const handleChange = (key: keyof TableListConfig, value: any) => {
|
||||||
// 기존 config와 병합하여 전달 (다른 속성 손실 방지)
|
// 기존 config와 병합하여 전달 (다른 속성 손실 방지)
|
||||||
const newConfig = { ...config, [key]: value };
|
onChange({ ...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) => {
|
||||||
|
|
@ -886,67 +884,6 @@ 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,12 +278,6 @@ 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" },
|
||||||
},
|
},
|
||||||
|
|
||||||
// 파일/이미지 → V2 파일 업로드
|
// 파일/이미지 → 레거시 file-upload (안정적인 파일 업로드)
|
||||||
file: {
|
file: {
|
||||||
componentType: "v2-file-upload",
|
componentType: "file-upload",
|
||||||
config: { multiple: true, accept: "*/*", maxFiles: 10 },
|
config: { maxFileCount: 10, accept: "*/*" },
|
||||||
},
|
},
|
||||||
image: {
|
image: {
|
||||||
componentType: "v2-file-upload",
|
componentType: "file-upload",
|
||||||
config: { multiple: false, accept: "image/*", maxFiles: 1, showPreview: true },
|
config: { maxFileCount: 1, accept: "image/*" },
|
||||||
},
|
},
|
||||||
img: {
|
img: {
|
||||||
componentType: "v2-file-upload",
|
componentType: "file-upload",
|
||||||
config: { multiple: false, accept: "image/*", maxFiles: 1, showPreview: true },
|
config: { maxFileCount: 1, accept: "image/*" },
|
||||||
},
|
},
|
||||||
|
|
||||||
// 버튼은 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: "v2-file-upload",
|
file: "file-upload",
|
||||||
image: "v2-file-upload",
|
image: "file-upload",
|
||||||
img: "v2-file-upload",
|
img: "file-upload",
|
||||||
button: "button-primary",
|
button: "button-primary",
|
||||||
label: "v2-input",
|
label: "v2-input",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue