Merge pull request '파일 업로드,다운로드 기능' (#17) from feature/screen-management into dev

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/17
This commit is contained in:
kjs 2025-09-05 12:04:42 +09:00
commit aea407bd22
10 changed files with 1028 additions and 91 deletions

View File

@ -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",

View File

@ -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",

View File

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

View File

@ -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

View File

@ -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>

View File

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

View File

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

View File

@ -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";
};

146
frontend/lib/api/file.ts Normal file
View File

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