auto commit
This commit is contained in:
parent
130c1dcf1d
commit
19f82aca2b
@ -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);
|
||||
|
||||
|
@ -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;
|
@ -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;
|
||||
|
@ -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;
|
105
fems-api/src/controllers/app/parts/parts.controller.js
Normal file
105
fems-api/src/controllers/app/parts/parts.controller.js
Normal file
@ -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;
|
||||
|
187
fems-api/src/controllers/app/personnel/personnel.controller.js
Normal file
187
fems-api/src/controllers/app/personnel/personnel.controller.js
Normal file
@ -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;
|
||||
|
@ -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" });
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
63
fems-api/src/models/EquipmentParts.js
Normal file
63
fems-api/src/models/EquipmentParts.js
Normal file
@ -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;
|
@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
51
fems-api/src/models/MaintenanceLogParts.js
Normal file
51
fems-api/src/models/MaintenanceLogParts.js
Normal file
@ -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;
|
41
fems-api/src/models/MaintenanceLogPersonnel.js
Normal file
41
fems-api/src/models/MaintenanceLogPersonnel.js
Normal file
@ -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;
|
132
fems-api/src/models/Parts.js
Normal file
132
fems-api/src/models/Parts.js
Normal file
@ -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;
|
118
fems-api/src/models/Personnel.js
Normal file
118
fems-api/src/models/Personnel.js
Normal file
@ -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;
|
@ -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;
|
||||
|
@ -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 },
|
||||
|
153
fems-api/src/services/equipmentParts.service.js
Normal file
153
fems-api/src/services/equipmentParts.service.js
Normal file
@ -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();
|
288
fems-api/src/services/maintenance.service.js
Normal file
288
fems-api/src/services/maintenance.service.js
Normal file
@ -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();
|
146
fems-api/src/services/parts.service.js
Normal file
146
fems-api/src/services/parts.service.js
Normal file
@ -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();
|
127
fems-api/src/services/personnel.service.js
Normal file
127
fems-api/src/services/personnel.service.js
Normal file
@ -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();
|
@ -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(
|
||||
|
111
fems-api/src/utils/initialSetup/maintenanceSetup.js
Normal file
111
fems-api/src/utils/initialSetup/maintenanceSetup.js
Normal file
@ -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,
|
||||
};
|
@ -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,
|
||||
};
|
||||
|
@ -264,7 +264,7 @@ const AccountsPage = () => {
|
||||
if (!open) setEditingUser(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingUser ? "유저 수정" : "새 유저"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
@ -387,7 +387,7 @@ const DepartmentsPage = () => {
|
||||
|
||||
{/* 부서 생성/수정 다이얼로그 */}
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingDepartment ? "부서 수정" : "새 부서"}
|
||||
|
@ -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<string[]>([]);
|
||||
|
||||
// 파일 변경 핸들러 수정
|
||||
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<typeof equipmentSchema>) => {
|
||||
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({
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{zone?.map((zone: any) => (
|
||||
{zone?.map((zone: { id: string; name: string }) => (
|
||||
<SelectItem key={zone.id} value={zone.id}>
|
||||
{zone.name}
|
||||
</SelectItem>
|
||||
@ -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}
|
||||
|
@ -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<DownloadState>({});
|
||||
|
||||
// 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,
|
||||
},
|
||||
}));
|
||||
|
@ -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<Equipment | null>(null);
|
||||
@ -42,36 +42,6 @@ const InventoryPage = () => {
|
||||
enabled: !!token,
|
||||
});
|
||||
|
||||
// Create equipment mutation
|
||||
// const createMutation = useMutation({
|
||||
// mutationFn: async (newEquipment: Partial<Equipment>) => {
|
||||
// const equipmentWithCompanyId = {
|
||||
// ...newEquipment,
|
||||
// companyId: user?.companyId,
|
||||
// };
|
||||
// const { data } = await api.post<Equipment>(
|
||||
// "/api/v1/app/equipment",
|
||||
// equipmentWithCompanyId
|
||||
// );
|
||||
// return data;
|
||||
// },
|
||||
// onSuccess: () => {
|
||||
// queryClient.invalidateQueries({ queryKey: ["equipment"] });
|
||||
// setIsOpen(false);
|
||||
// toast({
|
||||
// title: "설비 등록",
|
||||
// description: "새로운 설비가 등록되었습니다.",
|
||||
// });
|
||||
// },
|
||||
// onError: (error: AxiosError) => {
|
||||
// toast({
|
||||
// title: "설비 등록 실패",
|
||||
// description: (error.response?.data as { message: string }).message,
|
||||
// variant: "destructive",
|
||||
// });
|
||||
// },
|
||||
// });
|
||||
|
||||
// Update equipment mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async (equipmentData: Partial<Equipment>) => {
|
||||
@ -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);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingEquipment ? "설비 수정" : "새 설비"}
|
||||
|
@ -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<typeof maintenanceSchema>) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function MaintenanceForm({
|
||||
initialData,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: MaintenanceFormProps) {
|
||||
const form = useForm<z.infer<typeof maintenanceSchema>>({
|
||||
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<string[]>([]);
|
||||
|
||||
// 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<typeof maintenanceSchema>) => {
|
||||
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 <div>Loading files...</div>;
|
||||
}
|
||||
|
||||
if (isLoadingPersonnel || isLoadingParts) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>정비 유형</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="정비 유형을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="preventive">예방 정비</SelectItem>
|
||||
<SelectItem value="corrective">사후 정비</SelectItem>
|
||||
<SelectItem value="predictive">예지 정비</SelectItem>
|
||||
<SelectItem value="inspection">점검</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>정비 상태</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="정비 상태를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="scheduled">예정</SelectItem>
|
||||
<SelectItem value="in_progress">진행 중</SelectItem>
|
||||
<SelectItem value="completed">완료</SelectItem>
|
||||
<SelectItem value="cancelled">취소됨</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scheduledDate"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>예정일</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="date" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="completionDate"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>완료일</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="date" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>정비 내용</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea placeholder="정비 내용을 입력하세요" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="equipmentId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>설비 선택</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="설비를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{equipmentList?.map(
|
||||
(equipment: { id: string; name: string }) => (
|
||||
<SelectItem key={equipment.id} value={equipment.id}>
|
||||
{equipment.name}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="findings"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>발견 사항</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea placeholder="발견 사항을 입력하세요" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="actions"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>조치 사항</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea placeholder="조치 사항을 입력하세요" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cost"
|
||||
render={({ field: { onChange, value, ...rest } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>정비 비용</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
placeholder="정비 비용을 입력하세요"
|
||||
onChange={(e) => onChange(e.target.valueAsNumber || 0)}
|
||||
value={value || 0}
|
||||
{...rest}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 부품 정보 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-medium">부품 정보</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
appendPart({
|
||||
partId: "",
|
||||
quantity: 1,
|
||||
cost: 0,
|
||||
})
|
||||
}
|
||||
>
|
||||
부품 추가
|
||||
</Button>
|
||||
</div>
|
||||
{partsFields.map((field, index) => (
|
||||
<div key={field.id} className="flex gap-4 items-end">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`parts.${index}.partId`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>부품명</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="부품을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{partsList?.map(
|
||||
(part: {
|
||||
id: string;
|
||||
name: string;
|
||||
partNumber: string;
|
||||
}) => (
|
||||
<SelectItem key={part.id} value={part.id}>
|
||||
{`${part.name} (${part.partNumber})`}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`parts.${index}.quantity`}
|
||||
render={({ field: { onChange, value, ...rest } }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>수량</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="수량을 입력하세요"
|
||||
onChange={(e) =>
|
||||
onChange(e.target.valueAsNumber || 1)
|
||||
}
|
||||
value={value || 1}
|
||||
{...rest}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`parts.${index}.cost`}
|
||||
render={({ field: { onChange, value, ...rest } }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>비용</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
min={0}
|
||||
placeholder="비용을 입력하세요"
|
||||
onChange={(e) =>
|
||||
onChange(e.target.valueAsNumber || 0)
|
||||
}
|
||||
value={value || 0}
|
||||
{...rest}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => removePart(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 작업자 정보 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-medium">작업자 정보</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
appendPersonnel({
|
||||
personnelId: "",
|
||||
role: "",
|
||||
})
|
||||
}
|
||||
>
|
||||
작업자 추가
|
||||
</Button>
|
||||
</div>
|
||||
{personnelFields.map((field, index) => (
|
||||
<div key={field.id} className="flex gap-4 items-end">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`personnelInfo.${index}.personnelId`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>작업자</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="작업자를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{personnelList?.map(
|
||||
(person: {
|
||||
id: string;
|
||||
name: string;
|
||||
department: string;
|
||||
}) => (
|
||||
<SelectItem key={person.id} value={person.id}>
|
||||
{`${person.name} (${person.department})`}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`personnelInfo.${index}.role`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>역할</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="역할을 입력하세요" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => removePersonnel(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 파일 업로드 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">정비 결과 문서 관리</h3>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="documents.report"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>정비 결과</FormLabel>
|
||||
<FormControl>
|
||||
<FileUploader
|
||||
value={field.value || []}
|
||||
onChange={(files, removedFiles) => {
|
||||
field.onChange(files);
|
||||
handleFileChange(
|
||||
"report",
|
||||
Array.isArray(files) ? files : [files],
|
||||
removedFiles
|
||||
);
|
||||
}}
|
||||
multiple={true}
|
||||
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt"
|
||||
moduleType="maintenance"
|
||||
category="report"
|
||||
referenceId={initialData?.id}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="documents.checklist"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>체크 쉬트</FormLabel>
|
||||
<FormControl>
|
||||
<FileUploader
|
||||
value={field.value || []}
|
||||
onChange={(files, removedFiles) => {
|
||||
field.onChange(files);
|
||||
handleFileChange(
|
||||
"checklist",
|
||||
Array.isArray(files) ? files : [files],
|
||||
removedFiles
|
||||
);
|
||||
}}
|
||||
multiple={true}
|
||||
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt"
|
||||
moduleType="maintenance"
|
||||
category="checklist"
|
||||
referenceId={initialData?.id}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="documents.photo"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>사진</FormLabel>
|
||||
<FormControl>
|
||||
<FileUploader
|
||||
value={field.value || []}
|
||||
onChange={(files, removedFiles) => {
|
||||
field.onChange(files);
|
||||
handleFileChange(
|
||||
"photo",
|
||||
Array.isArray(files) ? files : [files],
|
||||
removedFiles
|
||||
);
|
||||
}}
|
||||
multiple={true}
|
||||
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt"
|
||||
moduleType="maintenance"
|
||||
category="photo"
|
||||
referenceId={initialData?.id}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit">{initialData ? "수정하기" : "등록하기"}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
163
fems-app/src/app/(equipment)/maintenance/[mode]/page.tsx
Normal file
163
fems-app/src/app/(equipment)/maintenance/[mode]/page.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
// src/app/(equipment)/maintenance/[mode]/page.tsx
|
||||
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useParams, useSearchParams, useRouter } from "next/navigation";
|
||||
import {
|
||||
MaintenanceLog,
|
||||
MaintenanceType,
|
||||
MaintenanceStatus,
|
||||
EquipmentStatus,
|
||||
} from "@/types/maintenance";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { MaintenanceForm } from "./components/MaintenanceForm";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { AxiosError } from "axios";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
|
||||
const MaintenanceFormPage = () => {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const mode = params.mode as "create" | "edit";
|
||||
const maintenanceId = searchParams.get("id"); // URL의 쿼리 파라미터에서 id 가져오기
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuthStore();
|
||||
|
||||
// 정비 로그 상세 정보 조회 (수정 모드일 경우)
|
||||
const { data: maintenanceLog, isLoading } = useQuery<MaintenanceLog>({
|
||||
queryKey: ["maintenanceLogs", mode === "edit" && maintenanceId],
|
||||
queryFn: async () => {
|
||||
if (!maintenanceId) throw new Error("Maintenance Log ID not found");
|
||||
const { data } = await api.get<MaintenanceLog>(
|
||||
`/api/v1/app/maintenance/${maintenanceId}`
|
||||
);
|
||||
return data;
|
||||
},
|
||||
enabled: mode === "edit" && !!maintenanceId, // maintenanceId가 있을 때만 쿼리 실행
|
||||
});
|
||||
|
||||
// 정비 로그 생성 mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (newMaintenance: Partial<MaintenanceLog>) => {
|
||||
const maintenanceWithCompanyId = {
|
||||
...newMaintenance,
|
||||
companyId: user?.companyId,
|
||||
};
|
||||
const { data } = await api.post<MaintenanceLog>(
|
||||
"/api/v1/app/maintenance",
|
||||
maintenanceWithCompanyId
|
||||
);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["maintenanceLogs"] });
|
||||
toast({
|
||||
title: "정비 로그 등록",
|
||||
description: "새로운 정비 로그가 등록되었습니다.",
|
||||
});
|
||||
router.push("/maintenance");
|
||||
},
|
||||
onError: (error: AxiosError) => {
|
||||
toast({
|
||||
title: "정비 로그 등록 실패",
|
||||
description: (error.response?.data as { message: string }).message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// 정비 로그 수정 mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async (maintenanceData: Partial<MaintenanceLog>) => {
|
||||
if (!maintenanceId) throw new Error("Maintenance Log ID not found");
|
||||
const { data } = await api.put<MaintenanceLog>(
|
||||
`/api/v1/app/maintenance/${maintenanceId}`,
|
||||
maintenanceData
|
||||
);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["maintenanceLogs"] });
|
||||
toast({
|
||||
title: "정비 로그 수정",
|
||||
description: "정비 로그 정보가 수정되었습니다.",
|
||||
});
|
||||
router.push("/maintenance");
|
||||
},
|
||||
onError: (error: AxiosError) => {
|
||||
toast({
|
||||
title: "정비 로그 수정 실패",
|
||||
description: (error.response?.data as { message: string }).message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (mode === "edit" && !maintenanceId) {
|
||||
router.push("/maintenance");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (mode === "edit" && isLoading) return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-6">
|
||||
<Card>
|
||||
{/* Header */}
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">
|
||||
{mode === "create" ? "정비 로그 등록" : "정비 로그 수정"}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{mode === "create"
|
||||
? "새로운 정비 로그를 등록합니다."
|
||||
: "기존 정비 로그 정보를 수정합니다."}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
뒤로 가기
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{/* Form */}
|
||||
<CardContent className="max-w-3xl mx-auto">
|
||||
<MaintenanceForm
|
||||
initialData={mode === "edit" ? maintenanceLog : undefined}
|
||||
onSubmit={(data) => {
|
||||
console.log("페이지 onSubmit 데이터:", data); // 추가
|
||||
if (mode === "edit") {
|
||||
updateMutation.mutate({
|
||||
...data,
|
||||
type: data.type as MaintenanceType,
|
||||
status: data.status as MaintenanceStatus,
|
||||
equipmentStatus: data.equipmentStatus as EquipmentStatus,
|
||||
});
|
||||
} else {
|
||||
createMutation.mutate({
|
||||
...data,
|
||||
type: data.type as MaintenanceType,
|
||||
status: data.status as MaintenanceStatus,
|
||||
equipmentStatus: data.equipmentStatus as EquipmentStatus,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onCancel={() => router.push("/maintenance")}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaintenanceFormPage;
|
@ -0,0 +1,192 @@
|
||||
// src/app/(equipment)/maintenance/detail/[id]/components/MaintenanceFiles.tsx
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2, AlertCircle } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import FilePreview from "@/components/ui/FilePreview";
|
||||
|
||||
interface File {
|
||||
id: string;
|
||||
originalName: string;
|
||||
createdAt: string;
|
||||
size: number;
|
||||
category: string;
|
||||
path: string;
|
||||
mimetype: string;
|
||||
}
|
||||
|
||||
interface DownloadState {
|
||||
[key: string]: {
|
||||
isDownloading: boolean;
|
||||
error: string | null;
|
||||
success: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
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<DownloadState>({});
|
||||
|
||||
// handleDownload 함수 수정
|
||||
const handleDownload = async (fileId: string, originalName: string) => {
|
||||
setDownloadStates((prev) => ({
|
||||
...prev,
|
||||
[fileId]: { isDownloading: true, error: null, success: false },
|
||||
}));
|
||||
|
||||
try {
|
||||
const response = await api.get(`/api/v1/app/files/download/${fileId}`, {
|
||||
responseType: "blob",
|
||||
});
|
||||
|
||||
// Blob URL 생성 및 다운로드
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.setAttribute("download", originalName);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
setDownloadStates((prev) => ({
|
||||
...prev,
|
||||
[fileId]: { isDownloading: false, error: null, success: true },
|
||||
}));
|
||||
|
||||
toast({
|
||||
title: "다운로드 완료",
|
||||
description: `${originalName} 파일이 다운로드되었습니다.`,
|
||||
duration: 3000,
|
||||
});
|
||||
} catch (error) {
|
||||
setDownloadStates((prev) => ({
|
||||
...prev,
|
||||
[fileId]: {
|
||||
isDownloading: false,
|
||||
error: (error as Error).message,
|
||||
success: false,
|
||||
},
|
||||
}));
|
||||
|
||||
toast({
|
||||
title: "다운로드 실패",
|
||||
description: `${originalName} 파일을 다운로드하는 중 오류가 발생했습니다.`,
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
return (
|
||||
<div className="text-muted-foreground text-sm p-4 bg-muted/30 rounded-lg">
|
||||
등록된 파일이 없습니다
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{files.map((file) => (
|
||||
<FilePreview key={file.id} file={file} onDownload={handleDownload} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MaintenanceFiles: React.FC<{ maintenanceId: string }> = ({
|
||||
maintenanceId,
|
||||
}) => {
|
||||
const {
|
||||
data: files,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["equipment-files", maintenanceId],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/api/v1/app/files/list", {
|
||||
params: {
|
||||
moduleType: "maintenance",
|
||||
referenceId: maintenanceId,
|
||||
},
|
||||
});
|
||||
return data;
|
||||
},
|
||||
enabled: !!maintenanceId,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<span className="ml-2">파일 목록을 불러오는 중...</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>오류 발생</AlertTitle>
|
||||
<AlertDescription>
|
||||
파일 목록을 불러오는 중 오류가 발생했습니다. 다시 시도해 주세요.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const categorizedFiles = {
|
||||
report: files?.filter((file: File) => file.category === "report") || [],
|
||||
checklist:
|
||||
files?.filter((file: File) => file.category === "checklist") || [],
|
||||
photo: files?.filter((file: File) => file.category === "photo") || [],
|
||||
};
|
||||
|
||||
const categoryLabels = {
|
||||
report: "보고서",
|
||||
checklist: "체크리스트",
|
||||
photo: "사진",
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6 space-y-6">
|
||||
<h2 className="text-xl font-semibold">문서 정보</h2>
|
||||
<div className="space-y-6">
|
||||
{Object.entries(categorizedFiles).map(
|
||||
([category, files], index, array) => (
|
||||
<React.Fragment key={category}>
|
||||
<div>
|
||||
<h3 className="text-base font-medium mb-3">
|
||||
{categoryLabels[category as keyof typeof categoryLabels]}
|
||||
</h3>
|
||||
<FileList files={files} category={category} />
|
||||
</div>
|
||||
{index < array.length - 1 && <Separator className="my-4" />}
|
||||
</React.Fragment>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaintenanceFiles;
|
251
fems-app/src/app/(equipment)/maintenance/detail/[id]/page.tsx
Normal file
251
fems-app/src/app/(equipment)/maintenance/detail/[id]/page.tsx
Normal file
@ -0,0 +1,251 @@
|
||||
// src/app/(equipment)/maintenance/detail/[id]/page.tsx
|
||||
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { MaintenanceLog } from "@/types/maintenance";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft, Edit } from "lucide-react";
|
||||
import { Card, CardHeader, CardContent } from "@/components/ui/card";
|
||||
import MaintenanceFiles from "./components/MaintenanceFiles";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
const MaintenanceDetailPage = () => {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const maintenanceId = params.id as string;
|
||||
|
||||
// 정비 로그 상세 정보 조회
|
||||
const { data: maintenanceLog, isLoading } = useQuery<MaintenanceLog>({
|
||||
queryKey: ["maintenanceLogs", maintenanceId],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<MaintenanceLog>(
|
||||
`/api/v1/app/maintenance/${maintenanceId}`
|
||||
);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (!maintenanceLog) return <div>정비 로그를 찾을 수 없습니다.</div>;
|
||||
|
||||
// Calculate total parts cost
|
||||
const totalPartsCost =
|
||||
maintenanceLog.Parts?.reduce((total, part) => {
|
||||
return total + (part.MaintenanceLogParts?.cost || 0);
|
||||
}, 0) || 0;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-6 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">정비 로그 상세</h1>
|
||||
<p className="text-muted-foreground">
|
||||
정비 로그의 상세 정보를 확인합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
뒤로 가기
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
router.push(`/maintenance/edit?id=${maintenanceLog.id}`)
|
||||
}
|
||||
>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
수정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">기본 정보</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">정비 유형</div>
|
||||
<div>
|
||||
{
|
||||
{
|
||||
preventive: "예방 정비",
|
||||
corrective: "사후 정비",
|
||||
predictive: "예지 정비",
|
||||
inspection: "점검",
|
||||
}[maintenanceLog.type]
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">정비 상태</div>
|
||||
<div>
|
||||
{
|
||||
{
|
||||
scheduled: "예정",
|
||||
in_progress: "진행 중",
|
||||
completed: "완료",
|
||||
cancelled: "취소됨",
|
||||
}[maintenanceLog.status]
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">예정일</div>
|
||||
<div>
|
||||
{new Date(maintenanceLog.scheduledDate).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">완료일</div>
|
||||
<div>
|
||||
{maintenanceLog.completionDate
|
||||
? new Date(
|
||||
maintenanceLog.completionDate
|
||||
).toLocaleDateString()
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">설비명</div>
|
||||
<div>{maintenanceLog.Equipment?.name || "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">설비 상태</div>
|
||||
<div>
|
||||
{
|
||||
{
|
||||
operational: "운영 중",
|
||||
degraded: "성능 저하",
|
||||
failed: "고장",
|
||||
}[maintenanceLog.equipmentStatus || "operational"]
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">가동 상태</div>
|
||||
<div>{maintenanceLog.isActive ? "활성" : "비활성"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">설명</div>
|
||||
<div>{maintenanceLog.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 사양 정보 */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">사양 정보</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{Object.entries(maintenanceLog.specifications || {}).map(
|
||||
([key, value]) => (
|
||||
<div key={key}>
|
||||
<div className="text-sm text-muted-foreground">{key}</div>
|
||||
<div>{value}</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 부품 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-xl font-semibold">부품 정보</h2>
|
||||
<p className="text-muted-foreground">
|
||||
총 부품 비용: {totalPartsCost.toLocaleString()}원
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{maintenanceLog.Parts && maintenanceLog.Parts.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>부품명</TableHead>
|
||||
<TableHead>부품번호</TableHead>
|
||||
<TableHead className="text-right">수량</TableHead>
|
||||
<TableHead className="text-right">비용</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{maintenanceLog.Parts.map((part) => (
|
||||
<TableRow key={part.id}>
|
||||
<TableCell>{part.name}</TableCell>
|
||||
<TableCell>{part.partNumber}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{part.MaintenanceLogParts?.quantity || 0}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{(part.MaintenanceLogParts?.cost || 0).toLocaleString()}원
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground py-4">
|
||||
등록된 부품이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 작업자 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-xl font-semibold">작업자 정보</h2>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{maintenanceLog.Personnel && maintenanceLog.Personnel.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>이름</TableHead>
|
||||
<TableHead>부서</TableHead>
|
||||
<TableHead>역할</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{maintenanceLog.Personnel.map((person) => (
|
||||
<TableRow key={person.id}>
|
||||
<TableCell>{person.name}</TableCell>
|
||||
<TableCell>{person.department}</TableCell>
|
||||
<TableCell>
|
||||
{person.MaintenanceLogPersonnel?.role}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground py-4">
|
||||
등록된 작업자가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 문서 정보 */}
|
||||
<MaintenanceFiles maintenanceId={maintenanceLog.id} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaintenanceDetailPage;
|
@ -3,23 +3,223 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Construction } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { DataTable } from "@/components/ui/data-table";
|
||||
import { api } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Plus, Edit, Trash2, FileText } from "lucide-react";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { AxiosError } from "axios";
|
||||
import { MaintenanceLog } from "@/types/maintenance";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const MaintenancePage = () => {
|
||||
const { token } = useAuthStore();
|
||||
// const [isOpen, setIsOpen] = React.useState(false);
|
||||
// const [editingMaintenance, setEditingMaintenance] =
|
||||
// React.useState<MaintenanceLog | null>(null);
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
|
||||
// Fetch maintenance logs
|
||||
const { data: maintenanceLogs, isLoading } = useQuery<MaintenanceLog[]>({
|
||||
queryKey: ["maintenanceLogs"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<MaintenanceLog[]>(
|
||||
"/api/v1/app/maintenance"
|
||||
);
|
||||
return data;
|
||||
},
|
||||
enabled: !!token,
|
||||
});
|
||||
|
||||
// Delete maintenance log mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await api.delete(`/api/v1/app/maintenance/${id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["maintenanceLogs"] });
|
||||
toast({
|
||||
title: "정비 로그 삭제",
|
||||
description: "정비 로그가 삭제되었습니다.",
|
||||
});
|
||||
},
|
||||
onError: (error: AxiosError) => {
|
||||
toast({
|
||||
title: "정비 로그 삭제 실패",
|
||||
description: (error.response?.data as { message: string }).message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Table columns
|
||||
const columns: ColumnDef<MaintenanceLog>[] = [
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: "정비 유형",
|
||||
cell: ({ row }) => {
|
||||
const typeMap: Record<string, string> = {
|
||||
preventive: "예방 정비",
|
||||
corrective: "사후 정비",
|
||||
predictive: "예지 정비",
|
||||
inspection: "점검",
|
||||
};
|
||||
return typeMap[row.original.type] || row.original.type;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "정비 상태",
|
||||
cell: ({ row }) => {
|
||||
const statusMap: Record<string, string> = {
|
||||
scheduled: "예정",
|
||||
in_progress: "진행 중",
|
||||
completed: "완료",
|
||||
cancelled: "취소됨",
|
||||
};
|
||||
return statusMap[row.original.status] || row.original.status;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "scheduledDate",
|
||||
header: "예정일",
|
||||
cell: ({ row }) =>
|
||||
new Date(row.original.scheduledDate).toLocaleDateString(),
|
||||
},
|
||||
{
|
||||
accessorKey: "completionDate",
|
||||
header: "완료일",
|
||||
cell: ({ row }) =>
|
||||
row.original.completionDate
|
||||
? new Date(row.original.completionDate).toLocaleDateString()
|
||||
: "-",
|
||||
},
|
||||
{
|
||||
accessorKey: "equipment.name",
|
||||
header: "설비명",
|
||||
cell: ({ row }) => row.original.Equipment?.name || "-",
|
||||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
header: "정비 내용",
|
||||
},
|
||||
// {
|
||||
// accessorKey: "isActive",
|
||||
// header: "활성 상태",
|
||||
// cell: ({ row }) => (
|
||||
// <Switch
|
||||
// checked={row.original.isActive}
|
||||
// onCheckedChange={(value) => {
|
||||
// updateMutation.mutate({ id: row.original.id, isActive: value });
|
||||
// }}
|
||||
// />
|
||||
// ),
|
||||
// },
|
||||
{
|
||||
id: "actions",
|
||||
header: "액션",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
router.push(`/maintenance/detail/${row.original.id}`);
|
||||
}}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
router.push(`/maintenance/edit?id=${row.original.id}`);
|
||||
}}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm("정말 삭제하시겠습니까?")) {
|
||||
deleteMutation.mutate(row.original.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
|
||||
export default function SystemPage() {
|
||||
return (
|
||||
<div className="container mx-auto py-8">
|
||||
<Card className="border-2 border-dashed">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||
<Construction className="h-16 w-16 text-muted-foreground mb-4" />
|
||||
<h2 className="text-2xl font-semibold text-muted-foreground mb-2">
|
||||
준비 중입니다
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
시스템 설정 페이지는 현재 개발 중입니다.
|
||||
<div className="container mx-auto py-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl font-bold">정비 관리</h1>
|
||||
<p className="text-muted-foreground">
|
||||
정비 로그를 등록하고 관리합니다.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => router.push("/maintenance/create")}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
정비 로그 등록
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Maintenance Logs Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>정비 로그 목록</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{maintenanceLogs && maintenanceLogs.length > 0 ? (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={maintenanceLogs}
|
||||
filters={[
|
||||
{
|
||||
id: "type",
|
||||
title: "정비 유형",
|
||||
options: [
|
||||
{ value: "preventive", label: "예방 정비" },
|
||||
{ value: "corrective", label: "사후 정비" },
|
||||
{ value: "predictive", label: "예지 정비" },
|
||||
{ value: "inspection", label: "점검" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
title: "정비 상태",
|
||||
options: [
|
||||
{ value: "scheduled", label: "예정" },
|
||||
{ value: "in_progress", label: "진행 중" },
|
||||
{ value: "completed", label: "완료" },
|
||||
{ value: "cancelled", label: "취소됨" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
등록된 정비 로그가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default MaintenancePage;
|
||||
|
@ -0,0 +1,204 @@
|
||||
// src/app/(equipment)/parts/components/PartsDetailDialog.tsx
|
||||
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Package, DollarSign } from "lucide-react";
|
||||
import { Parts } from "@/types/maintenance";
|
||||
|
||||
interface PartsDetailDialogProps {
|
||||
part: Parts | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PartsDetailDialog({
|
||||
part,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: PartsDetailDialogProps) {
|
||||
if (!part) return null;
|
||||
|
||||
const categoryMap: Record<string, string> = {
|
||||
Filter: "필터",
|
||||
Motor: "모터",
|
||||
Pump: "펌프",
|
||||
Valve: "밸브",
|
||||
Other: "기타",
|
||||
};
|
||||
|
||||
const statusMap: Record<
|
||||
string,
|
||||
{ label: string; variant: "default" | "secondary" | "destructive" }
|
||||
> = {
|
||||
active: { label: "활성화", variant: "default" },
|
||||
discontinued: { label: "단종", variant: "destructive" },
|
||||
out_of_stock: { label: "재고 없음", variant: "secondary" },
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>부품 상세 정보</DialogTitle>
|
||||
<DialogDescription>
|
||||
선택한 부품의 상세 정보를 확인합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="text-sm text-muted-foreground">부품 번호</div>
|
||||
<div>{part.partNumber}</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">부품명</div>
|
||||
<div>{part.name}</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">설명</div>
|
||||
<div>{part.description || "-"}</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">카테고리</div>
|
||||
<div>{categoryMap[part.category] || part.category}</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">제조사</div>
|
||||
<div>{part.manufacturer}</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">상태</div>
|
||||
<div>
|
||||
<Badge variant={statusMap[part.status].variant}>
|
||||
{statusMap[part.status].label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 사양 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>사양 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{part.specifications ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{Object.entries(part.specifications).map(
|
||||
([key, value], index) => (
|
||||
<React.Fragment key={index}>
|
||||
<div className="text-sm text-muted-foreground capitalize">
|
||||
{key}
|
||||
</div>
|
||||
<div>{value}</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
사양 정보가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 공급업체 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>공급업체 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{part.supplier ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
공급업체 이름
|
||||
</div>
|
||||
<div>{part.supplier.name}</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">연락처</div>
|
||||
<div>{part.supplier.contact}</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">이메일</div>
|
||||
<div>{part.supplier.email}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
공급업체 정보가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 호환 설비 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>호환 설비</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{part.compatibleEquipment &&
|
||||
part.compatibleEquipment.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{part.compatibleEquipment.map((equip, index) => (
|
||||
<Badge key={index} variant="outline">
|
||||
<Package className="w-3 h-3 mr-1" />
|
||||
{equip}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
호환 설비가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 재고 및 가격 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>재고 및 가격 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="text-sm text-muted-foreground">단가</div>
|
||||
<div>
|
||||
<Badge variant="outline">
|
||||
<DollarSign className="w-3 h-3 mr-1" />
|
||||
{part.unitPrice.toLocaleString()} 원
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">재고 수량</div>
|
||||
<div>{part.stockQuantity} 개</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
최소 재고 수준
|
||||
</div>
|
||||
<div>{part.minStockLevel} 개</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">보관 위치</div>
|
||||
<div>{part.location}</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">리드 타임</div>
|
||||
<div>{part.leadTime} 일</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
521
fems-app/src/app/(equipment)/parts/components/PartsForm.tsx
Normal file
521
fems-app/src/app/(equipment)/parts/components/PartsForm.tsx
Normal file
@ -0,0 +1,521 @@
|
||||
// src/app/(equipment)/parts/components/PartsForm.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 { Trash2, Plus } from "lucide-react";
|
||||
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 { Parts } from "@/types/maintenance";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
const partsSchema = z.object({
|
||||
partNumber: z.string().min(1, "부품 번호를 입력하세요"),
|
||||
name: z.string().min(1, "부품명을 입력하세요"),
|
||||
description: z.string().optional(),
|
||||
category: z.string().min(1, "카테고리를 선택하세요"),
|
||||
manufacturer: z.string().min(1, "제조사를 입력하세요"),
|
||||
specifications: z.record(z.string(), z.string()).optional(),
|
||||
unitPrice: z.preprocess((val) => {
|
||||
if (typeof val === "string") return Number(val) || 0;
|
||||
return val;
|
||||
}, z.number().min(0, "단가는 0 이상이어야 합니다.")),
|
||||
stockQuantity: z.preprocess((val) => {
|
||||
if (typeof val === "string") return Number(val) || 0;
|
||||
return val;
|
||||
}, z.number().min(0, "재고 수량은 0 이상이어야 합니다.")),
|
||||
minStockLevel: z.preprocess((val) => {
|
||||
if (typeof val === "string") return Number(val) || 1;
|
||||
return val;
|
||||
}, z.number().min(1, "최소 재고 수준은 1 이상이어야 합니다.")),
|
||||
location: z.string().min(1, "보관 위치를 입력하세요"),
|
||||
leadTime: z.preprocess((val) => {
|
||||
if (typeof val === "string") return Number(val) || 1;
|
||||
return val;
|
||||
}, z.number().min(0, "소요일은 1 이상이어야 합니다.")),
|
||||
supplier: z
|
||||
.object({
|
||||
name: z.string().min(1, "공급업체 이름을 입력하세요"),
|
||||
contact: z.string().min(1, "공급업체 연락처를 입력하세요"),
|
||||
email: z.string().email("유효한 이메일 형식이 아닙니다"),
|
||||
})
|
||||
.optional(),
|
||||
compatibleEquipment: z.array(z.string()).optional(),
|
||||
status: z.enum(["active", "discontinued", "out_of_stock"]).optional(),
|
||||
});
|
||||
|
||||
interface PartsFormProps {
|
||||
initialData?: Parts;
|
||||
onSubmit: (data: z.infer<typeof partsSchema>) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const defaultSpecifications = {
|
||||
size: "",
|
||||
material: "",
|
||||
efficiency: "",
|
||||
flowRate: "",
|
||||
};
|
||||
|
||||
export function PartsForm({ initialData, onSubmit, onCancel }: PartsFormProps) {
|
||||
const form = useForm<z.infer<typeof partsSchema>>({
|
||||
resolver: zodResolver(partsSchema),
|
||||
defaultValues: {
|
||||
partNumber: initialData?.partNumber || "",
|
||||
name: initialData?.name || "",
|
||||
description: initialData?.description || "",
|
||||
category: initialData?.category || "",
|
||||
manufacturer: initialData?.manufacturer || "",
|
||||
specifications:
|
||||
typeof initialData?.specifications === "object"
|
||||
? initialData.specifications
|
||||
: defaultSpecifications,
|
||||
unitPrice: initialData?.unitPrice || 0,
|
||||
stockQuantity: initialData?.stockQuantity || 0,
|
||||
minStockLevel: initialData?.minStockLevel || 1,
|
||||
location: initialData?.location || "",
|
||||
leadTime: initialData?.leadTime || 1,
|
||||
supplier: initialData?.supplier || {
|
||||
name: "",
|
||||
contact: "",
|
||||
email: "",
|
||||
},
|
||||
compatibleEquipment: initialData?.compatibleEquipment || [],
|
||||
status: initialData?.status || "active",
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch compatible equipment list
|
||||
const { data: equipmentList, isLoading: isLoadingEquipment } = useQuery({
|
||||
queryKey: ["equipment"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/api/v1/app/equipment");
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoadingEquipment) {
|
||||
return <div>설비 목록을 불러오는 중...</div>;
|
||||
}
|
||||
|
||||
// Handle adding a new specification
|
||||
const handleAddSpecification = () => {
|
||||
const specName = prompt("추가할 사양의 이름을 입력하세요");
|
||||
if (specName && !form.getValues(`specifications.${specName}`)) {
|
||||
form.setValue(`specifications.${specName}`, "");
|
||||
}
|
||||
};
|
||||
|
||||
// Handle removing a specification
|
||||
const handleRemoveSpecification = (specName: string) => {
|
||||
form.unregister(`specifications.${specName}`);
|
||||
const updatedSpecs = { ...form.getValues("specifications") };
|
||||
delete updatedSpecs[specName];
|
||||
form.setValue("specifications", updatedSpecs);
|
||||
};
|
||||
|
||||
// Handle removing compatible equipment
|
||||
const handleRemoveCompatibleEquipment = (equipName: string) => {
|
||||
form.setValue(
|
||||
"compatibleEquipment",
|
||||
(form.getValues("compatibleEquipment") || []).filter(
|
||||
(eq) => eq !== equipName
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">기본 정보</h3>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="partNumber"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>부품 번호</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="예: HVAC-F001" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>부품명</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="부품명을 입력하세요" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>설명</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="부품에 대한 설명을 입력하세요"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="category"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>카테고리</FormLabel>
|
||||
<FormControl>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="카테고리를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Filter">필터</SelectItem>
|
||||
<SelectItem value="Motor">모터</SelectItem>
|
||||
<SelectItem value="Pump">펌프</SelectItem>
|
||||
<SelectItem value="Valve">밸브</SelectItem>
|
||||
<SelectItem value="Other">기타</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="manufacturer"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>제조사</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="제조사를 입력하세요" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="unitPrice"
|
||||
render={({ field: { onChange, ...field } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>단가</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
placeholder="단가를 입력하세요"
|
||||
onChange={(e) => onChange(e.target.valueAsNumber || 0)}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="stockQuantity"
|
||||
render={({ field: { onChange, ...field } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>재고 수량</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="재고 수량을 입력하세요"
|
||||
onChange={(e) => onChange(e.target.valueAsNumber || 0)}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="minStockLevel"
|
||||
render={({ field: { onChange, ...field } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>최소 재고 수준</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="최소 재고 수준을 입력하세요"
|
||||
onChange={(e) => onChange(e.target.valueAsNumber || 1)}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="location"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>보관 위치</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="보관 위치를 입력하세요" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="leadTime"
|
||||
render={({ field: { onChange, ...field } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>소요일</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="소요일을 입력하세요"
|
||||
onChange={(e) => onChange(e.target.valueAsNumber || 0)}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Status 필드 추가 */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>상태</FormLabel>
|
||||
<FormControl>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="상태를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">활성화</SelectItem>
|
||||
<SelectItem value="discontinued">단종</SelectItem>
|
||||
<SelectItem value="out_of_stock">재고 없음</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 사양 정보 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">사양 정보</h3>
|
||||
{Object.entries(form.watch("specifications") || {}).map(
|
||||
([key, value], index) => (
|
||||
<div key={index} className="flex items-center gap-4">
|
||||
<Input
|
||||
value={key}
|
||||
disabled
|
||||
className="w-1/3"
|
||||
placeholder="사양명"
|
||||
/>
|
||||
<Input
|
||||
value={value as string}
|
||||
onChange={(e) => {
|
||||
form.setValue(`specifications.${key}`, e.target.value);
|
||||
}}
|
||||
className="w-2/3"
|
||||
placeholder="값을 입력하세요"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveSpecification(key)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleAddSpecification}
|
||||
className="mt-2"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
사양 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 공급업체 정보 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">공급업체 정보</h3>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="supplier.name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>공급업체 이름</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="공급업체 이름을 입력하세요" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="supplier.contact"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>공급업체 연락처</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="예: 02-123-4567"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
let value = e.target.value;
|
||||
value = value.replace(/[^0-9-]/g, "");
|
||||
if (value.length === 11) {
|
||||
value = value.replace(
|
||||
/(\d{2,3})(\d{3,4})(\d{4})/,
|
||||
"$1-$2-$3"
|
||||
);
|
||||
}
|
||||
field.onChange(value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="supplier.email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>공급업체 이메일</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="예: supplier@company.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 호환 설비 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">호환 설비</h3>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="compatibleEquipment"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>호환 가능한 설비</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (!(field.value ?? []).includes(value)) {
|
||||
field.onChange([...(field.value || []), value]);
|
||||
}
|
||||
}}
|
||||
value=""
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="호환 설비를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{equipmentList?.map(
|
||||
(equipment: { id: string; name: string }) => (
|
||||
<SelectItem key={equipment.id} value={equipment.name}>
|
||||
{equipment.name}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{field.value?.map((equip, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveCompatibleEquipment(equip)}
|
||||
>
|
||||
{equip} ×
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit">{initialData ? "수정하기" : "등록하기"}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
305
fems-app/src/app/(equipment)/parts/page.tsx
Normal file
305
fems-app/src/app/(equipment)/parts/page.tsx
Normal file
@ -0,0 +1,305 @@
|
||||
// src/app/(equipment)/parts/page.tsx
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { DataTable } from "@/components/ui/data-table";
|
||||
import { api } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Search,
|
||||
Package,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { AxiosError } from "axios";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Parts } from "@/types/maintenance";
|
||||
import { PartsForm } from "./components/PartsForm";
|
||||
import { PartsDetailDialog } from "./components/PartsDetailDialog";
|
||||
|
||||
const PartsPage = () => {
|
||||
const { token, user } = useAuthStore();
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [editingPart, setEditingPart] = React.useState<Parts | null>(null);
|
||||
const [selectedPart, setSelectedPart] = React.useState<Parts | null>(null);
|
||||
const [isDetailOpen, setIsDetailOpen] = React.useState(false);
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch parts
|
||||
const { data: parts, isLoading } = useQuery<Parts[]>({
|
||||
queryKey: ["parts"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/api/v1/app/parts");
|
||||
return data;
|
||||
},
|
||||
enabled: !!token,
|
||||
});
|
||||
|
||||
// Create parts mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (newPart: Partial<Parts>) => {
|
||||
const partWithCompanyId = {
|
||||
...newPart,
|
||||
companyId: user?.companyId,
|
||||
};
|
||||
const { data } = await api.post("/api/v1/app/parts", partWithCompanyId);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["parts"] });
|
||||
setIsOpen(false);
|
||||
toast({
|
||||
title: "부품 등록",
|
||||
description: "새로운 부품이 등록되었습니다.",
|
||||
});
|
||||
},
|
||||
onError: (error: AxiosError) => {
|
||||
toast({
|
||||
title: "부품 등록 실패",
|
||||
description: (error.response?.data as { message: string }).message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Update parts mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async (data: Partial<Parts>) => {
|
||||
const { data: updatedData } = await api.put(
|
||||
`/api/v1/app/parts/${data.id}`,
|
||||
data
|
||||
);
|
||||
return updatedData;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["parts"] });
|
||||
setIsOpen(false);
|
||||
setEditingPart(null);
|
||||
toast({
|
||||
title: "부품 수정",
|
||||
description: "부품 정보가 수정되었습니다.",
|
||||
});
|
||||
},
|
||||
onError: (error: AxiosError) => {
|
||||
toast({
|
||||
title: "부품 수정 실패",
|
||||
description: (error.response?.data as { message: string }).message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Delete parts mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await api.delete(`/api/v1/app/parts/${id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["parts"] });
|
||||
toast({
|
||||
title: "부품 삭제",
|
||||
description: "부품이 삭제되었습니다.",
|
||||
});
|
||||
},
|
||||
onError: (error: AxiosError) => {
|
||||
toast({
|
||||
title: "부품 삭제 실패",
|
||||
description: (error.response?.data as { message: string }).message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const columns: ColumnDef<Parts>[] = [
|
||||
{
|
||||
accessorKey: "partNumber",
|
||||
header: "부품번호",
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "부품명",
|
||||
},
|
||||
{
|
||||
accessorKey: "category",
|
||||
header: "카테고리",
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="outline">
|
||||
<Package className="w-3 h-3 mr-1" />
|
||||
{row.original.category}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "manufacturer",
|
||||
header: "제조사",
|
||||
},
|
||||
{
|
||||
accessorKey: "stockQuantity",
|
||||
header: "재고수량",
|
||||
cell: ({ row }) => {
|
||||
const isLowStock =
|
||||
row.original.stockQuantity <= row.original.minStockLevel;
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>{row.original.stockQuantity}</span>
|
||||
{isLowStock && (
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "상태",
|
||||
cell: ({ row }) => {
|
||||
const statusMap: Record<
|
||||
string,
|
||||
{ label: string; variant: "default" | "secondary" | "destructive" }
|
||||
> = {
|
||||
active: { label: "정상", variant: "default" },
|
||||
discontinued: { label: "단종", variant: "destructive" },
|
||||
out_of_stock: { label: "재고없음", variant: "secondary" },
|
||||
};
|
||||
const status = statusMap[row.original.status];
|
||||
return <Badge variant={status.variant}>{status.label}</Badge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "액션",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedPart(row.original);
|
||||
setIsDetailOpen(true);
|
||||
}}
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingPart(row.original);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm("정말 삭제하시겠습니까?")) {
|
||||
deleteMutation.mutate(row.original.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl font-bold">부품 관리</h1>
|
||||
<p className="text-muted-foreground">
|
||||
정비 부품을 관리하고 재고를 확인합니다.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setIsOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
부품 등록
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Parts Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>부품 목록</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{parts && parts.length > 0 ? (
|
||||
<DataTable columns={columns} data={parts} />
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
등록된 부품이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Parts Create/Edit Dialog */}
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
if (!open) setEditingPart(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingPart ? "부품 수정" : "새 부품"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingPart
|
||||
? "기존 부품 정보를 수정합니다."
|
||||
: "새로운 부품을 등록합니다."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<PartsForm
|
||||
initialData={editingPart || undefined}
|
||||
onSubmit={(data) => {
|
||||
if (editingPart) {
|
||||
updateMutation.mutate({ id: editingPart.id, ...data });
|
||||
} else {
|
||||
createMutation.mutate(data);
|
||||
}
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsOpen(false);
|
||||
setEditingPart(null);
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Parts Detail Dialog */}
|
||||
<PartsDetailDialog
|
||||
part={selectedPart}
|
||||
isOpen={isDetailOpen}
|
||||
onClose={() => {
|
||||
setIsDetailOpen(false);
|
||||
setSelectedPart(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PartsPage;
|
@ -0,0 +1,203 @@
|
||||
// src/app/(equipment)/personnel/components/PersonnelDetailDialog.tsx
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Wrench, Award, Phone, Mail } from "lucide-react";
|
||||
import { Personnel } from "@/types/maintenance";
|
||||
|
||||
interface PersonnelDetailDialogProps {
|
||||
personnel: Personnel | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PersonnelDetailDialog({
|
||||
personnel,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: PersonnelDetailDialogProps) {
|
||||
if (!personnel) return null;
|
||||
|
||||
const statusMap: Record<
|
||||
string,
|
||||
{ label: string; variant: "default" | "secondary" | "destructive" }
|
||||
> = {
|
||||
active: { label: "재직", variant: "default" },
|
||||
inactive: { label: "휴직", variant: "secondary" },
|
||||
leave: { label: "퇴사", variant: "destructive" },
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>작업자 상세 정보</DialogTitle>
|
||||
<DialogDescription>
|
||||
작업자의 상세 정보를 조회합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="text-sm text-muted-foreground">사원번호</div>
|
||||
<div>{personnel.employeeNumber}</div>
|
||||
<div className="text-sm text-muted-foreground">이름</div>
|
||||
<div>{personnel.name}</div>
|
||||
<div className="text-sm text-muted-foreground">직위</div>
|
||||
<div>{personnel.position}</div>
|
||||
<div className="text-sm text-muted-foreground">소속</div>
|
||||
<div>{personnel.department}</div>
|
||||
<div className="text-sm text-muted-foreground">상태</div>
|
||||
<div>
|
||||
<Badge variant={statusMap[personnel.status].variant}>
|
||||
{statusMap[personnel.status].label}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">가용상태</div>
|
||||
<div>
|
||||
<Badge
|
||||
variant={personnel.isAvailable ? "default" : "secondary"}
|
||||
>
|
||||
{personnel.isAvailable ? "가능" : "불가"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 전문성 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>전문 분야</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-2">
|
||||
전문 분야
|
||||
</div>
|
||||
{personnel.specialization && (
|
||||
<Badge className="mr-2" variant="outline">
|
||||
{personnel.specialization}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-2">
|
||||
보유 기술
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{personnel.skills?.map((skill, index) => (
|
||||
<Badge key={index} variant="outline">
|
||||
<Wrench className="w-3 h-3 mr-1" />
|
||||
{skill}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-2">자격증</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{personnel.certifications?.map((cert, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
<Award className="w-3 h-3 mr-1" />
|
||||
{cert.name}
|
||||
{cert.expiryDate && (
|
||||
<span className="ml-1 text-xs">
|
||||
(~{new Date(cert.expiryDate).getFullYear()})
|
||||
</span>
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 연락처 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>연락처 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Phone className="w-4 h-4" />
|
||||
<span>{personnel.contactNumber}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="w-4 h-4" />
|
||||
<span>{personnel.email}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 근무 일정 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>근무 일정</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{personnel.availabilitySchedule && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="text-sm text-muted-foreground">평일</div>
|
||||
<div>{personnel.availabilitySchedule.weekday}</div>
|
||||
<div className="text-sm text-muted-foreground">주말</div>
|
||||
<div>{personnel.availabilitySchedule.weekend}</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 정비 이력 */}
|
||||
{personnel.MaintenanceLogs &&
|
||||
personnel.MaintenanceLogs.length > 0 && (
|
||||
<Card className="col-span-full">
|
||||
<CardHeader>
|
||||
<CardTitle>최근 정비 이력</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{personnel.MaintenanceLogs.map(
|
||||
(log: {
|
||||
id: string;
|
||||
type: string;
|
||||
scheduledDate: string;
|
||||
status: string;
|
||||
}) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className="flex items-center justify-between p-2 bg-muted rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{log.type}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{new Date(log.scheduledDate).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline">{log.status}</Badge>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
@ -0,0 +1,509 @@
|
||||
// src/app/(equipment)/personnel/components/PersonnelForm.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 { Badge } from "@/components/ui/badge";
|
||||
import { Award, Plus, X } from "lucide-react";
|
||||
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 { Personnel } from "@/types/maintenance";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
// import { Textarea } from "@/components/ui/textarea";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
// FormSchema for validation
|
||||
const formSchema = z.object({
|
||||
employeeNumber: z.string().min(1, "사원번호는 필수입니다"),
|
||||
name: z.string().min(1, "이름은 필수입니다"),
|
||||
position: z.string().min(1, "직위는 필수입니다"),
|
||||
department: z.string().min(1, "소속 부서는 필수입니다"),
|
||||
specialization: z.string().optional(),
|
||||
certifications: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
issueDate: z.string(),
|
||||
expiryDate: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
contactNumber: z
|
||||
.string()
|
||||
.min(1, "연락처는 필수입니다")
|
||||
.regex(/^\d{2,3}-\d{3,4}-\d{4}$/, "올바른 전화번호 형식이 아닙니다"),
|
||||
email: z.string().email("유효한 이메일 형식이 아닙니다"),
|
||||
status: z.enum(["active", "inactive", "leave"]),
|
||||
skills: z.array(z.string()).optional(),
|
||||
availabilitySchedule: z
|
||||
.object({
|
||||
weekday: z.string(),
|
||||
weekend: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
isAvailable: z.boolean().optional(),
|
||||
});
|
||||
|
||||
interface PersonnelFormProps {
|
||||
initialData?: Personnel;
|
||||
onSubmit: (data: any) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function PersonnelForm({
|
||||
initialData,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: PersonnelFormProps) {
|
||||
// Initialize form with default values or initial data
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
employeeNumber: initialData?.employeeNumber || "",
|
||||
name: initialData?.name || "",
|
||||
position: initialData?.position || "",
|
||||
department: initialData?.department || "",
|
||||
specialization: initialData?.specialization || "",
|
||||
certifications: initialData?.certifications || [],
|
||||
contactNumber: initialData?.contactNumber || "",
|
||||
email: initialData?.email || "",
|
||||
status:
|
||||
(initialData?.status as "active" | "inactive" | "leave") || "active",
|
||||
skills: initialData?.skills || [],
|
||||
availabilitySchedule: initialData?.availabilitySchedule || {
|
||||
weekday: "09:00-18:00",
|
||||
weekend: "off",
|
||||
},
|
||||
isAvailable: initialData?.isAvailable ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch department for dropdown
|
||||
const { data: department } = useQuery({
|
||||
queryKey: ["department"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/api/v1/app/department");
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
// PersonnelForm 컴포넌트 내부 상단에 추가
|
||||
const [newCert, setNewCert] = React.useState({
|
||||
name: "",
|
||||
issueDate: "",
|
||||
expiryDate: "",
|
||||
});
|
||||
|
||||
// useFieldArray 설정
|
||||
const {
|
||||
fields: certFields,
|
||||
append: appendCert,
|
||||
remove: removeCert,
|
||||
} = useFieldArray({
|
||||
control: form.control,
|
||||
name: "certifications",
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">기본 정보</h3>
|
||||
<Separator />
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="employeeNumber"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>사원번호</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="예: EMP001" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>이름</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="이름을 입력하세요" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="position"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>직위</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="직위를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="팀장">팀장</SelectItem>
|
||||
<SelectItem value="과장">과장</SelectItem>
|
||||
<SelectItem value="대리">대리</SelectItem>
|
||||
<SelectItem value="사원">사원</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="department"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>소속 부서</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="부서를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{department?.map((dept: any) => (
|
||||
<SelectItem key={dept.id} value={dept.name}>
|
||||
{dept.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 전문성 정보 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">전문성 정보</h3>
|
||||
<Separator />
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="specialization"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>전문 분야</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="전문 분야를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="기계설비">기계설비</SelectItem>
|
||||
<SelectItem value="전기설비">전기설비</SelectItem>
|
||||
<SelectItem value="공조설비">공조설비</SelectItem>
|
||||
<SelectItem value="자동제어">자동제어</SelectItem>
|
||||
<SelectItem value="소방설비">소방설비</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="skills"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>보유 기술</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
field.onChange([...(field.value || []), value])
|
||||
}
|
||||
value=""
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="보유 기술을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="공조기">공조기</SelectItem>
|
||||
<SelectItem value="보일러">보일러</SelectItem>
|
||||
<SelectItem value="냉동기">냉동기</SelectItem>
|
||||
<SelectItem value="펌프">펌프</SelectItem>
|
||||
<SelectItem value="전기">전기</SelectItem>
|
||||
<SelectItem value="자동제어">자동제어</SelectItem>
|
||||
<SelectItem value="PLC">PLC</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{field.value?.map((skill, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
field.onChange(
|
||||
field.value?.filter((_, i) => i !== index) || []
|
||||
)
|
||||
}
|
||||
>
|
||||
{skill} ×
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="certifications"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>보유 자격증</FormLabel>
|
||||
<div className="space-y-4">
|
||||
{/* 현재 등록된 자격증 목록 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{field.value?.map((cert, index) => (
|
||||
<Badge key={index} variant="secondary" className="p-2">
|
||||
<Award className="w-4 h-4 mr-2" />
|
||||
<span>{cert.name}</span>
|
||||
<span className="mx-2 text-xs">
|
||||
({cert.issueDate} ~{" "}
|
||||
{cert.expiryDate || "유효기간 없음"})
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-4 w-4 p-0 ml-2"
|
||||
onClick={() => {
|
||||
const newCerts = [...(field.value || [])];
|
||||
newCerts.splice(index, 1);
|
||||
field.onChange(newCerts);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 새 자격증 추가 폼 */}
|
||||
<div className="flex items-end gap-4">
|
||||
<div className="flex-1">
|
||||
<FormLabel>자격증명</FormLabel>
|
||||
<Input
|
||||
placeholder="자격증명을 입력하세요"
|
||||
value={newCert.name}
|
||||
onChange={(e) =>
|
||||
setNewCert({ ...newCert, name: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<FormLabel>취득일</FormLabel>
|
||||
<Input
|
||||
type="date"
|
||||
value={newCert.issueDate}
|
||||
onChange={(e) =>
|
||||
setNewCert({ ...newCert, issueDate: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<FormLabel>만료일</FormLabel>
|
||||
<Input
|
||||
type="date"
|
||||
value={newCert.expiryDate || ""}
|
||||
onChange={(e) =>
|
||||
setNewCert({ ...newCert, expiryDate: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (newCert.name && newCert.issueDate) {
|
||||
field.onChange([...(field.value || []), newCert]);
|
||||
setNewCert({
|
||||
name: "",
|
||||
issueDate: "",
|
||||
expiryDate: "",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 연락처 정보 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">연락처 정보</h3>
|
||||
<Separator />
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="contactNumber"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>연락처</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="예: 010-1234-5678"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
let value = e.target.value;
|
||||
value = value.replace(/[^0-9-]/g, "");
|
||||
if (value.length === 11) {
|
||||
value = value.replace(
|
||||
/(\d{3})(\d{4})(\d{4})/,
|
||||
"$1-$2-$3"
|
||||
);
|
||||
}
|
||||
field.onChange(value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>이메일</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="예: name@company.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 상태 정보 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">상태 정보</h3>
|
||||
<Separator />
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>재직 상태</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="상태를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">재직</SelectItem>
|
||||
<SelectItem value="inactive">휴직</SelectItem>
|
||||
<SelectItem value="leave">퇴사</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isAvailable"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>가용 상태</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={(value) => field.onChange(value === "true")}
|
||||
defaultValue={field.value ? "true" : "false"}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="가용 상태를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">가능</SelectItem>
|
||||
<SelectItem value="false">불가</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit">{initialData ? "수정하기" : "등록하기"}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
335
fems-app/src/app/(equipment)/personnel/page.tsx
Normal file
335
fems-app/src/app/(equipment)/personnel/page.tsx
Normal file
@ -0,0 +1,335 @@
|
||||
// src/app/(equipment)/personnel/page.tsx
|
||||
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { DataTable } from "@/components/ui/data-table";
|
||||
import { api } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Plus, Edit, Trash2, Wrench, Search } from "lucide-react";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { AxiosError } from "axios";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Personnel } from "@/types/maintenance";
|
||||
import { PersonnelForm } from "./components/PersonnelForm";
|
||||
import { PersonnelDetailDialog } from "./components/PersonnelDetailDialog";
|
||||
|
||||
const PersonnelPage = () => {
|
||||
const { token, user } = useAuthStore();
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [editingPersonnel, setEditingPersonnel] =
|
||||
React.useState<Personnel | null>(null);
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedPersonnel, setSelectedPersonnel] =
|
||||
React.useState<Personnel | null>(null);
|
||||
const [isDetailOpen, setIsDetailOpen] = React.useState(false);
|
||||
|
||||
// Fetch personnel
|
||||
const { data: personnel, isLoading } = useQuery<Personnel[]>({
|
||||
queryKey: ["personnel"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/api/v1/app/personnel");
|
||||
return data;
|
||||
},
|
||||
enabled: !!token,
|
||||
});
|
||||
|
||||
// Create personnel mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (newPersonnel: Partial<Personnel>) => {
|
||||
const personnelWithCompanyId = {
|
||||
...newPersonnel,
|
||||
companyId: user?.companyId,
|
||||
};
|
||||
const { data } = await api.post(
|
||||
"/api/v1/app/personnel",
|
||||
personnelWithCompanyId
|
||||
);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["personnel"] });
|
||||
setIsOpen(false);
|
||||
toast({
|
||||
title: "작업자 등록",
|
||||
description: "새로운 작업자가 등록되었습니다.",
|
||||
});
|
||||
},
|
||||
onError: (error: AxiosError) => {
|
||||
toast({
|
||||
title: "작업자 등록 실패",
|
||||
description: (error.response?.data as { message: string }).message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Update personnel mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async (data: Partial<Personnel>) => {
|
||||
const { data: updatedData } = await api.put(
|
||||
`/api/v1/app/personnel/${data.id}`,
|
||||
data
|
||||
);
|
||||
return updatedData;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["personnel"] });
|
||||
setIsOpen(false);
|
||||
setEditingPersonnel(null);
|
||||
toast({
|
||||
title: "작업자 수정",
|
||||
description: "작업자 정보가 수정되었습니다.",
|
||||
});
|
||||
},
|
||||
onError: (error: AxiosError) => {
|
||||
toast({
|
||||
title: "작업자 수정 실패",
|
||||
description: (error.response?.data as { message: string }).message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Delete personnel mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await api.delete(`/api/v1/app/personnel/${id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["personnel"] });
|
||||
toast({
|
||||
title: "작업자 삭제",
|
||||
description: "작업자가 삭제되었습니다.",
|
||||
});
|
||||
},
|
||||
onError: (error: AxiosError) => {
|
||||
toast({
|
||||
title: "작업자 삭제 실패",
|
||||
description: (error.response?.data as { message: string }).message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const columns: ColumnDef<Personnel>[] = [
|
||||
// {
|
||||
// accessorKey: "employeeNumber",
|
||||
// header: "사원번호",
|
||||
// },
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "이름",
|
||||
},
|
||||
{
|
||||
accessorKey: "position",
|
||||
header: "직위",
|
||||
},
|
||||
{
|
||||
accessorKey: "department",
|
||||
header: "소속부서",
|
||||
},
|
||||
{
|
||||
accessorKey: "specialization",
|
||||
header: "전문분야",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-1">
|
||||
{row.original.specialization && (
|
||||
<Badge variant="outline">{row.original.specialization}</Badge>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "certifications",
|
||||
header: "자격증",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{row.original.certifications?.map((cert: any, index: number) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{cert.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
// {
|
||||
// accessorKey: "contactNumber",
|
||||
// header: "연락처",
|
||||
// },
|
||||
// {
|
||||
// accessorKey: "email",
|
||||
// header: "이메일",
|
||||
// },
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "상태",
|
||||
cell: ({ row }) => {
|
||||
const statusMap: Record<
|
||||
string,
|
||||
{ label: string; variant: "default" | "secondary" | "destructive" }
|
||||
> = {
|
||||
active: { label: "재직", variant: "default" },
|
||||
inactive: { label: "휴직", variant: "secondary" },
|
||||
leave: { label: "퇴사", variant: "destructive" },
|
||||
};
|
||||
const status = statusMap[row.original.status];
|
||||
return <Badge variant={status.variant}>{status.label}</Badge>;
|
||||
},
|
||||
},
|
||||
// {
|
||||
// accessorKey: "skills",
|
||||
// header: "보유기술",
|
||||
// cell: ({ row }) => (
|
||||
// <div className="flex flex-wrap gap-1">
|
||||
// {row.original.skills?.map((skill: string, index: number) => (
|
||||
// <Badge key={index} variant="outline">
|
||||
// <Wrench className="w-3 h-3 mr-1" />
|
||||
// {skill}
|
||||
// </Badge>
|
||||
// ))}
|
||||
// </div>
|
||||
// ),
|
||||
// },
|
||||
{
|
||||
accessorKey: "isAvailable",
|
||||
header: "가용상태",
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={row.original.isAvailable ? "default" : "secondary"}>
|
||||
{row.original.isAvailable ? "가능" : "불가"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "액션",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedPersonnel(row.original);
|
||||
setIsDetailOpen(true);
|
||||
}}
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingPersonnel(row.original);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm("정말 삭제하시겠습니까?")) {
|
||||
deleteMutation.mutate(row.original.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl font-bold">작업자 관리</h1>
|
||||
<p className="text-muted-foreground">
|
||||
정비 작업자를 관리하고 자격 및 기술을 설정합니다.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setIsOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
작업자 등록
|
||||
</Button>
|
||||
</div>
|
||||
{/* Personnel Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>작업자 목록</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{personnel && personnel.length > 0 ? (
|
||||
<DataTable columns={columns} data={personnel} />
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
등록된 작업자가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Personnel Create/Edit Dialog */}
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
if (!open) setEditingPersonnel(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingPersonnel ? "작업자 수정" : "새 작업자"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingPersonnel
|
||||
? "기존 작업자 정보를 수정합니다."
|
||||
: "새로운 작업자를 등록합니다."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<PersonnelForm
|
||||
initialData={editingPersonnel || undefined}
|
||||
onSubmit={(data) => {
|
||||
if (editingPersonnel) {
|
||||
updateMutation.mutate({ id: editingPersonnel.id, ...data });
|
||||
} else {
|
||||
createMutation.mutate(data);
|
||||
}
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsOpen(false);
|
||||
setEditingPersonnel(null);
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<PersonnelDetailDialog
|
||||
personnel={selectedPersonnel}
|
||||
isOpen={isDetailOpen}
|
||||
onClose={() => {
|
||||
setIsDetailOpen(false);
|
||||
setSelectedPersonnel(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PersonnelPage;
|
@ -2,18 +2,26 @@
|
||||
import { MonitoringSidebar } from "@/components/monitoring/MonitoringSidebar";
|
||||
import { MonitoringHeader } from "@/components/monitoring/MonitoringHeader";
|
||||
|
||||
export default function MonitoringLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const MonitoringLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<MonitoringHeader />
|
||||
<div className="flex-1 flex">
|
||||
<div className="h-screen flex">
|
||||
{/* 왼쪽 사이드바 */}
|
||||
<aside className="w-64 h-screen flex-shrink-0 bg-gray-800">
|
||||
<MonitoringSidebar />
|
||||
<main className="flex-1 p-6">{children}</main>
|
||||
</aside>
|
||||
|
||||
{/* 오른쪽 메인 영역 */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* 상단 헤더 */}
|
||||
<header className="h-16 bg-white border-b">
|
||||
<MonitoringHeader />
|
||||
</header>
|
||||
|
||||
{/* 메인 컨텐츠 영역 */}
|
||||
<main className="flex-1 overflow-auto bg-gray-50 p-6">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default MonitoringLayout;
|
||||
|
@ -32,6 +32,7 @@ import {
|
||||
Newspaper,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Puzzle,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
@ -63,6 +64,8 @@ const menuItems = [
|
||||
{ title: "설비 목록", href: "/inventory", icon: Box },
|
||||
{ title: "상태 모니터링", href: "/monitoring", icon: Activity },
|
||||
{ title: "정비 관리", href: "/maintenance", icon: Wrench },
|
||||
{ title: "부품 관리", href: "/parts", icon: Puzzle },
|
||||
{ title: "작업자 관리", href: "/personnel", icon: Users },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
@ -32,8 +32,10 @@ import {
|
||||
Newspaper,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Puzzle,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { title } from "process";
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
@ -63,6 +65,8 @@ const menuItems = [
|
||||
{ title: "설비 목록", href: "/inventory", icon: Box },
|
||||
{ title: "상태 모니터링", href: "/monitoring", icon: Activity },
|
||||
{ title: "정비 관리", href: "/maintenance", icon: Wrench },
|
||||
{ title: "부품 관리", href: "/parts", icon: Puzzle },
|
||||
{ title: "작업자 관리", href: "/personnel", icon: Users },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
@ -1,9 +1,15 @@
|
||||
// src/components/monitoring/MonitoringHeader.tsx
|
||||
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import {
|
||||
Bell,
|
||||
@ -13,72 +19,15 @@ import {
|
||||
LogOut,
|
||||
User,
|
||||
ChevronDown,
|
||||
Download,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { DatePickerWithRange } from "@/components/ui/date-range-picker";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
const MonitoringHeader = () => {
|
||||
export const MonitoringHeader = () => {
|
||||
const { user, logout } = useAuth();
|
||||
const pathname = usePathname();
|
||||
|
||||
// 페이지에 따른 제목 설정
|
||||
const pageTitles: { [key: string]: string } = {
|
||||
"/monitoring/electricity": "전력 모니터링",
|
||||
"/monitoring/gas": "가스 모니터링",
|
||||
"/monitoring/water": "용수 모니터링",
|
||||
"/monitoring/steam": "스팀 모니터링",
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="h-16 border-b bg-white fixed top-0 right-0 left-64 z-50">
|
||||
<div className="h-full px-4 flex items-center justify-between">
|
||||
{/* 페이지 제목 */}
|
||||
<h2 className="text-lg font-semibold">{pageTitles[pathname]}</h2>
|
||||
|
||||
{/* 필터 및 데이터 주기 설정 */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<Select defaultValue="realtime">
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="데이터 주기" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="realtime">실시간</SelectItem>
|
||||
<SelectItem value="hourly">시간별</SelectItem>
|
||||
<SelectItem value="daily">일별</SelectItem>
|
||||
<SelectItem value="monthly">월별</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<DatePickerWithRange className="w-[300px]" />
|
||||
|
||||
<Button variant="outline" size="icon">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button variant="outline">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
데이터 다운로드
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 검색, 알림, 도움말 및 사용자 메뉴 */}
|
||||
<div className="flex items-center gap-3 ml-auto">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 검색바 */}
|
||||
<div className="relative">
|
||||
<input
|
||||
@ -89,7 +38,9 @@ const MonitoringHeader = () => {
|
||||
/>
|
||||
<Search className="h-4 w-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 알림 버튼 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
@ -1,4 +1,6 @@
|
||||
// src/components/monitoring/MonitoringSidebar.tsx
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
@ -30,6 +32,7 @@ import {
|
||||
Newspaper,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Puzzle,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
@ -61,6 +64,8 @@ const menuItems = [
|
||||
{ title: "설비 목록", href: "/inventory", icon: Box },
|
||||
{ title: "상태 모니터링", href: "/monitoring", icon: Activity },
|
||||
{ title: "정비 관리", href: "/maintenance", icon: Wrench },
|
||||
{ title: "부품 관리", href: "/parts", icon: Puzzle },
|
||||
{ title: "작업자 관리", href: "/personnel", icon: Users },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
@ -1,6 +1,6 @@
|
||||
// app/components/ui/FilePreview.tsx: image: files.filter((file) => file.type.startsWith("image")),
|
||||
|
||||
// app/components/ui/FilePreview.tsx
|
||||
import React, { useState, useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@ -10,13 +10,11 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
File,
|
||||
Download,
|
||||
Maximize2,
|
||||
FileImage,
|
||||
FileCog,
|
||||
FileCode,
|
||||
FileSpreadsheet,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
@ -55,37 +53,39 @@ const getFileIcon = (mimetype: string) => {
|
||||
const FilePreview: React.FC<FilePreviewProps> = ({ file, onDownload }) => {
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
const [objectUrl, setObjectUrl] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const isPreviewable = /^image\/|^application\/pdf/.test(file.mimetype);
|
||||
const isImage = /^image\//.test(file.mimetype);
|
||||
|
||||
// 파일 데이터 가져오기
|
||||
const fetchFileData = async () => {
|
||||
try {
|
||||
const response = await api.get(`/api/v1/app/files/view/${file.id}`, {
|
||||
responseType: "blob",
|
||||
});
|
||||
|
||||
const blob = new Blob([response.data], { type: file.mimetype });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setObjectUrl(url);
|
||||
} catch (error) {
|
||||
console.error("Error fetching file:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 프리뷰 상태 변경 시에만 실행
|
||||
useEffect(() => {
|
||||
if (isPreviewOpen && isPreviewable) {
|
||||
fetchFileData();
|
||||
}
|
||||
const fetchFileData = async () => {
|
||||
if (!isPreviewOpen || !isPreviewable) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await api.get(`/api/v1/app/files/view/${file.id}`, {
|
||||
responseType: "blob",
|
||||
});
|
||||
|
||||
const blob = new Blob([response.data], { type: file.mimetype });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setObjectUrl(url);
|
||||
} catch (error) {
|
||||
console.error("Error fetching file:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchFileData();
|
||||
|
||||
return () => {
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
setObjectUrl("");
|
||||
}
|
||||
};
|
||||
}, [isPreviewOpen]); // isPreviewOpen만 의존성으로 설정
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isPreviewOpen, isPreviewable, file.id, file.mimetype]);
|
||||
|
||||
const handlePreview = () => {
|
||||
if (isPreviewable) {
|
||||
@ -93,8 +93,16 @@ const FilePreview: React.FC<FilePreviewProps> = ({ file, onDownload }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleClosePreview = () => {
|
||||
setIsPreviewOpen(false);
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
setObjectUrl("");
|
||||
}
|
||||
};
|
||||
|
||||
const getPreviewContent = () => {
|
||||
if (!objectUrl) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
@ -103,13 +111,19 @@ const FilePreview: React.FC<FilePreviewProps> = ({ file, onDownload }) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (!objectUrl) return null;
|
||||
|
||||
if (isImage) {
|
||||
return (
|
||||
<img
|
||||
src={objectUrl}
|
||||
alt={file.originalName}
|
||||
className="max-w-full max-h-[80vh] object-contain"
|
||||
/>
|
||||
<div className="relative w-full h-[80vh]">
|
||||
<Image
|
||||
src={objectUrl}
|
||||
alt={file.originalName}
|
||||
fill
|
||||
className="object-contain"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else if (file.mimetype === "application/pdf") {
|
||||
return (
|
||||
@ -166,7 +180,7 @@ const FilePreview: React.FC<FilePreviewProps> = ({ file, onDownload }) => {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Dialog open={isPreviewOpen} onOpenChange={setIsPreviewOpen}>
|
||||
<Dialog open={isPreviewOpen} onOpenChange={handleClosePreview}>
|
||||
<DialogContent className="max-w-4xl w-full">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{file.originalName}</DialogTitle>
|
||||
|
@ -1,3 +1,216 @@
|
||||
// // components/ui/file-uploader.tsx
|
||||
// import React, { useCallback, useState, useEffect } from "react";
|
||||
// import { useDropzone, Accept } from "react-dropzone";
|
||||
// import { api } from "@/lib/api";
|
||||
// import {
|
||||
// Upload as IconUpload,
|
||||
// File as IconFile,
|
||||
// X as IconX,
|
||||
// } from "lucide-react";
|
||||
// import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
// interface FileUploaderProps {
|
||||
// value: any | any[]; // 단일 파일 또는 배열 허용
|
||||
// onChange: (files: any[] | any, removedFiles: any[]) => void;
|
||||
// accept?: string;
|
||||
// multiple?: boolean;
|
||||
// moduleType: string;
|
||||
// category: string;
|
||||
// referenceId?: string;
|
||||
// }
|
||||
|
||||
// export function FileUploader({
|
||||
// value,
|
||||
// onChange,
|
||||
// accept,
|
||||
// multiple = false, // 기본값을 false로 변경
|
||||
// moduleType,
|
||||
// category,
|
||||
// referenceId,
|
||||
// }: FileUploaderProps) {
|
||||
// const { toast } = useToast();
|
||||
// const [files, setFiles] = useState<any[]>(() => {
|
||||
// if (multiple) {
|
||||
// return Array.isArray(value) ? value : value ? [value] : [];
|
||||
// } else {
|
||||
// return value ? [value] : [];
|
||||
// }
|
||||
// });
|
||||
// const [removedFiles, setRemovedFiles] = useState<any[]>([]);
|
||||
|
||||
// // value prop이 변경될 때 내부 상태 업데이트
|
||||
// useEffect(() => {
|
||||
// if (multiple) {
|
||||
// setFiles(Array.isArray(value) ? value : value ? [value] : []);
|
||||
// } else {
|
||||
// setFiles(value ? [value] : []);
|
||||
// }
|
||||
// }, [value, multiple]);
|
||||
|
||||
// const onDrop = useCallback(
|
||||
// async (acceptedFiles: File[]) => {
|
||||
// try {
|
||||
// if (!multiple && files.length >= 1) {
|
||||
// // 단일 파일 모드에서 기존 파일이 있으면 제거
|
||||
// setRemovedFiles([...removedFiles, ...files]);
|
||||
// setFiles([]);
|
||||
// }
|
||||
|
||||
// const formData = new FormData();
|
||||
// acceptedFiles.forEach((file) => {
|
||||
// formData.append("files", file);
|
||||
// });
|
||||
|
||||
// formData.append("moduleType", moduleType);
|
||||
// formData.append("category", category);
|
||||
// if (referenceId) {
|
||||
// formData.append("referenceId", referenceId);
|
||||
// }
|
||||
|
||||
// const metadata = {
|
||||
// uploadTime: new Date().toISOString(),
|
||||
// fileCount: acceptedFiles.length,
|
||||
// };
|
||||
// formData.append("metadata", JSON.stringify(metadata));
|
||||
|
||||
// const { data } = await api.post("/api/v1/app/files/upload", formData);
|
||||
|
||||
// // 새로운 파일 추가 로직
|
||||
// let updatedFiles;
|
||||
// if (multiple) {
|
||||
// updatedFiles = [...files, ...data];
|
||||
// } else {
|
||||
// // 단일 파일 모드에서는 마지막 업로드된 파일만 유지
|
||||
// updatedFiles = [data[data.length - 1]];
|
||||
// }
|
||||
|
||||
// setFiles(updatedFiles);
|
||||
// onChange(multiple ? updatedFiles : updatedFiles[0], removedFiles);
|
||||
|
||||
// toast({
|
||||
// title: "파일 업로드 성공",
|
||||
// description: "파일이 성공적으로 업로드되었습니다.",
|
||||
// });
|
||||
// } catch (error: any) {
|
||||
// console.error("File upload failed:", error.response?.data);
|
||||
// toast({
|
||||
// title: "파일 업로드 실패",
|
||||
// description:
|
||||
// error.response?.data?.message ||
|
||||
// "파일 업로드 중 오류가 발생했습니다",
|
||||
// variant: "destructive",
|
||||
// });
|
||||
// }
|
||||
// },
|
||||
// [
|
||||
// moduleType,
|
||||
// category,
|
||||
// referenceId,
|
||||
// files,
|
||||
// onChange,
|
||||
// toast,
|
||||
// removedFiles,
|
||||
// multiple,
|
||||
// ]
|
||||
// );
|
||||
|
||||
// const removeFile = (indexToRemove: number) => {
|
||||
// const fileToRemove = files[indexToRemove];
|
||||
// const updatedFiles = files.filter((_, index) => index !== indexToRemove);
|
||||
// setFiles(updatedFiles);
|
||||
// setRemovedFiles([...removedFiles, fileToRemove]);
|
||||
// onChange(multiple ? updatedFiles : null, [...removedFiles, fileToRemove]);
|
||||
// };
|
||||
|
||||
// const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
// onDrop,
|
||||
// accept: accept ? parseAcceptString(accept) : undefined,
|
||||
// multiple,
|
||||
// maxSize: 50 * 1024 * 1024,
|
||||
// onDropRejected: (fileRejections) => {
|
||||
// fileRejections.forEach(({ file, errors }) => {
|
||||
// errors.forEach((error) => {
|
||||
// toast({
|
||||
// title: "파일 업로드 오류",
|
||||
// description:
|
||||
// error.code === "file-too-large"
|
||||
// ? `${file.name}의 크기가 50MB를 초과합니다.`
|
||||
// : `${file.name}은(는) 지원되지 않는 파일 형식입니다.`,
|
||||
// variant: "destructive",
|
||||
// });
|
||||
// });
|
||||
// });
|
||||
// },
|
||||
// });
|
||||
|
||||
// return (
|
||||
// <div className="space-y-4">
|
||||
// <div
|
||||
// {...getRootProps()}
|
||||
// className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer hover:border-gray-400 transition-colors
|
||||
// ${isDragActive ? "border-primary" : "border-gray-300"}`}
|
||||
// >
|
||||
// <input {...getInputProps()} />
|
||||
// <div className="flex flex-col items-center">
|
||||
// <IconUpload className="h-8 w-8 mb-2 text-gray-400" />
|
||||
// <p className="text-sm text-gray-600">
|
||||
// {isDragActive
|
||||
// ? "파일을 여기에 놓으세요"
|
||||
// : "파일을 드래그하거나 클릭하여 업로드"}
|
||||
// </p>
|
||||
// <p className="text-xs text-gray-400 mt-1">
|
||||
// {accept ? `지원 형식: ${accept}` : "모든 파일"}
|
||||
// {!multiple && " (단일 파일만 허용)"}
|
||||
// </p>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// {/* 업로드된 파일 목록 */}
|
||||
// {files.length > 0 && (
|
||||
// <div className="space-y-2">
|
||||
// {files.map((file, index) => (
|
||||
// <div
|
||||
// key={file.id || index}
|
||||
// className="flex items-center justify-between p-2 bg-gray-50 rounded"
|
||||
// >
|
||||
// <div className="flex items-center space-x-2">
|
||||
// <IconFile className="h-4 w-4" />
|
||||
// <span className="text-sm">
|
||||
// {file.originalName || file.name}
|
||||
// </span>
|
||||
// </div>
|
||||
// <button
|
||||
// type="button"
|
||||
// onClick={() => removeFile(index)}
|
||||
// className="p-1 hover:bg-gray-200 rounded"
|
||||
// >
|
||||
// <IconX className="h-4 w-4" />
|
||||
// </button>
|
||||
// </div>
|
||||
// ))}
|
||||
// </div>
|
||||
// )}
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
// function parseAcceptString(accept: string): Accept | undefined {
|
||||
// const acceptArray = accept.split(",").map((type) => type.trim());
|
||||
// const acceptObject: Accept = {};
|
||||
|
||||
// acceptArray.forEach((type) => {
|
||||
// if (type.includes("/")) {
|
||||
// const [mimeType, subtype] = type.split("/");
|
||||
// if (!acceptObject[mimeType]) {
|
||||
// acceptObject[mimeType] = [];
|
||||
// }
|
||||
// acceptObject[mimeType] = [...acceptObject[mimeType], subtype];
|
||||
// }
|
||||
// });
|
||||
|
||||
// return acceptObject;
|
||||
// }
|
||||
|
||||
// components/ui/file-uploader.tsx
|
||||
import React, { useCallback, useState, useEffect } from "react";
|
||||
import { useDropzone, Accept } from "react-dropzone";
|
||||
@ -9,6 +222,21 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
// MIME 타입 매핑 객체
|
||||
const extensionToMime: Record<string, string> = {
|
||||
".pdf": "application/pdf",
|
||||
".doc": "application/msword",
|
||||
".docx":
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".xls": "application/vnd.ms-excel",
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".ppt": "application/vnd.ms-powerpoint",
|
||||
".pptx":
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
".txt": "text/plain",
|
||||
// 필요에 따라 추가적인 확장자와 MIME 타입을 여기에 추가하세요
|
||||
};
|
||||
|
||||
interface FileUploaderProps {
|
||||
value: any | any[]; // 단일 파일 또는 배열 허용
|
||||
onChange: (files: any[] | any, removedFiles: any[]) => void;
|
||||
@ -47,6 +275,28 @@ export function FileUploader({
|
||||
}
|
||||
}, [value, multiple]);
|
||||
|
||||
// 확장자를 MIME 타입으로 매핑하는 함수
|
||||
const parseAcceptString = (accept: string): Accept | undefined => {
|
||||
const acceptArray = accept.split(",").map((type) => type.trim());
|
||||
const acceptObject: Accept = {};
|
||||
|
||||
acceptArray.forEach((type) => {
|
||||
if (type.startsWith(".")) {
|
||||
const mimeType = extensionToMime[type.toLowerCase()];
|
||||
if (mimeType) {
|
||||
if (!acceptObject[mimeType]) {
|
||||
acceptObject[mimeType] = [];
|
||||
}
|
||||
acceptObject[mimeType] = [...acceptObject[mimeType], type];
|
||||
}
|
||||
} else if (type.includes("/")) {
|
||||
acceptObject[type] = [];
|
||||
}
|
||||
});
|
||||
|
||||
return Object.keys(acceptObject).length > 0 ? acceptObject : undefined;
|
||||
};
|
||||
|
||||
const onDrop = useCallback(
|
||||
async (acceptedFiles: File[]) => {
|
||||
try {
|
||||
@ -124,7 +374,7 @@ export function FileUploader({
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: accept ? parseAcceptString(accept) : undefined,
|
||||
accept: accept ? parseAcceptString(accept) : undefined, // 수정된 parseAcceptString 사용
|
||||
multiple,
|
||||
maxSize: 50 * 1024 * 1024,
|
||||
onDropRejected: (fileRejections) => {
|
||||
@ -193,20 +443,3 @@ export function FileUploader({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function parseAcceptString(accept: string): Accept | undefined {
|
||||
const acceptArray = accept.split(",").map((type) => type.trim());
|
||||
const acceptObject: Accept = {};
|
||||
|
||||
acceptArray.forEach((type) => {
|
||||
if (type.includes("/")) {
|
||||
const [mimeType, subtype] = type.split("/");
|
||||
if (!acceptObject[mimeType]) {
|
||||
acceptObject[mimeType] = [];
|
||||
}
|
||||
acceptObject[mimeType] = [...acceptObject[mimeType], subtype];
|
||||
}
|
||||
});
|
||||
|
||||
return acceptObject;
|
||||
}
|
||||
|
@ -1,17 +1,20 @@
|
||||
// src/types/equipment.ts
|
||||
import { MaintenanceLog } from "./maintenance";
|
||||
|
||||
export interface Equipment {
|
||||
id: string;
|
||||
name: string;
|
||||
model: string;
|
||||
manufacturer: string;
|
||||
type: "HVAC" | "Boiler" | "Compressor" | "Motor" | "Pump" | "Other";
|
||||
specifications: Record<string, any>;
|
||||
specifications: Record<string, string | number | boolean>;
|
||||
installationDate: string;
|
||||
lastMaintenance: string | null;
|
||||
isActive: boolean;
|
||||
zoneId: string;
|
||||
companyId: string;
|
||||
branchId: string;
|
||||
MaintenanceLogs?: MaintenanceLog[];
|
||||
Zone?: {
|
||||
name: string;
|
||||
};
|
||||
|
223
fems-app/src/types/maintenance.ts
Normal file
223
fems-app/src/types/maintenance.ts
Normal file
@ -0,0 +1,223 @@
|
||||
// src/types/maintenance.ts
|
||||
|
||||
import { Equipment } from "./equipment";
|
||||
// import { User } from "./user";
|
||||
|
||||
/**
|
||||
* 정비 유형을 정의하는 열거형
|
||||
*/
|
||||
export enum MaintenanceType {
|
||||
Preventive = "preventive",
|
||||
Corrective = "corrective",
|
||||
Predictive = "predictive",
|
||||
Inspection = "inspection",
|
||||
}
|
||||
|
||||
/**
|
||||
* 정비 상태를 정의하는 열거형
|
||||
*/
|
||||
export enum MaintenanceStatus {
|
||||
Scheduled = "scheduled",
|
||||
InProgress = "in_progress",
|
||||
Completed = "completed",
|
||||
Cancelled = "cancelled",
|
||||
}
|
||||
|
||||
/**
|
||||
* 설비 상태를 정의하는 열거형
|
||||
*/
|
||||
export enum EquipmentStatus {
|
||||
Operational = "operational",
|
||||
Degraded = "degraded",
|
||||
Failed = "failed",
|
||||
}
|
||||
|
||||
/**
|
||||
* 중간 테이블 MaintenanceLogParts 타입 정의
|
||||
*/
|
||||
export interface MaintenanceLogParts {
|
||||
maintenanceLogId: string;
|
||||
partId: string;
|
||||
quantity: number;
|
||||
cost: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 중간 테이블 MaintenanceLogPersonnel 타입 정의
|
||||
*/
|
||||
export interface MaintenanceLogPersonnel {
|
||||
maintenanceLogId: string;
|
||||
personnelId: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부 파일 정보를 정의하는 인터페이스
|
||||
*/
|
||||
export interface Attachment {
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
fileUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parts 타입 정의
|
||||
*/
|
||||
export interface Parts {
|
||||
id: string;
|
||||
partNumber: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
manufacturer: string;
|
||||
specifications?: string;
|
||||
unitPrice: number;
|
||||
stockQuantity: number;
|
||||
minStockLevel: number;
|
||||
location: string;
|
||||
leadTime: number;
|
||||
supplier?: {
|
||||
name: string;
|
||||
contact: string;
|
||||
email: string;
|
||||
};
|
||||
status: "active" | "discontinued" | "out_of_stock";
|
||||
companyId?: string;
|
||||
branchId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
compatibleEquipment?: string[];
|
||||
Equipment?: Equipment[];
|
||||
MaintenanceLogParts?: MaintenanceLogParts | null; // 중간 테이블 정보
|
||||
}
|
||||
|
||||
/**
|
||||
* Personnel 타입 정의
|
||||
*/
|
||||
export interface Personnel {
|
||||
id: string;
|
||||
employeeNumber: string;
|
||||
name: string;
|
||||
position: string;
|
||||
department: string;
|
||||
specialization?: string;
|
||||
certifications?: {
|
||||
name: string;
|
||||
date: string;
|
||||
expiry: string;
|
||||
expiryDate: string;
|
||||
}[];
|
||||
contactNumber: string;
|
||||
email: string;
|
||||
status: string;
|
||||
skills?: string[];
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
availabilitySchedule?: {
|
||||
weekday: string;
|
||||
weekend: string;
|
||||
};
|
||||
isAvailable?: boolean;
|
||||
companyId?: string;
|
||||
branchId?: string;
|
||||
MaintenanceLogPersonnel?: MaintenanceLogPersonnel;
|
||||
MaintenanceLogs?: MaintenanceLog[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 정비 로그를 정의하는 인터페이스
|
||||
*/
|
||||
export interface MaintenanceLog {
|
||||
id: string;
|
||||
type: MaintenanceType;
|
||||
status: MaintenanceStatus;
|
||||
scheduledDate: string;
|
||||
completionDate?: string;
|
||||
description: string;
|
||||
findings?: string;
|
||||
actions?: string;
|
||||
cost?: number;
|
||||
nextMaintenanceDate?: string;
|
||||
equipmentStatus?: EquipmentStatus;
|
||||
equipmentId: string;
|
||||
Equipment?: Equipment;
|
||||
specifications?: Record<string, string>;
|
||||
documents?: {
|
||||
report?: string[] | null;
|
||||
checklist?: string[] | null;
|
||||
photo?: string[] | null;
|
||||
};
|
||||
// 관계 필드
|
||||
Parts?: Parts[]; // 중간 테이블을 통한 관계
|
||||
Personnel?: Personnel[]; // 중간 테이블을 통한 관계
|
||||
companyId: string;
|
||||
createdBy: string;
|
||||
completedBy?: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 정비 로그 생성 시 필요한 데이터 타입
|
||||
*/
|
||||
export interface CreateMaintenanceLogInput {
|
||||
type: MaintenanceType;
|
||||
status?: MaintenanceStatus;
|
||||
scheduledDate: string;
|
||||
completionDate?: string;
|
||||
description: string;
|
||||
findings?: string;
|
||||
actions?: string;
|
||||
cost?: number;
|
||||
nextMaintenanceDate?: string;
|
||||
equipmentStatus?: EquipmentStatus;
|
||||
equipmentId: string;
|
||||
parts?: Array<{
|
||||
partId: string;
|
||||
quantity: number;
|
||||
cost: number;
|
||||
}>;
|
||||
personnelInfo?: Array<{
|
||||
personnelId: string;
|
||||
role: string;
|
||||
}>;
|
||||
documents?: {
|
||||
report?: string[] | null;
|
||||
checklist?: string[] | null;
|
||||
photo?: string[] | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 정비 로그 수정 시 필요한 데이터 타입
|
||||
*/
|
||||
export interface UpdateMaintenanceLogInput {
|
||||
type?: MaintenanceType;
|
||||
status?: MaintenanceStatus;
|
||||
scheduledDate?: string;
|
||||
completionDate?: string;
|
||||
description?: string;
|
||||
findings?: string;
|
||||
actions?: string;
|
||||
cost?: number;
|
||||
nextMaintenanceDate?: string;
|
||||
equipmentStatus?: EquipmentStatus;
|
||||
equipmentId?: string;
|
||||
isActive?: boolean;
|
||||
parts?: Array<{
|
||||
partId: string;
|
||||
quantity: number;
|
||||
cost: number;
|
||||
}>;
|
||||
personnelInfo?: Array<{
|
||||
personnelId: string;
|
||||
role: string;
|
||||
}>;
|
||||
documents?: {
|
||||
report?: string[] | null;
|
||||
checklist?: string[] | null;
|
||||
photo?: string[] | null;
|
||||
};
|
||||
}
|
Loading…
Reference in New Issue
Block a user