auto commit
This commit is contained in:
parent
3faddff2d7
commit
f404c4b0d3
@ -4,6 +4,9 @@
|
||||
NODE_ENV=development
|
||||
TZ=Asia/Seoul
|
||||
|
||||
# Uproad Folder
|
||||
UPLOAD_FOLDER=/Users/effects/Documents/GitHub/wacefems_upload
|
||||
|
||||
# Frontend
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3001
|
||||
NEXT_PUBLIC_MQTT_URL=ws://localhost:1883
|
||||
|
@ -4,6 +4,9 @@
|
||||
NODE_ENV=production
|
||||
TZ=Asia/Seoul
|
||||
|
||||
# Uproad Folder
|
||||
UPLOAD_FOLDER=/home/wacefems
|
||||
|
||||
# Traefik Settings
|
||||
DOMAIN=fems.com
|
||||
TRAEFIK_NETWORK=toktork_server_default
|
||||
|
@ -55,10 +55,6 @@ services:
|
||||
- /app/node_modules
|
||||
environment:
|
||||
- NODE_ENV=${NODE_ENV:-development}
|
||||
# - POSTGRES_DB=${POSTGRES_DB}
|
||||
# - POSTGRES_USER=${POSTGRES_USER}
|
||||
# - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
# - POSTGRES_HOST=${POSTGRES_HOST}
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
@ -70,9 +66,29 @@ services:
|
||||
- .env.${NODE_ENV:-development}
|
||||
environment:
|
||||
- NODE_ENV=${NODE_ENV:-development}
|
||||
- LANG=en_US.utf8
|
||||
- LC_ALL=en_US.utf8
|
||||
- POSTGRES_HOST_AUTH_METHOD=scram-sha-256
|
||||
- POSTGRES_INITDB_ARGS=--auth-host=scram-sha-256 --auth-local=scram-sha-256
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./backups/postgres:/backups
|
||||
- ./init-scripts:/docker-entrypoint-initdb.d
|
||||
command:
|
||||
- "postgres"
|
||||
- "-c"
|
||||
- "max_connections=100"
|
||||
- "-c"
|
||||
- "shared_buffers=128MB"
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-postgres}",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
# healthcheck:
|
||||
# test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
# interval: 30s
|
||||
@ -96,12 +112,10 @@ services:
|
||||
environment:
|
||||
- NODE_ENV=${NODE_ENV:-development}
|
||||
- REDIS_PASSWORD=${NODE_ENV:-development:-REDIS_PASSWORD}
|
||||
depends_on:
|
||||
- postgres
|
||||
# healthcheck:
|
||||
# test: ["CMD", "redis-cli", "ping"]
|
||||
# interval: 30s
|
||||
# timeout: 10s
|
||||
# retries: 3
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
|
@ -10,6 +10,8 @@ services:
|
||||
- NEXT_WEBPACK_USEPOLLING=1
|
||||
- WATCHPACK_POLLING=true
|
||||
# 개발 환경에서는 healthcheck 비활성화
|
||||
volumes:
|
||||
- ../../wacefems/uploaded_files:/app/uploads
|
||||
healthcheck:
|
||||
disable: true
|
||||
|
||||
@ -19,10 +21,15 @@ services:
|
||||
environment:
|
||||
- NEXT_WEBPACK_USEPOLLING=1
|
||||
- WATCHPACK_POLLING=true
|
||||
volumes:
|
||||
- ../../wacefems/uploaded_files:/app/uploads
|
||||
|
||||
fems-api:
|
||||
ports:
|
||||
- "3001:3001"
|
||||
volumes:
|
||||
- ../../wacefems/uploaded_files:/app/uploads
|
||||
- fems_tmp:/app/tmp
|
||||
|
||||
postgres:
|
||||
ports:
|
||||
@ -32,6 +39,17 @@ services:
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
fems_tmp:
|
||||
uploaded_files:
|
||||
driver: local
|
||||
# driver_opts:
|
||||
# type: none
|
||||
# device: /Users/effects/Documents/GitHub/wacefems_upload
|
||||
# o: bind
|
||||
|
||||
networks:
|
||||
default:
|
||||
driver: bridge
|
||||
|
@ -23,6 +23,8 @@ services:
|
||||
- "traefik.http.routers.fems-admin.tls=true"
|
||||
- "traefik.http.routers.fems-admin.middlewares=secured@file"
|
||||
- "traefik.http.services.fems-admin.loadbalancer.server.port=3000"
|
||||
volumes:
|
||||
- ../../wacefems/uploaded_files:/app/uploads
|
||||
networks:
|
||||
- ${TRAEFIK_NETWORK}
|
||||
- internal
|
||||
@ -37,6 +39,8 @@ services:
|
||||
- "traefik.http.routers.fems-app.entrypoints=websecure"
|
||||
- "traefik.http.routers.fems-app.tls=true"
|
||||
- "traefik.http.services.fems-app.loadbalancer.server.port=3000"
|
||||
volumes:
|
||||
- ../../wacefems/uploaded_files:/app/uploads
|
||||
networks:
|
||||
- ${TRAEFIK_NETWORK}
|
||||
- internal
|
||||
@ -51,11 +55,25 @@ services:
|
||||
- "traefik.http.routers.fems-api.entrypoints=websecure"
|
||||
- "traefik.http.routers.fems-api.tls=true"
|
||||
- "traefik.http.services.fems-api.loadbalancer.server.port=3001"
|
||||
volumes:
|
||||
- ../../wacefems/uploaded_files:/app/uploads
|
||||
- fems_tmp:/app/tmp
|
||||
networks:
|
||||
- ${TRAEFIK_NETWORK}
|
||||
- internal
|
||||
command: npm start
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
fems_tmp:
|
||||
uploaded_files:
|
||||
driver: local
|
||||
# driver_opts:
|
||||
# type: none
|
||||
# device: /home/wacefems_upload
|
||||
# o: bind
|
||||
|
||||
networks:
|
||||
${TRAEFIK_NETWORK}:
|
||||
external: true
|
||||
|
7
fems-api/.gitignore
vendored
7
fems-api/.gitignore
vendored
@ -5,4 +5,9 @@ node_modules/
|
||||
!.env.example
|
||||
logs/
|
||||
coverage/
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
|
||||
temp/
|
||||
tmp/
|
||||
uploads/
|
||||
_app_uploads/
|
@ -1,4 +1,5 @@
|
||||
# fems-api/Dockerfile
|
||||
|
||||
# Build stage
|
||||
FROM node:18-alpine AS builder
|
||||
WORKDIR /app
|
||||
@ -10,18 +11,34 @@ RUN yarn build
|
||||
# Development stage
|
||||
FROM node:18-alpine AS development
|
||||
WORKDIR /app
|
||||
|
||||
# 필요한 디렉토리 생성 및 권한 설정
|
||||
RUN mkdir -p /app/tmp/uploads && \
|
||||
mkdir -p /app/uploads && \
|
||||
chmod -R 777 /app/tmp && \
|
||||
chmod -R 777 /app/uploads
|
||||
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn install --frozen-lockfile
|
||||
COPY . .
|
||||
|
||||
ENV NODE_ENV=development
|
||||
CMD ["yarn", "dev"]
|
||||
|
||||
# Production stage
|
||||
FROM node:18-alpine AS production
|
||||
WORKDIR /app
|
||||
|
||||
# 필요한 디렉토리 생성 및 권한 설정
|
||||
RUN mkdir -p /app/tmp/uploads && \
|
||||
mkdir -p /app/uploads && \
|
||||
chmod -R 777 /app/tmp && \
|
||||
chmod -R 777 /app/uploads
|
||||
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn install --frozen-lockfile --production
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
|
||||
ENV NODE_ENV=production
|
||||
CMD ["yarn", "start"]
|
@ -13,10 +13,12 @@
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"crypto": "^1.0.1",
|
||||
"dotenv": "^16.0.3",
|
||||
"express": "^4.18.2",
|
||||
"express-validator": "^7.0.1",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"pg": "^8.11.0",
|
||||
"pg-hstore": "^2.3.4",
|
||||
"sequelize": "^6.31.1",
|
||||
|
@ -1,16 +1,14 @@
|
||||
// src/app.js
|
||||
const express = require("express");
|
||||
const cors = require("cors");
|
||||
const path = require("path");
|
||||
const config = require("./config/config");
|
||||
const { sequelize } = require("./models");
|
||||
const logger = require("./config/logger");
|
||||
const requestLogger = require("./middleware/requestLogger.middleware");
|
||||
const errorHandler = require("./middleware/errorHandler.middleware");
|
||||
const registerRoutes = require("./routes");
|
||||
|
||||
// // Import routes
|
||||
// const adminRoutes = require("./routes/admin");
|
||||
// const appRoutes = require("./routes/app");
|
||||
const authMiddleware = require("./middleware/auth.middleware"); // 추가
|
||||
|
||||
const app = express();
|
||||
|
||||
@ -26,6 +24,35 @@ registerRoutes(app);
|
||||
// Error handling
|
||||
app.use(errorHandler);
|
||||
|
||||
// 파일 다운로드 라우트
|
||||
app.get(
|
||||
"/uploads/:companyId/:moduleType/:category/:filename",
|
||||
authMiddleware,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { companyId, moduleType, category, filename } = req.params;
|
||||
|
||||
// 인증된 사용자와 companyId 비교
|
||||
if (req.user.role !== "super_admin" && req.user.companyId !== companyId) {
|
||||
return res.status(403).json({ message: "Forbidden" });
|
||||
}
|
||||
|
||||
const filePath = path.join(
|
||||
__dirname,
|
||||
"../uploads",
|
||||
companyId,
|
||||
moduleType,
|
||||
category,
|
||||
filename
|
||||
);
|
||||
|
||||
res.sendFile(filePath);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Database initialization and server start
|
||||
const initializeServer = async () => {
|
||||
try {
|
||||
|
209
fems-api/src/controllers/app/equipment/equipment.controller.js
Normal file
209
fems-api/src/controllers/app/equipment/equipment.controller.js
Normal file
@ -0,0 +1,209 @@
|
||||
// src/controllers/app/equipment/equipment.controller.js
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const equipmentService = require("../../../services/equipment.service");
|
||||
const authMiddleware = require("../../../middleware/auth.middleware");
|
||||
const roleCheck = require("../../../middleware/roleCheck.middleware");
|
||||
const { body, param } = require("express-validator");
|
||||
const validate = require("../../../middleware/validator.middleware");
|
||||
|
||||
router.use(authMiddleware);
|
||||
router.use(roleCheck(["super_admin", "company_admin", "branch_admin", "user"]));
|
||||
|
||||
// 설비 목록 조회
|
||||
router.get("/", async (req, res, next) => {
|
||||
try {
|
||||
const equipment = await equipmentService.findAll(req.user);
|
||||
res.json(equipment);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// 설비 생성
|
||||
router.post(
|
||||
"/",
|
||||
[
|
||||
body("name")
|
||||
.notEmpty()
|
||||
.withMessage("설비명은 필수입니다")
|
||||
.isLength({ max: 100 })
|
||||
.withMessage("설비명은 100자를 초과할 수 없습니다"),
|
||||
body("model")
|
||||
.notEmpty()
|
||||
.withMessage("모델명은 필수입니다")
|
||||
.isLength({ max: 100 })
|
||||
.withMessage("모델명은 100자를 초과할 수 없습니다"),
|
||||
body("manufacturer")
|
||||
.notEmpty()
|
||||
.withMessage("제조사는 필수입니다")
|
||||
.isLength({ max: 100 })
|
||||
.withMessage("제조사는 100자를 초과할 수 없습니다"),
|
||||
body("type")
|
||||
.notEmpty()
|
||||
.withMessage("설비 유형은 필수입니다")
|
||||
.isIn(["HVAC", "Boiler", "Compressor", "Motor", "Pump", "Other"])
|
||||
.withMessage("유효한 설비 유형이 아닙니다"),
|
||||
body("specifications")
|
||||
.optional()
|
||||
.isObject()
|
||||
.withMessage("사양 정보는 객체 형태여야 합니다"),
|
||||
body("installationDate")
|
||||
.notEmpty()
|
||||
.withMessage("설치일은 필수입니다")
|
||||
.isISO8601()
|
||||
.withMessage("유효한 날짜 형식이 아닙니다"),
|
||||
body("zoneId")
|
||||
.optional()
|
||||
.isUUID()
|
||||
.withMessage("유효한 구역 ID가 필요합니다"),
|
||||
validate,
|
||||
],
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const equipmentData = req.body;
|
||||
|
||||
// company_admin은 자신의 회사에만 설비 생성 가능
|
||||
if (req.user.role === "company_admin") {
|
||||
equipmentData.companyId = req.user.companyId;
|
||||
}
|
||||
|
||||
const equipment = await equipmentService.createEquipment(
|
||||
equipmentData,
|
||||
req.user
|
||||
);
|
||||
res.status(201).json(equipment);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 설비 상세 조회 추가
|
||||
router.get(
|
||||
"/:id",
|
||||
[param("id").isUUID().withMessage("유효한 설비 ID가 필요합니다"), validate],
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const equipment = await equipmentService.findById(req.params.id);
|
||||
|
||||
if (!equipment) {
|
||||
return res.status(404).json({ message: "설비를 찾을 수 없습니다" });
|
||||
}
|
||||
|
||||
// company_admin은 자신의 회사 설비만 조회 가능
|
||||
if (
|
||||
req.user.role === "company_admin" &&
|
||||
equipment.companyId !== req.user.companyId
|
||||
) {
|
||||
return res.status(403).json({
|
||||
message: "다른 회사의 설비를 조회할 수 없습니다",
|
||||
});
|
||||
}
|
||||
|
||||
res.json(equipment);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 설비 수정
|
||||
router.put(
|
||||
"/:id",
|
||||
[
|
||||
param("id").isUUID().withMessage("유효한 설비 ID가 필요합니다"),
|
||||
body("name")
|
||||
.optional()
|
||||
.notEmpty()
|
||||
.isLength({ max: 100 })
|
||||
.withMessage("설비명은 100자를 초과할 수 없습니다"),
|
||||
body("model")
|
||||
.optional()
|
||||
.notEmpty()
|
||||
.isLength({ max: 100 })
|
||||
.withMessage("모델명은 100자를 초과할 수 없습니다"),
|
||||
body("manufacturer")
|
||||
.optional()
|
||||
.notEmpty()
|
||||
.isLength({ max: 100 })
|
||||
.withMessage("제조사는 100자를 초과할 수 없습니다"),
|
||||
body("type")
|
||||
.optional()
|
||||
.isIn(["HVAC", "Boiler", "Compressor", "Motor", "Pump", "Other"])
|
||||
.withMessage("유효한 설비 유형이 아닙니다"),
|
||||
body("specifications")
|
||||
.optional()
|
||||
.isObject()
|
||||
.withMessage("사양 정보는 객체 형태여야 합니다"),
|
||||
body("installationDate")
|
||||
.optional()
|
||||
.isISO8601()
|
||||
.withMessage("유효한 날짜 형식이 아닙니다"),
|
||||
body("zoneId")
|
||||
.optional()
|
||||
.isUUID()
|
||||
.withMessage("유효한 구역 ID가 필요합니다"),
|
||||
validate,
|
||||
],
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const equipment = await equipmentService.findById(req.params.id);
|
||||
|
||||
if (!equipment) {
|
||||
return res.status(404).json({ message: "설비를 찾을 수 없습니다" });
|
||||
}
|
||||
|
||||
// company_admin은 자신의 회사 설비만 수정 가능
|
||||
if (
|
||||
req.user.role === "company_admin" &&
|
||||
equipment.companyId !== req.user.companyId
|
||||
) {
|
||||
return res.status(403).json({
|
||||
message: "다른 회사의 설비를 수정할 수 없습니다",
|
||||
});
|
||||
}
|
||||
|
||||
const updatedEquipment = await equipmentService.updateEquipment(
|
||||
req.params.id,
|
||||
req.body,
|
||||
req.user
|
||||
);
|
||||
res.json(updatedEquipment);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 설비 삭제
|
||||
router.delete(
|
||||
"/:id",
|
||||
[param("id").isUUID().withMessage("유효한 설비 ID가 필요합니다"), validate],
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const equipment = await equipmentService.findById(req.params.id);
|
||||
|
||||
if (!equipment) {
|
||||
return res.status(404).json({ message: "설비를 찾을 수 없습니다" });
|
||||
}
|
||||
|
||||
// company_admin은 자신의 회사 설비만 삭제 가능
|
||||
if (
|
||||
req.user.role === "company_admin" &&
|
||||
equipment.companyId !== req.user.companyId
|
||||
) {
|
||||
return res.status(403).json({
|
||||
message: "다른 회사의 설비를 삭제할 수 없습니다",
|
||||
});
|
||||
}
|
||||
|
||||
await equipmentService.deleteEquipment(req.params.id, req.user);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = router;
|
287
fems-api/src/controllers/app/file/file.controller.js
Normal file
287
fems-api/src/controllers/app/file/file.controller.js
Normal file
@ -0,0 +1,287 @@
|
||||
// controllers/app/file/file.controller.js
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const multer = require("multer");
|
||||
const path = require("path");
|
||||
const fs = require("fs").promises;
|
||||
const { File } = require("../../../models");
|
||||
const { v4: uuidv4 } = require("uuid");
|
||||
const fileService = require("../../../services/file.service");
|
||||
const authMiddleware = require("../../../middleware/auth.middleware");
|
||||
const roleCheck = require("../../../middleware/roleCheck.middleware");
|
||||
const { body, param, query } = require("express-validator");
|
||||
const validate = require("../../../middleware/validator.middleware");
|
||||
|
||||
// multer 설정 수정
|
||||
const storage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
try {
|
||||
await fs.mkdir("tmp/uploads", { recursive: true });
|
||||
cb(null, "tmp/uploads");
|
||||
} catch (error) {
|
||||
cb(error);
|
||||
}
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
try {
|
||||
// 원본 파일명 디코딩
|
||||
const decodedName = Buffer.from(file.originalname, "latin1").toString(
|
||||
"utf8"
|
||||
);
|
||||
const ext = path.extname(decodedName);
|
||||
|
||||
// 파일명에서 확장자를 제외한 부분만 가져오기
|
||||
const nameWithoutExt = path.basename(decodedName, ext);
|
||||
|
||||
// 파일명 정리 - 특수문자, 공백 등 처리
|
||||
const sanitizedName = nameWithoutExt
|
||||
.replace(/[^\w\s가-힣]/g, "") // 특수문자 제거 (한글, 영숫자, 공백만 허용)
|
||||
.replace(/\s+/g, "_") // 공백을 언더스코어로 변경
|
||||
.trim() // 앞뒤 공백 제거
|
||||
.substring(0, 100); // 길이 제한
|
||||
|
||||
// UUID와 정리된 파일명 조합
|
||||
const uniqueId = uuidv4();
|
||||
const uniqueFilename = `${uniqueId}${
|
||||
sanitizedName ? "_" + sanitizedName : ""
|
||||
}${ext}`;
|
||||
|
||||
cb(null, uniqueFilename);
|
||||
} catch (error) {
|
||||
cb(new Error(`Error processing filename: ${error.message}`));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 파일 필터 추가
|
||||
const fileFilter = (req, file, cb) => {
|
||||
// MIME 타입 검사
|
||||
const allowedMimes = [
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"application/vnd.dwg",
|
||||
"application/vnd.dxf",
|
||||
];
|
||||
|
||||
if (allowedMimes.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error("Invalid file type"), false);
|
||||
}
|
||||
};
|
||||
|
||||
// multer 미들웨어 설정
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
fileFilter: fileFilter,
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024, // 10MB 제한
|
||||
},
|
||||
});
|
||||
|
||||
// 에러 핸들링 미들웨어
|
||||
const uploadErrorHandler = (error, req, res, next) => {
|
||||
if (error instanceof multer.MulterError) {
|
||||
if (error.code === "LIMIT_FILE_SIZE") {
|
||||
return res.status(400).json({ message: "파일 크기가 너무 큽니다." });
|
||||
}
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: "파일 업로드 중 오류가 발생했습니다." });
|
||||
}
|
||||
next(error);
|
||||
};
|
||||
|
||||
router.use(authMiddleware);
|
||||
router.use(roleCheck(["super_admin", "company_admin", "branch_admin", "user"]));
|
||||
|
||||
// 파일 업로드 라우트 수정
|
||||
router.post(
|
||||
"/upload",
|
||||
upload.array("files"),
|
||||
uploadErrorHandler,
|
||||
[
|
||||
body("moduleType").notEmpty().withMessage("모듈 타입은 필수입니다"),
|
||||
body("category").notEmpty().withMessage("카테고리는 필수입니다"),
|
||||
body("referenceId")
|
||||
.optional()
|
||||
.isUUID()
|
||||
.withMessage("유효한 참조 ID가 필요합니다"),
|
||||
body("metadata")
|
||||
.optional()
|
||||
.custom((value) => {
|
||||
try {
|
||||
// 이미 객체인 경우 그대로 반환
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return true;
|
||||
}
|
||||
// 문자열인 경우 JSON 파싱 시도
|
||||
JSON.parse(value);
|
||||
return true;
|
||||
} catch (e) {
|
||||
throw new Error("메타데이터는 유효한 JSON 형식이어야 합니다");
|
||||
}
|
||||
}),
|
||||
validate,
|
||||
],
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { moduleType, category, referenceId } = req.body;
|
||||
let metadata;
|
||||
|
||||
// metadata 파싱
|
||||
try {
|
||||
metadata =
|
||||
typeof req.body.metadata === "string"
|
||||
? JSON.parse(req.body.metadata)
|
||||
: req.body.metadata;
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
message: "메타데이터 파싱 실패",
|
||||
detail: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
const files = req.files || [];
|
||||
|
||||
if (files.length === 0) {
|
||||
return res.status(400).json({
|
||||
message: "업로드할 파일이 없습니다",
|
||||
});
|
||||
}
|
||||
|
||||
// 파일 매핑 및 한글 처리
|
||||
const processedFiles = files.map((file) => ({
|
||||
...file,
|
||||
originalname: Buffer.from(file.originalname, "latin1").toString("utf8"),
|
||||
}));
|
||||
|
||||
// 모듈 타입과 카테고리 검증
|
||||
if (!File.CATEGORIES[moduleType?.toUpperCase()]?.MODULE) {
|
||||
return res.status(400).json({
|
||||
message: "유효하지 않은 모듈 타입입니다",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!Object.values(
|
||||
File.CATEGORIES[moduleType.toUpperCase()].TYPES
|
||||
).includes(category)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
message: "유효하지 않은 카테고리입니다",
|
||||
});
|
||||
}
|
||||
|
||||
const uploadedFiles = await Promise.all(
|
||||
processedFiles.map((file) =>
|
||||
fileService.saveFile(file, {
|
||||
moduleType,
|
||||
category,
|
||||
referenceId,
|
||||
metadata,
|
||||
companyId: req.user.companyId,
|
||||
user: req.user,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
res.status(201).json(uploadedFiles);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 파일 다운로드
|
||||
router.get(
|
||||
"/download/:fileId",
|
||||
[
|
||||
param("fileId").isUUID().withMessage("유효한 파일 ID가 필요합니다"),
|
||||
validate,
|
||||
],
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const file = await fileService.findById(req.params.fileId, req.user);
|
||||
if (!file) {
|
||||
return res.status(404).json({ message: "파일을 찾을 수 없습니다" });
|
||||
}
|
||||
|
||||
const filePath = path.join(process.cwd(), "uploads", file.path);
|
||||
res.download(filePath, file.originalName);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 파일 조회
|
||||
router.get(
|
||||
"/list",
|
||||
[
|
||||
query("moduleType").optional(),
|
||||
query("category").optional(),
|
||||
query("referenceId")
|
||||
.optional()
|
||||
.isUUID()
|
||||
.withMessage("유효한 참조 ID가 필요합니다"),
|
||||
validate,
|
||||
],
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { moduleType, category, referenceId } = req.query;
|
||||
const files = await fileService.findFiles({
|
||||
moduleType,
|
||||
category,
|
||||
referenceId,
|
||||
user: req.user,
|
||||
});
|
||||
res.json(files);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 단일 파일 조회
|
||||
router.get(
|
||||
"/:fileId",
|
||||
[
|
||||
param("fileId").isUUID().withMessage("유효한 파일 ID가 필요합니다"),
|
||||
validate,
|
||||
],
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const file = await fileService.findById(req.params.fileId, req.user);
|
||||
if (!file) {
|
||||
return res.status(404).json({ message: "파일을 찾을 수 없습니다" });
|
||||
}
|
||||
res.json(file);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 파일 삭제
|
||||
router.delete(
|
||||
"/:fileId",
|
||||
[
|
||||
param("fileId").isUUID().withMessage("유효한 파일 ID가 필요합니다"),
|
||||
validate,
|
||||
],
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
await fileService.deleteFile(req.params.fileId, req.user);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = router;
|
150
fems-api/src/controllers/app/zone/zone.controller.js
Normal file
150
fems-api/src/controllers/app/zone/zone.controller.js
Normal file
@ -0,0 +1,150 @@
|
||||
// src/controllers/app/zone/zone.controller.js
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const { Branch } = require("../../../models");
|
||||
const zoneService = require("../../../services/zone.service");
|
||||
const authMiddleware = require("../../../middleware/auth.middleware");
|
||||
const roleCheck = require("../../../middleware/roleCheck.middleware");
|
||||
const { body, param } = require("express-validator");
|
||||
const validate = require("../../../middleware/validator.middleware");
|
||||
|
||||
router.use(authMiddleware);
|
||||
router.use(roleCheck(["super_admin", "company_admin", "branch_admin", "user"]));
|
||||
|
||||
// 구역 목록 조회
|
||||
router.get("/", async (req, res, next) => {
|
||||
try {
|
||||
const zones = await zoneService.findAll(req.user);
|
||||
res.json(zones);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// 구역 생성
|
||||
router.post(
|
||||
"/",
|
||||
[
|
||||
body("name")
|
||||
.notEmpty()
|
||||
.withMessage("구역명은 필수입니다")
|
||||
.isLength({ max: 100 })
|
||||
.withMessage("구역명은 100자를 초과할 수 없습니다"),
|
||||
body("description")
|
||||
.optional()
|
||||
.isLength({ max: 1000 })
|
||||
.withMessage("설명은 1000자를 초과할 수 없습니다"),
|
||||
body("area")
|
||||
.optional()
|
||||
.isFloat({ min: 0 })
|
||||
.withMessage("면적은 0보다 큰 숫자여야 합니다"),
|
||||
body("branchId")
|
||||
.notEmpty()
|
||||
.withMessage("지점 ID는 필수입니다")
|
||||
.isUUID()
|
||||
.withMessage("유효한 지점 ID가 필요합니다"),
|
||||
validate,
|
||||
],
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const zoneData = req.body;
|
||||
|
||||
// company_admin은 자신의 회사에 속한 지점의 구역만 생성 가능
|
||||
if (req.user.role === "company_admin") {
|
||||
const branch = await Branch.findByPk(zoneData.branchId);
|
||||
if (branch.companyId !== req.user.companyId) {
|
||||
return res.status(403).json({
|
||||
message: "다른 회사의 지점에 구역을 생성할 수 없습니다",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const zone = await zoneService.createZone(zoneData, req.user);
|
||||
res.status(201).json(zone);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 구역 수정
|
||||
router.put(
|
||||
"/:id",
|
||||
[
|
||||
param("id").isUUID().withMessage("유효한 구역 ID가 필요합니다"),
|
||||
body("name")
|
||||
.optional()
|
||||
.notEmpty()
|
||||
.isLength({ max: 100 })
|
||||
.withMessage("구역명은 100자를 초과할 수 없습니다"),
|
||||
body("description")
|
||||
.optional()
|
||||
.isLength({ max: 1000 })
|
||||
.withMessage("설명은 1000자를 초과할 수 없습니다"),
|
||||
body("area")
|
||||
.optional()
|
||||
.isFloat({ min: 0 })
|
||||
.withMessage("면적은 0보다 큰 숫자여야 합니다"),
|
||||
validate,
|
||||
],
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const zone = await zoneService.findById(req.params.id);
|
||||
|
||||
if (!zone) {
|
||||
return res.status(404).json({ message: "구역을 찾을 수 없습니다" });
|
||||
}
|
||||
|
||||
// company_admin은 자신의 회사의 지점에 속한 구역만 수정 가능
|
||||
if (req.user.role === "company_admin") {
|
||||
const branch = await Branch.findByPk(zone.branchId);
|
||||
if (branch.companyId !== req.user.companyId) {
|
||||
return res.status(403).json({
|
||||
message: "다른 회사의 구역을 수정할 수 없습니다",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updatedZone = await zoneService.updateZone(
|
||||
req.params.id,
|
||||
req.body,
|
||||
req.user
|
||||
);
|
||||
res.json(updatedZone);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 구역 삭제
|
||||
router.delete(
|
||||
"/:id",
|
||||
[param("id").isUUID().withMessage("유효한 구역 ID가 필요합니다"), validate],
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const zone = await zoneService.findById(req.params.id);
|
||||
|
||||
if (!zone) {
|
||||
return res.status(404).json({ message: "구역을 찾을 수 없습니다" });
|
||||
}
|
||||
|
||||
// company_admin은 자신의 회사의 지점에 속한 구역만 삭제 가능
|
||||
if (req.user.role === "company_admin") {
|
||||
const branch = await Branch.findByPk(zone.branchId);
|
||||
if (branch.companyId !== req.user.companyId) {
|
||||
return res.status(403).json({
|
||||
message: "다른 회사의 구역을 삭제할 수 없습니다",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await zoneService.deleteZone(req.params.id, req.user);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = router;
|
111
fems-api/src/models/File.js
Normal file
111
fems-api/src/models/File.js
Normal file
@ -0,0 +1,111 @@
|
||||
// models/File.js
|
||||
const { Model, DataTypes } = require("sequelize");
|
||||
class File extends Model {
|
||||
static init(sequelize) {
|
||||
super.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
originalName: {
|
||||
type: DataTypes.STRING(1000), // 길이 증가
|
||||
allowNull: false,
|
||||
},
|
||||
filename: {
|
||||
type: DataTypes.STRING(1000), // 길이 증가
|
||||
allowNull: false,
|
||||
},
|
||||
path: {
|
||||
type: DataTypes.STRING(1000), // 길이 증가
|
||||
allowNull: false,
|
||||
},
|
||||
size: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
comment: "파일 크기 (bytes)",
|
||||
},
|
||||
mimetype: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: false,
|
||||
comment: "파일 MIME 타입",
|
||||
},
|
||||
moduleType: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false,
|
||||
comment: "모듈 타입 (equipment, maintenance 등)",
|
||||
},
|
||||
category: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false,
|
||||
comment: "파일 카테고리 (manual, technical, certificate 등)",
|
||||
},
|
||||
referenceId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
comment: "참조 ID (해당 모듈의 데이터 ID)",
|
||||
},
|
||||
companyId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
comment: "회사 ID",
|
||||
},
|
||||
uploadedBy: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
comment: "업로드 사용자 ID",
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM("active", "deleted"),
|
||||
defaultValue: "active",
|
||||
comment: "파일 상태",
|
||||
},
|
||||
metadata: {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true,
|
||||
comment: "추가 메타데이터 (버전, 언어, 설명 등)",
|
||||
},
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
modelName: "File",
|
||||
tableName: "files",
|
||||
timestamps: true,
|
||||
}
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
static associate(models) {
|
||||
this.belongsTo(models.Company, { foreignKey: "companyId" });
|
||||
this.belongsTo(models.User, {
|
||||
foreignKey: "uploadedBy",
|
||||
as: "uploader",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 파일 카테고리 상수 정의
|
||||
File.CATEGORIES = {
|
||||
EQUIPMENT: {
|
||||
MODULE: "equipment",
|
||||
TYPES: {
|
||||
MANUAL: "manual",
|
||||
TECHNICAL: "technical",
|
||||
CERTIFICATE: "certificate",
|
||||
DRAWING: "drawing",
|
||||
},
|
||||
},
|
||||
MAINTENANCE: {
|
||||
MODULE: "maintenance",
|
||||
TYPES: {
|
||||
REPORT: "report",
|
||||
CHECKLIST: "checklist",
|
||||
PHOTO: "photo",
|
||||
},
|
||||
},
|
||||
// 필요한 만큼 모듈과 카테고리 추가 가능
|
||||
};
|
||||
|
||||
module.exports = File;
|
@ -2,14 +2,18 @@
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
|
||||
// Import app controllers
|
||||
const authController = require("../controllers/app/auth/auth.controller");
|
||||
const usersController = require("../controllers/app/users/users.controller");
|
||||
const dashboardController = require("../controllers/app/dashboard/dashboard.controller"); // 추가
|
||||
const dashboardController = require("../controllers/app/dashboard/dashboard.controller");
|
||||
const equipmentController = require("../controllers/app/equipment/equipment.controller");
|
||||
const zoneController = require("../controllers/app/zone/zone.controller");
|
||||
const fileController = require("../controllers/app/file/file.controller"); // 추가
|
||||
|
||||
// Mount app routes
|
||||
router.use("/auth", authController);
|
||||
router.use("/users", usersController);
|
||||
router.use("/dashboard", dashboardController); // 추가
|
||||
router.use("/dashboard", dashboardController);
|
||||
router.use("/equipment", equipmentController);
|
||||
router.use("/zone", zoneController);
|
||||
router.use("/files", fileController); // 추가
|
||||
|
||||
module.exports = router;
|
||||
|
127
fems-api/src/services/equipment.service.js
Normal file
127
fems-api/src/services/equipment.service.js
Normal file
@ -0,0 +1,127 @@
|
||||
// src/services/equipment.service.js
|
||||
const {
|
||||
Equipment,
|
||||
Company,
|
||||
Branch,
|
||||
Zone,
|
||||
MaintenanceLog,
|
||||
} = require("../models");
|
||||
const alertService = require("./alert.service");
|
||||
|
||||
class EquipmentService {
|
||||
async findAll(currentUser) {
|
||||
// Initialize the where clause
|
||||
let where = {};
|
||||
|
||||
// company_admin은 자신의 회사 설비만 조회 가능
|
||||
if (currentUser.role === "company_admin") {
|
||||
where.companyId = currentUser.companyId;
|
||||
}
|
||||
|
||||
const equipment = await Equipment.findAll({
|
||||
where,
|
||||
include: [
|
||||
{
|
||||
model: Company,
|
||||
attributes: ["id", "name"],
|
||||
},
|
||||
{
|
||||
model: Branch,
|
||||
attributes: ["id", "name"],
|
||||
},
|
||||
{
|
||||
model: Zone,
|
||||
attributes: ["id", "name"],
|
||||
},
|
||||
{
|
||||
model: MaintenanceLog,
|
||||
limit: 1,
|
||||
order: [["completionDate", "DESC"]],
|
||||
attributes: ["completionDate"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return equipment;
|
||||
}
|
||||
|
||||
async findById(id) {
|
||||
return await Equipment.findByPk(id, {
|
||||
include: [
|
||||
{
|
||||
model: Company,
|
||||
attributes: ["id", "name"],
|
||||
},
|
||||
{
|
||||
model: Branch,
|
||||
attributes: ["id", "name"],
|
||||
},
|
||||
{
|
||||
model: Zone,
|
||||
attributes: ["id", "name"],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async createEquipment(equipmentData, currentUser) {
|
||||
const equipment = await Equipment.create({
|
||||
...equipmentData,
|
||||
companyId:
|
||||
currentUser.role === "company_admin"
|
||||
? currentUser.companyId
|
||||
: equipmentData.companyId,
|
||||
});
|
||||
|
||||
await alertService.createAlert({
|
||||
type: "info",
|
||||
message: `설비 ${equipment.name}이(가) ${currentUser.name}에 의해 등록되었습니다.`,
|
||||
companyId: equipment.companyId,
|
||||
});
|
||||
|
||||
return equipment;
|
||||
}
|
||||
|
||||
async updateEquipment(id, updateData, currentUser) {
|
||||
const equipment = await Equipment.findByPk(id);
|
||||
if (!equipment) throw new Error("Equipment not found");
|
||||
|
||||
// company_admin은 자신의 회사 설비만 수정 가능
|
||||
if (currentUser.role === "company_admin") {
|
||||
if (equipment.companyId !== currentUser.companyId) {
|
||||
throw new Error("Cannot modify equipment from different company");
|
||||
}
|
||||
updateData.companyId = currentUser.companyId;
|
||||
}
|
||||
|
||||
const updatedEquipment = await equipment.update(updateData);
|
||||
|
||||
await alertService.createAlert({
|
||||
type: "info",
|
||||
message: `설비 ${equipment.name}이(가) ${currentUser.name}에 의해 수정되었습니다.`,
|
||||
companyId: equipment.companyId,
|
||||
});
|
||||
|
||||
return updatedEquipment;
|
||||
}
|
||||
|
||||
async deleteEquipment(id, currentUser) {
|
||||
const equipment = await Equipment.findByPk(id);
|
||||
if (!equipment) throw new Error("Equipment not found");
|
||||
|
||||
const equipmentName = equipment.name;
|
||||
const companyId = equipment.companyId;
|
||||
|
||||
await equipment.destroy();
|
||||
|
||||
await alertService.createAlert({
|
||||
type: "info",
|
||||
message: `설비 ${equipmentName}이(가) ${currentUser.name}에 의해 삭제되었습니다.`,
|
||||
companyId: companyId,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new EquipmentService();
|
289
fems-api/src/services/file.service.js
Normal file
289
fems-api/src/services/file.service.js
Normal file
@ -0,0 +1,289 @@
|
||||
// app/service/file.service.js
|
||||
const path = require("path");
|
||||
const fs = require("fs").promises;
|
||||
// const crypto = require("crypto");
|
||||
const { File, User } = require("../models");
|
||||
const logger = require("../config/logger");
|
||||
const { v4: uuidv4 } = require("uuid");
|
||||
|
||||
class FileService {
|
||||
constructor() {
|
||||
// process.cwd()는 현재 작업 디렉토리를 반환하므로,
|
||||
// 직접 경로를 지정하거나 상대 경로를 사용하는 것이 좋습니다.
|
||||
this.uploadDir = path.join(process.cwd(), "uploads"); // 또는
|
||||
// this.uploadDir = "./uploads"; // 상대 경로 사용
|
||||
// this.uploadDir = path.resolve(__dirname, "../../uploads"); // 절대 경로 사용
|
||||
|
||||
// 경로 로깅 추가
|
||||
logger.info(`Upload directory initialized: ${this.uploadDir}`);
|
||||
this.ensureBaseUploadDir();
|
||||
}
|
||||
|
||||
async validateFile(file) {
|
||||
if (!file || !file.path || !file.originalname) {
|
||||
throw new Error("Invalid file data");
|
||||
}
|
||||
|
||||
const maxSize = 10 * 1024 * 1024; // 10MB
|
||||
if (file.size > maxSize) {
|
||||
throw new Error("File size exceeds limit");
|
||||
}
|
||||
|
||||
const allowedMimes = [
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"application/vnd.dwg",
|
||||
"application/vnd.dxf",
|
||||
];
|
||||
|
||||
if (!allowedMimes.includes(file.mimetype)) {
|
||||
throw new Error("File type not allowed");
|
||||
}
|
||||
}
|
||||
|
||||
// 안전한 파일명 생성
|
||||
// FileService의 sanitizeFileName 메서드도 동일하게 수정
|
||||
sanitizeFileName(fileName) {
|
||||
try {
|
||||
const ext = path.extname(fileName);
|
||||
const nameWithoutExt = path.basename(fileName, ext);
|
||||
|
||||
const sanitizedName = nameWithoutExt
|
||||
.replace(/[^\w\s가-힣]/g, "") // 특수문자 제거 (한글, 영숫자, 공백만 허용)
|
||||
.replace(/\s+/g, "_") // 공백을 언더스코어로 변경
|
||||
.trim() // 앞뒤 공백 제거
|
||||
.substring(0, 100); // 길이 제한
|
||||
|
||||
const uniqueId = uuidv4();
|
||||
return `${uniqueId}${sanitizedName ? "_" + sanitizedName : ""}${ext}`;
|
||||
} catch (error) {
|
||||
throw new Error(`Error sanitizing filename: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getFileStream(fileId, user) {
|
||||
const file = await this.findById(fileId, user);
|
||||
if (!file) throw new Error("File not found");
|
||||
|
||||
const filePath = path.join(this.uploadDir, file.path);
|
||||
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return fs.createReadStream(filePath);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to access file: ${filePath}`, error);
|
||||
throw new Error("File not accessible");
|
||||
}
|
||||
}
|
||||
|
||||
// 경로 생성 로직 수정
|
||||
async ensureUploadPath(companyId, moduleType, category) {
|
||||
// 전체 경로를 명시적으로 구성
|
||||
const fullPath = path.join(
|
||||
this.uploadDir,
|
||||
companyId,
|
||||
moduleType.toLowerCase(),
|
||||
category.toLowerCase()
|
||||
);
|
||||
|
||||
try {
|
||||
await fs.mkdir(fullPath, { recursive: true });
|
||||
logger.info(`Created upload path: ${fullPath}`);
|
||||
return fullPath;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create upload path: ${fullPath}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 기존 코드와 같이 유지
|
||||
async ensureBaseUploadDir() {
|
||||
try {
|
||||
await fs.access(this.uploadDir);
|
||||
logger.info(`Access to upload directory confirmed: ${this.uploadDir}`);
|
||||
} catch {
|
||||
await fs.mkdir(this.uploadDir, { recursive: true });
|
||||
logger.info(`Base upload directory created: ${this.uploadDir}`);
|
||||
}
|
||||
}
|
||||
|
||||
async findFiles({ moduleType, category, referenceId, user }) {
|
||||
const where = {
|
||||
status: "active",
|
||||
};
|
||||
|
||||
if (user.role !== "super_admin") {
|
||||
where.companyId = user.companyId;
|
||||
}
|
||||
|
||||
if (moduleType) where.moduleType = moduleType;
|
||||
if (category) where.category = category;
|
||||
if (referenceId) where.referenceId = referenceId;
|
||||
|
||||
return await File.findAll({
|
||||
where,
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: "uploader",
|
||||
attributes: ["id", "name"],
|
||||
},
|
||||
],
|
||||
order: [["createdAt", "DESC"]],
|
||||
});
|
||||
}
|
||||
|
||||
generateFileName(originalName) {
|
||||
return this.sanitizeFileName(originalName);
|
||||
}
|
||||
|
||||
async saveFile(
|
||||
file,
|
||||
{ moduleType, category, referenceId, metadata, companyId, user }
|
||||
) {
|
||||
try {
|
||||
if (!file || !file.path || !file.originalname) {
|
||||
throw new Error("Invalid file data");
|
||||
}
|
||||
|
||||
const uploadDir = await this.ensureUploadPath(
|
||||
companyId,
|
||||
moduleType,
|
||||
category
|
||||
);
|
||||
|
||||
try {
|
||||
const originalName = file.originalname;
|
||||
const safeFileName = this.sanitizeFileName(originalName);
|
||||
const filePath = path.join(uploadDir, safeFileName);
|
||||
|
||||
await this.validateFile(file);
|
||||
|
||||
// 파일 이동
|
||||
await fs.copyFile(file.path, filePath);
|
||||
await fs.unlink(file.path).catch(() => {});
|
||||
|
||||
// DB에 저장될 상대 경로를 /uploads로 시작하도록 구성
|
||||
const relativePath = [
|
||||
"",
|
||||
"uploads",
|
||||
companyId,
|
||||
moduleType.toLowerCase(),
|
||||
category.toLowerCase(),
|
||||
safeFileName,
|
||||
]
|
||||
.join("/")
|
||||
.replace(/\\/g, "/");
|
||||
|
||||
logger.info("File path details:", {
|
||||
uploadDir: this.uploadDir,
|
||||
filePath,
|
||||
relativePath,
|
||||
originalName,
|
||||
safeFileName,
|
||||
});
|
||||
|
||||
const fileRecord = await File.create({
|
||||
originalName: originalName,
|
||||
filename: safeFileName,
|
||||
path: relativePath,
|
||||
size: file.size,
|
||||
mimetype: file.mimetype,
|
||||
moduleType,
|
||||
category,
|
||||
referenceId,
|
||||
metadata: {
|
||||
...metadata,
|
||||
uploadedAt: new Date(),
|
||||
uploadedBy: user.name,
|
||||
},
|
||||
companyId,
|
||||
uploadedBy: user.id,
|
||||
status: "active",
|
||||
});
|
||||
|
||||
return {
|
||||
...fileRecord.toJSON(),
|
||||
url: `/api/v1/app/files/download/${fileRecord.id}`,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("File processing error:", error);
|
||||
throw new Error("Failed to process file");
|
||||
}
|
||||
} catch (error) {
|
||||
if (file.path) {
|
||||
await fs.unlink(file.path).catch(() => {});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findById(fileId, user) {
|
||||
const where = {
|
||||
id: fileId,
|
||||
status: "active",
|
||||
};
|
||||
|
||||
if (user.role !== "super_admin") {
|
||||
where.companyId = user.companyId;
|
||||
}
|
||||
|
||||
const file = await File.findOne({
|
||||
where,
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: "uploader",
|
||||
attributes: ["id", "name"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (file && !file.path.startsWith("/uploads")) {
|
||||
// path가 /uploads로 시작하지 않는 경우에만 수정
|
||||
file.path = "/uploads" + file.path.replace(/^uploads\/?/, "");
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
async deleteFile(fileId, user) {
|
||||
const file = await File.findByPk(fileId);
|
||||
if (!file) throw new Error("File not found");
|
||||
|
||||
if (user.role !== "super_admin" && file.companyId !== user.companyId) {
|
||||
throw new Error("Unauthorized access to file");
|
||||
}
|
||||
|
||||
try {
|
||||
// 실제 파일 시스템 경로로 변환 (앞의 /uploads 제거)
|
||||
const filePath = path.join(
|
||||
process.cwd(),
|
||||
file.path.replace(/^\/uploads/, "uploads")
|
||||
);
|
||||
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
} catch (unlinkError) {
|
||||
logger.error(`Failed to delete file: ${filePath}`, unlinkError);
|
||||
}
|
||||
|
||||
await file.update({ status: "deleted" });
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error("File deletion failed:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// download 처리를 위한 경로 변환 메서드 추가
|
||||
getPhysicalPath(dbPath) {
|
||||
return path.join(process.cwd(), dbPath.replace(/^\/uploads/, "uploads"));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new FileService();
|
93
fems-api/src/services/zone.service.js
Normal file
93
fems-api/src/services/zone.service.js
Normal file
@ -0,0 +1,93 @@
|
||||
// src/services/zone.service.js
|
||||
const { Zone, Branch, Equipment } = require("../models");
|
||||
const alertService = require("./alert.service");
|
||||
|
||||
class ZoneService {
|
||||
async findAll(currentUser) {
|
||||
// Initialize the where clause for Branch
|
||||
let branchWhere = {};
|
||||
|
||||
// company_admin은 자신의 회사의 지점에 속한 구역만 조회 가능
|
||||
if (currentUser.role === "company_admin") {
|
||||
branchWhere.companyId = currentUser.companyId;
|
||||
}
|
||||
|
||||
const zones = await Zone.findAll({
|
||||
include: [
|
||||
{
|
||||
model: Branch,
|
||||
where: branchWhere,
|
||||
attributes: ["id", "name", "companyId"],
|
||||
},
|
||||
{
|
||||
model: Equipment,
|
||||
attributes: ["id", "name"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return zones;
|
||||
}
|
||||
|
||||
async findById(id) {
|
||||
return await Zone.findByPk(id, {
|
||||
include: [
|
||||
{
|
||||
model: Branch,
|
||||
attributes: ["id", "name", "companyId"],
|
||||
},
|
||||
{
|
||||
model: Equipment,
|
||||
attributes: ["id", "name"],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async createZone(zoneData, currentUser) {
|
||||
const zone = await Zone.create(zoneData);
|
||||
|
||||
await alertService.createAlert({
|
||||
type: "info",
|
||||
message: `구역 ${zone.name}이(가) ${currentUser.name}에 의해 생성되었습니다.`,
|
||||
companyId: (await Branch.findByPk(zone.branchId)).companyId,
|
||||
});
|
||||
|
||||
return zone;
|
||||
}
|
||||
|
||||
async updateZone(id, updateData, currentUser) {
|
||||
const zone = await Zone.findByPk(id);
|
||||
if (!zone) throw new Error("Zone not found");
|
||||
|
||||
const updatedZone = await zone.update(updateData);
|
||||
|
||||
await alertService.createAlert({
|
||||
type: "info",
|
||||
message: `구역 ${zone.name}이(가) ${currentUser.name}에 의해 수정되었습니다.`,
|
||||
companyId: (await Branch.findByPk(zone.branchId)).companyId,
|
||||
});
|
||||
|
||||
return updatedZone;
|
||||
}
|
||||
|
||||
async deleteZone(id, currentUser) {
|
||||
const zone = await Zone.findByPk(id);
|
||||
if (!zone) throw new Error("Zone not found");
|
||||
|
||||
const zoneName = zone.name;
|
||||
const branch = await Branch.findByPk(zone.branchId);
|
||||
|
||||
await zone.destroy();
|
||||
|
||||
await alertService.createAlert({
|
||||
type: "info",
|
||||
message: `구역 ${zoneName}이(가) ${currentUser.name}에 의해 삭제되었습니다.`,
|
||||
companyId: branch.companyId,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ZoneService();
|
@ -16,6 +16,8 @@ async function createInitialAdmin() {
|
||||
where: { businessNumber: "000-00-00000" },
|
||||
});
|
||||
|
||||
let adminBranch; // Branch 변수 선언
|
||||
|
||||
if (!adminCompany) {
|
||||
adminCompany = await Company.create({
|
||||
name: "FEMS 관리자",
|
||||
@ -33,7 +35,7 @@ async function createInitialAdmin() {
|
||||
logger.info("Admin company created successfully");
|
||||
|
||||
// 본사 지점 생성
|
||||
await Branch.create({
|
||||
adminBranch = await Branch.create({
|
||||
name: "본사",
|
||||
address: "서울시 강남구",
|
||||
tel: "02-0000-0000",
|
||||
@ -41,21 +43,25 @@ async function createInitialAdmin() {
|
||||
companyId: adminCompany.id,
|
||||
});
|
||||
logger.info("Admin company headquarters branch created successfully");
|
||||
} else {
|
||||
// 기존 회사의 지점 조회
|
||||
adminBranch = await Branch.findOne({
|
||||
where: { companyId: adminCompany.id },
|
||||
});
|
||||
}
|
||||
|
||||
const branch = await Branch.findOne({
|
||||
where: { companyId: adminCompany.id },
|
||||
});
|
||||
|
||||
// 2. 개발 환경에서만 초기 데이터 생성
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
// 조직 구조 설정
|
||||
const departments = await createDepartments(adminCompany.id, branch.id);
|
||||
const departments = await createDepartments(
|
||||
adminCompany.id,
|
||||
adminBranch.id
|
||||
);
|
||||
const roles = await createRoles(adminCompany.id);
|
||||
await createAllUsers(adminCompany.id, branch.id, departments, roles);
|
||||
await createAllUsers(adminCompany.id, adminBranch.id, departments, roles);
|
||||
|
||||
// 초기 데이터 생성
|
||||
await createInitialData(adminCompany.id);
|
||||
// 초기 데이터 생성 - branch.id 전달
|
||||
await createInitialData(adminCompany.id, adminBranch.id);
|
||||
|
||||
// 생성된 계정 정보 출력
|
||||
logCreatedAccounts();
|
||||
|
@ -1,122 +1,232 @@
|
||||
// src/utils/initialSetup/dataSetup.js
|
||||
const {
|
||||
EnergyUsage,
|
||||
EnergyCost,
|
||||
Alert,
|
||||
Kpi,
|
||||
} = require("../../models");
|
||||
const logger = require("../../config/logger");
|
||||
|
||||
async function createInitialData(companyId) {
|
||||
try {
|
||||
await createEnergyUsageData(companyId);
|
||||
await createEnergyCostData(companyId);
|
||||
await createAlertData(companyId);
|
||||
await createKpiData(companyId);
|
||||
|
||||
logger.info("Initial development data created successfully");
|
||||
} catch (error) {
|
||||
logger.error("Error creating initial data:", error);
|
||||
throw error;
|
||||
}
|
||||
Zone,
|
||||
Equipment,
|
||||
EquipmentData,
|
||||
MaintenanceLog,
|
||||
EnergyUsage,
|
||||
EnergyCost,
|
||||
Alert,
|
||||
Kpi,
|
||||
} = require("../../models");
|
||||
const {
|
||||
zoneDefinitions,
|
||||
equipmentDefinitions,
|
||||
maintenanceLogDefinitions,
|
||||
equipmentDataTemplate,
|
||||
} = require("./setupData");
|
||||
const logger = require("../../config/logger");
|
||||
|
||||
async function createInitialData(companyId, branchId) {
|
||||
try {
|
||||
// 1. 구역 및 설비 관련 데이터 생성
|
||||
await createZoneAndEquipmentData(companyId, branchId);
|
||||
|
||||
// 2. 에너지 관련 데이터 생성
|
||||
await createEnergyUsageData(companyId);
|
||||
await createEnergyCostData(companyId);
|
||||
await createAlertData(companyId);
|
||||
await createKpiData(companyId);
|
||||
|
||||
logger.info("Initial development data created successfully");
|
||||
} catch (error) {
|
||||
logger.error("Error creating initial data:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
async function createEnergyUsageData(companyId) {
|
||||
const now = new Date();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const date = new Date(now);
|
||||
date.setMonth(date.getMonth() - i);
|
||||
await EnergyUsage.create({
|
||||
companyId,
|
||||
timestamp: date,
|
||||
electricity: 1000 + Math.random() * 500,
|
||||
gas: 700 + Math.random() * 300,
|
||||
water: 400 + Math.random() * 200,
|
||||
steam: 250 + Math.random() * 150,
|
||||
});
|
||||
}
|
||||
logger.info("Energy usage data created successfully");
|
||||
}
|
||||
|
||||
async function createEnergyCostData(companyId) {
|
||||
const now = new Date();
|
||||
const categories = ["electricity", "gas", "water", "steam"];
|
||||
const currentMonth = now.getMonth() + 1;
|
||||
const currentYear = now.getFullYear();
|
||||
|
||||
for (const category of categories) {
|
||||
const amount = Math.floor(1000000 + Math.random() * 5000000);
|
||||
await EnergyCost.create({
|
||||
companyId,
|
||||
year: currentYear,
|
||||
month: currentMonth,
|
||||
category,
|
||||
amount,
|
||||
usage: amount / 100,
|
||||
unitPrice: 100,
|
||||
});
|
||||
|
||||
await EnergyCost.create({
|
||||
companyId,
|
||||
year: currentMonth === 1 ? currentYear - 1 : currentYear,
|
||||
month: currentMonth === 1 ? 12 : currentMonth - 1,
|
||||
category,
|
||||
amount: Math.floor(amount * (0.9 + Math.random() * 0.2)),
|
||||
usage: amount / 100,
|
||||
unitPrice: 100,
|
||||
});
|
||||
}
|
||||
logger.info("Energy cost data created successfully");
|
||||
}
|
||||
|
||||
async function createAlertData(companyId) {
|
||||
const alertTypes = ["error", "warning", "info"];
|
||||
const alertMessages = [
|
||||
"전력 사용량이 임계치를 초과했습니다.",
|
||||
"가스 사용량이 전월 대비 20% 증가했습니다.",
|
||||
"에너지 사용 보고서가 생성되었습니다.",
|
||||
];
|
||||
|
||||
for (let i = 0; i < alertTypes.length; i++) {
|
||||
await Alert.create({
|
||||
companyId,
|
||||
type: alertTypes[i],
|
||||
message: alertMessages[i],
|
||||
});
|
||||
}
|
||||
logger.info("Alert data created successfully");
|
||||
}
|
||||
|
||||
async function createKpiData(companyId) {
|
||||
const now = new Date();
|
||||
const categories = ["electricity", "gas", "water", "steam"];
|
||||
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const date = new Date(now);
|
||||
date.setDate(date.getDate() - i);
|
||||
|
||||
for (const category of categories) {
|
||||
const baselineUsage = 1000 + Math.random() * 500;
|
||||
const targetUsage = baselineUsage * 0.9;
|
||||
const currentUsage = baselineUsage * (0.8 + Math.random() * 0.4);
|
||||
const efficiency = (targetUsage / currentUsage) * 100;
|
||||
const savings = ((baselineUsage - currentUsage) / baselineUsage) * 100;
|
||||
|
||||
await Kpi.create({
|
||||
}
|
||||
|
||||
async function createZoneAndEquipmentData(companyId, branchId) {
|
||||
try {
|
||||
// 1. 구역 생성
|
||||
const zones = await Promise.all(
|
||||
zoneDefinitions.map((zone) =>
|
||||
Zone.create({
|
||||
...zone,
|
||||
branchId,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const zoneMap = zones.reduce((acc, zone) => {
|
||||
acc[zone.name] = zone;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// 2. 설비 생성
|
||||
const equipment = await Promise.all(
|
||||
equipmentDefinitions.map((equip) =>
|
||||
Equipment.create({
|
||||
...equip,
|
||||
companyId,
|
||||
category,
|
||||
timestamp: date,
|
||||
currentUsage,
|
||||
targetUsage,
|
||||
baselineUsage,
|
||||
efficiency,
|
||||
savings,
|
||||
});
|
||||
}
|
||||
branchId,
|
||||
zoneId: zoneMap[equip.zone].id,
|
||||
installationDate: new Date(),
|
||||
isActive: true,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// 3. 정비 이력 생성
|
||||
for (const equip of equipment) {
|
||||
const now = new Date();
|
||||
|
||||
await Promise.all(
|
||||
maintenanceLogDefinitions.map((log, index) => {
|
||||
const scheduledDate = new Date(now);
|
||||
scheduledDate.setDate(scheduledDate.getDate() - (index + 1) * 30);
|
||||
|
||||
const completionDate = new Date(scheduledDate);
|
||||
completionDate.setHours(completionDate.getHours() + 4);
|
||||
|
||||
return MaintenanceLog.create({
|
||||
...log,
|
||||
equipmentId: equip.id,
|
||||
scheduledDate,
|
||||
completionDate,
|
||||
nextMaintenanceDate: new Date(
|
||||
scheduledDate.setMonth(scheduledDate.getMonth() + 1)
|
||||
),
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
logger.info("KPI data created successfully");
|
||||
|
||||
// 4. 설비 데이터 생성 (최근 24시간)
|
||||
const now = new Date();
|
||||
const hourlyData = Array.from({ length: 24 }, (_, i) => {
|
||||
const timestamp = new Date(now);
|
||||
timestamp.setHours(timestamp.getHours() - i);
|
||||
return timestamp;
|
||||
});
|
||||
|
||||
for (const equip of equipment) {
|
||||
const template = equipmentDataTemplate[equip.type];
|
||||
if (!template) continue;
|
||||
|
||||
await Promise.all(
|
||||
hourlyData.map((timestamp) => {
|
||||
const measurements = Object.entries(template.measurements).reduce(
|
||||
(acc, [key, value]) => {
|
||||
const variation = value * 0.1;
|
||||
acc[key] = value + (Math.random() - 0.5) * variation;
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
return EquipmentData.create({
|
||||
equipmentId: equip.id,
|
||||
timestamp,
|
||||
parameters: template.parameters,
|
||||
measurements,
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("Zone and equipment data created successfully");
|
||||
} catch (error) {
|
||||
logger.error("Error creating zone and equipment data:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createInitialData,
|
||||
};
|
||||
}
|
||||
|
||||
async function createEnergyUsageData(companyId) {
|
||||
const now = new Date();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const date = new Date(now);
|
||||
date.setMonth(date.getMonth() - i);
|
||||
await EnergyUsage.create({
|
||||
companyId,
|
||||
timestamp: date,
|
||||
electricity: 1000 + Math.random() * 500,
|
||||
gas: 700 + Math.random() * 300,
|
||||
water: 400 + Math.random() * 200,
|
||||
steam: 250 + Math.random() * 150,
|
||||
});
|
||||
}
|
||||
logger.info("Energy usage data created successfully");
|
||||
}
|
||||
|
||||
async function createEnergyCostData(companyId) {
|
||||
const now = new Date();
|
||||
const categories = ["electricity", "gas", "water", "steam"];
|
||||
const currentMonth = now.getMonth() + 1;
|
||||
const currentYear = now.getFullYear();
|
||||
|
||||
for (const category of categories) {
|
||||
const amount = Math.floor(1000000 + Math.random() * 5000000);
|
||||
await EnergyCost.create({
|
||||
companyId,
|
||||
year: currentYear,
|
||||
month: currentMonth,
|
||||
category,
|
||||
amount,
|
||||
usage: amount / 100,
|
||||
unitPrice: 100,
|
||||
});
|
||||
|
||||
await EnergyCost.create({
|
||||
companyId,
|
||||
year: currentMonth === 1 ? currentYear - 1 : currentYear,
|
||||
month: currentMonth === 1 ? 12 : currentMonth - 1,
|
||||
category,
|
||||
amount: Math.floor(amount * (0.9 + Math.random() * 0.2)),
|
||||
usage: amount / 100,
|
||||
unitPrice: 100,
|
||||
});
|
||||
}
|
||||
logger.info("Energy cost data created successfully");
|
||||
}
|
||||
|
||||
async function createAlertData(companyId) {
|
||||
const alertTypes = ["error", "warning", "info"];
|
||||
const alertMessages = [
|
||||
"전력 사용량이 임계치를 초과했습니다.",
|
||||
"가스 사용량이 전월 대비 20% 증가했습니다.",
|
||||
"에너지 사용 보고서가 생성되었습니다.",
|
||||
];
|
||||
|
||||
for (let i = 0; i < alertTypes.length; i++) {
|
||||
await Alert.create({
|
||||
companyId,
|
||||
type: alertTypes[i],
|
||||
message: alertMessages[i],
|
||||
});
|
||||
}
|
||||
logger.info("Alert data created successfully");
|
||||
}
|
||||
|
||||
async function createKpiData(companyId) {
|
||||
const now = new Date();
|
||||
const categories = ["electricity", "gas", "water", "steam"];
|
||||
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const date = new Date(now);
|
||||
date.setDate(date.getDate() - i);
|
||||
|
||||
for (const category of categories) {
|
||||
const baselineUsage = 1000 + Math.random() * 500;
|
||||
const targetUsage = baselineUsage * 0.9;
|
||||
const currentUsage = baselineUsage * (0.8 + Math.random() * 0.4);
|
||||
const efficiency = (targetUsage / currentUsage) * 100;
|
||||
const savings = ((baselineUsage - currentUsage) / baselineUsage) * 100;
|
||||
|
||||
await Kpi.create({
|
||||
companyId,
|
||||
category,
|
||||
timestamp: date,
|
||||
currentUsage,
|
||||
targetUsage,
|
||||
baselineUsage,
|
||||
efficiency,
|
||||
savings,
|
||||
});
|
||||
}
|
||||
}
|
||||
logger.info("KPI data created successfully");
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createInitialData,
|
||||
};
|
||||
|
@ -147,8 +147,144 @@ const userDefinitions = {
|
||||
],
|
||||
};
|
||||
|
||||
const zoneDefinitions = [
|
||||
{
|
||||
name: "생산1구역",
|
||||
description: "1층 생산구역",
|
||||
area: 500,
|
||||
},
|
||||
{
|
||||
name: "생산2구역",
|
||||
description: "2층 생산구역",
|
||||
area: 500,
|
||||
},
|
||||
{
|
||||
name: "유틸리티구역",
|
||||
description: "지하1층 유틸리티실",
|
||||
area: 200,
|
||||
},
|
||||
{
|
||||
name: "공조기계실",
|
||||
description: "옥상 기계실",
|
||||
area: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const equipmentDefinitions = [
|
||||
{
|
||||
name: "공조기-1",
|
||||
model: "HAV-1000",
|
||||
manufacturer: "삼성전자",
|
||||
type: "HVAC",
|
||||
specifications: {
|
||||
power: "30kW",
|
||||
airflow: "1000CMH",
|
||||
coolingCapacity: "100RT",
|
||||
heatingCapacity: "200,000kcal/h",
|
||||
},
|
||||
zone: "공조기계실",
|
||||
},
|
||||
{
|
||||
name: "보일러-1",
|
||||
model: "BST-2000",
|
||||
manufacturer: "경동나비엔",
|
||||
type: "Boiler",
|
||||
specifications: {
|
||||
power: "50kW",
|
||||
capacity: "2000kg/h",
|
||||
pressure: "5kg/cm²",
|
||||
efficiency: "95%",
|
||||
},
|
||||
zone: "유틸리티구역",
|
||||
},
|
||||
{
|
||||
name: "컴프레서-1",
|
||||
model: "ACP-500",
|
||||
manufacturer: "아틀라스콥코",
|
||||
type: "Compressor",
|
||||
specifications: {
|
||||
power: "75kW",
|
||||
capacity: "500CFM",
|
||||
pressure: "8bar",
|
||||
efficiency: "92%",
|
||||
},
|
||||
zone: "유틸리티구역",
|
||||
},
|
||||
];
|
||||
|
||||
const maintenanceLogDefinitions = [
|
||||
{
|
||||
type: "preventive",
|
||||
status: "completed",
|
||||
description: "정기 점검 및 필터 교체",
|
||||
findings: "필터 오염도 증가",
|
||||
actions: "필터 교체 완료",
|
||||
parts: {
|
||||
filter: { name: "프리필터", quantity: 2, cost: 50000 },
|
||||
belt: { name: "V-벨트", quantity: 1, cost: 30000 },
|
||||
},
|
||||
cost: 80000,
|
||||
equipmentStatus: "operational",
|
||||
},
|
||||
{
|
||||
type: "inspection",
|
||||
status: "completed",
|
||||
description: "월간 성능 검사",
|
||||
findings: "정상 작동 확인",
|
||||
actions: "특이사항 없음",
|
||||
cost: 0,
|
||||
equipmentStatus: "operational",
|
||||
},
|
||||
];
|
||||
|
||||
const equipmentDataTemplate = {
|
||||
HVAC: {
|
||||
parameters: {
|
||||
temperature: { min: 18, max: 26 },
|
||||
humidity: { min: 40, max: 60 },
|
||||
airflow: { min: 800, max: 1200 },
|
||||
},
|
||||
measurements: {
|
||||
temperature: 22,
|
||||
humidity: 50,
|
||||
airflow: 1000,
|
||||
power: 25,
|
||||
},
|
||||
},
|
||||
Boiler: {
|
||||
parameters: {
|
||||
pressure: { min: 4, max: 6 },
|
||||
temperature: { min: 70, max: 90 },
|
||||
waterLevel: { min: 60, max: 80 },
|
||||
},
|
||||
measurements: {
|
||||
pressure: 5,
|
||||
temperature: 80,
|
||||
waterLevel: 70,
|
||||
power: 45,
|
||||
},
|
||||
},
|
||||
Compressor: {
|
||||
parameters: {
|
||||
pressure: { min: 7, max: 9 },
|
||||
temperature: { min: 40, max: 60 },
|
||||
airflow: { min: 450, max: 550 },
|
||||
},
|
||||
measurements: {
|
||||
pressure: 8,
|
||||
temperature: 50,
|
||||
airflow: 500,
|
||||
power: 70,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
departmentStructure,
|
||||
roleDefinitions,
|
||||
userDefinitions,
|
||||
zoneDefinitions,
|
||||
equipmentDefinitions,
|
||||
maintenanceLogDefinitions,
|
||||
equipmentDataTemplate,
|
||||
};
|
||||
|
@ -795,6 +795,11 @@ anymatch@^3.0.3, anymatch@~3.1.2:
|
||||
normalize-path "^3.0.0"
|
||||
picomatch "^2.0.4"
|
||||
|
||||
append-field@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/append-field/-/append-field-1.0.0.tgz#1e3440e915f0b1203d23748e78edd7b9b5b43e56"
|
||||
integrity sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==
|
||||
|
||||
argparse@^1.0.7:
|
||||
version "1.0.10"
|
||||
resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911"
|
||||
@ -978,6 +983,13 @@ buffer@^6.0.3:
|
||||
base64-js "^1.3.1"
|
||||
ieee754 "^1.2.1"
|
||||
|
||||
busboy@^1.0.0:
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893"
|
||||
integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==
|
||||
dependencies:
|
||||
streamsearch "^1.1.0"
|
||||
|
||||
bytes@3.1.2:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
|
||||
@ -1136,6 +1148,16 @@ concat-map@0.0.1:
|
||||
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
|
||||
integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
|
||||
|
||||
concat-stream@^1.5.2:
|
||||
version "1.6.2"
|
||||
resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34"
|
||||
integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==
|
||||
dependencies:
|
||||
buffer-from "^1.0.0"
|
||||
inherits "^2.0.3"
|
||||
readable-stream "^2.2.2"
|
||||
typedarray "^0.0.6"
|
||||
|
||||
content-disposition@0.5.4:
|
||||
version "0.5.4"
|
||||
resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe"
|
||||
@ -1168,6 +1190,11 @@ cookiejar@^2.1.4:
|
||||
resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.4.tgz#ee669c1fea2cf42dc31585469d193fef0d65771b"
|
||||
integrity sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==
|
||||
|
||||
core-util-is@~1.0.0:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85"
|
||||
integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==
|
||||
|
||||
cors@^2.8.5:
|
||||
version "2.8.5"
|
||||
resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29"
|
||||
@ -1198,6 +1225,11 @@ cross-spawn@^7.0.2, cross-spawn@^7.0.3:
|
||||
shebang-command "^2.0.0"
|
||||
which "^2.0.1"
|
||||
|
||||
crypto@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/crypto/-/crypto-1.0.1.tgz#2af1b7cad8175d24c8a1b0778255794a21803037"
|
||||
integrity sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==
|
||||
|
||||
debug@2.6.9:
|
||||
version "2.6.9"
|
||||
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
|
||||
@ -1907,7 +1939,7 @@ inflight@^1.0.4:
|
||||
once "^1.3.0"
|
||||
wrappy "1"
|
||||
|
||||
inherits@2, inherits@2.0.4, inherits@^2.0.3:
|
||||
inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@~2.0.3:
|
||||
version "2.0.4"
|
||||
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
|
||||
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
||||
@ -1978,6 +2010,11 @@ is-stream@^2.0.0:
|
||||
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077"
|
||||
integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==
|
||||
|
||||
isarray@~1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
|
||||
integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==
|
||||
|
||||
isexe@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
|
||||
@ -2666,6 +2703,18 @@ minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
|
||||
dependencies:
|
||||
brace-expansion "^1.1.7"
|
||||
|
||||
minimist@^1.2.6:
|
||||
version "1.2.8"
|
||||
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
|
||||
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
|
||||
|
||||
mkdirp@^0.5.4:
|
||||
version "0.5.6"
|
||||
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6"
|
||||
integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==
|
||||
dependencies:
|
||||
minimist "^1.2.6"
|
||||
|
||||
moment-timezone@^0.5.43:
|
||||
version "0.5.46"
|
||||
resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.46.tgz#a21aa6392b3c6b3ed916cd5e95858a28d893704a"
|
||||
@ -2688,6 +2737,19 @@ ms@2.1.3, ms@^2.1.1, ms@^2.1.3:
|
||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
|
||||
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
|
||||
|
||||
multer@^1.4.5-lts.1:
|
||||
version "1.4.5-lts.1"
|
||||
resolved "https://registry.yarnpkg.com/multer/-/multer-1.4.5-lts.1.tgz#803e24ad1984f58edffbc79f56e305aec5cfd1ac"
|
||||
integrity sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==
|
||||
dependencies:
|
||||
append-field "^1.0.0"
|
||||
busboy "^1.0.0"
|
||||
concat-stream "^1.5.2"
|
||||
mkdirp "^0.5.4"
|
||||
object-assign "^4.1.1"
|
||||
type-is "^1.6.4"
|
||||
xtend "^4.0.0"
|
||||
|
||||
natural-compare@^1.4.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
|
||||
@ -2736,7 +2798,7 @@ npm-run-path@^4.0.1:
|
||||
dependencies:
|
||||
path-key "^3.0.0"
|
||||
|
||||
object-assign@^4:
|
||||
object-assign@^4, object-assign@^4.1.1:
|
||||
version "4.1.1"
|
||||
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
|
||||
integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
|
||||
@ -2987,6 +3049,11 @@ pretty-format@^29.7.0:
|
||||
ansi-styles "^5.0.0"
|
||||
react-is "^18.0.0"
|
||||
|
||||
process-nextick-args@~2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
|
||||
integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==
|
||||
|
||||
process@^0.11.10:
|
||||
version "0.11.10"
|
||||
resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
|
||||
@ -3055,6 +3122,19 @@ react-is@^18.0.0:
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e"
|
||||
integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==
|
||||
|
||||
readable-stream@^2.2.2:
|
||||
version "2.3.8"
|
||||
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b"
|
||||
integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==
|
||||
dependencies:
|
||||
core-util-is "~1.0.0"
|
||||
inherits "~2.0.3"
|
||||
isarray "~1.0.0"
|
||||
process-nextick-args "~2.0.0"
|
||||
safe-buffer "~5.1.1"
|
||||
string_decoder "~1.1.1"
|
||||
util-deprecate "~1.0.1"
|
||||
|
||||
readable-stream@^3.4.0:
|
||||
version "3.6.2"
|
||||
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967"
|
||||
@ -3147,6 +3227,11 @@ safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@~5.2.0:
|
||||
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
|
||||
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
|
||||
|
||||
safe-buffer@~5.1.0, safe-buffer@~5.1.1:
|
||||
version "5.1.2"
|
||||
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
|
||||
integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
|
||||
|
||||
safe-stable-stringify@^2.3.1:
|
||||
version "2.5.0"
|
||||
resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz#4ca2f8e385f2831c432a719b108a3bf7af42a1dd"
|
||||
@ -3341,6 +3426,11 @@ statuses@2.0.1:
|
||||
resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63"
|
||||
integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==
|
||||
|
||||
streamsearch@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764"
|
||||
integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==
|
||||
|
||||
string-length@^4.0.1:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a"
|
||||
@ -3365,6 +3455,13 @@ string_decoder@^1.1.1, string_decoder@^1.3.0:
|
||||
dependencies:
|
||||
safe-buffer "~5.2.0"
|
||||
|
||||
string_decoder@~1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8"
|
||||
integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==
|
||||
dependencies:
|
||||
safe-buffer "~5.1.0"
|
||||
|
||||
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
@ -3510,7 +3607,7 @@ type-fest@^0.21.3:
|
||||
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37"
|
||||
integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==
|
||||
|
||||
type-is@~1.6.18:
|
||||
type-is@^1.6.4, type-is@~1.6.18:
|
||||
version "1.6.18"
|
||||
resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
|
||||
integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==
|
||||
@ -3518,6 +3615,11 @@ type-is@~1.6.18:
|
||||
media-typer "0.3.0"
|
||||
mime-types "~2.1.24"
|
||||
|
||||
typedarray@^0.0.6:
|
||||
version "0.0.6"
|
||||
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
|
||||
integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==
|
||||
|
||||
undefsafe@^2.0.5:
|
||||
version "2.0.5"
|
||||
resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.5.tgz#38733b9327bdcd226db889fb723a6efd162e6e2c"
|
||||
@ -3553,7 +3655,7 @@ uri-js@^4.2.2:
|
||||
dependencies:
|
||||
punycode "^2.1.0"
|
||||
|
||||
util-deprecate@^1.0.1:
|
||||
util-deprecate@^1.0.1, util-deprecate@~1.0.1:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
|
||||
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
|
||||
|
@ -1,4 +1,10 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {};
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
experimental: {
|
||||
optimizeCss: true,
|
||||
scrollRestoration: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
@ -25,6 +25,7 @@
|
||||
"@tanstack/react-query-devtools": "^5.59.16",
|
||||
"@tanstack/react-table": "^8.20.5",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/react-dropzone": "^5.1.0",
|
||||
"axios": "^1.7.7",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
|
@ -128,7 +128,7 @@ const AccountsPage = () => {
|
||||
const columns: ColumnDef<User>[] = [
|
||||
{
|
||||
accessorKey: "username",
|
||||
header: "유저네임",
|
||||
header: "아이디",
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
|
@ -0,0 +1,421 @@
|
||||
// src/app/(equipment)/inventory/components/EquipmentForm.tsx
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Equipment } from "@/types/equipment";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { FileUploader } from "@/components/ui/file-uploader";
|
||||
|
||||
// 설비 등록/수정 폼 스키마
|
||||
const equipmentSchema = z.object({
|
||||
name: z.string().min(1, "설비명을 입력하세요"),
|
||||
model: z.string().min(1, "모델명을 입력하세요"),
|
||||
manufacturer: z.string().min(1, "제조사를 입력하세요"),
|
||||
type: z.enum(["HVAC", "Boiler", "Compressor", "Motor", "Pump", "Other"]),
|
||||
specifications: z.record(z.string(), z.any()).optional(),
|
||||
installationDate: z.string().min(1, "설치일을 입력하세요"),
|
||||
zoneId: z.string().min(1, "설치 구역을 선택하세요"),
|
||||
description: z.string().optional(),
|
||||
documents: z
|
||||
.object({
|
||||
manual: z.any().optional(),
|
||||
technical: z.array(z.any()).optional(),
|
||||
certificates: z.array(z.any()).optional(),
|
||||
drawings: z.array(z.any()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
interface EquipmentFormProps {
|
||||
initialData?: Equipment;
|
||||
onSubmit: (data: z.infer<typeof equipmentSchema>) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const defaultSpecifications = {
|
||||
power: "",
|
||||
voltage: "",
|
||||
current: "",
|
||||
efficiency: "",
|
||||
capacity: "",
|
||||
};
|
||||
|
||||
export function EquipmentForm({
|
||||
initialData,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: EquipmentFormProps) {
|
||||
const form = useForm<z.infer<typeof equipmentSchema>>({
|
||||
resolver: zodResolver(equipmentSchema),
|
||||
defaultValues: {
|
||||
name: initialData?.name || "",
|
||||
model: initialData?.model || "",
|
||||
manufacturer: initialData?.manufacturer || "",
|
||||
type: initialData?.type || "Other",
|
||||
specifications: initialData?.specifications || defaultSpecifications,
|
||||
installationDate: initialData?.installationDate
|
||||
? new Date(initialData.installationDate).toISOString().split("T")[0]
|
||||
: "",
|
||||
zoneId: initialData?.zoneId || "",
|
||||
description: initialData?.description || "",
|
||||
documents: initialData?.documents || {
|
||||
manual: null,
|
||||
technical: [],
|
||||
certificates: [],
|
||||
drawings: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch zone for dropdown
|
||||
const { data: zone } = useQuery({
|
||||
queryKey: ["zone"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/api/v1/app/zone");
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
// 사양 정보 상태 추가
|
||||
const [specifications, setSpecifications] = React.useState<
|
||||
Record<string, string>
|
||||
>(initialData?.specifications || defaultSpecifications);
|
||||
|
||||
// 사양 정보 필드 추가/제거 핸들러 수정
|
||||
const handleSpecificationChange = (key: string, value: string) => {
|
||||
const newSpecs = {
|
||||
...specifications,
|
||||
[key]: value,
|
||||
};
|
||||
setSpecifications(newSpecs);
|
||||
form.setValue("specifications", newSpecs);
|
||||
};
|
||||
|
||||
const handleAddSpecification = () => {
|
||||
const key = prompt("추가할 사양의 이름을 입력하세요");
|
||||
if (key) {
|
||||
const newSpecs = {
|
||||
...specifications,
|
||||
[key]: "",
|
||||
};
|
||||
setSpecifications(newSpecs);
|
||||
form.setValue("specifications", newSpecs);
|
||||
}
|
||||
};
|
||||
|
||||
// 사양 정보 필드 삭제 핸들러 추가
|
||||
const handleRemoveSpecification = (keyToRemove: string) => {
|
||||
const newSpecs = { ...specifications };
|
||||
delete newSpecs[keyToRemove];
|
||||
setSpecifications(newSpecs);
|
||||
form.setValue("specifications", newSpecs);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>설비명</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="설비명을 입력하세요" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="model"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>모델명</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="모델명을 입력하세요" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="manufacturer"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>제조사</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="제조사를 입력하세요" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>설비 유형</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="설비 유형을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="HVAC">공조설비</SelectItem>
|
||||
<SelectItem value="Boiler">보일러</SelectItem>
|
||||
<SelectItem value="Compressor">컴프레서</SelectItem>
|
||||
<SelectItem value="Motor">모터</SelectItem>
|
||||
<SelectItem value="Pump">펌프</SelectItem>
|
||||
<SelectItem value="Other">기타</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="installationDate"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>설치일</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="date" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="zoneId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>설치 구역</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="설치 구역을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{zone?.map((zone: any) => (
|
||||
<SelectItem key={zone.id} value={zone.id}>
|
||||
{zone.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>설명</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="설비에 대한 설명을 입력하세요"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 사양 정보 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-medium">사양 정보</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleAddSpecification}
|
||||
>
|
||||
사양 추가
|
||||
</Button>
|
||||
</div>
|
||||
{Object.entries(specifications).map(([key, value]) => (
|
||||
<div key={key} className="flex gap-4">
|
||||
<div className="w-1/3 relative">
|
||||
<Input value={key} disabled />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2"
|
||||
onClick={() => handleRemoveSpecification(key)}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) =>
|
||||
handleSpecificationChange(key, e.target.value)
|
||||
}
|
||||
placeholder="값을 입력하세요"
|
||||
className="w-2/3"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 문서 업로드 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">문서 관리</h3>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="documents.manual"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>설비 매뉴얼</FormLabel>
|
||||
<FormControl>
|
||||
<FileUploader
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
accept=".pdf,.doc,.docx"
|
||||
moduleType="equipment"
|
||||
category="manual"
|
||||
referenceId={initialData?.id} // 수정 시에만 id가 있음
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="documents.technical"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>기술 문서</FormLabel>
|
||||
<FormControl>
|
||||
<FileUploader
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
accept=".pdf,.doc,.docx"
|
||||
multiple
|
||||
moduleType="equipment"
|
||||
category="technical"
|
||||
referenceId={initialData?.id}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="documents.certificates"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>인증서</FormLabel>
|
||||
<FormControl>
|
||||
<FileUploader
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
accept=".pdf,.jpg,.png"
|
||||
multiple
|
||||
moduleType="equipment"
|
||||
category="certificates"
|
||||
referenceId={initialData?.id}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="documents.drawings"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>도면</FormLabel>
|
||||
<FormControl>
|
||||
<FileUploader
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
accept=".pdf,.jpg,.png"
|
||||
multiple
|
||||
moduleType="equipment"
|
||||
category="drawings"
|
||||
referenceId={initialData?.id}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit">{initialData ? "수정하기" : "등록하기"}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
146
fems-app/src/app/(equipment)/inventory/[mode]/page.tsx
Normal file
146
fems-app/src/app/(equipment)/inventory/[mode]/page.tsx
Normal file
@ -0,0 +1,146 @@
|
||||
// src/app/(equipment)/inventory/[mode]/page.tsx
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useParams, useSearchParams, useRouter } from "next/navigation";
|
||||
import { Equipment } from "@/types/equipment";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { EquipmentForm } from "./components/EquipmentForm";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { AxiosError } from "axios";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
|
||||
const EquipmentFormPage = () => {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const mode = params.mode as "create" | "edit";
|
||||
const equipmentId = searchParams.get("id"); // URL의 쿼리 파라미터에서 id 가져오기
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuthStore();
|
||||
|
||||
// 설비 상세 정보 조회 (수정 모드일 경우)
|
||||
const { data: equipment, isLoading } = useQuery<Equipment>({
|
||||
queryKey: ["equipment", mode === "edit" && equipmentId],
|
||||
queryFn: async () => {
|
||||
if (!equipmentId) throw new Error("Equipment ID not found");
|
||||
const { data } = await api.get<Equipment>(
|
||||
`/api/v1/app/equipment/${equipmentId}`
|
||||
);
|
||||
return data;
|
||||
},
|
||||
enabled: mode === "edit" && !!equipmentId, // equipmentId가 있을 때만 쿼리 실행
|
||||
});
|
||||
|
||||
// 설비 생성 mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (newEquipment: Partial<Equipment>) => {
|
||||
const equipmentWithCompanyId = {
|
||||
...newEquipment,
|
||||
companyId: user?.companyId,
|
||||
};
|
||||
const { data } = await api.post<Equipment>(
|
||||
"/api/v1/app/equipment",
|
||||
equipmentWithCompanyId
|
||||
);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["equipment"] });
|
||||
toast({
|
||||
title: "설비 등록",
|
||||
description: "새로운 설비가 등록되었습니다.",
|
||||
});
|
||||
router.push("/inventory");
|
||||
},
|
||||
onError: (error: AxiosError) => {
|
||||
toast({
|
||||
title: "설비 등록 실패",
|
||||
description: (error.response?.data as { message: string }).message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// 설비 수정 mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async (equipmentData: Partial<Equipment>) => {
|
||||
if (!equipmentId) throw new Error("Equipment ID not found");
|
||||
const { data } = await api.put<Equipment>(
|
||||
`/api/v1/app/equipment/${equipmentId}`,
|
||||
equipmentData
|
||||
);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["equipment"] });
|
||||
toast({
|
||||
title: "설비 수정",
|
||||
description: "설비 정보가 수정되었습니다.",
|
||||
});
|
||||
router.push("/inventory");
|
||||
},
|
||||
onError: (error: AxiosError) => {
|
||||
toast({
|
||||
title: "설비 수정 실패",
|
||||
description: (error.response?.data as { message: string }).message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (mode === "edit" && !equipmentId) {
|
||||
router.push("/inventory");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (mode === "edit" && isLoading) return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-6">
|
||||
<Card>
|
||||
{/* Header */}
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">
|
||||
{mode === "create" ? "설비 등록" : "설비 수정"}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{mode === "create"
|
||||
? "새로운 설비를 등록합니다."
|
||||
: "기존 설비 정보를 수정합니다."}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
뒤로 가기
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{/* Form */}
|
||||
<CardContent className="max-w-3xl mx-auto">
|
||||
<EquipmentForm
|
||||
initialData={mode === "edit" ? equipment : undefined}
|
||||
onSubmit={(data) => {
|
||||
if (mode === "edit") {
|
||||
updateMutation.mutate(data);
|
||||
} else {
|
||||
createMutation.mutate(data);
|
||||
}
|
||||
}}
|
||||
onCancel={() => router.push("/inventory")}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EquipmentFormPage;
|
171
fems-app/src/app/(equipment)/inventory/detail/[id]/page.tsx
Normal file
171
fems-app/src/app/(equipment)/inventory/detail/[id]/page.tsx
Normal file
@ -0,0 +1,171 @@
|
||||
// src/app/(equipment)/inventory/detail/[id]/page.tsx
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Equipment } from "@/types/equipment";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft, Edit } from "lucide-react";
|
||||
import { Card, CardHeader, CardContent } from "@/components/ui/card";
|
||||
|
||||
const EquipmentDetailPage = () => {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const equipmentId = params.id as string;
|
||||
|
||||
// 설비 상세 정보 조회
|
||||
const { data: equipment, isLoading } = useQuery<Equipment>({
|
||||
queryKey: ["equipment", equipmentId],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<Equipment>(
|
||||
`/api/v1/app/equipment/${equipmentId}`
|
||||
);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (!equipment) return <div>설비를 찾을 수 없습니다.</div>;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">{equipment.name}</h1>
|
||||
<p className="text-muted-foreground">{equipment.description}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
뒤로 가기
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
router.push(`/inventory/edit?id=${equipment.id}`)
|
||||
}
|
||||
>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
수정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">기본 정보</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">설비명</div>
|
||||
<div>{equipment.name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">모델명</div>
|
||||
<div>{equipment.model}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">제조사</div>
|
||||
<div>{equipment.manufacturer}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">설비 유형</div>
|
||||
<div>
|
||||
{
|
||||
{
|
||||
HVAC: "공조설비",
|
||||
Boiler: "보일러",
|
||||
Compressor: "컴프레서",
|
||||
Motor: "모터",
|
||||
Pump: "펌프",
|
||||
Other: "기타",
|
||||
}[equipment.type]
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">설치일</div>
|
||||
<div>
|
||||
{new Date(equipment.installationDate).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">최근 정비일</div>
|
||||
<div>
|
||||
{equipment.lastMaintenance
|
||||
? new Date(equipment.lastMaintenance).toLocaleDateString()
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">구역</div>
|
||||
<div>{equipment.Zone?.name || "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">가동 상태</div>
|
||||
<div>{equipment.isActive ? "가동중" : "정지"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 사양 정보 */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">사양 정보</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{Object.entries(equipment.specifications || {}).map(
|
||||
([key, value]) => (
|
||||
<div key={key}>
|
||||
<div className="text-sm text-muted-foreground">{key}</div>
|
||||
<div>{value}</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 문서 정보 */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">문서 정보</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">설비 매뉴얼</div>
|
||||
<div>{equipment.documents?.manual || "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">기술 문서</div>
|
||||
<div>
|
||||
{equipment.documents?.technical?.length
|
||||
? equipment.documents.technical.join(", ")
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">인증서</div>
|
||||
<div>
|
||||
{equipment.documents?.certificates?.length
|
||||
? equipment.documents.certificates.join(", ")
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">도면</div>
|
||||
<div>
|
||||
{equipment.documents?.drawings?.length
|
||||
? equipment.documents.drawings.join(", ")
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EquipmentDetailPage;
|
@ -1 +1,304 @@
|
||||
// src/(equipment)/inventory/page.tsx
|
||||
// src/app/(equipment)/inventory/page.tsx
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { DataTable } from "@/components/ui/data-table";
|
||||
import { api } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Plus, Edit, Trash2, FileText } from "lucide-react";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { AxiosError } from "axios";
|
||||
import { Equipment } from "@/types/equipment";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const InventoryPage = () => {
|
||||
const { token, user } = useAuthStore();
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [editingEquipment, setEditingEquipment] =
|
||||
React.useState<Equipment | null>(null);
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
|
||||
// Fetch equipment
|
||||
const { data: equipment, isLoading } = useQuery<Equipment[]>({
|
||||
queryKey: ["equipment"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<Equipment[]>("/api/v1/app/equipment");
|
||||
return data;
|
||||
},
|
||||
enabled: !!token,
|
||||
});
|
||||
|
||||
// Create equipment mutation
|
||||
// const createMutation = useMutation({
|
||||
// mutationFn: async (newEquipment: Partial<Equipment>) => {
|
||||
// const equipmentWithCompanyId = {
|
||||
// ...newEquipment,
|
||||
// companyId: user?.companyId,
|
||||
// };
|
||||
// const { data } = await api.post<Equipment>(
|
||||
// "/api/v1/app/equipment",
|
||||
// equipmentWithCompanyId
|
||||
// );
|
||||
// return data;
|
||||
// },
|
||||
// onSuccess: () => {
|
||||
// queryClient.invalidateQueries({ queryKey: ["equipment"] });
|
||||
// setIsOpen(false);
|
||||
// toast({
|
||||
// title: "설비 등록",
|
||||
// description: "새로운 설비가 등록되었습니다.",
|
||||
// });
|
||||
// },
|
||||
// onError: (error: AxiosError) => {
|
||||
// toast({
|
||||
// title: "설비 등록 실패",
|
||||
// description: (error.response?.data as { message: string }).message,
|
||||
// variant: "destructive",
|
||||
// });
|
||||
// },
|
||||
// });
|
||||
|
||||
// Update equipment mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async (equipmentData: Partial<Equipment>) => {
|
||||
const { data } = await api.put<Equipment>(
|
||||
`/api/v1/app/equipment/${equipmentData.id}`,
|
||||
equipmentData
|
||||
);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["equipment"] });
|
||||
setIsOpen(false);
|
||||
setEditingEquipment(null);
|
||||
toast({
|
||||
title: "설비 수정",
|
||||
description: "설비 정보가 수정되었습니다.",
|
||||
});
|
||||
},
|
||||
onError: (error: AxiosError) => {
|
||||
toast({
|
||||
title: "설비 수정 실패",
|
||||
description: (error.response?.data as { message: string }).message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Delete equipment mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await api.delete(`/api/v1/app/equipment/${id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["equipment"] });
|
||||
toast({
|
||||
title: "설비 삭제",
|
||||
description: "설비가 삭제되었습니다.",
|
||||
});
|
||||
},
|
||||
onError: (error: AxiosError) => {
|
||||
toast({
|
||||
title: "설비 삭제 실패",
|
||||
description: (error.response?.data as { message: string }).message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Table columns
|
||||
const columns: ColumnDef<Equipment>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "설비명",
|
||||
},
|
||||
{
|
||||
accessorKey: "model",
|
||||
header: "모델명",
|
||||
},
|
||||
{
|
||||
accessorKey: "manufacturer",
|
||||
header: "제조사",
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: "유형",
|
||||
cell: ({ row }) => {
|
||||
const typeMap: Record<string, string> = {
|
||||
HVAC: "공조설비",
|
||||
Boiler: "보일러",
|
||||
Compressor: "컴프레서",
|
||||
Motor: "모터",
|
||||
Pump: "펌프",
|
||||
Other: "기타",
|
||||
};
|
||||
return typeMap[row.original.type] || row.original.type;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "installationDate",
|
||||
header: "설치일",
|
||||
cell: ({ row }) =>
|
||||
new Date(row.original.installationDate).toLocaleDateString(),
|
||||
},
|
||||
{
|
||||
accessorKey: "lastMaintenance",
|
||||
header: "최근 정비일",
|
||||
cell: ({ row }) =>
|
||||
row.original.lastMaintenance
|
||||
? new Date(row.original.lastMaintenance).toLocaleDateString()
|
||||
: "-",
|
||||
},
|
||||
{
|
||||
accessorKey: "Zone.name",
|
||||
header: "설치 구역",
|
||||
cell: ({ row }) => row.original.Zone?.name || "-",
|
||||
},
|
||||
{
|
||||
accessorKey: "isActive",
|
||||
header: "가동 상태",
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
checked={row.original.isActive}
|
||||
onCheckedChange={(value) => {
|
||||
updateMutation.mutate({ id: row.original.id, isActive: value });
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "액션",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
router.push(`/inventory/detail/${row.original.id}`);
|
||||
}}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
router.push(`/inventory/edit?id=${row.original.id}`);
|
||||
}}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm("정말 삭제하시겠습니까?")) {
|
||||
deleteMutation.mutate(row.original.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl font-bold">설비 관리</h1>
|
||||
<p className="text-muted-foreground">설비를 등록하고 관리합니다.</p>
|
||||
</div>
|
||||
<Button onClick={() => router.push("/inventory/create")}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
설비 등록
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Equipment Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>설비 목록</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{equipment && equipment.length > 0 ? (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={equipment}
|
||||
filters={[
|
||||
{
|
||||
id: "type",
|
||||
title: "설비 유형",
|
||||
options: [
|
||||
{ value: "HVAC", label: "공조설비" },
|
||||
{ value: "Boiler", label: "보일러" },
|
||||
{ value: "Compressor", label: "컴프레서" },
|
||||
{ value: "Motor", label: "모터" },
|
||||
{ value: "Pump", label: "펌프" },
|
||||
{ value: "Other", label: "기타" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "isActive",
|
||||
title: "가동 상태",
|
||||
options: [
|
||||
{ value: "true", label: "가동중" },
|
||||
{ value: "false", label: "중지" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
등록된 설비가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Equipment Create/Edit Dialog */}
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
if (!open) setEditingEquipment(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingEquipment ? "설비 수정" : "새 설비"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingEquipment
|
||||
? "기존 설비 정보를 수정합니다."
|
||||
: "새로운 설비를 등록합니다."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InventoryPage;
|
||||
|
@ -1,25 +1,89 @@
|
||||
// src/app/page.tsx (랜딩 페이지)
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Factory,
|
||||
Battery,
|
||||
LineChart,
|
||||
Settings,
|
||||
Building2,
|
||||
Smartphone,
|
||||
ChevronRight,
|
||||
Award,
|
||||
Lightbulb,
|
||||
BarChart3,
|
||||
} from "lucide-react";
|
||||
|
||||
export default function Home() {
|
||||
// 산업 분야별 솔루션 데이터
|
||||
const industries = [
|
||||
{
|
||||
title: "제조업",
|
||||
icon: <Factory className="h-12 w-12 mb-4 text-blue-600" />,
|
||||
description: "생산 공정 최적화와 에너지 효율 향상으로 제조 경쟁력 강화",
|
||||
features: ["실시간 에너지 모니터링", "설비별 효율 분석", "원단위 관리"],
|
||||
},
|
||||
{
|
||||
title: "화학/정유",
|
||||
icon: <Battery className="h-12 w-12 mb-4 text-blue-600" />,
|
||||
description: "공정 에너지 최적화 및 안전한 운영 지원",
|
||||
features: ["공정 최적화", "에너지 회수 분석", "규제 대응"],
|
||||
},
|
||||
{
|
||||
title: "반도체/디스플레이",
|
||||
icon: <Settings className="h-12 w-12 mb-4 text-blue-600" />,
|
||||
description: "클린룸 및 유틸리티 시스템의 효율적 운영",
|
||||
features: ["클린룸 에너지 관리", "유틸리티 최적화", "품질 연계 분석"],
|
||||
},
|
||||
];
|
||||
|
||||
// 주요 기능 데이터
|
||||
const features = [
|
||||
{
|
||||
icon: <LineChart className="h-6 w-6 text-blue-600" />,
|
||||
title: "실시간 모니터링",
|
||||
description: "설비와 에너지 사용량을 실시간으로 모니터링",
|
||||
},
|
||||
{
|
||||
icon: <BarChart3 className="h-6 w-6 text-blue-600" />,
|
||||
title: "데이터 분석",
|
||||
description: "AI 기반 에너지 사용 패턴 분석 및 최적화",
|
||||
},
|
||||
{
|
||||
icon: <Lightbulb className="h-6 w-6 text-blue-600" />,
|
||||
title: "절감 방안 도출",
|
||||
description: "맞춤형 에너지 절감 방안 제시",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Hero Section */}
|
||||
<div className="relative h-screen flex items-center justify-center bg-gradient-to-r from-blue-600 to-blue-800">
|
||||
<div className="absolute inset-0 bg-black/50" />
|
||||
<div className="relative z-10 text-center text-white px-4">
|
||||
<div className="relative z-10 text-center text-white px-4 max-w-4xl mx-auto">
|
||||
<h1 className="text-5xl font-bold mb-6">
|
||||
Factory Energy Management System
|
||||
</h1>
|
||||
<p className="text-xl mb-8">
|
||||
스마트한 에너지 관리로 비용 절감과 효율성을 높이세요
|
||||
<p className="text-xl mb-8 leading-relaxed">
|
||||
최첨단 AI 기술로 구현하는 스마트 에너지 관리 시스템으로 비용 절감과
|
||||
효율성을 극대화하세요
|
||||
</p>
|
||||
<div className="space-x-4">
|
||||
<Link href="/login">
|
||||
<Button size="lg">시작하기</Button>
|
||||
<Button size="lg" className="bg-blue-500 hover:bg-blue-600">
|
||||
시작하기
|
||||
<ChevronRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button variant="outline" size="lg">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="text-white border-white hover:bg-white/10"
|
||||
>
|
||||
자세히 보기
|
||||
</Button>
|
||||
</div>
|
||||
@ -27,7 +91,178 @@ export default function Home() {
|
||||
</div>
|
||||
|
||||
{/* Features Section */}
|
||||
<div className="py-20 bg-white">{/* ... Features 내용 */}</div>
|
||||
<div className="py-20 bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">주요 기능</h2>
|
||||
<p className="text-xl text-gray-600">
|
||||
스마트한 에너지 관리를 위한 핵심 기능을 제공합니다
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{features.map((feature, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
className="text-center p-6 hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<CardContent>
|
||||
<div className="flex justify-center mb-4">{feature.icon}</div>
|
||||
<h3 className="text-xl font-semibold mb-2">
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p className="text-gray-600">{feature.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Industries Section */}
|
||||
<div className="py-20 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
산업별 맞춤 솔루션
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600">
|
||||
다양한 산업 분야의 특성을 고려한 최적화된 솔루션을 제공합니다
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{industries.map((industry, index) => (
|
||||
<Card key={index} className="hover:shadow-lg transition-shadow">
|
||||
<CardContent className="p-6">
|
||||
<div className="text-center">
|
||||
{industry.icon}
|
||||
<h3 className="text-xl font-semibold mb-3">
|
||||
{industry.title}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">{industry.description}</p>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{industry.features.map((feature, idx) => (
|
||||
<li key={idx} className="flex items-center text-gray-700">
|
||||
<ChevronRight className="h-4 w-4 text-blue-600 mr-2" />
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Benefits Section */}
|
||||
<div className="py-20 bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">도입 효과</h2>
|
||||
<p className="text-xl text-gray-600">
|
||||
FEMS 도입을 통해 기대할 수 있는 핵심 가치
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-4 gap-8">
|
||||
{[
|
||||
{
|
||||
title: "에너지 비용 절감",
|
||||
value: "15~25%",
|
||||
description: "연간 에너지 비용 절감",
|
||||
},
|
||||
{
|
||||
title: "설비 효율 개선",
|
||||
value: "20%",
|
||||
description: "설비 운영 효율 향상",
|
||||
},
|
||||
{
|
||||
title: "탄소 배출 감축",
|
||||
value: "30%",
|
||||
description: "온실가스 배출량 감축",
|
||||
},
|
||||
{
|
||||
title: "투자비 회수",
|
||||
value: "2년",
|
||||
description: "평균 투자비 회수 기간",
|
||||
},
|
||||
].map((benefit, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
className="text-center hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<div className="text-4xl font-bold text-blue-600 mb-2">
|
||||
{benefit.value}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{benefit.title}
|
||||
</h3>
|
||||
<p className="text-gray-600">{benefit.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA Section */}
|
||||
<div className="py-20 bg-blue-600">
|
||||
<div className="max-w-4xl mx-auto px-4 text-center text-white">
|
||||
<h2 className="text-3xl font-bold mb-4">지금 바로 시작하세요</h2>
|
||||
<p className="text-xl mb-8">
|
||||
스마트한 에너지 관리의 시작, FEMS가 함께합니다
|
||||
</p>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="text-white border-white hover:bg-white/10"
|
||||
>
|
||||
무료 상담 신청
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-gray-900 text-gray-300 py-12">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="grid md:grid-cols-4 gap-8">
|
||||
<div>
|
||||
<h3 className="text-white font-semibold mb-4">About Us</h3>
|
||||
<p className="text-sm">
|
||||
최첨단 기술로 에너지 관리의 혁신을 선도하는 기업입니다.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-white font-semibold mb-4">Solutions</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>제조업</li>
|
||||
<li>화학/정유</li>
|
||||
<li>반도체/디스플레이</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-white font-semibold mb-4">Support</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>기술지원</li>
|
||||
<li>교육</li>
|
||||
<li>문의하기</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-white font-semibold mb-4">Contact</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>contact@fems.com</li>
|
||||
<li>02-123-4567</li>
|
||||
<li>서울시 강남구 테헤란로</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-gray-800 mt-8 pt-8 text-sm text-center">
|
||||
© 2024 FEMS. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -51,42 +51,42 @@ const menuItems = [
|
||||
{
|
||||
title: "에너지 모니터링",
|
||||
items: [
|
||||
{ title: "전력", href: "/monitoring/electricity", icon: Zap },
|
||||
{ title: "가스", href: "/monitoring/gas", icon: Flame },
|
||||
{ title: "용수", href: "/monitoring/water", icon: Droplet },
|
||||
{ title: "스팀", href: "/monitoring/steam", icon: Wind },
|
||||
{ title: "전력", href: "/electricity", icon: Zap },
|
||||
{ title: "가스", href: "/gas", icon: Flame },
|
||||
{ title: "용수", href: "/water", icon: Droplet },
|
||||
{ title: "스팀", href: "/steam", icon: Wind },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "설비 관리",
|
||||
items: [
|
||||
{ title: "설비 목록", href: "/equipment/inventory", icon: Box },
|
||||
{ title: "상태 모니터링", href: "/equipment/monitoring", icon: Activity },
|
||||
{ title: "정비 관리", href: "/equipment/maintenance", icon: Wrench },
|
||||
{ title: "설비 목록", href: "/inventory", icon: Box },
|
||||
{ title: "상태 모니터링", href: "/monitoring", icon: Activity },
|
||||
{ title: "정비 관리", href: "/maintenance", icon: Wrench },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "분석/리포트",
|
||||
items: [
|
||||
{ title: "에너지 분석", href: "/analysis/energy", icon: LineChart },
|
||||
{ title: "원단위 분석", href: "/analysis/efficiency", icon: BarChart },
|
||||
{ title: "보고서", href: "/analysis/reports", icon: FileText },
|
||||
{ title: "에너지 분석", href: "/energy", icon: LineChart },
|
||||
{ title: "원단위 분석", href: "/efficiency", icon: BarChart },
|
||||
{ title: "보고서", href: "/reports", icon: FileText },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "알람/이벤트",
|
||||
items: [
|
||||
{ title: "실시간 알람", href: "/alarm/realtime", icon: Bell },
|
||||
{ title: "이력 관리", href: "/alarm/history", icon: History },
|
||||
{ title: "알람 설정", href: "/alarm/settings", icon: SettingsIcon },
|
||||
{ title: "실시간 알람", href: "/realtime", icon: Bell },
|
||||
{ title: "이력 관리", href: "/history", icon: History },
|
||||
{ title: "알람 설정", href: "/settings", icon: SettingsIcon },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "에너지 계획",
|
||||
items: [
|
||||
{ title: "절감 목표", href: "/planning/targets", icon: Target },
|
||||
{ title: "수요 예측", href: "/planning/forecast", icon: TrendingUp },
|
||||
{ title: "최적화", href: "/planning/optimization", icon: Brain },
|
||||
{ title: "절감 목표", href: "/targets", icon: Target },
|
||||
{ title: "수요 예측", href: "/forecast", icon: TrendingUp },
|
||||
{ title: "최적화", href: "/optimization", icon: Brain },
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -108,7 +108,7 @@ const menuItems = [
|
||||
{
|
||||
title: "지원/커뮤니티",
|
||||
items: [
|
||||
{ title: "도움말", href: "/support/help", icon: HelpCircle },
|
||||
{ title: "도움말", href: "/help", icon: HelpCircle },
|
||||
{ title: "게시판", href: "/community/forum", icon: MessageSquare },
|
||||
{ title: "뉴스", href: "/community/news", icon: Newspaper },
|
||||
],
|
||||
|
@ -51,42 +51,42 @@ const menuItems = [
|
||||
{
|
||||
title: "에너지 모니터링",
|
||||
items: [
|
||||
{ title: "전력", href: "/monitoring/electricity", icon: Zap },
|
||||
{ title: "가스", href: "/monitoring/gas", icon: Flame },
|
||||
{ title: "용수", href: "/monitoring/water", icon: Droplet },
|
||||
{ title: "스팀", href: "/monitoring/steam", icon: Wind },
|
||||
{ title: "전력", href: "/electricity", icon: Zap },
|
||||
{ title: "가스", href: "/gas", icon: Flame },
|
||||
{ title: "용수", href: "/water", icon: Droplet },
|
||||
{ title: "스팀", href: "/steam", icon: Wind },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "설비 관리",
|
||||
items: [
|
||||
{ title: "설비 목록", href: "/equipment/inventory", icon: Box },
|
||||
{ title: "상태 모니터링", href: "/equipment/monitoring", icon: Activity },
|
||||
{ title: "정비 관리", href: "/equipment/maintenance", icon: Wrench },
|
||||
{ title: "설비 목록", href: "/inventory", icon: Box },
|
||||
{ title: "상태 모니터링", href: "/monitoring", icon: Activity },
|
||||
{ title: "정비 관리", href: "/maintenance", icon: Wrench },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "분석/리포트",
|
||||
items: [
|
||||
{ title: "에너지 분석", href: "/analysis/energy", icon: LineChart },
|
||||
{ title: "원단위 분석", href: "/analysis/efficiency", icon: BarChart },
|
||||
{ title: "보고서", href: "/analysis/reports", icon: FileText },
|
||||
{ title: "에너지 분석", href: "/energy", icon: LineChart },
|
||||
{ title: "원단위 분석", href: "/efficiency", icon: BarChart },
|
||||
{ title: "보고서", href: "/reports", icon: FileText },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "알람/이벤트",
|
||||
items: [
|
||||
{ title: "실시간 알람", href: "/alarm/realtime", icon: Bell },
|
||||
{ title: "이력 관리", href: "/alarm/history", icon: History },
|
||||
{ title: "알람 설정", href: "/alarm/settings", icon: SettingsIcon },
|
||||
{ title: "실시간 알람", href: "/realtime", icon: Bell },
|
||||
{ title: "이력 관리", href: "/history", icon: History },
|
||||
{ title: "알람 설정", href: "/settings", icon: SettingsIcon },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "에너지 계획",
|
||||
items: [
|
||||
{ title: "절감 목표", href: "/planning/targets", icon: Target },
|
||||
{ title: "수요 예측", href: "/planning/forecast", icon: TrendingUp },
|
||||
{ title: "최적화", href: "/planning/optimization", icon: Brain },
|
||||
{ title: "절감 목표", href: "/targets", icon: Target },
|
||||
{ title: "수요 예측", href: "/forecast", icon: TrendingUp },
|
||||
{ title: "최적화", href: "/optimization", icon: Brain },
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -108,7 +108,7 @@ const menuItems = [
|
||||
{
|
||||
title: "지원/커뮤니티",
|
||||
items: [
|
||||
{ title: "도움말", href: "/support/help", icon: HelpCircle },
|
||||
{ title: "도움말", href: "/help", icon: HelpCircle },
|
||||
{ title: "게시판", href: "/community/forum", icon: MessageSquare },
|
||||
{ title: "뉴스", href: "/community/news", icon: Newspaper },
|
||||
],
|
||||
|
@ -1,103 +1,230 @@
|
||||
// src/components/monitoring/MonitoringSidebar.tsx
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Gauge,
|
||||
TrendingUp,
|
||||
DollarSign,
|
||||
Zap,
|
||||
Flame,
|
||||
Droplets,
|
||||
Droplet,
|
||||
Wind,
|
||||
BarChart3,
|
||||
AlertTriangle,
|
||||
Settings,
|
||||
Flame,
|
||||
Box,
|
||||
Activity,
|
||||
Wrench,
|
||||
LineChart,
|
||||
BarChart,
|
||||
FileText,
|
||||
Bell,
|
||||
History,
|
||||
Settings as SettingsIcon,
|
||||
Target,
|
||||
Brain,
|
||||
Sliders,
|
||||
Building2,
|
||||
Users,
|
||||
HelpCircle,
|
||||
MessageSquare,
|
||||
Newspaper,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
title: "대시보드",
|
||||
items: [
|
||||
{
|
||||
title: "전체 현황",
|
||||
href: "/dashboard/overview",
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{ title: "KPI 지표", href: "/dashboard/kpi", icon: Gauge },
|
||||
{ title: "비용 현황", href: "/dashboard/costs", icon: DollarSign },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "에너지 모니터링",
|
||||
items: [
|
||||
{
|
||||
title: "전력",
|
||||
icon: Zap,
|
||||
href: "/monitoring/electricity",
|
||||
},
|
||||
{
|
||||
title: "가스",
|
||||
icon: Flame,
|
||||
href: "/monitoring/gas",
|
||||
},
|
||||
{
|
||||
title: "용수",
|
||||
icon: Droplets,
|
||||
href: "/monitoring/water",
|
||||
},
|
||||
{
|
||||
title: "스팀",
|
||||
icon: Wind,
|
||||
href: "/monitoring/steam",
|
||||
},
|
||||
{ title: "전력", href: "/electricity", icon: Zap },
|
||||
{ title: "가스", href: "/gas", icon: Flame },
|
||||
{ title: "용수", href: "/water", icon: Droplet },
|
||||
{ title: "스팀", href: "/steam", icon: Wind },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "분석",
|
||||
title: "설비 관리",
|
||||
items: [
|
||||
{
|
||||
title: "에너지 분석",
|
||||
icon: BarChart3,
|
||||
href: "/monitoring/analysis",
|
||||
},
|
||||
{
|
||||
title: "알람 이력",
|
||||
icon: AlertTriangle,
|
||||
href: "/monitoring/alerts",
|
||||
},
|
||||
{ title: "설비 목록", href: "/inventory", icon: Box },
|
||||
{ title: "상태 모니터링", href: "/monitoring", icon: Activity },
|
||||
{ title: "정비 관리", href: "/maintenance", icon: Wrench },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "설정",
|
||||
title: "분석/리포트",
|
||||
items: [
|
||||
{ title: "에너지 분석", href: "/energy", icon: LineChart },
|
||||
{ title: "원단위 분석", href: "/efficiency", icon: BarChart },
|
||||
{ title: "보고서", href: "/reports", icon: FileText },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "알람/이벤트",
|
||||
items: [
|
||||
{ title: "실시간 알람", href: "/realtime", icon: Bell },
|
||||
{ title: "이력 관리", href: "/history", icon: History },
|
||||
{ title: "알람 설정", href: "/settings", icon: SettingsIcon },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "에너지 계획",
|
||||
items: [
|
||||
{ title: "절감 목표", href: "/targets", icon: Target },
|
||||
{ title: "수요 예측", href: "/forecast", icon: TrendingUp },
|
||||
{ title: "최적화", href: "/optimization", icon: Brain },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "관리",
|
||||
items: [
|
||||
{ title: "회사 설정", href: "/company/profile", icon: Building2 },
|
||||
{
|
||||
title: "모니터링 설정",
|
||||
icon: Settings,
|
||||
href: "/monitoring/settings",
|
||||
title: "지점/공장 관리",
|
||||
href: "/company/branches",
|
||||
icon: Building2,
|
||||
},
|
||||
{ title: "결재 관리", href: "/company/billing", icon: DollarSign },
|
||||
{ title: "사용자 권한 관리", href: "/users/roles", icon: Users },
|
||||
{ title: "부서 관리", href: "/users/departments", icon: Users },
|
||||
{ title: "계정 관리", href: "/users/accounts", icon: Users },
|
||||
{ title: "시스템 설정", href: "/system", icon: Sliders },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "지원/커뮤니티",
|
||||
items: [
|
||||
{ title: "도움말", href: "/help", icon: HelpCircle },
|
||||
{ title: "게시판", href: "/community/forum", icon: MessageSquare },
|
||||
{ title: "뉴스", href: "/community/news", icon: Newspaper },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function MonitoringSidebar() {
|
||||
const pathname = usePathname();
|
||||
interface MenuItemProps {
|
||||
item: {
|
||||
title: string;
|
||||
items: {
|
||||
title: string;
|
||||
href: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}[];
|
||||
};
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
pathname: string;
|
||||
}
|
||||
|
||||
const MenuItem: React.FC<MenuItemProps> = ({
|
||||
item,
|
||||
isOpen,
|
||||
onToggle,
|
||||
pathname,
|
||||
}) => {
|
||||
const firstIcon = item.items[0]?.icon;
|
||||
const IconComponent = firstIcon || Box;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-64 bg-slate-900 text-white min-h-[calc(100vh-4rem)]">
|
||||
<div className="space-y-4 py-4">
|
||||
{menuItems.map((section) => (
|
||||
<div key={section.title} className="px-3 py-2">
|
||||
<h2 className="mb-2 px-4 text-xs font-semibold tracking-tight text-slate-400">
|
||||
{section.title}
|
||||
</h2>
|
||||
<div className="space-y-1">
|
||||
{section.items.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center rounded-lg px-3 py-2 text-sm font-medium hover:bg-slate-800 hover:text-slate-50 transition-colors",
|
||||
pathname === item.href
|
||||
? "bg-slate-800 text-slate-50"
|
||||
: "text-slate-400"
|
||||
)}
|
||||
>
|
||||
<item.icon className="mr-2 h-4 w-4" />
|
||||
{item.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mb-1">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"w-full flex items-center px-3 py-2 text-sm font-medium rounded-md",
|
||||
"transition-colors duration-150",
|
||||
"hover:bg-gray-100",
|
||||
isOpen ? "text-blue-600 bg-blue-50" : "text-gray-700"
|
||||
)}
|
||||
>
|
||||
<IconComponent className="h-5 w-5 mr-2" />
|
||||
<span className="flex-1 text-left">{item.title}</span>
|
||||
{isOpen ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="ml-9 mt-1 space-y-1">
|
||||
{item.items.map((subItem) => (
|
||||
<Link
|
||||
key={subItem.href}
|
||||
href={subItem.href}
|
||||
className={cn(
|
||||
"flex items-center px-3 py-2 text-sm rounded-md",
|
||||
"transition-colors duration-150",
|
||||
pathname === subItem.href
|
||||
? "bg-blue-50 text-blue-600 font-medium"
|
||||
: "text-gray-600 hover:bg-gray-50"
|
||||
)}
|
||||
>
|
||||
{subItem.icon && (
|
||||
<subItem.icon className="h-4 w-4 mr-2 flex-shrink-0" />
|
||||
)}
|
||||
{subItem.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function MonitoringSidebar() {
|
||||
const pathname = usePathname();
|
||||
const [openSections, setOpenSections] = useState<{ [key: string]: boolean }>(
|
||||
() => {
|
||||
// 현재 경로에 해당하는 섹션을 자동으로 열어둠
|
||||
return menuItems.reduce((acc: { [key: string]: boolean }, item) => {
|
||||
if (item.items.some((subItem) => subItem.href === pathname)) {
|
||||
acc[item.title] = true;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
);
|
||||
|
||||
const toggleSection = (title: string) => {
|
||||
setOpenSections((prev) => ({
|
||||
...prev,
|
||||
[title]: !prev[title],
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="w-64 bg-white border-r border-gray-200 h-screen overflow-y-auto">
|
||||
<div className="sticky top-0 z-10 bg-white p-4 border-b border-gray-200">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Gauge className="h-8 w-6 text-blue-600" />
|
||||
<h1 className="text-xl font-semibold text-gray-900">FEMS</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3">
|
||||
{menuItems.map((item) => (
|
||||
<MenuItem
|
||||
key={item.title}
|
||||
item={item}
|
||||
isOpen={openSections[item.title]}
|
||||
onToggle={() => toggleSection(item.title)}
|
||||
pathname={pathname}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export default MonitoringSidebar;
|
||||
|
@ -1,37 +1,39 @@
|
||||
// src/components/ui/data-table.tsx
|
||||
"use client";
|
||||
|
||||
// components/ui/data-table.tsx
|
||||
import * as React from "react";
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
getPaginationRowModel,
|
||||
SortingState,
|
||||
getSortedRowModel,
|
||||
ColumnFiltersState,
|
||||
getFilteredRowModel,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
filters?: {
|
||||
id: string;
|
||||
title: string;
|
||||
options: {
|
||||
value: string;
|
||||
label: string;
|
||||
}[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
filters,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
||||
[]
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
@ -40,80 +42,109 @@ export function DataTable<TData, TValue>({
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Filters */}
|
||||
{filters && filters.length > 0 && (
|
||||
<div className="flex gap-4 mb-4">
|
||||
{filters.map((filter) => (
|
||||
<div key={filter.id} className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium">{filter.title}</label>
|
||||
<select
|
||||
className="h-8 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm"
|
||||
value={
|
||||
(table.getColumn(filter.id)?.getFilterValue() as string) ?? ""
|
||||
}
|
||||
onChange={(event) =>
|
||||
table.getColumn(filter.id)?.setFilterValue(event.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">전체</option>
|
||||
{filter.options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th
|
||||
key={header.id}
|
||||
className="border-b px-4 py-2 text-left font-medium"
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
<tr key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
<td key={cell.id} className="border-b px-4 py-2">
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
</td>
|
||||
))}
|
||||
</TableRow>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
className="h-24 text-center text-muted-foreground"
|
||||
>
|
||||
데이터가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-end space-x-2 py-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
<button
|
||||
className="rounded border px-2 py-1 disabled:opacity-50"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
이전
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
</button>
|
||||
<button
|
||||
className="rounded border px-2 py-1 disabled:opacity-50"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
다음
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
172
fems-app/src/components/ui/file-uploader.tsx
Normal file
172
fems-app/src/components/ui/file-uploader.tsx
Normal file
@ -0,0 +1,172 @@
|
||||
// src/components/ui/file-uploader.tsx
|
||||
import React, { useCallback } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { api } from "@/lib/api";
|
||||
import {
|
||||
Upload as IconUpload,
|
||||
File as IconFile,
|
||||
X as IconX,
|
||||
} from "lucide-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
interface FileUploaderProps {
|
||||
value: any;
|
||||
onChange: (files: any) => void;
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
moduleType: string;
|
||||
category: string;
|
||||
referenceId?: string;
|
||||
}
|
||||
|
||||
export function FileUploader({
|
||||
value,
|
||||
onChange,
|
||||
accept,
|
||||
multiple = false,
|
||||
moduleType,
|
||||
category,
|
||||
referenceId,
|
||||
}: FileUploaderProps) {
|
||||
const { toast } = useToast();
|
||||
const onDrop = useCallback(
|
||||
async (acceptedFiles: File[]) => {
|
||||
try {
|
||||
if (acceptedFiles.length === 0) {
|
||||
toast({
|
||||
title: "파일 업로드 오류",
|
||||
description: "업로드할 파일을 선택해주세요",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
acceptedFiles.forEach((file) => {
|
||||
formData.append("files", file);
|
||||
});
|
||||
|
||||
formData.append("moduleType", moduleType);
|
||||
formData.append("category", category);
|
||||
if (referenceId) {
|
||||
formData.append("referenceId", referenceId);
|
||||
}
|
||||
|
||||
const metadata = {
|
||||
uploadTime: new Date().toISOString(),
|
||||
fileCount: acceptedFiles.length,
|
||||
};
|
||||
|
||||
// metadata를 JSON 문자열로 변환
|
||||
formData.append("metadata", JSON.stringify(metadata));
|
||||
|
||||
const { data } = await api.post("/api/v1/app/files/upload", formData);
|
||||
|
||||
onChange(multiple ? [...(value || []), ...data] : data[0]);
|
||||
|
||||
toast({
|
||||
title: "파일 업로드 성공",
|
||||
description: "파일이 성공적으로 업로드되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("File upload failed:", error.response?.data);
|
||||
|
||||
toast({
|
||||
title: "파일 업로드 실패",
|
||||
description:
|
||||
error.response?.data?.message ||
|
||||
"파일 업로드 중 오류가 발생했습니다",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
},
|
||||
[moduleType, category, referenceId, multiple, value, onChange, toast]
|
||||
);
|
||||
|
||||
// dropzone 설정 수정
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: accept
|
||||
? {
|
||||
"application/pdf": [".pdf"],
|
||||
"image/jpeg": [".jpg", ".jpeg"],
|
||||
"image/png": [".png"],
|
||||
"image/gif": [".gif"],
|
||||
}
|
||||
: undefined,
|
||||
multiple,
|
||||
maxSize: 10 * 1024 * 1024, // 10MB
|
||||
onDropRejected: (fileRejections) => {
|
||||
fileRejections.forEach(({ file, errors }) => {
|
||||
errors.forEach((error) => {
|
||||
toast({
|
||||
title: "파일 업로드 오류",
|
||||
description:
|
||||
error.code === "file-too-large"
|
||||
? `${file.name}의 크기가 10MB를 초과합니다.`
|
||||
: `${file.name}은(는) 지원되지 않는 파일 형식입니다.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
if (multiple) {
|
||||
const newFiles = [...value];
|
||||
newFiles.splice(index, 1);
|
||||
onChange(newFiles);
|
||||
} else {
|
||||
onChange(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer hover:border-gray-400 transition-colors
|
||||
${isDragActive ? "border-primary" : "border-gray-300"}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<div className="flex flex-col items-center">
|
||||
<IconUpload className="h-8 w-8 mb-2 text-gray-400" />
|
||||
<p className="text-sm text-gray-600">
|
||||
{isDragActive
|
||||
? "파일을 여기에 놓으세요"
|
||||
: "파일을 드래그하거나 클릭하여 업로드"}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{accept ? `지원 형식: ${accept}` : "모든 파일"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 업로드된 파일 목록 */}
|
||||
{value && (
|
||||
<div className="space-y-2">
|
||||
{(multiple ? value : [value]).map((file: any, index: number) => (
|
||||
<div
|
||||
key={file.id || index}
|
||||
className="flex items-center justify-between p-2 bg-gray-50 rounded"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<IconFile className="h-4 w-4" />
|
||||
<span className="text-sm">
|
||||
{file.originalName || file.name}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeFile(index)}
|
||||
className="p-1 hover:bg-gray-200 rounded"
|
||||
>
|
||||
<IconX className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -37,7 +37,7 @@ export function useAuth() {
|
||||
const logout = () => {
|
||||
clearAuth();
|
||||
localStorage.removeItem("token");
|
||||
router.push("/login");
|
||||
router.push("/");
|
||||
};
|
||||
|
||||
return { user, token, login, logout };
|
||||
|
@ -1,3 +1,21 @@
|
||||
// // src/lib/api.ts
|
||||
// import axios from "axios";
|
||||
// import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
// export const api = axios.create({
|
||||
// baseURL: process.env.NEXT_PUBLIC_API_URL,
|
||||
// timeout: 10000,
|
||||
// });
|
||||
|
||||
// api.interceptors.request.use((config) => {
|
||||
// // localStorage 대신 Zustand store에서 직접 토큰을 가져옴
|
||||
// const token = useAuthStore.getState().token;
|
||||
// if (token) {
|
||||
// config.headers.Authorization = `Bearer ${token}`;
|
||||
// }
|
||||
// return config;
|
||||
// });
|
||||
|
||||
// src/lib/api.ts
|
||||
import axios from "axios";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
@ -8,10 +26,44 @@ export const api = axios.create({
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
// localStorage 대신 Zustand store에서 직접 토큰을 가져옴
|
||||
const token = useAuthStore.getState().token;
|
||||
|
||||
// FormData인 경우 Content-Type을 설정하지 않음 (브라우저가 자동으로 설정)
|
||||
if (config.data instanceof FormData) {
|
||||
delete config.headers["Content-Type"];
|
||||
} else {
|
||||
// JSON 데이터의 경우 application/json으로 설정
|
||||
config.headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// 디버깅을 위한 요청 로깅
|
||||
console.log("Request Config:", {
|
||||
url: config.url,
|
||||
method: config.method,
|
||||
headers: config.headers,
|
||||
data: config.data instanceof FormData ? "FormData" : config.data,
|
||||
});
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
// 응답 인터셉터 추가
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
// 에러 응답 로깅
|
||||
console.error("API Error:", {
|
||||
url: error.config?.url,
|
||||
method: error.config?.method,
|
||||
status: error.response?.status,
|
||||
data: error.response?.data,
|
||||
});
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
@ -15,8 +15,8 @@ export function middleware(request: NextRequest) {
|
||||
const token = request.cookies.get("token")?.value;
|
||||
|
||||
// 비인증 사용자는 로그인 페이지로
|
||||
if (!token && !request.nextUrl.pathname.startsWith("/login")) {
|
||||
return NextResponse.redirect(new URL("/login", request.url));
|
||||
if (!token && !request.nextUrl.pathname.startsWith("/")) {
|
||||
return NextResponse.redirect(new URL("/", request.url));
|
||||
}
|
||||
|
||||
// 권한별 접근 제어
|
||||
|
25
fems-app/src/types/equipment.ts
Normal file
25
fems-app/src/types/equipment.ts
Normal file
@ -0,0 +1,25 @@
|
||||
// src/types/equipment.ts
|
||||
export interface Equipment {
|
||||
id: string;
|
||||
name: string;
|
||||
model: string;
|
||||
manufacturer: string;
|
||||
type: "HVAC" | "Boiler" | "Compressor" | "Motor" | "Pump" | "Other";
|
||||
specifications: Record<string, any>;
|
||||
installationDate: string;
|
||||
lastMaintenance: string | null;
|
||||
isActive: boolean;
|
||||
zoneId: string;
|
||||
companyId: string;
|
||||
branchId: string;
|
||||
Zone?: {
|
||||
name: string;
|
||||
};
|
||||
description?: string;
|
||||
documents?: {
|
||||
manual?: string;
|
||||
technical?: string[];
|
||||
certificates?: string[];
|
||||
drawings?: string[];
|
||||
};
|
||||
}
|
@ -12,6 +12,7 @@
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"target": "esnext",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
|
@ -739,6 +739,13 @@
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react-dropzone@^5.1.0":
|
||||
version "5.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-dropzone/-/react-dropzone-5.1.0.tgz#5e27eb0b1ccadd7964ce4d328e1926fcd1c1f1ef"
|
||||
integrity sha512-VCdDCwSsr1MT2frsVl5p8qH+LWwUGzsaNtGkEQekHviZqK0dmTbiIp2Pzfb8lTkH4oTE2JtBbWnbuM6B4FH80A==
|
||||
dependencies:
|
||||
react-dropzone "*"
|
||||
|
||||
"@types/react@*", "@types/react@^18":
|
||||
version "18.3.12"
|
||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.12.tgz#99419f182ccd69151813b7ee24b792fe08774f60"
|
||||
@ -1009,6 +1016,11 @@ asynckit@^0.4.0:
|
||||
resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
|
||||
integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==
|
||||
|
||||
attr-accept@^2.2.4:
|
||||
version "2.2.4"
|
||||
resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-2.2.4.tgz#e28749d5975732586aea03c8912e2d0f1d1d77e7"
|
||||
integrity sha512-2pA6xFIbdTUDCAwjN8nQwI+842VwzbDUXO2IYlpPXQIORgKnavorcr4Ce3rwh+zsNg9zK7QPsdvDj3Lum4WX4w==
|
||||
|
||||
available-typed-arrays@^1.0.7:
|
||||
version "1.0.7"
|
||||
resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846"
|
||||
@ -1790,6 +1802,13 @@ file-entry-cache@^6.0.1:
|
||||
dependencies:
|
||||
flat-cache "^3.0.4"
|
||||
|
||||
file-selector@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-2.1.0.tgz#beb164ca5ce48af8a48d3e632c94750bc573581a"
|
||||
integrity sha512-ZuXAqGePcSPz4JuerOY06Dzzq0hrmQ6VGoXVzGyFI1npeOfBgqGIKKpznfYWRkSLJlXutkqVC5WvGZtkFVhu9Q==
|
||||
dependencies:
|
||||
tslib "^2.7.0"
|
||||
|
||||
fill-range@^7.1.1:
|
||||
version "7.1.1"
|
||||
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292"
|
||||
@ -2830,6 +2849,15 @@ react-dom@^18:
|
||||
loose-envify "^1.1.0"
|
||||
scheduler "^0.23.2"
|
||||
|
||||
react-dropzone@*:
|
||||
version "14.3.3"
|
||||
resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-14.3.3.tgz#8f2cf46567615123c91e1ba59b07e6c815e5aef0"
|
||||
integrity sha512-38yaWovqDqZdtn7LKTE5lFdqc57hYbPdS/oIxyhmaTP/yzC0yztPxnNy+poxew5HKF44gzGCNcNDBYjqlCXr6g==
|
||||
dependencies:
|
||||
attr-accept "^2.2.4"
|
||||
file-selector "^2.1.0"
|
||||
prop-types "^15.8.1"
|
||||
|
||||
react-hook-form@^7.53.1:
|
||||
version "7.53.1"
|
||||
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.53.1.tgz#3f2cd1ed2b3af99416a4ac674da2d526625add67"
|
||||
@ -3351,7 +3379,7 @@ tsconfig-paths@^3.15.0:
|
||||
minimist "^1.2.6"
|
||||
strip-bom "^3.0.0"
|
||||
|
||||
tslib@^2.0.0, tslib@^2.1.0, tslib@^2.4.0:
|
||||
tslib@^2.0.0, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.7.0:
|
||||
version "2.8.1"
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
|
||||
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
|
||||
|
Loading…
Reference in New Issue
Block a user