auto commit

This commit is contained in:
bangdk 2024-11-02 18:01:31 +09:00
parent d715a8c7a0
commit a3d01a981f
52 changed files with 2072 additions and 238 deletions

View File

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

View File

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

View File

@ -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/ # 뉴스

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (
<div>
<h1 className="text-2xl font-bold mb-6"> </h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<Card className="p-6">
<h3 className="text-sm font-medium text-gray-500">
</h3>
<div className="mt-2 flex items-baseline">
<p className="text-3xl font-semibold">2,453,890</p>
<p className="ml-2 text-sm">kWh</p>
</div>
<p className="mt-1 text-sm text-green-600"> 5% </p>
</Card>
{/* ... 다른 카드들 */}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<Card className="p-6">
<h3 className="text-lg font-medium mb-4"> </h3>
<UsageChart className="h-80" />
</Card>
<Card className="p-6">
<h3 className="text-lg font-medium mb-4"> </h3>
<CostChart className="h-80" />
</Card>
</div>
<div className="space-y-4">
<h3 className="text-lg font-medium"> </h3>
<Alert
variant="warning"
title="전력 사용량 경고"
description="3번 생산라인 전력 사용량이 기준치를 초과했습니다."
/>
{/* ... 다른 알림들 */}
</div>
</div>
);
}

View File

@ -1,15 +1,25 @@
// 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 <div> .</div>;
}
return (
<div className="min-h-screen flex">
<SideNav />
<AdminSidebar />
<div className="flex-1">
<TopNav />
<main className="p-6">{children}</main>

View File

View File

View File

@ -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 (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
{children}
</div>
);
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}</>;
}

View File

@ -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<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
})
defaultValues: {
// 여기에 초기값 추가
username: "",
password: "",
},
});
async function onSubmit(values: z.infer<typeof formSchema>) {
try {
await login(values)
setIsLoading(true);
setError("");
await login(values);
} catch (error) {
console.error(error)
console.error(error);
setError("로그인에 실패했습니다.");
} finally {
setIsLoading(false);
}
}
return (
<div className="w-full max-w-md">
<div className="bg-white shadow-md rounded-lg p-8">
<h1 className="text-2xl font-bold text-center mb-6"></h1>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
<div className="min-h-screen flex items-center justify-center bg-gradient-to-r from-blue-600 to-blue-800 p-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-center">
</CardTitle>
<CardDescription className="text-center">
FEMS에
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full">
</Button>
</form>
</Form>
</div>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input {...field} disabled={isLoading} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" {...field} disabled={isLoading} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
<div className="mt-4 text-center space-y-2">
<Link
href="/forgot-password"
className="text-sm text-muted-foreground hover:underline"
>
?
</Link>
<div className="flex items-center justify-center space-x-1 text-sm">
<span className="text-muted-foreground">
?
</span>
<Link
href="/register"
className="font-semibold hover:underline"
>
</Link>
</div>
</div>
</form>
</Form>
</CardContent>
</Card>
</div>
)
);
}

View File

@ -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 (
<div className="min-h-screen flex">
<SideNav />
<div className="flex-1">
<TopNav />
<main className="p-6">{children}</main>
</div>
</div>
)
}

View File

View File

