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

전체 현황

- -
- -

- 금월 전력 사용량 -

-
-

2,453,890

-

kWh

-
-

전월 대비 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 (
- +
{children}
); -} \ 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 ( -
-
-

로그인

-
- - ( - - 아이디 - - - - - +
+ + + + 로그인 + + + FEMS에 오신 것을 환영합니다 + + + + + + {error && ( + + {error} + )} - /> - ( - - 비밀번호 - - - - - - )} - /> - - - -
+ + ( + + 아이디 + + + + + + )} + /> + + ( + + 비밀번호 + + + + + + )} + /> + + + +
+ + 비밀번호를 잊으셨나요? + + +
+ + 계정이 없으신가요? + + + 회원가입 + +
+
+ + + +
- ) -} \ 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 ( -
- -
- -
{children}
-
-
- ) -} \ 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 ( +
+ +
+ +
{children}
+
+
+ ); +} 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==