auto commit

This commit is contained in:
bangdk 2024-11-05 21:55:38 +09:00
parent 130c1dcf1d
commit 19f82aca2b
49 changed files with 6624 additions and 320 deletions

View File

@ -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);

View File

@ -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;

View File

@ -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;

View File

@ -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;

View 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;

View 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;

View File

@ -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" });
}
}

View File

@ -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",
});
}
}

View 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;

View File

@ -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",
});
}
}

View 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;

View 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;

View 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;

View 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;

View File

@ -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;

View File

@ -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 },

View 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();

View 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();

View 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();

View 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();

View File

@ -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(

View 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,
};

View File

@ -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,
};

View File

@ -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>

View File

@ -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 ? "부서 수정" : "새 부서"}

View File

@ -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}

View File

@ -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,
},
}));

View File

@ -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 ? "설비 수정" : "새 설비"}

View File

@ -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>
);
}

View 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;

View File

@ -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;

View 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;

View File

@ -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;

View File

@ -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>
);
}

View 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>
);
}

View 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;

View File

@ -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>
);
}

View File

@ -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>
);
}

View 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;

View File

@ -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;

View File

@ -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 },
],
},
{

View File

@ -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 },
],
},
{

View File

@ -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"

View File

@ -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 },
],
},
{

View File

@ -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>

View File

@ -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;
}

View File

@ -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;
};

View 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;
};
}