2025-09-03 15:23:12 +09:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import React, { useState, useEffect, useCallback } from "react";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
2025-09-03 16:38:10 +09:00
|
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
2025-09-03 15:23:12 +09:00
|
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
2025-09-03 16:38:10 +09:00
|
|
|
import {
|
|
|
|
|
Dialog,
|
|
|
|
|
DialogContent,
|
|
|
|
|
DialogDescription,
|
|
|
|
|
DialogFooter,
|
|
|
|
|
DialogHeader,
|
|
|
|
|
DialogTitle,
|
|
|
|
|
} from "@/components/ui/dialog";
|
|
|
|
|
import { Label } from "@/components/ui/label";
|
2025-09-03 15:23:12 +09:00
|
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
|
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
|
|
|
import { Separator } from "@/components/ui/separator";
|
2025-09-05 12:04:13 +09:00
|
|
|
import {
|
|
|
|
|
Search,
|
|
|
|
|
ChevronLeft,
|
|
|
|
|
ChevronRight,
|
|
|
|
|
RotateCcw,
|
|
|
|
|
Database,
|
|
|
|
|
Loader2,
|
|
|
|
|
Plus,
|
|
|
|
|
Edit,
|
|
|
|
|
Trash2,
|
|
|
|
|
File,
|
|
|
|
|
Download,
|
|
|
|
|
Eye,
|
|
|
|
|
X,
|
2025-09-05 14:52:10 +09:00
|
|
|
ZoomIn,
|
|
|
|
|
ZoomOut,
|
|
|
|
|
RotateCw,
|
2025-09-06 00:16:27 +09:00
|
|
|
Folder,
|
|
|
|
|
FolderOpen,
|
2025-09-05 12:04:13 +09:00
|
|
|
} from "lucide-react";
|
2025-09-03 15:23:12 +09:00
|
|
|
import { tableTypeApi } from "@/lib/api/screen";
|
2025-09-03 16:38:10 +09:00
|
|
|
import { getCurrentUser, UserInfo } from "@/lib/api/client";
|
2025-09-06 00:16:27 +09:00
|
|
|
import { DataTableComponent, DataTableColumn, DataTableFilter, AttachedFileInfo } from "@/types/screen";
|
2025-09-03 15:23:12 +09:00
|
|
|
import { cn } from "@/lib/utils";
|
2025-09-06 00:16:27 +09:00
|
|
|
import { downloadFile, getLinkedFiles } from "@/lib/api/file";
|
2025-09-05 12:04:13 +09:00
|
|
|
import { toast } from "sonner";
|
2025-09-06 00:16:27 +09:00
|
|
|
import { FileUpload } from "@/components/screen/widgets/FileUpload";
|
2025-09-05 12:04:13 +09:00
|
|
|
|
2025-09-06 00:16:27 +09:00
|
|
|
// 파일 데이터 타입 정의 (AttachedFileInfo와 호환)
|
2025-09-05 12:04:13 +09:00
|
|
|
interface FileInfo {
|
2025-09-06 00:16:27 +09:00
|
|
|
// 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와 동일
|
2025-09-05 12:04:13 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface FileColumnData {
|
|
|
|
|
files: FileInfo[];
|
|
|
|
|
totalCount: number;
|
|
|
|
|
totalSize: number;
|
|
|
|
|
lastModified: string;
|
|
|
|
|
}
|
2025-09-03 15:23:12 +09:00
|
|
|
|
|
|
|
|
interface InteractiveDataTableProps {
|
|
|
|
|
component: DataTableComponent;
|
|
|
|
|
className?: string;
|
|
|
|
|
style?: React.CSSProperties;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|
|
|
|
component,
|
|
|
|
|
className = "",
|
|
|
|
|
style = {},
|
|
|
|
|
}) => {
|
|
|
|
|
const [data, setData] = useState<Record<string, any>[]>([]);
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
|
|
|
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
|
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
|
|
|
const [total, setTotal] = useState(0);
|
2025-09-03 16:38:10 +09:00
|
|
|
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
|
|
|
|
const [showAddModal, setShowAddModal] = useState(false);
|
|
|
|
|
const [addFormData, setAddFormData] = useState<Record<string, any>>({});
|
|
|
|
|
const [isAdding, setIsAdding] = useState(false);
|
|
|
|
|
const [showEditModal, setShowEditModal] = useState(false);
|
|
|
|
|
const [editFormData, setEditFormData] = useState<Record<string, any>>({});
|
|
|
|
|
const [editingRowData, setEditingRowData] = useState<Record<string, any> | null>(null);
|
|
|
|
|
const [isEditing, setIsEditing] = useState(false);
|
2025-09-05 14:52:10 +09:00
|
|
|
|
|
|
|
|
// 이미지 미리보기 상태
|
|
|
|
|
const [previewImage, setPreviewImage] = useState<FileInfo | null>(null);
|
|
|
|
|
const [showPreviewModal, setShowPreviewModal] = useState(false);
|
|
|
|
|
const [zoom, setZoom] = useState(1);
|
|
|
|
|
const [rotation, setRotation] = useState(0);
|
|
|
|
|
|
2025-09-06 00:16:27 +09:00
|
|
|
// 파일 관리 상태
|
|
|
|
|
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],
|
|
|
|
|
);
|
|
|
|
|
|
2025-09-05 14:52:10 +09:00
|
|
|
// 이미지 미리보기 핸들러들
|
|
|
|
|
const handlePreviewImage = useCallback((fileInfo: FileInfo) => {
|
|
|
|
|
setPreviewImage(fileInfo);
|
|
|
|
|
setShowPreviewModal(true);
|
|
|
|
|
setZoom(1);
|
|
|
|
|
setRotation(0);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const closePreviewModal = useCallback(() => {
|
|
|
|
|
setShowPreviewModal(false);
|
|
|
|
|
setPreviewImage(null);
|
|
|
|
|
setZoom(1);
|
|
|
|
|
setRotation(0);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const handleZoom = useCallback((direction: "in" | "out") => {
|
|
|
|
|
setZoom((prev) => {
|
|
|
|
|
if (direction === "in") {
|
|
|
|
|
return Math.min(prev + 0.25, 3);
|
|
|
|
|
} else {
|
|
|
|
|
return Math.max(prev - 0.25, 0.25);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const handleRotate = useCallback(() => {
|
|
|
|
|
setRotation((prev) => (prev + 90) % 360);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const formatFileSize = useCallback((bytes: number): string => {
|
|
|
|
|
if (bytes === 0) return "0 Bytes";
|
|
|
|
|
const k = 1024;
|
|
|
|
|
const sizes = ["Bytes", "KB", "MB", "GB"];
|
|
|
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
|
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
|
|
|
|
}, []);
|
2025-09-05 12:04:13 +09:00
|
|
|
const [showFileModal, setShowFileModal] = useState(false);
|
|
|
|
|
const [currentFileData, setCurrentFileData] = useState<FileColumnData | null>(null);
|
|
|
|
|
const [currentFileColumn, setCurrentFileColumn] = useState<DataTableColumn | null>(null);
|
2025-09-03 16:38:10 +09:00
|
|
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
|
|
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
|
|
|
|
|
|
|
|
// 현재 사용자 정보
|
|
|
|
|
const [currentUser, setCurrentUser] = useState<UserInfo | null>(null);
|
2025-09-03 15:23:12 +09:00
|
|
|
|
2025-09-03 17:12:27 +09:00
|
|
|
// 테이블 컬럼 타입 정보 (웹 타입 포함)
|
|
|
|
|
const [tableColumns, setTableColumns] = useState<any[]>([]);
|
|
|
|
|
|
|
|
|
|
// 파일 업로드 관련 상태
|
|
|
|
|
const [uploadingFiles, setUploadingFiles] = useState<Record<string, boolean>>({});
|
|
|
|
|
const [uploadedFiles, setUploadedFiles] = useState<Record<string, File[]>>({});
|
|
|
|
|
|
2025-09-03 15:23:12 +09:00
|
|
|
// 검색 가능한 컬럼만 필터링
|
|
|
|
|
const visibleColumns = component.columns?.filter((col: DataTableColumn) => col.visible) || [];
|
|
|
|
|
const searchFilters = component.filters || [];
|
|
|
|
|
|
2025-09-03 17:12:27 +09:00
|
|
|
// 컬럼의 실제 웹 타입 정보 찾기
|
|
|
|
|
const getColumnWebType = useCallback(
|
|
|
|
|
(columnName: string) => {
|
|
|
|
|
// 먼저 컴포넌트에 설정된 컬럼에서 찾기 (화면 관리에서 설정한 값 우선)
|
|
|
|
|
const componentColumn = component.columns?.find((col) => col.columnName === columnName);
|
|
|
|
|
if (componentColumn?.widgetType && componentColumn.widgetType !== "text") {
|
|
|
|
|
return componentColumn.widgetType;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 없으면 테이블 타입 관리에서 설정된 값 찾기
|
|
|
|
|
const tableColumn = tableColumns.find((col) => col.columnName === columnName);
|
|
|
|
|
return tableColumn?.webType || "text";
|
|
|
|
|
},
|
|
|
|
|
[component.columns, tableColumns],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 컬럼의 상세 설정 정보 찾기
|
|
|
|
|
const getColumnDetailSettings = useCallback(
|
|
|
|
|
(columnName: string) => {
|
|
|
|
|
// 먼저 컴포넌트에 설정된 컬럼에서 찾기 (화면 관리에서 설정한 값 우선)
|
|
|
|
|
const componentColumn = component.columns?.find((col) => col.columnName === columnName);
|
|
|
|
|
if (componentColumn?.webTypeConfig) {
|
|
|
|
|
return componentColumn.webTypeConfig;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 없으면 테이블 타입 관리에서 설정된 값 찾기
|
|
|
|
|
const tableColumn = tableColumns.find((col) => col.columnName === columnName);
|
|
|
|
|
try {
|
|
|
|
|
return tableColumn?.detailSettings ? JSON.parse(tableColumn.detailSettings) : {};
|
|
|
|
|
} catch {
|
|
|
|
|
console.warn("상세 설정 파싱 실패:", tableColumn?.detailSettings);
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[component.columns, tableColumns],
|
|
|
|
|
);
|
|
|
|
|
|
2025-09-03 15:23:12 +09:00
|
|
|
// 그리드 컬럼 계산
|
|
|
|
|
const totalGridColumns = visibleColumns.reduce((sum, col) => sum + (col.gridColumns || 2), 0);
|
|
|
|
|
|
|
|
|
|
// 페이지 크기 설정
|
|
|
|
|
const pageSize = component.pagination?.pageSize || 10;
|
|
|
|
|
|
|
|
|
|
// 데이터 로드 함수
|
|
|
|
|
const loadData = useCallback(
|
|
|
|
|
async (page: number = 1, searchParams: Record<string, any> = {}) => {
|
|
|
|
|
if (!component.tableName) return;
|
|
|
|
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const result = await tableTypeApi.getTableData(component.tableName, {
|
|
|
|
|
page,
|
|
|
|
|
size: pageSize,
|
|
|
|
|
search: searchParams,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
setData(result.data);
|
|
|
|
|
setTotal(result.total);
|
|
|
|
|
setTotalPages(result.totalPages);
|
|
|
|
|
setCurrentPage(result.page);
|
2025-09-06 00:16:27 +09:00
|
|
|
|
|
|
|
|
// 각 행의 파일 상태 확인 (전체 행 + 가상 파일 컬럼별)
|
|
|
|
|
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);
|
|
|
|
|
});
|
2025-09-03 15:23:12 +09:00
|
|
|
} catch (error) {
|
|
|
|
|
console.error("❌ 테이블 데이터 조회 실패:", error);
|
|
|
|
|
setData([]);
|
|
|
|
|
setTotal(0);
|
|
|
|
|
setTotalPages(1);
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[component.tableName, pageSize],
|
|
|
|
|
);
|
|
|
|
|
|
2025-09-03 16:38:10 +09:00
|
|
|
// 현재 사용자 정보 로드
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const fetchCurrentUser = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const response = await getCurrentUser();
|
|
|
|
|
if (response.success && response.data) {
|
|
|
|
|
setCurrentUser(response.data);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("현재 사용자 정보 로드 실패:", error);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
fetchCurrentUser();
|
|
|
|
|
}, []);
|
|
|
|
|
|
2025-09-03 17:12:27 +09:00
|
|
|
// 테이블 컬럼 정보 로드 (웹 타입 정보 포함)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const fetchTableColumns = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const columns = await tableTypeApi.getColumns(component.tableName);
|
|
|
|
|
setTableColumns(columns);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("테이블 컬럼 정보 로드 실패:", error);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (component.tableName) {
|
|
|
|
|
fetchTableColumns();
|
|
|
|
|
}
|
|
|
|
|
}, [component.tableName]);
|
|
|
|
|
|
2025-09-03 15:23:12 +09:00
|
|
|
// 초기 데이터 로드
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
loadData(1, searchValues);
|
|
|
|
|
}, [loadData]);
|
|
|
|
|
|
|
|
|
|
// 검색 실행
|
|
|
|
|
const handleSearch = useCallback(() => {
|
|
|
|
|
loadData(1, searchValues);
|
|
|
|
|
}, [searchValues, loadData]);
|
|
|
|
|
|
|
|
|
|
// 검색값 변경
|
|
|
|
|
const handleSearchValueChange = useCallback((columnName: string, value: any) => {
|
|
|
|
|
setSearchValues((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
[columnName]: value,
|
|
|
|
|
}));
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 페이지 변경
|
|
|
|
|
const handlePageChange = useCallback(
|
|
|
|
|
(page: number) => {
|
|
|
|
|
loadData(page, searchValues);
|
|
|
|
|
},
|
|
|
|
|
[loadData, searchValues],
|
|
|
|
|
);
|
|
|
|
|
|
2025-09-03 16:38:10 +09:00
|
|
|
// 행 선택 핸들러
|
|
|
|
|
const handleRowSelect = useCallback((rowIndex: number, isSelected: boolean) => {
|
|
|
|
|
setSelectedRows((prev) => {
|
|
|
|
|
const newSet = new Set(prev);
|
|
|
|
|
if (isSelected) {
|
|
|
|
|
newSet.add(rowIndex);
|
|
|
|
|
} else {
|
|
|
|
|
newSet.delete(rowIndex);
|
|
|
|
|
}
|
|
|
|
|
return newSet;
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 전체 선택/해제 핸들러
|
|
|
|
|
const handleSelectAll = useCallback(
|
|
|
|
|
(isSelected: boolean) => {
|
|
|
|
|
if (isSelected) {
|
|
|
|
|
setSelectedRows(new Set(Array.from({ length: data.length }, (_, i) => i)));
|
|
|
|
|
} else {
|
|
|
|
|
setSelectedRows(new Set());
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[data.length],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 모달에 표시할 컬럼 계산
|
|
|
|
|
const getDisplayColumns = useCallback(() => {
|
|
|
|
|
const { hiddenFields, fieldOrder, advancedFieldConfigs } = component.addModalConfig || {};
|
|
|
|
|
|
|
|
|
|
// 숨겨진 필드와 고급 설정에서 숨겨진 필드 제외
|
|
|
|
|
let displayColumns = visibleColumns.filter((col) => {
|
|
|
|
|
// 기본 숨김 필드 체크
|
|
|
|
|
if (hiddenFields?.includes(col.columnName)) return false;
|
|
|
|
|
|
|
|
|
|
// 고급 설정에서 숨김 체크
|
|
|
|
|
const config = advancedFieldConfigs?.[col.columnName];
|
|
|
|
|
if (config?.inputType === "hidden") return false;
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 필드 순서 적용
|
|
|
|
|
if (fieldOrder && fieldOrder.length > 0) {
|
|
|
|
|
const orderedColumns: typeof displayColumns = [];
|
|
|
|
|
const remainingColumns = [...displayColumns];
|
|
|
|
|
|
|
|
|
|
// 지정된 순서대로 추가
|
|
|
|
|
fieldOrder.forEach((columnName) => {
|
|
|
|
|
const column = remainingColumns.find((col) => col.columnName === columnName);
|
|
|
|
|
if (column) {
|
|
|
|
|
orderedColumns.push(column);
|
|
|
|
|
const index = remainingColumns.indexOf(column);
|
|
|
|
|
remainingColumns.splice(index, 1);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 나머지 컬럼들 추가
|
|
|
|
|
orderedColumns.push(...remainingColumns);
|
|
|
|
|
displayColumns = orderedColumns;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return displayColumns;
|
|
|
|
|
}, [visibleColumns, component.addModalConfig]);
|
|
|
|
|
|
|
|
|
|
// 자동 값 생성
|
|
|
|
|
const generateAutoValue = useCallback(
|
|
|
|
|
(autoValueType: string): string => {
|
|
|
|
|
const now = new Date();
|
|
|
|
|
switch (autoValueType) {
|
|
|
|
|
case "current_datetime":
|
|
|
|
|
return now.toISOString().slice(0, 19); // YYYY-MM-DDTHH:mm:ss
|
|
|
|
|
case "current_date":
|
|
|
|
|
return now.toISOString().slice(0, 10); // YYYY-MM-DD
|
|
|
|
|
case "current_time":
|
|
|
|
|
return now.toTimeString().slice(0, 8); // HH:mm:ss
|
|
|
|
|
case "current_user":
|
|
|
|
|
return currentUser?.userName || currentUser?.userId || "unknown_user";
|
|
|
|
|
case "uuid":
|
|
|
|
|
return crypto.randomUUID();
|
|
|
|
|
case "sequence":
|
|
|
|
|
return `SEQ_${Date.now()}`;
|
|
|
|
|
default:
|
|
|
|
|
return "";
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[currentUser],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 데이터 추가 핸들러
|
|
|
|
|
const handleAddData = useCallback(() => {
|
|
|
|
|
// 폼 데이터 초기화
|
|
|
|
|
const initialData: Record<string, any> = {};
|
|
|
|
|
const displayColumns = getDisplayColumns();
|
|
|
|
|
const advancedConfigs = component.addModalConfig?.advancedFieldConfigs || {};
|
|
|
|
|
|
|
|
|
|
displayColumns.forEach((col) => {
|
|
|
|
|
const config = advancedConfigs[col.columnName];
|
|
|
|
|
|
|
|
|
|
if (config?.inputType === "auto") {
|
|
|
|
|
// 자동 값 설정
|
|
|
|
|
if (config.autoValueType === "custom") {
|
|
|
|
|
initialData[col.columnName] = config.customValue || "";
|
|
|
|
|
} else {
|
|
|
|
|
initialData[col.columnName] = generateAutoValue(config.autoValueType);
|
|
|
|
|
}
|
|
|
|
|
} else if (config?.defaultValue) {
|
|
|
|
|
// 기본값 설정
|
|
|
|
|
initialData[col.columnName] = config.defaultValue;
|
|
|
|
|
} else {
|
|
|
|
|
// 일반 빈 값
|
|
|
|
|
initialData[col.columnName] = "";
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
setAddFormData(initialData);
|
|
|
|
|
setShowAddModal(true);
|
|
|
|
|
}, [getDisplayColumns, generateAutoValue, component.addModalConfig]);
|
|
|
|
|
|
|
|
|
|
// 추가 폼 데이터 변경 핸들러
|
|
|
|
|
const handleAddFormChange = useCallback((columnName: string, value: any) => {
|
|
|
|
|
setAddFormData((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
[columnName]: value,
|
|
|
|
|
}));
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 데이터 수정 핸들러
|
|
|
|
|
const handleEditData = useCallback(() => {
|
|
|
|
|
if (selectedRows.size !== 1) return;
|
|
|
|
|
|
|
|
|
|
const selectedIndex = Array.from(selectedRows)[0];
|
|
|
|
|
const selectedRowData = data[selectedIndex];
|
|
|
|
|
|
|
|
|
|
if (!selectedRowData) return;
|
|
|
|
|
|
|
|
|
|
// 수정할 데이터로 폼 초기화
|
|
|
|
|
const initialData: Record<string, any> = {};
|
|
|
|
|
const displayColumns = getDisplayColumns();
|
|
|
|
|
|
|
|
|
|
displayColumns.forEach((col) => {
|
|
|
|
|
initialData[col.columnName] = selectedRowData[col.columnName] || "";
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
setEditFormData(initialData);
|
|
|
|
|
setEditingRowData(selectedRowData);
|
|
|
|
|
setShowEditModal(true);
|
|
|
|
|
}, [selectedRows, data, getDisplayColumns]);
|
|
|
|
|
|
|
|
|
|
// 수정 폼 데이터 변경 핸들러
|
|
|
|
|
const handleEditFormChange = useCallback((columnName: string, value: any) => {
|
|
|
|
|
setEditFormData((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
[columnName]: value,
|
|
|
|
|
}));
|
|
|
|
|
}, []);
|
|
|
|
|
|
2025-09-03 17:12:27 +09:00
|
|
|
// 파일 업로드 핸들러
|
|
|
|
|
const handleFileUpload = useCallback(
|
|
|
|
|
async (columnName: string, files: FileList | null, isEdit: boolean = false) => {
|
|
|
|
|
if (!files || files.length === 0) return;
|
|
|
|
|
|
|
|
|
|
const detailSettings = getColumnDetailSettings(columnName);
|
|
|
|
|
const maxSize = detailSettings?.maxSize || 10 * 1024 * 1024; // 기본 10MB
|
|
|
|
|
const acceptedTypes = detailSettings?.accept
|
|
|
|
|
? detailSettings.accept.split(",").map((type: string) => type.trim())
|
|
|
|
|
: [];
|
|
|
|
|
const multiple = detailSettings?.multiple || false;
|
|
|
|
|
|
|
|
|
|
// 파일 검증
|
|
|
|
|
const validFiles: File[] = [];
|
|
|
|
|
for (let i = 0; i < files.length; i++) {
|
|
|
|
|
const file = files[i];
|
|
|
|
|
|
|
|
|
|
// 크기 체크
|
|
|
|
|
if (file.size > maxSize) {
|
|
|
|
|
alert(`파일 크기가 너무 큽니다. 최대 ${Math.round(maxSize / 1024 / 1024)}MB까지 가능합니다.`);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 타입 체크
|
|
|
|
|
if (
|
|
|
|
|
acceptedTypes.length > 0 &&
|
|
|
|
|
!acceptedTypes.some((type: string) => {
|
|
|
|
|
if (type.startsWith(".")) {
|
|
|
|
|
return file.name.toLowerCase().endsWith(type.toLowerCase());
|
|
|
|
|
} else {
|
|
|
|
|
return file.type.includes(type);
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
) {
|
|
|
|
|
alert(`지원하지 않는 파일 형식입니다. (${acceptedTypes.join(", ")})`);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
validFiles.push(file);
|
|
|
|
|
if (!multiple) break; // 단일 파일만 허용
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (validFiles.length === 0) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
setUploadingFiles((prev) => ({ ...prev, [columnName]: true }));
|
|
|
|
|
|
|
|
|
|
// TODO: 실제 파일 업로드 API 호출
|
|
|
|
|
// const uploadPromises = validFiles.map(file => uploadFileToServer(file));
|
|
|
|
|
// const uploadResults = await Promise.all(uploadPromises);
|
|
|
|
|
|
|
|
|
|
// 임시: 파일 정보를 로컬 상태에 저장
|
|
|
|
|
setUploadedFiles((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
[columnName]: multiple ? [...(prev[columnName] || []), ...validFiles] : validFiles,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// 폼 데이터 업데이트
|
|
|
|
|
const fileNames = validFiles.map((file) => file.name).join(", ");
|
|
|
|
|
if (isEdit) {
|
|
|
|
|
handleEditFormChange(columnName, fileNames);
|
|
|
|
|
} else {
|
|
|
|
|
handleAddFormChange(columnName, fileNames);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("파일 업로드 실패:", error);
|
|
|
|
|
alert("파일 업로드에 실패했습니다.");
|
|
|
|
|
} finally {
|
|
|
|
|
setUploadingFiles((prev) => ({ ...prev, [columnName]: false }));
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[getColumnDetailSettings, handleAddFormChange, handleEditFormChange],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 파일 제거 핸들러
|
|
|
|
|
const handleFileRemove = useCallback(
|
|
|
|
|
(columnName: string, fileIndex: number, isEdit: boolean = false) => {
|
|
|
|
|
setUploadedFiles((prev) => {
|
|
|
|
|
const currentFiles = prev[columnName] || [];
|
|
|
|
|
const newFiles = currentFiles.filter((_, index) => index !== fileIndex);
|
|
|
|
|
|
|
|
|
|
// 폼 데이터 업데이트
|
|
|
|
|
const fileNames = newFiles.map((file) => file.name).join(", ");
|
|
|
|
|
if (isEdit) {
|
|
|
|
|
handleEditFormChange(columnName, fileNames);
|
|
|
|
|
} else {
|
|
|
|
|
handleAddFormChange(columnName, fileNames);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { ...prev, [columnName]: newFiles };
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
[handleAddFormChange, handleEditFormChange],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 파일 목록 렌더링 컴포넌트
|
|
|
|
|
const renderFileList = useCallback(
|
|
|
|
|
(columnName: string, isEdit: boolean = false) => {
|
|
|
|
|
const currentFiles = uploadedFiles[columnName] || [];
|
|
|
|
|
const isUploading = uploadingFiles[columnName];
|
|
|
|
|
|
|
|
|
|
if (currentFiles.length === 0 && !isUploading) return null;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="mt-2 space-y-2">
|
|
|
|
|
{currentFiles.map((file, index) => (
|
|
|
|
|
<div key={index} className="flex items-center justify-between rounded border bg-gray-50 p-2">
|
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
<div className="text-xs text-gray-600">📄</div>
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm font-medium">{file.name}</p>
|
|
|
|
|
<p className="text-xs text-gray-500">{(file.size / 1024).toFixed(1)} KB</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => handleFileRemove(columnName, index, isEdit)}
|
|
|
|
|
className="h-6 w-6 p-0"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-3 w-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
{isUploading && (
|
|
|
|
|
<div className="flex items-center space-x-2 rounded border bg-blue-50 p-2">
|
|
|
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
|
|
|
<span className="text-sm text-blue-600">업로드 중...</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
[uploadedFiles, uploadingFiles, handleFileRemove],
|
|
|
|
|
);
|
|
|
|
|
|
2025-09-03 16:38:10 +09:00
|
|
|
// 데이터 추가 제출 핸들러
|
|
|
|
|
const handleAddSubmit = useCallback(async () => {
|
|
|
|
|
try {
|
|
|
|
|
setIsAdding(true);
|
|
|
|
|
|
|
|
|
|
// 실제 API 호출로 데이터 추가
|
|
|
|
|
console.log("🔥 추가할 데이터:", addFormData);
|
|
|
|
|
await tableTypeApi.addTableData(component.tableName, addFormData);
|
|
|
|
|
|
|
|
|
|
// 모달 닫기 및 폼 초기화
|
|
|
|
|
setShowAddModal(false);
|
|
|
|
|
setAddFormData({});
|
|
|
|
|
|
|
|
|
|
// 첫 페이지로 이동하여 새 데이터 확인
|
|
|
|
|
loadData(1, searchValues);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("데이터 추가 실패:", error);
|
|
|
|
|
alert("데이터 추가에 실패했습니다.");
|
|
|
|
|
} finally {
|
|
|
|
|
setIsAdding(false);
|
|
|
|
|
}
|
|
|
|
|
}, [addFormData, loadData, searchValues]);
|
|
|
|
|
|
|
|
|
|
// 데이터 수정 제출 핸들러
|
|
|
|
|
const handleEditSubmit = useCallback(async () => {
|
|
|
|
|
try {
|
|
|
|
|
setIsEditing(true);
|
|
|
|
|
|
|
|
|
|
// 실제 API 호출로 데이터 수정
|
|
|
|
|
console.log("🔥 수정할 데이터:", editFormData);
|
|
|
|
|
console.log("🔥 원본 데이터:", editingRowData);
|
|
|
|
|
|
|
|
|
|
if (editingRowData) {
|
|
|
|
|
await tableTypeApi.editTableData(component.tableName, editingRowData, editFormData);
|
|
|
|
|
|
|
|
|
|
// 모달 닫기 및 폼 초기화
|
|
|
|
|
setShowEditModal(false);
|
|
|
|
|
setEditFormData({});
|
|
|
|
|
setEditingRowData(null);
|
|
|
|
|
setSelectedRows(new Set()); // 선택 해제
|
|
|
|
|
|
|
|
|
|
// 현재 페이지 데이터 새로고침
|
|
|
|
|
loadData(currentPage, searchValues);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("데이터 수정 실패:", error);
|
|
|
|
|
alert("데이터 수정에 실패했습니다.");
|
|
|
|
|
} finally {
|
|
|
|
|
setIsEditing(false);
|
|
|
|
|
}
|
|
|
|
|
}, [editFormData, editingRowData, component.tableName, currentPage, searchValues, loadData]);
|
|
|
|
|
|
|
|
|
|
// 추가 모달 닫기 핸들러
|
|
|
|
|
const handleAddModalClose = useCallback(() => {
|
|
|
|
|
if (!isAdding) {
|
|
|
|
|
setShowAddModal(false);
|
|
|
|
|
setAddFormData({});
|
2025-09-03 17:12:27 +09:00
|
|
|
setUploadedFiles({}); // 파일 상태 초기화
|
2025-09-03 16:38:10 +09:00
|
|
|
}
|
|
|
|
|
}, [isAdding]);
|
|
|
|
|
|
|
|
|
|
// 데이터 삭제 핸들러
|
|
|
|
|
const handleDeleteData = useCallback(() => {
|
|
|
|
|
if (selectedRows.size === 0) {
|
|
|
|
|
alert("삭제할 데이터를 선택해주세요.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setShowDeleteDialog(true);
|
|
|
|
|
}, [selectedRows.size]);
|
|
|
|
|
|
|
|
|
|
// 삭제 확인 핸들러
|
|
|
|
|
const handleDeleteConfirm = useCallback(async () => {
|
|
|
|
|
try {
|
|
|
|
|
setIsDeleting(true);
|
|
|
|
|
|
|
|
|
|
// 선택된 행의 실제 데이터 가져오기
|
|
|
|
|
const selectedData = Array.from(selectedRows).map((index) => data[index]);
|
|
|
|
|
|
|
|
|
|
// 실제 삭제 API 호출
|
|
|
|
|
console.log("🗑️ 삭제할 데이터:", selectedData);
|
|
|
|
|
await tableTypeApi.deleteTableData(component.tableName, selectedData);
|
|
|
|
|
|
|
|
|
|
// 선택 해제 및 다이얼로그 닫기
|
|
|
|
|
setSelectedRows(new Set());
|
|
|
|
|
setShowDeleteDialog(false);
|
|
|
|
|
|
|
|
|
|
// 데이터 새로고침
|
|
|
|
|
loadData(currentPage, searchValues);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("데이터 삭제 실패:", error);
|
|
|
|
|
alert("데이터 삭제에 실패했습니다.");
|
|
|
|
|
} finally {
|
|
|
|
|
setIsDeleting(false);
|
|
|
|
|
}
|
|
|
|
|
}, [selectedRows, data, currentPage, searchValues, loadData]);
|
|
|
|
|
|
|
|
|
|
// 삭제 다이얼로그 닫기 핸들러
|
|
|
|
|
const handleDeleteDialogClose = useCallback(() => {
|
|
|
|
|
if (!isDeleting) {
|
|
|
|
|
setShowDeleteDialog(false);
|
|
|
|
|
}
|
|
|
|
|
}, [isDeleting]);
|
|
|
|
|
|
|
|
|
|
// 필수 필드 여부 확인
|
|
|
|
|
const isRequiredField = useCallback(
|
|
|
|
|
(columnName: string) => {
|
|
|
|
|
return component.addModalConfig?.requiredFields?.includes(columnName) || false;
|
|
|
|
|
},
|
|
|
|
|
[component.addModalConfig],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 모달 크기 클래스 가져오기
|
|
|
|
|
const getModalSizeClass = useCallback(() => {
|
|
|
|
|
const width = component.addModalConfig?.width || "lg";
|
|
|
|
|
const sizeMap = {
|
|
|
|
|
sm: "max-w-sm",
|
|
|
|
|
md: "max-w-md",
|
|
|
|
|
lg: "max-w-lg",
|
|
|
|
|
xl: "max-w-xl",
|
|
|
|
|
"2xl": "max-w-2xl",
|
|
|
|
|
full: "max-w-full mx-4",
|
|
|
|
|
};
|
|
|
|
|
return sizeMap[width];
|
|
|
|
|
}, [component.addModalConfig]);
|
|
|
|
|
|
|
|
|
|
// 레이아웃 클래스 가져오기
|
|
|
|
|
const getLayoutClass = useCallback(() => {
|
|
|
|
|
const layout = component.addModalConfig?.layout || "two-column";
|
|
|
|
|
const gridColumns = component.addModalConfig?.gridColumns || 2;
|
|
|
|
|
|
|
|
|
|
switch (layout) {
|
|
|
|
|
case "single":
|
|
|
|
|
return "grid grid-cols-1 gap-4";
|
|
|
|
|
case "two-column":
|
|
|
|
|
return "grid grid-cols-2 gap-4";
|
|
|
|
|
case "grid":
|
|
|
|
|
return `grid grid-cols-${Math.min(gridColumns, 4)} gap-4`;
|
|
|
|
|
default:
|
|
|
|
|
return "grid grid-cols-2 gap-4";
|
|
|
|
|
}
|
|
|
|
|
}, [component.addModalConfig]);
|
|
|
|
|
|
|
|
|
|
// 수정 폼 입력 컴포넌트 렌더링
|
|
|
|
|
const renderEditFormInput = (column: DataTableColumn) => {
|
|
|
|
|
const value = editFormData[column.columnName] || "";
|
|
|
|
|
const isRequired = isRequiredField(column.columnName);
|
|
|
|
|
const advancedConfig = component.addModalConfig?.advancedFieldConfigs?.[column.columnName];
|
|
|
|
|
|
2025-09-03 17:12:27 +09:00
|
|
|
// 데이터베이스에서 실제 웹 타입 가져오기
|
|
|
|
|
const actualWebType = getColumnWebType(column.columnName);
|
|
|
|
|
const detailSettings = getColumnDetailSettings(column.columnName);
|
|
|
|
|
|
2025-09-03 16:38:10 +09:00
|
|
|
// 자동 생성 필드는 수정에서 읽기 전용으로 처리
|
|
|
|
|
if (advancedConfig?.inputType === "auto") {
|
|
|
|
|
return (
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<Input
|
|
|
|
|
value={value}
|
|
|
|
|
readOnly
|
|
|
|
|
className="bg-gray-50 text-gray-700"
|
|
|
|
|
placeholder={`${column.label} (자동 생성됨)`}
|
|
|
|
|
/>
|
|
|
|
|
<p className="mt-1 text-xs text-gray-500">자동 생성된 필드는 수정할 수 없습니다.</p>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 읽기 전용 필드
|
|
|
|
|
if (advancedConfig?.inputType === "readonly") {
|
|
|
|
|
return (
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<Input
|
|
|
|
|
value={value}
|
|
|
|
|
readOnly
|
|
|
|
|
className="bg-gray-50 text-gray-700"
|
|
|
|
|
placeholder={advancedConfig?.placeholder || `${column.label} (읽기 전용)`}
|
|
|
|
|
/>
|
|
|
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 일반 입력 필드 렌더링
|
|
|
|
|
const commonProps = {
|
|
|
|
|
value,
|
|
|
|
|
onChange: (e: React.ChangeEvent<HTMLInputElement>) => handleEditFormChange(column.columnName, e.target.value),
|
|
|
|
|
placeholder: advancedConfig?.placeholder || `${column.label} 입력...`,
|
|
|
|
|
required: isRequired,
|
|
|
|
|
className: isRequired && !value ? "border-orange-300 focus:border-orange-500" : "",
|
|
|
|
|
};
|
|
|
|
|
|
2025-09-03 17:12:27 +09:00
|
|
|
// 실제 웹 타입에 따라 입력 컴포넌트 렌더링
|
|
|
|
|
switch (actualWebType) {
|
2025-09-03 16:38:10 +09:00
|
|
|
case "text":
|
|
|
|
|
case "email":
|
|
|
|
|
case "tel":
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
<Input
|
2025-09-03 17:12:27 +09:00
|
|
|
type={actualWebType === "email" ? "email" : actualWebType === "tel" ? "tel" : "text"}
|
2025-09-03 16:38:10 +09:00
|
|
|
{...commonProps}
|
2025-09-03 17:12:27 +09:00
|
|
|
maxLength={detailSettings?.maxLength}
|
2025-09-03 16:38:10 +09:00
|
|
|
/>
|
|
|
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "number":
|
|
|
|
|
case "decimal":
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
2025-09-03 17:12:27 +09:00
|
|
|
<Input
|
|
|
|
|
type="number"
|
|
|
|
|
step={actualWebType === "decimal" ? detailSettings?.step || "0.01" : "1"}
|
|
|
|
|
min={detailSettings?.min}
|
|
|
|
|
max={detailSettings?.max}
|
|
|
|
|
{...commonProps}
|
|
|
|
|
/>
|
2025-09-03 16:38:10 +09:00
|
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "date":
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
2025-09-03 17:12:27 +09:00
|
|
|
<Input type="date" min={detailSettings?.minDate} max={detailSettings?.maxDate} {...commonProps} />
|
2025-09-03 16:38:10 +09:00
|
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "datetime":
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
2025-09-03 17:12:27 +09:00
|
|
|
<Input type="datetime-local" min={detailSettings?.minDate} max={detailSettings?.maxDate} {...commonProps} />
|
2025-09-03 16:38:10 +09:00
|
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "select":
|
|
|
|
|
case "dropdown":
|
2025-09-03 17:12:27 +09:00
|
|
|
// 상세 설정에서 옵션 목록 가져오기
|
|
|
|
|
const options = detailSettings?.options || [];
|
|
|
|
|
if (options.length > 0) {
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
<Select value={value} onValueChange={(newValue) => handleEditFormChange(column.columnName, newValue)}>
|
|
|
|
|
<SelectTrigger className={commonProps.className}>
|
|
|
|
|
<SelectValue placeholder={commonProps.placeholder} />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{options.map((option: any, index: number) => (
|
|
|
|
|
<SelectItem key={index} value={option.value || option}>
|
|
|
|
|
{option.label || option}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
return <Input {...commonProps} placeholder={`${column.label} 선택... (옵션 설정 필요)`} readOnly />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "radio":
|
|
|
|
|
// 상세 설정에서 옵션 목록 가져오기
|
|
|
|
|
const radioOptions = detailSettings?.options || [];
|
|
|
|
|
if (radioOptions.length > 0) {
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{radioOptions.map((option: any, index: number) => (
|
|
|
|
|
<div key={index} className="flex items-center space-x-2">
|
|
|
|
|
<input
|
|
|
|
|
type="radio"
|
|
|
|
|
id={`${column.columnName}-edit-${index}`}
|
|
|
|
|
name={`${column.columnName}-edit`}
|
|
|
|
|
value={option.value || option}
|
|
|
|
|
checked={value === (option.value || option)}
|
|
|
|
|
onChange={(e) => handleEditFormChange(column.columnName, e.target.value)}
|
|
|
|
|
className="text-primary focus:ring-primary"
|
|
|
|
|
/>
|
|
|
|
|
<Label htmlFor={`${column.columnName}-edit-${index}`} className="text-sm">
|
|
|
|
|
{option.label || option}
|
|
|
|
|
</Label>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
return <Input {...commonProps} placeholder={`${column.label} 선택... (옵션 설정 필요)`} readOnly />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "textarea":
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
<textarea
|
|
|
|
|
value={value}
|
|
|
|
|
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
|
|
|
|
handleEditFormChange(column.columnName, e.target.value)
|
|
|
|
|
}
|
|
|
|
|
placeholder={advancedConfig?.placeholder || `${column.label} 입력...`}
|
|
|
|
|
required={isRequired}
|
|
|
|
|
className={`border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 ${
|
|
|
|
|
isRequired && !value ? "border-orange-300 focus:border-orange-500" : ""
|
|
|
|
|
}`}
|
|
|
|
|
rows={detailSettings?.rows || 3}
|
|
|
|
|
maxLength={detailSettings?.maxLength}
|
|
|
|
|
/>
|
|
|
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "boolean":
|
|
|
|
|
case "checkbox":
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
<Checkbox
|
|
|
|
|
checked={value === true || value === "true" || value === 1}
|
|
|
|
|
onCheckedChange={(checked) => handleEditFormChange(column.columnName, checked)}
|
|
|
|
|
/>
|
|
|
|
|
<Label>{column.label}</Label>
|
|
|
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "file":
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
<Input
|
|
|
|
|
type="file"
|
|
|
|
|
accept={detailSettings?.accept}
|
|
|
|
|
multiple={detailSettings?.multiple}
|
|
|
|
|
onChange={(e) => handleFileUpload(column.columnName, e.target.files, true)}
|
|
|
|
|
className="hidden"
|
|
|
|
|
id={`file-edit-${column.columnName}`}
|
|
|
|
|
/>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => document.getElementById(`file-edit-${column.columnName}`)?.click()}
|
|
|
|
|
disabled={uploadingFiles[column.columnName]}
|
|
|
|
|
className="gap-2"
|
|
|
|
|
>
|
|
|
|
|
{uploadingFiles[column.columnName] ? (
|
|
|
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
|
|
|
) : (
|
|
|
|
|
<Plus className="h-4 w-4" />
|
|
|
|
|
)}
|
|
|
|
|
파일 선택
|
|
|
|
|
</Button>
|
|
|
|
|
{detailSettings?.accept && <span className="text-xs text-gray-500">({detailSettings.accept})</span>}
|
|
|
|
|
</div>
|
|
|
|
|
{renderFileList(column.columnName, true)}
|
|
|
|
|
</div>
|
|
|
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
2025-09-03 16:38:10 +09:00
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
<Input {...commonProps} />
|
|
|
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 추가 폼 입력 컴포넌트 렌더링
|
|
|
|
|
const renderAddFormInput = (column: DataTableColumn) => {
|
|
|
|
|
const value = addFormData[column.columnName] || "";
|
|
|
|
|
const isRequired = isRequiredField(column.columnName);
|
|
|
|
|
const advancedConfig = component.addModalConfig?.advancedFieldConfigs?.[column.columnName];
|
|
|
|
|
|
2025-09-03 17:12:27 +09:00
|
|
|
// 데이터베이스에서 실제 웹 타입 가져오기
|
|
|
|
|
const actualWebType = getColumnWebType(column.columnName);
|
|
|
|
|
const detailSettings = getColumnDetailSettings(column.columnName);
|
|
|
|
|
|
2025-09-03 16:38:10 +09:00
|
|
|
// 읽기 전용 또는 자동 값인 경우
|
|
|
|
|
if (advancedConfig?.inputType === "readonly" || advancedConfig?.inputType === "auto") {
|
|
|
|
|
return (
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<Input
|
|
|
|
|
value={value}
|
|
|
|
|
readOnly
|
|
|
|
|
className="bg-gray-50 text-gray-700"
|
|
|
|
|
placeholder={advancedConfig?.placeholder || `${column.label} (자동 생성)`}
|
|
|
|
|
/>
|
|
|
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 일반 입력 필드 렌더링
|
|
|
|
|
const commonProps = {
|
|
|
|
|
value,
|
|
|
|
|
onChange: (e: React.ChangeEvent<HTMLInputElement>) => handleAddFormChange(column.columnName, e.target.value),
|
|
|
|
|
placeholder: advancedConfig?.placeholder || `${column.label} 입력...`,
|
|
|
|
|
required: isRequired,
|
|
|
|
|
className: isRequired && !value ? "border-orange-300 focus:border-orange-500" : "",
|
|
|
|
|
};
|
|
|
|
|
|
2025-09-03 17:12:27 +09:00
|
|
|
// 실제 웹 타입에 따라 입력 컴포넌트 렌더링
|
|
|
|
|
switch (actualWebType) {
|
2025-09-03 16:38:10 +09:00
|
|
|
case "text":
|
|
|
|
|
case "email":
|
|
|
|
|
case "tel":
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
<Input
|
2025-09-03 17:12:27 +09:00
|
|
|
type={actualWebType === "email" ? "email" : actualWebType === "tel" ? "tel" : "text"}
|
2025-09-03 16:38:10 +09:00
|
|
|
{...commonProps}
|
2025-09-03 17:12:27 +09:00
|
|
|
maxLength={detailSettings?.maxLength}
|
2025-09-03 16:38:10 +09:00
|
|
|
/>
|
|
|
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "number":
|
|
|
|
|
case "decimal":
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
2025-09-03 17:12:27 +09:00
|
|
|
<Input
|
|
|
|
|
type="number"
|
|
|
|
|
step={actualWebType === "decimal" ? detailSettings?.step || "0.01" : "1"}
|
|
|
|
|
min={detailSettings?.min}
|
|
|
|
|
max={detailSettings?.max}
|
|
|
|
|
{...commonProps}
|
|
|
|
|
/>
|
2025-09-03 16:38:10 +09:00
|
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "date":
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
2025-09-03 17:12:27 +09:00
|
|
|
<Input type="date" min={detailSettings?.minDate} max={detailSettings?.maxDate} {...commonProps} />
|
2025-09-03 16:38:10 +09:00
|
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "datetime":
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
2025-09-03 17:12:27 +09:00
|
|
|
<Input type="datetime-local" min={detailSettings?.minDate} max={detailSettings?.maxDate} {...commonProps} />
|
2025-09-03 16:38:10 +09:00
|
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "select":
|
|
|
|
|
case "dropdown":
|
2025-09-03 17:12:27 +09:00
|
|
|
// 상세 설정에서 옵션 목록 가져오기
|
|
|
|
|
const options = detailSettings?.options || [];
|
|
|
|
|
if (options.length > 0) {
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
<Select value={value} onValueChange={(newValue) => handleAddFormChange(column.columnName, newValue)}>
|
|
|
|
|
<SelectTrigger className={commonProps.className}>
|
|
|
|
|
<SelectValue placeholder={commonProps.placeholder} />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{options.map((option: any, index: number) => (
|
|
|
|
|
<SelectItem key={index} value={option.value || option}>
|
|
|
|
|
{option.label || option}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
return <Input {...commonProps} placeholder={`${column.label} 선택... (옵션 설정 필요)`} readOnly />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "radio":
|
|
|
|
|
// 상세 설정에서 옵션 목록 가져오기
|
|
|
|
|
const radioOptions = detailSettings?.options || [];
|
|
|
|
|
const defaultValue = detailSettings?.defaultValue;
|
|
|
|
|
|
|
|
|
|
// 추가 모달에서는 기본값이 있으면 초기값으로 설정
|
|
|
|
|
if (radioOptions.length > 0) {
|
|
|
|
|
// 폼 데이터에 값이 없고 기본값이 있으면 기본값 설정
|
|
|
|
|
if (!value && defaultValue) {
|
|
|
|
|
setTimeout(() => handleAddFormChange(column.columnName, defaultValue), 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{radioOptions.map((option: any, index: number) => (
|
|
|
|
|
<div key={index} className="flex items-center space-x-2">
|
|
|
|
|
<input
|
|
|
|
|
type="radio"
|
|
|
|
|
id={`${column.columnName}-add-${index}`}
|
|
|
|
|
name={`${column.columnName}-add`}
|
|
|
|
|
value={option.value || option}
|
|
|
|
|
checked={value === (option.value || option)}
|
|
|
|
|
onChange={(e) => handleAddFormChange(column.columnName, e.target.value)}
|
|
|
|
|
className="text-primary focus:ring-primary"
|
|
|
|
|
/>
|
|
|
|
|
<Label htmlFor={`${column.columnName}-add-${index}`} className="text-sm">
|
|
|
|
|
{option.label || option}
|
|
|
|
|
</Label>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
return <Input {...commonProps} placeholder={`${column.label} 선택... (옵션 설정 필요)`} readOnly />;
|
|
|
|
|
}
|
2025-09-03 16:38:10 +09:00
|
|
|
|
|
|
|
|
case "boolean":
|
|
|
|
|
case "checkbox":
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
<Checkbox
|
2025-09-03 17:12:27 +09:00
|
|
|
checked={value === true || value === "true" || value === 1}
|
2025-09-03 16:38:10 +09:00
|
|
|
onCheckedChange={(checked) => handleAddFormChange(column.columnName, checked)}
|
|
|
|
|
/>
|
|
|
|
|
<Label>{column.label}</Label>
|
2025-09-03 17:12:27 +09:00
|
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "textarea":
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
<textarea
|
|
|
|
|
value={value}
|
|
|
|
|
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
|
|
|
|
handleAddFormChange(column.columnName, e.target.value)
|
|
|
|
|
}
|
|
|
|
|
placeholder={advancedConfig?.placeholder || `${column.label} 입력...`}
|
|
|
|
|
required={isRequired}
|
|
|
|
|
className={`border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 ${
|
|
|
|
|
isRequired && !value ? "border-orange-300 focus:border-orange-500" : ""
|
|
|
|
|
}`}
|
|
|
|
|
rows={detailSettings?.rows || 3}
|
|
|
|
|
maxLength={detailSettings?.maxLength}
|
|
|
|
|
/>
|
|
|
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "file":
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
<Input
|
|
|
|
|
type="file"
|
|
|
|
|
accept={detailSettings?.accept}
|
|
|
|
|
multiple={detailSettings?.multiple}
|
|
|
|
|
onChange={(e) => handleFileUpload(column.columnName, e.target.files, false)}
|
|
|
|
|
className="hidden"
|
|
|
|
|
id={`file-add-${column.columnName}`}
|
|
|
|
|
/>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => document.getElementById(`file-add-${column.columnName}`)?.click()}
|
|
|
|
|
disabled={uploadingFiles[column.columnName]}
|
|
|
|
|
className="gap-2"
|
|
|
|
|
>
|
|
|
|
|
{uploadingFiles[column.columnName] ? (
|
|
|
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
|
|
|
) : (
|
|
|
|
|
<Plus className="h-4 w-4" />
|
|
|
|
|
)}
|
|
|
|
|
파일 선택
|
|
|
|
|
</Button>
|
|
|
|
|
{detailSettings?.accept && <span className="text-xs text-gray-500">({detailSettings.accept})</span>}
|
|
|
|
|
</div>
|
|
|
|
|
{renderFileList(column.columnName, false)}
|
|
|
|
|
</div>
|
|
|
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
2025-09-03 16:38:10 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
return (
|
2025-09-03 17:12:27 +09:00
|
|
|
<div>
|
|
|
|
|
<Input {...commonProps} />
|
|
|
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
|
|
|
|
</div>
|
2025-09-03 16:38:10 +09:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-09-03 15:23:12 +09:00
|
|
|
// 검색 필터 렌더링
|
|
|
|
|
const renderSearchFilter = (filter: DataTableFilter) => {
|
|
|
|
|
const value = searchValues[filter.columnName] || "";
|
|
|
|
|
|
|
|
|
|
switch (filter.widgetType) {
|
|
|
|
|
case "text":
|
|
|
|
|
case "email":
|
|
|
|
|
case "tel":
|
|
|
|
|
return (
|
|
|
|
|
<Input
|
|
|
|
|
key={filter.columnName}
|
|
|
|
|
placeholder={`${filter.label} 검색...`}
|
|
|
|
|
value={value}
|
|
|
|
|
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
|
|
|
|
|
onKeyPress={(e) => {
|
|
|
|
|
if (e.key === "Enter") {
|
|
|
|
|
handleSearch();
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "number":
|
|
|
|
|
case "decimal":
|
|
|
|
|
return (
|
|
|
|
|
<Input
|
|
|
|
|
key={filter.columnName}
|
|
|
|
|
type="number"
|
|
|
|
|
placeholder={`${filter.label} 입력...`}
|
|
|
|
|
value={value}
|
|
|
|
|
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
|
|
|
|
|
onKeyPress={(e) => {
|
|
|
|
|
if (e.key === "Enter") {
|
|
|
|
|
handleSearch();
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "date":
|
|
|
|
|
return (
|
|
|
|
|
<Input
|
|
|
|
|
key={filter.columnName}
|
|
|
|
|
type="date"
|
|
|
|
|
value={value}
|
|
|
|
|
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "datetime":
|
|
|
|
|
return (
|
|
|
|
|
<Input
|
|
|
|
|
key={filter.columnName}
|
|
|
|
|
type="datetime-local"
|
|
|
|
|
value={value}
|
|
|
|
|
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "select":
|
|
|
|
|
// TODO: 선택 옵션은 추후 구현
|
|
|
|
|
return (
|
|
|
|
|
<Select
|
|
|
|
|
key={filter.columnName}
|
|
|
|
|
value={value}
|
|
|
|
|
onValueChange={(newValue) => handleSearchValueChange(filter.columnName, newValue)}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger>
|
|
|
|
|
<SelectValue placeholder={`${filter.label} 선택...`} />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value="">전체</SelectItem>
|
|
|
|
|
{/* TODO: 동적 옵션 로드 */}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
return (
|
|
|
|
|
<Input
|
|
|
|
|
key={filter.columnName}
|
|
|
|
|
placeholder={`${filter.label} 검색...`}
|
|
|
|
|
value={value}
|
|
|
|
|
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
|
|
|
|
|
onKeyPress={(e) => {
|
|
|
|
|
if (e.key === "Enter") {
|
|
|
|
|
handleSearch();
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-09-05 12:04:13 +09:00
|
|
|
// 파일 다운로드
|
|
|
|
|
const handleDownloadFile = useCallback(async (fileInfo: FileInfo) => {
|
|
|
|
|
try {
|
2025-09-06 00:16:27 +09:00
|
|
|
// savedFileName이 없는 경우 파일 경로에서 추출 시도
|
|
|
|
|
const serverFilename = fileInfo.savedFileName || (fileInfo.path ? fileInfo.path.split("/").pop() : null);
|
2025-09-05 21:52:19 +09:00
|
|
|
|
|
|
|
|
if (!serverFilename) {
|
2025-09-05 12:04:13 +09:00
|
|
|
// _file 속성이 있는 경우 로컬 파일로 다운로드
|
|
|
|
|
if ((fileInfo as any)._file) {
|
|
|
|
|
try {
|
|
|
|
|
const file = (fileInfo as any)._file;
|
|
|
|
|
|
|
|
|
|
// File 객체 유효성 검사
|
|
|
|
|
if (!(file instanceof File) && !(file instanceof Blob)) {
|
|
|
|
|
console.error("❌ 잘못된 파일 객체:", file);
|
|
|
|
|
toast.error("파일 객체가 손상되었습니다. 파일을 다시 업로드해주세요.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const url = URL.createObjectURL(file);
|
|
|
|
|
const link = document.createElement("a");
|
|
|
|
|
link.href = url;
|
|
|
|
|
link.download = fileInfo.name || file.name || "download";
|
|
|
|
|
document.body.appendChild(link);
|
|
|
|
|
link.click();
|
|
|
|
|
document.body.removeChild(link);
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
toast.success(`${fileInfo.name} 다운로드가 완료되었습니다.`);
|
|
|
|
|
return;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("❌ 로컬 파일 다운로드 오류:", error);
|
|
|
|
|
toast.error("로컬 파일 다운로드에 실패했습니다. 파일을 다시 업로드해주세요.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
toast.error("이 파일은 이전 버전에서 저장된 파일입니다. 파일을 다시 업로드해주세요.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
toast.loading(`${fileInfo.name} 다운로드 중...`);
|
|
|
|
|
|
|
|
|
|
await downloadFile({
|
2025-09-05 21:52:19 +09:00
|
|
|
fileId: fileInfo.objid || fileInfo.id,
|
|
|
|
|
serverFilename: serverFilename,
|
2025-09-05 12:04:13 +09:00
|
|
|
originalName: fileInfo.name,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
toast.success(`${fileInfo.name} 다운로드가 완료되었습니다.`);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("파일 다운로드 오류:", error);
|
|
|
|
|
toast.error(`${fileInfo.name} 다운로드에 실패했습니다.`);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
2025-09-03 15:23:12 +09:00
|
|
|
// 셀 값 포맷팅
|
2025-09-06 00:16:27 +09:00
|
|
|
const formatCellValue = (value: any, column: DataTableColumn, rowData?: Record<string, any>): React.ReactNode => {
|
|
|
|
|
// 가상 파일 컬럼의 경우 value가 없어도 파일 아이콘을 표시해야 함
|
|
|
|
|
if (!column.isVirtualFileColumn && (value === null || value === undefined)) return "";
|
|
|
|
|
|
|
|
|
|
// 파일 타입 컬럼 처리 (가상 파일 컬럼 포함)
|
|
|
|
|
const isFileColumn =
|
|
|
|
|
column.widgetType === "file" || column.columnName === "file_path" || column.isVirtualFileColumn;
|
|
|
|
|
|
|
|
|
|
// 파일 타입 컬럼은 파일 아이콘으로 표시 (컬럼별 파일 관리)
|
|
|
|
|
if (isFileColumn && rowData) {
|
|
|
|
|
// 현재 행의 기본키 값 가져오기
|
|
|
|
|
const primaryKeyField = Object.keys(rowData)[0];
|
|
|
|
|
const recordId = rowData[primaryKeyField];
|
|
|
|
|
|
|
|
|
|
// 해당 컬럼에 대한 파일 상태 확인
|
|
|
|
|
const columnFileKey = `${recordId}_${column.columnName}`;
|
|
|
|
|
const columnFileStatus = fileStatusMap[columnFileKey];
|
|
|
|
|
const hasFiles = columnFileStatus?.hasFiles || false;
|
|
|
|
|
const fileCount = columnFileStatus?.fileCount || 0;
|
2025-09-05 21:52:19 +09:00
|
|
|
|
2025-09-06 00:16:27 +09:00
|
|
|
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>
|
|
|
|
|
)}
|
2025-09-05 21:52:19 +09:00
|
|
|
</div>
|
2025-09-06 00:16:27 +09:00
|
|
|
) : (
|
|
|
|
|
<Folder className="h-4 w-4 text-gray-400" />
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
2025-09-05 21:52:19 +09:00
|
|
|
}
|
2025-09-05 12:04:13 +09:00
|
|
|
|
2025-09-05 21:52:19 +09:00
|
|
|
switch (column.widgetType) {
|
2025-09-03 15:23:12 +09:00
|
|
|
case "date":
|
|
|
|
|
if (value) {
|
|
|
|
|
try {
|
|
|
|
|
const date = new Date(value);
|
|
|
|
|
return date.toLocaleDateString("ko-KR");
|
|
|
|
|
} catch {
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case "datetime":
|
|
|
|
|
if (value) {
|
|
|
|
|
try {
|
|
|
|
|
const date = new Date(value);
|
|
|
|
|
return date.toLocaleString("ko-KR");
|
|
|
|
|
} catch {
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case "number":
|
|
|
|
|
case "decimal":
|
|
|
|
|
if (typeof value === "number") {
|
|
|
|
|
return value.toLocaleString();
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
return String(value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return String(value);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Card className={cn("flex h-full flex-col", className)} style={{ ...style, minHeight: "680px" }}>
|
|
|
|
|
{/* 헤더 */}
|
|
|
|
|
<CardHeader className="pb-3">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
<Database className="text-muted-foreground h-4 w-4" />
|
|
|
|
|
<CardTitle className="text-lg">{component.title || component.label}</CardTitle>
|
|
|
|
|
{loading && (
|
|
|
|
|
<Badge variant="secondary" className="flex items-center gap-1">
|
|
|
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
|
|
|
로딩중...
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center space-x-2">
|
2025-09-03 16:38:10 +09:00
|
|
|
{/* 선택된 행 개수 표시 */}
|
|
|
|
|
{selectedRows.size > 0 && (
|
|
|
|
|
<Badge variant="secondary" className="text-xs">
|
|
|
|
|
{selectedRows.size}개 선택됨
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-09-03 15:23:12 +09:00
|
|
|
{searchFilters.length > 0 && (
|
|
|
|
|
<Badge variant="outline" className="text-xs">
|
|
|
|
|
<Search className="mr-1 h-3 w-3" />
|
|
|
|
|
필터 {searchFilters.length}개
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
2025-09-03 16:38:10 +09:00
|
|
|
|
|
|
|
|
{/* CRUD 버튼들 */}
|
|
|
|
|
{component.enableAdd && (
|
|
|
|
|
<Button size="sm" onClick={handleAddData} disabled={loading} className="gap-2">
|
|
|
|
|
<Plus className="h-3 w-3" />
|
|
|
|
|
{component.addButtonText || "추가"}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{component.enableEdit && selectedRows.size === 1 && (
|
|
|
|
|
<Button size="sm" onClick={handleEditData} disabled={loading} className="gap-2" variant="outline">
|
|
|
|
|
<Edit className="h-3 w-3" />
|
|
|
|
|
{component.editButtonText || "수정"}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{component.enableDelete && selectedRows.size > 0 && (
|
|
|
|
|
<Button size="sm" variant="destructive" onClick={handleDeleteData} disabled={loading} className="gap-2">
|
|
|
|
|
<Trash2 className="h-3 w-3" />
|
|
|
|
|
{component.deleteButtonText || "삭제"}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-09-03 15:23:12 +09:00
|
|
|
{component.showSearchButton && (
|
|
|
|
|
<Button size="sm" onClick={handleSearch} disabled={loading} className="gap-2">
|
|
|
|
|
<Search className="h-3 w-3" />
|
|
|
|
|
{component.searchButtonText || "검색"}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
2025-09-03 16:38:10 +09:00
|
|
|
|
2025-09-03 15:23:12 +09:00
|
|
|
<Button size="sm" variant="outline" onClick={() => loadData(1, {})} disabled={loading} className="gap-2">
|
|
|
|
|
<RotateCcw className="h-3 w-3" />
|
|
|
|
|
새로고침
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 검색 필터 */}
|
|
|
|
|
{searchFilters.length > 0 && (
|
|
|
|
|
<>
|
|
|
|
|
<Separator className="my-2" />
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<CardDescription className="flex items-center gap-2">
|
|
|
|
|
<Search className="h-3 w-3" />
|
|
|
|
|
검색 필터
|
|
|
|
|
</CardDescription>
|
|
|
|
|
<div
|
|
|
|
|
className="grid gap-3"
|
|
|
|
|
style={{
|
|
|
|
|
gridTemplateColumns: searchFilters
|
|
|
|
|
.map((filter: DataTableFilter) => `${filter.gridColumns || 3}fr`)
|
|
|
|
|
.join(" "),
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{searchFilters.map((filter: DataTableFilter) => (
|
|
|
|
|
<div key={filter.columnName} className="space-y-1">
|
|
|
|
|
<label className="text-muted-foreground text-xs font-medium">{filter.label}</label>
|
|
|
|
|
{renderSearchFilter(filter)}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
|
|
|
|
{/* 테이블 내용 */}
|
|
|
|
|
<CardContent className="flex-1 p-0">
|
|
|
|
|
<div className="flex h-full flex-col">
|
|
|
|
|
{visibleColumns.length > 0 ? (
|
|
|
|
|
<>
|
|
|
|
|
<Table>
|
|
|
|
|
<TableHeader>
|
|
|
|
|
<TableRow>
|
2025-09-03 16:38:10 +09:00
|
|
|
{/* 체크박스 컬럼 (삭제 기능이 활성화된 경우) */}
|
|
|
|
|
{component.enableDelete && (
|
|
|
|
|
<TableHead className="w-12 px-4">
|
|
|
|
|
<Checkbox
|
|
|
|
|
checked={selectedRows.size === data.length && data.length > 0}
|
|
|
|
|
onCheckedChange={handleSelectAll}
|
|
|
|
|
/>
|
|
|
|
|
</TableHead>
|
|
|
|
|
)}
|
2025-09-03 15:23:12 +09:00
|
|
|
{visibleColumns.map((column: DataTableColumn) => (
|
|
|
|
|
<TableHead
|
|
|
|
|
key={column.id}
|
|
|
|
|
className="px-4 font-semibold"
|
|
|
|
|
style={{ width: `${((column.gridColumns || 2) / totalGridColumns) * 100}%` }}
|
|
|
|
|
>
|
|
|
|
|
{column.label}
|
|
|
|
|
</TableHead>
|
|
|
|
|
))}
|
2025-09-06 00:16:27 +09:00
|
|
|
{/* 기본 파일 컬럼은 가상 파일 컬럼이 있으면 완전히 숨김 */}
|
|
|
|
|
{!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>
|
|
|
|
|
)}
|
2025-09-03 15:23:12 +09:00
|
|
|
</TableRow>
|
|
|
|
|
</TableHeader>
|
|
|
|
|
<TableBody>
|
|
|
|
|
{loading ? (
|
|
|
|
|
<TableRow>
|
2025-09-03 16:38:10 +09:00
|
|
|
<TableCell
|
2025-09-06 00:16:27 +09:00
|
|
|
colSpan={
|
|
|
|
|
visibleColumns.length +
|
|
|
|
|
(component.enableDelete ? 1 : 0) +
|
|
|
|
|
(!visibleColumns.some((col) => col.widgetType === "file") ? 1 : 0)
|
|
|
|
|
}
|
2025-09-03 16:38:10 +09:00
|
|
|
className="h-32 text-center"
|
|
|
|
|
>
|
2025-09-03 15:23:12 +09:00
|
|
|
<div className="text-muted-foreground flex items-center justify-center gap-2">
|
|
|
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
|
|
|
데이터를 불러오는 중...
|
|
|
|
|
</div>
|
|
|
|
|
</TableCell>
|
|
|
|
|
</TableRow>
|
|
|
|
|
) : data.length > 0 ? (
|
|
|
|
|
data.map((row, rowIndex) => (
|
|
|
|
|
<TableRow key={rowIndex} className="hover:bg-muted/50">
|
2025-09-03 16:38:10 +09:00
|
|
|
{/* 체크박스 셀 (삭제 기능이 활성화된 경우) */}
|
|
|
|
|
{component.enableDelete && (
|
|
|
|
|
<TableCell className="w-12 px-4">
|
|
|
|
|
<Checkbox
|
|
|
|
|
checked={selectedRows.has(rowIndex)}
|
|
|
|
|
onCheckedChange={(checked) => handleRowSelect(rowIndex, checked as boolean)}
|
|
|
|
|
/>
|
|
|
|
|
</TableCell>
|
|
|
|
|
)}
|
2025-09-03 15:23:12 +09:00
|
|
|
{visibleColumns.map((column: DataTableColumn) => (
|
|
|
|
|
<TableCell key={column.id} className="px-4 font-mono text-sm">
|
2025-09-06 00:16:27 +09:00
|
|
|
{formatCellValue(row[column.columnName], column, row)}
|
2025-09-03 15:23:12 +09:00
|
|
|
</TableCell>
|
|
|
|
|
))}
|
2025-09-06 00:16:27 +09:00
|
|
|
{/* 기본 파일 셀은 가상 파일 컬럼이 있으면 완전히 숨김 */}
|
|
|
|
|
{!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>
|
|
|
|
|
)}
|
2025-09-03 15:23:12 +09:00
|
|
|
</TableRow>
|
|
|
|
|
))
|
|
|
|
|
) : (
|
|
|
|
|
<TableRow>
|
2025-09-03 16:38:10 +09:00
|
|
|
<TableCell
|
2025-09-06 00:16:27 +09:00
|
|
|
colSpan={
|
|
|
|
|
visibleColumns.length +
|
|
|
|
|
(component.enableDelete ? 1 : 0) +
|
|
|
|
|
(!visibleColumns.some((col) => col.widgetType === "file") ? 1 : 0)
|
|
|
|
|
}
|
2025-09-03 16:38:10 +09:00
|
|
|
className="h-32 text-center"
|
|
|
|
|
>
|
2025-09-03 15:23:12 +09:00
|
|
|
<div className="text-muted-foreground flex flex-col items-center gap-2">
|
|
|
|
|
<Database className="h-8 w-8" />
|
|
|
|
|
<p>검색 결과가 없습니다</p>
|
|
|
|
|
<p className="text-xs">검색 조건을 변경하거나 새로고침을 시도해보세요</p>
|
|
|
|
|
</div>
|
|
|
|
|
</TableCell>
|
|
|
|
|
</TableRow>
|
|
|
|
|
)}
|
|
|
|
|
</TableBody>
|
|
|
|
|
</Table>
|
|
|
|
|
|
|
|
|
|
{/* 페이지네이션 */}
|
|
|
|
|
{component.pagination?.enabled && totalPages > 1 && (
|
|
|
|
|
<div className="bg-muted/20 mt-auto border-t">
|
|
|
|
|
<div className="flex items-center justify-between px-6 py-3">
|
|
|
|
|
{component.pagination.showPageInfo && (
|
|
|
|
|
<div className="text-muted-foreground text-sm">
|
|
|
|
|
총 <span className="font-medium">{total.toLocaleString()}</span>개 중{" "}
|
|
|
|
|
<span className="font-medium">{((currentPage - 1) * pageSize + 1).toLocaleString()}</span>-
|
|
|
|
|
<span className="font-medium">{Math.min(currentPage * pageSize, total).toLocaleString()}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
{component.pagination.showFirstLast && (
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => handlePageChange(1)}
|
|
|
|
|
disabled={currentPage === 1 || loading}
|
|
|
|
|
className="gap-1"
|
|
|
|
|
>
|
|
|
|
|
처음
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => handlePageChange(currentPage - 1)}
|
|
|
|
|
disabled={currentPage === 1 || loading}
|
|
|
|
|
className="gap-1"
|
|
|
|
|
>
|
|
|
|
|
<ChevronLeft className="h-3 w-3" />
|
|
|
|
|
이전
|
|
|
|
|
</Button>
|
|
|
|
|
<div className="flex items-center gap-1 text-sm font-medium">
|
|
|
|
|
<span>{currentPage}</span>
|
|
|
|
|
<span className="text-muted-foreground">/</span>
|
|
|
|
|
<span>{totalPages}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => handlePageChange(currentPage + 1)}
|
|
|
|
|
disabled={currentPage === totalPages || loading}
|
|
|
|
|
className="gap-1"
|
|
|
|
|
>
|
|
|
|
|
다음
|
|
|
|
|
<ChevronRight className="h-3 w-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
{component.pagination.showFirstLast && (
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => handlePageChange(totalPages)}
|
|
|
|
|
disabled={currentPage === totalPages || loading}
|
|
|
|
|
className="gap-1"
|
|
|
|
|
>
|
|
|
|
|
마지막
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="flex flex-1 items-center justify-center">
|
|
|
|
|
<div className="text-muted-foreground flex flex-col items-center gap-2">
|
|
|
|
|
<Database className="h-8 w-8" />
|
|
|
|
|
<p className="text-sm">표시할 컬럼이 없습니다</p>
|
|
|
|
|
<p className="text-xs">테이블 설정에서 컬럼을 추가해주세요</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
2025-09-03 16:38:10 +09:00
|
|
|
|
|
|
|
|
{/* 데이터 추가 모달 */}
|
|
|
|
|
<Dialog open={showAddModal} onOpenChange={handleAddModalClose}>
|
|
|
|
|
<DialogContent className={`max-h-[80vh] overflow-y-auto ${getModalSizeClass()}`}>
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle>{component.addModalConfig?.title || "새 데이터 추가"}</DialogTitle>
|
|
|
|
|
<DialogDescription>
|
|
|
|
|
{component.addModalConfig?.description ||
|
|
|
|
|
`${component.title || component.label}에 새로운 데이터를 추가합니다.`}
|
|
|
|
|
</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
<div className="py-4">
|
|
|
|
|
<div className={getLayoutClass()}>
|
|
|
|
|
{getDisplayColumns().map((column) => (
|
|
|
|
|
<div key={column.id} className="space-y-2">
|
|
|
|
|
<Label htmlFor={column.columnName} className="text-sm font-medium">
|
|
|
|
|
{column.label}
|
|
|
|
|
{isRequiredField(column.columnName) && <span className="ml-1 text-orange-500">*</span>}
|
|
|
|
|
</Label>
|
|
|
|
|
<div>{renderAddFormInput(column)}</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
<Button variant="outline" onClick={handleAddModalClose} disabled={isAdding}>
|
|
|
|
|
{component.addModalConfig?.cancelButtonText || "취소"}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button onClick={handleAddSubmit} disabled={isAdding}>
|
|
|
|
|
{isAdding ? (
|
|
|
|
|
<>
|
|
|
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
|
|
|
추가 중...
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
|
|
|
{component.addModalConfig?.submitButtonText || "추가"}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
|
|
|
{/* 데이터 수정 모달 */}
|
|
|
|
|
<Dialog
|
|
|
|
|
open={showEditModal}
|
|
|
|
|
onOpenChange={(open) => {
|
|
|
|
|
if (!isEditing && !open) {
|
|
|
|
|
setShowEditModal(false);
|
|
|
|
|
setEditFormData({});
|
|
|
|
|
setEditingRowData(null);
|
2025-09-03 17:12:27 +09:00
|
|
|
setUploadedFiles({}); // 파일 상태 초기화
|
2025-09-03 16:38:10 +09:00
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<DialogContent className={`max-h-[80vh] overflow-y-auto ${getModalSizeClass()}`}>
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle>데이터 수정</DialogTitle>
|
|
|
|
|
<DialogDescription>선택된 데이터를 수정합니다.</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
<div className="py-4">
|
|
|
|
|
<div className={getLayoutClass()}>
|
|
|
|
|
{getDisplayColumns().map((column) => (
|
|
|
|
|
<div key={column.id} className="space-y-2">
|
|
|
|
|
<Label htmlFor={`edit-${column.columnName}`} className="text-sm font-medium">
|
|
|
|
|
{column.label}
|
|
|
|
|
{isRequiredField(column.columnName) && <span className="ml-1 text-orange-500">*</span>}
|
|
|
|
|
</Label>
|
|
|
|
|
<div>{renderEditFormInput(column)}</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setShowEditModal(false);
|
|
|
|
|
setEditFormData({});
|
|
|
|
|
setEditingRowData(null);
|
2025-09-03 17:12:27 +09:00
|
|
|
setUploadedFiles({}); // 파일 상태 초기화
|
2025-09-03 16:38:10 +09:00
|
|
|
}}
|
|
|
|
|
disabled={isEditing}
|
|
|
|
|
>
|
|
|
|
|
취소
|
|
|
|
|
</Button>
|
|
|
|
|
<Button onClick={handleEditSubmit} disabled={isEditing}>
|
|
|
|
|
{isEditing ? (
|
|
|
|
|
<>
|
|
|
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
|
|
|
수정 중...
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<Edit className="mr-2 h-4 w-4" />
|
|
|
|
|
{component.editButtonText || "수정"}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
2025-09-05 12:04:13 +09:00
|
|
|
{/* 파일 정보 모달 */}
|
|
|
|
|
<Dialog open={showFileModal} onOpenChange={setShowFileModal}>
|
|
|
|
|
<DialogContent className="flex max-h-[80vh] max-w-2xl flex-col overflow-hidden">
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle className="flex items-center gap-2">
|
|
|
|
|
<File className="h-5 w-5" />
|
|
|
|
|
파일 정보 - {currentFileColumn?.label || "파일"}
|
|
|
|
|
</DialogTitle>
|
|
|
|
|
<DialogDescription>
|
|
|
|
|
{currentFileData?.totalCount === 1
|
|
|
|
|
? "1개의 파일이 저장되어 있습니다."
|
|
|
|
|
: `총 ${currentFileData?.totalCount}개의 파일이 저장되어 있습니다.`}
|
|
|
|
|
</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
<div className="flex-1 overflow-auto">
|
|
|
|
|
{currentFileData?.files && Array.isArray(currentFileData.files) && (
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
{currentFileData.files.map((fileInfo: FileInfo, index: number) => {
|
|
|
|
|
const isImage = fileInfo.type?.startsWith("image/");
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div key={index} className="rounded-lg border bg-gray-50 p-4 transition-colors hover:bg-gray-100">
|
|
|
|
|
<div className="flex items-start gap-4">
|
|
|
|
|
{/* 파일 아이콘/미리보기 */}
|
|
|
|
|
<div className="flex-shrink-0">
|
|
|
|
|
{isImage ? (
|
|
|
|
|
<div className="flex h-16 w-16 items-center justify-center rounded border bg-white">
|
|
|
|
|
<div className="text-xs font-medium text-green-600">IMG</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="flex h-16 w-16 items-center justify-center rounded border bg-white">
|
|
|
|
|
<File className="h-8 w-8 text-gray-500" />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 파일 정보 */}
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
<h4 className="truncate font-medium text-gray-900" title={fileInfo.name}>
|
|
|
|
|
{fileInfo.name}
|
|
|
|
|
</h4>
|
|
|
|
|
<div className="mt-1 space-y-1 text-sm text-gray-600">
|
|
|
|
|
<div className="flex items-center gap-4">
|
|
|
|
|
<span>크기: {(fileInfo.size / 1024 / 1024).toFixed(2)} MB</span>
|
|
|
|
|
<span>타입: {fileInfo.type || "알 수 없음"}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-4">
|
2025-09-05 21:52:19 +09:00
|
|
|
{fileInfo.regdate && (
|
|
|
|
|
<span>등록일: {new Date(fileInfo.regdate).toLocaleString("ko-KR")}</span>
|
|
|
|
|
)}
|
|
|
|
|
{fileInfo.writer && <span>등록자: {fileInfo.writer}</span>}
|
2025-09-05 12:04:13 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 액션 버튼 */}
|
|
|
|
|
<div className="flex flex-col gap-2">
|
|
|
|
|
{isImage && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="w-full"
|
2025-09-05 14:52:10 +09:00
|
|
|
onClick={() => handlePreviewImage(fileInfo)}
|
2025-09-05 12:04:13 +09:00
|
|
|
>
|
|
|
|
|
<Eye className="mr-1 h-4 w-4" />
|
|
|
|
|
미리보기
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="w-full"
|
|
|
|
|
onClick={() => handleDownloadFile(fileInfo)}
|
|
|
|
|
>
|
|
|
|
|
<Download className="mr-1 h-4 w-4" />
|
|
|
|
|
다운로드
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 요약 정보 */}
|
|
|
|
|
{currentFileData && (
|
|
|
|
|
<div className="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
|
|
|
|
<h5 className="mb-2 font-medium text-blue-900">파일 요약</h5>
|
|
|
|
|
<div className="grid grid-cols-2 gap-4 text-sm text-blue-800">
|
|
|
|
|
<div>
|
|
|
|
|
<span className="font-medium">총 파일 수:</span>
|
|
|
|
|
{" "}
|
|
|
|
|
{currentFileData.totalCount}개
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<span className="font-medium">총 크기:</span>
|
|
|
|
|
{" "}
|
|
|
|
|
{(currentFileData.totalSize / 1024 / 1024).toFixed(2)} MB
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
<Button variant="outline" onClick={() => setShowFileModal(false)}>
|
|
|
|
|
<X className="mr-1 h-4 w-4" />
|
|
|
|
|
닫기
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
2025-09-03 16:38:10 +09:00
|
|
|
{/* 데이터 삭제 확인 다이얼로그 */}
|
|
|
|
|
<Dialog open={showDeleteDialog} onOpenChange={handleDeleteDialogClose}>
|
|
|
|
|
<DialogContent>
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle>데이터 삭제 확인</DialogTitle>
|
|
|
|
|
<DialogDescription>
|
|
|
|
|
선택된 <strong>{selectedRows.size}개</strong>의 데이터를 삭제하시겠습니까?
|
|
|
|
|
<br />
|
|
|
|
|
<span className="text-red-600">이 작업은 되돌릴 수 없습니다.</span>
|
|
|
|
|
</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
<Button variant="outline" onClick={handleDeleteDialogClose} disabled={isDeleting}>
|
|
|
|
|
취소
|
|
|
|
|
</Button>
|
|
|
|
|
<Button variant="destructive" onClick={handleDeleteConfirm} disabled={isDeleting}>
|
|
|
|
|
{isDeleting ? (
|
|
|
|
|
<>
|
|
|
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
|
|
|
삭제 중...
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
|
|
|
|
삭제
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
2025-09-05 14:52:10 +09:00
|
|
|
|
|
|
|
|
{/* 이미지 미리보기 다이얼로그 */}
|
|
|
|
|
<Dialog open={showPreviewModal} onOpenChange={closePreviewModal}>
|
|
|
|
|
<DialogContent className="max-h-[90vh] max-w-4xl overflow-hidden">
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle className="flex items-center justify-between">
|
|
|
|
|
<span className="truncate">{previewImage?.name}</span>
|
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
<Button size="sm" variant="outline" onClick={() => handleZoom("out")} disabled={zoom <= 0.25}>
|
|
|
|
|
<ZoomOut className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
<span className="min-w-[60px] text-center text-sm text-gray-500">{Math.round(zoom * 100)}%</span>
|
|
|
|
|
<Button size="sm" variant="outline" onClick={() => handleZoom("in")} disabled={zoom >= 3}>
|
|
|
|
|
<ZoomIn className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
<Button size="sm" variant="outline" onClick={handleRotate}>
|
|
|
|
|
<RotateCw className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
{previewImage && (
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
handleDownloadFile(previewImage);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Download className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</DialogTitle>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-1 items-center justify-center overflow-auto rounded-lg bg-gray-50 p-4">
|
|
|
|
|
{previewImage && (
|
|
|
|
|
<img
|
2025-09-06 00:16:27 +09:00
|
|
|
src={`${process.env.NEXT_PUBLIC_API_URL}/files/preview/${previewImage.id}?serverFilename=${previewImage.savedFileName}`}
|
2025-09-05 14:52:10 +09:00
|
|
|
alt={previewImage.name}
|
|
|
|
|
className="max-h-full max-w-full object-contain transition-transform duration-200"
|
|
|
|
|
style={{
|
|
|
|
|
transform: `scale(${zoom}) rotate(${rotation}deg)`,
|
|
|
|
|
}}
|
|
|
|
|
onError={() => {
|
|
|
|
|
console.error("이미지 로딩 실패:", previewImage);
|
|
|
|
|
toast.error("이미지를 불러올 수 없습니다.");
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{previewImage && (
|
|
|
|
|
<div className="flex items-center justify-between border-t pt-3 text-sm text-gray-500">
|
|
|
|
|
<div>크기: {formatFileSize(previewImage.size)}</div>
|
|
|
|
|
<div>타입: {previewImage.type}</div>
|
|
|
|
|
<div>업로드: {new Date(previewImage.uploadedAt).toLocaleDateString("ko-KR")}</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
2025-09-06 00:16:27 +09:00
|
|
|
|
|
|
|
|
{/* 파일 관리 모달 */}
|
|
|
|
|
<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>
|
2025-09-03 15:23:12 +09:00
|
|
|
</Card>
|
|
|
|
|
);
|
|
|
|
|
};
|