@ -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<EnergyUsage[]>(
"/api/v1/app/dashboard/energy-usage"
);
return data;
},
});
// 에너지 비용 데이터 불러오기
const { data: energyCostsData, isLoading: loadingCosts } = useQuery({
queryKey: ["energy-costs"],
queryFn: async () => {
const { data } = await api.get<EnergyCost[]>(
"/api/v1/app/dashboard/energy-costs"
);
return data;
},
});
// 알림 데이터 불러오기
const { data: alertsData, isLoading: loadingAlerts } = useQuery({
queryKey: ["alerts"],
queryFn: async () => {
const { data } = await api.get<AlertItem[]>("/api/v1/app/dashboard/alerts");
return data;
},
});
if (loadingUsage || loadingCosts || loadingAlerts) {
return <div>Loading...</div>; // 로딩 상태 표시
}
return (
<div className="space-y-6">
{/* 에너지 비용 카드 */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{energyCostsData?.map((cost) => (
<Card key={cost.category}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{cost.category}
</CardTitle>
<span
className={`text-sm ${
cost.change > 0 ? "text-red-500" : "text-green-500"
}`}
>
{cost.change > 0 ? "+" : ""}
{cost.change.toFixed(1)}%
</span>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(cost.amount)}
</div>
<p className="text-xs text-muted-foreground">
: {formatNumber(cost.previousAmount)}
</p>
</CardContent>
</Card>
))}
</div>
{/* 에너지 사용량 차트 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<div className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={energyUsageData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="timestamp"
tickFormatter={(value) => {
return new Date(value).toLocaleDateString("ko-KR", {
month: "short",
year: "numeric",
});
}}
/>
<YAxis />
<Tooltip
labelFormatter={(label) =>
new Date(label).toLocaleDateString("ko-KR")
}
formatter={(value) => [`${value} kWh`, ""]}
/>
<Legend />
<Line
type="monotone"
dataKey="electricity"
stroke="#8884d8"
name="전력"
/>
<Line
type="monotone"
dataKey="gas"
stroke="#82ca9d"
name="가스"
/>
<Line
type="monotone"
dataKey="water"
stroke="#ffc658"
name="용수"
/>
<Line
type="monotone"
dataKey="steam"
stroke="#ff7300"
name="스팀"
/>
</LineChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
{/* 알림 섹션 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{alertsData?.map((alert) => (
<Alert
key={alert.id}
variant={alert.type === "error" ? "destructive" : "default"}
>
{alert.type === "error" && <AlertCircle className="h-4 w-4" />}
{alert.type === "warning" && (
<AlertTriangle className="h-4 w-4" />
)}
{alert.type === "info" && <Info className="h-4 w-4" />}
<AlertDescription className="flex justify-between items-center">
<span>{alert.message}</span>
<span className="text-sm text-muted-foreground">
{new Date(alert.createdAt).toLocaleTimeString()}
</span>
</AlertDescription>
</Alert>
))}
</CardContent>
</Card>
</div>
);
}

View File

@ -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 (
<div className="h-screen flex">
{/* 왼쪽 사이드바 */}
<aside className="w-64 h-screen flex-shrink-0 bg-gray-800">
<GeneralSidebar />
</aside>
{/* 오른쪽 메인 영역 */}
<div className="flex-1 flex flex-col">
{/* 상단 헤더 */}
<header className="h-16 bg-white border-b">
<GeneralHeader />
</header>
{/* 메인 컨텐츠 영역 */}
<main className="flex-1 overflow-auto bg-gray-50 p-6">{children}</main>
</div>
</div>
);
};
export default GeneralLayout;

View File

@ -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 (
<div className="h-screen flex flex-col">
<MonitoringHeader />
<div className="flex-1 flex">
<MonitoringSidebar />
<main className="flex-1 p-6">{children}</main>
</div>
</div>
);
}

View File

@ -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 (
<html lang="ko">
<body className={inter.className}>
<body>
<Providers>{children}</Providers>
</body>
</html>
)
);
}

View File

@ -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 (
<nav className="w-64 bg-slate-900 text-white min-h-screen">
<div className="p-4">
<h1 className="text-xl font-bold">FEMS</h1>
</div>
<div className="space-y-4">
{menuItems.map((group) => (
<div key={group.title}>
<h2 className="px-4 text-xs font-semibold text-slate-400 uppercase tracking-wider">
{group.title}
</h2>
<div className="mt-2 space-y-1">
{group.items.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center px-4 py-2 text-sm font-medium",
pathname === item.href
? "bg-slate-800 text-white"
: "text-slate-400 hover:text-white hover:bg-slate-800"
)}
>
{item.icon && <item.icon className="mr-3 h-4 w-4" />}
{item.title}
</Link>
))}
</div>
</div>
))}
</div>
</nav>
);
}

View File

@ -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 (
<header className="h-16 border-b bg-white">
<div className="h-full px-4 flex items-center justify-between">
<div>{/* 추가 기능 버튼들 */}</div>
<div className="flex items-center gap-4">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button>{user?.name}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={logout}></DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</header>
);
}

View File

