diff --git a/backend-node/prisma/schema.prisma b/backend-node/prisma/schema.prisma index 1f68eda9..7859e626 100644 --- a/backend-node/prisma/schema.prisma +++ b/backend-node/prisma/schema.prisma @@ -93,6 +93,7 @@ model approval_kind { @@index([status]) } +/// This model contains an expression index which requires additional setup for migrations. Visit https://pris.ly/d/expression-indexes for more info. model approval_target { objid Decimal @default(0) @db.Decimal master_target_objid Decimal @default(0) @db.Decimal @@ -427,7 +428,7 @@ model column_labels { column_name String? @db.VarChar(100) column_label String? @db.VarChar(200) web_type String? @db.VarChar(50) - input_type String? @default("direct") @db.VarChar(20) // direct, auto + input_type String? @default("direct") @db.VarChar(20) detail_settings String? description String? display_order Int? @default(0) @@ -513,13 +514,11 @@ model company_code_sequence { } model company_mng { - company_code String @id(map: "pk_company_mng") @db.VarChar(32) - company_name String? @db.VarChar(64) - writer String? @db.VarChar(32) - regdate DateTime? @db.Timestamp(6) - status String? @db.VarChar(32) - - // 관계 정의 + company_code String @id(map: "pk_company_mng") @db.VarChar(32) + company_name String? @db.VarChar(64) + writer String? @db.VarChar(32) + regdate DateTime? @db.Timestamp(6) + status String? @db.VarChar(32) menus menu_info[] } @@ -1475,23 +1474,21 @@ model material_release { } model menu_info { - objid Decimal @id @default(0) @db.Decimal - menu_type Decimal? @db.Decimal - parent_obj_id Decimal? @db.Decimal - menu_name_kor String? @db.VarChar(64) - menu_name_eng String? @db.VarChar(64) - seq Decimal? @db.Decimal - menu_url String? @db.VarChar(256) - menu_desc String? @db.VarChar(1024) - writer String? @db.VarChar(32) - regdate DateTime? @db.Timestamp(6) - status String? @db.VarChar(32) - system_name String? @db.VarChar(32) - company_code String? @default("*") @db.VarChar(50) - lang_key String? @db.VarChar(100) - lang_key_desc String? @db.VarChar(100) - - // 관계 정의 + objid Decimal @id @default(0) @db.Decimal + menu_type Decimal? @db.Decimal + parent_obj_id Decimal? @db.Decimal + menu_name_kor String? @db.VarChar(64) + menu_name_eng String? @db.VarChar(64) + seq Decimal? @db.Decimal + menu_url String? @db.VarChar(256) + menu_desc String? @db.VarChar(1024) + writer String? @db.VarChar(32) + regdate DateTime? @db.Timestamp(6) + status String? @db.VarChar(32) + system_name String? @db.VarChar(32) + company_code String? @default("*") @db.VarChar(50) + lang_key String? @db.VarChar(100) + lang_key_desc String? @db.VarChar(100) company company_mng? @relation(fields: [company_code], references: [company_code]) @@index([parent_obj_id]) @@ -4993,6 +4990,7 @@ model screen_definitions { company_code String @db.VarChar(50) description String? is_active String @default("Y") @db.Char(1) + layout_metadata Json? created_date DateTime @default(now()) @db.Timestamp(6) created_by String? @db.VarChar(50) updated_date DateTime @default(now()) @db.Timestamp(6) diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 83c16e2a..ac92d38b 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -17,6 +17,7 @@ import screenManagementRoutes from "./routes/screenManagementRoutes"; import commonCodeRoutes from "./routes/commonCodeRoutes"; import dynamicFormRoutes from "./routes/dynamicFormRoutes"; import fileRoutes from "./routes/fileRoutes"; +import companyManagementRoutes from "./routes/companyManagementRoutes"; // import userRoutes from './routes/userRoutes'; // import menuRoutes from './routes/menuRoutes'; @@ -81,6 +82,7 @@ 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/company-management", companyManagementRoutes); // app.use('/api/users', userRoutes); // app.use('/api/menus', menuRoutes); diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 17576407..fee6205c 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -7,6 +7,7 @@ import { PrismaClient } from "@prisma/client"; import config from "../config/environment"; import { AdminService } from "../services/adminService"; import { EncryptUtil } from "../utils/encryptUtil"; +import { FileSystemManager } from "../utils/fileSystemManager"; const prisma = new PrismaClient(); @@ -2095,6 +2096,19 @@ export const createCompany = async ( const insertResult = await client.query(insertQuery, insertValues); const createdCompany = insertResult.rows[0]; + // 회사 폴더 초기화 (파일 시스템) + try { + FileSystemManager.initializeCompanyFolder(createdCompany.company_code); + logger.info("회사 폴더 초기화 완료", { + companyCode: createdCompany.company_code, + }); + } catch (folderError) { + logger.warn("회사 폴더 초기화 실패 (회사 등록은 성공)", { + companyCode: createdCompany.company_code, + error: folderError, + }); + } + logger.info("회사 등록 성공", { companyCode: createdCompany.company_code, companyName: createdCompany.company_name, diff --git a/backend-node/src/routes/companyManagementRoutes.ts b/backend-node/src/routes/companyManagementRoutes.ts new file mode 100644 index 00000000..a2e4a85c --- /dev/null +++ b/backend-node/src/routes/companyManagementRoutes.ts @@ -0,0 +1,182 @@ +import express from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { AuthenticatedRequest } from "../types/auth"; +import { logger } from "../utils/logger"; +import { FileSystemManager } from "../utils/fileSystemManager"; +import { PrismaClient } from "@prisma/client"; + +const router = express.Router(); +const prisma = new PrismaClient(); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +/** + * DELETE /api/company-management/:companyCode + * 회사 삭제 및 파일 정리 + */ +router.delete( + "/:companyCode", + async (req: AuthenticatedRequest, res): Promise => { + try { + const { companyCode } = req.params; + const { createBackup = true } = req.body; + + logger.info("회사 삭제 요청", { + companyCode, + createBackup, + userId: req.user?.userId, + }); + + // 1. 회사 존재 확인 + const existingCompany = await prisma.company_mng.findUnique({ + where: { company_code: companyCode }, + }); + + if (!existingCompany) { + res.status(404).json({ + success: false, + message: "존재하지 않는 회사입니다.", + errorCode: "COMPANY_NOT_FOUND", + }); + return; + } + + // 2. 회사 파일 정리 (백업 또는 삭제) + try { + await FileSystemManager.cleanupCompanyFiles(companyCode, createBackup); + logger.info("회사 파일 정리 완료", { companyCode, createBackup }); + } catch (fileError) { + logger.error("회사 파일 정리 실패", { companyCode, error: fileError }); + res.status(500).json({ + success: false, + message: "회사 파일 정리 중 오류가 발생했습니다.", + error: + fileError instanceof Error ? fileError.message : "Unknown error", + }); + return; + } + + // 3. 데이터베이스에서 회사 삭제 (soft delete) + await prisma.company_mng.update({ + where: { company_code: companyCode }, + data: { + status: "deleted", + }, + }); + + logger.info("회사 삭제 완료", { + companyCode, + companyName: existingCompany.company_name, + deletedBy: req.user?.userId, + }); + + res.json({ + success: true, + message: `회사 '${existingCompany.company_name}'이(가) 성공적으로 삭제되었습니다.`, + data: { + companyCode, + companyName: existingCompany.company_name, + backupCreated: createBackup, + deletedAt: new Date().toISOString(), + }, + }); + } catch (error) { + logger.error("회사 삭제 실패", { + error, + companyCode: req.params.companyCode, + }); + res.status(500).json({ + success: false, + message: "회사 삭제 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } +); + +/** + * GET /api/company-management/:companyCode/disk-usage + * 회사별 디스크 사용량 조회 + */ +router.get( + "/:companyCode/disk-usage", + async (req: AuthenticatedRequest, res): Promise => { + try { + const { companyCode } = req.params; + + const diskUsage = FileSystemManager.getCompanyDiskUsage(companyCode); + + res.json({ + success: true, + data: { + companyCode, + fileCount: diskUsage.fileCount, + totalSize: diskUsage.totalSize, + totalSizeMB: + Math.round((diskUsage.totalSize / 1024 / 1024) * 100) / 100, + lastChecked: new Date().toISOString(), + }, + }); + } catch (error) { + logger.error("디스크 사용량 조회 실패", { + error, + companyCode: req.params.companyCode, + }); + res.status(500).json({ + success: false, + message: "디스크 사용량 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } +); + +/** + * GET /api/company-management/disk-usage/all + * 전체 회사 디스크 사용량 조회 + */ +router.get( + "/disk-usage/all", + async (req: AuthenticatedRequest, res): Promise => { + try { + const allUsage = FileSystemManager.getAllCompaniesDiskUsage(); + + const totalStats = allUsage.reduce( + (acc, company) => ({ + totalFiles: acc.totalFiles + company.fileCount, + totalSize: acc.totalSize + company.totalSize, + }), + { totalFiles: 0, totalSize: 0 } + ); + + res.json({ + success: true, + data: { + companies: allUsage.map((company) => ({ + ...company, + totalSizeMB: + Math.round((company.totalSize / 1024 / 1024) * 100) / 100, + })), + summary: { + totalCompanies: allUsage.length, + totalFiles: totalStats.totalFiles, + totalSize: totalStats.totalSize, + totalSizeMB: + Math.round((totalStats.totalSize / 1024 / 1024) * 100) / 100, + }, + lastChecked: new Date().toISOString(), + }, + }); + } catch (error) { + logger.error("전체 디스크 사용량 조회 실패", { error }); + res.status(500).json({ + success: false, + message: "전체 디스크 사용량 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } +); + +export default router; diff --git a/backend-node/src/routes/fileRoutes.ts b/backend-node/src/routes/fileRoutes.ts index 9978526e..dd7bcd97 100644 --- a/backend-node/src/routes/fileRoutes.ts +++ b/backend-node/src/routes/fileRoutes.ts @@ -5,6 +5,7 @@ import fs from "fs"; import { authenticateToken } from "../middleware/authMiddleware"; import { AuthenticatedRequest } from "../types/auth"; import { logger } from "../utils/logger"; +import { FileSystemManager } from "../utils/fileSystemManager"; const router = express.Router(); @@ -16,21 +17,53 @@ if (!fs.existsSync(UPLOAD_PATH)) { fs.mkdirSync(UPLOAD_PATH, { recursive: true }); } -// Multer 설정 - 파일 업로드용 +// Multer 설정 - 회사별 폴더 구조 지원 const storage = multer.diskStorage({ destination: (req, file, cb) => { - return cb(null, UPLOAD_PATH); + try { + // 사용자의 회사 코드 가져오기 + const user = (req as AuthenticatedRequest).user; + const companyCode = user?.companyCode || "default"; + + // 회사별 날짜별 폴더 생성 + const uploadPath = FileSystemManager.createCompanyUploadPath(companyCode); + + logger.info("파일 업로드 대상 폴더", { + companyCode, + uploadPath, + userId: user?.userId, + }); + + return cb(null, uploadPath); + } catch (error) { + logger.error("업로드 폴더 생성 실패", error); + return cb(error as Error, ""); + } }, 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); + try { + // 사용자의 회사 코드 가져오기 + const user = (req as AuthenticatedRequest).user; + const companyCode = user?.companyCode || "default"; + + // 회사코드가 포함된 안전한 파일명 생성 + const safeFileName = FileSystemManager.generateSafeFileName( + file.originalname, + companyCode + ); + + logger.info("파일명 생성", { + originalName: file.originalname, + safeFileName, + companyCode, + userId: user?.userId, + }); + + return cb(null, safeFileName); + } catch (error) { + logger.error("파일명 생성 실패", error); + return cb(error as Error, ""); + } }, }); @@ -251,6 +284,128 @@ router.delete( } ); +/** + * 이미지 미리보기 + * GET /api/files/preview/:fileId + */ +router.get( + "/preview/:fileId", + async (req: AuthenticatedRequest, res): Promise => { + try { + const { fileId } = req.params; + const { serverFilename } = req.query; + + if (!serverFilename) { + res.status(400).json({ + success: false, + message: "서버 파일명이 필요합니다.", + }); + return; + } + + // 회사별 폴더 구조를 고려하여 파일 경로 찾기 + const user = req.user; + const companyCode = user?.companyCode || "default"; + + // 먼저 회사별 폴더에서 찾기 + let filePath = FileSystemManager.findFileInCompanyFolders( + companyCode, + serverFilename as string + ); + + // 찾지 못하면 기본 uploads 폴더에서 찾기 + if (!filePath) { + filePath = path.join(UPLOAD_PATH, serverFilename as string); + } + + // 파일 존재 확인 + if (!fs.existsSync(filePath)) { + logger.warn("이미지 파일을 찾을 수 없음", { + fileId, + serverFilename, + filePath, + userId: user?.userId, + }); + + res.status(404).json({ + success: false, + message: "요청한 이미지 파일을 찾을 수 없습니다.", + }); + return; + } + + // 파일 확장자로 MIME 타입 결정 + const ext = path.extname(filePath).toLowerCase(); + const mimeTypes: { [key: string]: string } = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", + ".bmp": "image/bmp", + ".svg": "image/svg+xml", + }; + + const mimeType = mimeTypes[ext] || "application/octet-stream"; + + // 이미지 파일이 아닌 경우 에러 반환 + if (!mimeType.startsWith("image/")) { + res.status(400).json({ + success: false, + message: "이미지 파일이 아닙니다.", + }); + return; + } + + // 파일 정보 확인 + const stats = fs.statSync(filePath); + + logger.info("이미지 미리보기 요청", { + fileId, + serverFilename, + mimeType, + fileSize: stats.size, + userId: user?.userId, + }); + + // 캐시 헤더 설정 (이미지는 캐시 가능) + res.setHeader("Content-Type", mimeType); + res.setHeader("Content-Length", stats.size); + res.setHeader("Cache-Control", "public, max-age=86400"); // 24시간 캐시 + res.setHeader("Last-Modified", stats.mtime.toUTCString()); + + // If-Modified-Since 헤더 확인 + const ifModifiedSince = req.headers["if-modified-since"]; + if (ifModifiedSince && new Date(ifModifiedSince) >= stats.mtime) { + res.status(304).end(); + return; + } + + // 파일 스트림으로 전송 + 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 : "알 수 없는 오류", + }); + } + } +); + /** * 파일 정보 조회 * GET /api/files/info/:fileId diff --git a/backend-node/src/utils/fileSystemManager.ts b/backend-node/src/utils/fileSystemManager.ts new file mode 100644 index 00000000..b0053656 --- /dev/null +++ b/backend-node/src/utils/fileSystemManager.ts @@ -0,0 +1,280 @@ +import fs from "fs"; +import path from "path"; +import { logger } from "./logger"; + +/** + * 파일 시스템 관리 유틸리티 + * 회사별 폴더 구조 관리 + */ +export class FileSystemManager { + private static readonly BASE_UPLOAD_PATH = path.join( + process.cwd(), + "uploads" + ); + private static readonly SHARED_FOLDER = "shared"; + + /** + * 회사별 업로드 경로 생성 + * @param companyCode 회사 코드 + * @param date 업로드 날짜 (선택적) + * @returns 생성된 폴더 경로 + */ + static createCompanyUploadPath(companyCode: string, date?: Date): string { + const uploadDate = date || new Date(); + const year = uploadDate.getFullYear(); + const month = String(uploadDate.getMonth() + 1).padStart(2, "0"); + const day = String(uploadDate.getDate()).padStart(2, "0"); + + const folderPath = path.join( + this.BASE_UPLOAD_PATH, + `company_${companyCode}`, + String(year), + month, + day + ); + + // 폴더가 없으면 생성 (recursive) + if (!fs.existsSync(folderPath)) { + fs.mkdirSync(folderPath, { recursive: true }); + logger.info(`회사별 업로드 폴더 생성: ${folderPath}`); + } + + return folderPath; + } + + /** + * 회사 등록 시 기본 폴더 구조 생성 + * @param companyCode 회사 코드 + */ + static initializeCompanyFolder(companyCode: string): void { + try { + const companyBasePath = path.join( + this.BASE_UPLOAD_PATH, + `company_${companyCode}` + ); + + if (!fs.existsSync(companyBasePath)) { + fs.mkdirSync(companyBasePath, { recursive: true }); + + // README 파일 생성 (폴더 설명) + const readmePath = path.join(companyBasePath, "README.txt"); + const readmeContent = `회사 코드: ${companyCode} +생성일: ${new Date().toISOString()} +폴더 구조: YYYY/MM/DD/파일명 +관리자: 시스템 자동 생성`; + + fs.writeFileSync(readmePath, readmeContent, "utf8"); + + logger.info(`회사 폴더 초기화 완료: ${companyCode}`); + } + } catch (error) { + logger.error(`회사 폴더 초기화 실패: ${companyCode}`, error); + throw error; + } + } + + /** + * 회사 삭제 시 폴더 및 파일 정리 + * @param companyCode 회사 코드 + * @param createBackup 백업 생성 여부 + */ + static async cleanupCompanyFiles( + companyCode: string, + createBackup: boolean = true + ): Promise { + try { + const companyPath = path.join( + this.BASE_UPLOAD_PATH, + `company_${companyCode}` + ); + + if (!fs.existsSync(companyPath)) { + logger.warn(`삭제할 회사 폴더가 없습니다: ${companyCode}`); + return; + } + + // 백업 생성 + if (createBackup) { + const backupPath = path.join( + this.BASE_UPLOAD_PATH, + "deleted_companies", + `${companyCode}_${Date.now()}` + ); + + if (!fs.existsSync(path.dirname(backupPath))) { + fs.mkdirSync(path.dirname(backupPath), { recursive: true }); + } + + // 폴더 이동 (백업) + fs.renameSync(companyPath, backupPath); + logger.info(`회사 파일 백업 완료: ${companyCode} -> ${backupPath}`); + } else { + // 완전 삭제 + fs.rmSync(companyPath, { recursive: true, force: true }); + logger.info(`회사 파일 완전 삭제: ${companyCode}`); + } + } catch (error) { + logger.error(`회사 파일 정리 실패: ${companyCode}`, error); + throw error; + } + } + + /** + * 공통 파일 경로 (시스템 관리용) + */ + static getSharedPath(): string { + const sharedPath = path.join(this.BASE_UPLOAD_PATH, this.SHARED_FOLDER); + + if (!fs.existsSync(sharedPath)) { + fs.mkdirSync(sharedPath, { recursive: true }); + } + + return sharedPath; + } + + /** + * 회사별 디스크 사용량 조회 + * @param companyCode 회사 코드 + */ + static getCompanyDiskUsage(companyCode: string): { + fileCount: number; + totalSize: number; + } { + try { + const companyPath = path.join( + this.BASE_UPLOAD_PATH, + `company_${companyCode}` + ); + + if (!fs.existsSync(companyPath)) { + return { fileCount: 0, totalSize: 0 }; + } + + let fileCount = 0; + let totalSize = 0; + + const scanDirectory = (dirPath: string) => { + const items = fs.readdirSync(dirPath); + + for (const item of items) { + const itemPath = path.join(dirPath, item); + const stats = fs.statSync(itemPath); + + if (stats.isDirectory()) { + scanDirectory(itemPath); + } else { + fileCount++; + totalSize += stats.size; + } + } + }; + + scanDirectory(companyPath); + + return { fileCount, totalSize }; + } catch (error) { + logger.error(`디스크 사용량 조회 실패: ${companyCode}`, error); + return { fileCount: 0, totalSize: 0 }; + } + } + + /** + * 전체 시스템 디스크 사용량 조회 + */ + static getAllCompaniesDiskUsage(): Array<{ + companyCode: string; + fileCount: number; + totalSize: number; + }> { + try { + const baseDir = this.BASE_UPLOAD_PATH; + + if (!fs.existsSync(baseDir)) { + return []; + } + + const companies = fs + .readdirSync(baseDir) + .filter((item) => item.startsWith("company_")) + .map((folder) => folder.replace("company_", "")); + + return companies.map((companyCode) => ({ + companyCode, + ...this.getCompanyDiskUsage(companyCode), + })); + } catch (error) { + logger.error("전체 디스크 사용량 조회 실패", error); + return []; + } + } + + /** + * 안전한 파일명 생성 (기존 로직 + 회사 정보) + * @param originalName 원본 파일명 + * @param companyCode 회사 코드 + */ + static generateSafeFileName( + originalName: string, + companyCode: string + ): string { + const timestamp = Date.now(); + const cleanName = Buffer.from(originalName, "latin1").toString("utf8"); + const ext = path.extname(cleanName); + const nameWithoutExt = path.basename(cleanName, ext); + + // 회사코드_타임스탬프_파일명 형식 + return `${companyCode}_${timestamp}_${nameWithoutExt}${ext}`; + } + + /** + * 회사별 폴더에서 파일 찾기 + * @param companyCode 회사 코드 + * @param serverFilename 서버 파일명 + * @returns 파일 경로 (찾지 못하면 null) + */ + static findFileInCompanyFolders( + companyCode: string, + serverFilename: string + ): string | null { + try { + const companyBasePath = path.join( + this.BASE_UPLOAD_PATH, + `company_${companyCode}` + ); + + if (!fs.existsSync(companyBasePath)) { + return null; + } + + // 재귀적으로 폴더 탐색 + const findFileRecursively = (dirPath: string): string | null => { + try { + const items = fs.readdirSync(dirPath); + + for (const item of items) { + const itemPath = path.join(dirPath, item); + const stats = fs.statSync(itemPath); + + if (stats.isDirectory()) { + // 하위 폴더에서 재귀 검색 + const found = findFileRecursively(itemPath); + if (found) return found; + } else if (item === serverFilename) { + // 파일을 찾았으면 경로 반환 + return itemPath; + } + } + } catch (error) { + logger.error(`폴더 탐색 오류: ${dirPath}`, error); + } + + return null; + }; + + return findFileRecursively(companyBasePath); + } catch (error) { + logger.error(`파일 찾기 실패: ${companyCode}/${serverFilename}`, error); + return null; + } + } +} diff --git a/backend-node/uploads/company_COMPANY_1/README.txt b/backend-node/uploads/company_COMPANY_1/README.txt new file mode 100644 index 00000000..2ac631ec --- /dev/null +++ b/backend-node/uploads/company_COMPANY_1/README.txt @@ -0,0 +1,4 @@ +회사 코드: COMPANY_1 +생성일: 2025-09-05T05:34:30.357Z +폴더 구조: YYYY/MM/DD/파일명 +관리자: 시스템 자동 생성 \ No newline at end of file diff --git a/frontend/components/admin/CompanyManagement.tsx b/frontend/components/admin/CompanyManagement.tsx index 77692f0a..4e88e35a 100644 --- a/frontend/components/admin/CompanyManagement.tsx +++ b/frontend/components/admin/CompanyManagement.tsx @@ -5,6 +5,7 @@ import { CompanyToolbar } from "./CompanyToolbar"; import { CompanyTable } from "./CompanyTable"; import { CompanyFormModal } from "./CompanyFormModal"; import { CompanyDeleteDialog } from "./CompanyDeleteDialog"; +import { DiskUsageSummary } from "./DiskUsageSummary"; /** * 회사 관리 메인 컴포넌트 @@ -18,6 +19,11 @@ export function CompanyManagement() { isLoading, error, + // 디스크 사용량 관련 + diskUsageInfo, + isDiskUsageLoading, + loadDiskUsage, + // 모달 상태 modalState, deleteState, @@ -46,6 +52,9 @@ export function CompanyManagement() { return (
+ {/* 디스크 사용량 요약 */} + + {/* 툴바 - 검색, 필터, 등록 버튼 */} { + if (!company.diskUsage) { + return ( +
+ + 정보 없음 +
+ ); + } + + const { fileCount, totalSizeMB } = company.diskUsage; + + return ( +
+
+ + {fileCount}개 파일 +
+
+ + {totalSizeMB.toFixed(1)} MB +
+
+ ); + }; // 상태에 따른 Badge 색상 결정 console.log(companies); // 로딩 상태 렌더링 @@ -94,6 +121,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company {column.label} ))} + 디스크 사용량 작업 @@ -103,6 +131,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company {company.company_code} {company.company_name} {company.writer} + {formatDiskUsage(company)}
+ + +
+
+ +

