파일 업로드,다운로드 기능
This commit is contained in:
parent
aa066a1ea9
commit
53a44b901d
|
|
@ -35,7 +35,7 @@
|
|||
"@types/jest": "^29.5.11",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@types/multer": "^1.4.13",
|
||||
"@types/node": "^20.10.5",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@
|
|||
"@types/jest": "^29.5.11",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@types/multer": "^1.4.13",
|
||||
"@types/node": "^20.10.5",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import tableManagementRoutes from "./routes/tableManagementRoutes";
|
|||
import screenManagementRoutes from "./routes/screenManagementRoutes";
|
||||
import commonCodeRoutes from "./routes/commonCodeRoutes";
|
||||
import dynamicFormRoutes from "./routes/dynamicFormRoutes";
|
||||
import fileRoutes from "./routes/fileRoutes";
|
||||
// import userRoutes from './routes/userRoutes';
|
||||
// import menuRoutes from './routes/menuRoutes';
|
||||
|
||||
|
|
@ -79,6 +80,7 @@ app.use("/api/table-management", tableManagementRoutes);
|
|||
app.use("/api/screen-management", screenManagementRoutes);
|
||||
app.use("/api/common-codes", commonCodeRoutes);
|
||||
app.use("/api/dynamic-form", dynamicFormRoutes);
|
||||
app.use("/api/files", fileRoutes);
|
||||
// app.use('/api/users', userRoutes);
|
||||
// app.use('/api/menus', menuRoutes);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,306 @@
|
|||
import express from "express";
|
||||
import multer from "multer";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 파일 저장 경로 설정
|
||||
const UPLOAD_PATH = path.join(process.cwd(), "uploads");
|
||||
|
||||
// uploads 디렉토리가 없으면 생성
|
||||
if (!fs.existsSync(UPLOAD_PATH)) {
|
||||
fs.mkdirSync(UPLOAD_PATH, { recursive: true });
|
||||
}
|
||||
|
||||
// Multer 설정 - 파일 업로드용
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
return cb(null, UPLOAD_PATH);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
// 파일명: timestamp_originalname
|
||||
const timestamp = Date.now();
|
||||
const originalName = Buffer.from(file.originalname, "latin1").toString(
|
||||
"utf8"
|
||||
);
|
||||
const ext = path.extname(originalName);
|
||||
const nameWithoutExt = path.basename(originalName, ext);
|
||||
const safeFileName = `${timestamp}_${nameWithoutExt}${ext}`;
|
||||
return cb(null, safeFileName);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: {
|
||||
fileSize: 50 * 1024 * 1024, // 50MB 제한
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
// 허용된 파일 타입 검사 (필요시 확장)
|
||||
const allowedTypes = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"text/plain",
|
||||
"text/csv",
|
||||
];
|
||||
|
||||
if (allowedTypes.includes(file.mimetype)) {
|
||||
return cb(null, true);
|
||||
} else {
|
||||
return cb(new Error(`허용되지 않는 파일 타입입니다: ${file.mimetype}`));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 모든 라우트에 인증 미들웨어 적용
|
||||
router.use(authenticateToken);
|
||||
|
||||
/**
|
||||
* 파일 업로드
|
||||
* POST /api/files/upload
|
||||
*/
|
||||
router.post(
|
||||
"/upload",
|
||||
upload.array("files", 10),
|
||||
async (req: AuthenticatedRequest, res): Promise<void> => {
|
||||
try {
|
||||
const files = req.files as Express.Multer.File[];
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "업로드할 파일이 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const fileInfos = files.map((file) => ({
|
||||
id: `file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
name: Buffer.from(file.originalname, "latin1").toString("utf8"),
|
||||
size: file.size,
|
||||
type: file.mimetype,
|
||||
extension: path.extname(file.originalname).toLowerCase().substring(1),
|
||||
uploadedAt: new Date().toISOString(),
|
||||
lastModified: new Date().toISOString(),
|
||||
serverPath: file.path,
|
||||
serverFilename: file.filename,
|
||||
}));
|
||||
|
||||
logger.info("파일 업로드 완료", {
|
||||
userId: req.user?.userId,
|
||||
fileCount: files.length,
|
||||
files: fileInfos.map((f) => ({ name: f.name, size: f.size })),
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `${files.length}개 파일이 성공적으로 업로드되었습니다.`,
|
||||
files: fileInfos,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("파일 업로드 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "파일 업로드 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 파일 다운로드
|
||||
* GET /api/files/download/:fileId
|
||||
*/
|
||||
router.get(
|
||||
"/download/:fileId",
|
||||
async (req: AuthenticatedRequest, res): Promise<void> => {
|
||||
try {
|
||||
const { fileId } = req.params;
|
||||
const { serverFilename, originalName } = req.query;
|
||||
|
||||
if (!serverFilename || !originalName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message:
|
||||
"파일 정보가 부족합니다. (serverFilename, originalName 필요)",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = path.join(UPLOAD_PATH, serverFilename as string);
|
||||
|
||||
// 파일 존재 확인
|
||||
if (!fs.existsSync(filePath)) {
|
||||
logger.warn("파일을 찾을 수 없음", {
|
||||
fileId,
|
||||
serverFilename,
|
||||
filePath,
|
||||
userId: req.user?.userId,
|
||||
});
|
||||
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "요청한 파일을 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 정보 확인
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
logger.info("파일 다운로드 요청", {
|
||||
fileId,
|
||||
originalName,
|
||||
serverFilename,
|
||||
fileSize: stats.size,
|
||||
userId: req.user?.userId,
|
||||
});
|
||||
|
||||
// 파일명 인코딩 (한글 파일명 지원)
|
||||
const encodedFilename = encodeURIComponent(originalName as string);
|
||||
|
||||
// 응답 헤더 설정
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename*=UTF-8''${encodedFilename}`
|
||||
);
|
||||
res.setHeader("Content-Type", "application/octet-stream");
|
||||
res.setHeader("Content-Length", stats.size);
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
|
||||
// 파일 스트림으로 전송
|
||||
const fileStream = fs.createReadStream(filePath);
|
||||
|
||||
fileStream.on("error", (error) => {
|
||||
logger.error("파일 스트림 오류:", error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "파일 전송 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
fileStream.pipe(res);
|
||||
} catch (error) {
|
||||
logger.error("파일 다운로드 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "파일 다운로드 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 파일 삭제
|
||||
* DELETE /api/files/:fileId
|
||||
*/
|
||||
router.delete(
|
||||
"/:fileId",
|
||||
async (req: AuthenticatedRequest, res): Promise<void> => {
|
||||
try {
|
||||
const { fileId } = req.params;
|
||||
const { serverFilename } = req.body;
|
||||
|
||||
if (!serverFilename) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "서버 파일명이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = path.join(UPLOAD_PATH, serverFilename);
|
||||
|
||||
// 파일 존재 확인
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
logger.info("파일 삭제 완료", {
|
||||
fileId,
|
||||
serverFilename,
|
||||
userId: req.user?.userId,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "파일이 성공적으로 삭제되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("파일 삭제 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "파일 삭제 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 파일 정보 조회
|
||||
* GET /api/files/info/:fileId
|
||||
*/
|
||||
router.get(
|
||||
"/info/:fileId",
|
||||
async (req: AuthenticatedRequest, res): Promise<void> => {
|
||||
try {
|
||||
const { fileId } = req.params;
|
||||
const { serverFilename } = req.query;
|
||||
|
||||
if (!serverFilename) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "서버 파일명이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = path.join(UPLOAD_PATH, serverFilename as string);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "파일을 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
fileId,
|
||||
serverFilename,
|
||||
size: stats.size,
|
||||
lastModified: stats.mtime.toISOString(),
|
||||
exists: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("파일 정보 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "파일 정보 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.7 MiB |
|
|
@ -18,11 +18,46 @@ import { Label } from "@/components/ui/label";
|
|||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Search, ChevronLeft, ChevronRight, RotateCcw, Database, Loader2, Plus, Edit, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Search,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
RotateCcw,
|
||||
Database,
|
||||
Loader2,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
File,
|
||||
Download,
|
||||
Eye,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { getCurrentUser, UserInfo } from "@/lib/api/client";
|
||||
import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { downloadFile } from "@/lib/api/file";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// 파일 데이터 타입 정의
|
||||
interface FileInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
extension: string;
|
||||
uploadedAt: string;
|
||||
lastModified: string;
|
||||
serverFilename?: string; // 서버에 저장된 파일명 (다운로드용)
|
||||
}
|
||||
|
||||
interface FileColumnData {
|
||||
files: FileInfo[];
|
||||
totalCount: number;
|
||||
totalSize: number;
|
||||
lastModified: string;
|
||||
}
|
||||
|
||||
interface InteractiveDataTableProps {
|
||||
component: DataTableComponent;
|
||||
|
|
@ -49,6 +84,9 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
const [editFormData, setEditFormData] = useState<Record<string, any>>({});
|
||||
const [editingRowData, setEditingRowData] = useState<Record<string, any> | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [showFileModal, setShowFileModal] = useState(false);
|
||||
const [currentFileData, setCurrentFileData] = useState<FileColumnData | null>(null);
|
||||
const [currentFileColumn, setCurrentFileColumn] = useState<DataTableColumn | null>(null);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
|
|
@ -1196,11 +1234,126 @@ 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이 없는 경우 처리
|
||||
if (!fileInfo.serverFilename) {
|
||||
// _file 속성이 있는 경우 로컬 파일로 다운로드
|
||||
if ((fileInfo as any)._file) {
|
||||
console.log("📁 로컬 파일 다운로드 시도:", fileInfo.name);
|
||||
try {
|
||||
const file = (fileInfo as any)._file;
|
||||
|
||||
// File 객체 유효성 검사
|
||||
if (!(file instanceof File) && !(file instanceof Blob)) {
|
||||
console.error("❌ 잘못된 파일 객체:", file);
|
||||
toast.error("파일 객체가 손상되었습니다. 파일을 다시 업로드해주세요.");
|
||||
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;
|
||||
link.download = fileInfo.name || file.name || "download";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success(`${fileInfo.name} 다운로드가 완료되었습니다.`);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error("❌ 로컬 파일 다운로드 오류:", error);
|
||||
toast.error("로컬 파일 다운로드에 실패했습니다. 파일을 다시 업로드해주세요.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
toast.error("이 파일은 이전 버전에서 저장된 파일입니다. 파일을 다시 업로드해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.loading(`${fileInfo.name} 다운로드 중...`);
|
||||
|
||||
await downloadFile({
|
||||
fileId: fileInfo.id,
|
||||
serverFilename: fileInfo.serverFilename,
|
||||
originalName: fileInfo.name,
|
||||
});
|
||||
|
||||
toast.success(`${fileInfo.name} 다운로드가 완료되었습니다.`);
|
||||
} catch (error) {
|
||||
console.error("파일 다운로드 오류:", error);
|
||||
toast.error(`${fileInfo.name} 다운로드에 실패했습니다.`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 셀 값 포맷팅
|
||||
const formatCellValue = (value: any, column: DataTableColumn) => {
|
||||
const formatCellValue = (value: any, column: DataTableColumn): React.ReactNode => {
|
||||
if (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,
|
||||
});
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
|
@ -1593,6 +1746,132 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 파일 정보 모달 */}
|
||||
<Dialog open={showFileModal} onOpenChange={setShowFileModal}>
|
||||
<DialogContent className="flex max-h-[80vh] max-w-2xl flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<File className="h-5 w-5" />
|
||||
파일 정보 - {currentFileColumn?.label || "파일"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{currentFileData?.totalCount === 1
|
||||
? "1개의 파일이 저장되어 있습니다."
|
||||
: `총 ${currentFileData?.totalCount}개의 파일이 저장되어 있습니다.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{currentFileData?.files && Array.isArray(currentFileData.files) && (
|
||||
<div className="space-y-3">
|
||||
{currentFileData.files.map((fileInfo: FileInfo, index: number) => {
|
||||
const isImage = fileInfo.type?.startsWith("image/");
|
||||
|
||||
return (
|
||||
<div key={index} className="rounded-lg border bg-gray-50 p-4 transition-colors hover:bg-gray-100">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* 파일 아이콘/미리보기 */}
|
||||
<div className="flex-shrink-0">
|
||||
{isImage ? (
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded border bg-white">
|
||||
<div className="text-xs font-medium text-green-600">IMG</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded border bg-white">
|
||||
<File className="h-8 w-8 text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 파일 정보 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="truncate font-medium text-gray-900" title={fileInfo.name}>
|
||||
{fileInfo.name}
|
||||
</h4>
|
||||
<div className="mt-1 space-y-1 text-sm text-gray-600">
|
||||
<div className="flex items-center gap-4">
|
||||
<span>크기: {(fileInfo.size / 1024 / 1024).toFixed(2)} MB</span>
|
||||
<span>타입: {fileInfo.type || "알 수 없음"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span>확장자: {fileInfo.extension || "N/A"}</span>
|
||||
<span>업로드: {new Date(fileInfo.uploadedAt).toLocaleString("ko-KR")}</span>
|
||||
</div>
|
||||
{fileInfo.lastModified && (
|
||||
<div>
|
||||
<span>수정: {new Date(fileInfo.lastModified).toLocaleString("ko-KR")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{isImage && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
// TODO: 이미지 미리보기 모달 구현
|
||||
alert("이미지 미리보기 기능은 준비 중입니다.");
|
||||
}}
|
||||
>
|
||||
<Eye className="mr-1 h-4 w-4" />
|
||||
미리보기
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => handleDownloadFile(fileInfo)}
|
||||
>
|
||||
<Download className="mr-1 h-4 w-4" />
|
||||
다운로드
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 요약 정보 */}
|
||||
{currentFileData && (
|
||||
<div className="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||
<h5 className="mb-2 font-medium text-blue-900">파일 요약</h5>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm text-blue-800">
|
||||
<div>
|
||||
<span className="font-medium">총 파일 수:</span>
|
||||
{" "}
|
||||
{currentFileData.totalCount}개
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">총 크기:</span>
|
||||
{" "}
|
||||
{(currentFileData.totalSize / 1024 / 1024).toFixed(2)} MB
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="font-medium">최종 수정:</span>
|
||||
{" "}
|
||||
{new Date(currentFileData.lastModified).toLocaleString("ko-KR")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowFileModal(false)}>
|
||||
<X className="mr-1 h-4 w-4" />
|
||||
닫기
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 데이터 삭제 확인 다이얼로그 */}
|
||||
<Dialog open={showDeleteDialog} onOpenChange={handleDeleteDialogClose}>
|
||||
<DialogContent>
|
||||
|
|
|
|||
|
|
@ -9,10 +9,12 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
import { CalendarIcon, File, Upload, X } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { ko } from "date-fns/locale";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { uploadFilesAndCreateData } from "@/lib/api/file";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ComponentData,
|
||||
WidgetComponent,
|
||||
|
|
@ -714,21 +716,48 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
case "file": {
|
||||
const widget = comp as WidgetComponent;
|
||||
const config = widget.webTypeConfig as FileTypeConfig | undefined;
|
||||
|
||||
// 현재 파일 값 가져오기
|
||||
const getCurrentValue = () => {
|
||||
const fieldName = widget.columnName || widget.id;
|
||||
return (externalFormData?.[fieldName] || localFormData[fieldName]) as any;
|
||||
};
|
||||
|
||||
const currentValue = getCurrentValue();
|
||||
|
||||
console.log("📁 InteractiveScreenViewer - File 위젯:", {
|
||||
componentId: widget.id,
|
||||
widgetType: widget.widgetType,
|
||||
config,
|
||||
currentValue,
|
||||
appliedSettings: {
|
||||
accept: config?.accept,
|
||||
multiple: config?.multiple,
|
||||
maxSize: config?.maxSize,
|
||||
preview: config?.preview,
|
||||
},
|
||||
});
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files) return;
|
||||
const fieldName = widget.columnName || widget.id;
|
||||
|
||||
// 파일 선택을 취소한 경우 (files가 null이거나 길이가 0)
|
||||
if (!files || files.length === 0) {
|
||||
console.log("📁 파일 선택 취소됨 - 기존 파일 유지");
|
||||
|
||||
// 현재 저장된 파일이 있는지 확인
|
||||
const currentStoredValue = externalFormData?.[fieldName] || localFormData[fieldName];
|
||||
if (currentStoredValue) {
|
||||
console.log("📁 기존 파일 있음 - 유지:", currentStoredValue);
|
||||
// 기존 파일이 있으면 그대로 유지 (아무것도 하지 않음)
|
||||
return;
|
||||
} else {
|
||||
console.log("📁 기존 파일 없음 - 빈 상태 유지");
|
||||
// 기존 파일이 없으면 빈 상태 유지
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 파일 크기 검증
|
||||
if (config?.maxSize) {
|
||||
|
|
@ -742,21 +771,161 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
}
|
||||
}
|
||||
|
||||
const file = config?.multiple ? files : files[0];
|
||||
updateFormData(fieldName, file);
|
||||
// 실제 서버로 파일 업로드
|
||||
try {
|
||||
toast.loading(`${files.length}개 파일 업로드 중...`);
|
||||
|
||||
const uploadResult = await uploadFilesAndCreateData(files);
|
||||
|
||||
if (uploadResult.success) {
|
||||
console.log("📁 업로드 완료된 파일 데이터:", uploadResult.data);
|
||||
|
||||
setLocalFormData(prev => ({ ...prev, [fieldName]: uploadResult.data }));
|
||||
|
||||
// 외부 폼 데이터 변경 콜백 호출
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange(fieldName, uploadResult.data);
|
||||
}
|
||||
|
||||
toast.success(uploadResult.message);
|
||||
} else {
|
||||
throw new Error("파일 업로드에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("파일 업로드 오류:", error);
|
||||
toast.error("파일 업로드에 실패했습니다.");
|
||||
|
||||
// 파일 입력 초기화
|
||||
e.target.value = "";
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const clearFile = () => {
|
||||
const fieldName = widget.columnName || widget.id;
|
||||
setLocalFormData(prev => ({ ...prev, [fieldName]: null }));
|
||||
|
||||
// 외부 폼 데이터 변경 콜백 호출
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange(fieldName, null);
|
||||
}
|
||||
|
||||
// 파일 input 초기화
|
||||
const fileInput = document.querySelector(`input[type="file"][data-field="${fieldName}"]`) as HTMLInputElement;
|
||||
if (fileInput) {
|
||||
fileInput.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const renderFilePreview = () => {
|
||||
if (!currentValue || !config?.preview) return null;
|
||||
|
||||
// 새로운 JSON 구조에서 파일 정보 추출
|
||||
const fileData = currentValue.files || [];
|
||||
if (fileData.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-2">
|
||||
{fileData.map((fileInfo: any, index: number) => {
|
||||
const isImage = fileInfo.type?.startsWith('image/');
|
||||
|
||||
return (
|
||||
<div key={index} className="flex items-center gap-2 rounded border bg-gray-50 p-2">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded bg-gray-200">
|
||||
{isImage ? (
|
||||
<div className="text-green-600 text-xs font-medium">IMG</div>
|
||||
) : (
|
||||
<File className="h-8 w-8 text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">{fileInfo.name}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{(fileInfo.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{fileInfo.type || '알 수 없는 형식'}</p>
|
||||
<p className="text-xs text-gray-400">업로드: {new Date(fileInfo.uploadedAt).toLocaleString('ko-KR')}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearFile}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const fieldName = widget.columnName || widget.id;
|
||||
|
||||
return applyStyles(
|
||||
<Input
|
||||
type="file"
|
||||
onChange={handleFileChange}
|
||||
disabled={readonly}
|
||||
required={required}
|
||||
multiple={config?.multiple}
|
||||
accept={config?.accept}
|
||||
className="w-full"
|
||||
style={{ height: "100%" }}
|
||||
/>,
|
||||
<div className="w-full space-y-2">
|
||||
{/* 파일 선택 영역 */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="file"
|
||||
data-field={fieldName}
|
||||
onChange={handleFileChange}
|
||||
disabled={readonly}
|
||||
required={required}
|
||||
multiple={config?.multiple}
|
||||
accept={config?.accept}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
|
||||
style={{ zIndex: 1 }}
|
||||
/>
|
||||
<div className={`
|
||||
flex items-center justify-center rounded-lg border-2 border-dashed p-4 text-center transition-colors
|
||||
${currentValue && currentValue.files && currentValue.files.length > 0 ? 'border-green-300 bg-green-50' : 'border-gray-300 bg-gray-50 hover:border-gray-400 hover:bg-gray-100'}
|
||||
${readonly ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}
|
||||
`}>
|
||||
<div className="space-y-2">
|
||||
{currentValue && currentValue.files && currentValue.files.length > 0 ? (
|
||||
<>
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-100">
|
||||
<svg className="h-5 w-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-green-900">
|
||||
{currentValue.totalCount === 1
|
||||
? '파일 선택됨'
|
||||
: `${currentValue.totalCount}개 파일 선택됨`}
|
||||
</p>
|
||||
<p className="text-xs text-green-700">
|
||||
총 {(currentValue.totalSize / 1024 / 1024).toFixed(2)}MB
|
||||
</p>
|
||||
<p className="text-xs text-green-700">클릭하여 다른 파일 선택</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="mx-auto h-8 w-8 text-gray-400" />
|
||||
<p className="text-sm text-gray-600">
|
||||
{config?.dragDrop ? '파일을 드래그하여 놓거나 클릭하여 선택' : '클릭하여 파일 선택'}
|
||||
</p>
|
||||
{(config?.accept || config?.maxSize) && (
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
{config.accept && <div>허용 형식: {config.accept}</div>}
|
||||
{config.maxSize && <div>최대 크기: {config.maxSize}MB</div>}
|
||||
{config.multiple && <div>다중 선택 가능</div>}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 파일 미리보기 */}
|
||||
{renderFilePreview()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import {
|
|||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
|
||||
interface RealtimePreviewProps {
|
||||
|
|
@ -60,7 +61,13 @@ interface RealtimePreviewProps {
|
|||
|
||||
// 웹 타입에 따른 위젯 렌더링
|
||||
const renderWidget = (component: ComponentData) => {
|
||||
const { widgetType, label, placeholder, required, readonly, columnName, style } = component;
|
||||
// 위젯 컴포넌트가 아닌 경우 빈 div 반환
|
||||
if (component.type !== "widget") {
|
||||
return <div className="text-xs text-gray-500">위젯이 아닙니다</div>;
|
||||
}
|
||||
|
||||
const widget = component as WidgetComponent;
|
||||
const { widgetType, label, placeholder, required, readonly, columnName, style } = widget;
|
||||
|
||||
// 디버깅: 실제 widgetType 값 확인
|
||||
console.log("RealtimePreview - widgetType:", widgetType, "columnName:", columnName);
|
||||
|
|
@ -82,7 +89,6 @@ const renderWidget = (component: ComponentData) => {
|
|||
case "text":
|
||||
case "email":
|
||||
case "tel": {
|
||||
const widget = component as WidgetComponent;
|
||||
const config = widget.webTypeConfig as TextTypeConfig | undefined;
|
||||
|
||||
// 입력 타입에 따른 처리
|
||||
|
|
@ -199,7 +205,6 @@ const renderWidget = (component: ComponentData) => {
|
|||
minLength: config?.minLength,
|
||||
maxLength: config?.maxLength,
|
||||
pattern: getPatternByFormat(config?.format || "none"),
|
||||
onInput: handleInputChange,
|
||||
onChange: () => {}, // 읽기 전용으로 처리
|
||||
readOnly: readonly || isAutoInput, // 자동입력인 경우 읽기 전용
|
||||
className: `w-full h-full ${borderClass} ${isAutoInput ? "bg-gray-50 text-gray-600" : ""}`,
|
||||
|
|
@ -215,7 +220,6 @@ const renderWidget = (component: ComponentData) => {
|
|||
|
||||
case "number":
|
||||
case "decimal": {
|
||||
const widget = component as WidgetComponent;
|
||||
const config = widget.webTypeConfig as NumberTypeConfig | undefined;
|
||||
|
||||
// 입력 타입에 따른 처리
|
||||
|
|
@ -355,7 +359,6 @@ const renderWidget = (component: ComponentData) => {
|
|||
|
||||
case "date":
|
||||
case "datetime": {
|
||||
const widget = component as WidgetComponent;
|
||||
const config = widget.webTypeConfig as DateTypeConfig | undefined;
|
||||
|
||||
// 입력 타입에 따른 처리
|
||||
|
|
@ -497,7 +500,6 @@ const renderWidget = (component: ComponentData) => {
|
|||
|
||||
case "select":
|
||||
case "dropdown": {
|
||||
const widget = component as WidgetComponent;
|
||||
const config = widget.webTypeConfig as SelectTypeConfig | undefined;
|
||||
|
||||
// 디버깅: 현재 설정값 확인
|
||||
|
|
@ -524,7 +526,7 @@ const renderWidget = (component: ComponentData) => {
|
|||
disabled={readonly}
|
||||
required={required}
|
||||
multiple={config?.multiple}
|
||||
value={config?.defaultValue || ""}
|
||||
value=""
|
||||
onChange={() => {}} // 읽기 전용으로 처리
|
||||
className={`w-full rounded-md px-3 py-2 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-100 ${
|
||||
hasCustomBorder ? "!border-0" : "border border-gray-300"
|
||||
|
|
@ -542,7 +544,6 @@ const renderWidget = (component: ComponentData) => {
|
|||
|
||||
case "textarea":
|
||||
case "text_area": {
|
||||
const widget = component as WidgetComponent;
|
||||
const config = widget.webTypeConfig as TextareaTypeConfig | undefined;
|
||||
|
||||
return (
|
||||
|
|
@ -556,8 +557,8 @@ const renderWidget = (component: ComponentData) => {
|
|||
onChange={() => {}} // 읽기 전용으로 처리
|
||||
readOnly
|
||||
style={{
|
||||
resize: config?.resizable === false ? "none" : "vertical",
|
||||
whiteSpace: config?.wordWrap === false ? "nowrap" : "normal",
|
||||
resize: config?.resize || "vertical",
|
||||
whiteSpace: config?.wrap === "off" ? "nowrap" : "normal",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
@ -565,22 +566,21 @@ const renderWidget = (component: ComponentData) => {
|
|||
|
||||
case "boolean":
|
||||
case "checkbox": {
|
||||
const widget = component as WidgetComponent;
|
||||
const config = widget.webTypeConfig as CheckboxTypeConfig | undefined;
|
||||
|
||||
const checkboxText = config?.checkboxText || label || columnName || "체크박스";
|
||||
const isLeftLabel = config?.labelPosition === "left";
|
||||
const checkboxText = config?.defaultChecked ? "체크됨" : label || columnName || "체크박스";
|
||||
const isLeftLabel = false; // labelPosition 속성이 없으므로 기본값 사용
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
{isLeftLabel && (
|
||||
<Label htmlFor={`checkbox-${component.id}`} className="text-sm">
|
||||
<Label htmlFor={`checkbox-${widget.id}`} className="text-sm">
|
||||
{checkboxText}
|
||||
</Label>
|
||||
)}
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`checkbox-${component.id}`}
|
||||
id={`checkbox-${widget.id}`}
|
||||
disabled={readonly}
|
||||
required={required}
|
||||
checked={config?.defaultChecked || false}
|
||||
|
|
@ -589,7 +589,7 @@ const renderWidget = (component: ComponentData) => {
|
|||
className="h-4 w-4"
|
||||
/>
|
||||
{!isLeftLabel && (
|
||||
<Label htmlFor={`checkbox-${component.id}`} className="text-sm">
|
||||
<Label htmlFor={`checkbox-${widget.id}`} className="text-sm">
|
||||
{checkboxText}
|
||||
</Label>
|
||||
)}
|
||||
|
|
@ -598,7 +598,6 @@ const renderWidget = (component: ComponentData) => {
|
|||
}
|
||||
|
||||
case "radio": {
|
||||
const widget = component as WidgetComponent;
|
||||
const config = widget.webTypeConfig as RadioTypeConfig | undefined;
|
||||
|
||||
const options = config?.options || [
|
||||
|
|
@ -606,12 +605,7 @@ const renderWidget = (component: ComponentData) => {
|
|||
{ label: "옵션 2", value: "option2" },
|
||||
];
|
||||
|
||||
const layoutClass =
|
||||
config?.layout === "horizontal"
|
||||
? "flex flex-row space-x-4"
|
||||
: config?.layout === "grid"
|
||||
? "grid grid-cols-2 gap-2"
|
||||
: "space-y-2";
|
||||
const layoutClass = config?.inline ? "flex flex-row space-x-4" : "space-y-2";
|
||||
|
||||
return (
|
||||
<div className={layoutClass}>
|
||||
|
|
@ -619,8 +613,8 @@ const renderWidget = (component: ComponentData) => {
|
|||
<div key={option.value} className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
id={`radio-${component.id}-${index}`}
|
||||
name={`radio-group-${component.id}`}
|
||||
id={`radio-${widget.id}-${index}`}
|
||||
name={`radio-group-${widget.id}`}
|
||||
value={option.value}
|
||||
disabled={readonly}
|
||||
required={required}
|
||||
|
|
@ -629,7 +623,7 @@ const renderWidget = (component: ComponentData) => {
|
|||
readOnly
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Label htmlFor={`radio-${component.id}-${index}`} className="text-sm">
|
||||
<Label htmlFor={`radio-${widget.id}-${index}`} className="text-sm">
|
||||
{option.label}
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -639,7 +633,6 @@ const renderWidget = (component: ComponentData) => {
|
|||
}
|
||||
|
||||
case "code": {
|
||||
const widget = component as WidgetComponent;
|
||||
const config = widget.webTypeConfig as CodeTypeConfig | undefined;
|
||||
|
||||
console.log("💻 코드 위젯 렌더링:", {
|
||||
|
|
@ -647,37 +640,32 @@ const renderWidget = (component: ComponentData) => {
|
|||
widgetType: widget.widgetType,
|
||||
config,
|
||||
appliedSettings: {
|
||||
language: config?.language,
|
||||
theme: config?.theme,
|
||||
fontSize: config?.fontSize,
|
||||
defaultValue: config?.defaultValue,
|
||||
readOnly: config?.readOnly,
|
||||
wordWrap: config?.wordWrap,
|
||||
placeholder: config?.placeholder,
|
||||
// CodeTypeConfig에는 language, theme, fontSize 등의 속성이 없음
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
{...commonProps}
|
||||
rows={config?.rows || 4}
|
||||
rows={4}
|
||||
className={`w-full font-mono text-sm ${borderClass}`}
|
||||
placeholder={config?.placeholder || "코드를 입력하세요..."}
|
||||
value={config?.defaultValue || ""}
|
||||
value=""
|
||||
onChange={() => {}} // 읽기 전용으로 처리
|
||||
readOnly
|
||||
style={{
|
||||
fontSize: `${config?.fontSize || 14}px`,
|
||||
backgroundColor: config?.theme === "dark" ? "#1e1e1e" : "#ffffff",
|
||||
color: config?.theme === "dark" ? "#ffffff" : "#000000",
|
||||
whiteSpace: config?.wordWrap === false ? "nowrap" : "normal",
|
||||
tabSize: config?.tabSize || 2,
|
||||
fontSize: "14px",
|
||||
backgroundColor: "#f8f9fa",
|
||||
color: "#000000",
|
||||
whiteSpace: "pre",
|
||||
tabSize: 2,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case "entity": {
|
||||
const widget = component as WidgetComponent;
|
||||
const config = widget.webTypeConfig as EntityTypeConfig | undefined;
|
||||
|
||||
console.log("🏢 엔티티 위젯 렌더링:", {
|
||||
|
|
@ -685,13 +673,10 @@ const renderWidget = (component: ComponentData) => {
|
|||
widgetType: widget.widgetType,
|
||||
config,
|
||||
appliedSettings: {
|
||||
entityName: config?.entityName,
|
||||
displayField: config?.displayField,
|
||||
valueField: config?.valueField,
|
||||
multiple: config?.multiple,
|
||||
referenceTable: config?.referenceTable,
|
||||
referenceColumn: config?.referenceColumn,
|
||||
searchable: config?.searchable,
|
||||
allowClear: config?.allowClear,
|
||||
maxSelections: config?.maxSelections,
|
||||
// EntityTypeConfig에는 entityName, displayField, valueField, multiple, maxSelections 속성이 없음
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -707,8 +692,7 @@ const renderWidget = (component: ComponentData) => {
|
|||
<select
|
||||
disabled={readonly}
|
||||
required={required}
|
||||
multiple={config?.multiple}
|
||||
value={config?.defaultValue || ""}
|
||||
value=""
|
||||
onChange={() => {}} // 읽기 전용으로 처리
|
||||
className={`w-full rounded-md px-3 py-2 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-100 ${
|
||||
hasCustomBorder ? "!border-0" : "border border-gray-300"
|
||||
|
|
@ -717,9 +701,7 @@ const renderWidget = (component: ComponentData) => {
|
|||
<option value="">{config?.placeholder || "엔티티를 선택하세요..."}</option>
|
||||
{defaultOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{config?.displayFormat
|
||||
? config.displayFormat.replace("{label}", option.label).replace("{value}", option.value)
|
||||
: option.label}
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
|
@ -727,7 +709,6 @@ const renderWidget = (component: ComponentData) => {
|
|||
}
|
||||
|
||||
case "file": {
|
||||
const widget = component as WidgetComponent;
|
||||
const config = widget.webTypeConfig as FileTypeConfig | undefined;
|
||||
|
||||
console.log("📁 파일 위젯 렌더링:", {
|
||||
|
|
@ -739,7 +720,7 @@ const renderWidget = (component: ComponentData) => {
|
|||
multiple: config?.multiple,
|
||||
maxSize: config?.maxSize,
|
||||
preview: config?.preview,
|
||||
allowedTypes: config?.allowedTypes,
|
||||
dragDrop: config?.dragDrop,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -760,20 +741,47 @@ const renderWidget = (component: ComponentData) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<input
|
||||
type="file"
|
||||
disabled={readonly}
|
||||
required={required}
|
||||
multiple={config?.multiple}
|
||||
accept={config?.accept}
|
||||
onChange={handleFileChange}
|
||||
className={`w-full rounded-md px-3 py-2 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-100 ${
|
||||
hasCustomBorder ? "!border-0" : "border border-gray-300"
|
||||
}`}
|
||||
/>
|
||||
{config?.maxSize && <div className="mt-1 text-xs text-gray-500">최대 파일 크기: {config.maxSize}MB</div>}
|
||||
{config?.accept && <div className="mt-1 text-xs text-gray-500">허용된 파일 형식: {config.accept}</div>}
|
||||
<div className="w-full space-y-2">
|
||||
{/* 파일 선택 영역 */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="file"
|
||||
disabled={readonly}
|
||||
required={required}
|
||||
multiple={config?.multiple}
|
||||
accept={config?.accept}
|
||||
onChange={handleFileChange}
|
||||
className="absolute inset-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed"
|
||||
style={{ zIndex: 1 }}
|
||||
/>
|
||||
<div
|
||||
className={`flex items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-3 text-center transition-colors hover:border-gray-400 hover:bg-gray-100 ${readonly ? "cursor-not-allowed opacity-50" : "cursor-pointer"} `}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<Upload className="mx-auto h-6 w-6 text-gray-400" />
|
||||
<p className="text-xs text-gray-600">
|
||||
{config?.dragDrop ? "파일을 드래그하여 놓거나 클릭하여 선택" : "클릭하여 파일 선택"}
|
||||
</p>
|
||||
{(config?.accept || config?.maxSize) && (
|
||||
<div className="space-y-1 text-xs text-gray-500">
|
||||
{config.accept && <div>허용 형식: {config.accept}</div>}
|
||||
{config.maxSize && <div>최대 크기: {config.maxSize}MB</div>}
|
||||
{config.multiple && <div>다중 선택 가능</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 미리보기 영역 (설계 모드에서는 간단한 표시) */}
|
||||
{config?.preview && (
|
||||
<div className="rounded border bg-gray-50 p-2 text-center">
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<File className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-xs text-gray-500">미리보기 영역</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -842,7 +850,11 @@ export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
|
|||
children,
|
||||
onGroupToggle,
|
||||
}) => {
|
||||
const { type, label, tableName, columnName, widgetType, size, style } = component;
|
||||
const { type, label, tableName, size, style } = component;
|
||||
|
||||
// 위젯 컴포넌트인 경우에만 columnName과 widgetType 접근
|
||||
const columnName = component.type === "widget" ? (component as WidgetComponent).columnName : undefined;
|
||||
const widgetType = component.type === "widget" ? (component as WidgetComponent).widgetType : undefined;
|
||||
|
||||
// 사용자가 테두리를 설정했는지 확인
|
||||
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
|
||||
|
|
@ -868,8 +880,12 @@ export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
|
|||
const shouldShowLabel = component.style?.labelDisplay !== false && (component.label || component.style?.labelText);
|
||||
const labelText = component.style?.labelText || component.label || "";
|
||||
|
||||
// 위젯 타입 확인을 위한 타입 가드
|
||||
const isWidget = component.type === "widget";
|
||||
const widgetComponent = isWidget ? (component as WidgetComponent) : null;
|
||||
|
||||
// 라벨 하단 여백 값 추출 (px 단위 숫자로 변환)
|
||||
const labelMarginBottomValue = parseInt(component.style?.labelMarginBottom || "4px", 10);
|
||||
const labelMarginBottomValue = parseInt(String(component.style?.labelMarginBottom || "4px"), 10);
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: component.style?.labelFontSize || "12px",
|
||||
|
|
@ -920,7 +936,9 @@ export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
|
|||
}}
|
||||
>
|
||||
{labelText}
|
||||
{component.required && <span style={{ color: "#f97316", marginLeft: "2px" }}>*</span>}
|
||||
{component.type === "widget" && (component as WidgetComponent).required && (
|
||||
<span style={{ color: "#f97316", marginLeft: "2px" }}>*</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -1135,7 +1153,9 @@ export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
|
|||
}}
|
||||
>
|
||||
{labelText}
|
||||
{component.required && <span style={{ color: "#f97316", marginLeft: "2px" }}>*</span>}
|
||||
{component.type === "widget" && (component as WidgetComponent).required && (
|
||||
<span style={{ color: "#f97316", marginLeft: "2px" }}>*</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -397,6 +397,21 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
|||
if (name.includes("url") || name.includes("link")) return "text";
|
||||
if (name.includes("password") || name.includes("pwd")) return "text";
|
||||
|
||||
// 파일 타입 추론
|
||||
if (
|
||||
name.includes("file") ||
|
||||
name.includes("attach") ||
|
||||
name.includes("upload") ||
|
||||
name.includes("document") ||
|
||||
name.includes("docs") ||
|
||||
name.includes("image") ||
|
||||
name.includes("photo") ||
|
||||
name.includes("picture") ||
|
||||
name.includes("media")
|
||||
) {
|
||||
return "file";
|
||||
}
|
||||
|
||||
// 텍스트 타입 (기본값)
|
||||
return "text";
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,146 @@
|
|||
import { apiClient } from "./client";
|
||||
|
||||
export interface FileInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
extension: string;
|
||||
uploadedAt: string;
|
||||
lastModified: string;
|
||||
serverPath?: string;
|
||||
serverFilename?: string;
|
||||
}
|
||||
|
||||
export interface FileUploadResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
files: FileInfo[];
|
||||
}
|
||||
|
||||
export interface FileDownloadParams {
|
||||
fileId: string;
|
||||
serverFilename: string;
|
||||
originalName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 업로드
|
||||
*/
|
||||
export const uploadFiles = async (files: FileList): Promise<FileUploadResponse> => {
|
||||
const formData = new FormData();
|
||||
|
||||
Array.from(files).forEach((file) => {
|
||||
formData.append("files", file);
|
||||
});
|
||||
|
||||
const response = await apiClient.post("/files/upload", formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* 파일 다운로드
|
||||
*/
|
||||
export const downloadFile = async (params: FileDownloadParams): Promise<void> => {
|
||||
try {
|
||||
console.log("📥 downloadFile 호출:", params);
|
||||
|
||||
const response = await apiClient.get(`/files/download/${params.fileId}`, {
|
||||
params: {
|
||||
serverFilename: params.serverFilename,
|
||||
originalName: params.originalName,
|
||||
},
|
||||
responseType: "blob", // 파일 다운로드를 위해 blob 타입으로 설정
|
||||
});
|
||||
|
||||
console.log("📥 다운로드 응답:", response);
|
||||
|
||||
// Blob URL 생성
|
||||
const blob = new Blob([response.data]);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
|
||||
// 다운로드 링크 생성 및 클릭
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = params.originalName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
// 정리
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error("파일 다운로드 오류:", error);
|
||||
throw new Error("파일 다운로드에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 파일 삭제
|
||||
*/
|
||||
export const deleteFile = async (fileId: string, serverFilename: string): Promise<void> => {
|
||||
const response = await apiClient.delete(`/files/${fileId}`, {
|
||||
data: { serverFilename },
|
||||
});
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "파일 삭제에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 파일 정보 조회
|
||||
*/
|
||||
export const getFileInfo = async (fileId: string, serverFilename: string) => {
|
||||
const response = await apiClient.get(`/files/info/${fileId}`, {
|
||||
params: { serverFilename },
|
||||
});
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* 파일 업로드 및 JSON 데이터 생성
|
||||
* InteractiveScreenViewer에서 사용할 통합 함수
|
||||
*/
|
||||
export const uploadFilesAndCreateData = async (files: FileList) => {
|
||||
try {
|
||||
// 1. 파일 업로드
|
||||
const uploadResponse = await uploadFiles(files);
|
||||
|
||||
if (!uploadResponse.success) {
|
||||
throw new Error(uploadResponse.message);
|
||||
}
|
||||
|
||||
// 2. JSON 데이터 구조 생성
|
||||
const fileData = {
|
||||
files: uploadResponse.files.map((file) => ({
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
extension: file.extension,
|
||||
uploadedAt: file.uploadedAt,
|
||||
lastModified: file.lastModified,
|
||||
serverFilename: file.serverFilename, // 다운로드에 필요
|
||||
})),
|
||||
totalCount: uploadResponse.files.length,
|
||||
totalSize: uploadResponse.files.reduce((sum, file) => sum + file.size, 0),
|
||||
lastModified: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: fileData,
|
||||
message: uploadResponse.message,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("파일 업로드 및 데이터 생성 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
Loading…
Reference in New Issue