auto commit
This commit is contained in:
parent
a4b29b6a21
commit
9ee1e2cceb
@ -1,208 +1,64 @@
|
|||||||
// src/middleware/auth.middleware.js
|
// src/middleware/auth.middleware.js
|
||||||
const jwt = require("jsonwebtoken");
|
const jwt = require("jsonwebtoken");
|
||||||
const { promisify } = require("util");
|
|
||||||
const config = require("../config/config");
|
const config = require("../config/config");
|
||||||
const { User, Role, UserRole } = require("../models");
|
const { User, Role } = require("../models");
|
||||||
const { AuthenticationError, AuthorizationError } = require("../utils/errors");
|
|
||||||
const logger = require("../utils/logger");
|
|
||||||
const redis = require("../config/redis");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 토큰 검증 및 사용자 인증 미들웨어
|
|
||||||
*/
|
|
||||||
const authMiddleware = async (req, res, next) => {
|
const authMiddleware = async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
// 토큰 추출
|
const authHeader = req.headers.authorization;
|
||||||
const token = extractTokenFromHeader(req);
|
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||||
if (!token) {
|
return res
|
||||||
throw new AuthenticationError("Authentication token is required");
|
.status(401)
|
||||||
|
.json({ message: "Authentication token is required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 토큰 검증
|
const token = authHeader.split(" ")[1];
|
||||||
const decoded = await verifyToken(token);
|
const decoded = jwt.verify(token, config.jwt.secret);
|
||||||
|
|
||||||
// 세션 확인
|
const user = await User.findOne({
|
||||||
await validateSession(decoded.id, token);
|
where: { id: decoded.id },
|
||||||
|
|
||||||
// 사용자 정보 조회 및 검증
|
|
||||||
const user = await getUserWithRoles(decoded.id);
|
|
||||||
validateUser(user);
|
|
||||||
|
|
||||||
// 마지막 활동 시간 업데이트
|
|
||||||
await updateLastActivity(user.id);
|
|
||||||
|
|
||||||
// 요청 객체에 사용자 정보 추가
|
|
||||||
req.user = user;
|
|
||||||
next();
|
|
||||||
} catch (error) {
|
|
||||||
handleAuthError(error, res);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 특정 권한 필요한 엔드포인트용 미들웨어
|
|
||||||
*/
|
|
||||||
const requireRole = (...roles) => {
|
|
||||||
return (req, res, next) => {
|
|
||||||
if (!req.user) {
|
|
||||||
return res.status(401).json({ message: "Authentication required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasRequiredRole = req.user.Roles.some(
|
|
||||||
(role) => roles.includes(role.name) && role.isActive
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!hasRequiredRole) {
|
|
||||||
return res.status(403).json({
|
|
||||||
message: `Required role: ${roles.join(" or ")}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 세부 권한 체크 미들웨어
|
|
||||||
*/
|
|
||||||
const checkPermission = (resource, action) => {
|
|
||||||
return async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
if (!req.user) {
|
|
||||||
throw new AuthenticationError("Authentication required");
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasPermission = await validatePermission(
|
|
||||||
req.user.id,
|
|
||||||
resource,
|
|
||||||
action
|
|
||||||
);
|
|
||||||
if (!hasPermission) {
|
|
||||||
throw new AuthorizationError(
|
|
||||||
`Permission denied: ${resource}:${action}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
} catch (error) {
|
|
||||||
handleAuthError(error, res);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// 유틸리티 함수들
|
|
||||||
const extractTokenFromHeader = (req) => {
|
|
||||||
const authHeader = req.headers.authorization;
|
|
||||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return authHeader.split(" ")[1];
|
|
||||||
};
|
|
||||||
|
|
||||||
const verifyToken = async (token) => {
|
|
||||||
try {
|
|
||||||
// 토큰 블랙리스트 체크
|
|
||||||
const isBlacklisted = await redis.get(`blacklist:${token}`);
|
|
||||||
if (isBlacklisted) {
|
|
||||||
throw new AuthenticationError("Token has been revoked");
|
|
||||||
}
|
|
||||||
|
|
||||||
return await promisify(jwt.verify)(token, config.jwt.secret);
|
|
||||||
} catch (error) {
|
|
||||||
if (error.name === "TokenExpiredError") {
|
|
||||||
throw new AuthenticationError("Token has expired");
|
|
||||||
}
|
|
||||||
throw new AuthenticationError("Invalid token");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateSession = async (userId, token) => {
|
|
||||||
const currentSession = await redis.get(`session:${userId}`);
|
|
||||||
if (currentSession && currentSession !== token) {
|
|
||||||
throw new AuthenticationError("Session has been invalidated");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getUserWithRoles = async (userId) => {
|
|
||||||
return await User.findByPk(userId, {
|
|
||||||
attributes: { exclude: ["password"] },
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
model: Role,
|
|
||||||
through: UserRole,
|
|
||||||
attributes: ["id", "name", "permissions", "isActive"],
|
|
||||||
where: { isActive: true },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateUser = (user) => {
|
|
||||||
if (!user) {
|
|
||||||
throw new AuthenticationError("User not found");
|
|
||||||
}
|
|
||||||
if (!user.isActive) {
|
|
||||||
throw new AuthenticationError("User account is inactive");
|
|
||||||
}
|
|
||||||
if (user.Roles.length === 0) {
|
|
||||||
throw new AuthorizationError("User has no active roles");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const validatePermission = async (userId, resource, action) => {
|
|
||||||
// Redis에서 캐시된 권한 확인
|
|
||||||
const cacheKey = `permissions:${userId}`;
|
|
||||||
let permissions = await redis.get(cacheKey);
|
|
||||||
|
|
||||||
if (!permissions) {
|
|
||||||
// DB에서 권한 조회 및 캐싱
|
|
||||||
const user = await User.findByPk(userId, {
|
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: Role,
|
model: Role,
|
||||||
attributes: ["permissions"],
|
through: { attributes: [] },
|
||||||
where: { isActive: true },
|
attributes: ["id", "name", "permissions"],
|
||||||
|
required: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
attributes: {
|
||||||
|
exclude: ["password"],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
permissions = user.Roles.reduce((acc, role) => {
|
if (!user || !user.isActive) {
|
||||||
const rolePerms = role.permissions || {};
|
return res.status(401).json({ message: "User not found or inactive" });
|
||||||
Object.keys(rolePerms).forEach((res) => {
|
}
|
||||||
acc[res] = acc[res] || [];
|
|
||||||
acc[res].push(...rolePerms[res]);
|
// 권한 정보 처리
|
||||||
|
const permissions = {
|
||||||
|
"basic:view": true, // 기본 권한
|
||||||
|
};
|
||||||
|
|
||||||
|
if (user.Roles) {
|
||||||
|
user.Roles.forEach((role) => {
|
||||||
|
if (role.permissions) {
|
||||||
|
Object.entries(role.permissions).forEach(([key, value]) => {
|
||||||
|
permissions[key] = permissions[key] || value;
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return acc;
|
}
|
||||||
}, {});
|
|
||||||
|
|
||||||
await redis.set(cacheKey, JSON.stringify(permissions), "EX", 3600); // 1시간 캐시
|
// user 객체에서 Roles 제거하고 permissions 추가
|
||||||
} else {
|
const userData = user.toJSON();
|
||||||
permissions = JSON.parse(permissions);
|
delete userData.Roles;
|
||||||
|
userData.permissions = permissions;
|
||||||
|
|
||||||
|
req.user = userData;
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(401).json({ message: "Invalid token" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return permissions[resource]?.includes(action) || false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateLastActivity = async (userId) => {
|
module.exports = authMiddleware;
|
||||||
await redis.set(`lastActivity:${userId}`, Date.now());
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAuthError = (error, res) => {
|
|
||||||
logger.error("Authentication error", { error });
|
|
||||||
|
|
||||||
if (error instanceof AuthenticationError) {
|
|
||||||
return res.status(401).json({ message: error.message });
|
|
||||||
}
|
|
||||||
if (error instanceof AuthorizationError) {
|
|
||||||
return res.status(403).json({ message: error.message });
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(500).json({ message: "Internal server error" });
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
authMiddleware,
|
|
||||||
requireRole,
|
|
||||||
checkPermission,
|
|
||||||
validatePermission, // Export for testing
|
|
||||||
};
|
|
||||||
|
@ -1,19 +1,18 @@
|
|||||||
// src/services/auth.service.js
|
|
||||||
const jwt = require("jsonwebtoken");
|
const jwt = require("jsonwebtoken");
|
||||||
const config = require("../config/config");
|
const config = require("../config/config");
|
||||||
const { User, AuthLog, Company, Branch } = require("../models");
|
const { User, AuthLog, Company, Branch, Role } = require("../models");
|
||||||
|
|
||||||
class AuthService {
|
class AuthService {
|
||||||
async login(username, password, ipAddress, userAgent) {
|
async login(username, password, ipAddress, userAgent) {
|
||||||
const user = await User.findOne({
|
// 먼저 사용자 존재 여부와 비밀번호 확인
|
||||||
|
const userWithPassword = await User.findOne({
|
||||||
where: { username },
|
where: { username },
|
||||||
// 필요한 관계 데이터도 함께 로드
|
attributes: ["id", "password", "isActive"],
|
||||||
include: [{ model: Company }, { model: Branch }],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user || !user.isActive) {
|
if (!userWithPassword || !userWithPassword.isActive) {
|
||||||
await this._logAuthAttempt(
|
await this._logAuthAttempt(
|
||||||
user?.id,
|
userWithPassword?.id,
|
||||||
"failed_login",
|
"failed_login",
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent
|
userAgent
|
||||||
@ -21,39 +20,97 @@ class AuthService {
|
|||||||
throw new Error("Invalid credentials");
|
throw new Error("Invalid credentials");
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValidPassword = await user.validatePassword(password);
|
const isValidPassword = await userWithPassword.validatePassword(password);
|
||||||
if (!isValidPassword) {
|
if (!isValidPassword) {
|
||||||
await this._logAuthAttempt(user.id, "failed_login", ipAddress, userAgent);
|
await this._logAuthAttempt(
|
||||||
|
userWithPassword.id,
|
||||||
|
"failed_login",
|
||||||
|
ipAddress,
|
||||||
|
userAgent
|
||||||
|
);
|
||||||
throw new Error("Invalid credentials");
|
throw new Error("Invalid credentials");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 비밀번호 확인 후 전체 사용자 정보 조회
|
||||||
|
const user = await User.findOne({
|
||||||
|
where: { id: userWithPassword.id },
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Company,
|
||||||
|
attributes: ["id", "name", "businessNumber"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Branch,
|
||||||
|
attributes: ["id", "name"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Role,
|
||||||
|
through: { attributes: [] },
|
||||||
|
attributes: ["id", "name", "permissions"],
|
||||||
|
required: false, // Role이 없어도 조회 가능하도록 설정
|
||||||
|
},
|
||||||
|
],
|
||||||
|
attributes: {
|
||||||
|
exclude: ["password"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
await User.update({ lastLoginAt: new Date() }, { where: { id: user.id } });
|
await User.update({ lastLoginAt: new Date() }, { where: { id: user.id } });
|
||||||
|
|
||||||
await this._logAuthAttempt(user.id, "login", ipAddress, userAgent);
|
await this._logAuthAttempt(user.id, "login", ipAddress, userAgent);
|
||||||
|
|
||||||
const token = this._generateToken(user);
|
// 권한 정보 가공
|
||||||
// 안전한 사용자 데이터 반환을 위해 password 제외
|
const permissions = this._processPermissions(user.Roles || []);
|
||||||
const userWithoutPassword = user.toJSON();
|
|
||||||
delete userWithoutPassword.password;
|
// 사용자 정보에서 Roles 배열을 제거하고 가공된 권한 정보를 추가
|
||||||
|
const userData = user.toJSON();
|
||||||
|
delete userData.Roles;
|
||||||
|
|
||||||
|
const userInfo = {
|
||||||
|
...userData,
|
||||||
|
permissions,
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = this._generateToken(userInfo);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token,
|
token,
|
||||||
user: userWithoutPassword,
|
user: userInfo,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_processPermissions(roles) {
|
||||||
|
// 기본 권한 설정 (모든 사용자가 가져야 할 기본 권한)
|
||||||
|
const permissions = {
|
||||||
|
"basic:view": true, // 기본 조회 권한
|
||||||
|
};
|
||||||
|
|
||||||
|
roles.forEach((role) => {
|
||||||
|
if (role?.permissions) {
|
||||||
|
Object.entries(role.permissions).forEach(([key, value]) => {
|
||||||
|
permissions[key] = permissions[key] || value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return permissions;
|
||||||
|
}
|
||||||
|
|
||||||
async logout(userId, ipAddress, userAgent) {
|
async logout(userId, ipAddress, userAgent) {
|
||||||
await this._logAuthAttempt(userId, "logout", ipAddress, userAgent);
|
await this._logAuthAttempt(userId, "logout", ipAddress, userAgent);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async _logAuthAttempt(userId, action, ipAddress, userAgent) {
|
async _logAuthAttempt(userId, action, ipAddress, userAgent) {
|
||||||
await AuthLog.create({
|
if (userId) {
|
||||||
userId,
|
// userId가 있을 때만 로그 생성
|
||||||
action,
|
await AuthLog.create({
|
||||||
ipAddress,
|
userId,
|
||||||
userAgent,
|
action,
|
||||||
});
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_generateToken(user) {
|
_generateToken(user) {
|
||||||
@ -63,6 +120,7 @@ class AuthService {
|
|||||||
role: user.role,
|
role: user.role,
|
||||||
companyId: user.companyId,
|
companyId: user.companyId,
|
||||||
branchId: user.branchId,
|
branchId: user.branchId,
|
||||||
|
permissions: user.permissions,
|
||||||
},
|
},
|
||||||
config.jwt.secret,
|
config.jwt.secret,
|
||||||
{ expiresIn: config.jwt.expiresIn }
|
{ expiresIn: config.jwt.expiresIn }
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
// src/app/(admin)/layout.tsx
|
// src/app/(admin)/layout.tsx
|
||||||
import AdminGuard from "@/components/auth/AdminGuard";
|
import AdminGuard from "@/components/auth/AdminGuard";
|
||||||
import { AdminSidebar } from "@/components/admin/AdminSidebar";
|
import { SideNav } from "@/components/layout/SideNav";
|
||||||
import { GeneralHeader } from "@/components/general/GeneralHeader";
|
import { TopNav } from "@/components/layout/TopNav";
|
||||||
|
|
||||||
export default function AdminLayout({
|
export default function AdminLayout({
|
||||||
children,
|
children,
|
||||||
@ -12,13 +12,13 @@ export default function AdminLayout({
|
|||||||
<AdminGuard>
|
<AdminGuard>
|
||||||
<div className="h-screen flex">
|
<div className="h-screen flex">
|
||||||
<aside className="w-64 h-screen flex-shrink-0 bg-gray-800">
|
<aside className="w-64 h-screen flex-shrink-0 bg-gray-800">
|
||||||
<AdminSidebar />
|
<SideNav />
|
||||||
</aside>
|
</aside>
|
||||||
{/* 오른쪽 메인 영역 */}
|
{/* 오른쪽 메인 영역 */}
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col">
|
||||||
{/* 상단 헤더 */}
|
{/* 상단 헤더 */}
|
||||||
<header className="h-16 bg-white border-b">
|
<header className="h-16 bg-white border-b">
|
||||||
<GeneralHeader />
|
<TopNav />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* 메인 컨텐츠 영역 */}
|
{/* 메인 컨텐츠 영역 */}
|
||||||
|
@ -1,20 +1,20 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { GeneralSidebar } from "@/components/general/GeneralSidebar";
|
import { SideNav } from "@/components/layout/SideNav";
|
||||||
import { GeneralHeader } from "@/components/general/GeneralHeader";
|
import { TopNav } from "@/components/layout/TopNav";
|
||||||
|
|
||||||
const GeneralLayout = ({ children }: { children: React.ReactNode }) => {
|
const GeneralLayout = ({ children }: { children: React.ReactNode }) => {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex">
|
<div className="h-screen flex">
|
||||||
{/* 왼쪽 사이드바 */}
|
{/* 왼쪽 사이드바 */}
|
||||||
<aside className="w-64 h-screen flex-shrink-0 bg-gray-800">
|
<aside className="w-64 h-screen flex-shrink-0 bg-gray-800">
|
||||||
<GeneralSidebar />
|
<SideNav />
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* 오른쪽 메인 영역 */}
|
{/* 오른쪽 메인 영역 */}
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col">
|
||||||
{/* 상단 헤더 */}
|
{/* 상단 헤더 */}
|
||||||
<header className="h-16 bg-white border-b">
|
<header className="h-16 bg-white border-b">
|
||||||
<GeneralHeader />
|
<TopNav />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* 메인 컨텐츠 영역 */}
|
{/* 메인 컨텐츠 영역 */}
|
||||||
|
@ -1,20 +1,20 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { GeneralSidebar } from "@/components/general/GeneralSidebar";
|
import { SideNav } from "@/components/layout/SideNav";
|
||||||
import { GeneralHeader } from "@/components/general/GeneralHeader";
|
import { TopNav } from "@/components/layout/TopNav";
|
||||||
|
|
||||||
const GeneralLayout = ({ children }: { children: React.ReactNode }) => {
|
const GeneralLayout = ({ children }: { children: React.ReactNode }) => {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex">
|
<div className="h-screen flex">
|
||||||
{/* 왼쪽 사이드바 */}
|
{/* 왼쪽 사이드바 */}
|
||||||
<aside className="w-64 h-screen flex-shrink-0 bg-gray-800">
|
<aside className="w-64 h-screen flex-shrink-0 bg-gray-800">
|
||||||
<GeneralSidebar />
|
<SideNav />
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* 오른쪽 메인 영역 */}
|
{/* 오른쪽 메인 영역 */}
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col">
|
||||||
{/* 상단 헤더 */}
|
{/* 상단 헤더 */}
|
||||||
<header className="h-16 bg-white border-b">
|
<header className="h-16 bg-white border-b">
|
||||||
<GeneralHeader />
|
<TopNav />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* 메인 컨텐츠 영역 */}
|
{/* 메인 컨텐츠 영역 */}
|
||||||
@ -24,4 +24,4 @@ const GeneralLayout = ({ children }: { children: React.ReactNode }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default GeneralLayout;
|
export default GeneralLayout;
|
@ -1,21 +1,21 @@
|
|||||||
// src/(equipment)/layout.tsx
|
// src/(equipment)/layout.tsx
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { GeneralSidebar } from "@/components/general/GeneralSidebar";
|
import { SideNav } from "@/components/layout/SideNav";
|
||||||
import { GeneralHeader } from "@/components/general/GeneralHeader";
|
import { TopNav } from "@/components/layout/TopNav";
|
||||||
|
|
||||||
const GeneralLayout = ({ children }: { children: React.ReactNode }) => {
|
const GeneralLayout = ({ children }: { children: React.ReactNode }) => {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex">
|
<div className="h-screen flex">
|
||||||
{/* 왼쪽 사이드바 */}
|
{/* 왼쪽 사이드바 */}
|
||||||
<aside className="w-64 h-screen flex-shrink-0 bg-gray-800">
|
<aside className="w-64 h-screen flex-shrink-0 bg-gray-800">
|
||||||
<GeneralSidebar />
|
<SideNav />
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* 오른쪽 메인 영역 */}
|
{/* 오른쪽 메인 영역 */}
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col">
|
||||||
{/* 상단 헤더 */}
|
{/* 상단 헤더 */}
|
||||||
<header className="h-16 bg-white border-b">
|
<header className="h-16 bg-white border-b">
|
||||||
<GeneralHeader />
|
<TopNav />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* 메인 컨텐츠 영역 */}
|
{/* 메인 컨텐츠 영역 */}
|
||||||
|
@ -1,21 +1,21 @@
|
|||||||
// src/(general)/layout.tsx
|
// src/(general)/layout.tsx
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { GeneralSidebar } from "@/components/general/GeneralSidebar";
|
import { SideNav } from "@/components/layout/SideNav";
|
||||||
import { GeneralHeader } from "@/components/general/GeneralHeader";
|
import { TopNav } from "@/components/layout/TopNav";
|
||||||
|
|
||||||
const GeneralLayout = ({ children }: { children: React.ReactNode }) => {
|
const GeneralLayout = ({ children }: { children: React.ReactNode }) => {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex">
|
<div className="h-screen flex">
|
||||||
{/* 왼쪽 사이드바 */}
|
{/* 왼쪽 사이드바 */}
|
||||||
<aside className="w-64 h-screen flex-shrink-0 bg-gray-800">
|
<aside className="w-64 h-screen flex-shrink-0 bg-gray-800">
|
||||||
<GeneralSidebar />
|
<SideNav />
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* 오른쪽 메인 영역 */}
|
{/* 오른쪽 메인 영역 */}
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col">
|
||||||
{/* 상단 헤더 */}
|
{/* 상단 헤더 */}
|
||||||
<header className="h-16 bg-white border-b">
|
<header className="h-16 bg-white border-b">
|
||||||
<GeneralHeader />
|
<TopNav />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* 메인 컨텐츠 영역 */}
|
{/* 메인 컨텐츠 영역 */}
|
||||||
|
@ -1,20 +1,21 @@
|
|||||||
// src/app/(monitoring)/layout.tsx
|
// src/app/(monitoring)/layout.tsx
|
||||||
import { MonitoringSidebar } from "@/components/monitoring/MonitoringSidebar";
|
import React from "react";
|
||||||
import { MonitoringHeader } from "@/components/monitoring/MonitoringHeader";
|
import { SideNav } from "@/components/layout/SideNav";
|
||||||
|
import { TopNav } from "@/components/layout/TopNav";
|
||||||
|
|
||||||
const MonitoringLayout = ({ children }: { children: React.ReactNode }) => {
|
const GeneralLayout = ({ children }: { children: React.ReactNode }) => {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex">
|
<div className="h-screen flex">
|
||||||
{/* 왼쪽 사이드바 */}
|
{/* 왼쪽 사이드바 */}
|
||||||
<aside className="w-64 h-screen flex-shrink-0 bg-gray-800">
|
<aside className="w-64 h-screen flex-shrink-0 bg-gray-800">
|
||||||
<MonitoringSidebar />
|
<SideNav />
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* 오른쪽 메인 영역 */}
|
{/* 오른쪽 메인 영역 */}
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col">
|
||||||
{/* 상단 헤더 */}
|
{/* 상단 헤더 */}
|
||||||
<header className="h-16 bg-white border-b">
|
<header className="h-16 bg-white border-b">
|
||||||
<MonitoringHeader />
|
<TopNav />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* 메인 컨텐츠 영역 */}
|
{/* 메인 컨텐츠 영역 */}
|
||||||
@ -24,4 +25,4 @@ const MonitoringLayout = ({ children }: { children: React.ReactNode }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MonitoringLayout;
|
export default GeneralLayout;
|
||||||
|
@ -1,21 +1,21 @@
|
|||||||
// src/(general)/layout.tsx
|
// src/(general)/layout.tsx
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { GeneralSidebar } from "@/components/general/GeneralSidebar";
|
import { SideNav } from "@/components/layout/SideNav";
|
||||||
import { GeneralHeader } from "@/components/general/GeneralHeader";
|
import { TopNav } from "@/components/layout/TopNav";
|
||||||
|
|
||||||
const GeneralLayout = ({ children }: { children: React.ReactNode }) => {
|
const GeneralLayout = ({ children }: { children: React.ReactNode }) => {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex">
|
<div className="h-screen flex">
|
||||||
{/* 왼쪽 사이드바 */}
|
{/* 왼쪽 사이드바 */}
|
||||||
<aside className="w-64 h-screen flex-shrink-0 bg-gray-800">
|
<aside className="w-64 h-screen flex-shrink-0 bg-gray-800">
|
||||||
<GeneralSidebar />
|
<SideNav />
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* 오른쪽 메인 영역 */}
|
{/* 오른쪽 메인 영역 */}
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col">
|
||||||
{/* 상단 헤더 */}
|
{/* 상단 헤더 */}
|
||||||
<header className="h-16 bg-white border-b">
|
<header className="h-16 bg-white border-b">
|
||||||
<GeneralHeader />
|
<TopNav />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* 메인 컨텐츠 영역 */}
|
{/* 메인 컨텐츠 영역 */}
|
||||||
|
@ -1,21 +1,21 @@
|
|||||||
// src/(general)/layout.tsx
|
// src/(general)/layout.tsx
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { GeneralSidebar } from "@/components/general/GeneralSidebar";
|
import { SideNav } from "@/components/layout/SideNav";
|
||||||
import { GeneralHeader } from "@/components/general/GeneralHeader";
|
import { TopNav } from "@/components/layout/TopNav";
|
||||||
|
|
||||||
const GeneralLayout = ({ children }: { children: React.ReactNode }) => {
|
const GeneralLayout = ({ children }: { children: React.ReactNode }) => {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex">
|
<div className="h-screen flex">
|
||||||
{/* 왼쪽 사이드바 */}
|
{/* 왼쪽 사이드바 */}
|
||||||
<aside className="w-64 h-screen flex-shrink-0 bg-gray-800">
|
<aside className="w-64 h-screen flex-shrink-0 bg-gray-800">
|
||||||
<GeneralSidebar />
|
<SideNav />
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* 오른쪽 메인 영역 */}
|
{/* 오른쪽 메인 영역 */}
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col">
|
||||||
{/* 상단 헤더 */}
|
{/* 상단 헤더 */}
|
||||||
<header className="h-16 bg-white border-b">
|
<header className="h-16 bg-white border-b">
|
||||||
<GeneralHeader />
|
<TopNav />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* 메인 컨텐츠 영역 */}
|
{/* 메인 컨텐츠 영역 */}
|
||||||
|
@ -1,21 +1,43 @@
|
|||||||
// src/components/auth/AdminGuard.tsx
|
// src/components/auth/AdminGuard.tsx
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface AdminGuardProps {
|
||||||
|
children: ReactNode;
|
||||||
|
requiredPermissions?: string[]; // 필요한 권한 목록
|
||||||
|
requireAll?: boolean; // true면 모든 권한 필요, false면 하나라도 있으면 됨
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminGuard({
|
export default function AdminGuard({
|
||||||
children,
|
children,
|
||||||
}: {
|
requiredPermissions = [],
|
||||||
children: React.ReactNode;
|
requireAll = false,
|
||||||
}) {
|
}: AdminGuardProps) {
|
||||||
const { user } = useAuth();
|
const { hasAllPermissions, hasAnyPermission } = usePermissions();
|
||||||
const isAdmin =
|
|
||||||
user?.role === "super_admin" || user?.role === "company_admin";
|
|
||||||
|
|
||||||
if (!isAdmin) {
|
const hasAccess =
|
||||||
return <div>접근 권한이 없습니다.</div>;
|
requiredPermissions.length === 0
|
||||||
|
? true
|
||||||
|
: requireAll
|
||||||
|
? hasAllPermissions(requiredPermissions)
|
||||||
|
: hasAnyPermission(requiredPermissions);
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[200px] text-gray-500">
|
||||||
|
접근 권한이 없습니다.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 사용 예시:
|
||||||
|
/*
|
||||||
|
<AdminGuard requiredPermissions={['users:manage']}>
|
||||||
|
<UserManagementPanel />
|
||||||
|
</AdminGuard>
|
||||||
|
*/
|
||||||
|
@ -1,113 +0,0 @@
|
|||||||
// src/components/general/GeneralHeader.tsx
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
|
||||||
import {
|
|
||||||
Bell,
|
|
||||||
Search,
|
|
||||||
Settings,
|
|
||||||
HelpCircle,
|
|
||||||
LogOut,
|
|
||||||
User,
|
|
||||||
ChevronDown,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
export function GeneralHeader() {
|
|
||||||
const { user, logout } = useAuth();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header className="h-16 border-b bg-white fixed top-0 right-0 left-64 z-50">
|
|
||||||
<div className="h-full px-4 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{/* 검색바 */}
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="검색..."
|
|
||||||
className="h-9 w-64 px-4 pl-10 rounded-md bg-gray-50 border border-gray-200
|
|
||||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
<Search className="h-4 w-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{/* 알림 버튼 */}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="relative text-gray-600 hover:text-gray-900"
|
|
||||||
>
|
|
||||||
<Bell className="h-5 w-5" />
|
|
||||||
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* 도움말 버튼 */}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="text-gray-600 hover:text-gray-900"
|
|
||||||
>
|
|
||||||
<HelpCircle className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* 사용자 프로필 드롭다운 */}
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="flex items-center gap-2 px-3 hover:bg-gray-100"
|
|
||||||
>
|
|
||||||
<div className="w-8 h-8 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center font-medium">
|
|
||||||
{user?.name?.[0] || "U"}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex flex-col items-start">
|
|
||||||
<span className="text-sm font-medium text-gray-700">
|
|
||||||
{user?.name || "사용자"}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-gray-500">
|
|
||||||
{user?.role || "관리자"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ChevronDown className="h-4 w-4 text-gray-500" />
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
|
||||||
<DropdownMenuLabel>내 계정</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<User className="mr-2 h-4 w-4" />
|
|
||||||
<span>프로필 설정</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
|
||||||
<span>환경설정</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={logout}
|
|
||||||
className="text-red-600 focus:text-red-600"
|
|
||||||
>
|
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
|
||||||
<span>로그아웃</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default GeneralHeader;
|
|
@ -1,236 +0,0 @@
|
|||||||
// src/components/layout/GenericSidebar.tsx
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import {
|
|
||||||
LayoutDashboard,
|
|
||||||
Gauge,
|
|
||||||
TrendingUp,
|
|
||||||
DollarSign,
|
|
||||||
Zap,
|
|
||||||
Droplet,
|
|
||||||
Wind,
|
|
||||||
Flame,
|
|
||||||
Box,
|
|
||||||
Activity,
|
|
||||||
Wrench,
|
|
||||||
LineChart,
|
|
||||||
BarChart,
|
|
||||||
FileText,
|
|
||||||
Bell,
|
|
||||||
History,
|
|
||||||
Settings as SettingsIcon,
|
|
||||||
Target,
|
|
||||||
Brain,
|
|
||||||
Sliders,
|
|
||||||
Building2,
|
|
||||||
Users,
|
|
||||||
HelpCircle,
|
|
||||||
MessageSquare,
|
|
||||||
Newspaper,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
Puzzle,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { title } from "process";
|
|
||||||
|
|
||||||
const menuItems = [
|
|
||||||
{
|
|
||||||
title: "대시보드",
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
title: "전체 현황",
|
|
||||||
href: "/dashboard/overview",
|
|
||||||
icon: LayoutDashboard,
|
|
||||||
},
|
|
||||||
{ title: "KPI 지표", href: "/dashboard/kpi", icon: Gauge },
|
|
||||||
{ title: "비용 현황", href: "/dashboard/costs", icon: DollarSign },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "에너지 모니터링",
|
|
||||||
items: [
|
|
||||||
{ title: "전력", href: "/electricity", icon: Zap },
|
|
||||||
{ title: "가스", href: "/gas", icon: Flame },
|
|
||||||
{ title: "용수", href: "/water", icon: Droplet },
|
|
||||||
{ title: "스팀", href: "/steam", icon: Wind },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "설비 관리",
|
|
||||||
items: [
|
|
||||||
{ title: "설비 목록", href: "/inventory", icon: Box },
|
|
||||||
{ title: "상태 모니터링", href: "/monitoring", icon: Activity },
|
|
||||||
{ title: "정비 관리", href: "/maintenance", icon: Wrench },
|
|
||||||
{ title: "부품 관리", href: "/parts", icon: Puzzle },
|
|
||||||
{ title: "작업자 관리", href: "/personnel", icon: Users },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "분석/리포트",
|
|
||||||
items: [
|
|
||||||
{ title: "에너지 분석", href: "/energy", icon: LineChart },
|
|
||||||
{ title: "원단위 분석", href: "/efficiency", icon: BarChart },
|
|
||||||
{ title: "보고서", href: "/reports", icon: FileText },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "알람/이벤트",
|
|
||||||
items: [
|
|
||||||
{ title: "실시간 알람", href: "/realtime", icon: Bell },
|
|
||||||
{ title: "이력 관리", href: "/history", icon: History },
|
|
||||||
{ title: "알람 설정", href: "/settings", icon: SettingsIcon },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "에너지 계획",
|
|
||||||
items: [
|
|
||||||
{ title: "절감 목표", href: "/targets", icon: Target },
|
|
||||||
{ title: "수요 예측", href: "/forecast", icon: TrendingUp },
|
|
||||||
{ title: "최적화", href: "/optimization", icon: Brain },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "관리",
|
|
||||||
items: [
|
|
||||||
{ title: "회사 설정", href: "/company/profile", icon: Building2 },
|
|
||||||
{
|
|
||||||
title: "지점/공장 관리",
|
|
||||||
href: "/company/branches",
|
|
||||||
icon: Building2,
|
|
||||||
},
|
|
||||||
{ title: "결재 관리", href: "/company/billing", icon: DollarSign },
|
|
||||||
{ title: "사용자 권한 관리", href: "/users/roles", icon: Users },
|
|
||||||
{ title: "부서 관리", href: "/users/departments", icon: Users },
|
|
||||||
{ title: "계정 관리", href: "/users/accounts", icon: Users },
|
|
||||||
{ title: "시스템 설정", href: "/system", icon: Sliders },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "지원/커뮤니티",
|
|
||||||
items: [
|
|
||||||
{ title: "도움말", href: "/faq", icon: HelpCircle },
|
|
||||||
{ title: "게시판", href: "/community/forum", icon: MessageSquare },
|
|
||||||
{ title: "뉴스", href: "/community/news", icon: Newspaper },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
interface MenuItemProps {
|
|
||||||
item: {
|
|
||||||
title: string;
|
|
||||||
items: {
|
|
||||||
title: string;
|
|
||||||
href: string;
|
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
isOpen: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
pathname: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MenuItem: React.FC<MenuItemProps> = ({
|
|
||||||
item,
|
|
||||||
isOpen,
|
|
||||||
onToggle,
|
|
||||||
pathname,
|
|
||||||
}) => {
|
|
||||||
const firstIcon = item.items[0]?.icon;
|
|
||||||
const IconComponent = firstIcon || Box;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mb-1">
|
|
||||||
<button
|
|
||||||
onClick={onToggle}
|
|
||||||
className={cn(
|
|
||||||
"w-full flex items-center px-3 py-2 text-sm font-medium rounded-md",
|
|
||||||
"transition-colors duration-150",
|
|
||||||
"hover:bg-gray-100",
|
|
||||||
isOpen ? "text-blue-600 bg-blue-50" : "text-gray-700"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<IconComponent className="h-5 w-5 mr-2" />
|
|
||||||
<span className="flex-1 text-left">{item.title}</span>
|
|
||||||
{isOpen ? (
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isOpen && (
|
|
||||||
<div className="ml-9 mt-1 space-y-1">
|
|
||||||
{item.items.map((subItem) => (
|
|
||||||
<Link
|
|
||||||
key={subItem.href}
|
|
||||||
href={subItem.href}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center px-3 py-2 text-sm rounded-md",
|
|
||||||
"transition-colors duration-150",
|
|
||||||
pathname === subItem.href
|
|
||||||
? "bg-blue-50 text-blue-600 font-medium"
|
|
||||||
: "text-gray-600 hover:bg-gray-50"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{subItem.icon && (
|
|
||||||
<subItem.icon className="h-4 w-4 mr-2 flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
{subItem.title}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function GeneralSidebar() {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const [openSections, setOpenSections] = useState<{ [key: string]: boolean }>(
|
|
||||||
() => {
|
|
||||||
// 현재 경로에 해당하는 섹션을 자동으로 열어둠
|
|
||||||
return menuItems.reduce((acc: { [key: string]: boolean }, item) => {
|
|
||||||
if (item.items.some((subItem) => subItem.href === pathname)) {
|
|
||||||
acc[item.title] = true;
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const toggleSection = (title: string) => {
|
|
||||||
setOpenSections((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[title]: !prev[title],
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav className="w-64 bg-white border-r border-gray-200 h-screen overflow-y-auto">
|
|
||||||
<div className="sticky top-0 z-10 bg-white p-4 border-b border-gray-200">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Gauge className="h-8 w-6 text-blue-600" />
|
|
||||||
<h1 className="text-xl font-semibold text-gray-900">FEMS</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-3">
|
|
||||||
{menuItems.map((item) => (
|
|
||||||
<MenuItem
|
|
||||||
key={item.title}
|
|
||||||
item={item}
|
|
||||||
isOpen={openSections[item.title]}
|
|
||||||
onToggle={() => toggleSection(item.title)}
|
|
||||||
pathname={pathname}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default GeneralSidebar;
|
|
@ -4,78 +4,307 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { MenuIcon } from "lucide-react";
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Gauge,
|
||||||
|
TrendingUp,
|
||||||
|
DollarSign,
|
||||||
|
Zap,
|
||||||
|
Droplet,
|
||||||
|
Wind,
|
||||||
|
Flame,
|
||||||
|
Box,
|
||||||
|
Activity,
|
||||||
|
Wrench,
|
||||||
|
LineChart,
|
||||||
|
BarChart,
|
||||||
|
FileText,
|
||||||
|
Bell,
|
||||||
|
History,
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
Target,
|
||||||
|
Brain,
|
||||||
|
Sliders,
|
||||||
|
Building2,
|
||||||
|
Users,
|
||||||
|
HelpCircle,
|
||||||
|
MessageSquare,
|
||||||
|
Newspaper,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Puzzle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
const menuItems = [
|
// 관리자 메뉴에 필요한 권한 정의
|
||||||
{
|
const ADMIN_PERMISSIONS = {
|
||||||
title: "대시보드",
|
COMPANY: "company:manage",
|
||||||
|
BRANCH: "branches:manage",
|
||||||
|
BILLING: "billing:manage",
|
||||||
|
USERS: "users:manage",
|
||||||
|
DEPARTMENTS: "departments:manage",
|
||||||
|
ACCOUNTS: "accounts:manage",
|
||||||
|
SYSTEM: "system:manage",
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMenuItems = (
|
||||||
|
hasPermission: (permission: string) => boolean,
|
||||||
|
role: string
|
||||||
|
) => {
|
||||||
|
// 기본 메뉴 아이템 (관리자 메뉴 제외)
|
||||||
|
const baseMenuItems = [
|
||||||
|
{
|
||||||
|
title: "대시보드",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "전체 현황",
|
||||||
|
href: "/dashboard/overview",
|
||||||
|
icon: LayoutDashboard,
|
||||||
|
},
|
||||||
|
{ title: "KPI 지표", href: "/dashboard/kpi", icon: Gauge },
|
||||||
|
{ title: "비용 현황", href: "/dashboard/costs", icon: DollarSign },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "에너지 모니터링",
|
||||||
|
items: [
|
||||||
|
{ title: "전력", href: "/electricity", icon: Zap },
|
||||||
|
{ title: "가스", href: "/gas", icon: Flame },
|
||||||
|
{ title: "용수", href: "/water", icon: Droplet },
|
||||||
|
{ title: "스팀", href: "/steam", icon: Wind },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "설비 관리",
|
||||||
|
items: [
|
||||||
|
{ title: "설비 목록", href: "/inventory", icon: Box },
|
||||||
|
{ title: "상태 모니터링", href: "/monitoring", icon: Activity },
|
||||||
|
{ title: "정비 관리", href: "/maintenance", icon: Wrench },
|
||||||
|
{ title: "부품 관리", href: "/parts", icon: Puzzle },
|
||||||
|
{ title: "작업자 관리", href: "/personnel", icon: Users },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "분석/리포트",
|
||||||
|
items: [
|
||||||
|
{ title: "에너지 분석", href: "/energy", icon: LineChart },
|
||||||
|
{ title: "원단위 분석", href: "/efficiency", icon: BarChart },
|
||||||
|
{ title: "보고서", href: "/reports", icon: FileText },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "알람/이벤트",
|
||||||
|
items: [
|
||||||
|
{ title: "실시간 알람", href: "/realtime", icon: Bell },
|
||||||
|
{ title: "이력 관리", href: "/history", icon: History },
|
||||||
|
{ title: "알람 설정", href: "/settings", icon: SettingsIcon },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "에너지 계획",
|
||||||
|
items: [
|
||||||
|
{ title: "절감 목표", href: "/targets", icon: Target },
|
||||||
|
{ title: "수요 예측", href: "/forecast", icon: TrendingUp },
|
||||||
|
{ title: "최적화", href: "/optimization", icon: Brain },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "지원/커뮤니티",
|
||||||
|
items: [
|
||||||
|
{ title: "도움말", href: "/faq", icon: HelpCircle },
|
||||||
|
{ title: "게시판", href: "/community/forum", icon: MessageSquare },
|
||||||
|
{ title: "뉴스", href: "/community/news", icon: Newspaper },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 관리자 권한 체크
|
||||||
|
const isAdmin = ["super_admin", "company_admin"].includes(role);
|
||||||
|
const hasAnyAdminPermission = Object.values(ADMIN_PERMISSIONS).some(
|
||||||
|
(permission) => hasPermission(permission)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 관리자 메뉴 아이템
|
||||||
|
const adminMenuItem = {
|
||||||
|
title: "관리",
|
||||||
items: [
|
items: [
|
||||||
{ title: "전체 현황", href: "/dashboard/overview", icon: MenuIcon },
|
{
|
||||||
{ title: "KPI 지표", href: "/dashboard/kpi" },
|
title: "회사 설정",
|
||||||
{ title: "비용 현황", href: "/dashboard/costs" },
|
href: "/company/profile",
|
||||||
|
icon: Building2,
|
||||||
|
permission: ADMIN_PERMISSIONS.COMPANY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "지점/공장 관리",
|
||||||
|
href: "/company/branches",
|
||||||
|
icon: Building2,
|
||||||
|
permission: ADMIN_PERMISSIONS.BRANCH,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "결재 관리",
|
||||||
|
href: "/company/billing",
|
||||||
|
icon: DollarSign,
|
||||||
|
permission: ADMIN_PERMISSIONS.BILLING,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "사용자 권한 관리",
|
||||||
|
href: "/users/roles",
|
||||||
|
icon: Users,
|
||||||
|
permission: ADMIN_PERMISSIONS.USERS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "부서 관리",
|
||||||
|
href: "/users/departments",
|
||||||
|
icon: Users,
|
||||||
|
permission: ADMIN_PERMISSIONS.DEPARTMENTS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "계정 관리",
|
||||||
|
href: "/users/accounts",
|
||||||
|
icon: Users,
|
||||||
|
permission: ADMIN_PERMISSIONS.ACCOUNTS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "시스템 설정",
|
||||||
|
href: "/system",
|
||||||
|
icon: Sliders,
|
||||||
|
permission: ADMIN_PERMISSIONS.SYSTEM,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
};
|
||||||
{
|
|
||||||
title: "에너지 모니터링",
|
// 관리자 권한이 있는 메뉴 아이템만 필터링
|
||||||
items: [
|
const filteredAdminItems = {
|
||||||
{ title: "전력", href: "/monitoring/electricity" },
|
...adminMenuItem,
|
||||||
{ title: "가스", href: "/monitoring/gas" },
|
items: adminMenuItem.items.filter((item) => hasPermission(item.permission)),
|
||||||
{ title: "용수", href: "/monitoring/water" },
|
};
|
||||||
{ title: "스팀", href: "/monitoring/steam" },
|
|
||||||
],
|
// 관리자 권한이 있고, 접근 가능한 메뉴가 하나라도 있는 경우에만 관리자 메뉴 추가
|
||||||
},
|
return isAdmin ||
|
||||||
{
|
(hasAnyAdminPermission && filteredAdminItems.items.length > 0)
|
||||||
title: "설비 관리",
|
? [...baseMenuItems, filteredAdminItems]
|
||||||
items: [
|
: baseMenuItems;
|
||||||
{ title: "설비 목록", href: "/equipment/inventory" },
|
};
|
||||||
{ title: "상태 모니터링", href: "/equipment/monitoring" },
|
|
||||||
{ title: "정비 관리", href: "/equipment/maintenance" },
|
interface MenuItemProps {
|
||||||
],
|
item: {
|
||||||
},
|
title: string;
|
||||||
{
|
items: {
|
||||||
title: "시스템 관리",
|
title: string;
|
||||||
items: [
|
href: string;
|
||||||
{ title: "사용자 관리", href: "/system/users" },
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
{ title: "알림 관리", href: "/system/alerts" },
|
permission?: string;
|
||||||
{ title: "설정", href: "/system/settings" },
|
}[];
|
||||||
],
|
};
|
||||||
},
|
isOpen: boolean;
|
||||||
];
|
onToggle: () => void;
|
||||||
|
pathname: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MenuItem: React.FC<MenuItemProps> = ({
|
||||||
|
item,
|
||||||
|
isOpen,
|
||||||
|
onToggle,
|
||||||
|
pathname,
|
||||||
|
}) => {
|
||||||
|
const firstIcon = item.items[0]?.icon;
|
||||||
|
const IconComponent = firstIcon || Box;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-1">
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center px-3 py-2 text-sm font-medium rounded-md",
|
||||||
|
"transition-colors duration-150",
|
||||||
|
"hover:bg-gray-100",
|
||||||
|
isOpen ? "text-blue-600 bg-blue-50" : "text-gray-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<IconComponent className="h-5 w-5 mr-2" />
|
||||||
|
<span className="flex-1 text-left">{item.title}</span>
|
||||||
|
{isOpen ? (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="ml-9 mt-1 space-y-1">
|
||||||
|
{item.items.map((subItem) => (
|
||||||
|
<Link
|
||||||
|
key={subItem.href}
|
||||||
|
href={subItem.href}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center px-3 py-2 text-sm rounded-md",
|
||||||
|
"transition-colors duration-150",
|
||||||
|
pathname === subItem.href
|
||||||
|
? "bg-blue-50 text-blue-600 font-medium"
|
||||||
|
: "text-gray-600 hover:bg-gray-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{subItem.icon && (
|
||||||
|
<subItem.icon className="h-4 w-4 mr-2 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
{subItem.title}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export function SideNav() {
|
export function SideNav() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { hasPermission } = usePermissions();
|
||||||
|
|
||||||
|
const menuItems = getMenuItems(hasPermission, user?.role || "");
|
||||||
|
|
||||||
|
const [openSections, setOpenSections] = useState<{ [key: string]: boolean }>(
|
||||||
|
() => {
|
||||||
|
return menuItems.reduce((acc: { [key: string]: boolean }, item) => {
|
||||||
|
if (item.items.some((subItem) => subItem.href === pathname)) {
|
||||||
|
acc[item.title] = true;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleSection = (title: string) => {
|
||||||
|
setOpenSections((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[title]: !prev[title],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="w-64 bg-slate-900 text-white min-h-screen">
|
<nav className="w-64 bg-white border-r border-gray-200 h-screen overflow-y-auto">
|
||||||
<div className="p-4">
|
<div className="sticky top-0 z-10 bg-white p-4 border-b border-gray-200">
|
||||||
<h1 className="text-xl font-bold">FEMS</h1>
|
<div className="flex items-center space-x-2">
|
||||||
|
<Gauge className="h-8 w-6 text-blue-600" />
|
||||||
|
<h1 className="text-xl font-semibold text-gray-900">FEMS</h1>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
|
||||||
{menuItems.map((group) => (
|
<div className="p-3">
|
||||||
<div key={group.title}>
|
{menuItems.map((item) => (
|
||||||
<h2 className="px-4 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
<MenuItem
|
||||||
{group.title}
|
key={item.title}
|
||||||
</h2>
|
item={item}
|
||||||
<div className="mt-2 space-y-1">
|
isOpen={openSections[item.title]}
|
||||||
{group.items.map((item) => (
|
onToggle={() => toggleSection(item.title)}
|
||||||
<Link
|
pathname={pathname}
|
||||||
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>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default SideNav;
|
||||||
|
@ -6,24 +6,102 @@ import {
|
|||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import {
|
||||||
|
Bell,
|
||||||
|
Search,
|
||||||
|
Settings,
|
||||||
|
HelpCircle,
|
||||||
|
LogOut,
|
||||||
|
User,
|
||||||
|
ChevronDown,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
export function TopNav() {
|
export function TopNav() {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="h-16 border-b bg-white">
|
<header className="h-16 border-b bg-white fixed top-0 right-0 left-64 z-50">
|
||||||
<div className="h-full px-4 flex items-center justify-between">
|
<div className="h-full px-4 flex items-center justify-between">
|
||||||
<div>{/* 추가 기능 버튼들 */}</div>
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-4">
|
{/* 검색바 */}
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="검색..."
|
||||||
|
className="h-9 w-64 px-4 pl-10 rounded-md bg-gray-50 border border-gray-200
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
<Search className="h-4 w-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* 알림 버튼 */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="relative text-gray-600 hover:text-gray-900"
|
||||||
|
>
|
||||||
|
<Bell className="h-5 w-5" />
|
||||||
|
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 도움말 버튼 */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-gray-600 hover:text-gray-900"
|
||||||
|
>
|
||||||
|
<HelpCircle className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 사용자 프로필 드롭다운 */}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost">{user?.name}</Button>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="flex items-center gap-2 px-3 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center font-medium">
|
||||||
|
{user?.name?.[0] || "U"}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<span className="text-sm font-medium text-gray-700">
|
||||||
|
{user?.name || "사용자"}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{user?.role || "관리자"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ChevronDown className="h-4 w-4 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
<DropdownMenuItem onClick={logout}>로그아웃</DropdownMenuItem>
|
<DropdownMenuLabel>내 계정</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<User className="mr-2 h-4 w-4" />
|
||||||
|
<span>프로필 설정</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
|
<span>환경설정</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={logout}
|
||||||
|
className="text-red-600 focus:text-red-600"
|
||||||
|
>
|
||||||
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
<span>로그아웃</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
@ -31,3 +109,5 @@ export function TopNav() {
|
|||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default TopNav;
|
||||||
|
@ -1,113 +0,0 @@
|
|||||||
// src/components/monitoring/MonitoringHeader.tsx
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
|
||||||
import {
|
|
||||||
Bell,
|
|
||||||
Search,
|
|
||||||
Settings,
|
|
||||||
HelpCircle,
|
|
||||||
LogOut,
|
|
||||||
User,
|
|
||||||
ChevronDown,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
export const MonitoringHeader = () => {
|
|
||||||
const { user, logout } = useAuth();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header className="h-16 border-b bg-white fixed top-0 right-0 left-64 z-50">
|
|
||||||
<div className="h-full px-4 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{/* 검색바 */}
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="검색..."
|
|
||||||
className="h-9 w-64 px-4 pl-10 rounded-md bg-gray-50 border border-gray-200
|
|
||||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
<Search className="h-4 w-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{/* 알림 버튼 */}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="relative text-gray-600 hover:text-gray-900"
|
|
||||||
>
|
|
||||||
<Bell className="h-5 w-5" />
|
|
||||||
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* 도움말 버튼 */}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="text-gray-600 hover:text-gray-900"
|
|
||||||
>
|
|
||||||
<HelpCircle className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* 사용자 프로필 드롭다운 */}
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="flex items-center gap-2 px-3 hover:bg-gray-100"
|
|
||||||
>
|
|
||||||
<div className="w-8 h-8 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center font-medium">
|
|
||||||
{user?.name?.[0] || "U"}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex flex-col items-start">
|
|
||||||
<span className="text-sm font-medium text-gray-700">
|
|
||||||
{user?.name || "사용자"}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-gray-500">
|
|
||||||
{user?.role || "관리자"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ChevronDown className="h-4 w-4 text-gray-500" />
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
|
||||||
<DropdownMenuLabel>내 계정</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<User className="mr-2 h-4 w-4" />
|
|
||||||
<span>프로필 설정</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
|
||||||
<span>환경설정</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={logout}
|
|
||||||
className="text-red-600 focus:text-red-600"
|
|
||||||
>
|
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
|
||||||
<span>로그아웃</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MonitoringHeader;
|
|
@ -1,235 +0,0 @@
|
|||||||
// src/components/monitoring/MonitoringSidebar.tsx
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import {
|
|
||||||
LayoutDashboard,
|
|
||||||
Gauge,
|
|
||||||
TrendingUp,
|
|
||||||
DollarSign,
|
|
||||||
Zap,
|
|
||||||
Droplet,
|
|
||||||
Wind,
|
|
||||||
Flame,
|
|
||||||
Box,
|
|
||||||
Activity,
|
|
||||||
Wrench,
|
|
||||||
LineChart,
|
|
||||||
BarChart,
|
|
||||||
FileText,
|
|
||||||
Bell,
|
|
||||||
History,
|
|
||||||
Settings as SettingsIcon,
|
|
||||||
Target,
|
|
||||||
Brain,
|
|
||||||
Sliders,
|
|
||||||
Building2,
|
|
||||||
Users,
|
|
||||||
HelpCircle,
|
|
||||||
MessageSquare,
|
|
||||||
Newspaper,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
Puzzle,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
const menuItems = [
|
|
||||||
{
|
|
||||||
title: "대시보드",
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
title: "전체 현황",
|
|
||||||
href: "/dashboard/overview",
|
|
||||||
icon: LayoutDashboard,
|
|
||||||
},
|
|
||||||
{ title: "KPI 지표", href: "/dashboard/kpi", icon: Gauge },
|
|
||||||
{ title: "비용 현황", href: "/dashboard/costs", icon: DollarSign },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "에너지 모니터링",
|
|
||||||
items: [
|
|
||||||
{ title: "전력", href: "/electricity", icon: Zap },
|
|
||||||
{ title: "가스", href: "/gas", icon: Flame },
|
|
||||||
{ title: "용수", href: "/water", icon: Droplet },
|
|
||||||
{ title: "스팀", href: "/steam", icon: Wind },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "설비 관리",
|
|
||||||
items: [
|
|
||||||
{ title: "설비 목록", href: "/inventory", icon: Box },
|
|
||||||
{ title: "상태 모니터링", href: "/monitoring", icon: Activity },
|
|
||||||
{ title: "정비 관리", href: "/maintenance", icon: Wrench },
|
|
||||||
{ title: "부품 관리", href: "/parts", icon: Puzzle },
|
|
||||||
{ title: "작업자 관리", href: "/personnel", icon: Users },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "분석/리포트",
|
|
||||||
items: [
|
|
||||||
{ title: "에너지 분석", href: "/energy", icon: LineChart },
|
|
||||||
{ title: "원단위 분석", href: "/efficiency", icon: BarChart },
|
|
||||||
{ title: "보고서", href: "/reports", icon: FileText },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "알람/이벤트",
|
|
||||||
items: [
|
|
||||||
{ title: "실시간 알람", href: "/realtime", icon: Bell },
|
|
||||||
{ title: "이력 관리", href: "/history", icon: History },
|
|
||||||
{ title: "알람 설정", href: "/settings", icon: SettingsIcon },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "에너지 계획",
|
|
||||||
items: [
|
|
||||||
{ title: "절감 목표", href: "/targets", icon: Target },
|
|
||||||
{ title: "수요 예측", href: "/forecast", icon: TrendingUp },
|
|
||||||
{ title: "최적화", href: "/optimization", icon: Brain },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "관리",
|
|
||||||
items: [
|
|
||||||
{ title: "회사 설정", href: "/company/profile", icon: Building2 },
|
|
||||||
{
|
|
||||||
title: "지점/공장 관리",
|
|
||||||
href: "/company/branches",
|
|
||||||
icon: Building2,
|
|
||||||
},
|
|
||||||
{ title: "결재 관리", href: "/company/billing", icon: DollarSign },
|
|
||||||
{ title: "사용자 권한 관리", href: "/users/roles", icon: Users },
|
|
||||||
{ title: "부서 관리", href: "/users/departments", icon: Users },
|
|
||||||
{ title: "계정 관리", href: "/users/accounts", icon: Users },
|
|
||||||
{ title: "시스템 설정", href: "/system", icon: Sliders },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "지원/커뮤니티",
|
|
||||||
items: [
|
|
||||||
{ title: "도움말", href: "/faq", icon: HelpCircle },
|
|
||||||
{ title: "게시판", href: "/community/forum", icon: MessageSquare },
|
|
||||||
{ title: "뉴스", href: "/community/news", icon: Newspaper },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
interface MenuItemProps {
|
|
||||||
item: {
|
|
||||||
title: string;
|
|
||||||
items: {
|
|
||||||
title: string;
|
|
||||||
href: string;
|
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
isOpen: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
pathname: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MenuItem: React.FC<MenuItemProps> = ({
|
|
||||||
item,
|
|
||||||
isOpen,
|
|
||||||
onToggle,
|
|
||||||
pathname,
|
|
||||||
}) => {
|
|
||||||
const firstIcon = item.items[0]?.icon;
|
|
||||||
const IconComponent = firstIcon || Box;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mb-1">
|
|
||||||
<button
|
|
||||||
onClick={onToggle}
|
|
||||||
className={cn(
|
|
||||||
"w-full flex items-center px-3 py-2 text-sm font-medium rounded-md",
|
|
||||||
"transition-colors duration-150",
|
|
||||||
"hover:bg-gray-100",
|
|
||||||
isOpen ? "text-blue-600 bg-blue-50" : "text-gray-700"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<IconComponent className="h-5 w-5 mr-2" />
|
|
||||||
<span className="flex-1 text-left">{item.title}</span>
|
|
||||||
{isOpen ? (
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isOpen && (
|
|
||||||
<div className="ml-9 mt-1 space-y-1">
|
|
||||||
{item.items.map((subItem) => (
|
|
||||||
<Link
|
|
||||||
key={subItem.href}
|
|
||||||
href={subItem.href}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center px-3 py-2 text-sm rounded-md",
|
|
||||||
"transition-colors duration-150",
|
|
||||||
pathname === subItem.href
|
|
||||||
? "bg-blue-50 text-blue-600 font-medium"
|
|
||||||
: "text-gray-600 hover:bg-gray-50"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{subItem.icon && (
|
|
||||||
<subItem.icon className="h-4 w-4 mr-2 flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
{subItem.title}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function MonitoringSidebar() {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const [openSections, setOpenSections] = useState<{ [key: string]: boolean }>(
|
|
||||||
() => {
|
|
||||||
// 현재 경로에 해당하는 섹션을 자동으로 열어둠
|
|
||||||
return menuItems.reduce((acc: { [key: string]: boolean }, item) => {
|
|
||||||
if (item.items.some((subItem) => subItem.href === pathname)) {
|
|
||||||
acc[item.title] = true;
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const toggleSection = (title: string) => {
|
|
||||||
setOpenSections((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[title]: !prev[title],
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav className="w-64 bg-white border-r border-gray-200 h-screen overflow-y-auto">
|
|
||||||
<div className="sticky top-0 z-10 bg-white p-4 border-b border-gray-200">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Gauge className="h-8 w-6 text-blue-600" />
|
|
||||||
<h1 className="text-xl font-semibold text-gray-900">FEMS</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-3">
|
|
||||||
{menuItems.map((item) => (
|
|
||||||
<MenuItem
|
|
||||||
key={item.title}
|
|
||||||
item={item}
|
|
||||||
isOpen={openSections[item.title]}
|
|
||||||
onToggle={() => toggleSection(item.title)}
|
|
||||||
pathname={pathname}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default MonitoringSidebar;
|
|
39
fems-app/src/hooks/usePermissions.ts
Normal file
39
fems-app/src/hooks/usePermissions.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
// src/hooks/usePermissions.ts
|
||||||
|
import { useAuth } from "./useAuth";
|
||||||
|
|
||||||
|
export function usePermissions() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const permissions = user?.permissions || {};
|
||||||
|
|
||||||
|
const hasPermission = (permission: string): boolean => {
|
||||||
|
return !!permissions[permission];
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasAnyPermission = (requiredPermissions: string[]): boolean => {
|
||||||
|
return requiredPermissions.some((permission) => hasPermission(permission));
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasAllPermissions = (requiredPermissions: string[]): boolean => {
|
||||||
|
return requiredPermissions.every((permission) => hasPermission(permission));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
permissions,
|
||||||
|
hasPermission,
|
||||||
|
hasAnyPermission,
|
||||||
|
hasAllPermissions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용 예시:
|
||||||
|
/*
|
||||||
|
function MyComponent() {
|
||||||
|
const { hasPermission } = usePermissions();
|
||||||
|
|
||||||
|
if (!hasPermission('departments:view')) {
|
||||||
|
return <div>접근 권한이 없습니다.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div>부서 목록...</div>;
|
||||||
|
}
|
||||||
|
*/
|
@ -1,24 +1,65 @@
|
|||||||
// src/lib/jwt.ts
|
// src/lib/jwt.ts
|
||||||
import * as jose from "jose";
|
import * as jose from "jose";
|
||||||
import { UserRole } from "@/types/auth";
|
import { UserRole, Permissions } from "@/types/auth";
|
||||||
|
|
||||||
interface JwtPayload {
|
interface JwtPayload {
|
||||||
id: string;
|
id: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
branchId: string;
|
branchId?: string;
|
||||||
|
permissions: Permissions; // 권한 정보 추가
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decodeToken(token: string): JwtPayload | null {
|
export function decodeToken(token: string): JwtPayload | null {
|
||||||
try {
|
try {
|
||||||
// jose를 사용한 디코딩
|
|
||||||
const decoded = jose.decodeJwt(token);
|
const decoded = jose.decodeJwt(token);
|
||||||
const payload = decoded as unknown as JwtPayload;
|
const payload = decoded as unknown as JwtPayload;
|
||||||
if (payload.id && payload.role && payload.companyId && payload.branchId) {
|
|
||||||
return payload;
|
// 필수 필드 검증
|
||||||
|
if (
|
||||||
|
!payload.id ||
|
||||||
|
!payload.role ||
|
||||||
|
!payload.companyId ||
|
||||||
|
!payload.permissions
|
||||||
|
) {
|
||||||
|
console.warn("Invalid token payload:", payload);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
} catch {
|
return {
|
||||||
|
id: payload.id,
|
||||||
|
role: payload.role,
|
||||||
|
companyId: payload.companyId,
|
||||||
|
branchId: payload.branchId,
|
||||||
|
permissions: payload.permissions,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Token decode error:", error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 권한 체크 유틸리티 함수들 추가
|
||||||
|
export function hasPermission(
|
||||||
|
payload: JwtPayload | null,
|
||||||
|
permission: string
|
||||||
|
): boolean {
|
||||||
|
if (!payload?.permissions) return false;
|
||||||
|
return !!payload.permissions[permission];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasAnyPermission(
|
||||||
|
payload: JwtPayload | null,
|
||||||
|
permissions: string[]
|
||||||
|
): boolean {
|
||||||
|
if (!payload?.permissions) return false;
|
||||||
|
return permissions.some((permission) => hasPermission(payload, permission));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasAllPermissions(
|
||||||
|
payload: JwtPayload | null,
|
||||||
|
permissions: string[]
|
||||||
|
): boolean {
|
||||||
|
if (!payload?.permissions) return false;
|
||||||
|
return permissions.every((permission) => hasPermission(payload, permission));
|
||||||
|
}
|
||||||
|
@ -1,36 +1,75 @@
|
|||||||
// src/middleware.ts
|
// src/middleware.ts
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { decodeToken } from "@/lib/jwt";
|
import { decodeToken, hasPermission, hasAnyPermission } 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) {
|
export function middleware(request: NextRequest) {
|
||||||
const token = request.cookies.get("token")?.value;
|
const token = request.cookies.get("token")?.value;
|
||||||
|
|
||||||
|
// Public routes
|
||||||
|
if (
|
||||||
|
request.nextUrl.pathname === "/" ||
|
||||||
|
request.nextUrl.pathname === "/login"
|
||||||
|
) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
// 비인증 사용자는 로그인 페이지로
|
// 비인증 사용자는 로그인 페이지로
|
||||||
if (!token && !request.nextUrl.pathname.startsWith("/")) {
|
if (!token) {
|
||||||
return NextResponse.redirect(new URL("/", request.url));
|
return NextResponse.redirect(new URL("/", request.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 권한별 접근 제어
|
// 토큰 디코딩
|
||||||
|
const tokenData = decodeToken(token);
|
||||||
|
if (!tokenData) {
|
||||||
|
return NextResponse.redirect(new URL("/", request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 권한 기반 라우팅 보호
|
||||||
if (request.nextUrl.pathname.startsWith("/admin")) {
|
if (request.nextUrl.pathname.startsWith("/admin")) {
|
||||||
const role = getUserRole(token);
|
// admin 페이지 접근을 위해서는 admin:access 권한이나 특정 role이 필요
|
||||||
if (
|
const hasAdminAccess =
|
||||||
!role ||
|
hasPermission(tokenData, "admin:access") ||
|
||||||
!["super_admin", "company_admin", "branch_admin", "user"].includes(role)
|
["super_admin", "company_admin"].includes(tokenData.role);
|
||||||
) {
|
|
||||||
|
if (!hasAdminAccess) {
|
||||||
return NextResponse.redirect(new URL("/dashboard/overview", request.url));
|
return NextResponse.redirect(new URL("/dashboard/overview", request.url));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.next();
|
// 사용자 관리 페이지
|
||||||
|
if (request.nextUrl.pathname.startsWith("/users")) {
|
||||||
|
const hasUserManageAccess = hasAnyPermission(tokenData, [
|
||||||
|
"users:manage",
|
||||||
|
"users:view",
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!hasUserManageAccess) {
|
||||||
|
return NextResponse.redirect(new URL("/dashboard/overview", request.url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부서 관리 페이지
|
||||||
|
if (request.nextUrl.pathname.startsWith("/departments")) {
|
||||||
|
const hasDepartmentAccess = hasAnyPermission(tokenData, [
|
||||||
|
"departments:manage",
|
||||||
|
"departments:view",
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!hasDepartmentAccess) {
|
||||||
|
return NextResponse.redirect(new URL("/dashboard/overview", request.url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 응답 헤더에 사용자 권한 정보 추가 (옵션)
|
||||||
|
const response = NextResponse.next();
|
||||||
|
response.headers.set(
|
||||||
|
"X-User-Permissions",
|
||||||
|
JSON.stringify(tokenData.permissions)
|
||||||
|
);
|
||||||
|
response.headers.set("X-User-Role", tokenData.role);
|
||||||
|
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
|
@ -5,6 +5,10 @@ export type UserRole =
|
|||||||
| "branch_admin"
|
| "branch_admin"
|
||||||
| "user";
|
| "user";
|
||||||
|
|
||||||
|
export interface Permissions {
|
||||||
|
[key: string]: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
@ -13,4 +17,22 @@ export interface User {
|
|||||||
role: UserRole;
|
role: UserRole;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
branchId?: string;
|
branchId?: string;
|
||||||
|
permissions: Permissions; // 권한 정보 추가
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 권한 타입 상수 정의
|
||||||
|
export const PERMISSIONS = {
|
||||||
|
DEPARTMENTS: {
|
||||||
|
VIEW: "departments:view",
|
||||||
|
CREATE: "departments:create",
|
||||||
|
UPDATE: "departments:update",
|
||||||
|
DELETE: "departments:delete",
|
||||||
|
},
|
||||||
|
USERS: {
|
||||||
|
VIEW: "users:view",
|
||||||
|
CREATE: "users:create",
|
||||||
|
UPDATE: "users:update",
|
||||||
|
DELETE: "users:delete",
|
||||||
|
},
|
||||||
|
// 다른 권한들도 추가...
|
||||||
|
} as const;
|
||||||
|
Loading…
Reference in New Issue
Block a user