auto commit

This commit is contained in:
bangdk 2024-11-04 17:51:38 +09:00
parent 3faddff2d7
commit f404c4b0d3
40 changed files with 3769 additions and 319 deletions

View File

@ -4,6 +4,9 @@
NODE_ENV=development
TZ=Asia/Seoul
# Uproad Folder
UPLOAD_FOLDER=/Users/effects/Documents/GitHub/wacefems_upload
# Frontend
NEXT_PUBLIC_API_URL=http://localhost:3001
NEXT_PUBLIC_MQTT_URL=ws://localhost:1883

View File

@ -4,6 +4,9 @@
NODE_ENV=production
TZ=Asia/Seoul
# Uproad Folder
UPLOAD_FOLDER=/home/wacefems
# Traefik Settings
DOMAIN=fems.com
TRAEFIK_NETWORK=toktork_server_default

View File

@ -55,10 +55,6 @@ services:
- /app/node_modules
environment:
- NODE_ENV=${NODE_ENV:-development}
# - POSTGRES_DB=${POSTGRES_DB}
# - POSTGRES_USER=${POSTGRES_USER}
# - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
# - POSTGRES_HOST=${POSTGRES_HOST}
depends_on:
- postgres
- redis
@ -70,9 +66,29 @@ services:
- .env.${NODE_ENV:-development}
environment:
- NODE_ENV=${NODE_ENV:-development}
- LANG=en_US.utf8
- LC_ALL=en_US.utf8
- POSTGRES_HOST_AUTH_METHOD=scram-sha-256
- POSTGRES_INITDB_ARGS=--auth-host=scram-sha-256 --auth-local=scram-sha-256
volumes:
- postgres_data:/var/lib/postgresql/data
- ./backups/postgres:/backups
- ./init-scripts:/docker-entrypoint-initdb.d
command:
- "postgres"
- "-c"
- "max_connections=100"
- "-c"
- "shared_buffers=128MB"
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-postgres}",
]
interval: 30s
timeout: 10s
retries: 3
# healthcheck:
# test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
# interval: 30s
@ -96,12 +112,10 @@ services:
environment:
- NODE_ENV=${NODE_ENV:-development}
- REDIS_PASSWORD=${NODE_ENV:-development:-REDIS_PASSWORD}
depends_on:
- postgres
# healthcheck:
# test: ["CMD", "redis-cli", "ping"]
# interval: 30s
# timeout: 10s
# retries: 3
volumes:
postgres_data:
redis_data:

View File

@ -10,6 +10,8 @@ services:
- NEXT_WEBPACK_USEPOLLING=1
- WATCHPACK_POLLING=true
# 개발 환경에서는 healthcheck 비활성화
volumes:
- ../../wacefems/uploaded_files:/app/uploads
healthcheck:
disable: true
@ -19,10 +21,15 @@ services:
environment:
- NEXT_WEBPACK_USEPOLLING=1
- WATCHPACK_POLLING=true
volumes:
- ../../wacefems/uploaded_files:/app/uploads
fems-api:
ports:
- "3001:3001"
volumes:
- ../../wacefems/uploaded_files:/app/uploads
- fems_tmp:/app/tmp
postgres:
ports:
@ -32,6 +39,17 @@ services:
ports:
- "6379:6379"
volumes:
postgres_data:
redis_data:
fems_tmp:
uploaded_files:
driver: local
# driver_opts:
# type: none
# device: /Users/effects/Documents/GitHub/wacefems_upload
# o: bind
networks:
default:
driver: bridge

View File

@ -23,6 +23,8 @@ services:
- "traefik.http.routers.fems-admin.tls=true"
- "traefik.http.routers.fems-admin.middlewares=secured@file"
- "traefik.http.services.fems-admin.loadbalancer.server.port=3000"
volumes:
- ../../wacefems/uploaded_files:/app/uploads
networks:
- ${TRAEFIK_NETWORK}
- internal
@ -37,6 +39,8 @@ services:
- "traefik.http.routers.fems-app.entrypoints=websecure"
- "traefik.http.routers.fems-app.tls=true"
- "traefik.http.services.fems-app.loadbalancer.server.port=3000"
volumes:
- ../../wacefems/uploaded_files:/app/uploads
networks:
- ${TRAEFIK_NETWORK}
- internal
@ -51,11 +55,25 @@ services:
- "traefik.http.routers.fems-api.entrypoints=websecure"
- "traefik.http.routers.fems-api.tls=true"
- "traefik.http.services.fems-api.loadbalancer.server.port=3001"
volumes:
- ../../wacefems/uploaded_files:/app/uploads
- fems_tmp:/app/tmp
networks:
- ${TRAEFIK_NETWORK}
- internal
command: npm start
volumes:
postgres_data:
redis_data:
fems_tmp:
uploaded_files:
driver: local
# driver_opts:
# type: none
# device: /home/wacefems_upload
# o: bind
networks:
${TRAEFIK_NETWORK}:
external: true

7
fems-api/.gitignore vendored
View File

@ -5,4 +5,9 @@ node_modules/
!.env.example
logs/
coverage/
.DS_Store
.DS_Store
temp/
tmp/
uploads/
_app_uploads/

View File

@ -1,4 +1,5 @@
# fems-api/Dockerfile
# Build stage
FROM node:18-alpine AS builder
WORKDIR /app
@ -10,18 +11,34 @@ RUN yarn build
# Development stage
FROM node:18-alpine AS development
WORKDIR /app
# 필요한 디렉토리 생성 및 권한 설정
RUN mkdir -p /app/tmp/uploads && \
mkdir -p /app/uploads && \
chmod -R 777 /app/tmp && \
chmod -R 777 /app/uploads
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY . .
ENV NODE_ENV=development
CMD ["yarn", "dev"]
# Production stage
FROM node:18-alpine AS production
WORKDIR /app
# 필요한 디렉토리 생성 및 권한 설정
RUN mkdir -p /app/tmp/uploads && \
mkdir -p /app/uploads && \
chmod -R 777 /app/tmp && \
chmod -R 777 /app/uploads
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile --production
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./package.json
ENV NODE_ENV=production
CMD ["yarn", "start"]

View File

@ -13,10 +13,12 @@
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"crypto": "^1.0.1",
"dotenv": "^16.0.3",
"express": "^4.18.2",
"express-validator": "^7.0.1",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"pg": "^8.11.0",
"pg-hstore": "^2.3.4",
"sequelize": "^6.31.1",

View File

@ -1,16 +1,14 @@
// src/app.js
const express = require("express");
const cors = require("cors");
const path = require("path");
const config = require("./config/config");
const { sequelize } = require("./models");
const logger = require("./config/logger");
const requestLogger = require("./middleware/requestLogger.middleware");
const errorHandler = require("./middleware/errorHandler.middleware");
const registerRoutes = require("./routes");
// // Import routes
// const adminRoutes = require("./routes/admin");
// const appRoutes = require("./routes/app");
const authMiddleware = require("./middleware/auth.middleware"); // 추가
const app = express();
@ -26,6 +24,35 @@ registerRoutes(app);
// Error handling
app.use(errorHandler);
// 파일 다운로드 라우트
app.get(
"/uploads/:companyId/:moduleType/:category/:filename",
authMiddleware,
async (req, res, next) => {
try {
const { companyId, moduleType, category, filename } = req.params;
// 인증된 사용자와 companyId 비교
if (req.user.role !== "super_admin" && req.user.companyId !== companyId) {
return res.status(403).json({ message: "Forbidden" });
}
const filePath = path.join(
__dirname,
"../uploads",
companyId,
moduleType,
category,
filename
);
res.sendFile(filePath);
} catch (error) {
next(error);
}
}
);
// Database initialization and server start
const initializeServer = async () => {
try {

View File

@ -0,0 +1,209 @@
// src/controllers/app/equipment/equipment.controller.js
const express = require("express");
const router = express.Router();
const equipmentService = require("../../../services/equipment.service");
const authMiddleware = require("../../../middleware/auth.middleware");
const roleCheck = require("../../../middleware/roleCheck.middleware");
const { body, param } = require("express-validator");
const validate = require("../../../middleware/validator.middleware");
router.use(authMiddleware);
router.use(roleCheck(["super_admin", "company_admin", "branch_admin", "user"]));
// 설비 목록 조회
router.get("/", async (req, res, next) => {
try {
const equipment = await equipmentService.findAll(req.user);
res.json(equipment);
} catch (error) {
next(error);
}
});
// 설비 생성
router.post(
"/",
[
body("name")
.notEmpty()
.withMessage("설비명은 필수입니다")
.isLength({ max: 100 })
.withMessage("설비명은 100자를 초과할 수 없습니다"),
body("model")
.notEmpty()
.withMessage("모델명은 필수입니다")
.isLength({ max: 100 })
.withMessage("모델명은 100자를 초과할 수 없습니다"),
body("manufacturer")
.notEmpty()
.withMessage("제조사는 필수입니다")
.isLength({ max: 100 })
.withMessage("제조사는 100자를 초과할 수 없습니다"),
body("type")
.notEmpty()
.withMessage("설비 유형은 필수입니다")
.isIn(["HVAC", "Boiler", "Compressor", "Motor", "Pump", "Other"])
.withMessage("유효한 설비 유형이 아닙니다"),
body("specifications")
.optional()
.isObject()
.withMessage("사양 정보는 객체 형태여야 합니다"),
body("installationDate")
.notEmpty()
.withMessage("설치일은 필수입니다")
.isISO8601()
.withMessage("유효한 날짜 형식이 아닙니다"),
body("zoneId")
.optional()
.isUUID()
.withMessage("유효한 구역 ID가 필요합니다"),
validate,
],
async (req, res, next) => {
try {
const equipmentData = req.body;
// company_admin은 자신의 회사에만 설비 생성 가능
if (req.user.role === "company_admin") {
equipmentData.companyId = req.user.companyId;
}
const equipment = await equipmentService.createEquipment(
equipmentData,
req.user
);
res.status(201).json(equipment);
} catch (error) {
next(error);
}
}
);
// 설비 상세 조회 추가
router.get(
"/:id",
[param("id").isUUID().withMessage("유효한 설비 ID가 필요합니다"), validate],
async (req, res, next) => {
try {
const equipment = await equipmentService.findById(req.params.id);
if (!equipment) {
return res.status(404).json({ message: "설비를 찾을 수 없습니다" });
}
// company_admin은 자신의 회사 설비만 조회 가능
if (
req.user.role === "company_admin" &&
equipment.companyId !== req.user.companyId
) {
return res.status(403).json({
message: "다른 회사의 설비를 조회할 수 없습니다",
});
}
res.json(equipment);
} catch (error) {
next(error);
}
}
);
// 설비 수정
router.put(
"/:id",
[
param("id").isUUID().withMessage("유효한 설비 ID가 필요합니다"),
body("name")
.optional()
.notEmpty()
.isLength({ max: 100 })
.withMessage("설비명은 100자를 초과할 수 없습니다"),
body("model")
.optional()
.notEmpty()
.isLength({ max: 100 })
.withMessage("모델명은 100자를 초과할 수 없습니다"),
body("manufacturer")
.optional()
.notEmpty()
.isLength({ max: 100 })
.withMessage("제조사는 100자를 초과할 수 없습니다"),
body("type")
.optional()
.isIn(["HVAC", "Boiler", "Compressor", "Motor", "Pump", "Other"])
.withMessage("유효한 설비 유형이 아닙니다"),
body("specifications")
.optional()
.isObject()
.withMessage("사양 정보는 객체 형태여야 합니다"),
body("installationDate")
.optional()
.isISO8601()
.withMessage("유효한 날짜 형식이 아닙니다"),
body("zoneId")
.optional()
.isUUID()
.withMessage("유효한 구역 ID가 필요합니다"),
validate,
],
async (req, res, next) => {
try {
const equipment = await equipmentService.findById(req.params.id);
if (!equipment) {
return res.status(404).json({ message: "설비를 찾을 수 없습니다" });
}
// company_admin은 자신의 회사 설비만 수정 가능
if (
req.user.role === "company_admin" &&
equipment.companyId !== req.user.companyId
) {
return res.status(403).json({
message: "다른 회사의 설비를 수정할 수 없습니다",
});
}
const updatedEquipment = await equipmentService.updateEquipment(
req.params.id,
req.body,
req.user
);
res.json(updatedEquipment);
} catch (error) {
next(error);
}
}
);
// 설비 삭제
router.delete(
"/:id",
[param("id").isUUID().withMessage("유효한 설비 ID가 필요합니다"), validate],
async (req, res, next) => {
try {
const equipment = await equipmentService.findById(req.params.id);
if (!equipment) {
return res.status(404).json({ message: "설비를 찾을 수 없습니다" });
}
// company_admin은 자신의 회사 설비만 삭제 가능
if (
req.user.role === "company_admin" &&
equipment.companyId !== req.user.companyId
) {
return res.status(403).json({
message: "다른 회사의 설비를 삭제할 수 없습니다",
});
}
await equipmentService.deleteEquipment(req.params.id, req.user);
res.status(204).send();
} catch (error) {
next(error);
}
}
);
module.exports = router;

View File

@ -0,0 +1,287 @@
// controllers/app/file/file.controller.js
const express = require("express");
const router = express.Router();
const multer = require("multer");
const path = require("path");
const fs = require("fs").promises;
const { File } = require("../../../models");
const { v4: uuidv4 } = require("uuid");
const fileService = require("../../../services/file.service");
const authMiddleware = require("../../../middleware/auth.middleware");
const roleCheck = require("../../../middleware/roleCheck.middleware");
const { body, param, query } = require("express-validator");
const validate = require("../../../middleware/validator.middleware");
// multer 설정 수정
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
try {
await fs.mkdir("tmp/uploads", { recursive: true });
cb(null, "tmp/uploads");
} catch (error) {
cb(error);
}
},
filename: (req, file, cb) => {
try {
// 원본 파일명 디코딩
const decodedName = Buffer.from(file.originalname, "latin1").toString(
"utf8"
);
const ext = path.extname(decodedName);
// 파일명에서 확장자를 제외한 부분만 가져오기
const nameWithoutExt = path.basename(decodedName, ext);
// 파일명 정리 - 특수문자, 공백 등 처리
const sanitizedName = nameWithoutExt
.replace(/[^\w\s가-힣]/g, "") // 특수문자 제거 (한글, 영숫자, 공백만 허용)
.replace(/\s+/g, "_") // 공백을 언더스코어로 변경
.trim() // 앞뒤 공백 제거
.substring(0, 100); // 길이 제한
// UUID와 정리된 파일명 조합
const uniqueId = uuidv4();
const uniqueFilename = `${uniqueId}${
sanitizedName ? "_" + sanitizedName : ""
}${ext}`;
cb(null, uniqueFilename);
} catch (error) {
cb(new Error(`Error processing filename: ${error.message}`));
}
},
});
// 파일 필터 추가
const fileFilter = (req, file, cb) => {
// MIME 타입 검사
const allowedMimes = [
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"image/jpeg",
"image/png",
"image/gif",
"application/vnd.dwg",
"application/vnd.dxf",
];
if (allowedMimes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error("Invalid file type"), false);
}
};
// multer 미들웨어 설정
const upload = multer({
storage: storage,
fileFilter: fileFilter,
limits: {
fileSize: 10 * 1024 * 1024, // 10MB 제한
},
});
// 에러 핸들링 미들웨어
const uploadErrorHandler = (error, req, res, next) => {
if (error instanceof multer.MulterError) {
if (error.code === "LIMIT_FILE_SIZE") {
return res.status(400).json({ message: "파일 크기가 너무 큽니다." });
}
return res
.status(400)
.json({ message: "파일 업로드 중 오류가 발생했습니다." });
}
next(error);
};
router.use(authMiddleware);
router.use(roleCheck(["super_admin", "company_admin", "branch_admin", "user"]));
// 파일 업로드 라우트 수정
router.post(
"/upload",
upload.array("files"),
uploadErrorHandler,
[
body("moduleType").notEmpty().withMessage("모듈 타입은 필수입니다"),
body("category").notEmpty().withMessage("카테고리는 필수입니다"),
body("referenceId")
.optional()
.isUUID()
.withMessage("유효한 참조 ID가 필요합니다"),
body("metadata")
.optional()
.custom((value) => {
try {
// 이미 객체인 경우 그대로 반환
if (typeof value === "object" && value !== null) {
return true;
}
// 문자열인 경우 JSON 파싱 시도
JSON.parse(value);
return true;
} catch (e) {
throw new Error("메타데이터는 유효한 JSON 형식이어야 합니다");
}
}),
validate,
],
async (req, res, next) => {
try {
const { moduleType, category, referenceId } = req.body;
let metadata;
// metadata 파싱
try {
metadata =
typeof req.body.metadata === "string"
? JSON.parse(req.body.metadata)
: req.body.metadata;
} catch (error) {
return res.status(400).json({
message: "메타데이터 파싱 실패",
detail: error.message,
});
}
const files = req.files || [];
if (files.length === 0) {
return res.status(400).json({
message: "업로드할 파일이 없습니다",
});
}
// 파일 매핑 및 한글 처리
const processedFiles = files.map((file) => ({
...file,
originalname: Buffer.from(file.originalname, "latin1").toString("utf8"),
}));
// 모듈 타입과 카테고리 검증
if (!File.CATEGORIES[moduleType?.toUpperCase()]?.MODULE) {
return res.status(400).json({
message: "유효하지 않은 모듈 타입입니다",
});
}
if (
!Object.values(
File.CATEGORIES[moduleType.toUpperCase()].TYPES
).includes(category)
) {
return res.status(400).json({
message: "유효하지 않은 카테고리입니다",
});
}
const uploadedFiles = await Promise.all(
processedFiles.map((file) =>
fileService.saveFile(file, {
moduleType,
category,
referenceId,
metadata,
companyId: req.user.companyId,
user: req.user,
})
)
);
res.status(201).json(uploadedFiles);
} catch (error) {
next(error);
}
}
);
// 파일 다운로드
router.get(
"/download/:fileId",
[
param("fileId").isUUID().withMessage("유효한 파일 ID가 필요합니다"),
validate,
],
async (req, res, next) => {
try {
const file = await fileService.findById(req.params.fileId, req.user);
if (!file) {
return res.status(404).json({ message: "파일을 찾을 수 없습니다" });
}
const filePath = path.join(process.cwd(), "uploads", file.path);
res.download(filePath, file.originalName);
} catch (error) {
next(error);
}
}
);
// 파일 조회
router.get(
"/list",
[
query("moduleType").optional(),
query("category").optional(),
query("referenceId")
.optional()
.isUUID()
.withMessage("유효한 참조 ID가 필요합니다"),
validate,
],
async (req, res, next) => {
try {
const { moduleType, category, referenceId } = req.query;
const files = await fileService.findFiles({
moduleType,
category,
referenceId,
user: req.user,
});
res.json(files);
} catch (error) {
next(error);
}
}
);
// 단일 파일 조회
router.get(
"/:fileId",
[
param("fileId").isUUID().withMessage("유효한 파일 ID가 필요합니다"),
validate,
],
async (req, res, next) => {
try {
const file = await fileService.findById(req.params.fileId, req.user);
if (!file) {
return res.status(404).json({ message: "파일을 찾을 수 없습니다" });
}
res.json(file);
} catch (error) {
next(error);
}
}
);
// 파일 삭제
router.delete(
"/:fileId",
[
param("fileId").isUUID().withMessage("유효한 파일 ID가 필요합니다"),
validate,
],
async (req, res, next) => {
try {
await fileService.deleteFile(req.params.fileId, req.user);
res.status(204).send();
} catch (error) {
next(error);
}
}
);
module.exports = router;