@ -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 (
<nav className="w-64 bg-slate-900 text-white min-h-screen">
<div className="p-4">
<h1 className="text-xl font-bold">FEMS</h1>
</div>
<div className="space-y-4">
{menuItems.map((group) => (
<div key={group.title}>
<h2 className="px-4 text-xs font-semibold text-slate-400 uppercase tracking-wider">
{group.title}
</h2>
<div className="mt-2 space-y-1">
{group.items.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center px-4 py-2 text-sm font-medium",
pathname === item.href
? "bg-slate-800 text-white"
: "text-slate-400 hover:text-white hover:bg-slate-800"
)}
>
{item.icon && <item.icon className="mr-3 h-4 w-4" />}
{item.title}
</Link>
))}
</div>
</div>
))}
</div>
</nav>
);
}

View File

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

View File

@ -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 (
<div className="border-b">
<div className="flex h-16 items-center px-4">
<h2 className="text-lg font-semibold">{pageTitles[pathname]}</h2>
<div className="ml-auto flex items-center space-x-4">
<Select defaultValue="realtime">
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="데이터 주기" />
</SelectTrigger>
<SelectContent>
<SelectItem value="realtime"></SelectItem>
<SelectItem value="hourly"></SelectItem>
<SelectItem value="daily"></SelectItem>
<SelectItem value="monthly"></SelectItem>
</SelectContent>
</Select>
<DatePickerWithRange className="w-[300px]" />
<Button variant="outline" size="icon">
<RefreshCw className="h-4 w-4" />
</Button>
<Button variant="outline">
<Download className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
</div>
);
}

View File

@ -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 (
<div className="flex flex-col w-64 bg-slate-900 text-white min-h-[calc(100vh-4rem)]">
<div className="space-y-4 py-4">
{menuItems.map((section) => (
<div key={section.title} className="px-3 py-2">
<h2 className="mb-2 px-4 text-xs font-semibold tracking-tight text-slate-400">
{section.title}
</h2>
<div className="space-y-1">
{section.items.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center rounded-lg px-3 py-2 text-sm font-medium hover:bg-slate-800 hover:text-slate-50 transition-colors",
pathname === item.href
? "bg-slate-800 text-slate-50"
: "text-slate-400"
)}
>
<item.icon className="mr-2 h-4 w-4" />
{item.title}
</Link>
))}
</div>
</div>
))}
</div>
</div>
);
}

View File

@ -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<typeof DayPicker>
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(
buttonVariants({ variant: "ghost" }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
)
}
Calendar.displayName = "Calendar"
export { Calendar }

View File

@ -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<DateRange | undefined>({
from: new Date(new Date().setDate(new Date().getDate() - 7)),
to: new Date(),
});
return (
<div className={cn("grid gap-2", className)}>
<Popover>
<PopoverTrigger asChild>
<Button
id="date"
variant={"outline"}
className={cn(
"w-[300px] justify-start text-left font-normal",
!date && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{date?.from ? (
date.to ? (
<>
{format(date.from, "y년 M월 d일", { locale: ko })} -{" "}
{format(date.to, "y년 M월 d일", { locale: ko })}
</>
) : (
format(date.from, "y년 M월 d일", { locale: ko })
)
) : (
<span> </span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="range"
defaultMonth={date?.from}
selected={date}
onSelect={setDate}
numberOfMonths={2}
locale={ko}
/>
</PopoverContent>
</Popover>
</div>
);
}

View File

@ -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<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

View File

@ -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<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

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

24
fems-app/src/lib/jwt.ts Normal file
View File

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

View File

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

View File

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

View File

@ -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 (
<QueryClientProvider client={queryClient}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
<Toaster /> {/* 토스트 알림 */}
</ThemeProvider>
{children}
{process.env.NODE_ENV === "development" && <ReactQueryDevtools />}
</QueryClientProvider>
)
);
}

View File

@ -1,30 +1,25 @@
// src/stores/auth.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
// src/stores/auth.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { User } from '@/types/auth';
interface User {
id: string
name: string
role: string
}
interface AuthState {
user: User | null;
token: string | null;
setAuth: (user: User, token: string) => void;
clearAuth: () => void;
}
interface AuthState {
user: User | null
token: string | null
setAuth: (user: User, token: string) => void
clearAuth: () => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: null,
setAuth: (user, token) => set({ user, token }),
clearAuth: () => set({ user: null, token: null }),
}),
{
name: 'auth-storage',
}
)
)
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: null,
setAuth: (user, token) => set({ user, token }),
clearAuth: () => set({ user: null, token: null }),
}),
{
name: 'auth-storage', // localStorage에 저장될 키 이름
}
)
);

View File

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

View File

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

View File

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