Merge branch 'dev' of http://39.117.244.52:3000/kjs/ERP-node into dataflowMng

This commit is contained in:
hyeonsu
2025-09-08 16:47:58 +09:00
22 changed files with 2910 additions and 618 deletions

View File

@@ -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,23 +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);
// savedFileName이 없는 경우 파일 경로에서 추출 시도
const serverFilename = fileInfo.savedFileName || (fileInfo.path ? fileInfo.path.split("/").pop() : null);
// serverFilename이 없는 경우 처리
if (!fileInfo.serverFilename) {
if (!serverFilename) {
// _file 속성이 있는 경우 로컬 파일로 다운로드
if ((fileInfo as any)._file) {
console.log("📁 로컬 파일 다운로드 시도:", fileInfo.name);
try {
const file = (fileInfo as any)._file;
@@ -1307,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;
@@ -1337,8 +1509,8 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
toast.loading(`${fileInfo.name} 다운로드 중...`);
await downloadFile({
fileId: fileInfo.id,
serverFilename: fileInfo.serverFilename,
fileId: fileInfo.objid || fileInfo.id,
serverFilename: serverFilename,
originalName: fileInfo.name,
});
@@ -1350,56 +1522,53 @@ 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;
// 파일 타입 컬럼은 파일 아이콘으로 표시 (컬럼별 파일 관리)
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;
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>
) : (
<Folder className="h-4 w-4 text-gray-400" />
)}
</Button>
</div>
);
}
// file_path 컬럼은 강제로 파일 타입으로 처리 (임시 해결책)
const isFileColumn = column.widgetType === "file" || column.columnName === "file_path";
switch (column.widgetType) {
case "file":
console.log("🗂️ 파일 타입 컬럼 처리 중:", value);
if (value) {
try {
// JSON 문자열이면 파싱
const fileData = typeof value === "string" ? JSON.parse(value) : value;
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>
</div>
);
}
} catch (error) {
console.warn("파일 데이터 파싱 오류:", error);
}
}
return <span className="text-sm text-gray-400 italic"> </span>;
case "date":
if (value) {
try {
@@ -1557,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">
@@ -1586,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">
@@ -1840,14 +2061,11 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
<span>: {fileInfo.type || "알 수 없음"}</span>
</div>
<div className="flex items-center gap-4">
<span>: {fileInfo.extension || "N/A"}</span>
<span>: {new Date(fileInfo.uploadedAt).toLocaleString("ko-KR")}</span>
{fileInfo.regdate && (
<span>: {new Date(fileInfo.regdate).toLocaleString("ko-KR")}</span>
)}
{fileInfo.writer && <span>: {fileInfo.writer}</span>}
</div>
{fileInfo.lastModified && (
<div>
<span>: {new Date(fileInfo.lastModified).toLocaleString("ko-KR")}</span>
</div>
)}
</div>
</div>
@@ -1896,11 +2114,6 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
{" "}
{(currentFileData.totalSize / 1024 / 1024).toFixed(2)} MB
</div>
<div className="col-span-2">
<span className="font-medium"> :</span>
{" "}
{new Date(currentFileData.lastModified).toLocaleString("ko-KR")}
</div>
</div>
</div>
)}
@@ -1983,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={{
@@ -2006,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>
);
};

View File

