diff --git a/.env.development b/.env.development
index 2a97f67..9b65656 100644
--- a/.env.development
+++ b/.env.development
@@ -7,13 +7,13 @@ TZ=Asia/Seoul
# Frontend
NEXT_PUBLIC_API_URL=http://localhost:3001
NEXT_PUBLIC_MQTT_URL=ws://localhost:1883
-NEXTAUTH_URL=http://localhost:3000
+NEXTAUTH_URL=http://localhost:3003
NEXTAUTH_SECRET=wacefems-secret-work-in-progress-PPw09!keep!
# Backend API
API_PORT=3001
API_PREFIX=/api/v1
-CORS_ORIGIN=http://localhost:3000
+CORS_ORIGIN=http://localhost:3002,http://localhost:3003
JWT_SECRET=wacefems-secret-work-in-progress-PPw09!keep
JWT_EXPIRES_IN=1d
diff --git a/.env.production b/.env.production
index 9220896..17111da 100644
--- a/.env.production
+++ b/.env.production
@@ -8,7 +8,6 @@ TZ=Asia/Seoul
DOMAIN=fems.com
TRAEFIK_NETWORK=toktork_server_default
ADMIN_SUBDOMAIN=admin.${DOMAIN}
-APP_SUBDOMAIN=app.${DOMAIN}
API_SUBDOMAIN=api.${DOMAIN}
MQTT_SUBDOMAIN=mqtt.${DOMAIN}
NODERED_SUBDOMAIN=nodered.${DOMAIN}
@@ -25,7 +24,7 @@ NEXTAUTH_SECRET=wacefems-secret-work-in-progress-PPw09!keep!
# Backend API
API_PORT=3001
API_PREFIX=/api/v1
-CORS_ORIGIN=https://${DOMAIN}
+CORS_ORIGIN=https://${DOMAIN},https://www.admin.${DOMAIN}
JWT_SECRET=wacefems-secret-work-in-progress-PPw09!keep
JWT_EXPIRES_IN=1d
diff --git a/README.md b/README.md
index e2ab393..bf3e099 100644
--- a/README.md
+++ b/README.md
@@ -35,53 +35,61 @@ wacefems/
│ │ │ │ ├── login/
│ │ │ │ └── register/
│ │ │ ├── (admin)/ # 기업 관리자용 기능
-│ │ │ │ ├── company-settings/ # 회사 설정
+│ │ │ │ ├── company/ # 회사 설정
│ │ │ │ │ ├── profile/ # 회사 프로필
│ │ │ │ │ ├── branches/ # 지점/공장 관리
│ │ │ │ │ └── billing/ # 결제 관리
-│ │ │ │ ├── user-management/ # 사용자 관리
+│ │ │ │ ├── user/ # 사용자 관리
│ │ │ │ │ ├── departments/ # 부서 관리
│ │ │ │ │ ├── roles/ # 권한 관리
│ │ │ │ │ └── accounts/ # 계정 관리
-│ │ │ │ └── system-settings/ # 시스템 설정
+│ │ │ │ └── system/ # 시스템 설정
│ │ │ │
-│ │ │ ├── dashboard/ # 대시보드
-│ │ │ │ ├── overview/ # 전체 현황
-│ │ │ │ ├── kpi/ # KPI 지표
-│ │ │ │ └── costs/ # 비용 현황
+│ │ │ ├── (general)/ # 일반 사용자용 기능
+│ │ │ │ ├── layout.tsx
+│ │ │ │ ├── dashboard/ # 대시보드
+│ │ │ │ │ ├── overview/ # 전체 현황
+│ │ │ │ │ │ └── page.tsx
+│ │ │ │ │ ├── kpi/ # KPI 지표
+│ │ │ │ │ │ └── page.tsx
+│ │ │ │ │ └── costs/ # 비용 현황
+│ │ │ │ │ └── page.tsx
+│ │ │ │ │
+│ │ │ │ └── settings/ # 개인 설정
+│ │ │ │ └── page.tsx
│ │ │ │
-│ │ │ ├── monitoring/ # 에너지 모니터링
+│ │ │ ├── (monitoring)/ # 에너지 모니터링
│ │ │ │ ├── electricity/ # 전력
│ │ │ │ ├── gas/ # 가스
│ │ │ │ ├── water/ # 용수
│ │ │ │ └── steam/ # 스팀
│ │ │ │
-│ │ │ ├── equipment/ # 설비 관리
+│ │ │ ├── (equipment)/ # 설비 관리
│ │ │ │ ├── inventory/ # 설비 목록
│ │ │ │ ├── monitoring/ # 상태 모니터링
│ │ │ │ └── maintenance/ # 정비 관리
│ │ │ │
-│ │ │ ├── analysis/ # 분석/리포트
+│ │ │ ├── (analysis)/ # 분석/리포트
│ │ │ │ ├── energy/ # 에너지 분석
│ │ │ │ ├── efficiency/ # 원단위 분석
│ │ │ │ └── reports/ # 보고서
│ │ │ │
-│ │ │ ├── alarm/ # 알람/이벤트
+│ │ │ ├── (alarm)/ # 알람/이벤트
│ │ │ │ ├── realtime/ # 실시간 알람
│ │ │ │ ├── history/ # 이력 관리
│ │ │ │ └── settings/ # 알람 설정
│ │ │ │
-│ │ │ ├── planning/ # 에너지 계획
+│ │ │ ├── (planning)/ # 에너지 계획
│ │ │ │ ├── targets/ # 절감 목표
│ │ │ │ ├── forecast/ # 수요 예측
│ │ │ │ └── optimization/# 최적화
│ │ │ │
-│ │ │ ├── support/ # 도움말/지원
+│ │ │ ├── (support)/ # 도움말/지원
│ │ │ │ ├── manual/ # 사용자 가이드
│ │ │ │ ├── faq/ # FAQ
│ │ │ │ └── contact/ # 문의하기
│ │ │ │
-│ │ │ └── community/ # 커뮤니티
+│ │ │ └── (community)/ # 커뮤니티
│ │ │ ├── notice/ # 공지사항
│ │ │ ├── forum/ # 게시판
│ │ │ └── news/ # 뉴스
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 8879795..8f6a4d7 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -33,7 +33,7 @@ services:
- "3003"
labels:
- "traefik.enable=true"
- - "traefik.http.routers.fems-app.rule=Host(`${APP_SUBDOMAIN}`)"
+ - "traefik.http.routers.fems-app.rule=Host(`${DOMAIN}`)"
- "traefik.http.routers.fems-app.entrypoints=websecure"
- "traefik.http.routers.fems-app.tls=true"
- "traefik.http.services.fems-app.loadbalancer.server.port=3000"
diff --git a/fems-api/package.json b/fems-api/package.json
index 2e9815d..cc699e0 100644
--- a/fems-api/package.json
+++ b/fems-api/package.json
@@ -20,6 +20,7 @@
"pg": "^8.11.0",
"pg-hstore": "^2.3.4",
"sequelize": "^6.31.1",
+ "uuid": "^11.0.2",
"winston": "^3.8.2"
},
"devDependencies": {
diff --git a/fems-api/src/app.js b/fems-api/src/app.js
index ed1b4da..3ee3d42 100644
--- a/fems-api/src/app.js
+++ b/fems-api/src/app.js
@@ -6,10 +6,11 @@ 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");
+// // Import routes
+// const adminRoutes = require("./routes/admin");
+// const appRoutes = require("./routes/app");
const app = express();
@@ -19,9 +20,8 @@ app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(requestLogger);
-// Routes
-app.use("/api/v1/admin", adminRoutes);
-app.use("/api/v1/app", appRoutes);
+// Register all routes
+registerRoutes(app);
// Error handling
app.use(errorHandler);
diff --git a/fems-api/src/config/config.js b/fems-api/src/config/config.js
index 814f75f..e85df10 100644
--- a/fems-api/src/config/config.js
+++ b/fems-api/src/config/config.js
@@ -20,7 +20,7 @@ module.exports = {
expiresIn: process.env.JWT_EXPIRES_IN || "1d",
},
cors: {
- origin: process.env.CORS_ORIGIN,
+ origin: process.env.CORS_ORIGIN?.split(","),
credentials: true,
},
},
@@ -40,7 +40,7 @@ module.exports = {
expiresIn: process.env.JWT_EXPIRES_IN || "1d",
},
cors: {
- origin: process.env.CORS_ORIGIN,
+ origin: process.env.CORS_ORIGIN?.split(","),
credentials: true,
},
},
diff --git a/fems-api/src/controllers/app/dashboard/dashboard.controller.js b/fems-api/src/controllers/app/dashboard/dashboard.controller.js
new file mode 100644
index 0000000..4327213
--- /dev/null
+++ b/fems-api/src/controllers/app/dashboard/dashboard.controller.js
@@ -0,0 +1,57 @@
+// src/controllers/app/dashboard/dashboard.controller.js
+const express = require("express");
+const router = express.Router();
+const dashboardService = require("../../../services/dashboard.service");
+const authMiddleware = require("../../../middleware/auth.middleware");
+
+router.use(authMiddleware);
+
+router.get("/energy-usage", async (req, res, next) => {
+ try {
+ const startDate =
+ req.query.startDate ||
+ new Date(new Date().setMonth(new Date().getMonth() - 6));
+ const endDate = req.query.endDate || new Date();
+
+ const data = await dashboardService.getEnergyUsage(
+ req.user.companyId,
+ startDate,
+ endDate
+ );
+ res.json(data);
+ } catch (error) {
+ next(error);
+ }
+});
+
+router.get("/energy-costs", async (req, res, next) => {
+ try {
+ const now = new Date();
+ const year = parseInt(req.query.year) || now.getFullYear();
+ const month = parseInt(req.query.month) || now.getMonth() + 1;
+
+ const data = await dashboardService.getEnergyCosts(
+ req.user.companyId,
+ year,
+ month
+ );
+ res.json(data);
+ } catch (error) {
+ next(error);
+ }
+});
+
+router.get("/alerts", async (req, res, next) => {
+ try {
+ const limit = parseInt(req.query.limit) || 5;
+ const data = await dashboardService.getRecentAlerts(
+ req.user.companyId,
+ limit
+ );
+ res.json(data);
+ } catch (error) {
+ next(error);
+ }
+});
+
+module.exports = router;
diff --git a/fems-api/src/models/Alert.js b/fems-api/src/models/Alert.js
new file mode 100644
index 0000000..3292c74
--- /dev/null
+++ b/fems-api/src/models/Alert.js
@@ -0,0 +1,50 @@
+// src/models/Alert.js
+const { Model, DataTypes } = require("sequelize");
+
+class Alert extends Model {
+ static init(sequelize) {
+ super.init(
+ {
+ id: {
+ type: DataTypes.UUID,
+ defaultValue: DataTypes.UUIDV4,
+ primaryKey: true,
+ },
+ type: {
+ type: DataTypes.ENUM("error", "warning", "info"),
+ allowNull: false,
+ },
+ message: {
+ type: DataTypes.STRING,
+ allowNull: false,
+ },
+ read: {
+ type: DataTypes.BOOLEAN,
+ defaultValue: false,
+ },
+ companyId: {
+ type: DataTypes.UUID,
+ allowNull: false,
+ },
+ branchId: {
+ type: DataTypes.UUID,
+ allowNull: true,
+ },
+ },
+ {
+ sequelize,
+ modelName: "Alert",
+ tableName: "alerts",
+ timestamps: true,
+ }
+ );
+ return this;
+ }
+
+ static associate(models) {
+ this.belongsTo(models.Company, { foreignKey: "companyId" });
+ this.belongsTo(models.Branch, { foreignKey: "branchId" });
+ }
+}
+
+module.exports = Alert;
diff --git a/fems-api/src/models/EnergyCost.js b/fems-api/src/models/EnergyCost.js
new file mode 100644
index 0000000..62fb1dc
--- /dev/null
+++ b/fems-api/src/models/EnergyCost.js
@@ -0,0 +1,70 @@
+// src/models/EnergyCost.js
+const { Model, DataTypes } = require("sequelize");
+
+class EnergyCost extends Model {
+ static init(sequelize) {
+ super.init(
+ {
+ id: {
+ type: DataTypes.UUID,
+ defaultValue: DataTypes.UUIDV4,
+ primaryKey: true,
+ },
+ year: {
+ type: DataTypes.INTEGER,
+ allowNull: false,
+ },
+ month: {
+ type: DataTypes.INTEGER,
+ allowNull: false,
+ },
+ category: {
+ type: DataTypes.ENUM("electricity", "gas", "water", "steam"),
+ allowNull: false,
+ },
+ amount: {
+ type: DataTypes.DECIMAL(15, 2),
+ allowNull: false,
+ defaultValue: 0,
+ },
+ usage: {
+ type: DataTypes.FLOAT,
+ allowNull: false,
+ defaultValue: 0,
+ },
+ unitPrice: {
+ type: DataTypes.DECIMAL(10, 2),
+ allowNull: false,
+ defaultValue: 0,
+ },
+ companyId: {
+ type: DataTypes.UUID,
+ allowNull: false,
+ },
+ branchId: {
+ type: DataTypes.UUID,
+ allowNull: true,
+ },
+ },
+ {
+ sequelize,
+ modelName: "EnergyCost",
+ tableName: "energy_costs",
+ timestamps: true,
+ indexes: [
+ {
+ fields: ["year", "month", "category", "companyId"],
+ },
+ ],
+ }
+ );
+ return this;
+ }
+
+ static associate(models) {
+ this.belongsTo(models.Company, { foreignKey: "companyId" });
+ this.belongsTo(models.Branch, { foreignKey: "branchId" });
+ }
+}
+
+module.exports = EnergyCost;
diff --git a/fems-api/src/models/EnergyUsage.js b/fems-api/src/models/EnergyUsage.js
new file mode 100644
index 0000000..9dc7434
--- /dev/null
+++ b/fems-api/src/models/EnergyUsage.js
@@ -0,0 +1,62 @@
+// src/models/EnergyUsage.js
+const { Model, DataTypes } = require("sequelize");
+
+class EnergyUsage extends Model {
+ static init(sequelize) {
+ super.init(
+ {
+ id: {
+ type: DataTypes.UUID,
+ defaultValue: DataTypes.UUIDV4,
+ primaryKey: true,
+ },
+ timestamp: {
+ type: DataTypes.DATE,
+ allowNull: false,
+ },
+ electricity: {
+ type: DataTypes.FLOAT,
+ allowNull: false,
+ defaultValue: 0,
+ },
+ gas: {
+ type: DataTypes.FLOAT,
+ allowNull: false,
+ defaultValue: 0,
+ },
+ water: {
+ type: DataTypes.FLOAT,
+ allowNull: false,
+ defaultValue: 0,
+ },
+ steam: {
+ type: DataTypes.FLOAT,
+ allowNull: false,
+ defaultValue: 0,
+ },
+ companyId: {
+ type: DataTypes.UUID,
+ allowNull: false,
+ },
+ branchId: {
+ type: DataTypes.UUID,
+ allowNull: true,
+ },
+ },
+ {
+ sequelize,
+ modelName: "EnergyUsage",
+ tableName: "energy_usages",
+ timestamps: true,
+ }
+ );
+ return this;
+ }
+
+ static associate(models) {
+ this.belongsTo(models.Company, { foreignKey: "companyId" });
+ this.belongsTo(models.Branch, { foreignKey: "branchId" });
+ }
+}
+
+module.exports = EnergyUsage;
diff --git a/fems-api/src/models/User.js b/fems-api/src/models/User.js
index bb0c80a..c7adfde 100644
--- a/fems-api/src/models/User.js
+++ b/fems-api/src/models/User.js
@@ -1,5 +1,6 @@
// src/models/User.js
const { Model, DataTypes } = require("sequelize");
+const bcrypt = require("bcryptjs");
class User extends Model {
static init(sequelize) {
@@ -58,11 +59,29 @@ class User extends Model {
modelName: "User",
tableName: "Users",
timestamps: true,
+ hooks: {
+ beforeCreate: async (user) => {
+ if (user.password) {
+ user.password = await bcrypt.hash(user.password, 10);
+ }
+ },
+ beforeUpdate: async (user) => {
+ if (user.changed("password")) {
+ user.password = await bcrypt.hash(user.password, 10);
+ }
+ },
+ },
}
);
+
return this;
}
+ // validatePassword 메서드 추가
+ async validatePassword(password) {
+ return bcrypt.compare(password, this.password);
+ }
+
static associate(models) {
this.belongsTo(models.Company, { foreignKey: "companyId" });
this.belongsTo(models.Branch, { foreignKey: "branchId" });
diff --git a/fems-api/src/models/index.js b/fems-api/src/models/index.js
index 31223e3..3195b28 100644
--- a/fems-api/src/models/index.js
+++ b/fems-api/src/models/index.js
@@ -1,3 +1,6 @@
+// src/models/index.js
+const fs = require("fs");
+const path = require("path");
const { Sequelize } = require("sequelize");
const config = require("../config/config");
const logger = require("../config/logger");
@@ -14,21 +17,21 @@ const sequelize = new Sequelize(
}
);
-// 모델 import
-const Company = require("./Company");
-const Branch = require("./Branch");
-const User = require("./User");
-const AuthLog = require("./AuthLog");
+const models = {};
-// 모델 초기화
-const models = {
- Company: Company.init(sequelize),
- Branch: Branch.init(sequelize),
- User: User.init(sequelize),
- AuthLog: AuthLog.init(sequelize),
-};
+// 현재 디렉토리의 모든 파일을 읽습니다.
+fs.readdirSync(__dirname)
+ .filter((file) => {
+ return (
+ file.indexOf(".") !== 0 && file !== "index.js" && file.slice(-3) === ".js"
+ );
+ })
+ .forEach((file) => {
+ const model = require(path.join(__dirname, file));
+ models[model.name] = model.init(sequelize);
+ });
-// 관계 설정
+// 모델 간의 관계 설정
Object.values(models)
.filter((model) => typeof model.associate === "function")
.forEach((model) => model.associate(models));
diff --git a/fems-api/src/routes/app.js b/fems-api/src/routes/app.js
index a55d0e6..bcfd571 100644
--- a/fems-api/src/routes/app.js
+++ b/fems-api/src/routes/app.js
@@ -5,9 +5,11 @@ 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"); // 추가
// Mount app routes
router.use("/auth", authController);
router.use("/users", usersController);
+router.use("/dashboard", dashboardController); // 추가
module.exports = router;
diff --git a/fems-api/src/routes/index.js b/fems-api/src/routes/index.js
new file mode 100644
index 0000000..6879f37
--- /dev/null
+++ b/fems-api/src/routes/index.js
@@ -0,0 +1,33 @@
+// src/routes/index.js
+const logger = require("../config/logger");
+const adminRoutes = require("./admin");
+const appRoutes = require("./app");
+
+function registerRoutes(app) {
+ // 기본 경로에 라우터 연결
+ app.use("/api/v1/admin", adminRoutes);
+ app.use("/api/v1/app", appRoutes);
+
+ // 등록된 라우트 출력 (디버깅용)
+ const routes = [];
+ app._router.stack.forEach((middleware) => {
+ if (middleware.route) {
+ routes.push(
+ `${Object.keys(middleware.route.methods)} ${middleware.route.path}`
+ );
+ } else if (middleware.name === "router") {
+ middleware.handle.stack.forEach((handler) => {
+ if (handler.route) {
+ routes.push(
+ `${Object.keys(handler.route.methods)} ${handler.route.path}`
+ );
+ }
+ });
+ }
+ });
+
+ logger.info("Registered routes:");
+ routes.forEach((route) => logger.info(route));
+}
+
+module.exports = registerRoutes;
diff --git a/fems-api/src/services/auth.service.js b/fems-api/src/services/auth.service.js
index ac876a6..7af0c63 100644
--- a/fems-api/src/services/auth.service.js
+++ b/fems-api/src/services/auth.service.js
@@ -1,11 +1,15 @@
// src/services/auth.service.js
const jwt = require("jsonwebtoken");
const config = require("../config/config");
-const { User, AuthLog } = require("../models");
+const { User, AuthLog, Company, Branch } = require("../models");
class AuthService {
async login(username, password, ipAddress, userAgent) {
- const user = await User.findOne({ where: { username } });
+ const user = await User.findOne({
+ where: { username },
+ // 필요한 관계 데이터도 함께 로드
+ include: [{ model: Company }, { model: Branch }],
+ });
if (!user || !user.isActive) {
await this._logAuthAttempt(
@@ -28,7 +32,8 @@ class AuthService {
await this._logAuthAttempt(user.id, "login", ipAddress, userAgent);
const token = this._generateToken(user);
- const userWithoutPassword = { ...user.toJSON() };
+ // 안전한 사용자 데이터 반환을 위해 password 제외
+ const userWithoutPassword = user.toJSON();
delete userWithoutPassword.password;
return {
diff --git a/fems-api/src/services/dashboard.service.js b/fems-api/src/services/dashboard.service.js
new file mode 100644
index 0000000..c985dc3
--- /dev/null
+++ b/fems-api/src/services/dashboard.service.js
@@ -0,0 +1,61 @@
+// src/services/dashboard.service.js
+const { EnergyUsage, EnergyCost, Alert } = require("../models");
+const { Op } = require("sequelize");
+
+class DashboardService {
+ async getEnergyUsage(companyId, startDate, endDate) {
+ return await EnergyUsage.findAll({
+ where: {
+ companyId,
+ timestamp: {
+ [Op.between]: [startDate, endDate],
+ },
+ },
+ order: [["timestamp", "ASC"]],
+ });
+ }
+
+ async getEnergyCosts(companyId, year, month) {
+ const currentCosts = await EnergyCost.findAll({
+ where: {
+ companyId,
+ year,
+ month,
+ },
+ });
+
+ const previousCosts = await EnergyCost.findAll({
+ where: {
+ companyId,
+ year: month === 1 ? year - 1 : year,
+ month: month === 1 ? 12 : month - 1,
+ },
+ });
+
+ return currentCosts.map((cost) => {
+ const previousCost = previousCosts.find(
+ (pc) => pc.category === cost.category
+ );
+ return {
+ category: cost.category,
+ amount: cost.amount,
+ previousAmount: previousCost?.amount || 0,
+ change: previousCost
+ ? ((cost.amount - previousCost.amount) / previousCost.amount) * 100
+ : 0,
+ };
+ });
+ }
+
+ async getRecentAlerts(companyId, limit = 5) {
+ return await Alert.findAll({
+ where: {
+ companyId,
+ },
+ order: [["createdAt", "DESC"]],
+ limit,
+ });
+ }
+}
+
+module.exports = new DashboardService();
diff --git a/fems-api/src/utils/createInitialAdmin.js b/fems-api/src/utils/createInitialAdmin.js
index 4e4137c..01e9360 100644
--- a/fems-api/src/utils/createInitialAdmin.js
+++ b/fems-api/src/utils/createInitialAdmin.js
@@ -1,31 +1,213 @@
// src/utils/createInitialAdmin.js
-const { User } = require("../models");
+const {
+ User,
+ Company,
+ Branch,
+ Alert,
+ EnergyUsage,
+ EnergyCost,
+} = require("../models");
const logger = require("../config/logger");
-const bcrypt = require("bcryptjs");
-const createInitialAdmin = async () => {
+async function createInitialAdmin() {
try {
+ // 관리자 회사 생성 또는 조회
+ let adminCompany = await Company.findOne({
+ where: { businessNumber: "000-00-00000" }, // 관리자 회사 사업자번호
+ });
+
+ if (!adminCompany) {
+ adminCompany = await Company.create({
+ name: "FEMS 관리자",
+ businessNumber: "000-00-00000",
+ address: "서울시 강남구",
+ tel: "02-0000-0000",
+ email: "admin@fems.com",
+ representative: "관리자",
+ isActive: true,
+ contractStartDate: new Date(),
+ contractEndDate: new Date(
+ new Date().setFullYear(new Date().getFullYear() + 10)
+ ), // 10년 계약
+ });
+ logger.info("Admin company created successfully");
+
+ // 본사 지점 생성
+ await Branch.create({
+ name: "본사",
+ address: "서울시 강남구",
+ tel: "02-0000-0000",
+ isActive: true,
+ companyId: adminCompany.id,
+ });
+ logger.info("Admin company headquarters branch created successfully");
+ }
+
+ // 슈퍼 관리자 계정 생성
const existingAdmin = await User.findOne({
where: { role: "super_admin" },
});
if (!existingAdmin) {
- const hashedPassword = await bcrypt.hash("Admin123!@#", 10);
await User.create({
username: "admin",
- password: hashedPassword,
+ password: "Admin123!@#", // 초기 비밀번호 (변경 필요)
name: "System Administrator",
email: "admin@fems.com",
phone: "010-0000-0000",
role: "super_admin",
isActive: true,
+ companyId: adminCompany.id,
+ branchId: (
+ await Branch.findOne({ where: { companyId: adminCompany.id } })
+ ).id,
});
logger.info("Initial super admin created successfully");
}
+
+ // 권한별 테스트 사용자 생성
+ const userRoles = [
+ {
+ username: "company_admin",
+ password: "Admin123!@#",
+ name: "Company Admin",
+ email: "company_admin@fems.com",
+ phone: "010-1111-1111",
+ role: "company_admin",
+ },
+ {
+ username: "branch_admin",
+ password: "Admin123!@#",
+ name: "Branch Admin",
+ email: "branch_admin@fems.com",
+ phone: "010-2222-2222",
+ role: "branch_admin",
+ },
+ {
+ username: "user",
+ password: "Admin123!@#",
+ name: "Normal User",
+ email: "user@fems.com",
+ phone: "010-3333-3333",
+ role: "user",
+ },
+ ];
+
+ const branchId = (
+ await Branch.findOne({ where: { companyId: adminCompany.id } })
+ ).id;
+
+ // 각 역할별 사용자 생성
+ for (const userData of userRoles) {
+ const existingUser = await User.findOne({
+ where: { username: userData.username },
+ });
+
+ if (!existingUser) {
+ await User.create({
+ ...userData,
+ isActive: true,
+ companyId: adminCompany.id,
+ branchId: branchId,
+ });
+ logger.info(`Created ${userData.role} user successfully`);
+ }
+ }
+
+ // 초기 데이터 생성 (옵션)
+ if (process.env.NODE_ENV === "development") {
+ await createInitialData(adminCompany.id);
+ }
+
+ // 생성된 모든 계정 정보 출력
+ if (process.env.NODE_ENV === "development") {
+ logger.info("Created test accounts:");
+ logger.info(`Super Admin:
+ Username: admin
+ Password: Admin123!@#
+ Role: super_admin`);
+
+ userRoles.forEach((user) => {
+ logger.info(`${user.role}:
+ Username: ${user.username}
+ Password: ${user.password}
+ Role: ${user.role}`);
+ });
+ }
} catch (error) {
logger.error("Error creating initial admin:", error);
- throw error; // 에러를 상위로 전파
+ throw error;
}
-};
+}
+
+// 개발 환경을 위한 초기 데이터 생성 (옵션)
+async function createInitialData(companyId) {
+ try {
+ // 에너지 사용량 데이터 생성
+ 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,
+ });
+ }
+
+ // 에너지 비용 데이터 생성
+ 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,
+ });
+ }
+
+ // 알림 데이터 생성
+ 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("Initial development data created successfully");
+ } catch (error) {
+ logger.error("Error creating initial data:", error);
+ throw error;
+ }
+}
module.exports = createInitialAdmin;
diff --git a/fems-api/yarn.lock b/fems-api/yarn.lock
index 646bb20..f80bfdf 100644
--- a/fems-api/yarn.lock
+++ b/fems-api/yarn.lock
@@ -3563,6 +3563,11 @@ utils-merge@1.0.1:
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
+uuid@^11.0.2:
+ version "11.0.2"
+ resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.0.2.tgz#a8d68ba7347d051e7ea716cc8dcbbab634d66875"
+ integrity sha512-14FfcOJmqdjbBPdDjFQyk/SdT4NySW4eM0zcG+HqbHP5jzuH56xO3J1DGhgs/cEMCfwYi3HQI1gnTO62iaG+tQ==
+
uuid@^8.3.2:
version "8.3.2"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
diff --git a/fems-app/package.json b/fems-app/package.json
index 8d7e55b..39947f9 100644
--- a/fems-app/package.json
+++ b/fems-app/package.json
@@ -13,22 +13,32 @@
"@hookform/resolvers": "^3.9.1",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.0",
+ "@radix-ui/react-popover": "^1.1.2",
+ "@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-toast": "^1.2.2",
"@tanstack/react-query": "^5.59.16",
+ "@tanstack/react-query-devtools": "^5.59.16",
+ "@types/js-cookie": "^3.0.6",
+ "axios": "^1.7.7",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
+ "date-fns": "^4.1.0",
+ "jose": "^5.9.6",
+ "js-cookie": "^3.0.5",
"lucide-react": "^0.454.0",
"next": "14.2.16",
"next-themes": "^0.3.0",
"providers": "^0.5.0",
"react": "^18",
+ "react-day-picker": "^9.2.1",
"react-dom": "^18",
"react-hook-form": "^7.53.1",
"recharts": "^2.13.3",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7",
- "zod": "^3.23.8"
+ "zod": "^3.23.8",
+ "zustand": "^5.0.1"
},
"devDependencies": {
"@types/node": "^20",
diff --git a/fems-app/src/app/(admin)/dashboard/overview/page.tsx b/fems-app/src/app/(admin)/dashboard/overview/page.tsx
deleted file mode 100644
index aef7f15..0000000
--- a/fems-app/src/app/(admin)/dashboard/overview/page.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-// src/app/(admin)/dashboard/overview/page.tsx
-import { Card } from "@/components/ui/card";
-import { UsageChart } from "@/components/charts/UsageChart";
-import { CostChart } from "@/components/charts/CostChart";
-import { Alert } from "@/components/ui/alert";
-
-export default function DashboardOverviewPage() {
- return (
-
-
전체 현황
-
-
-
-
- 금월 전력 사용량
-
-
- 전월 대비 5% 감소
-
- {/* ... 다른 카드들 */}
-
-
-
-
- 에너지 사용 추이
-
-
-
- 에너지원별 비용
-
-
-
-
-
-
최근 알림
-
- {/* ... 다른 알림들 */}
-
-
- );
-}
diff --git a/fems-app/src/app/(admin)/layout.tsx b/fems-app/src/app/(admin)/layout.tsx
index 47db0d8..637cb0c 100644
--- a/fems-app/src/app/(admin)/layout.tsx
+++ b/fems-app/src/app/(admin)/layout.tsx
@@ -1,19 +1,29 @@
// src/app/(admin)/layout.tsx
-import { SideNav } from '@/components/layout/SideNav';
-import { TopNav } from '@/components/layout/TopNav';
+import { useAuth } from "@/hooks/useAuth";
+// import { SideNav } from '@/components/layout/SideNav';
+import { AdminSidebar } from "@/components/admin/AdminSidebar";
+import { TopNav } from "@/components/layout/TopNav";
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
+ const { user } = useAuth();
+ const isAdmin =
+ user?.role === "super_admin" || user?.role === "company_admin";
+
+ if (!isAdmin) {
+ return 접근 권한이 없습니다.
;
+ }
+
return (
);
-}
\ No newline at end of file
+}
diff --git a/fems-app/src/app/(alarm)/layout.tsx b/fems-app/src/app/(alarm)/layout.tsx
new file mode 100644
index 0000000..e69de29
diff --git a/fems-app/src/app/(analysis)/layout.tsx b/fems-app/src/app/(analysis)/layout.tsx
new file mode 100644
index 0000000..e69de29
diff --git a/fems-app/src/app/(auth)/layout.tsx b/fems-app/src/app/(auth)/layout.tsx
index 0f02b89..ab9e79a 100644
--- a/fems-app/src/app/(auth)/layout.tsx
+++ b/fems-app/src/app/(auth)/layout.tsx
@@ -1,12 +1,29 @@
// src/app/(auth)/layout.tsx
+// 인증이 필요한 페이지를 위한 레이아웃
+"use client";
+
+import { useEffect } from "react";
+import { useRouter, usePathname } from "next/navigation";
+import { useAuthStore } from "@/stores/auth";
+
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
- return (
-
- {children}
-
- );
+ const router = useRouter();
+ const pathname = usePathname();
+ const { token } = useAuthStore();
+
+ useEffect(() => {
+ if (!token && pathname !== "/login") {
+ router.push("/login");
+ }
+ // 이미 로그인된 상태에서 로그인 페이지 접근 시 대시보드로
+ if (token && pathname === "/login") {
+ router.push("/dashboard/overview"); // 그룹 이름 제거
+ }
+ }, [token, pathname, router]);
+
+ return <>{children}>;
}
diff --git a/fems-app/src/app/(auth)/login/page.tsx b/fems-app/src/app/(auth)/login/page.tsx
index ab78e7c..770b01c 100644
--- a/fems-app/src/app/(auth)/login/page.tsx
+++ b/fems-app/src/app/(auth)/login/page.tsx
@@ -1,10 +1,10 @@
// src/app/(auth)/login/page.tsx
-'use client'
+"use client";
-import { zodResolver } from '@hookform/resolvers/zod'
-import { useForm } from 'react-hook-form'
-import * as z from 'zod'
-import { Button } from '@/components/ui/button'
+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,
@@ -12,67 +12,130 @@ import {
FormItem,
FormLabel,
FormMessage,
-} from '@/components/ui/form'
-import { Input } from '@/components/ui/input'
-import { useAuth } from '@/hooks/useAuth'
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { useAuth } from "@/hooks/useAuth";
+import { useState } from "react";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import {
+ Card,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+ CardContent,
+} from "@/components/ui/card";
+import Link from "next/link";
+import { Loader2 } from "lucide-react"; // 로딩 아이콘
const formSchema = z.object({
- username: z.string().min(4, '아이디는 4자 이상이어야 합니다'),
- password: z.string().min(6, '비밀번호는 6자 이상이어야 합니다'),
-})
+ username: z.string().min(4, "아이디는 4자 이상이어야 합니다"),
+ password: z.string().min(6, "비밀번호는 6자 이상이어야 합니다"),
+});
export default function LoginPage() {
- const { login } = useAuth()
+ const { login } = useAuth();
+ const [error, setError] = useState("");
+ const [isLoading, setIsLoading] = useState(false);
+
const form = useForm>({
resolver: zodResolver(formSchema),
- })
+ defaultValues: {
+ // 여기에 초기값 추가
+ username: "",
+ password: "",
+ },
+ });
async function onSubmit(values: z.infer) {
try {
- await login(values)
+ setIsLoading(true);
+ setError("");
+ await login(values);
} catch (error) {
- console.error(error)
+ console.error(error);
+ setError("로그인에 실패했습니다.");
+ } finally {
+ setIsLoading(false);
}
}
return (
-
-
- )
-}
\ No newline at end of file
+ );
+}
diff --git a/fems-app/src/app/(dashboard)/layout.tsx b/fems-app/src/app/(dashboard)/layout.tsx
deleted file mode 100644
index eacd0ba..0000000
--- a/fems-app/src/app/(dashboard)/layout.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-// src/app/(dashboard)/layout.tsx
-import { SideNav } from '@/components/layout/SideNav'
-import { TopNav } from '@/components/layout/TopNav'
-
-export default function DashboardLayout({
- children,
-}: {
- children: React.ReactNode
-}) {
- return (
-
- )
-}
\ No newline at end of file
diff --git a/fems-app/src/app/(equipment)/layout.tsx b/fems-app/src/app/(equipment)/layout.tsx
new file mode 100644
index 0000000..e69de29
diff --git a/fems-app/src/app/(general)/dashboard/overview/page.tsx b/fems-app/src/app/(general)/dashboard/overview/page.tsx
new file mode 100644
index 0000000..97711d9
--- /dev/null
+++ b/fems-app/src/app/(general)/dashboard/overview/page.tsx
@@ -0,0 +1,196 @@
+// src/app/(general)/dashboard/overview/page.tsx
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ LineChart,
+ Line,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ ResponsiveContainer,
+ Legend,
+} from "recharts";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { AlertCircle, AlertTriangle, Info } from "lucide-react";
+import { formatNumber } from "@/lib/utils";
+import { api } from "@/lib/api";
+
+// API 응답 타입 정의
+interface EnergyUsage {
+ timestamp: string;
+ electricity: number;
+ gas: number;
+ water: number;
+ steam: number;
+}
+
+interface EnergyCost {
+ category: string;
+ amount: number;
+ previousAmount: number;
+ change: number;
+}
+
+interface AlertItem {
+ id: string;
+ type: "error" | "warning" | "info";
+ message: string;
+ createdAt: string;
+}
+
+export default function DashboardOverviewPage() {
+ // 에너지 사용량 데이터 불러오기
+ const { data: energyUsageData, isLoading: loadingUsage } = useQuery({
+ queryKey: ["energy-usage"],
+ queryFn: async () => {
+ const { data } = await api.get
(
+ "/api/v1/app/dashboard/energy-usage"
+ );
+ return data;
+ },
+ });
+
+ // 에너지 비용 데이터 불러오기
+ const { data: energyCostsData, isLoading: loadingCosts } = useQuery({
+ queryKey: ["energy-costs"],
+ queryFn: async () => {
+ const { data } = await api.get(
+ "/api/v1/app/dashboard/energy-costs"
+ );
+ return data;
+ },
+ });
+
+ // 알림 데이터 불러오기
+ const { data: alertsData, isLoading: loadingAlerts } = useQuery({
+ queryKey: ["alerts"],
+ queryFn: async () => {
+ const { data } = await api.get("/api/v1/app/dashboard/alerts");
+ return data;
+ },
+ });
+
+ if (loadingUsage || loadingCosts || loadingAlerts) {
+ return Loading...
; // 로딩 상태 표시
+ }
+
+ return (
+
+ {/* 에너지 비용 카드 */}
+
+ {energyCostsData?.map((cost) => (
+
+
+
+ {cost.category} 비용
+
+ 0 ? "text-red-500" : "text-green-500"
+ }`}
+ >
+ {cost.change > 0 ? "+" : ""}
+ {cost.change.toFixed(1)}%
+
+
+
+
+ {formatNumber(cost.amount)}원
+
+
+ 전월: {formatNumber(cost.previousAmount)}원
+
+
+
+ ))}
+
+
+ {/* 에너지 사용량 차트 */}
+
+
+ 에너지 사용량 추이
+
+
+
+
+
+
+ {
+ return new Date(value).toLocaleDateString("ko-KR", {
+ month: "short",
+ year: "numeric",
+ });
+ }}
+ />
+
+
+ new Date(label).toLocaleDateString("ko-KR")
+ }
+ formatter={(value) => [`${value} kWh`, ""]}
+ />
+
+
+
+
+
+
+
+
+
+
+
+ {/* 알림 섹션 */}
+
+
+ 최근 알림
+
+
+ {alertsData?.map((alert) => (
+
+ {alert.type === "error" && }
+ {alert.type === "warning" && (
+
+ )}
+ {alert.type === "info" && }
+
+ {alert.message}
+
+ {new Date(alert.createdAt).toLocaleTimeString()}
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/fems-app/src/app/(general)/layout.tsx b/fems-app/src/app/(general)/layout.tsx
new file mode 100644
index 0000000..de09b11
--- /dev/null
+++ b/fems-app/src/app/(general)/layout.tsx
@@ -0,0 +1,28 @@
+// src/(general)/layout.tsx
+import React from "react";
+import { GeneralSidebar } from "@/components/general/GeneralSidebar";
+import { GeneralHeader } from "@/components/general/GeneralHeader";
+
+const GeneralLayout = ({ children }: { children: React.ReactNode }) => {
+ return (
+
+ {/* 왼쪽 사이드바 */}
+
+
+ {/* 오른쪽 메인 영역 */}
+
+ {/* 상단 헤더 */}
+
+
+ {/* 메인 컨텐츠 영역 */}
+ {children}
+
+
+ );
+};
+
+export default GeneralLayout;
diff --git a/fems-app/src/app/(general)/settings/page.tsx b/fems-app/src/app/(general)/settings/page.tsx
new file mode 100644
index 0000000..e69de29
diff --git a/fems-app/src/app/(monitoring)/layout.tsx b/fems-app/src/app/(monitoring)/layout.tsx
new file mode 100644
index 0000000..e2ae48d
--- /dev/null
+++ b/fems-app/src/app/(monitoring)/layout.tsx
@@ -0,0 +1,19 @@
+// src/app/(monitoring)/layout.tsx
+import { MonitoringSidebar } from "@/components/monitoring/MonitoringSidebar";
+import { MonitoringHeader } from "@/components/monitoring/MonitoringHeader";
+
+export default function MonitoringLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+ );
+}
diff --git a/fems-app/src/app/layout.tsx b/fems-app/src/app/layout.tsx
index b44d46d..34be526 100644
--- a/fems-app/src/app/layout.tsx
+++ b/fems-app/src/app/layout.tsx
@@ -1,21 +1,23 @@
// src/app/layout.tsx
-import { Inter } from 'next/font/google'
-import './globals.css'
-import { Providers } from '@/providers'
+import type { Metadata } from "next";
+import "./globals.css";
+import { Providers } from "@/providers";
-const inter = Inter({ subsets: ['latin'] })
+export const metadata: Metadata = {
+ title: "FEMS",
+ description: "Factory Energy Management System",
+};
export default function RootLayout({
children,
}: {
- children: React.ReactNode
+ children: React.ReactNode;
}) {
return (
-
+
{children}
- )
+ );
}
-
diff --git a/fems-app/src/components/admin/AdminSidebar.tsx b/fems-app/src/components/admin/AdminSidebar.tsx
new file mode 100644
index 0000000..a6df18d
--- /dev/null
+++ b/fems-app/src/components/admin/AdminSidebar.tsx
@@ -0,0 +1,81 @@
+// src/components/admin/AdminSidebar.tsx
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { cn } from "@/lib/utils";
+import { MenuIcon } from "lucide-react";
+
+const menuItems = [
+ {
+ title: "대시보드",
+ items: [
+ { title: "전체 현황", href: "/dashboard/overview", icon: MenuIcon },
+ { title: "KPI 지표", href: "/dashboard/kpi" },
+ { title: "비용 현황", href: "/dashboard/costs" },
+ ],
+ },
+ {
+ title: "에너지 모니터링",
+ items: [
+ { title: "전력", href: "/monitoring/electricity" },
+ { title: "가스", href: "/monitoring/gas" },
+ { title: "용수", href: "/monitoring/water" },
+ { title: "스팀", href: "/monitoring/steam" },
+ ],
+ },
+ {
+ title: "설비 관리",
+ items: [
+ { title: "설비 목록", href: "/equipment/inventory" },
+ { title: "상태 모니터링", href: "/equipment/monitoring" },
+ { title: "정비 관리", href: "/equipment/maintenance" },
+ ],
+ },
+ {
+ title: "시스템 관리",
+ items: [
+ { title: "사용자 관리", href: "/system/users" },
+ { title: "알림 관리", href: "/system/alerts" },
+ { title: "설정", href: "/system/settings" },
+ ],
+ },
+];
+
+export function AdminSidebar() {
+ const pathname = usePathname();
+
+ return (
+
+ );
+}
diff --git a/fems-app/src/components/general/GeneralHeader.tsx b/fems-app/src/components/general/GeneralHeader.tsx
new file mode 100644
index 0000000..f996902
--- /dev/null
+++ b/fems-app/src/components/general/GeneralHeader.tsx
@@ -0,0 +1,33 @@
+// src/components/general/GeneralHeader.tsx
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { useAuth } from "@/hooks/useAuth";
+
+export function GeneralHeader() {
+ const { user, logout } = useAuth();
+
+ return (
+
+ );
+}
diff --git a/fems-app/src/components/general/GeneralSidebar.tsx b/fems-app/src/components/general/GeneralSidebar.tsx
new file mode 100644
index 0000000..8beb57f
--- /dev/null
+++ b/fems-app/src/components/general/GeneralSidebar.tsx
@@ -0,0 +1,81 @@
+// src/components/layout/GenericSidebar.tsx
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { cn } from "@/lib/utils";
+import { MenuIcon } from "lucide-react";
+
+const menuItems = [
+ {
+ title: "대시보드",
+ items: [
+ { title: "전체 현황", href: "/dashboard/overview", icon: MenuIcon },
+ { title: "KPI 지표", href: "/dashboard/kpi" },
+ { title: "비용 현황", href: "/dashboard/costs" },
+ ],
+ },
+ {
+ title: "에너지 모니터링",
+ items: [
+ { title: "전력", href: "/monitoring/electricity" },
+ { title: "가스", href: "/monitoring/gas" },
+ { title: "용수", href: "/monitoring/water" },
+ { title: "스팀", href: "/monitoring/steam" },
+ ],
+ },
+ {
+ title: "설비 관리",
+ items: [
+ { title: "설비 목록", href: "/equipment/inventory" },
+ { title: "상태 모니터링", href: "/equipment/monitoring" },
+ { title: "정비 관리", href: "/equipment/maintenance" },
+ ],
+ },
+ {
+ title: "시스템 관리",
+ items: [
+ { title: "사용자 관리", href: "/system/users" },
+ { title: "알림 관리", href: "/system/alerts" },
+ { title: "설정", href: "/system/settings" },
+ ],
+ },
+];
+
+export function GeneralSidebar() {
+ const pathname = usePathname();
+
+ return (
+
+ );
+}
diff --git a/fems-app/src/components/layout/SideNav.tsx b/fems-app/src/components/layout/SideNav.tsx
index 35e94d5..778f60f 100644
--- a/fems-app/src/components/layout/SideNav.tsx
+++ b/fems-app/src/components/layout/SideNav.tsx
@@ -1,4 +1,6 @@
// src/components/layout/SideNav.tsx
+"use client";
+
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
diff --git a/fems-app/src/components/monitoring/MonitoringHeader.tsx b/fems-app/src/components/monitoring/MonitoringHeader.tsx
new file mode 100644
index 0000000..dcff4bc
--- /dev/null
+++ b/fems-app/src/components/monitoring/MonitoringHeader.tsx
@@ -0,0 +1,56 @@
+// src/components/monitoring/MonitoringHeader.tsx
+"use client";
+
+import { usePathname } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { DatePickerWithRange } from "@/components/ui/date-range-picker";
+import { Download, RefreshCw } from "lucide-react";
+
+export function MonitoringHeader() {
+ const pathname = usePathname();
+ const pageTitles: { [key: string]: string } = {
+ "/monitoring/electricity": "전력 모니터링",
+ "/monitoring/gas": "가스 모니터링",
+ "/monitoring/water": "용수 모니터링",
+ "/monitoring/steam": "스팀 모니터링",
+ };
+
+ return (
+
+
+
{pageTitles[pathname]}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/fems-app/src/components/monitoring/MonitoringSidebar.tsx b/fems-app/src/components/monitoring/MonitoringSidebar.tsx
new file mode 100644
index 0000000..056b8c6
--- /dev/null
+++ b/fems-app/src/components/monitoring/MonitoringSidebar.tsx
@@ -0,0 +1,103 @@
+// src/components/monitoring/MonitoringSidebar.tsx
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { cn } from "@/lib/utils";
+import {
+ Zap,
+ Flame,
+ Droplets,
+ Wind,
+ BarChart3,
+ AlertTriangle,
+ Settings,
+} from "lucide-react";
+
+const menuItems = [
+ {
+ 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: "분석",
+ items: [
+ {
+ title: "에너지 분석",
+ icon: BarChart3,
+ href: "/monitoring/analysis",
+ },
+ {
+ title: "알람 이력",
+ icon: AlertTriangle,
+ href: "/monitoring/alerts",
+ },
+ ],
+ },
+ {
+ title: "설정",
+ items: [
+ {
+ title: "모니터링 설정",
+ icon: Settings,
+ href: "/monitoring/settings",
+ },
+ ],
+ },
+];
+
+export function MonitoringSidebar() {
+ const pathname = usePathname();
+
+ return (
+
+
+ {menuItems.map((section) => (
+
+
+ {section.title}
+
+
+ {section.items.map((item) => (
+
+
+ {item.title}
+
+ ))}
+
+
+ ))}
+
+
+ );
+}
diff --git a/fems-app/src/components/ui/calendar.tsx b/fems-app/src/components/ui/calendar.tsx
new file mode 100644
index 0000000..2f02434
--- /dev/null
+++ b/fems-app/src/components/ui/calendar.tsx
@@ -0,0 +1,66 @@
+"use client"
+
+import * as React from "react"
+import { ChevronLeft, ChevronRight } from "lucide-react"
+import { DayPicker } from "react-day-picker"
+
+import { cn } from "@/lib/utils"
+import { buttonVariants } from "@/components/ui/button"
+
+export type CalendarProps = React.ComponentProps
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ ...props
+}: CalendarProps) {
+ return (
+ ,
+ IconRight: ({ ...props }) => ,
+ }}
+ {...props}
+ />
+ )
+}
+Calendar.displayName = "Calendar"
+
+export { Calendar }
diff --git a/fems-app/src/components/ui/date-range-picker.tsx b/fems-app/src/components/ui/date-range-picker.tsx
new file mode 100644
index 0000000..d1eaaed
--- /dev/null
+++ b/fems-app/src/components/ui/date-range-picker.tsx
@@ -0,0 +1,70 @@
+// 날짜 선택기 컴포넌트 예시
+// src/components/ui/date-range-picker.tsx
+"use client";
+
+import * as React from "react";
+import { format } from "date-fns";
+import { Calendar as CalendarIcon } from "lucide-react";
+import { DateRange } from "react-day-picker";
+import { ko } from "date-fns/locale";
+
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import { Calendar } from "@/components/ui/calendar";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+
+interface DatePickerWithRangeProps {
+ className?: string;
+}
+
+export function DatePickerWithRange({ className }: DatePickerWithRangeProps) {
+ const [date, setDate] = React.useState({
+ from: new Date(new Date().setDate(new Date().getDate() - 7)),
+ to: new Date(),
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/fems-app/src/components/ui/popover.tsx b/fems-app/src/components/ui/popover.tsx
new file mode 100644
index 0000000..a0ec48b
--- /dev/null
+++ b/fems-app/src/components/ui/popover.tsx
@@ -0,0 +1,31 @@
+"use client"
+
+import * as React from "react"
+import * as PopoverPrimitive from "@radix-ui/react-popover"
+
+import { cn } from "@/lib/utils"
+
+const Popover = PopoverPrimitive.Root
+
+const PopoverTrigger = PopoverPrimitive.Trigger
+
+const PopoverContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
+
+
+
+))
+PopoverContent.displayName = PopoverPrimitive.Content.displayName
+
+export { Popover, PopoverTrigger, PopoverContent }
diff --git a/fems-app/src/components/ui/select.tsx b/fems-app/src/components/ui/select.tsx
new file mode 100644
index 0000000..cbe5a36
--- /dev/null
+++ b/fems-app/src/components/ui/select.tsx
@@ -0,0 +1,160 @@
+"use client"
+
+import * as React from "react"
+import * as SelectPrimitive from "@radix-ui/react-select"
+import { Check, ChevronDown, ChevronUp } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Select = SelectPrimitive.Root
+
+const SelectGroup = SelectPrimitive.Group
+
+const SelectValue = SelectPrimitive.Value
+
+const SelectTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+ span]:line-clamp-1",
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+
+))
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
+
+const SelectScrollUpButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
+
+const SelectScrollDownButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+SelectScrollDownButton.displayName =
+ SelectPrimitive.ScrollDownButton.displayName
+
+const SelectContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, position = "popper", ...props }, ref) => (
+
+
+
+
+ {children}
+
+
+
+
+))
+SelectContent.displayName = SelectPrimitive.Content.displayName
+
+const SelectLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SelectLabel.displayName = SelectPrimitive.Label.displayName
+
+const SelectItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+
+ {children}
+
+))
+SelectItem.displayName = SelectPrimitive.Item.displayName
+
+const SelectSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName
+
+export {
+ Select,
+ SelectGroup,
+ SelectValue,
+ SelectTrigger,
+ SelectContent,
+ SelectLabel,
+ SelectItem,
+ SelectSeparator,
+ SelectScrollUpButton,
+ SelectScrollDownButton,
+}
diff --git a/fems-app/src/hooks/useAuth.ts b/fems-app/src/hooks/useAuth.ts
index 98101a2..16eebd1 100644
--- a/fems-app/src/hooks/useAuth.ts
+++ b/fems-app/src/hooks/useAuth.ts
@@ -1,12 +1,17 @@
// src/hooks/useAuth.ts
+"use client";
+
import { useRouter } from "next/navigation";
import { useAuthStore } from "@/stores/auth";
+import Cookies from "js-cookie";
import { api } from "@/lib/api";
export function useAuth() {
const router = useRouter();
const { user, token, setAuth, clearAuth } = useAuthStore();
+ // console.log("user", user);
+
const login = async ({
username,
password,
@@ -15,9 +20,17 @@ export function useAuth() {
password: string;
}) => {
try {
- const { data } = await api.post("/auth/login", { username, password });
+ const { data } = await api.post("/api/v1/app/auth/login", {
+ username,
+ password,
+ });
setAuth(data.user, data.token);
- router.push("/dashboard/overview");
+ // console.log("data", data);
+
+ // Set the token in a cookie
+ Cookies.set("token", data.token, { path: "/" });
+
+ router.push("/dashboard/overview"); // 그룹 이름 제거
} catch (error) {
throw error;
}
diff --git a/fems-app/src/lib/jwt.ts b/fems-app/src/lib/jwt.ts
new file mode 100644
index 0000000..f76cbbb
--- /dev/null
+++ b/fems-app/src/lib/jwt.ts
@@ -0,0 +1,24 @@
+// src/lib/jwt.ts
+import * as jose from "jose";
+import { UserRole } from "@/types/auth";
+
+interface JwtPayload {
+ id: string;
+ role: UserRole;
+ companyId: string;
+ branchId: string;
+}
+
+export function decodeToken(token: string): JwtPayload | null {
+ try {
+ // jose를 사용한 디코딩
+ const decoded = jose.decodeJwt(token);
+ const payload = decoded as unknown as JwtPayload;
+ if (payload.id && payload.role && payload.companyId && payload.branchId) {
+ return payload;
+ }
+ return null;
+ } catch {
+ return null;
+ }
+}
diff --git a/fems-app/src/lib/utils.ts b/fems-app/src/lib/utils.ts
index bd0c391..2768091 100644
--- a/fems-app/src/lib/utils.ts
+++ b/fems-app/src/lib/utils.ts
@@ -1,6 +1,10 @@
-import { clsx, type ClassValue } from "clsx"
-import { twMerge } from "tailwind-merge"
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
- return twMerge(clsx(inputs))
+ return twMerge(clsx(inputs));
+}
+
+export function formatNumber(num: number): string {
+ return new Intl.NumberFormat("ko-KR").format(num);
}
diff --git a/fems-app/src/middleware.tsx b/fems-app/src/middleware.tsx
new file mode 100644
index 0000000..e4e5208
--- /dev/null
+++ b/fems-app/src/middleware.tsx
@@ -0,0 +1,38 @@
+// src/middleware.ts
+import { NextResponse } from "next/server";
+import type { NextRequest } from "next/server";
+import { decodeToken } from "@/lib/jwt";
+import type { UserRole } from "@/types/auth";
+
+function getUserRole(token: string | undefined): UserRole | null {
+ if (!token) return null;
+
+ const decodedToken = decodeToken(token);
+ return decodedToken?.role || null;
+}
+
+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 (request.nextUrl.pathname.startsWith("/admin")) {
+ const role = getUserRole(token);
+ if (
+ !role ||
+ !["super_admin", "company_admin", "branch_admin", "user"].includes(role)
+ ) {
+ return NextResponse.redirect(new URL("/dashboard/overview", request.url));
+ }
+ }
+
+ return NextResponse.next();
+}
+
+export const config = {
+ matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
+};
diff --git a/fems-app/src/providers.tsx b/fems-app/src/providers.tsx
index 05d577a..805366d 100644
--- a/fems-app/src/providers.tsx
+++ b/fems-app/src/providers.tsx
@@ -1,10 +1,13 @@
// src/providers.tsx
-'use client'
+"use client";
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import { ThemeProvider } from 'next-themes'
-import { Toaster } from '@/components/ui/toaster'
-import { useState } from 'react'
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; // 개발 도구 추가
+import { useAuthStore } from "@/stores/auth";
+import { useEffect } from "react";
+import { api } from "@/lib/api";
+import { useState } from "react";
+import Cookies from "js-cookie";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
@@ -12,20 +15,56 @@ export function Providers({ children }: { children: React.ReactNode }) {
new QueryClient({
defaultOptions: {
queries: {
- staleTime: 60 * 1000,
- retry: 1,
- refetchOnWindowFocus: false,
+ staleTime: 60 * 1000, // 1분 동안 데이터를 'fresh'하게 유지
+ retry: 1, // 실패시 1번만 재시도
+ refetchOnWindowFocus: false, // 윈도우 포커스시 자동 리페치 비활성화
},
},
})
- )
+ );
+
+ const token = useAuthStore((state) => state.token);
+ const clearAuth = useAuthStore((state) => state.clearAuth); // 로그아웃 함수 추가
+
+ // API 인터셉터 설정
+ useEffect(() => {
+ // 요청 인터셉터
+ const requestInterceptor = api.interceptors.request.use(
+ (config) => {
+ if (token) {
+ config.headers.Authorization = `Bearer ${token}`;
+ }
+ return config;
+ },
+ (error) => {
+ return Promise.reject(error);
+ }
+ );
+
+ // 응답 인터셉터
+ const responseInterceptor = api.interceptors.response.use(
+ (response) => response,
+ (error) => {
+ // 401 에러시 자동 로그아웃
+ if (error.response?.status === 401) {
+ clearAuth();
+ Cookies.remove("token", { path: "/" });
+ }
+ return Promise.reject(error);
+ }
+ );
+
+ // 클린업 함수
+ return () => {
+ api.interceptors.request.eject(requestInterceptor);
+ api.interceptors.response.eject(responseInterceptor);
+ };
+ }, [token, clearAuth]);
return (
-
- {children}
- {/* 토스트 알림 */}
-
+ {children}
+ {process.env.NODE_ENV === "development" && }
- )
-}
\ No newline at end of file
+ );
+}
diff --git a/fems-app/src/stores/auth.ts b/fems-app/src/stores/auth.ts
index 16c525f..fe35d40 100644
--- a/fems-app/src/stores/auth.ts
+++ b/fems-app/src/stores/auth.ts
@@ -1,30 +1,25 @@
-// src/stores/auth.ts
-import { create } from 'zustand'
-import { persist } from 'zustand/middleware'
-
-interface User {
- id: string
- name: string
- role: string
-}
-
-interface AuthState {
- user: User | null
- token: string | null
- setAuth: (user: User, token: string) => void
- clearAuth: () => void
-}
-
-export const useAuthStore = create()(
- persist(
- (set) => ({
- user: null,
- token: null,
- setAuth: (user, token) => set({ user, token }),
- clearAuth: () => set({ user: null, token: null }),
- }),
- {
- name: 'auth-storage',
- }
- )
-)
\ No newline at end of file
+ // src/stores/auth.ts
+ import { create } from 'zustand';
+ import { persist } from 'zustand/middleware';
+ import type { User } from '@/types/auth';
+
+ interface AuthState {
+ user: User | null;
+ token: string | null;
+ setAuth: (user: User, token: string) => void;
+ clearAuth: () => void;
+ }
+
+ export const useAuthStore = create()(
+ persist(
+ (set) => ({
+ user: null,
+ token: null,
+ setAuth: (user, token) => set({ user, token }),
+ clearAuth: () => set({ user: null, token: null }),
+ }),
+ {
+ name: 'auth-storage', // localStorage에 저장될 키 이름
+ }
+ )
+ );
\ No newline at end of file
diff --git a/fems-app/src/types/auth.ts b/fems-app/src/types/auth.ts
new file mode 100644
index 0000000..d3d853d
--- /dev/null
+++ b/fems-app/src/types/auth.ts
@@ -0,0 +1,16 @@
+// src/types/auth.ts
+export type UserRole =
+ | "super_admin"
+ | "company_admin"
+ | "branch_admin"
+ | "user";
+
+export interface User {
+ id: string;
+ username: string;
+ name: string;
+ email: string;
+ role: UserRole;
+ companyId: string;
+ branchId?: string;
+}
diff --git a/fems-app/src/types/dashboard.ts b/fems-app/src/types/dashboard.ts
new file mode 100644
index 0000000..6e1a084
--- /dev/null
+++ b/fems-app/src/types/dashboard.ts
@@ -0,0 +1,22 @@
+// src/types/dashboard.ts
+export interface EnergyUsage {
+ timestamp: string;
+ electricity: number;
+ gas: number;
+ water: number;
+ steam: number;
+}
+
+export interface CostData {
+ category: string;
+ amount: number;
+ previousAmount: number;
+ change: number;
+}
+
+export interface AlertItem {
+ id: string;
+ type: "warning" | "error" | "info";
+ message: string;
+ timestamp: string;
+}
diff --git a/fems-app/yarn.lock b/fems-app/yarn.lock
index ac9188a..b775b36 100644
--- a/fems-app/yarn.lock
+++ b/fems-app/yarn.lock
@@ -14,6 +14,11 @@
dependencies:
regenerator-runtime "^0.14.0"
+"@date-fns/tz@^1.1.2":
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/@date-fns/tz/-/tz-1.2.0.tgz#81cb3211693830babaf3b96aff51607e143030a6"
+ integrity sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==
+
"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0":
version "4.4.1"
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz#d1145bf2c20132d6400495d6df4bf59362fd9d56"
@@ -229,6 +234,11 @@
resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"
integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==
+"@radix-ui/number@1.1.0":
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/@radix-ui/number/-/number-1.1.0.tgz#1e95610461a09cdf8bb05c152e76ca1278d5da46"
+ integrity sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==
+
"@radix-ui/primitive@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.0.tgz#42ef83b3b56dccad5d703ae8c42919a68798bbe2"
@@ -347,6 +357,27 @@
aria-hidden "^1.1.1"
react-remove-scroll "2.6.0"
+"@radix-ui/react-popover@^1.1.2":
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/@radix-ui/react-popover/-/react-popover-1.1.2.tgz#a0cab25f69aa49ad0077d91e9e9dcd323758020c"
+ integrity sha512-u2HRUyWW+lOiA2g0Le0tMmT55FGOEWHwPFt1EPfbLly7uXQExFo5duNKqG2DzmFXIdqOeNd+TpE8baHWJCyP9w==
+ dependencies:
+ "@radix-ui/primitive" "1.1.0"
+ "@radix-ui/react-compose-refs" "1.1.0"
+ "@radix-ui/react-context" "1.1.1"
+ "@radix-ui/react-dismissable-layer" "1.1.1"
+ "@radix-ui/react-focus-guards" "1.1.1"
+ "@radix-ui/react-focus-scope" "1.1.0"
+ "@radix-ui/react-id" "1.1.0"
+ "@radix-ui/react-popper" "1.2.0"
+ "@radix-ui/react-portal" "1.1.2"
+ "@radix-ui/react-presence" "1.1.1"
+ "@radix-ui/react-primitive" "2.0.0"
+ "@radix-ui/react-slot" "1.1.0"
+ "@radix-ui/react-use-controllable-state" "1.1.0"
+ aria-hidden "^1.1.1"
+ react-remove-scroll "2.6.0"
+
"@radix-ui/react-popper@1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-popper/-/react-popper-1.2.0.tgz#a3e500193d144fe2d8f5d5e60e393d64111f2a7a"
@@ -401,6 +432,33 @@
"@radix-ui/react-use-callback-ref" "1.1.0"
"@radix-ui/react-use-controllable-state" "1.1.0"
+"@radix-ui/react-select@^2.1.2":
+ version "2.1.2"
+ resolved "https://registry.yarnpkg.com/@radix-ui/react-select/-/react-select-2.1.2.tgz#2346e118966db793940f6a866fd4cc5db2cc275e"
+ integrity sha512-rZJtWmorC7dFRi0owDmoijm6nSJH1tVw64QGiNIZ9PNLyBDtG+iAq+XGsya052At4BfarzY/Dhv9wrrUr6IMZA==
+ dependencies:
+ "@radix-ui/number" "1.1.0"
+ "@radix-ui/primitive" "1.1.0"
+ "@radix-ui/react-collection" "1.1.0"
+ "@radix-ui/react-compose-refs" "1.1.0"
+ "@radix-ui/react-context" "1.1.1"
+ "@radix-ui/react-direction" "1.1.0"
+ "@radix-ui/react-dismissable-layer" "1.1.1"
+ "@radix-ui/react-focus-guards" "1.1.1"
+ "@radix-ui/react-focus-scope" "1.1.0"
+ "@radix-ui/react-id" "1.1.0"
+ "@radix-ui/react-popper" "1.2.0"
+ "@radix-ui/react-portal" "1.1.2"
+ "@radix-ui/react-primitive" "2.0.0"
+ "@radix-ui/react-slot" "1.1.0"
+ "@radix-ui/react-use-callback-ref" "1.1.0"
+ "@radix-ui/react-use-controllable-state" "1.1.0"
+ "@radix-ui/react-use-layout-effect" "1.1.0"
+ "@radix-ui/react-use-previous" "1.1.0"
+ "@radix-ui/react-visually-hidden" "1.1.0"
+ aria-hidden "^1.1.1"
+ react-remove-scroll "2.6.0"
+
"@radix-ui/react-slot@1.1.0", "@radix-ui/react-slot@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.1.0.tgz#7c5e48c36ef5496d97b08f1357bb26ed7c714b84"
@@ -450,6 +508,11 @@
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz#3c2c8ce04827b26a39e442ff4888d9212268bd27"
integrity sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==
+"@radix-ui/react-use-previous@1.1.0":
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/@radix-ui/react-use-previous/-/react-use-previous-1.1.0.tgz#d4dd37b05520f1d996a384eb469320c2ada8377c"
+ integrity sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==
+
"@radix-ui/react-use-rect@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz#13b25b913bd3e3987cc9b073a1a164bb1cf47b88"
@@ -504,6 +567,18 @@
resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.59.16.tgz#aa4616e8a9c12afeef4cfbf3ed0f55f404d66e67"
integrity sha512-crHn+G3ltqb5JG0oUv6q+PMz1m1YkjpASrXTU+sYWW9pLk0t2GybUHNRqYPZWhxgjPaVGC4yp92gSFEJgYEsPw==
+"@tanstack/query-devtools@5.58.0":
+ version "5.58.0"
+ resolved "https://registry.yarnpkg.com/@tanstack/query-devtools/-/query-devtools-5.58.0.tgz#5c68ce90562e154004de4372bc0a28fcd94cdf31"
+ integrity sha512-iFdQEFXaYYxqgrv63ots+65FGI+tNp5ZS5PdMU1DWisxk3fez5HG3FyVlbUva+RdYS5hSLbxZ9aw3yEs97GNTw==
+
+"@tanstack/react-query-devtools@^5.59.16":
+ version "5.59.16"
+ resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-5.59.16.tgz#f058e3ba146b97a4763b886074581d66cc1e5cf7"
+ integrity sha512-Dejo39QBXmDqXZ3vdrk7vHDvs7TvL573/AX2NveMBmRAufAPYuE3oWSKP/gGqkDfEqyr4CmldOj+v9cKskUchQ==
+ dependencies:
+ "@tanstack/query-devtools" "5.58.0"
+
"@tanstack/react-query@^5.59.16":
version "5.59.16"
resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.59.16.tgz#1e701c6e6681965c04aa426df9da54b8edc6db1b"
@@ -562,6 +637,11 @@
resolved "https://registry.yarnpkg.com/@types/d3-timer/-/d3-timer-3.0.2.tgz#70bbda77dc23aa727413e22e214afa3f0e852f70"
integrity sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==
+"@types/js-cookie@^3.0.6":
+ version "3.0.6"
+ resolved "https://registry.yarnpkg.com/@types/js-cookie/-/js-cookie-3.0.6.tgz#a04ca19e877687bd449f5ad37d33b104b71fdf95"
+ integrity sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==
+
"@types/json5@^0.0.29":
version "0.0.29"
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
@@ -851,6 +931,11 @@ ast-types-flow@^0.0.8:
resolved "https://registry.yarnpkg.com/ast-types-flow/-/ast-types-flow-0.0.8.tgz#0a85e1c92695769ac13a428bb653e7538bea27d6"
integrity sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==
+asynckit@^0.4.0:
+ version "0.4.0"
+ resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
+ integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==
+
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"
@@ -863,6 +948,15 @@ axe-core@^4.10.0:
resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.10.2.tgz#85228e3e1d8b8532a27659b332e39b7fa0e022df"
integrity sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w==
+axios@^1.7.7:
+ version "1.7.7"
+ resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.7.tgz#2f554296f9892a72ac8d8e4c5b79c14a91d0a47f"
+ integrity sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==
+ dependencies:
+ follow-redirects "^1.15.6"
+ form-data "^4.0.0"
+ proxy-from-env "^1.1.0"
+
axobject-query@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-4.1.0.tgz#28768c76d0e3cff21bc62a9e2d0b6ac30042a1ee"
@@ -990,6 +1084,13 @@ color-name@~1.1.4:
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
+combined-stream@^1.0.8:
+ version "1.0.8"
+ resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
+ integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
+ dependencies:
+ delayed-stream "~1.0.0"
+
commander@^4.0.0:
version "4.1.1"
resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068"
@@ -1122,6 +1223,11 @@ data-view-byte-offset@^1.0.0:
es-errors "^1.3.0"
is-data-view "^1.0.1"
+date-fns@^4.1.0:
+ version "4.1.0"
+ resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-4.1.0.tgz#64b3d83fff5aa80438f5b1a633c2e83b8a1c2d14"
+ integrity sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==
+
debug@^3.2.7:
version "3.2.7"
resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a"
@@ -1164,6 +1270,11 @@ define-properties@^1.1.3, define-properties@^1.2.0, define-properties@^1.2.1:
has-property-descriptors "^1.0.0"
object-keys "^1.1.1"
+delayed-stream@~1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
+ integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
+
detect-node-es@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493"
@@ -1635,6 +1746,11 @@ flatted@^3.2.9:
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.1.tgz#21db470729a6734d4997002f439cb308987f567a"
integrity sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==
+follow-redirects@^1.15.6:
+ version "1.15.9"
+ resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1"
+ integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==
+
for-each@^0.3.3:
version "0.3.3"
resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e"
@@ -1650,6 +1766,15 @@ foreground-child@^3.1.0:
cross-spawn "^7.0.0"
signal-exit "^4.0.1"
+form-data@^4.0.0:
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.1.tgz#ba1076daaaa5bfd7e99c1a6cb02aa0a5cff90d48"
+ integrity sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==
+ dependencies:
+ asynckit "^0.4.0"
+ combined-stream "^1.0.8"
+ mime-types "^2.1.12"
+
fs.realpath@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
@@ -2119,6 +2244,16 @@ jiti@^1.21.0:
resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.21.6.tgz#6c7f7398dd4b3142767f9a168af2f317a428d268"
integrity sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==
+jose@^5.9.6:
+ version "5.9.6"
+ resolved "https://registry.yarnpkg.com/jose/-/jose-5.9.6.tgz#77f1f901d88ebdc405e57cce08d2a91f47521883"
+ integrity sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==
+
+js-cookie@^3.0.5:
+ version "3.0.5"
+ resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.5.tgz#0b7e2fd0c01552c58ba86e0841f94dc2557dcdbc"
+ integrity sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==
+
"js-tokens@^3.0.0 || ^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
@@ -2252,6 +2387,18 @@ micromatch@^4.0.4, micromatch@^4.0.5:
braces "^3.0.3"
picomatch "^2.3.1"
+mime-db@1.52.0:
+ version "1.52.0"
+ resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
+ integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
+
+mime-types@^2.1.12:
+ version "2.1.35"
+ resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
+ integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
+ dependencies:
+ mime-db "1.52.0"
+
minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
@@ -2579,6 +2726,11 @@ providers@^0.5.0:
resolved "https://registry.yarnpkg.com/providers/-/providers-0.5.0.tgz#1f2940884672c37b954b1223aeb9e4eac2ad57ff"
integrity sha512-yd88Zn5myWdjxa6+UQYtqUmKoHBBUiswWy+3QLm8ufBnl4X368NoHEP1lL/Tjw75bP/9nfh7UXKbVMsMNCPX4w==
+proxy-from-env@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
+ integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
+
punycode@^2.1.0:
version "2.3.1"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
@@ -2589,6 +2741,14 @@ queue-microtask@^1.2.2:
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
+react-day-picker@^9.2.1:
+ version "9.2.1"
+ resolved "https://registry.yarnpkg.com/react-day-picker/-/react-day-picker-9.2.1.tgz#051bc8d0d72a1ea4c414bf7990a742c56a49d01b"
+ integrity sha512-rCoK4oJi9NBXt1nNdQFSa7gBG+hWsqVCtoLFLxvMzkVxDp+rSqsuUQ0LccJyLigwp/hX8XnAokTsT03+5lbjyA==
+ dependencies:
+ "@date-fns/tz" "^1.1.2"
+ date-fns "^4.1.0"
+
react-dom@^18:
version "18.3.1"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4"
@@ -3345,3 +3505,8 @@ zod@^3.23.8:
version "3.23.8"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d"
integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==
+
+zustand@^5.0.1:
+ version "5.0.1"
+ resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.1.tgz#2bdca5e4be172539558ce3974fe783174a48fdcf"
+ integrity sha512-pRET7Lao2z+n5R/HduXMio35TncTlSW68WsYBq2Lg1ASspsNGjpwLAsij3RpouyV6+kHMwwwzP0bZPD70/Jx/w==