View File

@ -0,0 +1,150 @@
// src/controllers/app/zone/zone.controller.js
const express = require("express");
const router = express.Router();
const { Branch } = require("../../../models");
const zoneService = require("../../../services/zone.service");
const authMiddleware = require("../../../middleware/auth.middleware");
const roleCheck = require("../../../middleware/roleCheck.middleware");
const { body, param } = require("express-validator");
const validate = require("../../../middleware/validator.middleware");
router.use(authMiddleware);
router.use(roleCheck(["super_admin", "company_admin", "branch_admin", "user"]));
// 구역 목록 조회
router.get("/", async (req, res, next) => {
try {
const zones = await zoneService.findAll(req.user);
res.json(zones);
} catch (error) {
next(error);
}
});
// 구역 생성
router.post(
"/",
[
body("name")
.notEmpty()
.withMessage("구역명은 필수입니다")
.isLength({ max: 100 })
.withMessage("구역명은 100자를 초과할 수 없습니다"),
body("description")
.optional()
.isLength({ max: 1000 })
.withMessage("설명은 1000자를 초과할 수 없습니다"),
body("area")
.optional()
.isFloat({ min: 0 })
.withMessage("면적은 0보다 큰 숫자여야 합니다"),
body("branchId")
.notEmpty()
.withMessage("지점 ID는 필수입니다")
.isUUID()
.withMessage("유효한 지점 ID가 필요합니다"),
validate,
],
async (req, res, next) => {
try {
const zoneData = req.body;
// company_admin은 자신의 회사에 속한 지점의 구역만 생성 가능
if (req.user.role === "company_admin") {
const branch = await Branch.findByPk(zoneData.branchId);
if (branch.companyId !== req.user.companyId) {
return res.status(403).json({
message: "다른 회사의 지점에 구역을 생성할 수 없습니다",
});
}
}
const zone = await zoneService.createZone(zoneData, req.user);
res.status(201).json(zone);
} catch (error) {
next(error);
}
}
);
// 구역 수정
router.put(
"/:id",
[
param("id").isUUID().withMessage("유효한 구역 ID가 필요합니다"),
body("name")
.optional()
.notEmpty()
.isLength({ max: 100 })
.withMessage("구역명은 100자를 초과할 수 없습니다"),
body("description")
.optional()
.isLength({ max: 1000 })
.withMessage("설명은 1000자를 초과할 수 없습니다"),
body("area")
.optional()
.isFloat({ min: 0 })
.withMessage("면적은 0보다 큰 숫자여야 합니다"),
validate,
],
async (req, res, next) => {
try {
const zone = await zoneService.findById(req.params.id);
if (!zone) {
return res.status(404).json({ message: "구역을 찾을 수 없습니다" });
}
// company_admin은 자신의 회사의 지점에 속한 구역만 수정 가능
if (req.user.role === "company_admin") {
const branch = await Branch.findByPk(zone.branchId);
if (branch.companyId !== req.user.companyId) {
return res.status(403).json({
message: "다른 회사의 구역을 수정할 수 없습니다",
});
}
}
const updatedZone = await zoneService.updateZone(
req.params.id,
req.body,
req.user
);
res.json(updatedZone);
} catch (error) {
next(error);
}
}
);
// 구역 삭제
router.delete(
"/:id",
[param("id").isUUID().withMessage("유효한 구역 ID가 필요합니다"), validate],
async (req, res, next) => {
try {
const zone = await zoneService.findById(req.params.id);
if (!zone) {
return res.status(404).json({ message: "구역을 찾을 수 없습니다" });
}
// company_admin은 자신의 회사의 지점에 속한 구역만 삭제 가능
if (req.user.role === "company_admin") {
const branch = await Branch.findByPk(zone.branchId);
if (branch.companyId !== req.user.companyId) {
return res.status(403).json({
message: "다른 회사의 구역을 삭제할 수 없습니다",
});
}
}
await zoneService.deleteZone(req.params.id, req.user);
res.status(204).send();
} catch (error) {
next(error);
}
}
);
module.exports = router;

111
fems-api/src/models/File.js Normal file
View File

@ -0,0 +1,111 @@
// models/File.js
const { Model, DataTypes } = require("sequelize");
class File extends Model {
static init(sequelize) {
super.init(
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
originalName: {
type: DataTypes.STRING(1000), // 길이 증가
allowNull: false,
},
filename: {
type: DataTypes.STRING(1000), // 길이 증가
allowNull: false,
},
path: {
type: DataTypes.STRING(1000), // 길이 증가
allowNull: false,
},
size: {
type: DataTypes.INTEGER,
allowNull: false,
comment: "파일 크기 (bytes)",
},
mimetype: {
type: DataTypes.STRING(100),
allowNull: false,
comment: "파일 MIME 타입",
},
moduleType: {
type: DataTypes.STRING(50),
allowNull: false,
comment: "모듈 타입 (equipment, maintenance 등)",
},
category: {
type: DataTypes.STRING(50),
allowNull: false,
comment: "파일 카테고리 (manual, technical, certificate 등)",
},
referenceId: {
type: DataTypes.UUID,
allowNull: true,
comment: "참조 ID (해당 모듈의 데이터 ID)",
},
companyId: {
type: DataTypes.UUID,
allowNull: false,
comment: "회사 ID",
},
uploadedBy: {
type: DataTypes.UUID,
allowNull: false,
comment: "업로드 사용자 ID",
},
status: {
type: DataTypes.ENUM("active", "deleted"),
defaultValue: "active",
comment: "파일 상태",
},
metadata: {
type: DataTypes.JSONB,
allowNull: true,
comment: "추가 메타데이터 (버전, 언어, 설명 등)",
},
},
{
sequelize,
modelName: "File",
tableName: "files",
timestamps: true,
}
);
return this;
}
static associate(models) {
this.belongsTo(models.Company, { foreignKey: "companyId" });
this.belongsTo(models.User, {
foreignKey: "uploadedBy",
as: "uploader",
});
}
}
// 파일 카테고리 상수 정의
File.CATEGORIES = {
EQUIPMENT: {
MODULE: "equipment",
TYPES: {
MANUAL: "manual",
TECHNICAL: "technical",
CERTIFICATE: "certificate",
DRAWING: "drawing",
},
},
MAINTENANCE: {
MODULE: "maintenance",
TYPES: {
REPORT: "report",
CHECKLIST: "checklist",
PHOTO: "photo",
},
},
// 필요한 만큼 모듈과 카테고리 추가 가능
};
module.exports = File;

View File

@ -2,14 +2,18 @@
const express = require("express");
const router = express.Router();
// Import app controllers
const authController = require("../controllers/app/auth/auth.controller");
const usersController = require("../controllers/app/users/users.controller");
const dashboardController = require("../controllers/app/dashboard/dashboard.controller"); // 추가
const dashboardController = require("../controllers/app/dashboard/dashboard.controller");
const equipmentController = require("../controllers/app/equipment/equipment.controller");
const zoneController = require("../controllers/app/zone/zone.controller");
const fileController = require("../controllers/app/file/file.controller"); // 추가
// Mount app routes
router.use("/auth", authController);
router.use("/users", usersController);
router.use("/dashboard", dashboardController); // 추가
router.use("/dashboard", dashboardController);
router.use("/equipment", equipmentController);
router.use("/zone", zoneController);
router.use("/files", fileController); // 추가
module.exports = router;

View File

