Merge pull request '이미지 미리보기 기능' (#20) from feature/screen-management into dev
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/20
This commit is contained in:
commit
096838adab
|
|
@ -93,6 +93,7 @@ model approval_kind {
|
||||||
@@index([status])
|
@@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 {
|
model approval_target {
|
||||||
objid Decimal @default(0) @db.Decimal
|
objid Decimal @default(0) @db.Decimal
|
||||||
master_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_name String? @db.VarChar(100)
|
||||||
column_label String? @db.VarChar(200)
|
column_label String? @db.VarChar(200)
|
||||||
web_type String? @db.VarChar(50)
|
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?
|
detail_settings String?
|
||||||
description String?
|
description String?
|
||||||
display_order Int? @default(0)
|
display_order Int? @default(0)
|
||||||
|
|
@ -513,13 +514,11 @@ model company_code_sequence {
|
||||||
}
|
}
|
||||||
|
|
||||||
model company_mng {
|
model company_mng {
|
||||||
company_code String @id(map: "pk_company_mng") @db.VarChar(32)
|
company_code String @id(map: "pk_company_mng") @db.VarChar(32)
|
||||||
company_name String? @db.VarChar(64)
|
company_name String? @db.VarChar(64)
|
||||||
writer String? @db.VarChar(32)
|
writer String? @db.VarChar(32)
|
||||||
regdate DateTime? @db.Timestamp(6)
|
regdate DateTime? @db.Timestamp(6)
|
||||||
status String? @db.VarChar(32)
|
status String? @db.VarChar(32)
|
||||||
|
|
||||||
// 관계 정의
|
|
||||||
menus menu_info[]
|
menus menu_info[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1475,23 +1474,21 @@ model material_release {
|
||||||
}
|
}
|
||||||
|
|
||||||
model menu_info {
|
model menu_info {
|
||||||
objid Decimal @id @default(0) @db.Decimal
|
objid Decimal @id @default(0) @db.Decimal
|
||||||
menu_type Decimal? @db.Decimal
|
menu_type Decimal? @db.Decimal
|
||||||
parent_obj_id Decimal? @db.Decimal
|
parent_obj_id Decimal? @db.Decimal
|
||||||
menu_name_kor String? @db.VarChar(64)
|
menu_name_kor String? @db.VarChar(64)
|
||||||
menu_name_eng String? @db.VarChar(64)
|
menu_name_eng String? @db.VarChar(64)
|
||||||
seq Decimal? @db.Decimal
|
seq Decimal? @db.Decimal
|
||||||
menu_url String? @db.VarChar(256)
|
menu_url String? @db.VarChar(256)
|
||||||
menu_desc String? @db.VarChar(1024)
|
menu_desc String? @db.VarChar(1024)
|
||||||
writer String? @db.VarChar(32)
|
writer String? @db.VarChar(32)
|
||||||
regdate DateTime? @db.Timestamp(6)
|
regdate DateTime? @db.Timestamp(6)
|
||||||
status String? @db.VarChar(32)
|
status String? @db.VarChar(32)
|
||||||
system_name String? @db.VarChar(32)
|
system_name String? @db.VarChar(32)
|
||||||
company_code String? @default("*") @db.VarChar(50)
|
company_code String? @default("*") @db.VarChar(50)
|
||||||
lang_key String? @db.VarChar(100)
|
lang_key String? @db.VarChar(100)
|
||||||
lang_key_desc String? @db.VarChar(100)
|
lang_key_desc String? @db.VarChar(100)
|
||||||
|
|
||||||
// 관계 정의
|
|
||||||
company company_mng? @relation(fields: [company_code], references: [company_code])
|
company company_mng? @relation(fields: [company_code], references: [company_code])
|
||||||
|
|
||||||
@@index([parent_obj_id])
|
@@index([parent_obj_id])
|
||||||
|
|
@ -4993,6 +4990,7 @@ model screen_definitions {
|
||||||
company_code String @db.VarChar(50)
|
company_code String @db.VarChar(50)
|
||||||
description String?
|
description String?
|
||||||
is_active String @default("Y") @db.Char(1)
|
is_active String @default("Y") @db.Char(1)
|
||||||
|
layout_metadata Json?
|
||||||
created_date DateTime @default(now()) @db.Timestamp(6)
|
created_date DateTime @default(now()) @db.Timestamp(6)
|
||||||
created_by String? @db.VarChar(50)
|
created_by String? @db.VarChar(50)
|
||||||
updated_date DateTime @default(now()) @db.Timestamp(6)
|
updated_date DateTime @default(now()) @db.Timestamp(6)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import screenManagementRoutes from "./routes/screenManagementRoutes";
|
||||||
import commonCodeRoutes from "./routes/commonCodeRoutes";
|
import commonCodeRoutes from "./routes/commonCodeRoutes";
|
||||||
import dynamicFormRoutes from "./routes/dynamicFormRoutes";
|
import dynamicFormRoutes from "./routes/dynamicFormRoutes";
|
||||||
import fileRoutes from "./routes/fileRoutes";
|
import fileRoutes from "./routes/fileRoutes";
|
||||||
|
import companyManagementRoutes from "./routes/companyManagementRoutes";
|
||||||
// import userRoutes from './routes/userRoutes';
|
// import userRoutes from './routes/userRoutes';
|
||||||
// import menuRoutes from './routes/menuRoutes';
|
// import menuRoutes from './routes/menuRoutes';
|
||||||
|
|
||||||
|
|
@ -81,6 +82,7 @@ app.use("/api/screen-management", screenManagementRoutes);
|
||||||
app.use("/api/common-codes", commonCodeRoutes);
|
app.use("/api/common-codes", commonCodeRoutes);
|
||||||
app.use("/api/dynamic-form", dynamicFormRoutes);
|
app.use("/api/dynamic-form", dynamicFormRoutes);
|
||||||
app.use("/api/files", fileRoutes);
|
app.use("/api/files", fileRoutes);
|
||||||
|
app.use("/api/company-management", companyManagementRoutes);
|
||||||
// app.use('/api/users', userRoutes);
|
// app.use('/api/users', userRoutes);
|
||||||
// app.use('/api/menus', menuRoutes);
|
// app.use('/api/menus', menuRoutes);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { PrismaClient } from "@prisma/client";
|
||||||
import config from "../config/environment";
|
import config from "../config/environment";
|
||||||
import { AdminService } from "../services/adminService";
|
import { AdminService } from "../services/adminService";
|
||||||
import { EncryptUtil } from "../utils/encryptUtil";
|
import { EncryptUtil } from "../utils/encryptUtil";
|
||||||
|
import { FileSystemManager } from "../utils/fileSystemManager";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
|
@ -2095,6 +2096,19 @@ export const createCompany = async (
|
||||||
const insertResult = await client.query(insertQuery, insertValues);
|
const insertResult = await client.query(insertQuery, insertValues);
|
||||||
const createdCompany = insertResult.rows[0];
|
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("회사 등록 성공", {
|
logger.info("회사 등록 성공", {
|
||||||
companyCode: createdCompany.company_code,
|
companyCode: createdCompany.company_code,
|
||||||
companyName: createdCompany.company_name,
|
companyName: createdCompany.company_name,
|
||||||
|
|
|
||||||
|
|
@ -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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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;
|
||||||
|
|
@ -5,6 +5,7 @@ import fs from "fs";
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
import { AuthenticatedRequest } from "../types/auth";
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
import { FileSystemManager } from "../utils/fileSystemManager";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
|
@ -16,21 +17,53 @@ if (!fs.existsSync(UPLOAD_PATH)) {
|
||||||
fs.mkdirSync(UPLOAD_PATH, { recursive: true });
|
fs.mkdirSync(UPLOAD_PATH, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multer 설정 - 파일 업로드용
|
// Multer 설정 - 회사별 폴더 구조 지원
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: (req, file, cb) => {
|
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) => {
|
filename: (req, file, cb) => {
|
||||||
// 파일명: timestamp_originalname
|
try {
|
||||||
const timestamp = Date.now();
|
// 사용자의 회사 코드 가져오기
|
||||||
const originalName = Buffer.from(file.originalname, "latin1").toString(
|
const user = (req as AuthenticatedRequest).user;
|
||||||
"utf8"
|
const companyCode = user?.companyCode || "default";
|
||||||
);
|
|
||||||
const ext = path.extname(originalName);
|
// 회사코드가 포함된 안전한 파일명 생성
|
||||||
const nameWithoutExt = path.basename(originalName, ext);
|
const safeFileName = FileSystemManager.generateSafeFileName(
|
||||||
const safeFileName = `${timestamp}_${nameWithoutExt}${ext}`;
|
file.originalname,
|
||||||
return cb(null, safeFileName);
|
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<void> => {
|
||||||
|
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
|
* GET /api/files/info/:fileId
|
||||||
|
|
|
||||||
|
|
@ -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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
회사 코드: COMPANY_1
|
||||||
|
생성일: 2025-09-05T05:34:30.357Z
|
||||||
|
폴더 구조: YYYY/MM/DD/파일명
|
||||||
|
관리자: 시스템 자동 생성
|
||||||
|
|
@ -5,6 +5,7 @@ import { CompanyToolbar } from "./CompanyToolbar";
|
||||||
import { CompanyTable } from "./CompanyTable";
|
import { CompanyTable } from "./CompanyTable";
|
||||||
import { CompanyFormModal } from "./CompanyFormModal";
|
import { CompanyFormModal } from "./CompanyFormModal";
|
||||||
import { CompanyDeleteDialog } from "./CompanyDeleteDialog";
|
import { CompanyDeleteDialog } from "./CompanyDeleteDialog";
|
||||||
|
import { DiskUsageSummary } from "./DiskUsageSummary";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 회사 관리 메인 컴포넌트
|
* 회사 관리 메인 컴포넌트
|
||||||
|
|
@ -18,6 +19,11 @@ export function CompanyManagement() {
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
|
|
||||||
|
// 디스크 사용량 관련
|
||||||
|
diskUsageInfo,
|
||||||
|
isDiskUsageLoading,
|
||||||
|
loadDiskUsage,
|
||||||
|
|
||||||
// 모달 상태
|
// 모달 상태
|
||||||
modalState,
|
modalState,
|
||||||
deleteState,
|
deleteState,
|
||||||
|
|
@ -46,6 +52,9 @@ export function CompanyManagement() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* 디스크 사용량 요약 */}
|
||||||
|
<DiskUsageSummary diskUsageInfo={diskUsageInfo} isLoading={isDiskUsageLoading} onRefresh={loadDiskUsage} />
|
||||||
|
|
||||||
{/* 툴바 - 검색, 필터, 등록 버튼 */}
|
{/* 툴바 - 검색, 필터, 등록 버튼 */}
|
||||||
<CompanyToolbar
|
<CompanyToolbar
|
||||||
searchFilter={searchFilter}
|
searchFilter={searchFilter}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { Edit, Trash2 } from "lucide-react";
|
import { Edit, Trash2, HardDrive, FileText } from "lucide-react";
|
||||||
import { Company } from "@/types/company";
|
import { Company } from "@/types/company";
|
||||||
import { COMPANY_TABLE_COLUMNS } from "@/constants/company";
|
import { COMPANY_TABLE_COLUMNS } from "@/constants/company";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
interface CompanyTableProps {
|
interface CompanyTableProps {
|
||||||
companies: Company[];
|
companies: Company[];
|
||||||
|
|
@ -15,6 +16,32 @@ interface CompanyTableProps {
|
||||||
* 회사 목록 테이블 컴포넌트
|
* 회사 목록 테이블 컴포넌트
|
||||||
*/
|
*/
|
||||||
export function CompanyTable({ companies, isLoading, onEdit, onDelete }: CompanyTableProps) {
|
export function CompanyTable({ companies, isLoading, onEdit, onDelete }: CompanyTableProps) {
|
||||||
|
// 디스크 사용량 포맷팅 함수
|
||||||
|
const formatDiskUsage = (company: Company) => {
|
||||||
|
if (!company.diskUsage) {
|
||||||
|
return (
|
||||||
|
<div className="text-muted-foreground flex items-center gap-1">
|
||||||
|
<HardDrive className="h-3 w-3" />
|
||||||
|
<span className="text-xs">정보 없음</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { fileCount, totalSizeMB } = company.diskUsage;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<FileText className="h-3 w-3 text-blue-500" />
|
||||||
|
<span className="text-xs font-medium">{fileCount}개 파일</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<HardDrive className="h-3 w-3 text-green-500" />
|
||||||
|
<span className="text-xs">{totalSizeMB.toFixed(1)} MB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
// 상태에 따른 Badge 색상 결정
|
// 상태에 따른 Badge 색상 결정
|
||||||
console.log(companies);
|
console.log(companies);
|
||||||
// 로딩 상태 렌더링
|
// 로딩 상태 렌더링
|
||||||
|
|
@ -94,6 +121,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
||||||
{column.label}
|
{column.label}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
|
<TableHead className="w-[140px]">디스크 사용량</TableHead>
|
||||||
<TableHead className="w-[120px]">작업</TableHead>
|
<TableHead className="w-[120px]">작업</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
@ -103,6 +131,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
||||||
<TableCell className="font-mono text-sm">{company.company_code}</TableCell>
|
<TableCell className="font-mono text-sm">{company.company_code}</TableCell>
|
||||||
<TableCell className="font-medium">{company.company_name}</TableCell>
|
<TableCell className="font-medium">{company.company_name}</TableCell>
|
||||||
<TableCell>{company.writer}</TableCell>
|
<TableCell>{company.writer}</TableCell>
|
||||||
|
<TableCell className="py-2">{formatDiskUsage(company)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
import { RefreshCw, HardDrive, FileText, Building2, Clock } from "lucide-react";
|
||||||
|
import { AllDiskUsageInfo } from "@/types/company";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
interface DiskUsageSummaryProps {
|
||||||
|
diskUsageInfo: AllDiskUsageInfo | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 디스크 사용량 요약 정보 컴포넌트
|
||||||
|
*/
|
||||||
|
export function DiskUsageSummary({ diskUsageInfo, isLoading, onRefresh }: DiskUsageSummaryProps) {
|
||||||
|
if (!diskUsageInfo) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-sm font-medium">디스크 사용량</CardTitle>
|
||||||
|
<CardDescription>전체 회사 파일 저장 현황</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading} className="h-8 w-8 p-0">
|
||||||
|
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-muted-foreground flex items-center justify-center py-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<HardDrive className="mx-auto mb-2 h-8 w-8" />
|
||||||
|
<p className="text-sm">디스크 사용량 정보를 불러오는 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { summary, lastChecked } = diskUsageInfo;
|
||||||
|
const lastCheckedDate = new Date(lastChecked);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-sm font-medium">디스크 사용량 현황</CardTitle>
|
||||||
|
<CardDescription>전체 회사 파일 저장 통계</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
title="새로고침"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||||
|
{/* 총 회사 수 */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Building2 className="h-4 w-4 text-blue-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-xs">총 회사</p>
|
||||||
|
<p className="text-lg font-semibold">{summary.totalCompanies}개</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 총 파일 수 */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<FileText className="h-4 w-4 text-green-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-xs">총 파일</p>
|
||||||
|
<p className="text-lg font-semibold">{summary.totalFiles.toLocaleString()}개</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 총 용량 */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<HardDrive className="h-4 w-4 text-orange-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-xs">총 용량</p>
|
||||||
|
<p className="text-lg font-semibold">{summary.totalSizeMB.toFixed(1)} MB</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 마지막 업데이트 */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Clock className="h-4 w-4 text-gray-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-xs">마지막 확인</p>
|
||||||
|
<p className="text-xs font-medium">
|
||||||
|
{lastCheckedDate.toLocaleString("ko-KR", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 용량 기준 상태 표시 */}
|
||||||
|
<div className="mt-4 border-t pt-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-muted-foreground text-xs">저장소 상태</span>
|
||||||
|
<Badge
|
||||||
|
variant={summary.totalSizeMB > 1000 ? "destructive" : summary.totalSizeMB > 500 ? "secondary" : "default"}
|
||||||
|
>
|
||||||
|
{summary.totalSizeMB > 1000 ? "용량 주의" : summary.totalSizeMB > 500 ? "보통" : "여유"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 간단한 진행 바 */}
|
||||||
|
<div className="mt-2 h-2 w-full rounded-full bg-gray-200">
|
||||||
|
<div
|
||||||
|
className={`h-2 rounded-full transition-all duration-300 ${
|
||||||
|
summary.totalSizeMB > 1000 ? "bg-red-500" : summary.totalSizeMB > 500 ? "bg-yellow-500" : "bg-green-500"
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
width: `${Math.min((summary.totalSizeMB / 2000) * 100, 100)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground mt-1 flex justify-between text-xs">
|
||||||
|
<span>0 MB</span>
|
||||||
|
<span>2,000 MB (권장 최대)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -32,6 +32,9 @@ import {
|
||||||
Download,
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
X,
|
X,
|
||||||
|
ZoomIn,
|
||||||
|
ZoomOut,
|
||||||
|
RotateCw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
import { getCurrentUser, UserInfo } from "@/lib/api/client";
|
import { getCurrentUser, UserInfo } from "@/lib/api/client";
|
||||||
|
|
@ -84,6 +87,49 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
const [editFormData, setEditFormData] = useState<Record<string, any>>({});
|
const [editFormData, setEditFormData] = useState<Record<string, any>>({});
|
||||||
const [editingRowData, setEditingRowData] = useState<Record<string, any> | null>(null);
|
const [editingRowData, setEditingRowData] = useState<Record<string, any> | null>(null);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
|
||||||
|
// 이미지 미리보기 상태
|
||||||
|
const [previewImage, setPreviewImage] = useState<FileInfo | null>(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 [showFileModal, setShowFileModal] = useState(false);
|
||||||
const [currentFileData, setCurrentFileData] = useState<FileColumnData | null>(null);
|
const [currentFileData, setCurrentFileData] = useState<FileColumnData | null>(null);
|
||||||
const [currentFileColumn, setCurrentFileColumn] = useState<DataTableColumn | null>(null);
|
const [currentFileColumn, setCurrentFileColumn] = useState<DataTableColumn | null>(null);
|
||||||
|
|
@ -1812,10 +1858,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={() => {
|
onClick={() => handlePreviewImage(fileInfo)}
|
||||||
// TODO: 이미지 미리보기 모달 구현
|
|
||||||
alert("이미지 미리보기 기능은 준비 중입니다.");
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Eye className="mr-1 h-4 w-4" />
|
<Eye className="mr-1 h-4 w-4" />
|
||||||
미리보기
|
미리보기
|
||||||
|
|
@ -1904,6 +1947,65 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 이미지 미리보기 다이얼로그 */}
|
||||||
|
<Dialog open={showPreviewModal} onOpenChange={closePreviewModal}>
|
||||||
|
<DialogContent className="max-h-[90vh] max-w-4xl overflow-hidden">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center justify-between">
|
||||||
|
<span className="truncate">{previewImage?.name}</span>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => handleZoom("out")} disabled={zoom <= 0.25}>
|
||||||
|
<ZoomOut className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="min-w-[60px] text-center text-sm text-gray-500">{Math.round(zoom * 100)}%</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => handleZoom("in")} disabled={zoom >= 3}>
|
||||||
|
<ZoomIn className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={handleRotate}>
|
||||||
|
<RotateCw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
{previewImage && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
handleDownloadFile(previewImage);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-1 items-center justify-center overflow-auto rounded-lg bg-gray-50 p-4">
|
||||||
|
{previewImage && (
|
||||||
|
<img
|
||||||
|
src={`${process.env.NEXT_PUBLIC_API_URL}/files/preview/${previewImage.id}?serverFilename=${previewImage.serverFilename}`}
|
||||||
|
alt={previewImage.name}
|
||||||
|
className="max-h-full max-w-full object-contain transition-transform duration-200"
|
||||||
|
style={{
|
||||||
|
transform: `scale(${zoom}) rotate(${rotation}deg)`,
|
||||||
|
}}
|
||||||
|
onError={() => {
|
||||||
|
console.error("이미지 로딩 실패:", previewImage);
|
||||||
|
toast.error("이미지를 불러올 수 없습니다.");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{previewImage && (
|
||||||
|
<div className="flex items-center justify-between border-t pt-3 text-sm text-gray-500">
|
||||||
|
<div>크기: {formatFileSize(previewImage.size)}</div>
|
||||||
|
<div>타입: {previewImage.type}</div>
|
||||||
|
<div>업로드: {new Date(previewImage.uploadedAt).toLocaleDateString("ko-KR")}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -826,6 +826,9 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 space-y-2">
|
<div className="mt-2 space-y-2">
|
||||||
|
<div className="text-sm font-medium text-gray-700">
|
||||||
|
업로드된 파일 ({fileData.length}개)
|
||||||
|
</div>
|
||||||
{fileData.map((fileInfo: any, index: number) => {
|
{fileData.map((fileInfo: any, index: number) => {
|
||||||
const isImage = fileInfo.type?.startsWith('image/');
|
const isImage = fileInfo.type?.startsWith('image/');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,9 @@ export const COMPANY_STATUS_LABELS = {
|
||||||
|
|
||||||
// 회사 목록 테이블 컬럼 정의
|
// 회사 목록 테이블 컬럼 정의
|
||||||
export const COMPANY_TABLE_COLUMNS: CompanyTableColumn[] = [
|
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: "company_name", label: "회사명", sortable: true },
|
||||||
{ key: "writer", label: "등록자", sortable: true, width: "400px" },
|
{ key: "writer", label: "등록자", sortable: true, width: "200px" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// 하드코딩된 회사 목록 데이터 (백엔드 구현 전까지 사용)
|
// 하드코딩된 회사 목록 데이터 (백엔드 구현 전까지 사용)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
import { useState, useCallback, useMemo, useEffect } from "react";
|
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 { DEFAULT_COMPANY_FORM_DATA, COMPANY_STATUS } from "@/constants/company";
|
||||||
import { companyAPI } from "@/lib/api/company";
|
import { companyAPI } from "@/lib/api/company";
|
||||||
|
|
||||||
|
|
@ -32,6 +39,10 @@ export const useCompanyManagement = () => {
|
||||||
targetCompany: null,
|
targetCompany: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 디스크 사용량 상태
|
||||||
|
const [diskUsageInfo, setDiskUsageInfo] = useState<AllDiskUsageInfo | null>(null);
|
||||||
|
const [isDiskUsageLoading, setIsDiskUsageLoading] = useState(false);
|
||||||
|
|
||||||
// 회사 목록 로드
|
// 회사 목록 로드
|
||||||
const loadCompanies = useCallback(async () => {
|
const loadCompanies = useCallback(async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
@ -58,15 +69,52 @@ export const useCompanyManagement = () => {
|
||||||
}
|
}
|
||||||
}, [searchFilter]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
loadCompanies();
|
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(() => {
|
const filteredCompanies = useMemo(() => {
|
||||||
return companies;
|
return companiesWithDiskUsage;
|
||||||
}, [companies]);
|
}, [companiesWithDiskUsage]);
|
||||||
|
|
||||||
// 검색 필터 업데이트
|
// 검색 필터 업데이트
|
||||||
const updateSearchFilter = useCallback((filter: Partial<CompanySearchFilter>) => {
|
const updateSearchFilter = useCallback((filter: Partial<CompanySearchFilter>) => {
|
||||||
|
|
@ -225,6 +273,11 @@ export const useCompanyManagement = () => {
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
|
|
||||||
|
// 디스크 사용량 관련
|
||||||
|
diskUsageInfo,
|
||||||
|
isDiskUsageLoading,
|
||||||
|
loadDiskUsage,
|
||||||
|
|
||||||
// 모달 상태
|
// 모달 상태
|
||||||
modalState,
|
modalState,
|
||||||
deleteState,
|
deleteState,
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,52 @@ export async function deleteCompany(companyCode: string): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회사별 디스크 사용량 조회
|
||||||
|
*/
|
||||||
|
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 객체 (통합)
|
* 회사 관리 API 객체 (통합)
|
||||||
*/
|
*/
|
||||||
|
|
@ -120,4 +166,6 @@ export const companyAPI = {
|
||||||
create: createCompany,
|
create: createCompany,
|
||||||
update: updateCompany,
|
update: updateCompany,
|
||||||
delete: deleteCompany,
|
delete: deleteCompany,
|
||||||
|
getDiskUsage: getCompanyDiskUsage,
|
||||||
|
getAllDiskUsage: getAllCompaniesDiskUsage,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,13 @@ export interface Company {
|
||||||
writer: string; // 등록자 (varchar 32)
|
writer: string; // 등록자 (varchar 32)
|
||||||
regdate: string; // 등록일시 (timestamp -> ISO string)
|
regdate: string; // 등록일시 (timestamp -> ISO string)
|
||||||
status: string; // 상태 (varchar 32)
|
status: string; // 상태 (varchar 32)
|
||||||
|
// 디스크 사용량 정보 (선택적)
|
||||||
|
diskUsage?: {
|
||||||
|
fileCount: number;
|
||||||
|
totalSize: number;
|
||||||
|
totalSizeMB: number;
|
||||||
|
lastChecked: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 회사 등록/수정 폼 데이터
|
// 회사 등록/수정 폼 데이터
|
||||||
|
|
@ -50,3 +57,24 @@ export interface CompanyTableColumn {
|
||||||
sortable?: boolean;
|
sortable?: boolean;
|
||||||
width?: string;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue