diff --git a/.env.development b/.env.development index 9b65656..31e21da 100644 --- a/.env.development +++ b/.env.development @@ -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 diff --git a/.env.production b/.env.production index 17111da..e447dad 100644 --- a/.env.production +++ b/.env.production @@ -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 diff --git a/docker-compose.base.yml b/docker-compose.base.yml index dfed101..837cbe8 100644 --- a/docker-compose.base.yml +++ b/docker-compose.base.yml @@ -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: diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 51c7323..4c76452 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -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 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 8f6a4d7..f378549 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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 diff --git a/fems-api/.gitignore b/fems-api/.gitignore index 628d609..53205df 100644 --- a/fems-api/.gitignore +++ b/fems-api/.gitignore @@ -5,4 +5,9 @@ node_modules/ !.env.example logs/ coverage/ -.DS_Store \ No newline at end of file +.DS_Store + +temp/ +tmp/ +uploads/ +_app_uploads/ \ No newline at end of file diff --git a/fems-api/Dockerfile b/fems-api/Dockerfile index 59804b0..1ea6086 100644 --- a/fems-api/Dockerfile +++ b/fems-api/Dockerfile @@ -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"] \ No newline at end of file diff --git a/fems-api/package.json b/fems-api/package.json index cc699e0..0f05980 100644 --- a/fems-api/package.json +++ b/fems-api/package.json @@ -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", diff --git a/fems-api/src/app.js b/fems-api/src/app.js index 3ee3d42..2327279 100644 --- a/fems-api/src/app.js +++ b/fems-api/src/app.js @@ -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 { diff --git a/fems-api/src/controllers/app/equipment/equipment.controller.js b/fems-api/src/controllers/app/equipment/equipment.controller.js new file mode 100644 index 0000000..78be2fb --- /dev/null +++ b/fems-api/src/controllers/app/equipment/equipment.controller.js @@ -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; diff --git a/fems-api/src/controllers/app/file/file.controller.js b/fems-api/src/controllers/app/file/file.controller.js new file mode 100644 index 0000000..162918b --- /dev/null +++ b/fems-api/src/controllers/app/file/file.controller.js @@ -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; diff --git a/fems-api/src/controllers/app/zone/zone.controller.js b/fems-api/src/controllers/app/zone/zone.controller.js new file mode 100644 index 0000000..e37905f --- /dev/null +++ b/fems-api/src/controllers/app/zone/zone.controller.js @@ -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; diff --git a/fems-api/src/models/File.js b/fems-api/src/models/File.js new file mode 100644 index 0000000..6546424 --- /dev/null +++ b/fems-api/src/models/File.js @@ -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; diff --git a/fems-api/src/routes/app.js b/fems-api/src/routes/app.js index bcfd571..1a69dca 100644 --- a/fems-api/src/routes/app.js +++ b/fems-api/src/routes/app.js @@ -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; diff --git a/fems-api/src/services/equipment.service.js b/fems-api/src/services/equipment.service.js new file mode 100644 index 0000000..ff051e0 --- /dev/null +++ b/fems-api/src/services/equipment.service.js @@ -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(); diff --git a/fems-api/src/services/file.service.js b/fems-api/src/services/file.service.js new file mode 100644 index 0000000..8bc2ccb --- /dev/null +++ b/fems-api/src/services/file.service.js @@ -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(); diff --git a/fems-api/src/services/zone.service.js b/fems-api/src/services/zone.service.js new file mode 100644 index 0000000..6167911 --- /dev/null +++ b/fems-api/src/services/zone.service.js @@ -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(); diff --git a/fems-api/src/utils/createInitialAdmin.js b/fems-api/src/utils/createInitialAdmin.js index b7cf757..c365a62 100644 --- a/fems-api/src/utils/createInitialAdmin.js +++ b/fems-api/src/utils/createInitialAdmin.js @@ -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(); diff --git a/fems-api/src/utils/initialSetup/dataSetup.js b/fems-api/src/utils/initialSetup/dataSetup.js index 10c80bc..eaae39d 100644 --- a/fems-api/src/utils/initialSetup/dataSetup.js +++ b/fems-api/src/utils/initialSetup/dataSetup.js @@ -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, - }; \ No newline at end of file +} + +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, +}; diff --git a/fems-api/src/utils/initialSetup/setupData.js b/fems-api/src/utils/initialSetup/setupData.js index 8f57118..509ac73 100644 --- a/fems-api/src/utils/initialSetup/setupData.js +++ b/fems-api/src/utils/initialSetup/setupData.js @@ -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, }; diff --git a/fems-api/yarn.lock b/fems-api/yarn.lock index f80bfdf..9f20c53 100644 --- a/fems-api/yarn.lock +++ b/fems-api/yarn.lock @@ -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== diff --git a/fems-app/next.config.mjs b/fems-app/next.config.mjs index 4678774..c47dffb 100644 --- a/fems-app/next.config.mjs +++ b/fems-app/next.config.mjs @@ -1,4 +1,10 @@ /** @type {import('next').NextConfig} */ -const nextConfig = {}; +const nextConfig = { + reactStrictMode: true, + experimental: { + optimizeCss: true, + scrollRestoration: true, + }, +}; export default nextConfig; diff --git a/fems-app/package.json b/fems-app/package.json index f49dc8d..78272a5 100644 --- a/fems-app/package.json +++ b/fems-app/package.json @@ -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", diff --git a/fems-app/src/app/(admin)/users/accounts/page.tsx b/fems-app/src/app/(admin)/users/accounts/page.tsx index 8429e41..1ae91c3 100644 --- a/fems-app/src/app/(admin)/users/accounts/page.tsx +++ b/fems-app/src/app/(admin)/users/accounts/page.tsx @@ -128,7 +128,7 @@ const AccountsPage = () => { const columns: ColumnDef[] = [ { accessorKey: "username", - header: "유저네임", + header: "아이디", }, { accessorKey: "name", diff --git a/fems-app/src/app/(equipment)/inventory/[mode]/components/EquipmentForm.tsx b/fems-app/src/app/(equipment)/inventory/[mode]/components/EquipmentForm.tsx new file mode 100644 index 0000000..94c33a1 --- /dev/null +++ b/fems-app/src/app/(equipment)/inventory/[mode]/components/EquipmentForm.tsx @@ -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) => void; + onCancel: () => void; +} + +const defaultSpecifications = { + power: "", + voltage: "", + current: "", + efficiency: "", + capacity: "", +}; + +export function EquipmentForm({ + initialData, + onSubmit, + onCancel, +}: EquipmentFormProps) { + const form = useForm>({ + 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 + >(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 ( +
+ + {/* 기본 정보 */} +
+ ( + + 설비명 + + + + + + )} + /> + + ( + + 모델명 + + + + + + )} + /> + + ( + + 제조사 + + + + + + )} + /> + + ( + + 설비 유형 + + + + )} + /> + + ( + + 설치일 + + + + + + )} + /> + + ( + + 설치 구역 + + + + )} + /> + + ( + + 설명 + +