@@ -19,6 +19,7 @@ import {
ComponentData,
WidgetComponent,
DataTableComponent,
FileComponent,
TextTypeConfig,
NumberTypeConfig,
DateTypeConfig,
@@ -32,6 +33,7 @@ import {
ButtonTypeConfig,
} from "@/types/screen";
import { InteractiveDataTable } from "./InteractiveDataTable";
import { FileUpload } from "./widgets/FileUpload";
import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
import { useParams } from "next/navigation";
import { screenApi } from "@/lib/api/screen";
@@ -56,7 +58,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
hideLabel = false,
screenInfo,
}) => {
const { userName } = useAuth(); // 현재 로그인한 사용자명 가져오기
const { userName, user } = useAuth(); // 현재 로그인한 사용자명과 사용자 정보 가져오기
const [localFormData, setLocalFormData] = useState<Record<string, any>>({});
const [dateValues, setDateValues] = useState<Record<string, Date | undefined>>({});
@@ -1499,6 +1501,86 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
}
};
// 파일 첨부 컴포넌트 처리
if (component.type === "file") {
const fileComponent = component as FileComponent;
console.log("🎯 File 컴포넌트 렌더링:", {
componentId: fileComponent.id,
currentUploadedFiles: fileComponent.uploadedFiles?.length || 0,
hasOnFormDataChange: !!onFormDataChange,
userInfo: user ? { userId: user.userId, companyCode: user.companyCode } : "no user"
});
const handleFileUpdate = useCallback(async (updates: Partial<FileComponent>) => {
// 실제 화면에서는 파일 업데이트를 처리
console.log("📎 InteractiveScreenViewer - 파일 컴포넌트 업데이트:", {
updates,
hasUploadedFiles: !!updates.uploadedFiles,
uploadedFilesCount: updates.uploadedFiles?.length || 0,
hasOnFormDataChange: !!onFormDataChange
});
if (updates.uploadedFiles && onFormDataChange) {
const fieldName = fileComponent.columnName || fileComponent.id;
// attach_file_info 테이블 구조에 맞는 데이터 생성
const fileInfoForDB = updates.uploadedFiles.map(file => ({
objid: file.objid.replace('temp_', ''), // temp_ 제거
target_objid: "",
saved_file_name: file.savedFileName,
real_file_name: file.realFileName,
doc_type: file.docType,
doc_type_name: file.docTypeName,
file_size: file.fileSize,
file_ext: file.fileExt,
file_path: file.filePath,
writer: file.writer,
regdate: file.regdate,
status: file.status,
parent_target_objid: "",
company_code: file.companyCode
}));
console.log("💾 attach_file_info 형태로 변환된 데이터:", fileInfoForDB);
// FormData에는 파일 연결 정보만 저장 (간단한 형태)
const formDataValue = {
fileCount: updates.uploadedFiles.length,
docType: fileComponent.fileConfig.docType,
files: updates.uploadedFiles.map(file => ({
objid: file.objid,
realFileName: file.realFileName,
fileSize: file.fileSize,
status: file.status
}))
};
console.log("📝 FormData 저장값:", { fieldName, formDataValue });
onFormDataChange(fieldName, formDataValue);
// TODO: 실제 API 연동 시 attach_file_info 테이블에 저장
// await saveFilesToDatabase(fileInfoForDB);
} else {
console.warn("⚠️ 파일 업데이트 실패:", {
hasUploadedFiles: !!updates.uploadedFiles,
hasOnFormDataChange: !!onFormDataChange
});
}
}, [fileComponent, onFormDataChange]);
return (
<div className="h-full w-full">
<FileUpload
component={fileComponent}
onUpdateComponent={handleFileUpdate}
userInfo={user} // 사용자 정보를 프롭으로 전달
/>
</div>
);
}
// 그룹 컴포넌트 처리
if (component.type === "group") {
const children = allComponents.filter((comp) => comp.parentId === component.id);

View File

@@ -5,6 +5,7 @@ import {
ComponentData,
WebType,
WidgetComponent,
FileComponent,
DateTypeConfig,
NumberTypeConfig,
SelectTypeConfig,
@@ -24,6 +25,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { FileUpload } from "./widgets/FileUpload";
import { useAuth } from "@/hooks/useAuth";
// import { Checkbox } from "@/components/ui/checkbox";
// import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
@@ -850,6 +853,7 @@ export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
children,
onGroupToggle,
}) => {
const { user } = useAuth(); // 사용자 정보 가져오기
const { type, label, tableName, size, style } = component;
// 위젯 컴포넌트인 경우에만 columnName과 widgetType 접근
@@ -1364,6 +1368,22 @@ export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
<div className="pointer-events-none flex-1">{renderWidget(component)}</div>
</div>
)}
{type === "file" && (
<div className="flex h-full flex-col">
{/* 파일 첨부 컴포넌트 */}
<div className="pointer-events-none flex-1">
<FileUpload
component={component as FileComponent}
onUpdateComponent={() => {
// 미리보기에서는 업데이트 비활성화
console.log("파일 컴포넌트 업데이트 (미리보기에서는 비활성화)");
}}
userInfo={user} // 사용자 정보 전달
/>
</div>
</div>
)}
</div>
</div>
);

View File

@@ -991,6 +991,57 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
...templateComp.style,
},
} as ComponentData;
} else if (templateComp.type === "file") {
// 파일 첨부 컴포넌트 생성
const gridColumns = 6; // 기본값: 6컬럼
const calculatedSize =
currentGridInfo && layout.gridSettings?.snapToGrid
? (() => {
const newWidth = calculateWidthFromColumns(
gridColumns,
currentGridInfo,
layout.gridSettings as GridUtilSettings,
);
return {
width: newWidth,
height: templateComp.size.height,
};
})()
: templateComp.size;
return {
id: componentId,
type: "file",
label: templateComp.label,
position: finalPosition,
size: calculatedSize,
gridColumns,
fileConfig: {
accept: ["image/*", ".pdf", ".doc", ".docx", ".xls", ".xlsx"],
multiple: true,
maxSize: 10, // 10MB
maxFiles: 5,
docType: "DOCUMENT",
docTypeName: "일반 문서",
targetObjid: selectedScreen?.screenId || "",
showPreview: true,
showProgress: true,
dragDropText: "파일을 드래그하여 업로드하세요",
uploadButtonText: "파일 선택",
autoUpload: true,
chunkedUpload: false,
},
uploadedFiles: [],
style: {
labelDisplay: true,
labelFontSize: "14px",
labelColor: "#374151",
labelFontWeight: "600",
labelMarginBottom: "8px",
...templateComp.style,
},
} as ComponentData;
} else {
// 위젯 컴포넌트
const widgetType = templateComp.widgetType || "text";
@@ -2959,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>

View File

@@ -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>
);
})()}

View File

