auto commit
This commit is contained in:
parent
d715a8c7a0
commit
a3d01a981f
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
36
README.md
36
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/ # 뉴스
|
||||
|
@ -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"
|
||||
|
@ -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": {
|
||||
|
@ -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);
|
||||
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
@ -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;
|
50
fems-api/src/models/Alert.js
Normal file
50
fems-api/src/models/Alert.js
Normal 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;
|
70
fems-api/src/models/EnergyCost.js
Normal file
70
fems-api/src/models/EnergyCost.js
Normal 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;
|
62
fems-api/src/models/EnergyUsage.js
Normal file
62
fems-api/src/models/EnergyUsage.js
Normal 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;
|
@ -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" });
|
||||
|
@ -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));
|
||||
|
@ -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;
|
||||
|
33
fems-api/src/routes/index.js
Normal file
33
fems-api/src/routes/index.js
Normal 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;
|
@ -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 {
|
||||
|
61
fems-api/src/services/dashboard.service.js
Normal file
61
fems-api/src/services/dashboard.service.js
Normal 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();
|
@ -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;
|
||||
|
@ -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"
|
||||
|
@ -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",
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
|
0
fems-app/src/app/(alarm)/layout.tsx
Normal file
0
fems-app/src/app/(alarm)/layout.tsx
Normal file
0
fems-app/src/app/(analysis)/layout.tsx
Normal file
0
fems-app/src/app/(analysis)/layout.tsx
Normal 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}</>;
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
0
fems-app/src/app/(equipment)/layout.tsx
Normal file
0
fems-app/src/app/(equipment)/layout.tsx
Normal file
196
fems-app/src/app/(general)/dashboard/overview/page.tsx
Normal file
196
fems-app/src/app/(general)/dashboard/overview/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
28
fems-app/src/app/(general)/layout.tsx
Normal file
28
fems-app/src/app/(general)/layout.tsx
Normal 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;
|
0
fems-app/src/app/(general)/settings/page.tsx
Normal file
0
fems-app/src/app/(general)/settings/page.tsx
Normal file
19
fems-app/src/app/(monitoring)/layout.tsx
Normal file
19
fems-app/src/app/(monitoring)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
81
fems-app/src/components/admin/AdminSidebar.tsx
Normal file
81
fems-app/src/components/admin/AdminSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
33
fems-app/src/components/general/GeneralHeader.tsx
Normal file
33
fems-app/src/components/general/GeneralHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
81
fems-app/src/components/general/GeneralSidebar.tsx
Normal file
81
fems-app/src/components/general/GeneralSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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";
|
||||
|
56
fems-app/src/components/monitoring/MonitoringHeader.tsx
Normal file
56
fems-app/src/components/monitoring/MonitoringHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
103
fems-app/src/components/monitoring/MonitoringSidebar.tsx
Normal file
103
fems-app/src/components/monitoring/MonitoringSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
66
fems-app/src/components/ui/calendar.tsx
Normal file
66
fems-app/src/components/ui/calendar.tsx
Normal 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 }
|
70
fems-app/src/components/ui/date-range-picker.tsx
Normal file
70
fems-app/src/components/ui/date-range-picker.tsx
Normal 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>
|
||||
);
|
||||
}
|
31
fems-app/src/components/ui/popover.tsx
Normal file
31
fems-app/src/components/ui/popover.tsx
Normal 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 }
|
160
fems-app/src/components/ui/select.tsx
Normal file
160
fems-app/src/components/ui/select.tsx
Normal 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,
|
||||
}
|
@ -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
24
fems-app/src/lib/jwt.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
38
fems-app/src/middleware.tsx
Normal file
38
fems-app/src/middleware.tsx
Normal 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).*)"],
|
||||
};
|
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
@ -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에 저장될 키 이름
|
||||
}
|
||||
)
|
||||
);
|
16
fems-app/src/types/auth.ts
Normal file
16
fems-app/src/types/auth.ts
Normal 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;
|
||||
}
|
22
fems-app/src/types/dashboard.ts
Normal file
22
fems-app/src/types/dashboard.ts
Normal 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;
|
||||
}
|
@ -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==
|
||||
|
Loading…
Reference in New Issue
Block a user