diff --git a/fems-api/src/app.js b/fems-api/src/app.js index 25d2552..73e485c 100644 --- a/fems-api/src/app.js +++ b/fems-api/src/app.js @@ -22,98 +22,6 @@ app.use(requestLogger); // Register all routes registerRoutes(app); -// 파일 다운로드/프리뷰 통합 라우트 -// app.get( -// "/uploads/:companyId/:moduleType/:category/:filename", -// authMiddleware, -// async (req, res, next) => { -// try { -// const { companyId, moduleType, category, filename } = req.params; - -// // 권한 검사 -// if (req.user.role !== "super_admin" && req.user.companyId !== companyId) { -// return res.status(403).json({ message: "Forbidden" }); -// } - -// const filePath = path.join( -// process.cwd(), -// "uploads", -// companyId, -// moduleType, -// category, -// filename -// ); - -// // Content-Type 설정 -// const ext = path.extname(filename).toLowerCase(); -// const mimeTypes = { -// ".pdf": "application/pdf", -// ".jpg": "image/jpeg", -// ".jpeg": "image/jpeg", -// ".png": "image/png", -// ".gif": "image/gif", -// ".dwg": "application/acad", -// ".dxf": "application/dxf", -// ".doc": "application/msword", -// ".docx": -// "application/vnd.openxmlformats-officedocument.wordprocessingml.document", -// }; - -// // 다운로드 요청인지 확인 -// const isDownload = req.query.download === "true"; - -// if (isDownload) { -// // 다운로드 처리 -// res.download(filePath); -// } else { -// // 프리뷰 처리 -// if (mimeTypes[ext]) { -// res.setHeader("Content-Type", mimeTypes[ext]); -// } -// res.sendFile(filePath); -// } -// } catch (error) { -// next(error); -// } -// } -// ); - -// // 파일 프리뷰를 위한 라우트 -// app.get( -// "/api/v1/app/files/view/:fileId", -// authMiddleware, -// 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(), -// file.path.replace(/^\/uploads/, "uploads") -// ); - -// // Content-Type 설정 -// res.setHeader("Content-Type", file.mimetype); - -// // 원본 파일명으로 다운로드하도록 설정 -// if (req.query.download === "true") { -// res.setHeader( -// "Content-Disposition", -// `attachment; filename="${encodeURIComponent(file.originalName)}"` -// ); -// } - -// // 파일 전송 -// res.sendFile(filePath); -// } catch (error) { -// next(error); -// } -// } -// ); - // Error handling app.use(errorHandler); diff --git a/fems-api/src/controllers/app/department/department.controller.js b/fems-api/src/controllers/app/department/department.controller.js new file mode 100644 index 0000000..7674464 --- /dev/null +++ b/fems-api/src/controllers/app/department/department.controller.js @@ -0,0 +1,45 @@ +// src/controllers/app/department/department.controller.js +const express = require("express"); +const router = express.Router(); +const departmentService = require("../../../services/department.service"); +const authMiddleware = require("../../../middleware/auth.middleware"); +const roleCheck = require("../../../middleware/roleCheck.middleware"); +const { query } = require("express-validator"); +const validate = require("../../../middleware/validator.middleware"); + +// 인증 및 권한 확인 +router.use(authMiddleware); +router.use(roleCheck(["super_admin", "company_admin", "branch_admin", "user"])); + +// 부서 목록 조회 (트리 구조 또는 평면 구조로 조회 가능) +router.get( + "/", + [ + query("format") + .optional() + .isIn(["tree", "flat"]) + .withMessage("유효한 형식이 아닙니다"), + query("includeInactive") + .optional() + .isBoolean() + .withMessage("유효한 값이 아닙니다"), + validate, + ], + async (req, res, next) => { + try { + const format = req.query.format || "flat"; + const includeInactive = req.query.includeInactive === "true"; + + const department = await departmentService.findAll( + req.user, + format, + includeInactive + ); + res.json(department); + } catch (error) { + next(error); + } + } +); + +module.exports = router; diff --git a/fems-api/src/controllers/app/equipmentParts/equipmentParts.controller.js b/fems-api/src/controllers/app/equipmentParts/equipmentParts.controller.js new file mode 100644 index 0000000..2d9a620 --- /dev/null +++ b/fems-api/src/controllers/app/equipmentParts/equipmentParts.controller.js @@ -0,0 +1,129 @@ +// src/controllers/app/equipmentParts/equipmentParts.controller.js +const express = require("express"); +const router = express.Router(); +const equipmentPartsService = require("../../../services/equipmentParts.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"); + +// 인증 및 권한 확인 +router.use(authMiddleware); +router.use(roleCheck(["super_admin", "company_admin", "branch_admin", "user"])); + +// 설비-부품 매핑 목록 조회 +router.get("/", [ + query("equipmentId").optional().isUUID(), + query("partId").optional().isUUID(), + validate, +], async (req, res, next) => { + try { + const equipmentParts = await equipmentPartsService.findAll(req.user, req.query); + res.json(equipmentParts); + } catch (error) { + next(error); + } +}); + +// 설비-부품 매핑 생성 +router.post("/", [ + body("equipmentId").isUUID().withMessage("유효한 설비 ID가 필요합니다"), + body("partId").isUUID().withMessage("유효한 부품 ID가 필요합니다"), + body("quantity") + .isInt({ min: 1 }) + .withMessage("수량은 1 이상이어야 합니다"), + body("installationDate") + .optional() + .isISO8601() + .withMessage("유효한 설치일 형식이 아닙니다"), + body("recommendedReplacementInterval") + .optional() + .isInt({ min: 1 }) + .withMessage("권장 교체 주기는 1일 이상이어야 합니다"), + validate, +], async (req, res, next) => { + try { + const equipmentPart = await equipmentPartsService.createEquipmentPart(req.body, req.user); + res.status(201).json(equipmentPart); + } catch (error) { + next(error); + } +}); + +// 설비-부품 매핑 상세 조회 +router.get("/:id", [ + param("id").isUUID().withMessage("유효한 매핑 ID가 필요합니다"), + validate, +], async (req, res, next) => { + try { + const equipmentPart = await equipmentPartsService.findById(req.params.id); + if (!equipmentPart) { + return res.status(404).json({ message: "설비-부품 매핑을 찾을 수 없습니다" }); + } + res.json(equipmentPart); + } catch (error) { + next(error); + } +}); + +// 설비-부품 매핑 수정 +router.put("/:id", [ + param("id").isUUID().withMessage("유효한 매핑 ID가 필요합니다"), + body("quantity") + .optional() + .isInt({ min: 1 }) + .withMessage("수량은 1 이상이어야 합니다"), + body("lastReplacementDate") + .optional() + .isISO8601() + .withMessage("유효한 교체일 형식이 아닙니다"), + validate, +], async (req, res, next) => { + try { + const updatedEquipmentPart = await equipmentPartsService.updateEquipmentPart( + req.params.id, + req.body, + req.user + ); + res.json(updatedEquipmentPart); + } catch (error) { + next(error); + } +}); + +// 설비-부품 매핑 삭제 +router.delete("/:id", [ + param("id").isUUID().withMessage("유효한 매핑 ID가 필요합니다"), + validate, +], async (req, res, next) => { + try { + await equipmentPartsService.deleteEquipmentPart(req.params.id, req.user); + res.status(204).send(); + } catch (error) { + next(error); + } +}); + +// 부품 교체 이력 기록 +router.post("/:id/replacement", [ + param("id").isUUID().withMessage("유효한 매핑 ID가 필요합니다"), + body("replacementDate") + .isISO8601() + .withMessage("유효한 교체일 형식이 아닙니다"), + body("notes").optional().isString(), + validate, +], async (req, res, next) => { + try { + const updatedEquipmentPart = await equipmentPartsService.recordReplacement( + req.params.id, + req.body, + req.user + ); + res.json(updatedEquipmentPart); + } catch (error) { + next(error); + } +}); + +module.exports = router; + diff --git a/fems-api/src/controllers/app/maintenance/maintenance.controller.js b/fems-api/src/controllers/app/maintenance/maintenance.controller.js new file mode 100644 index 0000000..48daaa3 --- /dev/null +++ b/fems-api/src/controllers/app/maintenance/maintenance.controller.js @@ -0,0 +1,255 @@ +// src/controllers/app/maintenance/maintenance.controller.js +const express = require("express"); +const router = express.Router(); +const maintenanceService = require("../../../services/maintenance.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 maintenanceLogs = await maintenanceService.findAll(req.user); + res.json(maintenanceLogs); + } catch (error) { + next(error); + } +}); + +// 정비 로그 생성 +router.post( + "/", + [ + body("type") + .notEmpty() + .withMessage("정비 유형은 필수입니다") + .isIn(["preventive", "corrective", "predictive", "inspection"]) + .withMessage("유효한 정비 유형이 아닙니다"), + body("status") + .optional() + .isIn(["scheduled", "in_progress", "completed", "cancelled"]) + .withMessage("유효한 정비 상태가 아닙니다"), + body("scheduledDate") + .notEmpty() + .withMessage("예정일은 필수입니다") + .isISO8601() + .withMessage("유효한 날짜 형식이 아닙니다"), + body("completionDate") + .optional() + .isISO8601() + .withMessage("유효한 날짜 형식이 아닙니다"), + body("description").notEmpty().withMessage("정비 내용은 필수입니다"), + body("equipmentId") + .notEmpty() + .withMessage("설비 ID는 필수입니다") + .isUUID() + .withMessage("유효한 설비 ID가 아닙니다"), + body("findings") + .optional() + .isString() + .withMessage("발견 사항은 문자열이어야 합니다"), + body("actions") + .optional() + .isString() + .withMessage("조치 사항은 문자열이어야 합니다"), + body("cost") + .optional() + .isDecimal() + .withMessage("정비 비용은 숫자여야 합니다"), + body("attachments") + .optional() + .isArray() + .withMessage("첨부 파일 정보는 배열 형태여야 합니다"), + body("nextMaintenanceDate") + .optional() + .isISO8601() + .withMessage("유효한 날짜 형식이 아닙니다"), + body("equipmentStatus") + .optional() + .isIn(["operational", "degraded", "failed"]) + .withMessage("유효한 설비 상태가 아닙니다"), + validate, + ], + async (req, res, next) => { + try { + const maintenanceData = req.body; + + // company_admin은 자신의 회사에만 정비 로그 생성 가능 + if (req.user.role === "company_admin") { + maintenanceData.companyId = req.user.companyId; + } + + const maintenanceLog = await maintenanceService.createMaintenanceLog( + maintenanceData, + req.user + ); + res.status(201).json(maintenanceLog); + } catch (error) { + next(error); + } + } +); + +// 정비 로그 상세 조회 +router.get( + "/:id", + [ + param("id").isUUID().withMessage("유효한 정비 로그 ID가 필요합니다"), + validate, + ], + async (req, res, next) => { + try { + const maintenanceLog = await maintenanceService.findById(req.params.id); + + if (!maintenanceLog) { + return res + .status(404) + .json({ message: "정비 로그를 찾을 수 없습니다" }); + } + + // company_admin은 자신의 회사 정비 로그만 조회 가능 + if ( + req.user.role === "company_admin" && + maintenanceLog.companyId !== req.user.companyId + ) { + return res.status(403).json({ + message: "다른 회사의 정비 로그를 조회할 수 없습니다", + }); + } + + res.json(maintenanceLog); + } catch (error) { + next(error); + } + } +); + +// 정비 로그 수정 +router.put( + "/:id", + [ + param("id").isUUID().withMessage("유효한 정비 로그 ID가 필요합니다"), + body("type") + .optional() + .isIn(["preventive", "corrective", "predictive", "inspection"]) + .withMessage("유효한 정비 유형이 아닙니다"), + body("status") + .optional() + .isIn(["scheduled", "in_progress", "completed", "cancelled"]) + .withMessage("유효한 정비 상태가 아닙니다"), + body("scheduledDate") + .optional() + .isISO8601() + .withMessage("유효한 날짜 형식이 아닙니다"), + body("completionDate") + .optional() + .isISO8601() + .withMessage("유효한 날짜 형식이 아닙니다"), + body("description") + .optional() + .isString() + .withMessage("정비 내용은 문자열이어야 합니다"), + body("equipmentId") + .optional() + .isUUID() + .withMessage("유효한 설비 ID가 아닙니다"), + body("findings") + .optional() + .isString() + .withMessage("발견 사항은 문자열이어야 합니다"), + body("actions") + .optional() + .isString() + .withMessage("조치 사항은 문자열이어야 합니다"), + body("cost") + .optional() + .isDecimal() + .withMessage("정비 비용은 숫자여야 합니다"), + body("attachments") + .optional() + .isArray() + .withMessage("첨부 파일 정보는 배열 형태여야 합니다"), + body("nextMaintenanceDate") + .optional() + .isISO8601() + .withMessage("유효한 날짜 형식이 아닙니다"), + body("equipmentStatus") + .optional() + .isIn(["operational", "degraded", "failed"]) + .withMessage("유효한 설비 상태가 아닙니다"), + validate, + ], + async (req, res, next) => { + try { + const maintenanceLog = await maintenanceService.findById(req.params.id); + + if (!maintenanceLog) { + return res + .status(404) + .json({ message: "정비 로그를 찾을 수 없습니다" }); + } + + // company_admin은 자신의 회사 정비 로그만 수정 가능 + if ( + req.user.role === "company_admin" && + maintenanceLog.companyId !== req.user.companyId + ) { + return res.status(403).json({ + message: "다른 회사의 정비 로그를 수정할 수 없습니다", + }); + } + + const updatedMaintenanceLog = + await maintenanceService.updateMaintenanceLog( + req.params.id, + req.body, + req.user + ); + res.json(updatedMaintenanceLog); + } catch (error) { + next(error); + } + } +); + +// 정비 로그 삭제 +router.delete( + "/:id", + [ + param("id").isUUID().withMessage("유효한 정비 로그 ID가 필요합니다"), + validate, + ], + async (req, res, next) => { + try { + const maintenanceLog = await maintenanceService.findById(req.params.id); + + if (!maintenanceLog) { + return res + .status(404) + .json({ message: "정비 로그를 찾을 수 없습니다" }); + } + + // company_admin은 자신의 회사 정비 로그만 삭제 가능 + if ( + req.user.role === "company_admin" && + maintenanceLog.companyId !== req.user.companyId + ) { + return res.status(403).json({ + message: "다른 회사의 정비 로그를 삭제할 수 없습니다", + }); + } + + await maintenanceService.deleteMaintenanceLog(req.params.id, req.user); + res.status(204).send(); + } catch (error) { + next(error); + } + } +); + +module.exports = router; diff --git a/fems-api/src/controllers/app/parts/parts.controller.js b/fems-api/src/controllers/app/parts/parts.controller.js new file mode 100644 index 0000000..7c01a48 --- /dev/null +++ b/fems-api/src/controllers/app/parts/parts.controller.js @@ -0,0 +1,105 @@ +// src/controllers/app/parts/parts.controller.js +const express = require("express"); +const router = express.Router(); +const partsService = require("../../../services/parts.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"); + +// 인증 및 권한 확인 +router.use(authMiddleware); +router.use(roleCheck(["super_admin", "company_admin", "branch_admin", "user"])); + +// 부품 목록 조회 (필터링, 검색 기능 포함) +router.get("/", [ + query("category").optional().isString(), + query("manufacturer").optional().isString(), + query("status").optional().isIn(["active", "discontinued", "out_of_stock"]), + query("search").optional().isString(), + validate, +], async (req, res, next) => { + try { + const parts = await partsService.findAll(req.user, req.query); + res.json(parts); + } catch (error) { + next(error); + } +}); + +// 부품 생성 +router.post("/", [ + body("partNumber").notEmpty().withMessage("부품 번호는 필수입니다"), + body("name").notEmpty().withMessage("부품명은 필수입니다"), + body("category").notEmpty().withMessage("부품 카테고리는 필수입니다"), + body("unitPrice") + .isFloat({ min: 0 }) + .withMessage("단가는 0 이상이어야 합니다"), + body("stockQuantity") + .isInt({ min: 0 }) + .withMessage("재고 수량은 0 이상의 정수여야 합니다"), + validate, +], async (req, res, next) => { + try { + const partData = req.body; + if (req.user.role === "company_admin") { + partData.companyId = req.user.companyId; + } + const part = await partsService.createPart(partData, req.user); + res.status(201).json(part); + } catch (error) { + next(error); + } +}); + +// 부품 상세 조회 +router.get("/:id", [ + param("id").isUUID().withMessage("유효한 부품 ID가 필요합니다"), + validate, +], async (req, res, next) => { + try { + const part = await partsService.findById(req.params.id); + if (!part) { + return res.status(404).json({ message: "부품을 찾을 수 없습니다" }); + } + res.json(part); + } catch (error) { + next(error); + } +}); + +// 부품 수정 +router.put("/:id", [ + param("id").isUUID().withMessage("유효한 부품 ID가 필요합니다"), + body("status") + .optional() + .isIn(["active", "discontinued", "out_of_stock"]), + validate, +], async (req, res, next) => { + try { + const updatedPart = await partsService.updatePart( + req.params.id, + req.body, + req.user + ); + res.json(updatedPart); + } catch (error) { + next(error); + } +}); + +// 부품 삭제 +router.delete("/:id", [ + param("id").isUUID().withMessage("유효한 부품 ID가 필요합니다"), + validate, +], async (req, res, next) => { + try { + await partsService.deletePart(req.params.id, req.user); + res.status(204).send(); + } catch (error) { + next(error); + } +}); + +module.exports = router; + diff --git a/fems-api/src/controllers/app/personnel/personnel.controller.js b/fems-api/src/controllers/app/personnel/personnel.controller.js new file mode 100644 index 0000000..7510172 --- /dev/null +++ b/fems-api/src/controllers/app/personnel/personnel.controller.js @@ -0,0 +1,187 @@ +// src/controllers/app/personnel/personnel.controller.js +const express = require("express"); +const router = express.Router(); +const personnelService = require("../../../services/personnel.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 personnel = await personnelService.findAll(req.user); + res.json(personnel); + } catch (error) { + next(error); + } +}); + +// 작업자 생성 +router.post( + "/", + [ + body("employeeNumber").notEmpty().withMessage("사원번호는 필수입니다"), + body("name").notEmpty().withMessage("작업자 이름은 필수입니다"), + body("position").notEmpty().withMessage("직위는 필수입니다"), + body("department").notEmpty().withMessage("소속 부서는 필수입니다"), + body("contactNumber").notEmpty().withMessage("연락처는 필수입니다"), + body("email") + .notEmpty() + .withMessage("이메일은 필수입니다") + .isEmail() + .withMessage("유효한 이메일 형식이 아닙니다"), + body("status") + .optional() + .isIn(["active", "inactive", "leave"]) + .withMessage("유효한 재직 상태가 아닙니다"), + body("specialization") + .optional() + .isString() + .withMessage("전문 분야는 문자열이어야 합니다"), + body("certifications") + .optional() + .isArray() + .withMessage("자격증 정보는 배열 형태여야 합니다"), + body("skills") + .optional() + .isArray() + .withMessage("보유 기술은 배열 형태여야 합니다"), + body("availabilitySchedule") + .optional() + .isObject() + .withMessage("근무 일정은 객체 형태여야 합니다"), + validate, + ], + async (req, res, next) => { + try { + const personnelData = req.body; + + // company_admin은 자신의 회사에만 작업자 생성 가능 + if (req.user.role === "company_admin") { + personnelData.companyId = req.user.companyId; + } + + const personnel = await personnelService.createPersonnel( + personnelData, + req.user + ); + res.status(201).json(personnel); + } catch (error) { + next(error); + } + } +); + +// 작업자 상세 조회 +router.get( + "/:id", + [param("id").isUUID().withMessage("유효한 작업자 ID가 필요합니다"), validate], + async (req, res, next) => { + try { + const personnel = await personnelService.findById(req.params.id); + + if (!personnel) { + return res.status(404).json({ message: "작업자를 찾을 수 없습니다" }); + } + + // company_admin은 자신의 회사 작업자만 조회 가능 + if ( + req.user.role === "company_admin" && + personnel.companyId !== req.user.companyId + ) { + return res + .status(403) + .json({ message: "다른 회사의 작업자를 조회할 수 없습니다" }); + } + + res.json(personnel); + } catch (error) { + next(error); + } + } +); + +// 작업자 정보 수정 +router.put( + "/:id", + [ + param("id").isUUID().withMessage("유효한 작업자 ID가 필요합니다"), + body("employeeNumber").optional(), + body("name").optional(), + body("position").optional(), + body("department").optional(), + body("contactNumber").optional(), + body("email").optional().isEmail().withMessage("유효한 이메일 형식이 아닙니다"), + body("status") + .optional() + .isIn(["active", "inactive", "leave"]) + .withMessage("유효한 재직 상태가 아닙니다"), + validate, + ], + async (req, res, next) => { + try { + const personnel = await personnelService.findById(req.params.id); + + if (!personnel) { + return res.status(404).json({ message: "작업자를 찾을 수 없습니다" }); + } + + // company_admin은 자신의 회사 작업자만 수정 가능 + if ( + req.user.role === "company_admin" && + personnel.companyId !== req.user.companyId + ) { + return res + .status(403) + .json({ message: "다른 회사의 작업자를 수정할 수 없습니다" }); + } + + const updatedPersonnel = await personnelService.updatePersonnel( + req.params.id, + req.body, + req.user + ); + res.json(updatedPersonnel); + } catch (error) { + next(error); + } + } +); + +// 작업자 삭제 +router.delete( + "/:id", + [param("id").isUUID().withMessage("유효한 작업자 ID가 필요합니다"), validate], + async (req, res, next) => { + try { + const personnel = await personnelService.findById(req.params.id); + + if (!personnel) { + return res.status(404).json({ message: "작업자를 찾을 수 없습니다" }); + } + + // company_admin은 자신의 회사 작업자만 삭제 가능 + if ( + req.user.role === "company_admin" && + personnel.companyId !== req.user.companyId + ) { + return res + .status(403) + .json({ message: "다른 회사의 작업자를 삭제할 수 없습니다" }); + } + + await personnelService.deletePersonnel(req.params.id, req.user); + res.status(204).send(); + } catch (error) { + next(error); + } + } +); + +module.exports = router; + diff --git a/fems-api/src/models/Company.js b/fems-api/src/models/Company.js index 2206482..4473fe3 100644 --- a/fems-api/src/models/Company.js +++ b/fems-api/src/models/Company.js @@ -64,6 +64,8 @@ class Company extends Model { static associate(models) { this.hasMany(models.Branch, { foreignKey: "companyId" }); this.hasMany(models.User, { foreignKey: "companyId" }); + this.hasMany(models.Equipment, { foreignKey: "companyId" }); + this.hasMany(models.MaintenanceLog, { foreignKey: "companyId" }); } } diff --git a/fems-api/src/models/Equipment.js b/fems-api/src/models/Equipment.js index 9715dd4..53c8f7e 100644 --- a/fems-api/src/models/Equipment.js +++ b/fems-api/src/models/Equipment.js @@ -67,6 +67,12 @@ class Equipment extends Model { this.hasMany(models.MaintenanceLog, { foreignKey: "equipmentId" }); this.hasMany(models.EquipmentData, { foreignKey: "equipmentId" }); this.hasMany(models.Alert, { foreignKey: "equipmentId" }); + // Parts와의 N:M 관계 추가 필요 + this.belongsToMany(models.Parts, { + through: models.EquipmentParts, + foreignKey: "equipmentId", + otherKey: "partId", + }); } } diff --git a/fems-api/src/models/EquipmentParts.js b/fems-api/src/models/EquipmentParts.js new file mode 100644 index 0000000..4984c08 --- /dev/null +++ b/fems-api/src/models/EquipmentParts.js @@ -0,0 +1,63 @@ +// models/EquipmentParts.js +const { Model, DataTypes } = require("sequelize"); + +class EquipmentParts extends Model { + static init(sequelize) { + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + quantity: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 1, + comment: "설치된 수량", + }, + installationDate: { + type: DataTypes.DATE, + allowNull: true, + comment: "설치일", + }, + lastReplacementDate: { + type: DataTypes.DATE, + allowNull: true, + comment: "최근 교체일", + }, + recommendedReplacementInterval: { + type: DataTypes.INTEGER, + allowNull: true, + comment: "권장 교체 주기(일)", + }, + notes: { + type: DataTypes.TEXT, + allowNull: true, + comment: "비고", + }, + }, + { + sequelize, + modelName: "EquipmentParts", + tableName: "equipment_parts", + timestamps: true, + indexes: [ + { + unique: true, + fields: ["equipmentId", "partId"], + }, + ], + } + ); + return this; + } + + static associate(models) { + this.belongsTo(models.Equipment, { foreignKey: "equipmentId" }); + this.belongsTo(models.Parts, { foreignKey: "partId" }); + this.hasMany(models.MaintenanceLog, { foreignKey: "equipmentPartId" }); + } +} + +module.exports = EquipmentParts; diff --git a/fems-api/src/models/MaintenanceLog.js b/fems-api/src/models/MaintenanceLog.js index 3658b74..60c94c2 100644 --- a/fems-api/src/models/MaintenanceLog.js +++ b/fems-api/src/models/MaintenanceLog.js @@ -109,10 +109,9 @@ class MaintenanceLog extends Model { static associate(models) { // Equipment와의 관계 - this.belongsTo(models.Equipment, { - foreignKey: "equipmentId", - onDelete: "CASCADE", - }); + this.belongsTo(models.Equipment, { foreignKey: "equipmentId" }); + this.belongsTo(models.Company, { foreignKey: "companyId" }); + this.belongsTo(models.Branch, { foreignKey: "branchId" }); // User(작업자)와의 관계 this.belongsTo(models.User, { @@ -124,6 +123,28 @@ class MaintenanceLog extends Model { foreignKey: "completedBy", as: "completer", }); + + // Parts 관계 (다대다) + this.belongsToMany(models.Parts, { + through: { + model: models.MaintenanceLogParts, + unique: false, + }, + foreignKey: "maintenanceLogId", + otherKey: "partId", + as: "Parts", + }); + + // Personnel 관계 (다대다) + this.belongsToMany(models.Personnel, { + through: { + model: models.MaintenanceLogPersonnel, + unique: false, + }, + foreignKey: "maintenanceLogId", + otherKey: "personnelId", + as: "Personnel", + }); } } diff --git a/fems-api/src/models/MaintenanceLogParts.js b/fems-api/src/models/MaintenanceLogParts.js new file mode 100644 index 0000000..30e8f0d --- /dev/null +++ b/fems-api/src/models/MaintenanceLogParts.js @@ -0,0 +1,51 @@ +// models/MaintenanceLogParts.js +const { Model, DataTypes } = require("sequelize"); + +class MaintenanceLogParts extends Model { + static init(sequelize) { + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + quantity: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 1, + validate: { + min: 1, + }, + comment: "사용 수량", + }, + cost: { + type: DataTypes.DECIMAL(15, 2), + allowNull: false, + defaultValue: 0, + comment: "부품 비용", + }, + }, + { + sequelize, + modelName: "MaintenanceLogParts", + tableName: "maintenance_log_parts", + timestamps: true, + indexes: [ + { + fields: ["maintenanceLogId", "partId"], + unique: true, + }, + ], + } + ); + return this; + } + + static associate(models) { + this.belongsTo(models.MaintenanceLog, { foreignKey: "maintenanceLogId" }); + this.belongsTo(models.Parts, { foreignKey: "partId" }); + } +} + +module.exports = MaintenanceLogParts; diff --git a/fems-api/src/models/MaintenanceLogPersonnel.js b/fems-api/src/models/MaintenanceLogPersonnel.js new file mode 100644 index 0000000..41e5db8 --- /dev/null +++ b/fems-api/src/models/MaintenanceLogPersonnel.js @@ -0,0 +1,41 @@ +// models/MaintenanceLogPersonnel.js +const { Model, DataTypes } = require("sequelize"); + +class MaintenanceLogPersonnel extends Model { + static init(sequelize) { + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + role: { + type: DataTypes.STRING(100), + allowNull: false, + comment: "정비 작업 역할", + }, + }, + { + sequelize, + modelName: "MaintenanceLogPersonnel", + tableName: "maintenance_log_personnel", + timestamps: true, + indexes: [ + { + fields: ["maintenanceLogId", "personnelId"], + unique: true, + }, + ], + } + ); + return this; + } + + static associate(models) { + this.belongsTo(models.MaintenanceLog, { foreignKey: "maintenanceLogId" }); + this.belongsTo(models.Personnel, { foreignKey: "personnelId" }); + } +} + +module.exports = MaintenanceLogPersonnel; diff --git a/fems-api/src/models/Parts.js b/fems-api/src/models/Parts.js new file mode 100644 index 0000000..40f4f25 --- /dev/null +++ b/fems-api/src/models/Parts.js @@ -0,0 +1,132 @@ +// models/Parts.js +const { Model, DataTypes } = require("sequelize"); + +class Parts extends Model { + static init(sequelize) { + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + partNumber: { + type: DataTypes.STRING(50), + allowNull: false, + unique: true, + comment: "부품 번호", + }, + name: { + type: DataTypes.STRING(100), + allowNull: false, + comment: "부품명", + }, + description: { + type: DataTypes.TEXT, + allowNull: true, + comment: "부품 설명", + }, + category: { + type: DataTypes.STRING(50), + allowNull: false, + comment: "부품 카테고리", + }, + manufacturer: { + type: DataTypes.STRING(100), + allowNull: true, + comment: "제조사", + }, + specifications: { + type: DataTypes.JSONB, + allowNull: true, + comment: "상세 사양", + }, + unitPrice: { + type: DataTypes.DECIMAL(15, 2), + allowNull: false, + defaultValue: 0, + comment: "단가", + }, + stockQuantity: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + comment: "재고 수량", + }, + minStockLevel: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 1, + comment: "최소 재고 수준", + }, + location: { + type: DataTypes.STRING(100), + allowNull: true, + comment: "보관 위치", + }, + leadTime: { + type: DataTypes.INTEGER, + allowNull: true, + comment: "조달 소요 시간(일)", + }, + supplier: { + type: DataTypes.JSONB, + allowNull: true, + comment: "공급업체 정보", + }, + status: { + type: DataTypes.ENUM("active", "discontinued", "out_of_stock"), + defaultValue: "active", + comment: "부품 상태", + }, + lastPurchaseDate: { + type: DataTypes.DATE, + allowNull: true, + comment: "최근 구매일", + }, + compatibleEquipment: { + type: DataTypes.JSONB, + allowNull: true, + comment: "호환 설비 정보", + }, + }, + { + sequelize, + modelName: "Parts", + tableName: "parts", + timestamps: true, + indexes: [ + { + fields: ["partNumber"], + }, + { + fields: ["category", "manufacturer"], + }, + { + fields: ["status", "stockQuantity"], + }, + ], + } + ); + return this; + } + + static associate(models) { + this.belongsTo(models.Company, { foreignKey: "companyId" }); + this.belongsTo(models.Branch, { foreignKey: "branchId" }); + this.hasMany(models.MaintenanceLog, { foreignKey: "partId" }); + this.belongsToMany(models.Equipment, { + through: "EquipmentParts", + foreignKey: "partId", + otherKey: "equipmentId", + }); + // MaintenanceLog와의 다대다 관계 수정 + this.belongsToMany(models.MaintenanceLog, { + through: models.MaintenanceLogParts, + foreignKey: "partId", + otherKey: "maintenanceLogId", + }); + } +} + +module.exports = Parts; diff --git a/fems-api/src/models/Personnel.js b/fems-api/src/models/Personnel.js new file mode 100644 index 0000000..c036ad2 --- /dev/null +++ b/fems-api/src/models/Personnel.js @@ -0,0 +1,118 @@ +// models/Personnel.js +const { Model, DataTypes } = require("sequelize"); + +class Personnel extends Model { + static init(sequelize) { + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + employeeNumber: { + type: DataTypes.STRING(20), + allowNull: false, + unique: true, + comment: "사원 번호", + }, + name: { + type: DataTypes.STRING(50), + allowNull: false, + comment: "작업자 이름", + }, + position: { + type: DataTypes.STRING(50), + allowNull: false, + comment: "직위", + }, + department: { + type: DataTypes.STRING(50), + allowNull: false, + comment: "소속 부서", + }, + specialization: { + type: DataTypes.STRING(100), + allowNull: true, + comment: "전문 분야", + }, + certifications: { + type: DataTypes.JSONB, + allowNull: true, + comment: "보유 자격증", + }, + contactNumber: { + type: DataTypes.STRING(20), + allowNull: false, + comment: "연락처", + }, + email: { + type: DataTypes.STRING(100), + allowNull: false, + validate: { + isEmail: true, + }, + comment: "이메일", + }, + status: { + type: DataTypes.ENUM("active", "inactive", "leave"), + defaultValue: "active", + comment: "재직 상태", + }, + skills: { + type: DataTypes.JSONB, + allowNull: true, + comment: "보유 기술", + }, + availabilitySchedule: { + type: DataTypes.JSONB, + allowNull: true, + comment: "근무 일정", + }, + isAvailable: { + type: DataTypes.BOOLEAN, + defaultValue: true, + comment: "현재 가용 상태", + }, + }, + { + sequelize, + modelName: "Personnel", + tableName: "personnel", + timestamps: true, + indexes: [ + { + fields: ["employeeNumber"], + }, + { + fields: ["name"], + }, + { + fields: ["department", "position"], + }, + ], + } + ); + return this; + } + + static associate(models) { + this.belongsTo(models.Company, { foreignKey: "companyId" }); + this.belongsTo(models.Branch, { foreignKey: "branchId" }); + this.belongsTo(models.Department, { foreignKey: "departmentId" }); + this.hasMany(models.MaintenanceLog, { foreignKey: "personnelId" }); + this.belongsToMany(models.Equipment, { + through: "PersonnelEquipment", // 담당 설비 관계 테이블 + foreignKey: "personnelId", + otherKey: "equipmentId", + }); + // MaintenanceLog와의 다대다 관계 수정 + this.belongsToMany(models.MaintenanceLog, { + through: models.MaintenanceLogPersonnel, + foreignKey: "personnelId", + otherKey: "maintenanceLogId", + }); + } +} + +module.exports = Personnel; diff --git a/fems-api/src/routes/app.js b/fems-api/src/routes/app.js index 1a69dca..d091184 100644 --- a/fems-api/src/routes/app.js +++ b/fems-api/src/routes/app.js @@ -7,13 +7,23 @@ const usersController = require("../controllers/app/users/users.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"); // 추가 +const fileController = require("../controllers/app/file/file.controller"); +const maintenanceController = require("../controllers/app/maintenance/maintenance.controller"); +const personnelController = require("../controllers/app/personnel/personnel.controller"); +const partsController = require("../controllers/app/parts/parts.controller"); +const equipmentPartsController = require("../controllers/app/equipmentParts/equipmentParts.controller"); // 추가 +const departmentController = require("../controllers/app/department/department.controller"); router.use("/auth", authController); router.use("/users", usersController); router.use("/dashboard", dashboardController); router.use("/equipment", equipmentController); router.use("/zone", zoneController); -router.use("/files", fileController); // 추가 +router.use("/files", fileController); +router.use("/maintenance", maintenanceController); +router.use("/personnel", personnelController); +router.use("/parts", partsController); +router.use("/equipment-parts", equipmentPartsController); +router.use("/department", departmentController); module.exports = router; diff --git a/fems-api/src/services/department.service.js b/fems-api/src/services/department.service.js index 97103cd..a21bc4f 100644 --- a/fems-api/src/services/department.service.js +++ b/fems-api/src/services/department.service.js @@ -1,10 +1,9 @@ // src/services/department.service.js -const { Department, User } = require("../models"); +const { Department, Company, Branch, User } = require("../models"); const alertService = require("./alert.service"); class DepartmentService { async findByCompanyId(companyId) { - // 1. 모든 부서 데이터를 가져옴 (raw 옵션 제거) const departments = await Department.findAll({ where: { companyId }, include: [ @@ -15,17 +14,14 @@ class DepartmentService { ], }); - // 2. 계층 구조로 변환하는 함수 const buildHierarchy = (items) => { const map = new Map(); const roots = []; - // 첫 번째 순회: 모든 부서를 map에 저장 items.forEach((item) => { - map.set(item.id, { ...item.get({ plain: true }), children: [] }); // plain:true 사용하여 직렬화 + map.set(item.id, { ...item.get({ plain: true }), children: [] }); }); - // 두 번째 순회: 부모-자식 관계 설정 items.forEach((item) => { if (item.parentId) { const parent = map.get(item.parentId); @@ -64,6 +60,90 @@ class DepartmentService { }); } + // 추가: 앱에서 사용할 부서 목록 조회 메서드 + async findAll(currentUser, format = "flat", includeInactive = false) { + let where = {}; + + // company_admin은 자신의 회사 부서만 조회 가능 + if (currentUser.role === "company_admin") { + where.companyId = currentUser.companyId; + } + + // 활성 상태 필터링 + if (!includeInactive) { + where.isActive = true; + } + + const departments = await Department.findAll({ + where, + include: [ + { + model: Company, + attributes: ["id", "name"], + }, + { + model: Branch, + attributes: ["id", "name"], + }, + { + model: Department, + as: "parent", + attributes: ["id", "name"], + }, + { + model: User, + attributes: ["id", "name"], + }, + ], + order: [ + ["parentId", "ASC NULLS FIRST"], + ["name", "ASC"], + ], + }); + + // 트리 구조로 반환 + if (format === "tree") { + const buildHierarchy = (items, parentId = null) => { + return items + .filter((item) => item.parentId === parentId) + .map((item) => ({ + ...item.get({ plain: true }), + children: buildHierarchy(items, item.id), + // 추가 정보 계산 + userCount: item.Users ? item.Users.length : 0, + path: this.getDepartmentPath(items, item), + })); + }; + + return buildHierarchy(departments); + } + + // 평면 구조로 반환 + return departments.map((dept) => { + const json = dept.get({ plain: true }); + // 추가 정보 계산 + json.path = this.getDepartmentPath(departments, dept); + json.childCount = departments.filter( + (d) => d.parentId === dept.id + ).length; + json.userCount = dept.Users ? dept.Users.length : 0; + return json; + }); + } + + // 부서 경로 생성 헬퍼 메서드 + getDepartmentPath(departments, department) { + const path = []; + let current = department; + + while (current) { + path.unshift(current.name); + current = departments.find((d) => d.id === current.parentId); + } + + return path.join(" > "); + } + async hasChildren(id) { const childCount = await Department.count({ where: { parentId: id }, diff --git a/fems-api/src/services/equipmentParts.service.js b/fems-api/src/services/equipmentParts.service.js new file mode 100644 index 0000000..dc9ba51 --- /dev/null +++ b/fems-api/src/services/equipmentParts.service.js @@ -0,0 +1,153 @@ +// src/services/equipmentParts.service.js +const { EquipmentParts, Equipment, Parts, Company } = require("../models"); +const alertService = require("./alert.service"); + +class EquipmentPartsService { + async findAll(currentUser, filters = {}) { + const include = [ + { + model: Equipment, + include: [ + { + model: Company, + attributes: ["id", "name"], + where: currentUser.role === "company_admin" ? { id: currentUser.companyId } : {}, + }, + ], + }, + { + model: Parts, + attributes: ["id", "partNumber", "name", "category"], + }, + ]; + + const where = {}; + if (filters.equipmentId) where.equipmentId = filters.equipmentId; + if (filters.partId) where.partId = filters.partId; + + return await EquipmentParts.findAll({ + where, + include, + }); + } + + async findById(id) { + return await EquipmentParts.findByPk(id, { + include: [ + { + model: Equipment, + include: [{ model: Company, attributes: ["id", "name"] }], + }, + { + model: Parts, + attributes: ["id", "partNumber", "name", "category", "specifications"], + }, + ], + }); + } + + async createEquipmentPart(data, currentUser) { + // 설비와 부품이 동일한 회사에 속하는지 확인 + const equipment = await Equipment.findByPk(data.equipmentId); + const part = await Parts.findByPk(data.partId); + + if (!equipment || !part) { + throw new Error("Invalid equipment or part"); + } + + if (equipment.companyId !== part.companyId) { + throw new Error("Equipment and part must belong to the same company"); + } + + if (currentUser.role === "company_admin" && equipment.companyId !== currentUser.companyId) { + throw new Error("Cannot create mapping for different company's equipment"); + } + + const equipmentPart = await EquipmentParts.create(data); + + await alertService.createAlert({ + type: "info", + message: `부품 ${part.name}이(가) 설비 ${equipment.name}에 연결되었습니다.`, + companyId: equipment.companyId, + }); + + return this.findById(equipmentPart.id); + } + + async updateEquipmentPart(id, updateData, currentUser) { + const equipmentPart = await this.findById(id); + if (!equipmentPart) throw new Error("Equipment-Part mapping not found"); + + if ( + currentUser.role === "company_admin" && + equipmentPart.Equipment.companyId !== currentUser.companyId + ) { + throw new Error("Cannot modify mapping from different company"); + } + + const updatedEquipmentPart = await equipmentPart.update(updateData); + + // 교체 주기 알림 생성 + if (updatedEquipmentPart.lastReplacementDate) { + const nextReplacementDate = new Date(updatedEquipmentPart.lastReplacementDate); + nextReplacementDate.setDate( + nextReplacementDate.getDate() + updatedEquipmentPart.recommendedReplacementInterval + ); + + await alertService.createAlert({ + type: "info", + message: `설비 ${equipmentPart.Equipment.name}의 부품 ${equipmentPart.Parts.name} 다음 교체 예정일: ${nextReplacementDate.toLocaleDateString()}`, + companyId: equipmentPart.Equipment.companyId, + }); + } + + return updatedEquipmentPart; + } + + async deleteEquipmentPart(id, currentUser) { + const equipmentPart = await this.findById(id); + if (!equipmentPart) throw new Error("Equipment-Part mapping not found"); + + if ( + currentUser.role === "company_admin" && + equipmentPart.Equipment.companyId !== currentUser.companyId + ) { + throw new Error("Cannot delete mapping from different company"); + } + + await alertService.createAlert({ + type: "info", + message: `설비 ${equipmentPart.Equipment.name}에서 부품 ${equipmentPart.Parts.name}이(가) 제거되었습니다.`, + companyId: equipmentPart.Equipment.companyId, + }); + + return await equipmentPart.destroy(); + } + + async recordReplacement(id, replacementData, currentUser) { + const equipmentPart = await this.findById(id); + if (!equipmentPart) throw new Error("Equipment-Part mapping not found"); + + if ( + currentUser.role === "company_admin" && + equipmentPart.Equipment.companyId !== currentUser.companyId + ) { + throw new Error("Cannot record replacement for different company"); + } + + const updatedEquipmentPart = await equipmentPart.update({ + lastReplacementDate: replacementData.replacementDate, + notes: replacementData.notes, + }); + + await alertService.createAlert({ + type: "info", + message: `설비 ${equipmentPart.Equipment.name}의 부품 ${equipmentPart.Parts.name}이(가) 교체되었습니다.`, + companyId: equipmentPart.Equipment.companyId, + }); + + return updatedEquipmentPart; + } +} + +module.exports = new EquipmentPartsService(); \ No newline at end of file diff --git a/fems-api/src/services/maintenance.service.js b/fems-api/src/services/maintenance.service.js new file mode 100644 index 0000000..4e097ed --- /dev/null +++ b/fems-api/src/services/maintenance.service.js @@ -0,0 +1,288 @@ +// src/services/maintenance.service.js +const { + MaintenanceLog, + Equipment, + Company, + User, + MaintenanceLogParts, + MaintenanceLogPersonnel, + Parts, + Personnel, + sequelize, +} = require("../models"); +const alertService = require("./alert.service"); + +class MaintenanceService { + async findAll(currentUser) { + let where = {}; + + if (currentUser.role === "company_admin") { + where.companyId = currentUser.companyId; + } + + const maintenanceLogs = await MaintenanceLog.findAll({ + where, + include: [ + { + model: Equipment, + attributes: ["id", "name"], + }, + { + model: Company, + attributes: ["id", "name"], + }, + { + model: User, + as: "creator", + attributes: ["id", "name"], + }, + { + model: User, + as: "completer", + attributes: ["id", "name"], + }, + { + model: Parts, + as: "Parts", + through: { + model: MaintenanceLogParts, + attributes: ["quantity", "cost"], + }, + attributes: ["id", "partNumber", "name"], + }, + { + model: Personnel, + as: "Personnel", + through: { + model: MaintenanceLogPersonnel, + attributes: ["role"], + }, + attributes: ["id", "name", "department"], + }, + ], + }); + + return maintenanceLogs; + } + + async findById(id) { + return await MaintenanceLog.findByPk(id, { + include: [ + { + model: Equipment, + attributes: ["id", "name"], + }, + { + model: Company, + attributes: ["id", "name"], + }, + { + model: User, + as: "creator", + attributes: ["id", "name"], + }, + { + model: User, + as: "completer", + attributes: ["id", "name"], + }, + { + model: Parts, + as: "Parts", + through: { + model: MaintenanceLogParts, + attributes: ["quantity", "cost"], + }, + attributes: ["id", "partNumber", "name"], + }, + { + model: Personnel, + as: "Personnel", + through: { + model: MaintenanceLogPersonnel, + attributes: ["role"], + }, + attributes: ["id", "name", "department"], + }, + ], + }); + } + + async createMaintenanceLog(maintenanceData, currentUser) { + const transaction = await sequelize.transaction(); + + try { + const equipment = await Equipment.findByPk(maintenanceData.equipmentId); + if (!equipment) { + throw new Error("설비를 찾을 수 없습니다"); + } + + const companyId = + currentUser.role === "company_admin" + ? currentUser.companyId + : maintenanceData.companyId; + + // 1. MaintenanceLog 생성 + const maintenanceLog = await MaintenanceLog.create( + { + ...maintenanceData, + companyId, + createdBy: currentUser.id, + }, + { transaction } + ); + + // 2. Parts 관계 처리 + if (maintenanceData.parts && maintenanceData.parts.length > 0) { + const partsData = maintenanceData.parts.map((part) => ({ + maintenanceLogId: maintenanceLog.id, + partId: part.partId, + quantity: part.quantity, + cost: part.cost, + })); + + await MaintenanceLogParts.bulkCreate(partsData, { transaction }); + } + + // 3. Personnel 관계 처리 + if ( + maintenanceData.personnelInfo && + maintenanceData.personnelInfo.length > 0 + ) { + const personnelData = maintenanceData.personnelInfo.map((person) => ({ + maintenanceLogId: maintenanceLog.id, + personnelId: person.personnelId, + role: person.role, + })); + + await MaintenanceLogPersonnel.bulkCreate(personnelData, { + transaction, + }); + } + + await transaction.commit(); + + // 알림 생성 + await alertService.createAlert({ + type: "info", + message: `정비 로그 ${maintenanceLog.id}이(가) ${currentUser.name}에 의해 생성되었습니다.`, + companyId: companyId, + }); + + // 생성된 데이터 조회하여 반환 + return await this.findById(maintenanceLog.id); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + async updateMaintenanceLog(id, updateData, currentUser) { + const transaction = await sequelize.transaction(); + + try { + const maintenanceLog = await MaintenanceLog.findByPk(id); + if (!maintenanceLog) throw new Error("Maintenance log not found"); + + if (currentUser.role === "company_admin") { + if (maintenanceLog.companyId !== currentUser.companyId) { + throw new Error( + "Cannot modify maintenance log from different company" + ); + } + updateData.companyId = currentUser.companyId; + } + + // 1. MaintenanceLog 업데이트 + await maintenanceLog.update(updateData, { transaction }); + + // 2. Parts 관계 업데이트 + if (updateData.parts) { + // 기존 관계 삭제 + await MaintenanceLogParts.destroy({ + where: { maintenanceLogId: id }, + transaction, + }); + + // 새로운 관계 생성 + if (updateData.parts.length > 0) { + const partsData = updateData.parts.map((part) => ({ + maintenanceLogId: id, + partId: part.partId, + quantity: part.quantity, + cost: part.cost, + })); + + await MaintenanceLogParts.bulkCreate(partsData, { transaction }); + } + } + + // 3. Personnel 관계 업데이트 + if (updateData.personnelInfo) { + // 기존 관계 삭제 + await MaintenanceLogPersonnel.destroy({ + where: { maintenanceLogId: id }, + transaction, + }); + + // 새로운 관계 생성 + if (updateData.personnelInfo.length > 0) { + const personnelData = updateData.personnelInfo.map((person) => ({ + maintenanceLogId: id, + personnelId: person.personnelId, + role: person.role, + })); + + await MaintenanceLogPersonnel.bulkCreate(personnelData, { + transaction, + }); + } + } + + await transaction.commit(); + + // 알림 생성 + await alertService.createAlert({ + type: "info", + message: `정비 로그 ${maintenanceLog.id}이(가) ${currentUser.name}에 의해 수정되었습니다.`, + companyId: maintenanceLog.companyId, + }); + + // 업데이트된 데이터 조회하여 반환 + return await this.findById(id); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + async deleteMaintenanceLog(id, currentUser) { + const transaction = await sequelize.transaction(); + + try { + const maintenanceLog = await MaintenanceLog.findByPk(id); + if (!maintenanceLog) throw new Error("Maintenance log not found"); + + const maintenanceLogId = maintenanceLog.id; + const companyId = maintenanceLog.companyId; + + // 관련 데이터 삭제 (CASCADE 설정이 되어 있으므로 자동으로 삭제됨) + await maintenanceLog.destroy({ transaction }); + + await transaction.commit(); + + await alertService.createAlert({ + type: "info", + message: `정비 로그 ${maintenanceLogId}이(가) ${currentUser.name}에 의해 삭제되었습니다.`, + companyId: companyId, + }); + + return true; + } catch (error) { + await transaction.rollback(); + throw error; + } + } +} + +module.exports = new MaintenanceService(); diff --git a/fems-api/src/services/parts.service.js b/fems-api/src/services/parts.service.js new file mode 100644 index 0000000..8a0e12b --- /dev/null +++ b/fems-api/src/services/parts.service.js @@ -0,0 +1,146 @@ +// src/services/parts.service.js +const { + Parts, + Equipment, + Company, + Branch, + EquipmentParts, +} = require("../models"); +const { Op } = require("sequelize"); +const alertService = require("./alert.service"); + +class PartsService { + async findAll(currentUser, filters = {}) { + const where = {}; + if (currentUser.role === "company_admin") { + where.companyId = currentUser.companyId; + } + + // 필터 적용 + if (filters.category) where.category = filters.category; + if (filters.manufacturer) where.manufacturer = filters.manufacturer; + if (filters.status) where.status = filters.status; + if (filters.search) { + where[Op.or] = [ + { name: { [Op.iLike]: `%${filters.search}%` } }, + { partNumber: { [Op.iLike]: `%${filters.search}%` } }, + ]; + } + + return await Parts.findAll({ + where, + include: [ + { + model: Company, + attributes: ["id", "name"], + }, + { + model: Branch, + attributes: ["id", "name"], + }, + { + model: Equipment, + through: { + model: EquipmentParts, + attributes: ["quantity", "installationDate", "lastReplacementDate"], + }, + attributes: ["id", "name", "type"], + }, + ], + }); + } + + async findById(id) { + return await Parts.findByPk(id, { + include: [ + { + model: Company, + attributes: ["id", "name"], + }, + { + model: Branch, + attributes: ["id", "name"], + }, + { + model: Equipment, + through: { + model: EquipmentParts, + attributes: ["quantity", "installationDate", "lastReplacementDate"], + }, + attributes: ["id", "name", "type"], + }, + ], + }); + } + + async createPart(partData, currentUser) { + const part = await Parts.create({ + ...partData, + companyId: + currentUser.role === "company_admin" + ? currentUser.companyId + : partData.companyId, + }); + + // 재고 알림 생성 (재고가 최소 수준 이하일 경우) + if (part.stockQuantity <= part.minStockLevel) { + await alertService.createAlert({ + type: "warning", + message: `부품 ${part.name}(${part.partNumber})의 재고가 최소 수준 이하입니다.`, + companyId: part.companyId, + }); + } + + return part; + } + + async updatePart(id, updateData, currentUser) { + const part = await this.findById(id); + if (!part) throw new Error("Part not found"); + + if ( + currentUser.role === "company_admin" && + part.companyId !== currentUser.companyId + ) { + throw new Error("Cannot modify part from different company"); + } + + const updatedPart = await part.update(updateData); + + // 재고 상태 변경 알림 + if ( + updateData.stockQuantity && + updateData.stockQuantity <= part.minStockLevel + ) { + await alertService.createAlert({ + type: "warning", + message: `부품 ${part.name}의 재고가 최소 수준 이하입니다.`, + companyId: part.companyId, + }); + } + + return updatedPart; + } + + async deletePart(id, currentUser) { + const part = await this.findById(id); + if (!part) throw new Error("Part not found"); + + if ( + currentUser.role === "company_admin" && + part.companyId !== currentUser.companyId + ) { + throw new Error("Cannot delete part from different company"); + } + + await alertService.createAlert({ + type: "info", + message: `부품 ${part.name}(${part.partNumber})이(가) 삭제되었습니다.`, + companyId: part.companyId, + }); + + return await part.destroy(); + } +} + +module.exports = new PartsService(); diff --git a/fems-api/src/services/personnel.service.js b/fems-api/src/services/personnel.service.js new file mode 100644 index 0000000..ff83da8 --- /dev/null +++ b/fems-api/src/services/personnel.service.js @@ -0,0 +1,127 @@ +// src/services/personnel.service.js +const { + Personnel, + Company, + Branch, + Department, + MaintenanceLog, +} = require("../models"); +const alertService = require("./alert.service"); + +class PersonnelService { + async findAll(currentUser) { + let where = {}; + + // company_admin은 자신의 회사 작업자만 조회 가능 + if (currentUser.role === "company_admin") { + where.companyId = currentUser.companyId; + } + + const personnel = await Personnel.findAll({ + where, + include: [ + { + model: Company, + attributes: ["id", "name"], + }, + { + model: Branch, + attributes: ["id", "name"], + }, + { + model: Department, + attributes: ["id", "name"], + }, + ], + }); + + return personnel; + } + + async findById(id) { + return await Personnel.findByPk(id, { + include: [ + { + model: Company, + attributes: ["id", "name"], + }, + { + model: Branch, + attributes: ["id", "name"], + }, + { + model: Department, + attributes: ["id", "name"], + }, + { + model: MaintenanceLog, + attributes: ["id", "type", "scheduledDate", "status"], + }, + ], + }); + } + + async createPersonnel(personnelData, currentUser) { + // company_admin은 자신의 회사에만 작업자 생성 가능 + const companyId = + currentUser.role === "company_admin" + ? currentUser.companyId + : personnelData.companyId; + + const personnel = await Personnel.create({ + ...personnelData, + companyId, + }); + + await alertService.createAlert({ + type: "info", + message: `작업자 ${personnel.name}이(가) ${currentUser.name}에 의해 등록되었습니다.`, + companyId: companyId, + }); + + return personnel; + } + + async updatePersonnel(id, updateData, currentUser) { + const personnel = await Personnel.findByPk(id); + if (!personnel) throw new Error("Personnel not found"); + + // company_admin은 자신의 회사 작업자만 수정 가능 + if (currentUser.role === "company_admin") { + if (personnel.companyId !== currentUser.companyId) { + throw new Error("Cannot modify personnel from different company"); + } + updateData.companyId = currentUser.companyId; + } + + const updatedPersonnel = await personnel.update(updateData); + + await alertService.createAlert({ + type: "info", + message: `작업자 ${personnel.name}의 정보가 ${currentUser.name}에 의해 수정되었습니다.`, + companyId: personnel.companyId, + }); + + return updatedPersonnel; + } + + async deletePersonnel(id, currentUser) { + const personnel = await Personnel.findByPk(id); + if (!personnel) throw new Error("Personnel not found"); + + const personnelName = personnel.name; + const companyId = personnel.companyId; + + await personnel.destroy(); + + await alertService.createAlert({ + type: "info", + message: `작업자 ${personnelName}이(가) ${currentUser.name}에 의해 삭제되었습니다.`, + companyId: companyId, + }); + + return true; + } +} + +module.exports = new PersonnelService(); diff --git a/fems-api/src/utils/initialSetup/dataSetup.js b/fems-api/src/utils/initialSetup/dataSetup.js index eaae39d..17e5fb1 100644 --- a/fems-api/src/utils/initialSetup/dataSetup.js +++ b/fems-api/src/utils/initialSetup/dataSetup.js @@ -16,6 +16,7 @@ const { equipmentDataTemplate, } = require("./setupData"); const logger = require("../../config/logger"); +const { createMaintenanceData } = require("./maintenanceSetup"); async function createInitialData(companyId, branchId) { try { @@ -28,6 +29,9 @@ async function createInitialData(companyId, branchId) { await createAlertData(companyId); await createKpiData(companyId); + // 정비 관련 데이터 생성 추가 + await createMaintenanceData(companyId, branchId); + logger.info("Initial development data created successfully"); } catch (error) { logger.error("Error creating initial data:", error); @@ -81,6 +85,7 @@ async function createZoneAndEquipmentData(companyId, branchId) { return MaintenanceLog.create({ ...log, equipmentId: equip.id, + companyId, scheduledDate, completionDate, nextMaintenanceDate: new Date( diff --git a/fems-api/src/utils/initialSetup/maintenanceSetup.js b/fems-api/src/utils/initialSetup/maintenanceSetup.js new file mode 100644 index 0000000..2107a5f --- /dev/null +++ b/fems-api/src/utils/initialSetup/maintenanceSetup.js @@ -0,0 +1,111 @@ +// src/utils/initialSetup/maintenanceSetup.js +const { + Personnel, + Parts, + EquipmentParts, + Equipment, + Department, +} = require("../../models"); +const logger = require("../../config/logger"); +const { + personnelDefinitions, + partsDefinitions, + equipmentPartsDefinitions, +} = require("./setupData"); + +async function createMaintenanceData(companyId, branchId) { + try { + // 1. 정비 인력 생성 + for (const personnelData of personnelDefinitions) { + const department = await Department.findOne({ + where: { + name: personnelData.department, + companyId, + }, + }); + + if (!department) { + logger.warn( + `Department ${personnelData.department} not found for personnel ${personnelData.name}` + ); + continue; + } + + await Personnel.create({ + ...personnelData, + companyId, + branchId, + departmentId: department.id, + }); + } + logger.info("Personnel data created successfully"); + + // 2. 부품 데이터 생성 + const partsMap = {}; + for (const partData of partsDefinitions) { + try { + const part = await Parts.create({ + ...partData, + companyId, + branchId, + lastPurchaseDate: new Date(), + }); + partsMap[partData.partNumber] = part; + } catch (error) { + logger.error( + `Error creating part ${partData.partNumber}: ${error.message}` + ); + continue; + } + } + logger.info("Parts data created successfully"); + + // 3. 설비-부품 매핑 생성 + for (const mapping of equipmentPartsDefinitions) { + const equipment = await Equipment.findOne({ + where: { + name: mapping.equipmentName, + companyId, + }, + }); + + if (!equipment) { + logger.warn(`Equipment ${mapping.equipmentName} not found`); + continue; + } + + for (const partMapping of mapping.parts) { + const part = partsMap[partMapping.partNumber]; + if (!part) { + logger.warn( + `Part ${partMapping.partNumber} not found for equipment ${mapping.equipmentName}` + ); + continue; + } + + try { + await EquipmentParts.create({ + equipmentId: equipment.id, + partId: part.id, + ...partMapping, + installationDate: new Date(partMapping.installationDate), + lastReplacementDate: new Date(partMapping.lastReplacementDate), + }); + } catch (error) { + logger.error( + `Error creating equipment-part mapping for ${mapping.equipmentName} - ${partMapping.partNumber}: ${error.message}` + ); + continue; + } + } + } + logger.info("Equipment-Parts mapping created successfully"); + } catch (error) { + logger.error("Error in createMaintenanceData:", error); + throw new Error(`Failed to create maintenance data: ${error.message}`); + } +} + +module.exports = { + createMaintenanceData, +}; diff --git a/fems-api/src/utils/initialSetup/setupData.js b/fems-api/src/utils/initialSetup/setupData.js index 509ac73..f070b30 100644 --- a/fems-api/src/utils/initialSetup/setupData.js +++ b/fems-api/src/utils/initialSetup/setupData.js @@ -279,6 +279,168 @@ const equipmentDataTemplate = { }, }; +const personnelDefinitions = [ + { + employeeNumber: "M001", + name: "김정비", + position: "팀장", + department: "설비관리팀", + specialization: "기계설비", + certifications: [ + { + name: "에너지관리기사", + issueDate: "2020-01-01", + expiryDate: "2025-01-01", + }, + { + name: "공조냉동기계기사", + issueDate: "2019-01-01", + expiryDate: "2024-01-01", + }, + ], + contactNumber: "010-1234-5678", + email: "maintenance1@company.com", + status: "active", + skills: ["공조설비", "보일러", "냉동기"], + availabilitySchedule: { + weekday: "09:00-18:00", + weekend: "on-call", + }, + }, + { + employeeNumber: "M002", + name: "이기계", + position: "과장", + department: "설비관리팀", + specialization: "전기설비", + certifications: [ + { + name: "전기기사", + issueDate: "2021-01-01", + expiryDate: "2026-01-01", + }, + ], + contactNumber: "010-2345-6789", + email: "maintenance2@company.com", + status: "active", + skills: ["전기설비", "자동제어", "PLC"], + availabilitySchedule: { + weekday: "09:00-18:00", + weekend: "off", + }, + }, +]; + +const partsDefinitions = [ + { + partNumber: "HVAC-F001", + name: "공조기 프리필터", + description: "공조기용 프리필터 (초급)", + category: "Filter", + manufacturer: "필터테크", + specifications: { + size: "600x600x50mm", + material: "부직포", + efficiency: "G4", + flowRate: "3000CMH", + }, + unitPrice: 50000, + stockQuantity: 10, + minStockLevel: 3, + location: "자재창고 A-1", + leadTime: 7, + supplier: { + name: "필터테크", + contact: "02-123-4567", + email: "sales@filtertech.com", + }, + compatibleEquipment: ["공조기-1"], + }, + { + partNumber: "HVAC-B001", + name: "V-벨트", + description: "공조기 팬용 V-벨트", + category: "Belt", + manufacturer: "벨트코리아", + specifications: { + type: "B-type", + length: "1200mm", + width: "17mm", + }, + unitPrice: 30000, + stockQuantity: 5, + minStockLevel: 2, + location: "자재창고 A-2", + leadTime: 3, + supplier: { + name: "벨트코리아", + contact: "02-234-5678", + email: "sales@beltkorea.com", + }, + compatibleEquipment: ["공조기-1"], + }, + { + partNumber: "BLR-V001", + name: "볼밸브", + description: "보일러 급수용 볼밸브", + category: "Valve", + manufacturer: "밸브텍", + specifications: { + size: "50A", + pressure: "10K", + material: "청동", + }, + unitPrice: 150000, + stockQuantity: 2, + minStockLevel: 1, + location: "자재창고 B-1", + leadTime: 14, + supplier: { + name: "밸브텍", + contact: "02-345-6789", + email: "sales@valvetech.com", + }, + compatibleEquipment: ["보일러-1"], + }, +]; + +const equipmentPartsDefinitions = [ + { + equipmentName: "공조기-1", + parts: [ + { + partNumber: "HVAC-F001", + quantity: 2, + installationDate: "2024-01-01", + lastReplacementDate: "2024-01-01", + recommendedReplacementInterval: 90, + notes: "3개월 주기 교체 필요", + }, + { + partNumber: "HVAC-B001", + quantity: 1, + installationDate: "2024-01-01", + lastReplacementDate: "2024-01-01", + recommendedReplacementInterval: 180, + notes: "6개월 주기 점검 필요", + }, + ], + }, + { + equipmentName: "보일러-1", + parts: [ + { + partNumber: "BLR-V001", + quantity: 1, + installationDate: "2024-01-01", + lastReplacementDate: "2024-01-01", + recommendedReplacementInterval: 365, + notes: "연간 점검 필요", + }, + ], + }, +]; + module.exports = { departmentStructure, roleDefinitions, @@ -287,4 +449,7 @@ module.exports = { equipmentDefinitions, maintenanceLogDefinitions, equipmentDataTemplate, + personnelDefinitions, + partsDefinitions, + equipmentPartsDefinitions, }; diff --git a/fems-app/src/app/(admin)/users/accounts/page.tsx b/fems-app/src/app/(admin)/users/accounts/page.tsx index 1ae91c3..9423dd7 100644 --- a/fems-app/src/app/(admin)/users/accounts/page.tsx +++ b/fems-app/src/app/(admin)/users/accounts/page.tsx @@ -264,7 +264,7 @@ const AccountsPage = () => { if (!open) setEditingUser(null); }} > - + {editingUser ? "유저 수정" : "새 유저"} diff --git a/fems-app/src/app/(admin)/users/departments/page.tsx b/fems-app/src/app/(admin)/users/departments/page.tsx index 8c708e2..0ee193b 100644 --- a/fems-app/src/app/(admin)/users/departments/page.tsx +++ b/fems-app/src/app/(admin)/users/departments/page.tsx @@ -387,7 +387,7 @@ const DepartmentsPage = () => { {/* 부서 생성/수정 다이얼로그 */} - + {editingDepartment ? "부서 수정" : "새 부서"} diff --git a/fems-app/src/app/(equipment)/inventory/[mode]/components/EquipmentForm.tsx b/fems-app/src/app/(equipment)/inventory/[mode]/components/EquipmentForm.tsx index 1470caa..fad4fdf 100644 --- a/fems-app/src/app/(equipment)/inventory/[mode]/components/EquipmentForm.tsx +++ b/fems-app/src/app/(equipment)/inventory/[mode]/components/EquipmentForm.tsx @@ -112,10 +112,30 @@ export function EquipmentForm({ // 파일들을 카테고리별로 분류 const documents: { - manual: any[]; // null 대신 배열로 변경 - technical: any[]; - certificate: any[]; - drawing: any[]; + manual: { + category: string; + id: string; + name: string; + status: string; + }[]; + technical: { + category: string; + id: string; + name: string; + status: string; + }[]; + certificate: { + category: string; + id: string; + name: string; + status: string; + }[]; + drawing: { + category: string; + id: string; + name: string; + status: string; + }[]; } = { manual: [], // 배열로 초기화 technical: [], @@ -125,17 +145,24 @@ export function EquipmentForm({ console.log("Fetched files:", files); - files.forEach((file: any) => { - if (file.category === "manual") { - documents.manual.push(file); // push로 변경 - } else if (file.category === "technical") { - documents.technical.push(file); - } else if (file.category === "certificate") { - documents.certificate.push(file); - } else if (file.category === "drawing") { - documents.drawing.push(file); + files.forEach( + (file: { + category: string; + id: string; + name: string; + status: string; + }) => { + if (file.category === "manual") { + documents.manual.push(file); // push로 변경 + } else if (file.category === "technical") { + documents.technical.push(file); + } else if (file.category === "certificate") { + documents.certificate.push(file); + } else if (file.category === "drawing") { + documents.drawing.push(file); + } } - }); + ); console.log("Categorized documents:", documents); @@ -150,7 +177,7 @@ export function EquipmentForm({ }; fetchFiles(); - }, [initialData]); + }, [form, initialData]); // Fetch zone for dropdown const { data: zone } = useQuery({ @@ -196,27 +223,14 @@ export function EquipmentForm({ form.setValue("specifications", newSpecs); }; - // 삭제 예정 파일들을 관리하기 위한 상태 - const [removedFiles, setRemovedFiles] = React.useState<{ - manual: any[]; - technical: any[]; - certificate: any[]; - drawing: any[]; - }>({ - manual: [], - technical: [], - certificate: [], - drawing: [], - }); - // 삭제된 파일 ID 관리를 위한 상태 추가 const [deletedFileIds, setDeletedFileIds] = React.useState([]); // 파일 변경 핸들러 수정 const handleFileChange = ( fieldName: "manual" | "technical" | "certificate" | "drawing", - files: any[], - removedFiles: any[] + files: { id?: string; name: string; status: string }[], + removedFiles: { id?: string; name: string; status: string }[] ) => { form.setValue(`documents.${fieldName}`, files); @@ -224,14 +238,17 @@ export function EquipmentForm({ const removedFileIds = removedFiles .filter((file) => file.id) .map((file) => file.id); - setDeletedFileIds((prev) => [...prev, ...removedFileIds]); + setDeletedFileIds((prev) => [ + ...prev, + ...removedFileIds.filter((id) => id !== undefined), + ]); }; // 제출 핸들러 수정 const handleSubmit = async (data: z.infer) => { try { // 1. 먼저 장비 데이터 저장 - await onSubmit(data); + onSubmit(data); // 2. 삭제된 파일 처리 if (deletedFileIds.length > 0) { @@ -252,7 +269,7 @@ export function EquipmentForm({ if (file.id) newFileIds.push(file.id); }); } else if (files && typeof files === "object" && "id" in files) { - newFileIds.push((files as any).id); + newFileIds.push((files as { id: string }).id); } }); @@ -289,9 +306,9 @@ export function EquipmentForm({ files && typeof files === "object" && "id" in files && - (files as any).status === "temporary" + (files as { id: string; status: string }).status === "temporary" ) { - temporaryFiles.push((files as any).id); + temporaryFiles.push((files as { id: string; status: string }).id); } }); @@ -417,7 +434,7 @@ export function EquipmentForm({ - {zone?.map((zone: any) => ( + {zone?.map((zone: { id: string; name: string }) => ( {zone.name} @@ -514,7 +531,7 @@ export function EquipmentForm({ ); }} multiple={true} - accept=".pdf,.doc,.docx" + accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt" moduleType="equipment" category="manual" referenceId={initialData?.id} @@ -543,7 +560,7 @@ export function EquipmentForm({ ); }} multiple={true} - accept=".pdf,.doc,.docx" + accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt" moduleType="equipment" category="technical" referenceId={initialData?.id} @@ -572,7 +589,7 @@ export function EquipmentForm({ ); }} multiple={true} - accept=".pdf,.jpg,.png" + accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt" moduleType="equipment" category="certificate" referenceId={initialData?.id} @@ -601,7 +618,7 @@ export function EquipmentForm({ ); }} multiple={true} - accept=".pdf,.jpg,.png" + accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt" moduleType="equipment" category="drawing" referenceId={initialData?.id} diff --git a/fems-app/src/app/(equipment)/inventory/detail/[id]/components/EquipmentFiles.tsx b/fems-app/src/app/(equipment)/inventory/detail/[id]/components/EquipmentFiles.tsx index 1b74032..10ccd91 100644 --- a/fems-app/src/app/(equipment)/inventory/detail/[id]/components/EquipmentFiles.tsx +++ b/fems-app/src/app/(equipment)/inventory/detail/[id]/components/EquipmentFiles.tsx @@ -29,11 +29,9 @@ interface DownloadState { }; } -const FileList: React.FC<{ files: File[]; category: string }> = ({ - files, - category, -}) => { +const FileList: React.FC<{ files: File[]; category: string }> = ({ files }) => { const { toast } = useToast(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const [downloadStates, setDownloadStates] = useState({}); // handleDownload 함수 수정 @@ -73,7 +71,7 @@ const FileList: React.FC<{ files: File[]; category: string }> = ({ ...prev, [fileId]: { isDownloading: false, - error: (error as any).message, + error: (error as Error).message, success: false, }, })); diff --git a/fems-app/src/app/(equipment)/inventory/page.tsx b/fems-app/src/app/(equipment)/inventory/page.tsx index 0a315fa..6bfb2e2 100644 --- a/fems-app/src/app/(equipment)/inventory/page.tsx +++ b/fems-app/src/app/(equipment)/inventory/page.tsx @@ -24,7 +24,7 @@ import { Switch } from "@/components/ui/switch"; import { useRouter } from "next/navigation"; const InventoryPage = () => { - const { token, user } = useAuthStore(); + const { token } = useAuthStore(); const [isOpen, setIsOpen] = React.useState(false); const [editingEquipment, setEditingEquipment] = React.useState(null); @@ -42,36 +42,6 @@ const InventoryPage = () => { enabled: !!token, }); - // Create equipment mutation - // const createMutation = useMutation({ - // mutationFn: async (newEquipment: Partial) => { - // const equipmentWithCompanyId = { - // ...newEquipment, - // companyId: user?.companyId, - // }; - // const { data } = await api.post( - // "/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) => { @@ -159,8 +129,10 @@ const InventoryPage = () => { accessorKey: "lastMaintenance", header: "최근 정비일", cell: ({ row }) => - row.original.lastMaintenance - ? new Date(row.original.lastMaintenance).toLocaleDateString() + row.original.MaintenanceLogs?.[0]?.completionDate + ? new Date( + row.original.MaintenanceLogs?.[0]?.completionDate + ).toLocaleDateString() : "-", }, { @@ -284,7 +256,7 @@ const InventoryPage = () => { if (!open) setEditingEquipment(null); }} > - + {editingEquipment ? "설비 수정" : "새 설비"} diff --git a/fems-app/src/app/(equipment)/maintenance/[mode]/components/MaintenanceForm.tsx b/fems-app/src/app/(equipment)/maintenance/[mode]/components/MaintenanceForm.tsx new file mode 100644 index 0000000..546ee5e --- /dev/null +++ b/fems-app/src/app/(equipment)/maintenance/[mode]/components/MaintenanceForm.tsx @@ -0,0 +1,842 @@ +// src/app/(equipment)/maintenance/[mode]/components/MaintenanceForm.tsx + +"use client"; + +import React from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm, useFieldArray } 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 { MaintenanceLog } from "@/types/maintenance"; +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"; +import { Trash2 } from "lucide-react"; + +// 수정된 maintenanceSchema +const maintenanceSchema = z.object({ + type: z.enum(["preventive", "corrective", "predictive", "inspection"]), + status: z + .enum(["scheduled", "in_progress", "completed", "cancelled"]) + .optional(), + scheduledDate: z.string().min(1, "예정일을 입력하세요"), + completionDate: z.string().optional(), + description: z.string().min(1, "정비 내용을 입력하세요"), + equipmentId: z.string().uuid("유효한 설비 ID를 선택하세요"), + findings: z.string().optional(), + actions: z.string().optional(), + cost: z.preprocess((val) => { + const processed = parseFloat(val as string); + return isNaN(processed) ? 0 : processed; + }, z.number().min(0, "비용은 0 이상이어야 합니다.")), + parts: z + .array( + z.object({ + partId: z.string().uuid("유효한 부품 ID입니다."), + quantity: z.preprocess((val) => { + const processed = parseInt(val as string, 10); + return isNaN(processed) ? 0 : processed; + }, z.number().min(1, "수량은 1 이상이어야 합니다.")), + cost: z.preprocess((val) => { + const processed = parseFloat(val as string); + return isNaN(processed) ? 0 : processed; + }, z.number().min(0, "비용은 0 이상이어야 합니다.")), + }) + ) + .optional(), + personnelInfo: z + .array( + z.object({ + personnelId: z.string().uuid("유효한 작업자 ID입니다."), + role: z.string().min(1, "작업자 역할을 입력하세요"), + }) + ) + .optional(), + attachments: z.array(z.any()).optional(), + nextMaintenanceDate: z.string().optional(), + equipmentStatus: z.enum(["operational", "degraded", "failed"]).optional(), + specifications: z.record(z.string()).optional(), + documents: z.object({ + report: z.array(z.any()).optional(), + checklist: z.array(z.any()).optional(), + photo: z.array(z.any()).optional(), + }), +}); + +interface MaintenanceFormProps { + initialData?: MaintenanceLog; + onSubmit: (data: z.infer) => void; + onCancel: () => void; +} + +export function MaintenanceForm({ + initialData, + onSubmit, + onCancel, +}: MaintenanceFormProps) { + const form = useForm>({ + resolver: zodResolver(maintenanceSchema), + defaultValues: { + type: initialData?.type || "preventive", + status: initialData?.status || "scheduled", + scheduledDate: initialData?.scheduledDate + ? new Date(initialData.scheduledDate).toISOString().split("T")[0] + : "", + completionDate: initialData?.completionDate + ? new Date(initialData.completionDate).toISOString().split("T")[0] + : "", + description: initialData?.description || "", + equipmentId: initialData?.equipmentId || "", + findings: initialData?.findings || "", + actions: initialData?.actions || "", + cost: initialData?.cost || 0, + parts: + initialData?.Parts?.map((part) => ({ + partId: part.id, + quantity: part.MaintenanceLogParts?.quantity || 0, + cost: part.MaintenanceLogParts?.cost || 0, + })) || [], + personnelInfo: + initialData?.Personnel?.map((person) => ({ + personnelId: person.id, + role: person.MaintenanceLogPersonnel?.role || "", + })) || [], + nextMaintenanceDate: initialData?.nextMaintenanceDate + ? new Date(initialData.nextMaintenanceDate).toISOString().split("T")[0] + : "", + equipmentStatus: initialData?.equipmentStatus || undefined, + documents: initialData?.documents + ? { + report: initialData.documents.report || [], + checklist: initialData.documents.checklist || [], + photo: initialData.documents.photo || [], + } + : { + report: [], + checklist: [], + photo: [], + }, + }, + }); + + const { + fields: partsFields, + append: appendPart, + remove: removePart, + } = useFieldArray({ + control: form.control, + name: "parts", + }); + + const { + fields: personnelFields, + append: appendPersonnel, + remove: removePersonnel, + } = useFieldArray({ + control: form.control, + name: "personnelInfo", + }); + + const [isLoadingFiles, setIsLoadingFiles] = React.useState(false); + + // Fetch files in edit mode + React.useEffect(() => { + const fetchFiles = async () => { + if (initialData?.id) { + setIsLoadingFiles(true); + try { + const { data: files } = await api.get(`/api/v1/app/files/list`, { + params: { + referenceId: initialData.id, + }, + }); + + // Categorize files if needed + const documents: { + report: { + category: string; + id: string; + name: string; + status: string; + }[]; + checklist: { + category: string; + id: string; + name: string; + status: string; + }[]; + photo: { + category: string; + id: string; + name: string; + status: string; + }[]; + } = { + report: [], + checklist: [], + photo: [], + }; + + console.log("Fetched files:", files); + + files.forEach( + (file: { + category: string; + id: string; + name: string; + status: string; + }) => { + if (file.category === "report") { + documents.report.push(file); + } else if (file.category === "checklist") { + documents.checklist.push(file); + } else if (file.category === "photo") { + documents.photo.push(file); + } + } + ); + + console.log("Categorized documents:", documents); + + // Update form values + form.setValue("documents", documents); + } catch (error) { + console.error("파일 불러오기 실패:", error); + } finally { + setIsLoadingFiles(false); + } + } + }; + + fetchFiles(); + }, [initialData, form]); + + // Fetch equipment list for dropdown + const { data: equipmentList } = useQuery({ + queryKey: ["equipment"], + queryFn: async () => { + const { data } = await api.get("/api/v1/app/equipment"); + return data; + }, + }); + + // Fetch personnel list for dropdown (assuming such an API exists) + const { data: personnelList, isLoading: isLoadingPersonnel } = useQuery({ + queryKey: ["personnel"], + queryFn: async () => { + const { data } = await api.get("/api/v1/app/personnel"); + return data; + }, + }); + + // Fetch parts list for dropdown (assuming such an API exists) + const { data: partsList, isLoading: isLoadingParts } = useQuery({ + queryKey: ["parts"], + queryFn: async () => { + const { data } = await api.get("/api/v1/app/parts"); + return data; + }, + }); + + // 삭제된 파일 ID 관리를 위한 상태 추가 + const [deletedFileIds, setDeletedFileIds] = React.useState([]); + + // File change handler + const handleFileChange = ( + fieldName: "report" | "checklist" | "photo", + files: { id?: string; name: string; status: string }[], + removedFiles: { id?: string; name: string; status: string }[] + ) => { + // Store removed files + form.setValue(`documents.${fieldName}`, files); + + // 삭제된 파일의 ID를 저장 + const removedFileIds = removedFiles + .filter((file) => file.id) + .map((file) => file.id); + setDeletedFileIds((prev) => [ + ...prev, + ...removedFileIds.filter((id) => id !== undefined), + ]); + }; + + // Submit handler + const handleSubmit = async (data: z.infer) => { + console.log("폼 제출 데이터:", data); // 추가 + try { + // 1. Submit maintenance data + onSubmit(data); + + // 2. Delete removed files + if (deletedFileIds.length > 0) { + await Promise.all( + deletedFileIds.map((fileId) => + api.delete(`/api/v1/app/files/${fileId}`) + ) + ); + } + + // 3. Update status of new files to 'active' + const newFileIds: string[] = []; + + // documents의 각 카테고리에서 id가 있는 파일들 수집 + Object.values(data.documents || {}).forEach((files) => { + if (Array.isArray(files)) { + files.forEach((file) => { + if (file.id) newFileIds.push(file.id); + }); + } + }); + + if (newFileIds.length > 0) { + await api.put("/api/v1/app/files/update-status", { + fileIds: newFileIds, + status: "active", + }); + } + } catch (error) { + console.error("Form submission failed:", error); + throw error; + } + }; + + // Cancel handler + const handleCancel = async () => { + // temporary 상태의 파일들만 삭제 + const temporaryFiles: string[] = []; + const documents = form.getValues("documents"); + + Object.values(documents || {}).forEach((files) => { + if (Array.isArray(files)) { + files.forEach((file) => { + if (file.id && file.status === "temporary") { + temporaryFiles.push(file.id); + } + }); + } + }); + + // temporary 파일 삭제 + if (temporaryFiles.length > 0) { + await Promise.all( + temporaryFiles.map((fileId) => + api.delete(`/api/v1/app/files/${fileId}`) + ) + ); + } + + onCancel(); + }; + + if (isLoadingFiles) { + return
Loading files...
; + } + + if (isLoadingPersonnel || isLoadingParts) { + return
Loading...
; + } + + return ( +
+ + {/* 기본 정보 */} +
+ ( + + 정비 유형 + + + + + + )} + /> + + ( + + 정비 상태 + + + + + + )} + /> + + ( + + 예정일 + + + + + + )} + /> + + ( + + 완료일 + + + + + + )} + /> + + ( + + 정비 내용 + +