데이터 테이블 첨부파일 연계
This commit is contained in:
@@ -35,24 +35,42 @@ import {
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
RotateCw,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
} from "lucide-react";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { getCurrentUser, UserInfo } from "@/lib/api/client";
|
||||
import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen";
|
||||
import { DataTableComponent, DataTableColumn, DataTableFilter, AttachedFileInfo } from "@/types/screen";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { downloadFile } from "@/lib/api/file";
|
||||
import { downloadFile, getLinkedFiles } from "@/lib/api/file";
|
||||
import { toast } from "sonner";
|
||||
import { FileUpload } from "@/components/screen/widgets/FileUpload";
|
||||
|
||||
// 파일 데이터 타입 정의
|
||||
// 파일 데이터 타입 정의 (AttachedFileInfo와 호환)
|
||||
interface FileInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
extension: string;
|
||||
uploadedAt: string;
|
||||
lastModified: string;
|
||||
serverFilename?: string; // 서버에 저장된 파일명 (다운로드용)
|
||||
// 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와 동일
|
||||
}
|
||||
|
||||
interface FileColumnData {
|
||||
@@ -94,6 +112,112 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [rotation, setRotation] = useState(0);
|
||||
|
||||
// 파일 관리 상태
|
||||
const [fileStatusMap, setFileStatusMap] = useState<Record<string, { hasFiles: boolean; fileCount: number }>>({}); // 행별 파일 상태
|
||||
const [showFileManagementModal, setShowFileManagementModal] = useState(false);
|
||||
const [selectedRowForFiles, setSelectedRowForFiles] = useState<Record<string, any> | null>(null);
|
||||
const [selectedColumnForFiles, setSelectedColumnForFiles] = useState<DataTableColumn | null>(null); // 선택된 컬럼 정보
|
||||
const [linkedFiles, setLinkedFiles] = useState<any[]>([]);
|
||||
|
||||
// 파일 상태 확인 함수
|
||||
const checkFileStatus = useCallback(
|
||||
async (rowData: Record<string, any>) => {
|
||||
if (!component.tableName) return;
|
||||
|
||||
// 첫 번째 컬럼을 기본키로 사용 (실제로는 더 정교한 로직 필요)
|
||||
const primaryKeyField = Object.keys(rowData)[0]; // 임시로 첫 번째 컬럼 사용
|
||||
const recordId = rowData[primaryKeyField];
|
||||
|
||||
if (!recordId) return;
|
||||
|
||||
try {
|
||||
const response = await getLinkedFiles(component.tableName, recordId);
|
||||
const hasFiles = response.files && response.files.length > 0;
|
||||
const fileCount = response.files ? response.files.length : 0;
|
||||
|
||||
return { hasFiles, fileCount, files: response.files || [] };
|
||||
} catch (error) {
|
||||
console.error("파일 상태 확인 오류:", error);
|
||||
return { hasFiles: false, fileCount: 0, files: [] };
|
||||
}
|
||||
},
|
||||
[component.tableName],
|
||||
);
|
||||
|
||||
// 파일 폴더 아이콘 클릭 핸들러 (전체 행 파일 관리)
|
||||
const handleFileIconClick = useCallback(
|
||||
async (rowData: Record<string, any>) => {
|
||||
const fileStatus = await checkFileStatus(rowData);
|
||||
if (fileStatus) {
|
||||
setSelectedRowForFiles(rowData);
|
||||
setLinkedFiles(fileStatus.files);
|
||||
setShowFileManagementModal(true);
|
||||
}
|
||||
},
|
||||
[checkFileStatus],
|
||||
);
|
||||
|
||||
// 컬럼별 파일 상태 확인
|
||||
const checkColumnFileStatus = useCallback(
|
||||
async (rowData: Record<string, any>, column: DataTableColumn) => {
|
||||
if (!component.tableName) return null;
|
||||
|
||||
const primaryKeyField = Object.keys(rowData)[0];
|
||||
const recordId = rowData[primaryKeyField];
|
||||
if (!recordId) return null;
|
||||
|
||||
try {
|
||||
// 가상 파일 컬럼의 경우: tableName:recordId:columnName 형태로 target_objid 생성
|
||||
const targetObjid = column.isVirtualFileColumn
|
||||
? `${component.tableName}:${recordId}:${column.columnName}`
|
||||
: `${component.tableName}:${recordId}`;
|
||||
|
||||
const response = await getLinkedFiles(component.tableName, recordId);
|
||||
|
||||
// 가상 파일 컬럼의 경우 해당 컬럼의 파일만 필터링
|
||||
let files = response.files || [];
|
||||
if (column.isVirtualFileColumn) {
|
||||
// 현재 컬럼명으로 먼저 시도
|
||||
files = files.filter(
|
||||
(file: any) => file.targetObjid === targetObjid || file.targetObjid?.endsWith(`:${column.columnName}`), // target_objid → targetObjid
|
||||
);
|
||||
|
||||
// 파일이 없는 경우 fallback: 모든 파일 컬럼 패턴 시도
|
||||
if (files.length === 0) {
|
||||
// 해당 테이블:레코드의 모든 파일 컬럼 파일들을 가져옴
|
||||
files = (response.files || []).filter(
|
||||
(file: any) => file.targetObjid?.startsWith(`${component.tableName}:${recordId}:file_column_`), // target_objid → targetObjid
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const hasFiles = files.length > 0;
|
||||
const fileCount = files.length;
|
||||
|
||||
return { hasFiles, fileCount, files, targetObjid };
|
||||
} catch (error) {
|
||||
console.error("컬럼별 파일 상태 확인 오류:", error);
|
||||
return { hasFiles: false, fileCount: 0, files: [], targetObjid: null };
|
||||
}
|
||||
},
|
||||
[component.tableName],
|
||||
);
|
||||
|
||||
// 컬럼별 파일 클릭 핸들러
|
||||
const handleColumnFileClick = useCallback(
|
||||
async (rowData: Record<string, any>, column: DataTableColumn) => {
|
||||
// 컬럼별 파일 상태 확인
|
||||
const fileStatus = await checkColumnFileStatus(rowData, column);
|
||||
setSelectedRowForFiles(rowData);
|
||||
setSelectedColumnForFiles(column); // 선택된 컬럼 정보 저장
|
||||
setLinkedFiles(fileStatus?.files || []);
|
||||
setShowFileManagementModal(true);
|
||||
|
||||
// TODO: 모달에 컬럼 정보 전달하여 해당 컬럼 전용 파일 업로드 가능하게 하기
|
||||
},
|
||||
[checkColumnFileStatus],
|
||||
);
|
||||
|
||||
// 이미지 미리보기 핸들러들
|
||||
const handlePreviewImage = useCallback((fileInfo: FileInfo) => {
|
||||
setPreviewImage(fileInfo);
|
||||
@@ -200,25 +324,92 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
console.log("🔍 테이블 데이터 조회:", {
|
||||
tableName: component.tableName,
|
||||
page,
|
||||
pageSize,
|
||||
searchParams,
|
||||
});
|
||||
|
||||
const result = await tableTypeApi.getTableData(component.tableName, {
|
||||
page,
|
||||
size: pageSize,
|
||||
search: searchParams,
|
||||
});
|
||||
|
||||
console.log("✅ 테이블 데이터 조회 결과:", result);
|
||||
|
||||
setData(result.data);
|
||||
setTotal(result.total);
|
||||
setTotalPages(result.totalPages);
|
||||
setCurrentPage(result.page);
|
||||
|
||||
// 각 행의 파일 상태 확인 (전체 행 + 가상 파일 컬럼별)
|
||||
const fileStatusPromises = result.data.map(async (rowData: Record<string, any>) => {
|
||||
const primaryKeyField = Object.keys(rowData)[0];
|
||||
const recordId = rowData[primaryKeyField];
|
||||
|
||||
if (!recordId) return { rowKey: recordId, statuses: {} };
|
||||
|
||||
try {
|
||||
const fileResponse = await getLinkedFiles(component.tableName, recordId);
|
||||
const allFiles = fileResponse.files || [];
|
||||
|
||||
// 전체 행에 대한 파일 상태
|
||||
const rowStatus = {
|
||||
hasFiles: allFiles.length > 0,
|
||||
fileCount: allFiles.length,
|
||||
};
|
||||
|
||||
// 가상 파일 컬럼별 파일 상태
|
||||
const columnStatuses: Record<string, { hasFiles: boolean; fileCount: number }> = {};
|
||||
|
||||
// 가상 파일 컬럼 찾기
|
||||
const virtualFileColumns = component.columns.filter((col) => col.isVirtualFileColumn);
|
||||
|
||||
virtualFileColumns.forEach((column) => {
|
||||
// 해당 컬럼의 파일만 필터링 (targetObjid로 수정)
|
||||
let columnFiles = allFiles.filter((file: any) => file.targetObjid?.endsWith(`:${column.columnName}`));
|
||||
|
||||
// fallback: 컬럼명으로 찾지 못한 경우 모든 파일 컬럼 파일 포함
|
||||
if (columnFiles.length === 0) {
|
||||
columnFiles = allFiles.filter((file: any) =>
|
||||
file.targetObjid?.startsWith(`${component.tableName}:${recordId}:file_column_`),
|
||||
);
|
||||
}
|
||||
|
||||
const columnKey = `${recordId}_${column.columnName}`;
|
||||
columnStatuses[columnKey] = {
|
||||
hasFiles: columnFiles.length > 0,
|
||||
fileCount: columnFiles.length,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
rowKey: recordId,
|
||||
statuses: {
|
||||
[recordId]: rowStatus, // 전체 행 상태
|
||||
...columnStatuses, // 컬럼별 상태
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
// 에러 시 기본값
|
||||
const defaultStatuses: Record<string, { hasFiles: boolean; fileCount: number }> = {
|
||||
[recordId]: { hasFiles: false, fileCount: 0 },
|
||||
};
|
||||
|
||||
// 가상 파일 컬럼에 대해서도 기본값 설정
|
||||
const virtualFileColumns = component.columns.filter((col) => col.isVirtualFileColumn);
|
||||
virtualFileColumns.forEach((column) => {
|
||||
const columnKey = `${recordId}_${column.columnName}`;
|
||||
defaultStatuses[columnKey] = { hasFiles: false, fileCount: 0 };
|
||||
});
|
||||
|
||||
return { rowKey: recordId, statuses: defaultStatuses };
|
||||
}
|
||||
});
|
||||
|
||||
// 파일 상태 업데이트
|
||||
Promise.all(fileStatusPromises).then((results) => {
|
||||
const statusMap: Record<string, { hasFiles: boolean; fileCount: number }> = {};
|
||||
|
||||
results.forEach((result) => {
|
||||
Object.assign(statusMap, result.statuses);
|
||||
});
|
||||
|
||||
setFileStatusMap(statusMap);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ 테이블 데이터 조회 실패:", error);
|
||||
setData([]);
|
||||
@@ -251,10 +442,8 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||
useEffect(() => {
|
||||
const fetchTableColumns = async () => {
|
||||
try {
|
||||
console.log("🔄 테이블 컬럼 정보 로드 시작:", component.tableName);
|
||||
const columns = await tableTypeApi.getColumns(component.tableName);
|
||||
setTableColumns(columns);
|
||||
console.log("✅ 테이블 컬럼 정보 로드 완료:", columns);
|
||||
} catch (error) {
|
||||
console.error("테이블 컬럼 정보 로드 실패:", error);
|
||||
}
|
||||
@@ -272,7 +461,6 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||
|
||||
// 검색 실행
|
||||
const handleSearch = useCallback(() => {
|
||||
console.log("🔍 검색 실행:", searchValues);
|
||||
loadData(1, searchValues);
|
||||
}, [searchValues, loadData]);
|
||||
|
||||
@@ -512,8 +700,6 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||
} else {
|
||||
handleAddFormChange(columnName, fileNames);
|
||||
}
|
||||
|
||||
console.log("✅ 파일 업로드 완료:", validFiles);
|
||||
} catch (error) {
|
||||
console.error("파일 업로드 실패:", error);
|
||||
alert("파일 업로드에 실패했습니다.");
|
||||
@@ -1280,25 +1466,15 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 파일 모달 열기
|
||||
const openFileModal = (fileData: FileColumnData, column: DataTableColumn) => {
|
||||
setCurrentFileData(fileData);
|
||||
setCurrentFileColumn(column);
|
||||
setShowFileModal(true);
|
||||
};
|
||||
|
||||
// 파일 다운로드
|
||||
const handleDownloadFile = useCallback(async (fileInfo: FileInfo) => {
|
||||
try {
|
||||
console.log("📥 파일 다운로드 시작:", fileInfo);
|
||||
|
||||
// serverFilename이 없는 경우 파일 경로에서 추출 시도
|
||||
const serverFilename = fileInfo.serverFilename || (fileInfo.path ? fileInfo.path.split("/").pop() : null);
|
||||
// savedFileName이 없는 경우 파일 경로에서 추출 시도
|
||||
const serverFilename = fileInfo.savedFileName || (fileInfo.path ? fileInfo.path.split("/").pop() : null);
|
||||
|
||||
if (!serverFilename) {
|
||||
// _file 속성이 있는 경우 로컬 파일로 다운로드
|
||||
if ((fileInfo as any)._file) {
|
||||
console.log("📁 로컬 파일 다운로드 시도:", fileInfo.name);
|
||||
try {
|
||||
const file = (fileInfo as any)._file;
|
||||
|
||||
@@ -1309,12 +1485,6 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("📁 유효한 파일 객체 확인됨:", {
|
||||
name: file.name || fileInfo.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(file);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
@@ -1352,108 +1522,50 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||
}, []);
|
||||
|
||||
// 셀 값 포맷팅
|
||||
const formatCellValue = (value: any, column: DataTableColumn): React.ReactNode => {
|
||||
if (value === null || value === undefined) return "";
|
||||
const formatCellValue = (value: any, column: DataTableColumn, rowData?: Record<string, any>): React.ReactNode => {
|
||||
// 가상 파일 컬럼의 경우 value가 없어도 파일 아이콘을 표시해야 함
|
||||
if (!column.isVirtualFileColumn && (value === null || value === undefined)) return "";
|
||||
|
||||
// 디버깅을 위한 로그 추가
|
||||
if (column.columnName === "file_path") {
|
||||
console.log("📊 formatCellValue (file_path 컬럼):", {
|
||||
columnName: column.columnName,
|
||||
widgetType: column.widgetType,
|
||||
value: value,
|
||||
valueType: typeof value,
|
||||
fullColumn: column,
|
||||
});
|
||||
}
|
||||
// 파일 타입 컬럼 처리 (가상 파일 컬럼 포함)
|
||||
const isFileColumn =
|
||||
column.widgetType === "file" || column.columnName === "file_path" || column.isVirtualFileColumn;
|
||||
|
||||
// file_path 컬럼은 강제로 파일 타입으로 처리 (임시 해결책)
|
||||
const isFileColumn = column.widgetType === "file" || column.columnName === "file_path";
|
||||
// 파일 타입 컬럼은 파일 아이콘으로 표시 (컬럼별 파일 관리)
|
||||
if (isFileColumn && rowData) {
|
||||
// 현재 행의 기본키 값 가져오기
|
||||
const primaryKeyField = Object.keys(rowData)[0];
|
||||
const recordId = rowData[primaryKeyField];
|
||||
|
||||
// file_path 컬럼도 파일 타입으로 처리
|
||||
if (isFileColumn) {
|
||||
console.log("🗂️ 파일 타입 컬럼 처리 중:", value);
|
||||
if (value) {
|
||||
try {
|
||||
let fileData;
|
||||
// 해당 컬럼에 대한 파일 상태 확인
|
||||
const columnFileKey = `${recordId}_${column.columnName}`;
|
||||
const columnFileStatus = fileStatusMap[columnFileKey];
|
||||
const hasFiles = columnFileStatus?.hasFiles || false;
|
||||
const fileCount = columnFileStatus?.fileCount || 0;
|
||||
|
||||
// 파일 경로 문자열인지 확인 (/uploads/로 시작하는 경우)
|
||||
if (typeof value === "string" && value.startsWith("/uploads/")) {
|
||||
// 파일 경로 문자열인 경우 단일 파일로 처리
|
||||
const fileName = value.split("/").pop() || "파일";
|
||||
const fileExt = fileName.split(".").pop()?.toLowerCase() || "";
|
||||
fileData = {
|
||||
files: [
|
||||
{
|
||||
name: fileName.replace(/^\d+_/, ""), // 타임스탬프 제거
|
||||
path: value,
|
||||
objid: Date.now().toString(), // 임시 objid
|
||||
size: 0, // 크기 정보 없음
|
||||
type:
|
||||
fileExt === "jpg" || fileExt === "jpeg"
|
||||
? "image/jpeg"
|
||||
: fileExt === "png"
|
||||
? "image/png"
|
||||
: fileExt === "gif"
|
||||
? "image/gif"
|
||||
: fileExt === "pdf"
|
||||
? "application/pdf"
|
||||
: "application/octet-stream",
|
||||
extension: fileExt,
|
||||
regdate: new Date().toISOString(), // 등록일 추가
|
||||
writer: "시스템", // 기본 등록자
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
totalSize: 0,
|
||||
regdate: new Date().toISOString(), // 파일 데이터 전체에도 등록일 추가
|
||||
};
|
||||
} else {
|
||||
// JSON 문자열이면 파싱
|
||||
fileData = typeof value === "string" ? JSON.parse(value) : value;
|
||||
|
||||
// regdate가 없는 경우 기본값 설정
|
||||
if (!fileData.regdate) {
|
||||
fileData.regdate = new Date().toISOString();
|
||||
}
|
||||
|
||||
// 개별 파일들에도 regdate와 writer가 없는 경우 추가
|
||||
if (fileData.files && Array.isArray(fileData.files)) {
|
||||
fileData.files.forEach((file: any) => {
|
||||
if (!file.regdate) {
|
||||
file.regdate = new Date().toISOString();
|
||||
}
|
||||
if (!file.writer) {
|
||||
file.writer = "시스템";
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log("📁 파싱된 파일 데이터:", fileData);
|
||||
|
||||
if (fileData?.files && Array.isArray(fileData.files) && fileData.files.length > 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-blue-600 hover:bg-blue-50 hover:text-blue-800"
|
||||
onClick={() => openFileModal(fileData, column)}
|
||||
>
|
||||
<File className="mr-1 h-4 w-4" />
|
||||
{fileData.totalCount === 1 ? "파일 1개" : `파일 ${fileData.totalCount}개`}
|
||||
</Button>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{(fileData.totalSize / 1024 / 1024).toFixed(1)}MB
|
||||
</Badge>
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 hover:bg-blue-50"
|
||||
onClick={() => handleColumnFileClick(rowData, column)}
|
||||
title={hasFiles ? `${fileCount}개 파일 보기` : "파일 업로드"}
|
||||
>
|
||||
{hasFiles ? (
|
||||
<div className="relative">
|
||||
<FolderOpen className="h-4 w-4 text-blue-600" />
|
||||
{fileCount > 0 && (
|
||||
<div className="absolute -top-1 -right-1 flex h-3 w-3 items-center justify-center rounded-full bg-blue-600 text-[10px] text-white">
|
||||
{fileCount > 9 ? "9+" : fileCount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("파일 데이터 파싱 오류:", error);
|
||||
}
|
||||
}
|
||||
return <span className="text-sm text-gray-400 italic">파일 없음</span>;
|
||||
) : (
|
||||
<Folder className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
switch (column.widgetType) {
|
||||
@@ -1614,13 +1726,26 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||
{column.label}
|
||||
</TableHead>
|
||||
))}
|
||||
{/* 기본 파일 컬럼은 가상 파일 컬럼이 있으면 완전히 숨김 */}
|
||||
{!visibleColumns.some((col) => col.widgetType === "file") && (
|
||||
<TableHead className="w-16 px-4 text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Folder className="h-4 w-4" />
|
||||
<span className="text-xs">파일</span>
|
||||
</div>
|
||||
</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={visibleColumns.length + (component.enableDelete ? 1 : 0)}
|
||||
colSpan={
|
||||
visibleColumns.length +
|
||||
(component.enableDelete ? 1 : 0) +
|
||||
(!visibleColumns.some((col) => col.widgetType === "file") ? 1 : 0)
|
||||
}
|
||||
className="h-32 text-center"
|
||||
>
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2">
|
||||
@@ -1643,15 +1768,54 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||
)}
|
||||
{visibleColumns.map((column: DataTableColumn) => (
|
||||
<TableCell key={column.id} className="px-4 font-mono text-sm">
|
||||
{formatCellValue(row[column.columnName], column)}
|
||||
{formatCellValue(row[column.columnName], column, row)}
|
||||
</TableCell>
|
||||
))}
|
||||
{/* 기본 파일 셀은 가상 파일 컬럼이 있으면 완전히 숨김 */}
|
||||
{!visibleColumns.some((col) => col.widgetType === "file") && (
|
||||
<TableCell className="w-16 px-4 text-center">
|
||||
{(() => {
|
||||
const primaryKeyField = Object.keys(row)[0];
|
||||
const recordId = row[primaryKeyField];
|
||||
const fileStatus = fileStatusMap[recordId];
|
||||
const hasFiles = fileStatus?.hasFiles || false;
|
||||
const fileCount = fileStatus?.fileCount || 0;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 hover:bg-blue-50"
|
||||
onClick={() => handleFileIconClick(row)}
|
||||
title={hasFiles ? `${fileCount}개 파일 보기` : "파일 업로드"}
|
||||
>
|
||||
{hasFiles ? (
|
||||
<div className="relative">
|
||||
<FolderOpen className="h-4 w-4 text-blue-600" />
|
||||
{fileCount > 0 && (
|
||||
<div className="absolute -top-1 -right-1 flex h-3 w-3 items-center justify-center rounded-full bg-blue-600 text-[10px] text-white">
|
||||
{fileCount > 9 ? "9+" : fileCount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Folder className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={visibleColumns.length + (component.enableDelete ? 1 : 0)}
|
||||
colSpan={
|
||||
visibleColumns.length +
|
||||
(component.enableDelete ? 1 : 0) +
|
||||
(!visibleColumns.some((col) => col.widgetType === "file") ? 1 : 0)
|
||||
}
|
||||
className="h-32 text-center"
|
||||
>
|
||||
<div className="text-muted-foreground flex flex-col items-center gap-2">
|
||||
@@ -2032,7 +2196,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||
<div className="flex flex-1 items-center justify-center overflow-auto rounded-lg bg-gray-50 p-4">
|
||||
{previewImage && (
|
||||
<img
|
||||
src={`${process.env.NEXT_PUBLIC_API_URL}/files/preview/${previewImage.id}?serverFilename=${previewImage.serverFilename}`}
|
||||
src={`${process.env.NEXT_PUBLIC_API_URL}/files/preview/${previewImage.id}?serverFilename=${previewImage.savedFileName}`}
|
||||
alt={previewImage.name}
|
||||
className="max-h-full max-w-full object-contain transition-transform duration-200"
|
||||
style={{
|
||||
@@ -2055,6 +2219,205 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 파일 관리 모달 */}
|
||||
<Dialog open={showFileManagementModal} onOpenChange={setShowFileManagementModal}>
|
||||
<DialogContent className="max-h-[80vh] max-w-4xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Folder className="h-5 w-5" />
|
||||
파일 관리
|
||||
{selectedRowForFiles && (
|
||||
<Badge variant="outline" className="ml-2">
|
||||
{Object.keys(selectedRowForFiles)[0]}: {selectedRowForFiles[Object.keys(selectedRowForFiles)[0]]}
|
||||
</Badge>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{linkedFiles.length > 0
|
||||
? `${linkedFiles.length}개의 파일이 연결되어 있습니다.`
|
||||
: "연결된 파일이 없습니다. 새 파일을 업로드하세요."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 기존 파일 목록 */}
|
||||
{linkedFiles.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-gray-900">연결된 파일</h4>
|
||||
{linkedFiles.map((file: any, index: number) => (
|
||||
<div key={index} className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<File className="h-5 w-5 text-blue-600" />
|
||||
<div>
|
||||
<div className="font-medium">{file.realFileName}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{(Number(file.fileSize) / 1024 / 1024).toFixed(2)} MB • {file.docTypeName}
|
||||
{file.regdate && <span> • {new Date(file.regdate).toLocaleString("ko-KR")}</span>}
|
||||
{file.writer && <span> • {file.writer}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{file.fileExt && ["jpg", "jpeg", "png", "gif"].includes(file.fileExt.toLowerCase()) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
// 이미지 미리보기 (기존 로직 재사용)
|
||||
const fileInfo: FileInfo = {
|
||||
id: file.objid,
|
||||
name: file.realFileName,
|
||||
size: Number(file.fileSize),
|
||||
type: `image/${file.fileExt}`,
|
||||
path: file.filePath,
|
||||
objid: file.objid,
|
||||
extension: file.fileExt,
|
||||
uploadedAt: file.regdate || new Date().toISOString(),
|
||||
lastModified: file.regdate || new Date().toISOString(),
|
||||
};
|
||||
handlePreviewImage(fileInfo);
|
||||
}}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
// 파일 다운로드 (기존 로직 재사용)
|
||||
const fileInfo: FileInfo = {
|
||||
id: file.objid,
|
||||
name: file.realFileName,
|
||||
size: Number(file.fileSize),
|
||||
type: `application/${file.fileExt}`,
|
||||
path: file.filePath,
|
||||
objid: file.objid,
|
||||
savedFileName: file.savedFileName,
|
||||
};
|
||||
handleDownloadFile(fileInfo);
|
||||
}}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 파일 업로드 섹션 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-gray-900">{selectedColumnForFiles?.label || "파일"} 업로드</h4>
|
||||
{selectedColumnForFiles?.isVirtualFileColumn && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{selectedColumnForFiles.fileColumnConfig?.docTypeName || "문서"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedRowForFiles && selectedColumnForFiles && component.tableName && (
|
||||
<div className="rounded-lg border p-4">
|
||||
<FileUpload
|
||||
component={{
|
||||
id: `modal-file-upload-${selectedColumnForFiles.id}`,
|
||||
type: "file",
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 400, height: 300 },
|
||||
uploadedFiles: [], // 빈 배열로 초기화
|
||||
fileConfig: {
|
||||
maxSize: selectedColumnForFiles.fileColumnConfig?.maxFiles || 10,
|
||||
maxFiles: selectedColumnForFiles.fileColumnConfig?.maxFiles || 5,
|
||||
multiple: true,
|
||||
showPreview: true,
|
||||
showProgress: true,
|
||||
autoUpload: true, // 자동 업로드 활성화
|
||||
chunkedUpload: false, // 기본 업로드 방식
|
||||
dragDropText: `${selectedColumnForFiles.label} 파일을 드래그하여 업로드하거나 클릭하세요`,
|
||||
uploadButtonText: "파일 업로드", // 업로드 버튼 텍스트
|
||||
accept: selectedColumnForFiles.fileColumnConfig?.accept || ["*/*"],
|
||||
// 문서 분류 설정
|
||||
docType: selectedColumnForFiles.fileColumnConfig?.docType || "DOCUMENT",
|
||||
docTypeName: selectedColumnForFiles.fileColumnConfig?.docTypeName || "일반 문서",
|
||||
// 자동 연결 설정
|
||||
autoLink: true,
|
||||
linkedTable: component.tableName,
|
||||
linkedField: Object.keys(selectedRowForFiles)[0], // 기본키 필드
|
||||
recordId: selectedRowForFiles[Object.keys(selectedRowForFiles)[0]], // 기본키 값
|
||||
// 가상 파일 컬럼별 구분을 위한 추가 정보
|
||||
columnName: selectedColumnForFiles.columnName,
|
||||
isVirtualFileColumn: selectedColumnForFiles.isVirtualFileColumn,
|
||||
},
|
||||
}}
|
||||
onUpdateComponent={() => {
|
||||
// 모달에서는 컴포넌트 업데이트가 필요 없으므로 빈 함수 제공
|
||||
}}
|
||||
onFileUpload={async () => {
|
||||
// 파일 업로드 완료 후 연결된 파일 목록 새로고침
|
||||
if (selectedRowForFiles && selectedColumnForFiles) {
|
||||
const result = await checkColumnFileStatus(selectedRowForFiles, selectedColumnForFiles);
|
||||
if (result) {
|
||||
setLinkedFiles(result.files);
|
||||
|
||||
// 파일 상태 맵도 업데이트
|
||||
const primaryKeyField = Object.keys(selectedRowForFiles)[0];
|
||||
const recordId = selectedRowForFiles[primaryKeyField];
|
||||
const columnFileKey = `${recordId}_${selectedColumnForFiles.columnName}`;
|
||||
|
||||
setFileStatusMap((prev) => {
|
||||
const newFileStatusMap = {
|
||||
...prev,
|
||||
[columnFileKey]: {
|
||||
hasFiles: result.hasFiles,
|
||||
fileCount: result.fileCount,
|
||||
},
|
||||
};
|
||||
return newFileStatusMap;
|
||||
});
|
||||
|
||||
// 전체 테이블의 해당 컬럼 파일 상태도 강제 새로고침
|
||||
setTimeout(() => {
|
||||
// 테이블 데이터 새로고침을 위해 loadData 호출
|
||||
if (data && data.length > 0) {
|
||||
// 현재 데이터를 그대로 사용하되 파일 상태만 새로고침
|
||||
const refreshPromises = data.map(async (row) => {
|
||||
const pk = Object.keys(row)[0];
|
||||
const rowId = row[pk];
|
||||
const fileKey = `${rowId}_${selectedColumnForFiles.columnName}`;
|
||||
|
||||
const columnStatus = await checkColumnFileStatus(row, selectedColumnForFiles);
|
||||
if (columnStatus) {
|
||||
setFileStatusMap((prev) => ({
|
||||
...prev,
|
||||
[fileKey]: {
|
||||
hasFiles: columnStatus.hasFiles,
|
||||
fileCount: columnStatus.fileCount,
|
||||
},
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
Promise.all(refreshPromises);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowFileManagementModal(false)}>
|
||||
닫기
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user