@ -0,0 +1,127 @@
// src/services/equipment.service.js
const {
Equipment,
Company,
Branch,
Zone,
MaintenanceLog,
} = require("../models");
const alertService = require("./alert.service");
class EquipmentService {
async findAll(currentUser) {
// Initialize the where clause
let where = {};
// company_admin은 자신의 회사 설비만 조회 가능
if (currentUser.role === "company_admin") {
where.companyId = currentUser.companyId;
}
const equipment = await Equipment.findAll({
where,
include: [
{
model: Company,
attributes: ["id", "name"],
},
{
model: Branch,
attributes: ["id", "name"],
},
{
model: Zone,
attributes: ["id", "name"],
},
{
model: MaintenanceLog,
limit: 1,
order: [["completionDate", "DESC"]],
attributes: ["completionDate"],
},
],
});
return equipment;
}
async findById(id) {
return await Equipment.findByPk(id, {
include: [
{
model: Company,
attributes: ["id", "name"],
},
{
model: Branch,
attributes: ["id", "name"],
},
{
model: Zone,
attributes: ["id", "name"],
},
],
});
}
async createEquipment(equipmentData, currentUser) {
const equipment = await Equipment.create({
...equipmentData,
companyId:
currentUser.role === "company_admin"
? currentUser.companyId
: equipmentData.companyId,
});
await alertService.createAlert({
type: "info",
message: `설비 ${equipment.name}이(가) ${currentUser.name}에 의해 등록되었습니다.`,
companyId: equipment.companyId,
});
return equipment;
}
async updateEquipment(id, updateData, currentUser) {
const equipment = await Equipment.findByPk(id);
if (!equipment) throw new Error("Equipment not found");
// company_admin은 자신의 회사 설비만 수정 가능
if (currentUser.role === "company_admin") {
if (equipment.companyId !== currentUser.companyId) {
throw new Error("Cannot modify equipment from different company");
}
updateData.companyId = currentUser.companyId;
}
const updatedEquipment = await equipment.update(updateData);
await alertService.createAlert({
type: "info",
message: `설비 ${equipment.name}이(가) ${currentUser.name}에 의해 수정되었습니다.`,
companyId: equipment.companyId,
});
return updatedEquipment;
}
async deleteEquipment(id, currentUser) {
const equipment = await Equipment.findByPk(id);
if (!equipment) throw new Error("Equipment not found");
const equipmentName = equipment.name;
const companyId = equipment.companyId;
await equipment.destroy();
await alertService.createAlert({
type: "info",
message: `설비 ${equipmentName}이(가) ${currentUser.name}에 의해 삭제되었습니다.`,
companyId: companyId,
});
return true;
}
}
module.exports = new EquipmentService();

View File

@ -0,0 +1,289 @@
// app/service/file.service.js
const path = require("path");
const fs = require("fs").promises;
// const crypto = require("crypto");
const { File, User } = require("../models");
const logger = require("../config/logger");
const { v4: uuidv4 } = require("uuid");
class FileService {
constructor() {
// process.cwd()는 현재 작업 디렉토리를 반환하므로,
// 직접 경로를 지정하거나 상대 경로를 사용하는 것이 좋습니다.
this.uploadDir = path.join(process.cwd(), "uploads"); // 또는
// this.uploadDir = "./uploads"; // 상대 경로 사용
// this.uploadDir = path.resolve(__dirname, "../../uploads"); // 절대 경로 사용
// 경로 로깅 추가
logger.info(`Upload directory initialized: ${this.uploadDir}`);
this.ensureBaseUploadDir();
}
async validateFile(file) {
if (!file || !file.path || !file.originalname) {
throw new Error("Invalid file data");
}
const maxSize = 10 * 1024 * 1024; // 10MB
if (file.size > maxSize) {
throw new Error("File size exceeds limit");
}
const allowedMimes = [
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"image/jpeg",
"image/png",
"image/gif",
"application/vnd.dwg",
"application/vnd.dxf",
];
if (!allowedMimes.includes(file.mimetype)) {
throw new Error("File type not allowed");
}
}
// 안전한 파일명 생성
// FileService의 sanitizeFileName 메서드도 동일하게 수정
sanitizeFileName(fileName) {
try {
const ext = path.extname(fileName);
const nameWithoutExt = path.basename(fileName, ext);
const sanitizedName = nameWithoutExt
.replace(/[^\w\s가-힣]/g, "") // 특수문자 제거 (한글, 영숫자, 공백만 허용)
.replace(/\s+/g, "_") // 공백을 언더스코어로 변경
.trim() // 앞뒤 공백 제거
.substring(0, 100); // 길이 제한
const uniqueId = uuidv4();
return `${uniqueId}${sanitizedName ? "_" + sanitizedName : ""}${ext}`;
} catch (error) {
throw new Error(`Error sanitizing filename: ${error.message}`);
}
}
async getFileStream(fileId, user) {
const file = await this.findById(fileId, user);
if (!file) throw new Error("File not found");
const filePath = path.join(this.uploadDir, file.path);
try {
await fs.access(filePath);
return fs.createReadStream(filePath);
} catch (error) {
logger.error(`Failed to access file: ${filePath}`, error);
throw new Error("File not accessible");
}
}
// 경로 생성 로직 수정
async ensureUploadPath(companyId, moduleType, category) {
// 전체 경로를 명시적으로 구성
const fullPath = path.join(
this.uploadDir,
companyId,
moduleType.toLowerCase(),
category.toLowerCase()
);
try {
await fs.mkdir(fullPath, { recursive: true });
logger.info(`Created upload path: ${fullPath}`);
return fullPath;
} catch (error) {
logger.error(`Failed to create upload path: ${fullPath}`, error);
throw error;
}
}
// 기존 코드와 같이 유지
async ensureBaseUploadDir() {
try {
await fs.access(this.uploadDir);
logger.info(`Access to upload directory confirmed: ${this.uploadDir}`);
} catch {
await fs.mkdir(this.uploadDir, { recursive: true });
logger.info(`Base upload directory created: ${this.uploadDir}`);
}
}
async findFiles({ moduleType, category, referenceId, user }) {
const where = {
status: "active",
};
if (user.role !== "super_admin") {
where.companyId = user.companyId;
}
if (moduleType) where.moduleType = moduleType;
if (category) where.category = category;
if (referenceId) where.referenceId = referenceId;
return await File.findAll({
where,
include: [
{
model: User,
as: "uploader",
attributes: ["id", "name"],
},
],
order: [["createdAt", "DESC"]],
});
}
generateFileName(originalName) {
return this.sanitizeFileName(originalName);
}
async saveFile(
file,
{ moduleType, category, referenceId, metadata, companyId, user }
) {
try {
if (!file || !file.path || !file.originalname) {
throw new Error("Invalid file data");
}
const uploadDir = await this.ensureUploadPath(
companyId,
moduleType,
category
);
try {
const originalName = file.originalname;
const safeFileName = this.sanitizeFileName(originalName);
const filePath = path.join(uploadDir, safeFileName);
await this.validateFile(file);
// 파일 이동
await fs.copyFile(file.path, filePath);
await fs.unlink(file.path).catch(() => {});
// DB에 저장될 상대 경로를 /uploads로 시작하도록 구성
const relativePath = [
"",
"uploads",
companyId,
moduleType.toLowerCase(),
category.toLowerCase(),
safeFileName,
]
.join("/")
.replace(/\\/g, "/");
logger.info("File path details:", {
uploadDir: this.uploadDir,
filePath,
relativePath,
originalName,
safeFileName,
});
const fileRecord = await File.create({
originalName: originalName,
filename: safeFileName,
path: relativePath,
size: file.size,
mimetype: file.mimetype,
moduleType,
category,
referenceId,
metadata: {
...metadata,
uploadedAt: new Date(),
uploadedBy: user.name,
},
companyId,
uploadedBy: user.id,
status: "active",
});
return {
...fileRecord.toJSON(),
url: `/api/v1/app/files/download/${fileRecord.id}`,
};
} catch (error) {
logger.error("File processing error:", error);
throw new Error("Failed to process file");
}
} catch (error) {
if (file.path) {
await fs.unlink(file.path).catch(() => {});
}
throw error;
}
}
async findById(fileId, user) {
const where = {
id: fileId,
status: "active",
};
if (user.role !== "super_admin") {
where.companyId = user.companyId;
}
const file = await File.findOne({
where,
include: [
{
model: User,
as: "uploader",
attributes: ["id", "name"],
},
],
});
if (file && !file.path.startsWith("/uploads")) {
// path가 /uploads로 시작하지 않는 경우에만 수정
file.path = "/uploads" + file.path.replace(/^uploads\/?/, "");
}
return file;
}
async deleteFile(fileId, user) {
const file = await File.findByPk(fileId);
if (!file) throw new Error("File not found");
if (user.role !== "super_admin" && file.companyId !== user.companyId) {
throw new Error("Unauthorized access to file");
}
try {
// 실제 파일 시스템 경로로 변환 (앞의 /uploads 제거)
const filePath = path.join(
process.cwd(),
file.path.replace(/^\/uploads/, "uploads")
);
try {
await fs.unlink(filePath);
} catch (unlinkError) {
logger.error(`Failed to delete file: ${filePath}`, unlinkError);
}
await file.update({ status: "deleted" });
return true;
} catch (error) {
logger.error("File deletion failed:", error);
throw error;
}
}
// download 처리를 위한 경로 변환 메서드 추가
getPhysicalPath(dbPath) {
return path.join(process.cwd(), dbPath.replace(/^\/uploads/, "uploads"));
}
}
module.exports = new FileService();

View File

@ -0,0 +1,93 @@
// src/services/zone.service.js
const { Zone, Branch, Equipment } = require("../models");
const alertService = require("./alert.service");
class ZoneService {
async findAll(currentUser) {
// Initialize the where clause for Branch
let branchWhere = {};
// company_admin은 자신의 회사의 지점에 속한 구역만 조회 가능
if (currentUser.role === "company_admin") {
branchWhere.companyId = currentUser.companyId;
}
const zones = await Zone.findAll({
include: [
{
model: Branch,
where: branchWhere,
attributes: ["id", "name", "companyId"],
},
{
model: Equipment,
attributes: ["id", "name"],
},
],
});
return zones;
}
async findById(id) {
return await Zone.findByPk(id, {
include: [
{
model: Branch,
attributes: ["id", "name", "companyId"],
},
{
model: Equipment,
attributes: ["id", "name"],
},
],
});
}
async createZone(zoneData, currentUser) {
const zone = await Zone.create(zoneData);
await alertService.createAlert({
type: "info",
message: `구역 ${zone.name}이(가) ${currentUser.name}에 의해 생성되었습니다.`,
companyId: (await Branch.findByPk(zone.branchId)).companyId,
});
return zone;
}
async updateZone(id, updateData, currentUser) {
const zone = await Zone.findByPk(id);
if (!zone) throw new Error("Zone not found");
const updatedZone = await zone.update(updateData);
await alertService.createAlert({
type: "info",
message: `구역 ${zone.name}이(가) ${currentUser.name}에 의해 수정되었습니다.`,
companyId: (await Branch.findByPk(zone.branchId)).companyId,
});
return updatedZone;
}
async deleteZone(id, currentUser) {
const zone = await Zone.findByPk(id);
if (!zone) throw new Error("Zone not found");
const zoneName = zone.name;
const branch = await Branch.findByPk(zone.branchId);
await zone.destroy();
await alertService.createAlert({
type: "info",
message: `구역 ${zoneName}이(가) ${currentUser.name}에 의해 삭제되었습니다.`,
companyId: branch.companyId,
});
return true;
}
}
module.exports = new ZoneService();

View File

