Merge pull request '데이터 테이블 첨부파일 연계' (#24) from feature/screen-management into dev
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/24
This commit is contained in:
commit
7ade7b5f6a
|
|
@ -62,21 +62,45 @@ const upload = multer({
|
|||
fileSize: 50 * 1024 * 1024, // 50MB 제한
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
// 파일 타입 검증
|
||||
const allowedTypes = [
|
||||
// 프론트엔드에서 전송된 accept 정보 확인
|
||||
const acceptHeader = req.body?.accept;
|
||||
console.log("🔍 파일 타입 검증:", {
|
||||
fileName: file.originalname,
|
||||
mimeType: file.mimetype,
|
||||
acceptFromFrontend: acceptHeader,
|
||||
});
|
||||
|
||||
// 프론트엔드에서 */* 또는 * 허용한 경우 모든 파일 허용
|
||||
if (
|
||||
acceptHeader &&
|
||||
(acceptHeader.includes("*/*") || acceptHeader.includes("*"))
|
||||
) {
|
||||
console.log("✅ 와일드카드 허용: 모든 파일 타입 허용");
|
||||
cb(null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 기본 허용 파일 타입
|
||||
const defaultAllowedTypes = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"text/html", // HTML 파일 추가
|
||||
"text/plain", // 텍스트 파일 추가
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/zip", // ZIP 파일 추가
|
||||
"application/x-zip-compressed", // ZIP 파일 (다른 MIME 타입)
|
||||
];
|
||||
|
||||
if (allowedTypes.includes(file.mimetype)) {
|
||||
if (defaultAllowedTypes.includes(file.mimetype)) {
|
||||
console.log("✅ 기본 허용 파일 타입:", file.mimetype);
|
||||
cb(null, true);
|
||||
} else {
|
||||
console.log("❌ 허용되지 않는 파일 타입:", file.mimetype);
|
||||
cb(new Error("허용되지 않는 파일 타입입니다."));
|
||||
}
|
||||
},
|
||||
|
|
@ -116,11 +140,27 @@ export const uploadFiles = async (
|
|||
}
|
||||
|
||||
const files = req.files as Express.Multer.File[];
|
||||
|
||||
// 파라미터 확인 및 로깅
|
||||
console.log("📤 파일 업로드 요청 수신:", {
|
||||
filesCount: files?.length || 0,
|
||||
bodyKeys: Object.keys(req.body),
|
||||
fullBody: req.body, // 전체 body 내용 확인
|
||||
});
|
||||
|
||||
const {
|
||||
docType = "DOCUMENT",
|
||||
docTypeName = "일반 문서",
|
||||
targetObjid,
|
||||
parentTargetObjid,
|
||||
// 테이블 연결 정보 (새로 추가)
|
||||
linkedTable,
|
||||
linkedField,
|
||||
recordId,
|
||||
autoLink,
|
||||
// 가상 파일 컬럼 정보
|
||||
columnName,
|
||||
isVirtualFileColumn,
|
||||
} = req.body;
|
||||
|
||||
// 회사코드와 작성자 정보 결정 (우선순위: 요청 body > 사용자 토큰 정보 > 기본값)
|
||||
|
|
@ -128,6 +168,26 @@ export const uploadFiles = async (
|
|||
req.body.companyCode || (req.user as any)?.companyCode || "DEFAULT";
|
||||
const writer = req.body.writer || (req.user as any)?.userId || "system";
|
||||
|
||||
// 자동 연결 로직 - target_objid 자동 생성
|
||||
let finalTargetObjid = targetObjid;
|
||||
if (autoLink === "true" && linkedTable && recordId) {
|
||||
// 가상 파일 컬럼의 경우 컬럼명도 포함한 target_objid 생성
|
||||
if (isVirtualFileColumn === "true" && columnName) {
|
||||
finalTargetObjid = `${linkedTable}:${recordId}:${columnName}`;
|
||||
} else {
|
||||
finalTargetObjid = `${linkedTable}:${recordId}`;
|
||||
}
|
||||
|
||||
console.log("🔗 자동 연결 활성화:", {
|
||||
linkedTable,
|
||||
linkedField,
|
||||
recordId,
|
||||
columnName,
|
||||
isVirtualFileColumn,
|
||||
generatedTargetObjid: finalTargetObjid,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("🔍 사용자 정보 결정:", {
|
||||
bodyCompanyCode: req.body.companyCode,
|
||||
userCompanyCode: (req.user as any)?.companyCode,
|
||||
|
|
@ -185,7 +245,7 @@ export const uploadFiles = async (
|
|||
generateUUID().replace(/-/g, "").substring(0, 15),
|
||||
16
|
||||
),
|
||||
target_objid: targetObjid,
|
||||
target_objid: finalTargetObjid,
|
||||
saved_file_name: file.filename,
|
||||
real_file_name: file.originalname,
|
||||
doc_type: docType,
|
||||
|
|
@ -290,6 +350,86 @@ export const deleteFile = async (
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 테이블 연결된 파일 조회
|
||||
*/
|
||||
export const getLinkedFiles = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { tableName, recordId } = req.params;
|
||||
|
||||
console.log("📎 연결된 파일 조회 요청:", {
|
||||
tableName,
|
||||
recordId,
|
||||
});
|
||||
|
||||
// target_objid 생성 (테이블명:레코드ID 형식)
|
||||
const baseTargetObjid = `${tableName}:${recordId}`;
|
||||
|
||||
console.log("🔍 파일 조회 쿼리:", {
|
||||
tableName,
|
||||
recordId,
|
||||
baseTargetObjid,
|
||||
queryPattern: `${baseTargetObjid}%`,
|
||||
});
|
||||
|
||||
// 기본 target_objid와 파일 컬럼 패턴 모두 조회 (tableName:recordId% 패턴)
|
||||
const files = await prisma.attach_file_info.findMany({
|
||||
where: {
|
||||
target_objid: {
|
||||
startsWith: baseTargetObjid, // tableName:recordId로 시작하는 모든 파일
|
||||
},
|
||||
status: "ACTIVE",
|
||||
},
|
||||
orderBy: {
|
||||
regdate: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
console.log("📁 조회된 파일 목록:", {
|
||||
foundFiles: files.length,
|
||||
targetObjids: files.map((f) => f.target_objid),
|
||||
});
|
||||
|
||||
const fileList = files.map((file: any) => ({
|
||||
objid: file.objid.toString(),
|
||||
savedFileName: file.saved_file_name,
|
||||
realFileName: file.real_file_name,
|
||||
fileSize: Number(file.file_size),
|
||||
fileExt: file.file_ext,
|
||||
filePath: file.file_path,
|
||||
docType: file.doc_type,
|
||||
docTypeName: file.doc_type_name,
|
||||
targetObjid: file.target_objid,
|
||||
parentTargetObjid: file.parent_target_objid,
|
||||
writer: file.writer,
|
||||
regdate: file.regdate?.toISOString(),
|
||||
status: file.status,
|
||||
}));
|
||||
|
||||
console.log("✅ 연결된 파일 조회 완료:", {
|
||||
baseTargetObjid,
|
||||
fileCount: fileList.length,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
files: fileList,
|
||||
totalCount: fileList.length,
|
||||
targetObjid: baseTargetObjid, // 기준 target_objid 반환
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("연결된 파일 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "연결된 파일 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 파일 목록 조회
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
deleteFile,
|
||||
getFileList,
|
||||
downloadFile,
|
||||
getLinkedFiles,
|
||||
uploadMiddleware,
|
||||
} from "../controllers/fileController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
|
@ -28,6 +29,13 @@ router.post("/upload", uploadMiddleware, uploadFiles);
|
|||
*/
|
||||
router.get("/", getFileList);
|
||||
|
||||
/**
|
||||
* @route GET /api/files/linked/:tableName/:recordId
|
||||
* @desc 테이블 연결된 파일 조회
|
||||
* @access Private
|
||||
*/
|
||||
router.get("/linked/:tableName/:recordId", getLinkedFiles);
|
||||
|
||||
/**
|
||||
* @route DELETE /api/files/:objid
|
||||
* @desc 파일 삭제 (논리적 삭제)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3010,6 +3010,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
onUpdateProperty={(componentId: string, path: string, value: any) => {
|
||||
updateComponentProperty(componentId, path, value);
|
||||
}}
|
||||
currentTable={tables.length > 0 ? tables[0] : undefined}
|
||||
currentTableName={selectedScreen?.tableName}
|
||||
/>
|
||||
</FloatingPanel>
|
||||
|
||||
|
|
|
|||
|
|
@ -1061,6 +1061,71 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
|||
[selectedTable, component.columns, component.filters, onUpdateComponent],
|
||||
);
|
||||
|
||||
// 가상 파일 컬럼 추가
|
||||
const addVirtualFileColumn = useCallback(() => {
|
||||
const fileColumnCount = component.columns.filter((col) => col.isVirtualFileColumn).length;
|
||||
const newColumnName = `file_column_${fileColumnCount + 1}`; // 순차적 번호 사용
|
||||
|
||||
const newColumn: DataTableColumn = {
|
||||
id: generateComponentId(),
|
||||
columnName: newColumnName,
|
||||
label: `파일 컬럼 ${fileColumnCount + 1}`,
|
||||
widgetType: "file",
|
||||
gridColumns: 2,
|
||||
visible: true,
|
||||
filterable: false, // 파일 컬럼은 필터링 불가
|
||||
sortable: false, // 파일 컬럼은 정렬 불가
|
||||
searchable: false, // 파일 컬럼은 검색 불가
|
||||
isVirtualFileColumn: true, // 가상 파일 컬럼 표시
|
||||
fileColumnConfig: {
|
||||
docType: "DOCUMENT",
|
||||
docTypeName: "일반 문서",
|
||||
maxFiles: 5,
|
||||
accept: ["*/*"],
|
||||
},
|
||||
};
|
||||
|
||||
console.log("📁 가상 파일 컬럼 추가:", {
|
||||
columnName: newColumn.columnName,
|
||||
label: newColumn.label,
|
||||
isVirtualFileColumn: newColumn.isVirtualFileColumn,
|
||||
});
|
||||
|
||||
// 로컬 상태에 새 컬럼 입력값 추가
|
||||
setLocalColumnInputs((prev) => ({
|
||||
...prev,
|
||||
[newColumn.id]: newColumn.label,
|
||||
}));
|
||||
|
||||
// 로컬 체크박스 상태에 새 컬럼 추가
|
||||
setLocalColumnCheckboxes((prev) => ({
|
||||
...prev,
|
||||
[newColumn.id]: {
|
||||
visible: newColumn.visible,
|
||||
sortable: newColumn.sortable,
|
||||
searchable: newColumn.searchable,
|
||||
},
|
||||
}));
|
||||
|
||||
// 로컬 그리드 컬럼 상태에 새 컬럼 추가
|
||||
setLocalColumnGridColumns((prev) => ({
|
||||
...prev,
|
||||
[newColumn.id]: newColumn.gridColumns,
|
||||
}));
|
||||
|
||||
// 컬럼 업데이트
|
||||
const updates: Partial<DataTableComponent> = {
|
||||
columns: [...component.columns, newColumn],
|
||||
};
|
||||
|
||||
onUpdateComponent(updates);
|
||||
|
||||
// 컬럼 추가 후 컬럼 탭으로 자동 이동
|
||||
setActiveTab("columns");
|
||||
|
||||
console.log("✅ 가상 파일 컬럼 추가 완료");
|
||||
}, [component.columns, onUpdateComponent]);
|
||||
|
||||
return (
|
||||
<div className="max-h-[80vh] p-4">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
|
|
@ -1459,6 +1524,14 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
|||
<h3 className="text-sm font-medium">테이블 컬럼 설정</h3>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="secondary">{component.columns.length}개</Badge>
|
||||
|
||||
{/* 파일 컬럼 추가 버튼 */}
|
||||
<Button size="sm" variant="outline" onClick={addVirtualFileColumn} className="h-8 text-xs">
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="ml-1">파일 컬럼</span>
|
||||
</Button>
|
||||
|
||||
{/* 기존 DB 컬럼 추가 */}
|
||||
{selectedTable &&
|
||||
(() => {
|
||||
const availableColumns = selectedTable.columns.filter(
|
||||
|
|
@ -1468,7 +1541,7 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
|||
return availableColumns.length > 0 ? (
|
||||
<Select onValueChange={(value) => addColumn(value)}>
|
||||
<SelectTrigger className="h-8 w-32 text-xs">
|
||||
<SelectValue placeholder="컬럼 추가" />
|
||||
<SelectValue placeholder="DB 컬럼" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableColumns.map((col) => (
|
||||
|
|
@ -1481,7 +1554,7 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
|||
) : (
|
||||
<Button size="sm" disabled>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="ml-1 text-xs">모든 컬럼 추가됨</span>
|
||||
<span className="ml-1 text-xs">모든 DB 컬럼 추가됨</span>
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
CodeTypeConfig,
|
||||
EntityTypeConfig,
|
||||
ButtonTypeConfig,
|
||||
TableInfo,
|
||||
} from "@/types/screen";
|
||||
import { DateTypeConfigPanel } from "./webtype-configs/DateTypeConfigPanel";
|
||||
import { NumberTypeConfigPanel } from "./webtype-configs/NumberTypeConfigPanel";
|
||||
|
|
@ -36,9 +37,16 @@ import { FileComponentConfigPanel } from "./FileComponentConfigPanel";
|
|||
interface DetailSettingsPanelProps {
|
||||
selectedComponent?: ComponentData;
|
||||
onUpdateProperty: (componentId: string, path: string, value: any) => void;
|
||||
currentTable?: TableInfo; // 현재 화면의 테이블 정보
|
||||
currentTableName?: string; // 현재 화면의 테이블명
|
||||
}
|
||||
|
||||
export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({ selectedComponent, onUpdateProperty }) => {
|
||||
export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||
selectedComponent,
|
||||
onUpdateProperty,
|
||||
currentTable,
|
||||
currentTableName,
|
||||
}) => {
|
||||
// 입력 가능한 웹타입들 정의
|
||||
const inputableWebTypes = [
|
||||
"text",
|
||||
|
|
@ -251,7 +259,12 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({ select
|
|||
|
||||
{/* 파일 컴포넌트 설정 영역 */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<FileComponentConfigPanel component={fileComponent} onUpdateProperty={onUpdateProperty} />
|
||||
<FileComponentConfigPanel
|
||||
component={fileComponent}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
currentTable={currentTable}
|
||||
currentTableName={currentTableName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,16 +7,23 @@ import { Checkbox } from "@/components/ui/checkbox";
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { FileComponent } from "@/types/screen";
|
||||
import { FileComponent, TableInfo } from "@/types/screen";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface FileComponentConfigPanelProps {
|
||||
component: FileComponent;
|
||||
onUpdateProperty: (componentId: string, path: string, value: any) => void;
|
||||
currentTable?: TableInfo; // 현재 화면의 테이블 정보
|
||||
currentTableName?: string; // 현재 화면의 테이블명
|
||||
}
|
||||
|
||||
export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> = ({ component, onUpdateProperty }) => {
|
||||
export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> = ({
|
||||
component,
|
||||
onUpdateProperty,
|
||||
currentTable,
|
||||
currentTableName,
|
||||
}) => {
|
||||
// 로컬 상태
|
||||
const [localInputs, setLocalInputs] = useState({
|
||||
docType: component.fileConfig.docType || "DOCUMENT",
|
||||
|
|
@ -25,12 +32,15 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
maxSize: component.fileConfig.maxSize || 10,
|
||||
maxFiles: component.fileConfig.maxFiles || 5,
|
||||
newAcceptType: "", // 새 파일 타입 추가용
|
||||
linkedTable: component.fileConfig.linkedTable || "", // 연결 테이블
|
||||
linkedField: component.fileConfig.linkedField || "", // 연결 필드
|
||||
});
|
||||
|
||||
const [localValues, setLocalValues] = useState({
|
||||
multiple: component.fileConfig.multiple ?? true,
|
||||
showPreview: component.fileConfig.showPreview ?? true,
|
||||
showProgress: component.fileConfig.showProgress ?? true,
|
||||
autoLink: component.fileConfig.autoLink ?? false, // 자동 연결
|
||||
});
|
||||
|
||||
const [acceptTypes, setAcceptTypes] = useState<string[]>(component.fileConfig.accept || []);
|
||||
|
|
@ -44,12 +54,15 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
maxSize: component.fileConfig.maxSize || 10,
|
||||
maxFiles: component.fileConfig.maxFiles || 5,
|
||||
newAcceptType: "",
|
||||
linkedTable: component.fileConfig.linkedTable || "",
|
||||
linkedField: component.fileConfig.linkedField || "",
|
||||
});
|
||||
|
||||
setLocalValues({
|
||||
multiple: component.fileConfig.multiple ?? true,
|
||||
showPreview: component.fileConfig.showPreview ?? true,
|
||||
showProgress: component.fileConfig.showProgress ?? true,
|
||||
autoLink: component.fileConfig.autoLink ?? false,
|
||||
});
|
||||
|
||||
setAcceptTypes(component.fileConfig.accept || []);
|
||||
|
|
@ -332,6 +345,114 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
<Label htmlFor="showProgress">업로드 진행률 표시</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 연결 설정 섹션 */}
|
||||
<div className="mt-6 rounded-lg border bg-blue-50 p-4">
|
||||
<h4 className="mb-3 text-sm font-semibold text-blue-900">📎 테이블 연결 설정</h4>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="autoLink"
|
||||
checked={localValues.autoLink}
|
||||
onCheckedChange={(checked) => {
|
||||
setLocalValues((prev) => ({ ...prev, autoLink: checked as boolean }));
|
||||
onUpdateProperty(component.id, "fileConfig.autoLink", checked);
|
||||
|
||||
// 자동 연결이 활성화되면 현재 화면의 테이블 정보를 자동 설정
|
||||
if (checked && currentTableName && currentTable) {
|
||||
// 기본키 추정 로직 (일반적인 패턴들)
|
||||
const primaryKeyGuesses = [
|
||||
`${currentTableName}_id`, // table_name + "_id"
|
||||
`${currentTableName.replace(/_/g, "")}_id`, // undercore 제거 + "_id"
|
||||
currentTableName.endsWith("_info") || currentTableName.endsWith("_mng")
|
||||
? currentTableName.replace(/_(info|mng)$/, "_code") // _info, _mng -> _code
|
||||
: `${currentTableName}_code`, // table_name + "_code"
|
||||
"id", // 단순 "id"
|
||||
"objid", // "objid"
|
||||
];
|
||||
|
||||
// 실제 테이블 컬럼에서 기본키로 추정되는 컬럼 찾기
|
||||
let detectedPrimaryKey = "";
|
||||
for (const guess of primaryKeyGuesses) {
|
||||
const foundColumn = currentTable.columns.find(
|
||||
(col) => col.columnName.toLowerCase() === guess.toLowerCase(),
|
||||
);
|
||||
if (foundColumn) {
|
||||
detectedPrimaryKey = foundColumn.columnName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 찾지 못한 경우 첫 번째 컬럼을 기본키로 사용
|
||||
if (!detectedPrimaryKey && currentTable.columns.length > 0) {
|
||||
detectedPrimaryKey = currentTable.columns[0].columnName;
|
||||
}
|
||||
|
||||
console.log("🔗 자동 테이블 연결 설정:", {
|
||||
tableName: currentTableName,
|
||||
detectedPrimaryKey,
|
||||
availableColumns: currentTable.columns.map((c) => c.columnName),
|
||||
});
|
||||
|
||||
// 자동으로 테이블명과 기본키 설정
|
||||
setLocalInputs((prev) => ({
|
||||
...prev,
|
||||
linkedTable: currentTableName,
|
||||
linkedField: detectedPrimaryKey,
|
||||
}));
|
||||
|
||||
onUpdateProperty(component.id, "fileConfig.linkedTable", currentTableName);
|
||||
onUpdateProperty(component.id, "fileConfig.linkedField", detectedPrimaryKey);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="autoLink">다른 테이블과 자동 연결</Label>
|
||||
</div>
|
||||
|
||||
{localValues.autoLink && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="linkedTable">연결할 테이블명</Label>
|
||||
<Input
|
||||
id="linkedTable"
|
||||
value={localInputs.linkedTable}
|
||||
readOnly
|
||||
className="bg-gray-50 text-gray-700"
|
||||
placeholder="자동으로 설정됩니다"
|
||||
/>
|
||||
<div className="text-xs text-green-600">✅ 현재 화면의 테이블이 자동으로 설정됩니다</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="linkedField">연결할 필드명 (기본키)</Label>
|
||||
<Input
|
||||
id="linkedField"
|
||||
value={localInputs.linkedField}
|
||||
readOnly
|
||||
className="bg-gray-50 text-gray-700"
|
||||
placeholder="자동으로 감지됩니다"
|
||||
/>
|
||||
<div className="text-xs text-green-600">✅ 테이블의 기본키가 자동으로 감지됩니다</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded bg-blue-100 p-2 text-xs text-blue-600">
|
||||
💡 이 설정을 활성화하면 파일이 현재 레코드와 자동으로 연결됩니다.
|
||||
<br />
|
||||
{currentTableName && localInputs.linkedField ? (
|
||||
<>
|
||||
예: {currentTableName} 테이블의 {localInputs.linkedField}가 "값123"인 레코드에 파일을 업로드하면
|
||||
<br />
|
||||
target_objid가 "{currentTableName}:값123"로 설정됩니다.
|
||||
</>
|
||||
) : (
|
||||
<>테이블과 기본키 정보가 자동으로 설정되면 연결 예시가 표시됩니다.</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ import { useAuth } from "@/hooks/useAuth";
|
|||
|
||||
interface FileUploadProps {
|
||||
component: FileComponent;
|
||||
onUpdateComponent: (updates: Partial<FileComponent>) => void;
|
||||
onUpdateComponent?: (updates: Partial<FileComponent>) => void;
|
||||
onFileUpload?: (files: AttachedFileInfo[]) => void; // 파일 업로드 완료 콜백
|
||||
userInfo?: any; // 사용자 정보 (선택적)
|
||||
}
|
||||
|
||||
|
|
@ -16,7 +17,7 @@ interface FileUploadProps {
|
|||
* 독립적인 File 컴포넌트
|
||||
* attach_file_info 테이블 기반 파일 관리
|
||||
*/
|
||||
export function FileUpload({ component, onUpdateComponent, userInfo }: FileUploadProps) {
|
||||
export function FileUpload({ component, onUpdateComponent, onFileUpload, userInfo }: FileUploadProps) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [uploadQueue, setUploadQueue] = useState<File[]>([]);
|
||||
const [localUploadedFiles, setLocalUploadedFiles] = useState<AttachedFileInfo[]>(component.uploadedFiles || []);
|
||||
|
|
@ -110,16 +111,43 @@ export function FileUpload({ component, onUpdateComponent, userInfo }: FileUploa
|
|||
// 파일 확장자 검증
|
||||
const isFileTypeAllowed = (file: File): boolean => {
|
||||
const fileName = file.name.toLowerCase();
|
||||
return fileConfig.accept.some((accept) => {
|
||||
if (accept.startsWith(".")) {
|
||||
return fileName.endsWith(accept);
|
||||
|
||||
console.log("🔍 파일 타입 검증:", {
|
||||
fileName: file.name,
|
||||
fileType: file.type,
|
||||
acceptRules: fileConfig.accept,
|
||||
});
|
||||
|
||||
const result = fileConfig.accept.some((accept) => {
|
||||
// 모든 파일 허용 (와일드카드)
|
||||
if (accept === "*/*" || accept === "*") {
|
||||
console.log("✅ 와일드카드 매칭:", accept);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 확장자 기반 검증 (.jpg, .png 등)
|
||||
if (accept.startsWith(".")) {
|
||||
const matches = fileName.endsWith(accept.toLowerCase());
|
||||
console.log(`${matches ? "✅" : "❌"} 확장자 검증:`, accept, "→", matches);
|
||||
return matches;
|
||||
}
|
||||
|
||||
// MIME 타입 기반 검증 (image/*, text/* 등)
|
||||
if (accept.includes("/*")) {
|
||||
const type = accept.split("/")[0];
|
||||
return file.type.startsWith(type);
|
||||
const matches = file.type.startsWith(type);
|
||||
console.log(`${matches ? "✅" : "❌"} MIME 타입 검증:`, accept, "→", matches);
|
||||
return matches;
|
||||
}
|
||||
return file.type === accept;
|
||||
|
||||
// 정확한 MIME 타입 매칭 (image/jpeg, application/pdf 등)
|
||||
const matches = file.type === accept;
|
||||
console.log(`${matches ? "✅" : "❌"} 정확한 MIME 매칭:`, accept, "→", matches);
|
||||
return matches;
|
||||
});
|
||||
|
||||
console.log(`🎯 최종 검증 결과:`, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
// 파일 선택 핸들러
|
||||
|
|
@ -184,9 +212,18 @@ export function FileUpload({ component, onUpdateComponent, userInfo }: FileUploa
|
|||
setUploadQueue((prev) => [...prev, ...validFiles]);
|
||||
|
||||
if (fileConfig.autoUpload) {
|
||||
console.log("🚀 자동 업로드 시작");
|
||||
console.log("🚀 자동 업로드 시작:", {
|
||||
autoUpload: fileConfig.autoUpload,
|
||||
filesCount: validFiles.length,
|
||||
fileNames: validFiles.map((f) => f.name),
|
||||
});
|
||||
// 자동 업로드 실행
|
||||
validFiles.forEach(uploadFile);
|
||||
} else {
|
||||
console.log("⏸️ 자동 업로드 비활성화:", {
|
||||
autoUpload: fileConfig.autoUpload,
|
||||
filesCount: validFiles.length,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.log("❌ 업로드할 유효한 파일이 없음");
|
||||
|
|
@ -270,6 +307,45 @@ export function FileUpload({ component, onUpdateComponent, userInfo }: FileUploa
|
|||
formData.append("writer", "system");
|
||||
}
|
||||
|
||||
// 프론트엔드 파일 타입 설정을 백엔드로 전송
|
||||
if (fileConfig.accept && fileConfig.accept.length > 0) {
|
||||
const acceptString = fileConfig.accept.join(",");
|
||||
formData.append("accept", acceptString);
|
||||
console.log("✅ 허용 파일 타입 추가:", acceptString);
|
||||
}
|
||||
|
||||
// 자동 연결 정보 추가
|
||||
if (fileConfig.autoLink) {
|
||||
formData.append("autoLink", "true");
|
||||
console.log("✅ 자동 연결 활성화: true");
|
||||
|
||||
if (fileConfig.linkedTable) {
|
||||
formData.append("linkedTable", fileConfig.linkedTable);
|
||||
console.log("✅ 연결 테이블 추가:", fileConfig.linkedTable);
|
||||
}
|
||||
|
||||
if (fileConfig.linkedField) {
|
||||
formData.append("linkedField", fileConfig.linkedField);
|
||||
console.log("✅ 연결 필드 추가:", fileConfig.linkedField);
|
||||
}
|
||||
|
||||
if (fileConfig.recordId) {
|
||||
formData.append("recordId", fileConfig.recordId);
|
||||
console.log("✅ 레코드 ID 추가:", fileConfig.recordId);
|
||||
}
|
||||
|
||||
// 가상 파일 컬럼 정보 추가
|
||||
if (fileConfig.isVirtualFileColumn) {
|
||||
formData.append("isVirtualFileColumn", "true");
|
||||
console.log("✅ 가상 파일 컬럼 활성화: true");
|
||||
|
||||
if (fileConfig.columnName) {
|
||||
formData.append("columnName", fileConfig.columnName);
|
||||
console.log("✅ 컬럼명 추가:", fileConfig.columnName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FormData 내용 디버깅
|
||||
console.log("📋 FormData 내용 확인:");
|
||||
for (const [key, value] of formData.entries()) {
|
||||
|
|
@ -357,9 +433,17 @@ export function FileUpload({ component, onUpdateComponent, userInfo }: FileUploa
|
|||
// 로컬 상태 업데이트
|
||||
setLocalUploadedFiles(updatedFiles);
|
||||
|
||||
onUpdateComponent({
|
||||
uploadedFiles: updatedFiles,
|
||||
});
|
||||
// 컴포넌트 업데이트 (옵셔널)
|
||||
if (onUpdateComponent) {
|
||||
onUpdateComponent({
|
||||
uploadedFiles: updatedFiles,
|
||||
});
|
||||
}
|
||||
|
||||
// 파일 업로드 완료 콜백 호출 (모달에서 사용)
|
||||
if (onFileUpload) {
|
||||
onFileUpload(updatedFiles);
|
||||
}
|
||||
|
||||
// 업로드 큐에서 제거
|
||||
setUploadQueue((prev) => prev.filter((f) => f !== file));
|
||||
|
|
|
|||
|
|
@ -144,3 +144,29 @@ export const uploadFilesAndCreateData = async (files: FileList) => {
|
|||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 테이블 연결된 파일 조회
|
||||
*/
|
||||
export const getLinkedFiles = async (
|
||||
tableName: string,
|
||||
recordId: string,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
files: any[];
|
||||
totalCount: number;
|
||||
targetObjid: string;
|
||||
}> => {
|
||||
try {
|
||||
console.log("📎 연결된 파일 조회:", { tableName, recordId });
|
||||
|
||||
const response = await apiClient.get(`/files/linked/${tableName}/${recordId}`);
|
||||
|
||||
console.log("✅ 연결된 파일 조회 성공:", response.data);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("연결된 파일 조회 오류:", error);
|
||||
throw new Error("연결된 파일 조회에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -226,6 +226,16 @@ export interface FileComponent extends BaseComponent {
|
|||
targetObjid?: string; // 연결된 주 객체 ID (예: 계약 ID, 프로젝트 ID)
|
||||
parentTargetObjid?: string; // 부모 객체 ID (계층 구조용)
|
||||
|
||||
// 테이블 연결 설정 (새로 추가)
|
||||
linkedTable?: string; // 연결할 테이블명 (예: company_mng, user_info)
|
||||
linkedField?: string; // 연결할 필드명 (예: emp_id, user_id)
|
||||
autoLink?: boolean; // 자동 연결 여부 (현재 레코드와 자동 연결)
|
||||
recordId?: string; // 연결할 레코드 ID
|
||||
|
||||
// 가상 파일 컬럼 전용 설정
|
||||
columnName?: string; // 가상 파일 컬럼명 (tableName:recordId:columnName 형태로 target_objid 생성)
|
||||
isVirtualFileColumn?: boolean; // 가상 파일 컬럼 여부
|
||||
|
||||
// UI 설정
|
||||
showPreview: boolean; // 미리보기 표시 여부
|
||||
showProgress: boolean; // 업로드 진행률 표시
|
||||
|
|
@ -283,7 +293,7 @@ export interface WidgetComponent extends BaseComponent {
|
|||
// 데이터 테이블 컬럼 설정
|
||||
export interface DataTableColumn {
|
||||
id: string;
|
||||
columnName: string; // 실제 DB 컬럼명
|
||||
columnName: string; // 실제 DB 컬럼명 (가상 컬럼의 경우 고유 식별자)
|
||||
label: string; // 화면에 표시될 라벨
|
||||
widgetType: WebType; // 컬럼의 데이터 타입
|
||||
gridColumns: number; // 그리드에서 차지할 컬럼 수 (1-12)
|
||||
|
|
@ -292,6 +302,15 @@ export interface DataTableColumn {
|
|||
sortable: boolean; // 정렬 가능 여부
|
||||
searchable: boolean; // 검색 대상 여부
|
||||
webTypeConfig?: WebTypeConfig; // 컬럼별 상세 설정
|
||||
|
||||
// 가상 파일 컬럼 관련 속성
|
||||
isVirtualFileColumn?: boolean; // 가상 파일 컬럼인지 여부
|
||||
fileColumnConfig?: {
|
||||
docType?: string; // 문서 타입 (CONTRACT, DRAWING, PHOTO 등)
|
||||
docTypeName?: string; // 문서 타입 표시명
|
||||
maxFiles?: number; // 최대 파일 개수
|
||||
accept?: string[]; // 허용 파일 타입
|
||||
};
|
||||
}
|
||||
|
||||
// 데이터 테이블 필터 설정
|
||||
|
|
|
|||
Loading…
Reference in New Issue