디스크 사용량 정보를 불러오는 중...

+
+
+
+ + ); + } + + const { summary, lastChecked } = diskUsageInfo; + const lastCheckedDate = new Date(lastChecked); + + return ( + + +
+ 디스크 사용량 현황 + 전체 회사 파일 저장 통계 +
+ +
+ +
+ {/* 총 회사 수 */} +
+ +
+

총 회사

+

{summary.totalCompanies}개

+
+
+ + {/* 총 파일 수 */} +
+ +
+

총 파일

+

{summary.totalFiles.toLocaleString()}개

+
+
+ + {/* 총 용량 */} +
+ +
+

총 용량

+

{summary.totalSizeMB.toFixed(1)} MB

+
+
+ + {/* 마지막 업데이트 */} +
+ +
+

마지막 확인

+

+ {lastCheckedDate.toLocaleString("ko-KR", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })} +

+
+
+
+ + {/* 용량 기준 상태 표시 */} +
+
+ 저장소 상태 + 1000 ? "destructive" : summary.totalSizeMB > 500 ? "secondary" : "default"} + > + {summary.totalSizeMB > 1000 ? "용량 주의" : summary.totalSizeMB > 500 ? "보통" : "여유"} + +
+ + {/* 간단한 진행 바 */} +
+
1000 ? "bg-red-500" : summary.totalSizeMB > 500 ? "bg-yellow-500" : "bg-green-500" + }`} + style={{ + width: `${Math.min((summary.totalSizeMB / 2000) * 100, 100)}%`, + }} + /> +
+
+ 0 MB + 2,000 MB (권장 최대) +
+
+ + + ); +} diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index 13c206dd..868310d7 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -32,6 +32,9 @@ import { Download, Eye, X, + ZoomIn, + ZoomOut, + RotateCw, } from "lucide-react"; import { tableTypeApi } from "@/lib/api/screen"; import { getCurrentUser, UserInfo } from "@/lib/api/client"; @@ -84,6 +87,49 @@ export const InteractiveDataTable: React.FC = ({ const [editFormData, setEditFormData] = useState>({}); const [editingRowData, setEditingRowData] = useState | null>(null); const [isEditing, setIsEditing] = useState(false); + + // 이미지 미리보기 상태 + const [previewImage, setPreviewImage] = useState(null); + const [showPreviewModal, setShowPreviewModal] = useState(false); + const [zoom, setZoom] = useState(1); + const [rotation, setRotation] = useState(0); + + // 이미지 미리보기 핸들러들 + const handlePreviewImage = useCallback((fileInfo: FileInfo) => { + setPreviewImage(fileInfo); + setShowPreviewModal(true); + setZoom(1); + setRotation(0); + }, []); + + const closePreviewModal = useCallback(() => { + setShowPreviewModal(false); + setPreviewImage(null); + setZoom(1); + setRotation(0); + }, []); + + const handleZoom = useCallback((direction: "in" | "out") => { + setZoom((prev) => { + if (direction === "in") { + return Math.min(prev + 0.25, 3); + } else { + return Math.max(prev - 0.25, 0.25); + } + }); + }, []); + + const handleRotate = useCallback(() => { + setRotation((prev) => (prev + 90) % 360); + }, []); + + const formatFileSize = useCallback((bytes: number): string => { + if (bytes === 0) return "0 Bytes"; + const k = 1024; + const sizes = ["Bytes", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; + }, []); const [showFileModal, setShowFileModal] = useState(false); const [currentFileData, setCurrentFileData] = useState(null); const [currentFileColumn, setCurrentFileColumn] = useState(null); @@ -1812,10 +1858,7 @@ export const InteractiveDataTable: React.FC = ({ variant="outline" size="sm" className="w-full" - onClick={() => { - // TODO: 이미지 미리보기 모달 구현 - alert("이미지 미리보기 기능은 준비 중입니다."); - }} + onClick={() => handlePreviewImage(fileInfo)} > 미리보기 @@ -1904,6 +1947,65 @@ export const InteractiveDataTable: React.FC = ({ + + {/* 이미지 미리보기 다이얼로그 */} + + + + + {previewImage?.name} +
+ + {Math.round(zoom * 100)}% + + + {previewImage && ( + + )} +
+
+
+ +
+ {previewImage && ( + {previewImage.name} { + console.error("이미지 로딩 실패:", previewImage); + toast.error("이미지를 불러올 수 없습니다."); + }} + /> + )} +
+ + {previewImage && ( +
+
크기: {formatFileSize(previewImage.size)}
+
타입: {previewImage.type}
+
업로드: {new Date(previewImage.uploadedAt).toLocaleDateString("ko-KR")}
+
+ )} +
+
); }; diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index dd571c97..9c0da012 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -826,6 +826,9 @@ export const InteractiveScreenViewer: React.FC = ( return (
+
+ 업로드된 파일 ({fileData.length}개) +
{fileData.map((fileInfo: any, index: number) => { const isImage = fileInfo.type?.startsWith('image/'); diff --git a/frontend/constants/company.ts b/frontend/constants/company.ts index 18ecddca..2c39247d 100644 --- a/frontend/constants/company.ts +++ b/frontend/constants/company.ts @@ -20,9 +20,9 @@ export const COMPANY_STATUS_LABELS = { // 회사 목록 테이블 컬럼 정의 export const COMPANY_TABLE_COLUMNS: CompanyTableColumn[] = [ - { key: "company_code", label: "회사코드", sortable: true, width: "200px" }, + { key: "company_code", label: "회사코드", sortable: true, width: "150px" }, { key: "company_name", label: "회사명", sortable: true }, - { key: "writer", label: "등록자", sortable: true, width: "400px" }, + { key: "writer", label: "등록자", sortable: true, width: "200px" }, ]; // 하드코딩된 회사 목록 데이터 (백엔드 구현 전까지 사용) diff --git a/frontend/hooks/useCompanyManagement.ts b/frontend/hooks/useCompanyManagement.ts index 35420ca0..2baab8d0 100644 --- a/frontend/hooks/useCompanyManagement.ts +++ b/frontend/hooks/useCompanyManagement.ts @@ -1,5 +1,12 @@ import { useState, useCallback, useMemo, useEffect } from "react"; -import { Company, CompanyFormData, CompanyModalState, CompanyDeleteState, CompanySearchFilter } from "@/types/company"; +import { + Company, + CompanyFormData, + CompanyModalState, + CompanyDeleteState, + CompanySearchFilter, + AllDiskUsageInfo, +} from "@/types/company"; import { DEFAULT_COMPANY_FORM_DATA, COMPANY_STATUS } from "@/constants/company"; import { companyAPI } from "@/lib/api/company"; @@ -32,6 +39,10 @@ export const useCompanyManagement = () => { targetCompany: null, }); + // 디스크 사용량 상태 + const [diskUsageInfo, setDiskUsageInfo] = useState(null); + const [isDiskUsageLoading, setIsDiskUsageLoading] = useState(false); + // 회사 목록 로드 const loadCompanies = useCallback(async () => { setIsLoading(true); @@ -58,15 +69,52 @@ export const useCompanyManagement = () => { } }, [searchFilter]); + // 디스크 사용량 로드 + const loadDiskUsage = useCallback(async () => { + setIsDiskUsageLoading(true); + try { + const data = await companyAPI.getAllDiskUsage(); + setDiskUsageInfo(data); + console.log("✅ 디스크 사용량 조회 성공:", data.summary); + } catch (err) { + console.error("❌ 디스크 사용량 조회 실패:", err); + // 디스크 사용량 조회 실패는 에러로 처리하지 않음 (선택적 기능) + } finally { + setIsDiskUsageLoading(false); + } + }, []); + // 초기 로드 및 검색 필터 변경 시 재로드 useEffect(() => { loadCompanies(); - }, [loadCompanies]); + loadDiskUsage(); // 디스크 사용량도 함께 로드 + }, [loadCompanies, loadDiskUsage]); - // 필터링된 회사 목록 (이제 서버에서 필터링되므로 그대로 반환) + // 디스크 사용량 정보가 포함된 회사 목록 + const companiesWithDiskUsage = useMemo(() => { + if (!diskUsageInfo) return companies; + + return companies.map((company) => { + const diskUsage = diskUsageInfo.companies.find((usage) => usage.companyCode === company.company_code); + + return { + ...company, + diskUsage: diskUsage + ? { + fileCount: diskUsage.fileCount, + totalSize: diskUsage.totalSize, + totalSizeMB: diskUsage.totalSizeMB, + lastChecked: diskUsage.lastChecked, + } + : undefined, + }; + }); + }, [companies, diskUsageInfo]); + + // 필터링된 회사 목록 (디스크 사용량 정보 포함) const filteredCompanies = useMemo(() => { - return companies; - }, [companies]); + return companiesWithDiskUsage; + }, [companiesWithDiskUsage]); // 검색 필터 업데이트 const updateSearchFilter = useCallback((filter: Partial) => { @@ -225,6 +273,11 @@ export const useCompanyManagement = () => { isLoading, error, + // 디스크 사용량 관련 + diskUsageInfo, + isDiskUsageLoading, + loadDiskUsage, + // 모달 상태 modalState, deleteState, diff --git a/frontend/lib/api/company.ts b/frontend/lib/api/company.ts index ea5aa700..c8a8b42e 100644 --- a/frontend/lib/api/company.ts +++ b/frontend/lib/api/company.ts @@ -111,6 +111,52 @@ export async function deleteCompany(companyCode: string): Promise { } } +/** + * 회사별 디스크 사용량 조회 + */ +export async function getCompanyDiskUsage(companyCode: string): Promise<{ + companyCode: string; + fileCount: number; + totalSize: number; + totalSizeMB: number; + lastChecked: string; +}> { + const response = await apiClient.get(`/company-management/${companyCode}/disk-usage`); + + if (response.data.success && response.data.data) { + return response.data.data; + } + + throw new Error(response.data.message || "디스크 사용량 조회에 실패했습니다."); +} + +/** + * 전체 회사 디스크 사용량 조회 + */ +export async function getAllCompaniesDiskUsage(): Promise<{ + companies: Array<{ + companyCode: string; + fileCount: number; + totalSize: number; + totalSizeMB: number; + }>; + summary: { + totalCompanies: number; + totalFiles: number; + totalSize: number; + totalSizeMB: number; + }; + lastChecked: string; +}> { + const response = await apiClient.get("/company-management/disk-usage/all"); + + if (response.data.success && response.data.data) { + return response.data.data; + } + + throw new Error(response.data.message || "전체 디스크 사용량 조회에 실패했습니다."); +} + /** * 회사 관리 API 객체 (통합) */ @@ -120,4 +166,6 @@ export const companyAPI = { create: createCompany, update: updateCompany, delete: deleteCompany, + getDiskUsage: getCompanyDiskUsage, + getAllDiskUsage: getAllCompaniesDiskUsage, }; diff --git a/frontend/types/company.ts b/frontend/types/company.ts index 3118b3a6..503ce856 100644 --- a/frontend/types/company.ts +++ b/frontend/types/company.ts @@ -9,6 +9,13 @@ export interface Company { writer: string; // 등록자 (varchar 32) regdate: string; // 등록일시 (timestamp -> ISO string) status: string; // 상태 (varchar 32) + // 디스크 사용량 정보 (선택적) + diskUsage?: { + fileCount: number; + totalSize: number; + totalSizeMB: number; + lastChecked: string; + }; } // 회사 등록/수정 폼 데이터 @@ -50,3 +57,24 @@ export interface CompanyTableColumn { sortable?: boolean; width?: string; } + +// 디스크 사용량 정보 +export interface DiskUsageInfo { + companyCode: string; + fileCount: number; + totalSize: number; + totalSizeMB: number; + lastChecked: string; +} + +// 전체 디스크 사용량 통계 +export interface AllDiskUsageInfo { + companies: DiskUsageInfo[]; + summary: { + totalCompanies: number; + totalFiles: number; + totalSize: number; + totalSizeMB: number; + }; + lastChecked: string; +}