@ -16,6 +16,8 @@ async function createInitialAdmin() {
where: { businessNumber: "000-00-00000" },
});
let adminBranch; // Branch 변수 선언
if (!adminCompany) {
adminCompany = await Company.create({
name: "FEMS 관리자",
@ -33,7 +35,7 @@ async function createInitialAdmin() {
logger.info("Admin company created successfully");
// 본사 지점 생성
await Branch.create({
adminBranch = await Branch.create({
name: "본사",
address: "서울시 강남구",
tel: "02-0000-0000",
@ -41,21 +43,25 @@ async function createInitialAdmin() {
companyId: adminCompany.id,
});
logger.info("Admin company headquarters branch created successfully");
} else {
// 기존 회사의 지점 조회
adminBranch = await Branch.findOne({
where: { companyId: adminCompany.id },
});
}
const branch = await Branch.findOne({
where: { companyId: adminCompany.id },
});
// 2. 개발 환경에서만 초기 데이터 생성
if (process.env.NODE_ENV === "development") {
// 조직 구조 설정
const departments = await createDepartments(adminCompany.id, branch.id);
const departments = await createDepartments(
adminCompany.id,
adminBranch.id
);
const roles = await createRoles(adminCompany.id);
await createAllUsers(adminCompany.id, branch.id, departments, roles);
await createAllUsers(adminCompany.id, adminBranch.id, departments, roles);
// 초기 데이터 생성
await createInitialData(adminCompany.id);
// 초기 데이터 생성 - branch.id 전달
await createInitialData(adminCompany.id, adminBranch.id);
// 생성된 계정 정보 출력
logCreatedAccounts();

View File

@ -1,122 +1,232 @@
// src/utils/initialSetup/dataSetup.js
const {
EnergyUsage,
EnergyCost,
Alert,
Kpi,
} = require("../../models");
const logger = require("../../config/logger");
async function createInitialData(companyId) {
try {
await createEnergyUsageData(companyId);
await createEnergyCostData(companyId);
await createAlertData(companyId);
await createKpiData(companyId);
logger.info("Initial development data created successfully");
} catch (error) {
logger.error("Error creating initial data:", error);
throw error;
}
Zone,
Equipment,
EquipmentData,
MaintenanceLog,
EnergyUsage,
EnergyCost,
Alert,
Kpi,
} = require("../../models");
const {
zoneDefinitions,
equipmentDefinitions,
maintenanceLogDefinitions,
equipmentDataTemplate,
} = require("./setupData");
const logger = require("../../config/logger");
async function createInitialData(companyId, branchId) {
try {
// 1. 구역 및 설비 관련 데이터 생성
await createZoneAndEquipmentData(companyId, branchId);
// 2. 에너지 관련 데이터 생성
await createEnergyUsageData(companyId);
await createEnergyCostData(companyId);
await createAlertData(companyId);
await createKpiData(companyId);
logger.info("Initial development data created successfully");
} catch (error) {
logger.error("Error creating initial data:", error);
throw error;
}
async function createEnergyUsageData(companyId) {
const now = new Date();
for (let i = 0; i < 6; i++) {
const date = new Date(now);
date.setMonth(date.getMonth() - i);
await EnergyUsage.create({
companyId,
timestamp: date,
electricity: 1000 + Math.random() * 500,
gas: 700 + Math.random() * 300,
water: 400 + Math.random() * 200,
steam: 250 + Math.random() * 150,
});
}
logger.info("Energy usage data created successfully");
}
async function createEnergyCostData(companyId) {
const now = new Date();
const categories = ["electricity", "gas", "water", "steam"];
const currentMonth = now.getMonth() + 1;
const currentYear = now.getFullYear();
for (const category of categories) {
const amount = Math.floor(1000000 + Math.random() * 5000000);
await EnergyCost.create({
companyId,
year: currentYear,
month: currentMonth,
category,
amount,
usage: amount / 100,
unitPrice: 100,
});
await EnergyCost.create({
companyId,
year: currentMonth === 1 ? currentYear - 1 : currentYear,
month: currentMonth === 1 ? 12 : currentMonth - 1,
category,
amount: Math.floor(amount * (0.9 + Math.random() * 0.2)),
usage: amount / 100,
unitPrice: 100,
});
}
logger.info("Energy cost data created successfully");
}
async function createAlertData(companyId) {
const alertTypes = ["error", "warning", "info"];
const alertMessages = [
"전력 사용량이 임계치를 초과했습니다.",
"가스 사용량이 전월 대비 20% 증가했습니다.",
"에너지 사용 보고서가 생성되었습니다.",
];
for (let i = 0; i < alertTypes.length; i++) {
await Alert.create({
companyId,
type: alertTypes[i],
message: alertMessages[i],
});
}
logger.info("Alert data created successfully");
}
async function createKpiData(companyId) {
const now = new Date();
const categories = ["electricity", "gas", "water", "steam"];
for (let i = 0; i < 30; i++) {
const date = new Date(now);
date.setDate(date.getDate() - i);
for (const category of categories) {
const baselineUsage = 1000 + Math.random() * 500;
const targetUsage = baselineUsage * 0.9;
const currentUsage = baselineUsage * (0.8 + Math.random() * 0.4);
const efficiency = (targetUsage / currentUsage) * 100;
const savings = ((baselineUsage - currentUsage) / baselineUsage) * 100;
await Kpi.create({
}
async function createZoneAndEquipmentData(companyId, branchId) {
try {
// 1. 구역 생성
const zones = await Promise.all(
zoneDefinitions.map((zone) =>
Zone.create({
...zone,
branchId,
})
)
);
const zoneMap = zones.reduce((acc, zone) => {
acc[zone.name] = zone;
return acc;
}, {});
// 2. 설비 생성
const equipment = await Promise.all(
equipmentDefinitions.map((equip) =>
Equipment.create({
...equip,
companyId,
category,
timestamp: date,
currentUsage,
targetUsage,
baselineUsage,
efficiency,
savings,
});
}
branchId,
zoneId: zoneMap[equip.zone].id,
installationDate: new Date(),
isActive: true,
})
)
);
// 3. 정비 이력 생성
for (const equip of equipment) {
const now = new Date();
await Promise.all(
maintenanceLogDefinitions.map((log, index) => {
const scheduledDate = new Date(now);
scheduledDate.setDate(scheduledDate.getDate() - (index + 1) * 30);
const completionDate = new Date(scheduledDate);
completionDate.setHours(completionDate.getHours() + 4);
return MaintenanceLog.create({
...log,
equipmentId: equip.id,
scheduledDate,
completionDate,
nextMaintenanceDate: new Date(
scheduledDate.setMonth(scheduledDate.getMonth() + 1)
),
});
})
);
}
logger.info("KPI data created successfully");
// 4. 설비 데이터 생성 (최근 24시간)
const now = new Date();
const hourlyData = Array.from({ length: 24 }, (_, i) => {
const timestamp = new Date(now);
timestamp.setHours(timestamp.getHours() - i);
return timestamp;
});
for (const equip of equipment) {
const template = equipmentDataTemplate[equip.type];
if (!template) continue;
await Promise.all(
hourlyData.map((timestamp) => {
const measurements = Object.entries(template.measurements).reduce(
(acc, [key, value]) => {
const variation = value * 0.1;
acc[key] = value + (Math.random() - 0.5) * variation;
return acc;
},
{}
);
return EquipmentData.create({
equipmentId: equip.id,
timestamp,
parameters: template.parameters,
measurements,
});
})
);
}
logger.info("Zone and equipment data created successfully");
} catch (error) {
logger.error("Error creating zone and equipment data:", error);
throw error;
}
module.exports = {
createInitialData,
};
}
async function createEnergyUsageData(companyId) {
const now = new Date();
for (let i = 0; i < 6; i++) {
const date = new Date(now);
date.setMonth(date.getMonth() - i);
await EnergyUsage.create({
companyId,
timestamp: date,
electricity: 1000 + Math.random() * 500,
gas: 700 + Math.random() * 300,
water: 400 + Math.random() * 200,
steam: 250 + Math.random() * 150,
});
}
logger.info("Energy usage data created successfully");
}
async function createEnergyCostData(companyId) {
const now = new Date();
const categories = ["electricity", "gas", "water", "steam"];
const currentMonth = now.getMonth() + 1;
const currentYear = now.getFullYear();
for (const category of categories) {
const amount = Math.floor(1000000 + Math.random() * 5000000);
await EnergyCost.create({
companyId,
year: currentYear,
month: currentMonth,
category,
amount,
usage: amount / 100,
unitPrice: 100,
});
await EnergyCost.create({
companyId,
year: currentMonth === 1 ? currentYear - 1 : currentYear,
month: currentMonth === 1 ? 12 : currentMonth - 1,
category,
amount: Math.floor(amount * (0.9 + Math.random() * 0.2)),
usage: amount / 100,
unitPrice: 100,
});
}
logger.info("Energy cost data created successfully");
}
async function createAlertData(companyId) {
const alertTypes = ["error", "warning", "info"];
const alertMessages = [
"전력 사용량이 임계치를 초과했습니다.",
"가스 사용량이 전월 대비 20% 증가했습니다.",
"에너지 사용 보고서가 생성되었습니다.",
];
for (let i = 0; i < alertTypes.length; i++) {
await Alert.create({
companyId,
type: alertTypes[i],
message: alertMessages[i],
});
}
logger.info("Alert data created successfully");
}
async function createKpiData(companyId) {
const now = new Date();
const categories = ["electricity", "gas", "water", "steam"];
for (let i = 0; i < 30; i++) {
const date = new Date(now);
date.setDate(date.getDate() - i);
for (const category of categories) {
const baselineUsage = 1000 + Math.random() * 500;
const targetUsage = baselineUsage * 0.9;
const currentUsage = baselineUsage * (0.8 + Math.random() * 0.4);
const efficiency = (targetUsage / currentUsage) * 100;
const savings = ((baselineUsage - currentUsage) / baselineUsage) * 100;
await Kpi.create({
companyId,
category,
timestamp: date,
currentUsage,
targetUsage,
baselineUsage,
efficiency,
savings,
});
}
}
logger.info("KPI data created successfully");
}
module.exports = {
createInitialData,
};

View File

@ -147,8 +147,144 @@ const userDefinitions = {
],
};
const zoneDefinitions = [
{
name: "생산1구역",
description: "1층 생산구역",
area: 500,
},
{
name: "생산2구역",
description: "2층 생산구역",
area: 500,
},
{
name: "유틸리티구역",
description: "지하1층 유틸리티실",
area: 200,
},
{
name: "공조기계실",
description: "옥상 기계실",
area: 100,
},
];
const equipmentDefinitions = [
{
name: "공조기-1",
model: "HAV-1000",
manufacturer: "삼성전자",
type: "HVAC",
specifications: {
power: "30kW",
airflow: "1000CMH",
coolingCapacity: "100RT",
heatingCapacity: "200,000kcal/h",
},
zone: "공조기계실",
},
{
name: "보일러-1",
model: "BST-2000",
manufacturer: "경동나비엔",
type: "Boiler",
specifications: {
power: "50kW",
capacity: "2000kg/h",
pressure: "5kg/cm²",
efficiency: "95%",
},
zone: "유틸리티구역",
},
{
name: "컴프레서-1",
model: "ACP-500",
manufacturer: "아틀라스콥코",
type: "Compressor",
specifications: {
power: "75kW",
capacity: "500CFM",
pressure: "8bar",
efficiency: "92%",
},
zone: "유틸리티구역",
},
];
const maintenanceLogDefinitions = [
{
type: "preventive",
status: "completed",
description: "정기 점검 및 필터 교체",
findings: "필터 오염도 증가",
actions: "필터 교체 완료",
parts: {
filter: { name: "프리필터", quantity: 2, cost: 50000 },
belt: { name: "V-벨트", quantity: 1, cost: 30000 },
},
cost: 80000,
equipmentStatus: "operational",
},
{
type: "inspection",
status: "completed",
description: "월간 성능 검사",
findings: "정상 작동 확인",
actions: "특이사항 없음",
cost: 0,
equipmentStatus: "operational",
},
];
const equipmentDataTemplate = {
HVAC: {
parameters: {
temperature: { min: 18, max: 26 },
humidity: { min: 40, max: 60 },
airflow: { min: 800, max: 1200 },
},
measurements: {
temperature: 22,
humidity: 50,
airflow: 1000,
power: 25,
},
},
Boiler: {
parameters: {
pressure: { min: 4, max: 6 },
temperature: { min: 70, max: 90 },
waterLevel: { min: 60, max: 80 },
},
measurements: {
pressure: 5,
temperature: 80,
waterLevel: 70,
power: 45,
},
},
Compressor: {
parameters: {
pressure: { min: 7, max: 9 },
temperature: { min: 40, max: 60 },
airflow: { min: 450, max: 550 },
},
measurements: {
pressure: 8,
temperature: 50,
airflow: 500,
power: 70,
},
},
};
module.exports = {
departmentStructure,
roleDefinitions,
userDefinitions,
zoneDefinitions,
equipmentDefinitions,
maintenanceLogDefinitions,
equipmentDataTemplate,
};

View File

@ -795,6 +795,11 @@ anymatch@^3.0.3, anymatch@~3.1.2:
normalize-path "^3.0.0"
picomatch "^2.0.4"
append-field@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/append-field/-/append-field-1.0.0.tgz#1e3440e915f0b1203d23748e78edd7b9b5b43e56"
integrity sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==
argparse@^1.0.7:
version "1.0.10"
resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911"
@ -978,6 +983,13 @@ buffer@^6.0.3:
base64-js "^1.3.1"
ieee754 "^1.2.1"
busboy@^1.0.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893"
integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==
dependencies:
streamsearch "^1.1.0"
bytes@3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
@ -1136,6 +1148,16 @@ concat-map@0.0.1:
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
concat-stream@^1.5.2:
version "1.6.2"
resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34"
integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==
dependencies:
buffer-from "^1.0.0"
inherits "^2.0.3"
readable-stream "^2.2.2"
typedarray "^0.0.6"
content-disposition@0.5.4:
version "0.5.4"
resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe"
@ -1168,6 +1190,11 @@ cookiejar@^2.1.4:
resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.4.tgz#ee669c1fea2cf42dc31585469d193fef0d65771b"
integrity sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==
core-util-is@~1.0.0:
version "1.0.3"
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85"
integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==
cors@^2.8.5:
version "2.8.5"
resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29"
@ -1198,6 +1225,11 @@ cross-spawn@^7.0.2, cross-spawn@^7.0.3:
shebang-command "^2.0.0"
which "^2.0.1"
crypto@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/crypto/-/crypto-1.0.1.tgz#2af1b7cad8175d24c8a1b0778255794a21803037"
integrity sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==
debug@2.6.9:
version "2.6.9"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
@ -1907,7 +1939,7 @@ inflight@^1.0.4:
once "^1.3.0"
wrappy "1"
inherits@2, inherits@2.0.4, inherits@^2.0.3:
inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@~2.0.3:
version "2.0.4"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@ -1978,6 +2010,11 @@ is-stream@^2.0.0:
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077"
integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==
isarray@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==
isexe@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
@ -2666,6 +2703,18 @@ minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
dependencies:
brace-expansion "^1.1.7"
minimist@^1.2.6:
version "1.2.8"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
mkdirp@^0.5.4:
version "0.5.6"
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6"
integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==
dependencies:
minimist "^1.2.6"
moment-timezone@^0.5.43:
version "0.5.46"
resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.46.tgz#a21aa6392b3c6b3ed916cd5e95858a28d893704a"
@ -2688,6 +2737,19 @@ ms@2.1.3, ms@^2.1.1, ms@^2.1.3:
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
multer@^1.4.5-lts.1:
version "1.4.5-lts.1"
resolved "https://registry.yarnpkg.com/multer/-/multer-1.4.5-lts.1.tgz#803e24ad1984f58edffbc79f56e305aec5cfd1ac"
integrity sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==
dependencies:
append-field "^1.0.0"
busboy "^1.0.0"
concat-stream "^1.5.2"
mkdirp "^0.5.4"
object-assign "^4.1.1"
type-is "^1.6.4"
xtend "^4.0.0"
natural-compare@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
@ -2736,7 +2798,7 @@ npm-run-path@^4.0.1:
dependencies:
path-key "^3.0.0"
object-assign@^4:
object-assign@^4, object-assign@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
@ -2987,6 +3049,11 @@ pretty-format@^29.7.0:
ansi-styles "^5.0.0"
react-is "^18.0.0"
process-nextick-args@~2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==
process@^0.11.10:
version "0.11.10"
resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
@ -3055,6 +3122,19 @@ react-is@^18.0.0:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e"
integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==
readable-stream@^2.2.2:
version "2.3.8"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b"
integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==
dependencies:
core-util-is "~1.0.0"
inherits "~2.0.3"
isarray "~1.0.0"
process-nextick-args "~2.0.0"
safe-buffer "~5.1.1"
string_decoder "~1.1.1"
util-deprecate "~1.0.1"
readable-stream@^3.4.0:
version "3.6.2"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967"
@ -3147,6 +3227,11 @@ safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@~5.2.0:
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
safe-buffer@~5.1.0, safe-buffer@~5.1.1:
version "5.1.2"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
safe-stable-stringify@^2.3.1:
version "2.5.0"
resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz#4ca2f8e385f2831c432a719b108a3bf7af42a1dd"
@ -3341,6 +3426,11 @@ statuses@2.0.1:
resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63"
integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==
streamsearch@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764"
integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==
string-length@^4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a"
@ -3365,6 +3455,13 @@ string_decoder@^1.1.1, string_decoder@^1.3.0:
dependencies:
safe-buffer "~5.2.0"
string_decoder@~1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8"
integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==
dependencies:
safe-buffer "~5.1.0"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
@ -3510,7 +3607,7 @@ type-fest@^0.21.3:
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37"
integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==
type-is@~1.6.18:
type-is@^1.6.4, type-is@~1.6.18:
version "1.6.18"
resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==
@ -3518,6 +3615,11 @@ type-is@~1.6.18:
media-typer "0.3.0"
mime-types "~2.1.24"
typedarray@^0.0.6:
version "0.0.6"
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==
undefsafe@^2.0.5:
version "2.0.5"
resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.5.tgz#38733b9327bdcd226db889fb723a6efd162e6e2c"
@ -3553,7 +3655,7 @@ uri-js@^4.2.2:
dependencies:
punycode "^2.1.0"
util-deprecate@^1.0.1:
util-deprecate@^1.0.1, util-deprecate@~1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==

View File

@ -1,4 +1,10 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
const nextConfig = {
reactStrictMode: true,
experimental: {
optimizeCss: true,
scrollRestoration: true,
},
};
export default nextConfig;

View File

@ -25,6 +25,7 @@
"@tanstack/react-query-devtools": "^5.59.16",
"@tanstack/react-table": "^8.20.5",
"@types/js-cookie": "^3.0.6",
"@types/react-dropzone": "^5.1.0",
"axios": "^1.7.7",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",

View File

@ -128,7 +128,7 @@ const AccountsPage = () => {
const columns: ColumnDef<User>[] = [
{
accessorKey: "username",
header: "유저네임",
header: "아이디",
},
{
accessorKey: "name",

View File

@ -0,0 +1,421 @@
// src/app/(equipment)/inventory/components/EquipmentForm.tsx
"use client";
import React from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Equipment } from "@/types/equipment";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { Card, CardContent } from "@/components/ui/card";
import { FileUploader } from "@/components/ui/file-uploader";
// 설비 등록/수정 폼 스키마
const equipmentSchema = z.object({
name: z.string().min(1, "설비명을 입력하세요"),
model: z.string().min(1, "모델명을 입력하세요"),
manufacturer: z.string().min(1, "제조사를 입력하세요"),
type: z.enum(["HVAC", "Boiler", "Compressor", "Motor", "Pump", "Other"]),
specifications: z.record(z.string(), z.any()).optional(),
installationDate: z.string().min(1, "설치일을 입력하세요"),
zoneId: z.string().min(1, "설치 구역을 선택하세요"),
description: z.string().optional(),
documents: z
.object({
manual: z.any().optional(),
technical: z.array(z.any()).optional(),
certificates: z.array(z.any()).optional(),
drawings: z.array(z.any()).optional(),
})
.optional(),
});
interface EquipmentFormProps {
initialData?: Equipment;
onSubmit: (data: z.infer<typeof equipmentSchema>) => void;
onCancel: () => void;
}
const defaultSpecifications = {
power: "",
voltage: "",
current: "",
efficiency: "",
capacity: "",
};
export function EquipmentForm({
initialData,
onSubmit,
onCancel,
}: EquipmentFormProps) {
const form = useForm<z.infer<typeof equipmentSchema>>({
resolver: zodResolver(equipmentSchema),
defaultValues: {
name: initialData?.name || "",
model: initialData?.model || "",
manufacturer: initialData?.manufacturer || "",
type: initialData?.type || "Other",
specifications: initialData?.specifications || defaultSpecifications,
installationDate: initialData?.installationDate
? new Date(initialData.installationDate).toISOString().split("T")[0]
: "",
zoneId: initialData?.zoneId || "",
description: initialData?.description || "",
documents: initialData?.documents || {
manual: null,
technical: [],
certificates: [],
drawings: [],
},
},
});
// Fetch zone for dropdown
const { data: zone } = useQuery({
queryKey: ["zone"],
queryFn: async () => {
const { data } = await api.get("/api/v1/app/zone");
return data;
},
});
// 사양 정보 상태 추가
const [specifications, setSpecifications] = React.useState<
Record<string, string>
>(initialData?.specifications || defaultSpecifications);
// 사양 정보 필드 추가/제거 핸들러 수정
const handleSpecificationChange = (key: string, value: string) => {
const newSpecs = {
...specifications,
[key]: value,
};
setSpecifications(newSpecs);
form.setValue("specifications", newSpecs);
};
const handleAddSpecification = () => {
const key = prompt("추가할 사양의 이름을 입력하세요");
if (key) {
const newSpecs = {
...specifications,
[key]: "",
};
setSpecifications(newSpecs);
form.setValue("specifications", newSpecs);
}
};
// 사양 정보 필드 삭제 핸들러 추가
const handleRemoveSpecification = (keyToRemove: string) => {
const newSpecs = { ...specifications };
delete newSpecs[keyToRemove];
setSpecifications(newSpecs);
form.setValue("specifications", newSpecs);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* 기본 정보 */}
<div className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="설비명을 입력하세요" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="model"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="모델명을 입력하세요" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="manufacturer"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="제조사를 입력하세요" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel> </FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="설비 유형을 선택하세요" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="HVAC"></SelectItem>
<SelectItem value="Boiler"></SelectItem>
<SelectItem value="Compressor"></SelectItem>
<SelectItem value="Motor"></SelectItem>
<SelectItem value="Pump"></SelectItem>
<SelectItem value="Other"></SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="installationDate"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="zoneId"
render={({ field }) => (
<FormItem>
<FormLabel> </FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="설치 구역을 선택하세요" />
</SelectTrigger>
</FormControl>
<SelectContent>
{zone?.map((zone: any) => (
<SelectItem key={zone.id} value={zone.id}>
{zone.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Textarea
placeholder="설비에 대한 설명을 입력하세요"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* 사양 정보 */}
<Card>
<CardContent className="pt-6">
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium"> </h3>
<Button
type="button"
variant="outline"
onClick={handleAddSpecification}
>
</Button>
</div>
{Object.entries(specifications).map(([key, value]) => (
<div key={key} className="flex gap-4">
<div className="w-1/3 relative">
<Input value={key} disabled />
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-2 top-1/2 -translate-y-1/2"
onClick={() => handleRemoveSpecification(key)}
>
×
</Button>
</div>
<Input
value={value}
onChange={(e) =>
handleSpecificationChange(key, e.target.value)
}
placeholder="값을 입력하세요"
className="w-2/3"
/>
</div>
))}
</div>
</CardContent>
</Card>
{/* 문서 업로드 */}
<Card>
<CardContent className="pt-6">
<div className="space-y-4">
<h3 className="text-lg font-medium"> </h3>
<FormField
control={form.control}
name="documents.manual"
render={({ field }) => (
<FormItem>
<FormLabel> </FormLabel>
<FormControl>
<FileUploader
value={field.value}
onChange={field.onChange}
accept=".pdf,.doc,.docx"
moduleType="equipment"
category="manual"
referenceId={initialData?.id} // 수정 시에만 id가 있음
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="documents.technical"
render={({ field }) => (
<FormItem>
<FormLabel> </FormLabel>
<FormControl>
<FileUploader
value={field.value}
onChange={field.onChange}
accept=".pdf,.doc,.docx"
multiple
moduleType="equipment"
category="technical"
referenceId={initialData?.id}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="documents.certificates"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<FileUploader
value={field.value}
onChange={field.onChange}
accept=".pdf,.jpg,.png"
multiple
moduleType="equipment"
category="certificates"
referenceId={initialData?.id}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="documents.drawings"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<FileUploader
value={field.value}
onChange={field.onChange}
accept=".pdf,.jpg,.png"
multiple
moduleType="equipment"
category="drawings"
referenceId={initialData?.id}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
{/* 버튼 */}
<div className="flex justify-end gap-4">
<Button type="button" variant="outline" onClick={onCancel}>
</Button>
<Button type="submit">{initialData ? "수정하기" : "등록하기"}</Button>
</div>
</form>
</Form>
);
}

View File

@ -0,0 +1,146 @@
// src/app/(equipment)/inventory/[mode]/page.tsx
"use client";
import React from "react";
import { useParams, useSearchParams, useRouter } from "next/navigation";
import { Equipment } from "@/types/equipment";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { useToast } from "@/hooks/use-toast";
import { useAuthStore } from "@/stores/auth";
import { EquipmentForm } from "./components/EquipmentForm";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
import { AxiosError } from "axios";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
const EquipmentFormPage = () => {
const params = useParams();
const searchParams = useSearchParams();
const mode = params.mode as "create" | "edit";
const equipmentId = searchParams.get("id"); // URL의 쿼리 파라미터에서 id 가져오기
const router = useRouter();
const { toast } = useToast();
const queryClient = useQueryClient();
const { user } = useAuthStore();
// 설비 상세 정보 조회 (수정 모드일 경우)
const { data: equipment, isLoading } = useQuery<Equipment>({
queryKey: ["equipment", mode === "edit" && equipmentId],
queryFn: async () => {
if (!equipmentId) throw new Error("Equipment ID not found");
const { data } = await api.get<Equipment>(
`/api/v1/app/equipment/${equipmentId}`
);
return data;
},
enabled: mode === "edit" && !!equipmentId, // equipmentId가 있을 때만 쿼리 실행
});
// 설비 생성 mutation
const createMutation = useMutation({
mutationFn: async (newEquipment: Partial<Equipment>) => {
const equipmentWithCompanyId = {
...newEquipment,
companyId: user?.companyId,
};
const { data } = await api.post<Equipment>(
"/api/v1/app/equipment",
equipmentWithCompanyId
);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["equipment"] });
toast({
title: "설비 등록",
description: "새로운 설비가 등록되었습니다.",
});
router.push("/inventory");
},
onError: (error: AxiosError) => {
toast({
title: "설비 등록 실패",
description: (error.response?.data as { message: string }).message,
variant: "destructive",
});
},
});
// 설비 수정 mutation
const updateMutation = useMutation({
mutationFn: async (equipmentData: Partial<Equipment>) => {
if (!equipmentId) throw new Error("Equipment ID not found");
const { data } = await api.put<Equipment>(
`/api/v1/app/equipment/${equipmentId}`,
equipmentData
);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["equipment"] });
toast({
title: "설비 수정",
description: "설비 정보가 수정되었습니다.",
});
router.push("/inventory");
},
onError: (error: AxiosError) => {
toast({
title: "설비 수정 실패",
description: (error.response?.data as { message: string }).message,
variant: "destructive",
});
},
});
if (mode === "edit" && !equipmentId) {
router.push("/inventory");
return null;
}
if (mode === "edit" && isLoading) return <div>Loading...</div>;
return (
<div className="container mx-auto py-6">
<Card>
{/* Header */}
<CardHeader>
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">
{mode === "create" ? "설비 등록" : "설비 수정"}
</h1>
<p className="text-muted-foreground">
{mode === "create"
? "새로운 설비를 등록합니다."
: "기존 설비 정보를 수정합니다."}
</p>
</div>
<Button variant="ghost" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
</div>
</CardHeader>
{/* Form */}
<CardContent className="max-w-3xl mx-auto">
<EquipmentForm
initialData={mode === "edit" ? equipment : undefined}
onSubmit={(data) => {
if (mode === "edit") {
updateMutation.mutate(data);
} else {
createMutation.mutate(data);
}
}}
onCancel={() => router.push("/inventory")}
/>
</CardContent>
</Card>
</div>
);
};
export default EquipmentFormPage;

View File

@ -0,0 +1,171 @@
// src/app/(equipment)/inventory/detail/[id]/page.tsx
"use client";
import React from "react";
import { useParams, useRouter } from "next/navigation";
import { Equipment } from "@/types/equipment";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Edit } from "lucide-react";
import { Card, CardHeader, CardContent } from "@/components/ui/card";
const EquipmentDetailPage = () => {
const params = useParams();
const router = useRouter();
const equipmentId = params.id as string;
// 설비 상세 정보 조회
const { data: equipment, isLoading } = useQuery<Equipment>({
queryKey: ["equipment", equipmentId],
queryFn: async () => {
const { data } = await api.get<Equipment>(
`/api/v1/app/equipment/${equipmentId}`
);
return data;
},
});
if (isLoading) return <div>Loading...</div>;
if (!equipment) return <div> .</div>;
return (
<div className="container mx-auto py-6">
<Card>
<CardHeader>
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">{equipment.name}</h1>
<p className="text-muted-foreground">{equipment.description}</p>
</div>
<div className="flex gap-2">
<Button variant="ghost" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={() =>
router.push(`/inventory/edit?id=${equipment.id}`)
}
>
<Edit className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* 기본 정보 */}
<div>
<h2 className="text-xl font-semibold mb-4"> </h2>
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-sm text-muted-foreground"></div>
<div>{equipment.name}</div>
</div>
<div>
<div className="text-sm text-muted-foreground"></div>
<div>{equipment.model}</div>
</div>
<div>
<div className="text-sm text-muted-foreground"></div>
<div>{equipment.manufacturer}</div>
</div>
<div>
<div className="text-sm text-muted-foreground"> </div>
<div>
{
{
HVAC: "공조설비",
Boiler: "보일러",
Compressor: "컴프레서",
Motor: "모터",
Pump: "펌프",
Other: "기타",
}[equipment.type]
}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground"></div>
<div>
{new Date(equipment.installationDate).toLocaleDateString()}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground"> </div>
<div>
{equipment.lastMaintenance
? new Date(equipment.lastMaintenance).toLocaleDateString()
: "-"}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground"></div>
<div>{equipment.Zone?.name || "-"}</div>
</div>
<div>
<div className="text-sm text-muted-foreground"> </div>
<div>{equipment.isActive ? "가동중" : "정지"}</div>
</div>
</div>
</div>
{/* 사양 정보 */}
<div>
<h2 className="text-xl font-semibold mb-4"> </h2>
<div className="grid grid-cols-2 gap-4">
{Object.entries(equipment.specifications || {}).map(
([key, value]) => (
<div key={key}>
<div className="text-sm text-muted-foreground">{key}</div>
<div>{value}</div>
</div>
)
)}
</div>
</div>
{/* 문서 정보 */}
<div>
<h2 className="text-xl font-semibold mb-4"> </h2>
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-sm text-muted-foreground"> </div>
<div>{equipment.documents?.manual || "-"}</div>
</div>
<div>
<div className="text-sm text-muted-foreground"> </div>
<div>
{equipment.documents?.technical?.length
? equipment.documents.technical.join(", ")
: "-"}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground"></div>
<div>
{equipment.documents?.certificates?.length
? equipment.documents.certificates.join(", ")
: "-"}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground"></div>
<div>
{equipment.documents?.drawings?.length
? equipment.documents.drawings.join(", ")
: "-"}
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
};
export default EquipmentDetailPage;

View File

@ -1 +1,304 @@
// src/(equipment)/inventory/page.tsx
// src/app/(equipment)/inventory/page.tsx
"use client";
import React from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { DataTable } from "@/components/ui/data-table";
import { api } from "@/lib/api";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast";
import { Plus, Edit, Trash2, FileText } from "lucide-react";
import { ColumnDef } from "@tanstack/react-table";
import { useAuthStore } from "@/stores/auth";
import { AxiosError } from "axios";
import { Equipment } from "@/types/equipment";
import { Switch } from "@/components/ui/switch";
import { useRouter } from "next/navigation";
const InventoryPage = () => {
const { token, user } = useAuthStore();
const [isOpen, setIsOpen] = React.useState(false);
const [editingEquipment, setEditingEquipment] =
React.useState<Equipment | null>(null);
const { toast } = useToast();
const queryClient = useQueryClient();
const router = useRouter();
// Fetch equipment
const { data: equipment, isLoading } = useQuery<Equipment[]>({
queryKey: ["equipment"],
queryFn: async () => {
const { data } = await api.get<Equipment[]>("/api/v1/app/equipment");
return data;
},
enabled: !!token,
});
// Create equipment mutation
// const createMutation = useMutation({
// mutationFn: async (newEquipment: Partial<Equipment>) => {
// const equipmentWithCompanyId = {
// ...newEquipment,
// companyId: user?.companyId,
// };
// const { data } = await api.post<Equipment>(
// "/api/v1/app/equipment",
// equipmentWithCompanyId
// );
// return data;
// },
// onSuccess: () => {
// queryClient.invalidateQueries({ queryKey: ["equipment"] });
// setIsOpen(false);
// toast({
// title: "설비 등록",
// description: "새로운 설비가 등록되었습니다.",
// });
// },
// onError: (error: AxiosError) => {
// toast({
// title: "설비 등록 실패",
// description: (error.response?.data as { message: string }).message,
// variant: "destructive",
// });
// },
// });
// Update equipment mutation
const updateMutation = useMutation({
mutationFn: async (equipmentData: Partial<Equipment>) => {
const { data } = await api.put<Equipment>(
`/api/v1/app/equipment/${equipmentData.id}`,
equipmentData
);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["equipment"] });
setIsOpen(false);
setEditingEquipment(null);
toast({
title: "설비 수정",
description: "설비 정보가 수정되었습니다.",
});
},
onError: (error: AxiosError) => {
toast({
title: "설비 수정 실패",
description: (error.response?.data as { message: string }).message,
variant: "destructive",
});
},
});
// Delete equipment mutation
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
await api.delete(`/api/v1/app/equipment/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["equipment"] });
toast({
title: "설비 삭제",
description: "설비가 삭제되었습니다.",
});
},
onError: (error: AxiosError) => {
toast({
title: "설비 삭제 실패",
description: (error.response?.data as { message: string }).message,
variant: "destructive",
});
},
});
// Table columns
const columns: ColumnDef<Equipment>[] = [
{
accessorKey: "name",
header: "설비명",
},
{
accessorKey: "model",
header: "모델명",
},
{
accessorKey: "manufacturer",
header: "제조사",
},
{
accessorKey: "type",
header: "유형",
cell: ({ row }) => {
const typeMap: Record<string, string> = {
HVAC: "공조설비",
Boiler: "보일러",
Compressor: "컴프레서",
Motor: "모터",
Pump: "펌프",
Other: "기타",
};
return typeMap[row.original.type] || row.original.type;
},
},
{
accessorKey: "installationDate",
header: "설치일",
cell: ({ row }) =>
new Date(row.original.installationDate).toLocaleDateString(),
},
{
accessorKey: "lastMaintenance",
header: "최근 정비일",
cell: ({ row }) =>
row.original.lastMaintenance
? new Date(row.original.lastMaintenance).toLocaleDateString()
: "-",
},
{
accessorKey: "Zone.name",
header: "설치 구역",
cell: ({ row }) => row.original.Zone?.name || "-",
},
{
accessorKey: "isActive",
header: "가동 상태",
cell: ({ row }) => (
<Switch
checked={row.original.isActive}
onCheckedChange={(value) => {
updateMutation.mutate({ id: row.original.id, isActive: value });
}}
/>
),
},
{
id: "actions",
header: "액션",
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
router.push(`/inventory/detail/${row.original.id}`);
}}
>
<FileText className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
router.push(`/inventory/edit?id=${row.original.id}`);
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm("정말 삭제하시겠습니까?")) {
deleteMutation.mutate(row.original.id);
}
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
),
},
];
if (isLoading) return <div>Loading...</div>;
return (
<div className="container mx-auto py-6">
{/* Header */}
<div className="flex justify-between items-center mb-6">
<div className="space-y-1">
<h1 className="text-3xl font-bold"> </h1>
<p className="text-muted-foreground"> .</p>
</div>
<Button onClick={() => router.push("/inventory/create")}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
{/* Equipment Table */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
{equipment && equipment.length > 0 ? (
<DataTable
columns={columns}
data={equipment}
filters={[
{
id: "type",
title: "설비 유형",
options: [
{ value: "HVAC", label: "공조설비" },
{ value: "Boiler", label: "보일러" },
{ value: "Compressor", label: "컴프레서" },
{ value: "Motor", label: "모터" },
{ value: "Pump", label: "펌프" },
{ value: "Other", label: "기타" },
],
},
{
id: "isActive",
title: "가동 상태",
options: [
{ value: "true", label: "가동중" },
{ value: "false", label: "중지" },
],
},
]}
/>
) : (
<div className="text-center py-12 text-muted-foreground">
.
</div>
)}
</CardContent>
</Card>
{/* Equipment Create/Edit Dialog */}
<Dialog
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open);
if (!open) setEditingEquipment(null);
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{editingEquipment ? "설비 수정" : "새 설비"}
</DialogTitle>
<DialogDescription>
{editingEquipment
? "기존 설비 정보를 수정합니다."
: "새로운 설비를 등록합니다."}
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
</div>
);
};
export default InventoryPage;

View File

@ -1,25 +1,89 @@
// src/app/page.tsx (랜딩 페이지)
import React from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Factory,
Battery,
LineChart,
Settings,
Building2,
Smartphone,
ChevronRight,
Award,
Lightbulb,
BarChart3,
} from "lucide-react";
export default function Home() {
// 산업 분야별 솔루션 데이터
const industries = [
{
title: "제조업",
icon: <Factory className="h-12 w-12 mb-4 text-blue-600" />,
description: "생산 공정 최적화와 에너지 효율 향상으로 제조 경쟁력 강화",
features: ["실시간 에너지 모니터링", "설비별 효율 분석", "원단위 관리"],
},
{
title: "화학/정유",
icon: <Battery className="h-12 w-12 mb-4 text-blue-600" />,
description: "공정 에너지 최적화 및 안전한 운영 지원",
features: ["공정 최적화", "에너지 회수 분석", "규제 대응"],
},
{
title: "반도체/디스플레이",
icon: <Settings className="h-12 w-12 mb-4 text-blue-600" />,
description: "클린룸 및 유틸리티 시스템의 효율적 운영",
features: ["클린룸 에너지 관리", "유틸리티 최적화", "품질 연계 분석"],
},
];
// 주요 기능 데이터
const features = [
{
icon: <LineChart className="h-6 w-6 text-blue-600" />,
title: "실시간 모니터링",
description: "설비와 에너지 사용량을 실시간으로 모니터링",
},
{
icon: <BarChart3 className="h-6 w-6 text-blue-600" />,
title: "데이터 분석",
description: "AI 기반 에너지 사용 패턴 분석 및 최적화",
},
{
icon: <Lightbulb className="h-6 w-6 text-blue-600" />,
title: "절감 방안 도출",
description: "맞춤형 에너지 절감 방안 제시",
},
];
return (
<div className="min-h-screen">
{/* Hero Section */}
<div className="relative h-screen flex items-center justify-center bg-gradient-to-r from-blue-600 to-blue-800">
<div className="absolute inset-0 bg-black/50" />
<div className="relative z-10 text-center text-white px-4">
<div className="relative z-10 text-center text-white px-4 max-w-4xl mx-auto">
<h1 className="text-5xl font-bold mb-6">
Factory Energy Management System
</h1>
<p className="text-xl mb-8">
<p className="text-xl mb-8 leading-relaxed">
AI
</p>
<div className="space-x-4">
<Link href="/login">
<Button size="lg"></Button>
<Button size="lg" className="bg-blue-500 hover:bg-blue-600">
<ChevronRight className="ml-2 h-4 w-4" />
</Button>
</Link>
<Button variant="outline" size="lg">
<Button
variant="outline"
size="lg"
className="text-white border-white hover:bg-white/10"
>
</Button>
</div>
@ -27,7 +91,178 @@ export default function Home() {
</div>
{/* Features Section */}
<div className="py-20 bg-white">{/* ... Features 내용 */}</div>
<div className="py-20 bg-gray-50">
<div className="max-w-7xl mx-auto px-4">
<div className="text-center mb-16">
<h2 className="text-3xl font-bold text-gray-900 mb-4"> </h2>
<p className="text-xl text-gray-600">
</p>
</div>
<div className="grid md:grid-cols-3 gap-8">
{features.map((feature, index) => (
<Card
key={index}
className="text-center p-6 hover:shadow-lg transition-shadow"
>
<CardContent>
<div className="flex justify-center mb-4">{feature.icon}</div>
<h3 className="text-xl font-semibold mb-2">
{feature.title}
</h3>
<p className="text-gray-600">{feature.description}</p>
</CardContent>
</Card>
))}
</div>
</div>
</div>
{/* Industries Section */}
<div className="py-20 bg-white">
<div className="max-w-7xl mx-auto px-4">
<div className="text-center mb-16">
<h2 className="text-3xl font-bold text-gray-900 mb-4">
</h2>
<p className="text-xl text-gray-600">
</p>
</div>
<div className="grid md:grid-cols-3 gap-8">
{industries.map((industry, index) => (
<Card key={index} className="hover:shadow-lg transition-shadow">
<CardContent className="p-6">
<div className="text-center">
{industry.icon}
<h3 className="text-xl font-semibold mb-3">
{industry.title}
</h3>
<p className="text-gray-600 mb-4">{industry.description}</p>
</div>
<ul className="space-y-2">
{industry.features.map((feature, idx) => (
<li key={idx} className="flex items-center text-gray-700">
<ChevronRight className="h-4 w-4 text-blue-600 mr-2" />
{feature}
</li>
))}
</ul>
</CardContent>
</Card>
))}
</div>
</div>
</div>
{/* Benefits Section */}
<div className="py-20 bg-gray-50">
<div className="max-w-7xl mx-auto px-4">
<div className="text-center mb-16">
<h2 className="text-3xl font-bold text-gray-900 mb-4"> </h2>
<p className="text-xl text-gray-600">
FEMS
</p>
</div>
<div className="grid md:grid-cols-4 gap-8">
{[
{
title: "에너지 비용 절감",
value: "15~25%",
description: "연간 에너지 비용 절감",
},
{
title: "설비 효율 개선",
value: "20%",
description: "설비 운영 효율 향상",
},
{
title: "탄소 배출 감축",
value: "30%",
description: "온실가스 배출량 감축",
},
{
title: "투자비 회수",
value: "2년",
description: "평균 투자비 회수 기간",
},
].map((benefit, index) => (
<Card
key={index}
className="text-center hover:shadow-lg transition-shadow"
>
<CardContent className="p-6">
<div className="text-4xl font-bold text-blue-600 mb-2">
{benefit.value}
</div>
<h3 className="text-lg font-semibold mb-2">
{benefit.title}
</h3>
<p className="text-gray-600">{benefit.description}</p>
</CardContent>
</Card>
))}
</div>
</div>
</div>
{/* CTA Section */}
<div className="py-20 bg-blue-600">
<div className="max-w-4xl mx-auto px-4 text-center text-white">
<h2 className="text-3xl font-bold mb-4"> </h2>
<p className="text-xl mb-8">
, FEMS가
</p>
<Button
size="lg"
variant="outline"
className="text-white border-white hover:bg-white/10"
>
</Button>
</div>
</div>
{/* Footer */}
<footer className="bg-gray-900 text-gray-300 py-12">
<div className="max-w-7xl mx-auto px-4">
<div className="grid md:grid-cols-4 gap-8">
<div>
<h3 className="text-white font-semibold mb-4">About Us</h3>
<p className="text-sm">
.
</p>
</div>
<div>
<h3 className="text-white font-semibold mb-4">Solutions</h3>
<ul className="space-y-2 text-sm">
<li></li>
<li>/</li>
<li>/</li>
</ul>
</div>
<div>
<h3 className="text-white font-semibold mb-4">Support</h3>
<ul className="space-y-2 text-sm">
<li></li>
<li></li>
<li></li>
</ul>
</div>
<div>
<h3 className="text-white font-semibold mb-4">Contact</h3>
<ul className="space-y-2 text-sm">
<li>contact@fems.com</li>
<li>02-123-4567</li>
<li> </li>
</ul>
</div>
</div>
<div className="border-t border-gray-800 mt-8 pt-8 text-sm text-center">
© 2024 FEMS. All rights reserved.
</div>
</div>
</footer>
</div>
);
}

View File

@ -51,42 +51,42 @@ const menuItems = [
{
title: "에너지 모니터링",
items: [
{ title: "전력", href: "/monitoring/electricity", icon: Zap },
{ title: "가스", href: "/monitoring/gas", icon: Flame },
{ title: "용수", href: "/monitoring/water", icon: Droplet },
{ title: "스팀", href: "/monitoring/steam", icon: Wind },
{ title: "전력", href: "/electricity", icon: Zap },
{ title: "가스", href: "/gas", icon: Flame },
{ title: "용수", href: "/water", icon: Droplet },
{ title: "스팀", href: "/steam", icon: Wind },
],
},
{
title: "설비 관리",
items: [
{ title: "설비 목록", href: "/equipment/inventory", icon: Box },
{ title: "상태 모니터링", href: "/equipment/monitoring", icon: Activity },
{ title: "정비 관리", href: "/equipment/maintenance", icon: Wrench },
{ title: "설비 목록", href: "/inventory", icon: Box },
{ title: "상태 모니터링", href: "/monitoring", icon: Activity },
{ title: "정비 관리", href: "/maintenance", icon: Wrench },
],
},
{
title: "분석/리포트",
items: [
{ title: "에너지 분석", href: "/analysis/energy", icon: LineChart },
{ title: "원단위 분석", href: "/analysis/efficiency", icon: BarChart },
{ title: "보고서", href: "/analysis/reports", icon: FileText },
{ title: "에너지 분석", href: "/energy", icon: LineChart },
{ title: "원단위 분석", href: "/efficiency", icon: BarChart },
{ title: "보고서", href: "/reports", icon: FileText },
],
},
{
title: "알람/이벤트",
items: [
{ title: "실시간 알람", href: "/alarm/realtime", icon: Bell },
{ title: "이력 관리", href: "/alarm/history", icon: History },
{ title: "알람 설정", href: "/alarm/settings", icon: SettingsIcon },
{ title: "실시간 알람", href: "/realtime", icon: Bell },
{ title: "이력 관리", href: "/history", icon: History },
{ title: "알람 설정", href: "/settings", icon: SettingsIcon },
],
},
{
title: "에너지 계획",
items: [
{ title: "절감 목표", href: "/planning/targets", icon: Target },
{ title: "수요 예측", href: "/planning/forecast", icon: TrendingUp },
{ title: "최적화", href: "/planning/optimization", icon: Brain },
{ title: "절감 목표", href: "/targets", icon: Target },
{ title: "수요 예측", href: "/forecast", icon: TrendingUp },
{ title: "최적화", href: "/optimization", icon: Brain },
],
},
{
@ -108,7 +108,7 @@ const menuItems = [
{
title: "지원/커뮤니티",
items: [
{ title: "도움말", href: "/support/help", icon: HelpCircle },
{ title: "도움말", href: "/help", icon: HelpCircle },
{ title: "게시판", href: "/community/forum", icon: MessageSquare },
{ title: "뉴스", href: "/community/news", icon: Newspaper },
],

View File

@ -51,42 +51,42 @@ const menuItems = [
{
title: "에너지 모니터링",
items: [
{ title: "전력", href: "/monitoring/electricity", icon: Zap },
{ title: "가스", href: "/monitoring/gas", icon: Flame },
{ title: "용수", href: "/monitoring/water", icon: Droplet },
{ title: "스팀", href: "/monitoring/steam", icon: Wind },
{ title: "전력", href: "/electricity", icon: Zap },
{ title: "가스", href: "/gas", icon: Flame },
{ title: "용수", href: "/water", icon: Droplet },
{ title: "스팀", href: "/steam", icon: Wind },
],
},
{
title: "설비 관리",
items: [
{ title: "설비 목록", href: "/equipment/inventory", icon: Box },
{ title: "상태 모니터링", href: "/equipment/monitoring", icon: Activity },
{ title: "정비 관리", href: "/equipment/maintenance", icon: Wrench },
{ title: "설비 목록", href: "/inventory", icon: Box },
{ title: "상태 모니터링", href: "/monitoring", icon: Activity },
{ title: "정비 관리", href: "/maintenance", icon: Wrench },
],
},
{
title: "분석/리포트",
items: [
{ title: "에너지 분석", href: "/analysis/energy", icon: LineChart },
{ title: "원단위 분석", href: "/analysis/efficiency", icon: BarChart },
{ title: "보고서", href: "/analysis/reports", icon: FileText },
{ title: "에너지 분석", href: "/energy", icon: LineChart },
{ title: "원단위 분석", href: "/efficiency", icon: BarChart },
{ title: "보고서", href: "/reports", icon: FileText },
],
},
{
title: "알람/이벤트",
items: [
{ title: "실시간 알람", href: "/alarm/realtime", icon: Bell },
{ title: "이력 관리", href: "/alarm/history", icon: History },
{ title: "알람 설정", href: "/alarm/settings", icon: SettingsIcon },
{ title: "실시간 알람", href: "/realtime", icon: Bell },
{ title: "이력 관리", href: "/history", icon: History },
{ title: "알람 설정", href: "/settings", icon: SettingsIcon },
],
},
{
title: "에너지 계획",
items: [
{ title: "절감 목표", href: "/planning/targets", icon: Target },
{ title: "수요 예측", href: "/planning/forecast", icon: TrendingUp },
{ title: "최적화", href: "/planning/optimization", icon: Brain },
{ title: "절감 목표", href: "/targets", icon: Target },
{ title: "수요 예측", href: "/forecast", icon: TrendingUp },
{ title: "최적화", href: "/optimization", icon: Brain },
],
},
{
@ -108,7 +108,7 @@ const menuItems = [
{
title: "지원/커뮤니티",
items: [
{ title: "도움말", href: "/support/help", icon: HelpCircle },
{ title: "도움말", href: "/help", icon: HelpCircle },
{ title: "게시판", href: "/community/forum", icon: MessageSquare },
{ title: "뉴스", href: "/community/news", icon: Newspaper },
],

View File

@ -1,103 +1,230 @@
// src/components/monitoring/MonitoringSidebar.tsx
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import {
LayoutDashboard,
Gauge,
TrendingUp,
DollarSign,
Zap,
Flame,
Droplets,
Droplet,
Wind,
BarChart3,
AlertTriangle,
Settings,
Flame,
Box,
Activity,
Wrench,
LineChart,
BarChart,
FileText,
Bell,
History,
Settings as SettingsIcon,
Target,
Brain,
Sliders,
Building2,
Users,
HelpCircle,
MessageSquare,
Newspaper,
ChevronDown,
ChevronRight,
} from "lucide-react";
import { useState } from "react";
const menuItems = [
{
title: "대시보드",
items: [
{
title: "전체 현황",
href: "/dashboard/overview",
icon: LayoutDashboard,
},
{ title: "KPI 지표", href: "/dashboard/kpi", icon: Gauge },
{ title: "비용 현황", href: "/dashboard/costs", icon: DollarSign },
],
},
{
title: "에너지 모니터링",
items: [
{
title: "전력",
icon: Zap,
href: "/monitoring/electricity",
},
{
title: "가스",
icon: Flame,
href: "/monitoring/gas",
},
{
title: "용수",
icon: Droplets,
href: "/monitoring/water",
},
{
title: "스팀",
icon: Wind,
href: "/monitoring/steam",
},
{ title: "전력", href: "/electricity", icon: Zap },
{ title: "가스", href: "/gas", icon: Flame },
{ title: "용수", href: "/water", icon: Droplet },
{ title: "스팀", href: "/steam", icon: Wind },
],
},
{
title: "분석",
title: "설비 관리",
items: [
{
title: "에너지 분석",
icon: BarChart3,
href: "/monitoring/analysis",
},
{
title: "알람 이력",
icon: AlertTriangle,
href: "/monitoring/alerts",
},
{ title: "설비 목록", href: "/inventory", icon: Box },
{ title: "상태 모니터링", href: "/monitoring", icon: Activity },
{ title: "정비 관리", href: "/maintenance", icon: Wrench },
],
},
{
title: "설정",
title: "분석/리포트",
items: [
{ title: "에너지 분석", href: "/energy", icon: LineChart },
{ title: "원단위 분석", href: "/efficiency", icon: BarChart },
{ title: "보고서", href: "/reports", icon: FileText },
],
},
{
title: "알람/이벤트",
items: [
{ title: "실시간 알람", href: "/realtime", icon: Bell },
{ title: "이력 관리", href: "/history", icon: History },
{ title: "알람 설정", href: "/settings", icon: SettingsIcon },
],
},
{
title: "에너지 계획",
items: [
{ title: "절감 목표", href: "/targets", icon: Target },
{ title: "수요 예측", href: "/forecast", icon: TrendingUp },
{ title: "최적화", href: "/optimization", icon: Brain },
],
},
{
title: "관리",
items: [
{ title: "회사 설정", href: "/company/profile", icon: Building2 },
{
title: "모니터링 설정",
icon: Settings,
href: "/monitoring/settings",
title: "지점/공장 관리",
href: "/company/branches",
icon: Building2,
},
{ title: "결재 관리", href: "/company/billing", icon: DollarSign },
{ title: "사용자 권한 관리", href: "/users/roles", icon: Users },
{ title: "부서 관리", href: "/users/departments", icon: Users },
{ title: "계정 관리", href: "/users/accounts", icon: Users },
{ title: "시스템 설정", href: "/system", icon: Sliders },
],
},
{
title: "지원/커뮤니티",
items: [
{ title: "도움말", href: "/help", icon: HelpCircle },
{ title: "게시판", href: "/community/forum", icon: MessageSquare },
{ title: "뉴스", href: "/community/news", icon: Newspaper },
],
},
];
export function MonitoringSidebar() {
const pathname = usePathname();
interface MenuItemProps {
item: {
title: string;
items: {
title: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
}[];
};
isOpen: boolean;
onToggle: () => void;
pathname: string;
}
const MenuItem: React.FC<MenuItemProps> = ({
item,
isOpen,
onToggle,
pathname,
}) => {
const firstIcon = item.items[0]?.icon;
const IconComponent = firstIcon || Box;
return (
<div className="flex flex-col w-64 bg-slate-900 text-white min-h-[calc(100vh-4rem)]">
<div className="space-y-4 py-4">
{menuItems.map((section) => (
<div key={section.title} className="px-3 py-2">
<h2 className="mb-2 px-4 text-xs font-semibold tracking-tight text-slate-400">
{section.title}
</h2>
<div className="space-y-1">
{section.items.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center rounded-lg px-3 py-2 text-sm font-medium hover:bg-slate-800 hover:text-slate-50 transition-colors",
pathname === item.href
? "bg-slate-800 text-slate-50"
: "text-slate-400"
)}
>
<item.icon className="mr-2 h-4 w-4" />
{item.title}
</Link>
))}
</div>
</div>
))}
</div>
<div className="mb-1">
<button
onClick={onToggle}
className={cn(
"w-full flex items-center px-3 py-2 text-sm font-medium rounded-md",
"transition-colors duration-150",
"hover:bg-gray-100",
isOpen ? "text-blue-600 bg-blue-50" : "text-gray-700"
)}
>
<IconComponent className="h-5 w-5 mr-2" />
<span className="flex-1 text-left">{item.title}</span>
{isOpen ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
{isOpen && (
<div className="ml-9 mt-1 space-y-1">
{item.items.map((subItem) => (
<Link
key={subItem.href}
href={subItem.href}
className={cn(
"flex items-center px-3 py-2 text-sm rounded-md",
"transition-colors duration-150",
pathname === subItem.href
? "bg-blue-50 text-blue-600 font-medium"
: "text-gray-600 hover:bg-gray-50"
)}
>
{subItem.icon && (
<subItem.icon className="h-4 w-4 mr-2 flex-shrink-0" />
)}
{subItem.title}
</Link>
))}
</div>
)}
</div>
);
};
export function MonitoringSidebar() {
const pathname = usePathname();
const [openSections, setOpenSections] = useState<{ [key: string]: boolean }>(
() => {
// 현재 경로에 해당하는 섹션을 자동으로 열어둠
return menuItems.reduce((acc: { [key: string]: boolean }, item) => {
if (item.items.some((subItem) => subItem.href === pathname)) {
acc[item.title] = true;
}
return acc;
}, {});
}
);
const toggleSection = (title: string) => {
setOpenSections((prev) => ({
...prev,
[title]: !prev[title],
}));
};
return (
<nav className="w-64 bg-white border-r border-gray-200 h-screen overflow-y-auto">
<div className="sticky top-0 z-10 bg-white p-4 border-b border-gray-200">
<div className="flex items-center space-x-2">
<Gauge className="h-8 w-6 text-blue-600" />
<h1 className="text-xl font-semibold text-gray-900">FEMS</h1>
</div>
</div>
<div className="p-3">
{menuItems.map((item) => (
<MenuItem
key={item.title}
item={item}
isOpen={openSections[item.title]}
onToggle={() => toggleSection(item.title)}
pathname={pathname}
/>
))}
</div>
</nav>
);
}
export default MonitoringSidebar;

View File

@ -1,37 +1,39 @@
// src/components/ui/data-table.tsx
"use client";
// components/ui/data-table.tsx
import * as React from "react";
import {
ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
getPaginationRowModel,
SortingState,
getSortedRowModel,
ColumnFiltersState,
getFilteredRowModel,
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
filters?: {
id: string;
title: string;
options: {
value: string;
label: string;
}[];
}[];
}
export function DataTable<TData, TValue>({
columns,
data,
filters,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[]
);
const table = useReactTable({
data,
@ -40,80 +42,109 @@ export function DataTable<TData, TValue>({
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
state: {
sorting,
columnFilters,
},
});
return (
<div>
{/* Filters */}
{filters && filters.length > 0 && (
<div className="flex gap-4 mb-4">
{filters.map((filter) => (
<div key={filter.id} className="flex flex-col gap-2">
<label className="text-sm font-medium">{filter.title}</label>
<select
className="h-8 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm"
value={
(table.getColumn(filter.id)?.getFilterValue() as string) ?? ""
}
onChange={(event) =>
table.getColumn(filter.id)?.setFilterValue(event.target.value)
}
>
<option value=""></option>
{filter.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
))}
</div>
)}
{/* Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<table className="w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="border-b px-4 py-2 text-left font-medium"
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</TableHeader>
<TableBody>
</thead>
<tbody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
<td key={cell.id} className="border-b px-4 py-2">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
</td>
))}
</TableRow>
</tr>
))
) : (
<TableRow>
<TableCell
<tr>
<td
colSpan={columns.length}
className="h-24 text-center"
className="h-24 text-center text-muted-foreground"
>
.
</TableCell>
</TableRow>
</td>
</tr>
)}
</TableBody>
</Table>
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-end space-x-2 py-4">
<Button
variant="outline"
size="sm"
<button
className="rounded border px-2 py-1 disabled:opacity-50"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
</Button>
<Button
variant="outline"
size="sm"
</button>
<button
className="rounded border px-2 py-1 disabled:opacity-50"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
</Button>
</button>
</div>
</div>
);

View File

@ -0,0 +1,172 @@
// src/components/ui/file-uploader.tsx
import React, { useCallback } from "react";
import { useDropzone } from "react-dropzone";
import { api } from "@/lib/api";
import {
Upload as IconUpload,
File as IconFile,
X as IconX,
} from "lucide-react";
import { useToast } from "@/hooks/use-toast";
interface FileUploaderProps {
value: any;
onChange: (files: any) => void;
accept?: string;
multiple?: boolean;
moduleType: string;
category: string;
referenceId?: string;
}
export function FileUploader({
value,
onChange,
accept,
multiple = false,
moduleType,
category,
referenceId,
}: FileUploaderProps) {
const { toast } = useToast();
const onDrop = useCallback(
async (acceptedFiles: File[]) => {
try {
if (acceptedFiles.length === 0) {
toast({
title: "파일 업로드 오류",
description: "업로드할 파일을 선택해주세요",
variant: "destructive",
});
return;
}
const formData = new FormData();
acceptedFiles.forEach((file) => {
formData.append("files", file);
});
formData.append("moduleType", moduleType);
formData.append("category", category);
if (referenceId) {
formData.append("referenceId", referenceId);
}
const metadata = {
uploadTime: new Date().toISOString(),
fileCount: acceptedFiles.length,
};
// metadata를 JSON 문자열로 변환
formData.append("metadata", JSON.stringify(metadata));
const { data } = await api.post("/api/v1/app/files/upload", formData);
onChange(multiple ? [...(value || []), ...data] : data[0]);
toast({
title: "파일 업로드 성공",
description: "파일이 성공적으로 업로드되었습니다.",
});
} catch (error: any) {
console.error("File upload failed:", error.response?.data);
toast({
title: "파일 업로드 실패",
description:
error.response?.data?.message ||
"파일 업로드 중 오류가 발생했습니다",
variant: "destructive",
});
}
},
[moduleType, category, referenceId, multiple, value, onChange, toast]
);
// dropzone 설정 수정
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: accept
? {
"application/pdf": [".pdf"],
"image/jpeg": [".jpg", ".jpeg"],
"image/png": [".png"],
"image/gif": [".gif"],
}
: undefined,
multiple,
maxSize: 10 * 1024 * 1024, // 10MB
onDropRejected: (fileRejections) => {
fileRejections.forEach(({ file, errors }) => {
errors.forEach((error) => {
toast({
title: "파일 업로드 오류",
description:
error.code === "file-too-large"
? `${file.name}의 크기가 10MB를 초과합니다.`
: `${file.name}은(는) 지원되지 않는 파일 형식입니다.`,
variant: "destructive",
});
});
});
},
});
const removeFile = (index: number) => {
if (multiple) {
const newFiles = [...value];
newFiles.splice(index, 1);
onChange(newFiles);
} else {
onChange(null);
}
};
return (
<div className="space-y-4">
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer hover:border-gray-400 transition-colors
${isDragActive ? "border-primary" : "border-gray-300"}`}
>
<input {...getInputProps()} />
<div className="flex flex-col items-center">
<IconUpload className="h-8 w-8 mb-2 text-gray-400" />
<p className="text-sm text-gray-600">
{isDragActive
? "파일을 여기에 놓으세요"
: "파일을 드래그하거나 클릭하여 업로드"}
</p>
<p className="text-xs text-gray-400 mt-1">
{accept ? `지원 형식: ${accept}` : "모든 파일"}
</p>
</div>
</div>
{/* 업로드된 파일 목록 */}
{value && (
<div className="space-y-2">
{(multiple ? value : [value]).map((file: any, index: number) => (
<div
key={file.id || index}
className="flex items-center justify-between p-2 bg-gray-50 rounded"
>
<div className="flex items-center space-x-2">
<IconFile className="h-4 w-4" />
<span className="text-sm">
{file.originalName || file.name}
</span>
</div>
<button
onClick={() => removeFile(index)}
className="p-1 hover:bg-gray-200 rounded"
>
<IconX className="h-4 w-4" />
</button>
</div>
))}
</div>
)}
</div>
);
}

View File

@ -37,7 +37,7 @@ export function useAuth() {
const logout = () => {
clearAuth();
localStorage.removeItem("token");
router.push("/login");
router.push("/");
};
return { user, token, login, logout };

View File

@ -1,3 +1,21 @@
// // src/lib/api.ts
// import axios from "axios";
// import { useAuthStore } from "@/stores/auth";
// export const api = axios.create({
// baseURL: process.env.NEXT_PUBLIC_API_URL,
// timeout: 10000,
// });
// api.interceptors.request.use((config) => {
// // localStorage 대신 Zustand store에서 직접 토큰을 가져옴
// const token = useAuthStore.getState().token;
// if (token) {
// config.headers.Authorization = `Bearer ${token}`;
// }
// return config;
// });
// src/lib/api.ts
import axios from "axios";
import { useAuthStore } from "@/stores/auth";
@ -8,10 +26,44 @@ export const api = axios.create({
});
api.interceptors.request.use((config) => {
// localStorage 대신 Zustand store에서 직접 토큰을 가져옴
const token = useAuthStore.getState().token;
// FormData인 경우 Content-Type을 설정하지 않음 (브라우저가 자동으로 설정)
if (config.data instanceof FormData) {
delete config.headers["Content-Type"];
} else {
// JSON 데이터의 경우 application/json으로 설정
config.headers["Content-Type"] = "application/json";
}
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// 디버깅을 위한 요청 로깅
console.log("Request Config:", {
url: config.url,
method: config.method,
headers: config.headers,
data: config.data instanceof FormData ? "FormData" : config.data,
});
return config;
});
// 응답 인터셉터 추가
api.interceptors.response.use(
(response) => {
return response;
},
(error) => {
// 에러 응답 로깅
console.error("API Error:", {
url: error.config?.url,
method: error.config?.method,
status: error.response?.status,
data: error.response?.data,
});
return Promise.reject(error);
}
);

View File

@ -15,8 +15,8 @@ export function middleware(request: NextRequest) {
const token = request.cookies.get("token")?.value;
// 비인증 사용자는 로그인 페이지로
if (!token && !request.nextUrl.pathname.startsWith("/login")) {
return NextResponse.redirect(new URL("/login", request.url));
if (!token && !request.nextUrl.pathname.startsWith("/")) {
return NextResponse.redirect(new URL("/", request.url));
}
// 권한별 접근 제어

View File

@ -0,0 +1,25 @@
// src/types/equipment.ts
export interface Equipment {
id: string;
name: string;
model: string;
manufacturer: string;
type: "HVAC" | "Boiler" | "Compressor" | "Motor" | "Pump" | "Other";
specifications: Record<string, any>;
installationDate: string;
lastMaintenance: string | null;
isActive: boolean;
zoneId: string;
companyId: string;
branchId: string;
Zone?: {
name: string;
};
description?: string;
documents?: {
manual?: string;
technical?: string[];
certificates?: string[];
drawings?: string[];
};
}

View File

@ -12,6 +12,7 @@
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"target": "esnext",
"plugins": [
{
"name": "next"

View File

@ -739,6 +739,13 @@
dependencies:
"@types/react" "*"
"@types/react-dropzone@^5.1.0":
version "5.1.0"
resolved "https://registry.yarnpkg.com/@types/react-dropzone/-/react-dropzone-5.1.0.tgz#5e27eb0b1ccadd7964ce4d328e1926fcd1c1f1ef"
integrity sha512-VCdDCwSsr1MT2frsVl5p8qH+LWwUGzsaNtGkEQekHviZqK0dmTbiIp2Pzfb8lTkH4oTE2JtBbWnbuM6B4FH80A==
dependencies:
react-dropzone "*"
"@types/react@*", "@types/react@^18":
version "18.3.12"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.12.tgz#99419f182ccd69151813b7ee24b792fe08774f60"
@ -1009,6 +1016,11 @@ asynckit@^0.4.0:
resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==
attr-accept@^2.2.4:
version "2.2.4"
resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-2.2.4.tgz#e28749d5975732586aea03c8912e2d0f1d1d77e7"
integrity sha512-2pA6xFIbdTUDCAwjN8nQwI+842VwzbDUXO2IYlpPXQIORgKnavorcr4Ce3rwh+zsNg9zK7QPsdvDj3Lum4WX4w==
available-typed-arrays@^1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846"
@ -1790,6 +1802,13 @@ file-entry-cache@^6.0.1:
dependencies:
flat-cache "^3.0.4"
file-selector@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-2.1.0.tgz#beb164ca5ce48af8a48d3e632c94750bc573581a"
integrity sha512-ZuXAqGePcSPz4JuerOY06Dzzq0hrmQ6VGoXVzGyFI1npeOfBgqGIKKpznfYWRkSLJlXutkqVC5WvGZtkFVhu9Q==
dependencies:
tslib "^2.7.0"
fill-range@^7.1.1:
version "7.1.1"
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292"
@ -2830,6 +2849,15 @@ react-dom@^18:
loose-envify "^1.1.0"
scheduler "^0.23.2"
react-dropzone@*:
version "14.3.3"
resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-14.3.3.tgz#8f2cf46567615123c91e1ba59b07e6c815e5aef0"
integrity sha512-38yaWovqDqZdtn7LKTE5lFdqc57hYbPdS/oIxyhmaTP/yzC0yztPxnNy+poxew5HKF44gzGCNcNDBYjqlCXr6g==
dependencies:
attr-accept "^2.2.4"
file-selector "^2.1.0"
prop-types "^15.8.1"
react-hook-form@^7.53.1:
version "7.53.1"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.53.1.tgz#3f2cd1ed2b3af99416a4ac674da2d526625add67"
@ -3351,7 +3379,7 @@ tsconfig-paths@^3.15.0:
minimist "^1.2.6"
strip-bom "^3.0.0"
tslib@^2.0.0, tslib@^2.1.0, tslib@^2.4.0:
tslib@^2.0.0, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.7.0:
version "2.8.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==