@@ -6,6 +6,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import {
ComponentData,
WidgetComponent,
FileComponent,
WebTypeConfig,
DateTypeConfig,
NumberTypeConfig,
@@ -18,6 +19,7 @@ import {
CodeTypeConfig,
EntityTypeConfig,
ButtonTypeConfig,
TableInfo,
} from "@/types/screen";
import { DateTypeConfigPanel } from "./webtype-configs/DateTypeConfigPanel";
import { NumberTypeConfigPanel } from "./webtype-configs/NumberTypeConfigPanel";
@@ -30,13 +32,21 @@ import { FileTypeConfigPanel } from "./webtype-configs/FileTypeConfigPanel";
import { CodeTypeConfigPanel } from "./webtype-configs/CodeTypeConfigPanel";
import { EntityTypeConfigPanel } from "./webtype-configs/EntityTypeConfigPanel";
import { ButtonConfigPanel } from "./ButtonConfigPanel";
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",
@@ -214,13 +224,13 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({ select
);
}
if (selectedComponent.type !== "widget") {
if (selectedComponent.type !== "widget" && selectedComponent.type !== "file") {
return (
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
<Settings className="mb-4 h-12 w-12 text-gray-400" />
<h3 className="mb-2 text-lg font-medium text-gray-900"> </h3>
<h3 className="mb-2 text-lg font-medium text-gray-900"> </h3>
<p className="text-sm text-gray-500">
.
.
<br />
: {selectedComponent.type}
</p>
@@ -228,6 +238,38 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({ select
);
}
// 파일 컴포넌트인 경우 FileComponentConfigPanel 렌더링
if (selectedComponent.type === "file") {
const fileComponent = selectedComponent as FileComponent;
return (
<div className="flex h-full flex-col">
{/* 헤더 */}
<div className="border-b border-gray-200 p-4">
<div className="flex items-center space-x-2">
<Settings className="h-4 w-4 text-gray-600" />
<h3 className="font-medium text-gray-900"> </h3>
</div>
<div className="mt-2 flex items-center space-x-2">
<span className="text-sm text-gray-600">:</span>
<span className="rounded bg-purple-100 px-2 py-1 text-xs font-medium text-purple-800"> </span>
</div>
<div className="mt-1 text-xs text-gray-500"> : {fileComponent.fileConfig.docTypeName}</div>
</div>
{/* 파일 컴포넌트 설정 영역 */}
<div className="flex-1 overflow-y-auto p-4">
<FileComponentConfigPanel
component={fileComponent}
onUpdateProperty={onUpdateProperty}
currentTable={currentTable}
currentTableName={currentTableName}
/>
</div>
</div>
);
}
const widget = selectedComponent as WidgetComponent;
return (

View File

@@ -0,0 +1,459 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
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, 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,
currentTable,
currentTableName,
}) => {
// 로컬 상태
const [localInputs, setLocalInputs] = useState({
docType: component.fileConfig.docType || "DOCUMENT",
docTypeName: component.fileConfig.docTypeName || "일반 문서",
dragDropText: component.fileConfig.dragDropText || "파일을 드래그하거나 클릭하여 업로드하세요",
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 || []);
// 컴포넌트 변경 시 로컬 상태 동기화
useEffect(() => {
setLocalInputs({
docType: component.fileConfig.docType || "DOCUMENT",
docTypeName: component.fileConfig.docTypeName || "일반 문서",
dragDropText: component.fileConfig.dragDropText || "파일을 드래그하거나 클릭하여 업로드하세요",
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 || []);
}, [component.fileConfig]);
// 미리 정의된 문서 타입들
const docTypeOptions = [
{ value: "CONTRACT", label: "계약서" },
{ value: "DRAWING", label: "도면" },
{ value: "PHOTO", label: "사진" },
{ value: "DOCUMENT", label: "일반 문서" },
{ value: "REPORT", label: "보고서" },
{ value: "SPECIFICATION", label: "사양서" },
{ value: "MANUAL", label: "매뉴얼" },
{ value: "CERTIFICATE", label: "인증서" },
{ value: "OTHER", label: "기타" },
];
// 미리 정의된 파일 타입들
const commonFileTypes = [
{ value: "image/*", label: "모든 이미지 파일" },
{ value: ".pdf", label: "PDF 파일" },
{ value: ".doc,.docx", label: "Word 문서" },
{ value: ".xls,.xlsx", label: "Excel 파일" },
{ value: ".ppt,.pptx", label: "PowerPoint 파일" },
{ value: ".txt", label: "텍스트 파일" },
{ value: ".zip,.rar", label: "압축 파일" },
{ value: ".dwg,.dxf", label: "CAD 파일" },
];
// 파일 타입 추가
const addAcceptType = useCallback(() => {
const newType = localInputs.newAcceptType.trim();
if (newType && !acceptTypes.includes(newType)) {
const newAcceptTypes = [...acceptTypes, newType];
setAcceptTypes(newAcceptTypes);
onUpdateProperty(component.id, "fileConfig.accept", newAcceptTypes);
setLocalInputs((prev) => ({ ...prev, newAcceptType: "" }));
}
}, [localInputs.newAcceptType, acceptTypes, component.id, onUpdateProperty]);
// 파일 타입 제거
const removeAcceptType = useCallback(
(typeToRemove: string) => {
const newAcceptTypes = acceptTypes.filter((type) => type !== typeToRemove);
setAcceptTypes(newAcceptTypes);
onUpdateProperty(component.id, "fileConfig.accept", newAcceptTypes);
},
[acceptTypes, component.id, onUpdateProperty],
);
// 미리 정의된 파일 타입 추가
const addCommonFileType = useCallback(
(fileType: string) => {
const types = fileType.split(",");
const newAcceptTypes = [...acceptTypes];
types.forEach((type) => {
if (!newAcceptTypes.includes(type.trim())) {
newAcceptTypes.push(type.trim());
}
});
setAcceptTypes(newAcceptTypes);
onUpdateProperty(component.id, "fileConfig.accept", newAcceptTypes);
},
[acceptTypes, component.id, onUpdateProperty],
);
return (
<div className="space-y-6">
{/* 문서 분류 설정 */}
<div className="space-y-4">
<h4 className="text-sm font-medium text-gray-900"> </h4>
<div className="space-y-2">
<Label htmlFor="docType"> </Label>
<Select
value={localInputs.docType}
onValueChange={(value) => {
setLocalInputs((prev) => ({ ...prev, docType: value }));
onUpdateProperty(component.id, "fileConfig.docType", value);
// 문서 타입 변경 시 자동으로 타입명도 업데이트
const selectedOption = docTypeOptions.find((opt) => opt.value === value);
if (selectedOption) {
setLocalInputs((prev) => ({ ...prev, docTypeName: selectedOption.label }));
onUpdateProperty(component.id, "fileConfig.docTypeName", selectedOption.label);
}
}}
>
<SelectTrigger>
<SelectValue placeholder="문서 타입을 선택하세요" />
</SelectTrigger>
<SelectContent>
{docTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="docTypeName"> </Label>
<Input
id="docTypeName"
value={localInputs.docTypeName}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, docTypeName: newValue }));
onUpdateProperty(component.id, "fileConfig.docTypeName", newValue);
}}
placeholder="문서 타입 표시명"
/>
</div>
</div>
{/* 파일 업로드 제한 설정 */}
<div className="space-y-4">
<h4 className="text-sm font-medium text-gray-900"> </h4>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="maxSize"> (MB)</Label>
<Input
id="maxSize"
type="number"
min="1"
max="100"
value={localInputs.maxSize}
onChange={(e) => {
const newValue = parseInt(e.target.value) || 10;
setLocalInputs((prev) => ({ ...prev, maxSize: newValue }));
onUpdateProperty(component.id, "fileConfig.maxSize", newValue);
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxFiles"> </Label>
<Input
id="maxFiles"
type="number"
min="1"
max="20"
value={localInputs.maxFiles}
onChange={(e) => {
const newValue = parseInt(e.target.value) || 5;
setLocalInputs((prev) => ({ ...prev, maxFiles: newValue }));
onUpdateProperty(component.id, "fileConfig.maxFiles", newValue);
}}
/>
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="multiple"
checked={localValues.multiple}
onCheckedChange={(checked) => {
setLocalValues((prev) => ({ ...prev, multiple: checked as boolean }));
onUpdateProperty(component.id, "fileConfig.multiple", checked);
}}
/>
<Label htmlFor="multiple"> </Label>
</div>
</div>
{/* 허용 파일 타입 설정 */}
<div className="space-y-4">
<h4 className="text-sm font-medium text-gray-900"> </h4>
{/* 미리 정의된 파일 타입 버튼들 */}
<div className="space-y-2">
<Label> </Label>
<div className="flex flex-wrap gap-2">
{commonFileTypes.map((fileType) => (
<Button
key={fileType.value}
variant="outline"
size="sm"
onClick={() => addCommonFileType(fileType.value)}
className="text-xs"
>
<Plus className="mr-1 h-3 w-3" />
{fileType.label}
</Button>
))}
</div>
</div>
{/* 현재 설정된 파일 타입들 */}
<div className="space-y-2">
<Label> </Label>
<div className="flex flex-wrap gap-2">
{acceptTypes.map((type, index) => (
<Badge key={index} variant="secondary" className="flex items-center space-x-1">
<span>{type}</span>
<Button
variant="ghost"
size="sm"
onClick={() => removeAcceptType(type)}
className="h-4 w-4 p-0 hover:bg-transparent"
>
<X className="h-3 w-3" />
</Button>
</Badge>
))}
{acceptTypes.length === 0 && <span className="text-sm text-gray-500"> </span>}
</div>
</div>
{/* 사용자 정의 파일 타입 추가 */}
<div className="space-y-2">
<Label htmlFor="newAcceptType"> </Label>
<div className="flex space-x-2">
<Input
id="newAcceptType"
value={localInputs.newAcceptType}
onChange={(e) => {
setLocalInputs((prev) => ({ ...prev, newAcceptType: e.target.value }));
}}
placeholder="예: .dwg, image/*, .custom"
onKeyPress={(e) => {
if (e.key === "Enter") {
addAcceptType();
}
}}
/>
<Button onClick={addAcceptType} disabled={!localInputs.newAcceptType.trim()} size="sm">
</Button>
</div>
</div>
</div>
{/* UI 설정 */}
<div className="space-y-4">
<h4 className="text-sm font-medium text-gray-900">UI </h4>
<div className="space-y-2">
<Label htmlFor="dragDropText"> </Label>
<Textarea
id="dragDropText"
value={localInputs.dragDropText}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, dragDropText: newValue }));
onUpdateProperty(component.id, "fileConfig.dragDropText", newValue);
}}
placeholder="파일을 드래그하거나 클릭하여 업로드하세요"
rows={2}
/>
</div>
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="showPreview"
checked={localValues.showPreview}
onCheckedChange={(checked) => {
setLocalValues((prev) => ({ ...prev, showPreview: checked as boolean }));
onUpdateProperty(component.id, "fileConfig.showPreview", checked);
}}
/>
<Label htmlFor="showPreview"> </Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showProgress"
checked={localValues.showProgress}
onCheckedChange={(checked) => {
setLocalValues((prev) => ({ ...prev, showProgress: checked as boolean }));
onUpdateProperty(component.id, "fileConfig.showProgress", checked);
}}
/>
<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>
);
};

View File

@@ -22,6 +22,7 @@ import {
ExternalLink,
MousePointer,
Settings,
Upload,
} from "lucide-react";
// 템플릿 컴포넌트 타입 정의
@@ -29,11 +30,11 @@ export interface TemplateComponent {
id: string;
name: string;
description: string;
category: "table" | "button" | "form" | "layout" | "chart" | "status";
category: "table" | "button" | "form" | "layout" | "chart" | "status" | "file";
icon: React.ReactNode;
defaultSize: { width: number; height: number };
components: Array<{
type: "widget" | "container";
type: "widget" | "container" | "datatable" | "file";
widgetType?: string;
label: string;
placeholder?: string;
@@ -98,6 +99,30 @@ const templateComponents: TemplateComponent[] = [
},
],
},
// 파일 첨부 템플릿
{
id: "file-upload",
name: "파일 첨부",
description: "파일 업로드, 미리보기, 다운로드가 가능한 파일 첨부 컴포넌트",
category: "file",
icon: <Upload className="h-4 w-4" />,
defaultSize: { width: 600, height: 300 },
components: [
{
type: "file",
label: "파일 첨부",
position: { x: 0, y: 0 },
size: { width: 600, height: 300 },
style: {
border: "1px solid #e5e7eb",
borderRadius: "8px",
backgroundColor: "#ffffff",
padding: "16px",
},
},
],
},
];
interface TemplatesPanelProps {
@@ -111,6 +136,8 @@ export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) =
const categories = [
{ id: "all", name: "전체", icon: <Grid3x3 className="h-4 w-4" /> },
{ id: "table", name: "테이블", icon: <Table className="h-4 w-4" /> },
{ id: "button", name: "버튼", icon: <MousePointer className="h-4 w-4" /> },
{ id: "file", name: "파일", icon: <Upload className="h-4 w-4" /> },
];
const filteredTemplates = templateComponents.filter((template) => {

View File

@@ -0,0 +1,692 @@
import React, { useState, useCallback, useRef, useEffect } from "react";
import { Upload, X, File, Image, Eye, Download, AlertCircle, CheckCircle, Loader2 } from "lucide-react";
import { FileComponent, AttachedFileInfo } from "@/types/screen";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
interface FileUploadProps {
component: FileComponent;
onUpdateComponent?: (updates: Partial<FileComponent>) => void;
onFileUpload?: (files: AttachedFileInfo[]) => void; // 파일 업로드 완료 콜백
userInfo?: any; // 사용자 정보 (선택적)
}
/**
* 독립적인 File 컴포넌트
* attach_file_info 테이블 기반 파일 관리
*/
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 || []);
const fileInputRef = useRef<HTMLInputElement>(null);
const { fileConfig } = component;
const { user: authUser, isLoading, isLoggedIn } = useAuth(); // 인증 상태도 함께 가져오기
// props로 받은 userInfo를 우선 사용, 없으면 useAuth에서 가져온 user 사용
const user = userInfo || authUser;
// 초기화 시점의 사용자 정보를 저장 (타이밍 문제 해결)
const [initialUser, setInitialUser] = useState(user);
// 🎯 최신 사용자 정보를 추적하는 ref (useCallback 내부에서 접근 가능)
const userRef = useRef(user);
// 사용자 정보 디버깅
useEffect(() => {
console.log("👤 File 컴포넌트 인증 상태 및 사용자 정보:", {
isLoading,
isLoggedIn,
hasUser: !!user,
user: user,
userId: user?.userId,
company_code: user?.company_code,
companyCode: user?.companyCode,
userType: typeof user,
userKeys: user ? Object.keys(user) : "no user",
userValues: user ? Object.entries(user) : "no user",
});
// 사용자 정보가 유효하면 initialUser와 userRef 업데이트
if (user && user.userId) {
setInitialUser(user);
userRef.current = user; // 🎯 ref에도 최신 정보 저장
console.log("✅ 초기 사용자 정보 업데이트:", { userId: user.userId, companyCode: user.companyCode });
}
// 회사 관련 필드들 확인
if (user) {
console.log("🔍 회사 관련 필드 검색:", {
company_code: user.company_code,
companyCode: user.companyCode,
company: user.company,
deptCode: user.deptCode,
partnerCd: user.partnerCd,
// 모든 필드에서 company 관련된 것들 찾기
allFields: Object.keys(user).filter(
(key) =>
key.toLowerCase().includes("company") ||
key.toLowerCase().includes("corp") ||
key.toLowerCase().includes("code"),
),
});
} else {
console.warn("⚠️ 사용자 정보가 없습니다. 인증 상태 확인 필요");
}
}, [user, isLoading, isLoggedIn]);
// 컴포넌트 props가 변경될 때 로컬 상태 동기화
useEffect(() => {
console.log("🔄 File 컴포넌트 props 변경:", {
propsUploadedFiles: component.uploadedFiles?.length || 0,
localUploadedFiles: localUploadedFiles.length,
});
setLocalUploadedFiles(component.uploadedFiles || []);
}, [component.uploadedFiles]);
// 실제 사용할 uploadedFiles는 로컬 상태
const uploadedFiles = localUploadedFiles;
// 파일 크기 포맷팅
const formatFileSize = (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];
};
// 파일 타입 아이콘 결정
const getFileIcon = (fileExt: string) => {
const ext = fileExt.toLowerCase();
if (["jpg", "jpeg", "png", "gif", "webp"].includes(ext)) {
return <Image className="h-5 w-5 text-blue-500" />;
}
return <File className="h-5 w-5 text-gray-500" />;
};
// 파일 확장자 검증
const isFileTypeAllowed = (file: File): boolean => {
const fileName = file.name.toLowerCase();
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];
const matches = file.type.startsWith(type);
console.log(`${matches ? "✅" : "❌"} MIME 타입 검증:`, accept, "→", matches);
return matches;
}
// 정확한 MIME 타입 매칭 (image/jpeg, application/pdf 등)
const matches = file.type === accept;
console.log(`${matches ? "✅" : "❌"} 정확한 MIME 매칭:`, accept, "→", matches);
return matches;
});
console.log(`🎯 최종 검증 결과:`, result);
return result;
};
// 파일 선택 핸들러
const handleFileSelect = useCallback(
(files: FileList | null) => {
console.log("📁 파일 선택됨:", files ? Array.from(files).map((f) => f.name) : "없음");
if (!files) return;
const fileArray = Array.from(files);
const validFiles: File[] = [];
const errors: string[] = [];
console.log("🔍 파일 검증 시작:", {
totalFiles: fileArray.length,
currentUploadedCount: uploadedFiles.length,
maxFiles: fileConfig.maxFiles,
maxSize: fileConfig.maxSize,
allowedTypes: fileConfig.accept,
});
// 파일 검증
fileArray.forEach((file) => {
console.log(`📄 파일 검증: ${file.name} (${file.size} bytes, ${file.type})`);
// 파일 타입 검증
if (!isFileTypeAllowed(file)) {
errors.push(`${file.name}: 허용되지 않는 파일 타입입니다.`);
console.log(`❌ 파일 타입 거부: ${file.name}`);
return;
}
// 파일 크기 검증
if (file.size > fileConfig.maxSize * 1024 * 1024) {
errors.push(`${file.name}: 파일 크기가 ${fileConfig.maxSize}MB를 초과합니다.`);
console.log(`❌ 파일 크기 초과: ${file.name} (${file.size} > ${fileConfig.maxSize * 1024 * 1024})`);
return;
}
// 최대 파일 수 검증
if (uploadedFiles.length + validFiles.length >= fileConfig.maxFiles) {
errors.push(`최대 ${fileConfig.maxFiles}개까지만 업로드할 수 있습니다.`);
console.log(`❌ 최대 파일 수 초과`);
return;
}
validFiles.push(file);
console.log(`✅ 파일 검증 통과: ${file.name}`);
});
// 에러가 있으면 알림
if (errors.length > 0) {
console.error("💥 파일 업로드 오류:", errors);
// TODO: Toast 알림 표시
}
// 유효한 파일들을 업로드 큐에 추가
if (validFiles.length > 0) {
console.log(
"✅ 유효한 파일들 업로드 큐에 추가:",
validFiles.map((f) => f.name),
);
setUploadQueue((prev) => [...prev, ...validFiles]);
if (fileConfig.autoUpload) {
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("❌ 업로드할 유효한 파일이 없음");
}
},
[fileConfig, uploadedFiles.length],
);
// 파일 업로드 함수 (실시간 상태 조회로 타이밍 문제 해결)
const uploadFile = useCallback(
async (file: File) => {
console.log("📤 파일 업로드 시작:", file.name);
const formData = new FormData();
formData.append("files", file);
formData.append("docType", fileConfig.docType);
formData.append("docTypeName", fileConfig.docTypeName);
// 🎯 최신 사용자 정보 참조 (ref를 통해 실시간 값 접근)
const currentUser = userRef.current;
// 실시간 사용자 정보 디버깅
console.log("🔍 FileUpload - uploadFile ref를 통한 실시간 상태:", {
hasCurrentUser: !!currentUser,
currentUser: currentUser
? {
userId: currentUser.userId,
companyCode: currentUser.companyCode,
company_code: currentUser.company_code,
}
: null,
// 기존 상태와 비교
originalUser: user,
originalInitialUser: initialUser,
refExists: !!userRef.current,
});
// 사용자 정보가 로드되지 않은 경우 잠시 대기
if (isLoading) {
console.log("⏳ 사용자 정보 로딩 중... 업로드 대기");
setTimeout(() => uploadFile(file), 500); // 500ms 후 재시도
return;
}
// 사용자 정보가 없는 경우 - 무한루프 방지로 재시도 제한
if (!user && isLoggedIn) {
console.warn("⚠️ 로그인은 되어 있지만 사용자 정보가 없음. DEFAULT로 진행");
// 무한루프 방지: 재시도하지 않고 DEFAULT로 진행
// setTimeout(() => uploadFile(file), 1000); // 1초 후 재시도
// return;
}
// 사용자 정보 추가 (실시간 currentUser 사용으로 타이밍 문제 해결)
const effectiveUser = currentUser || user || initialUser;
const companyCode = effectiveUser?.companyCode || effectiveUser?.company_code || effectiveUser?.deptCode;
if (companyCode) {
// "*"는 실제 회사코드이므로 그대로 사용
formData.append("companyCode", companyCode);
console.log("✅ 회사코드 추가:", companyCode);
} else {
console.warn("⚠️ 회사코드가 없음, DEFAULT 사용. 사용자 정보:", {
user: user,
initialUser: initialUser,
effectiveUser: effectiveUser,
companyCode: effectiveUser?.companyCode,
company_code: effectiveUser?.company_code,
deptCode: effectiveUser?.deptCode,
isLoading,
isLoggedIn,
allUserKeys: effectiveUser ? Object.keys(effectiveUser) : "no user",
});
formData.append("companyCode", "DEFAULT");
}
if (effectiveUser?.userId) {
formData.append("writer", effectiveUser.userId);
console.log("✅ 작성자 추가:", effectiveUser.userId);
} else {
console.warn("⚠️ 사용자ID가 없음, system 사용");
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()) {
console.log(` ${key}:`, value);
}
try {
// 업로드 중 상태 표시를 위한 임시 파일 정보 생성
const tempFileInfo: AttachedFileInfo = {
objid: `temp_${Date.now()}`,
savedFileName: "",
realFileName: file.name,
fileSize: file.size,
fileExt: file.name.split(".").pop() || "",
filePath: "",
docType: fileConfig.docType,
docTypeName: fileConfig.docTypeName,
targetObjid: "",
companyCode: "",
writer: "",
regdate: new Date().toISOString(),
status: "UPLOADING",
uploadProgress: 0,
isUploading: true,
};
console.log("📋 임시 파일 정보 생성:", tempFileInfo);
const newUploadedFiles = [...uploadedFiles, tempFileInfo];
console.log("📊 업데이트 전 파일 목록:", uploadedFiles.length, "개");
console.log("📊 업데이트 후 파일 목록:", newUploadedFiles.length, "개");
// 로컬 상태 즉시 업데이트
setLocalUploadedFiles(newUploadedFiles);
// 임시 파일 정보를 업로드된 파일 목록에 추가
console.log("🔄 onUpdateComponent 호출 중...");
onUpdateComponent({
uploadedFiles: newUploadedFiles,
});
console.log("✅ onUpdateComponent 호출 완료");
console.log("🚀 API 호출 시작 - /files/upload");
// 실제 API 호출 (apiClient 사용으로 자동 JWT 토큰 추가)
// FormData 사용 시 Content-Type을 삭제하여 boundary가 자동 설정되도록 함
const response = await apiClient.post("/files/upload", formData, {
headers: {
"Content-Type": undefined, // axios가 자동으로 multipart/form-data를 설정하도록
},
});
const result = response.data;
console.log("📡 API 응답 성공:", result);
if (!result.success || !result.files || result.files.length === 0) {
throw new Error(result.message || "파일 업로드 실패");
}
// API 응답에서 실제 파일 정보 받아오기
const uploadedFileInfo = result.files[0]; // 현재는 하나씩 업로드
const successFileInfo: AttachedFileInfo = {
objid: uploadedFileInfo.objid,
savedFileName: uploadedFileInfo.savedFileName,
realFileName: uploadedFileInfo.realFileName,
fileSize: uploadedFileInfo.fileSize,
fileExt: uploadedFileInfo.fileExt,
filePath: uploadedFileInfo.filePath,
docType: uploadedFileInfo.docType,
docTypeName: uploadedFileInfo.docTypeName,
targetObjid: uploadedFileInfo.targetObjid,
parentTargetObjid: uploadedFileInfo.parentTargetObjid,
companyCode: uploadedFileInfo.companyCode,
writer: uploadedFileInfo.writer,
regdate: uploadedFileInfo.regdate,
status: uploadedFileInfo.status,
uploadProgress: 100,
isUploading: false,
};
console.log("✅ 실제 파일 업로드 완료 (attach_file_info 저장됨):", successFileInfo);
const updatedFiles = uploadedFiles.map((f) => (f.objid === tempFileInfo.objid ? successFileInfo : f));
// 로컬 상태 업데이트
setLocalUploadedFiles(updatedFiles);
// 컴포넌트 업데이트 (옵셔널)
if (onUpdateComponent) {
onUpdateComponent({
uploadedFiles: updatedFiles,
});
}
// 파일 업로드 완료 콜백 호출 (모달에서 사용)
if (onFileUpload) {
onFileUpload(updatedFiles);
}
// 업로드 큐에서 제거
setUploadQueue((prev) => prev.filter((f) => f !== file));
} catch (error) {
console.error("❌ 파일 업로드 실패:", {
error,
errorMessage: error instanceof Error ? error.message : "알 수 없는 오류",
errorStack: error instanceof Error ? error.stack : undefined,
user: user ? { userId: user.userId, companyCode: user.companyCode, hasUser: true } : "no user",
authState: { isLoading, isLoggedIn },
});
// API 응답 에러인 경우 상세 정보 출력
if ((error as any)?.response) {
console.error("📡 API 응답 에러:", {
status: (error as any).response.status,
statusText: (error as any).response.statusText,
data: (error as any).response.data,
});
}
// 에러 상태로 업데이트
const errorFiles = uploadedFiles.map((f) =>
f.objid === `temp_${file.name}`
? { ...f, hasError: true, errorMessage: "업로드 실패", isUploading: false }
: f,
);
// 로컬 상태 업데이트
setLocalUploadedFiles(errorFiles);
onUpdateComponent({
uploadedFiles: errorFiles,
});
}
},
[fileConfig, uploadedFiles, onUpdateComponent], // ref는 의존성에 포함하지 않음
);
// 파일 삭제
const deleteFile = async (fileInfo: AttachedFileInfo) => {
console.log("🗑️ 파일 삭제:", fileInfo.realFileName);
try {
// 실제 API 호출 (논리적 삭제)
const response = await fetch(`/api/files/${fileInfo.objid}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
writer: fileInfo.writer || "current_user",
}),
});
if (!response.ok) {
throw new Error(`파일 삭제 실패: ${response.status}`);
}
const result = await response.json();
console.log("📡 파일 삭제 API 응답:", result);
if (!result.success) {
throw new Error(result.message || "파일 삭제 실패");
}
const filteredFiles = uploadedFiles.filter((f) => f.objid !== fileInfo.objid);
// 로컬 상태 업데이트
setLocalUploadedFiles(filteredFiles);
onUpdateComponent({
uploadedFiles: filteredFiles,
});
console.log("✅ 파일 삭제 완료 (attach_file_info.status = DELETED)");
} catch (error) {
console.error("파일 삭제 실패:", error);
}
};
// 드래그 앤 드롭 핸들러
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
handleFileSelect(e.dataTransfer.files);
},
[handleFileSelect],
);
// 파일 입력 클릭
const handleFileInputClick = () => {
fileInputRef.current?.click();
};
// 파일 미리보기
const previewFile = (fileInfo: AttachedFileInfo) => {
const isImage = ["jpg", "jpeg", "png", "gif", "webp"].includes(fileInfo.fileExt.toLowerCase());
if (isImage) {
// TODO: 이미지 미리보기 모달 열기
console.log("이미지 미리보기:", fileInfo);
} else {
// TODO: 파일 다운로드
console.log("파일 다운로드:", fileInfo);
}
};
return (
<div className="w-full space-y-4">
{/* 드래그 앤 드롭 영역 */}
<div
className={`rounded-lg border-2 border-dashed p-6 text-center transition-colors ${
isDragOver ? "border-blue-500 bg-blue-50" : "border-gray-300 hover:border-gray-400"
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<Upload className="mx-auto mb-4 h-12 w-12 text-gray-400" />
<p className="mb-2 text-lg font-medium text-gray-900">
{fileConfig.dragDropText || "파일을 드래그하여 업로드하세요"}
</p>
<p className="mb-4 text-sm text-gray-500"> </p>
<Button variant="outline" onClick={handleFileInputClick} className="mb-4">
<Upload className="mr-2 h-4 w-4" />
{fileConfig.uploadButtonText || "파일 선택"}
</Button>
<div className="text-xs text-gray-500">
<p> : {fileConfig.accept.join(", ")}</p>
<p>
: {fileConfig.maxSize}MB | : {fileConfig.maxFiles}
</p>
</div>
<input
ref={fileInputRef}
type="file"
multiple={fileConfig.multiple}
accept={fileConfig.accept.join(",")}
onChange={(e) => handleFileSelect(e.target.files)}
className="hidden"
/>
</div>
{/* 업로드된 파일 목록 */}
{uploadedFiles.length > 0 && (
<div className="space-y-2">
<h4 className="font-medium text-gray-900">
({uploadedFiles.length}/{fileConfig.maxFiles})
</h4>
<div className="space-y-2">
{uploadedFiles.map((fileInfo) => (
<div key={fileInfo.objid} className="flex items-center justify-between rounded-lg border bg-gray-50 p-3">
<div className="flex flex-1 items-center space-x-3">
{getFileIcon(fileInfo.fileExt)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-gray-900">{fileInfo.realFileName}</p>
<div className="flex items-center space-x-2 text-xs text-gray-500">
<span>{formatFileSize(fileInfo.fileSize)}</span>
<span></span>
<span>{fileInfo.fileExt.toUpperCase()}</span>
{fileInfo.writer && (
<>
<span></span>
<span>{fileInfo.writer}</span>
</>
)}
</div>
{/* 업로드 진행률 */}
{fileInfo.isUploading && fileConfig.showProgress && (
<div className="mt-2 h-1 w-full rounded-full bg-gray-200">
<div
className="h-1 rounded-full bg-blue-600 transition-all duration-300"
style={{ width: `${fileInfo.uploadProgress || 0}%` }}
/>
</div>
)}
{/* 에러 메시지 */}
{fileInfo.hasError && (
<div className="mt-2 flex items-center space-x-2 rounded-md bg-red-50 p-2 text-sm text-red-700">
<AlertCircle className="h-4 w-4 flex-shrink-0" />
<span>{fileInfo.errorMessage}</span>
</div>
)}
</div>
</div>
<div className="flex items-center space-x-1">
{/* 상태 표시 */}
{fileInfo.isUploading && <Loader2 className="h-4 w-4 animate-spin text-blue-500" />}
{fileInfo.status === "ACTIVE" && <CheckCircle className="h-4 w-4 text-green-500" />}
{fileInfo.hasError && <AlertCircle className="h-4 w-4 text-red-500" />}
{/* 액션 버튼 */}
{!fileInfo.isUploading && !fileInfo.hasError && (
<>
{fileConfig.showPreview && (
<Button variant="ghost" size="sm" onClick={() => previewFile(fileInfo)} className="h-8 w-8 p-0">
<Eye className="h-4 w-4" />
</Button>
)}
<Button variant="ghost" size="sm" onClick={() => previewFile(fileInfo)} className="h-8 w-8 p-0">
<Download className="h-4 w-4" />
</Button>
</>
)}
<Button
variant="ghost"
size="sm"
onClick={() => deleteFile(fileInfo)}
className="h-8 w-8 p-0 text-red-500 hover:text-red-700"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</div>
)}
{/* 문서 타입 정보 */}
<div className="flex items-center space-x-2">
<Badge variant="outline">{fileConfig.docTypeName}</Badge>
</div>
</div>
);
}