feat: React 18 프론트엔드 추가 및 프로젝트 구조 정리

- wtm-frontend → wtm-frontend-vue 이름 변경
- wtm-frontend-react 추가 (React 18 + PrimeReact + Zustand)
  - 동일한 모듈 구조 및 API 연동 (Vue 버전과 기능 동일)
  - Vue:5173 / React:5174 포트 분리
- 개발자 가이드에 React 프론트엔드 안내 추가
- .gitignore: Claude/OMC, 문서 생성 스크립트, package-lock 제외
- 불필요 파일 git 추적 제거 (.omc, generate_*.py, regenerate_*.py)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
이 Commit은 다음에 포함되어 있습니다:
2026-03-30 20:50:23 +09:00
부모 dd263a6e46
커밋 cda5f9591e
212개의 변경된 파일3633개의 추가작업 그리고 5244개의 파일을 삭제

파일 보기

@@ -0,0 +1,13 @@
/** WBX compatible list response */
export interface PageResponse<T> {
items: T[];
total: number;
skip: number;
limit: number;
}
/** WBX compatible error response */
export interface ErrorResponse {
detail: string;
code?: string;
}

파일 보기

@@ -0,0 +1,63 @@
import axios from 'axios';
import type { InternalAxiosRequestConfig, AxiosError } from 'axios';
import { authService } from '@/core/auth/auth.service';
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 30000,
headers: { 'Content-Type': 'application/json' },
});
// Request: attach JWT
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = authService.getAccessToken();
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// Response: 401 token refresh + retry
let isRefreshing = false;
let failedQueue: Array<{ resolve: (token: string) => void; reject: (error: unknown) => void }> =
[];
api.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const original = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
if (error.response?.status === 401 && !original._retry) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({
resolve: (token: string) => {
original.headers.Authorization = `Bearer ${token}`;
resolve(api(original));
},
reject,
});
});
}
original._retry = true;
isRefreshing = true;
try {
const newToken = await authService.refreshToken();
failedQueue.forEach((q) => q.resolve(newToken));
failedQueue = [];
original.headers.Authorization = `Bearer ${newToken}`;
return api(original);
} catch {
failedQueue.forEach((q) => q.reject(error));
failedQueue = [];
authService.clearTokens();
window.location.href = '/login';
return Promise.reject(error);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
},
);
export default api;

파일 보기

@@ -0,0 +1,43 @@
const ACCESS_TOKEN_KEY = 'wtm_access_token';
const REFRESH_TOKEN_KEY = 'wtm_refresh_token';
export const authService = {
getAccessToken(): string | null {
return localStorage.getItem(ACCESS_TOKEN_KEY);
},
getRefreshToken(): string | null {
return localStorage.getItem(REFRESH_TOKEN_KEY);
},
setTokens(accessToken: string, refreshToken: string) {
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
},
clearTokens() {
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
},
isAuthenticated(): boolean {
return !!this.getAccessToken();
},
async refreshToken(): Promise<string> {
const refreshToken = this.getRefreshToken();
if (!refreshToken) throw new Error('No refresh token');
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/wtm/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
if (!response.ok) throw new Error('Refresh failed');
const data = await response.json();
this.setTokens(data.access_token ?? data.accessToken, data.refresh_token ?? data.refreshToken);
return data.access_token ?? data.accessToken;
},
};

파일 보기

@@ -0,0 +1,20 @@
export interface AuthUser {
id: number;
email: string;
fullName: string;
roles: string[];
departmentId?: number;
}
export interface LoginRequest {
email: string;
password: string;
}
export interface LoginResponse {
accessToken?: string;
refreshToken?: string;
access_token?: string;
refresh_token?: string;
user: AuthUser & { is_admin?: boolean; full_name?: string; department_id?: number };
}

파일 보기

@@ -0,0 +1,31 @@
@use '@/assets/styles/variables' as *;
.app-layout {
min-height: 100vh;
background: $color-surface;
&__overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: calc(#{$z-sidebar} - 1);
}
&__main {
transition: margin-left 0.2s ease;
min-height: 100vh;
display: flex;
flex-direction: column;
}
&__content {
flex: 1;
padding: $page-padding-y $page-padding-x;
padding-top: calc(#{$topbar-height} + #{$page-padding-y});
@media (max-width: $bp-mobile) {
padding: $space-sm;
padding-top: calc(#{$topbar-height} + #{$space-sm});
}
}
}

파일 보기

@@ -0,0 +1,73 @@
import { useState, useEffect, useCallback } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import AppSidebar from './AppSidebar';
import AppTopbar from './AppTopbar';
import { BREAKPOINTS, LAYOUT } from '@/core/constants/app.constants';
import './AppLayout.scss';
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handler = () => setWidth(window.innerWidth);
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);
return width;
}
export default function AppLayout() {
const width = useWindowWidth();
const location = useLocation();
const isMobile = width < BREAKPOINTS.tablet;
const isTablet = width >= BREAKPOINTS.tablet && width < BREAKPOINTS.desktop;
const [sidebarVisible, setSidebarVisible] = useState(!isMobile);
const [sidebarCollapsed, setSidebarCollapsed] = useState(isTablet);
useEffect(() => {
if (isMobile) {
setSidebarVisible(false);
setSidebarCollapsed(false);
} else {
setSidebarVisible(true);
setSidebarCollapsed(isTablet);
}
}, [isMobile, isTablet]);
// Close sidebar on route change (mobile)
useEffect(() => {
if (isMobile) setSidebarVisible(false);
}, [location.pathname, isMobile]);
const toggleSidebar = useCallback(() => {
if (isMobile) {
setSidebarVisible((v) => !v);
} else {
setSidebarCollapsed((c) => !c);
}
}, [isMobile]);
const contentMarginLeft = isMobile
? '0'
: sidebarCollapsed
? `${LAYOUT.sidebarCollapsedWidth}px`
: `${LAYOUT.sidebarWidth}px`;
return (
<div className="app-layout">
{isMobile && sidebarVisible && (
<div className="app-layout__overlay" onClick={() => setSidebarVisible(false)} />
)}
<AppSidebar visible={sidebarVisible} collapsed={sidebarCollapsed && !isMobile} mobile={isMobile} />
<div className="app-layout__main" style={{ marginLeft: contentMarginLeft }}>
<AppTopbar onToggleSidebar={toggleSidebar} />
<main className="app-layout__content">
<Outlet />
</main>
</div>
</div>
);
}

파일 보기

@@ -0,0 +1,62 @@
@use '@/assets/styles/variables' as *;
.app-sidebar {
position: fixed;
top: 0;
left: 0;
height: 100vh;
background: $color-surface-card;
border-right: 1px solid $color-border;
z-index: $z-sidebar;
overflow-y: auto;
overflow-x: hidden;
transition: width 0.2s ease, transform 0.2s ease;
&--mobile {
transform: translateX(-100%);
width: $sidebar-width !important;
&.app-sidebar--visible {
transform: translateX(0);
}
}
&--collapsed {
.app-sidebar__title,
.app-sidebar__nav .p-panelmenu-header-content span,
.app-sidebar__nav .p-menuitem-text {
display: none;
}
}
&__header {
display: flex;
align-items: center;
gap: $space-sm;
height: $topbar-height;
padding: 0 $space-md;
border-bottom: 1px solid $color-border;
}
&__logo {
width: 32px;
height: 32px;
flex-shrink: 0;
}
&__title {
font-size: $font-size-lg;
font-weight: 700;
color: $color-primary;
white-space: nowrap;
}
&__nav {
padding: $space-sm 0;
}
&__menu .p-panelmenu {
border: none;
background: transparent;
}
}

파일 보기

@@ -0,0 +1,60 @@
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { PanelMenu } from 'primereact/panelmenu';
import { MENU_ITEMS, LAYOUT } from '@/core/constants/app.constants';
import { useCurrentUser } from '@/core/hooks/useCurrentUser';
import logo from '@/assets/images/logo.svg';
import './AppSidebar.scss';
interface Props {
visible: boolean;
collapsed: boolean;
mobile: boolean;
}
export default function AppSidebar({ visible, collapsed, mobile }: Props) {
const { currentUser } = useCurrentUser();
const navigate = useNavigate();
const filteredMenu = useMemo(() => {
const userRoles = currentUser?.roles ?? [];
return MENU_ITEMS
.filter((item) => item.roles.some((r) => userRoles.includes(r)))
.map((item) => ({
...item,
command: item.to ? () => navigate(item.to!) : undefined,
items: item.items
? item.items
.filter((sub) => sub.roles.some((r) => userRoles.includes(r)))
.map((sub) => ({
...sub,
command: () => navigate(sub.to),
}))
: undefined,
}));
}, [currentUser, navigate]);
const sidebarWidth = collapsed ? `${LAYOUT.sidebarCollapsedWidth}px` : `${LAYOUT.sidebarWidth}px`;
const classNames = [
'app-sidebar',
visible && 'app-sidebar--visible',
collapsed && 'app-sidebar--collapsed',
mobile && 'app-sidebar--mobile',
]
.filter(Boolean)
.join(' ');
return (
<aside className={classNames} style={{ width: sidebarWidth }}>
<div className="app-sidebar__header">
<img src={logo} alt="WTM" className="app-sidebar__logo" />
{!collapsed && <span className="app-sidebar__title">WTM</span>}
</div>
<nav className="app-sidebar__nav">
<PanelMenu model={filteredMenu} className="app-sidebar__menu" />
</nav>
</aside>
);
}

파일 보기

@@ -0,0 +1,39 @@
@use '@/assets/styles/variables' as *;
.app-topbar {
position: fixed;
top: 0;
right: 0;
left: 0;
height: $topbar-height;
background: $color-surface-card;
border-bottom: 1px solid $color-border;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 $space-md;
z-index: $z-topbar;
&__right {
display: flex;
align-items: center;
gap: $space-xs;
}
&__notify-btn {
position: relative;
}
&__user-btn {
display: flex;
align-items: center;
gap: $space-sm;
}
&__username {
font-size: $font-size-sm;
@media (max-width: $bp-tablet) {
display: none;
}
}
}

파일 보기

@@ -0,0 +1,47 @@
import { useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from 'primereact/button';
import { Avatar } from 'primereact/avatar';
import { Menu } from 'primereact/menu';
import { useCurrentUser } from '@/core/hooks/useCurrentUser';
import './AppTopbar.scss';
interface Props {
onToggleSidebar: () => void;
}
export default function AppTopbar({ onToggleSidebar }: Props) {
const { currentUser } = useCurrentUser();
const navigate = useNavigate();
const userMenu = useRef<Menu>(null);
const userMenuItems = [
{ label: '내 정보', icon: 'pi pi-user', command: () => navigate('/profile') },
{ label: '비밀번호 변경', icon: 'pi pi-key', command: () => navigate('/change-password') },
{ separator: true },
{ label: '로그아웃', icon: 'pi pi-sign-out', command: () => navigate('/login') },
];
return (
<header className="app-topbar">
<div className="app-topbar__left">
<Button icon="pi pi-bars" text rounded severity="secondary" onClick={onToggleSidebar} />
</div>
<div className="app-topbar__right">
<Button icon="pi pi-bell" text rounded severity="secondary" className="app-topbar__notify-btn" />
<Button
text
rounded
onClick={(e) => userMenu.current?.toggle(e)}
className="app-topbar__user-btn"
>
<Avatar label={currentUser?.fullName?.charAt(0) ?? '?'} shape="circle" size="normal" />
<span className="app-topbar__username">{currentUser?.fullName}</span>
</Button>
<Menu model={userMenuItems} popup ref={userMenu} />
</div>
</header>
);
}

파일 보기

@@ -0,0 +1,37 @@
import { useEffect, useState } from 'react';
import { Navigate } from 'react-router-dom';
import { ProgressSpinner } from 'primereact/progressspinner';
import { authService } from '@/core/auth/auth.service';
import { useAuthStore } from '@/modules/auth/auth.store';
export default function AuthGuard({ children }: { children: React.ReactNode }) {
const currentUser = useAuthStore((s) => s.currentUser);
const fetchMe = useAuthStore((s) => s.fetchMe);
const [checking, setChecking] = useState(true);
useEffect(() => {
if (!authService.isAuthenticated()) {
setChecking(false);
return;
}
if (!currentUser) {
fetchMe().finally(() => setChecking(false));
} else {
setChecking(false);
}
}, [currentUser, fetchMe]);
if (checking) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<ProgressSpinner />
</div>
);
}
if (!authService.isAuthenticated()) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}

파일 보기

@@ -0,0 +1,36 @@
@use '@/assets/styles/variables' as *;
.crud-table {
&__toolbar {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: $space-sm;
margin-bottom: $space-md;
}
&__toolbar-left,
&__toolbar-right {
display: flex;
gap: $space-sm;
align-items: center;
flex-wrap: wrap;
}
&__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: $space-sm;
padding: $space-2xl;
color: $color-text-muted;
}
@media (max-width: $bp-mobile) {
&__toolbar {
flex-direction: column;
align-items: stretch;
}
}
}

파일 보기

@@ -0,0 +1,91 @@
import { useState, type ReactNode } from 'react';
import { DataTable, type DataTablePageEvent } from 'primereact/datatable';
import { InputText } from 'primereact/inputtext';
import { PAGINATION } from '@/core/constants/app.constants';
import './BaseCrudTable.scss';
interface Props<T> {
value: T[];
loading?: boolean;
totalRecords?: number;
dataKey?: string;
globalFilterFields?: string[];
paginator?: boolean;
rowsPerPage?: number;
emptyMessage?: string;
selectionMode?: 'single' | 'multiple' | 'checkbox' | 'radiobutton';
exportFilename?: string;
toolbarLeft?: ReactNode;
toolbarRight?: ReactNode;
onRowSelect?: (row: T) => void;
onPage?: (event: DataTablePageEvent) => void;
children: ReactNode;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export default function BaseCrudTable<T extends Record<string, any>>({
value,
loading = false,
totalRecords,
dataKey = 'id',
globalFilterFields,
paginator = true,
rowsPerPage = PAGINATION.defaultPageSize,
emptyMessage = '데이터가 없습니다.',
toolbarLeft,
toolbarRight,
onRowSelect,
onPage,
children,
}: Props<T>) {
const [globalFilter, setGlobalFilter] = useState('');
return (
<div className="crud-table">
<div className="crud-table__toolbar">
<div className="crud-table__toolbar-left">{toolbarLeft}</div>
<div className="crud-table__toolbar-right">
{toolbarRight}
{globalFilterFields && globalFilterFields.length > 0 && (
<span className="p-input-icon-left">
<i className="pi pi-search" />
<InputText
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
placeholder="검색..."
size={1}
/>
</span>
)}
</div>
</div>
<DataTable
value={value as any} // eslint-disable-line @typescript-eslint/no-explicit-any
loading={loading}
dataKey={dataKey}
paginator={paginator}
rows={rowsPerPage}
rowsPerPageOptions={[...PAGINATION.pageSizeOptions]}
totalRecords={totalRecords}
globalFilter={globalFilter}
globalFilterFields={globalFilterFields}
removableSort
stripedRows
showGridlines
size="small"
className="crud-table__datatable"
onRowSelect={(e) => onRowSelect?.(e.data as T)}
onPage={onPage}
emptyMessage={
<div className="crud-table__empty">
<i className="pi pi-inbox" style={{ fontSize: '2rem' }} />
<p>{emptyMessage}</p>
</div>
}
>
{children}
</DataTable>
</div>
);
}

파일 보기

@@ -0,0 +1,15 @@
@use '@/assets/styles/variables' as *;
.form-dialog {
&__body {
display: flex;
flex-direction: column;
gap: $space-md;
}
&__footer {
display: flex;
justify-content: flex-end;
gap: $space-sm;
}
}

파일 보기

@@ -0,0 +1,50 @@
import type { ReactNode } from 'react';
import { Dialog } from 'primereact/dialog';
import { Button } from 'primereact/button';
import './BaseFormDialog.scss';
interface Props {
visible: boolean;
onHide: () => void;
title: string;
width?: string;
loading?: boolean;
submitLabel?: string;
cancelLabel?: string;
onSubmit: () => void;
children: ReactNode;
}
export default function BaseFormDialog({
visible,
onHide,
title,
width = '540px',
loading = false,
submitLabel = '저장',
cancelLabel = '취소',
onSubmit,
children,
}: Props) {
const footer = (
<div className="form-dialog__footer">
<Button label={cancelLabel} severity="secondary" text disabled={loading} onClick={onHide} />
<Button label={submitLabel} loading={loading} onClick={onSubmit} />
</div>
);
return (
<Dialog
visible={visible}
header={title}
style={{ width, maxWidth: '95vw' }}
modal
closable={!loading}
draggable={false}
onHide={onHide}
footer={footer}
>
<div className="form-dialog__body">{children}</div>
</Dialog>
);
}

파일 보기

@@ -0,0 +1,37 @@
@use '@/assets/styles/variables' as *;
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
gap: $space-md;
margin-bottom: $space-lg;
&__title {
font-size: $font-size-2xl;
font-weight: 700;
margin: 0;
color: $color-text;
}
&__subtitle {
font-size: $font-size-sm;
color: $color-text-muted;
margin: $space-xs 0 0;
}
&__actions {
display: flex;
gap: $space-sm;
flex-wrap: wrap;
}
@media (max-width: $bp-mobile) {
flex-direction: column;
&__actions {
width: 100%;
justify-content: flex-end;
}
}
}

파일 보기

@@ -0,0 +1,20 @@
import type { ReactNode } from 'react';
import './BasePageHeader.scss';
interface Props {
title: string;
subtitle?: string;
actions?: ReactNode;
}
export default function BasePageHeader({ title, subtitle, actions }: Props) {
return (
<div className="page-header">
<div className="page-header__text">
<h1 className="page-header__title">{title}</h1>
{subtitle && <p className="page-header__subtitle">{subtitle}</p>}
</div>
{actions && <div className="page-header__actions">{actions}</div>}
</div>
);
}

파일 보기

@@ -0,0 +1,11 @@
import { Link } from 'react-router-dom';
export default function NotFoundView() {
return (
<div style={{ textAlign: 'center', padding: '4rem 1rem' }}>
<h1>404</h1>
<p> .</p>
<Link to="/dashboard"> </Link>
</div>
);
}

파일 보기

@@ -0,0 +1,151 @@
// Breakpoints
export const BREAKPOINTS = {
mobile: 576,
tablet: 768,
desktop: 992,
wide: 1200,
ultra: 1400,
} as const;
// Layout
export const LAYOUT = {
sidebarWidth: 260,
sidebarCollapsedWidth: 64,
topbarHeight: 56,
} as const;
// Pagination
export const PAGINATION = {
defaultPageSize: 20,
pageSizeOptions: [10, 20, 50, 100],
} as const;
// Toast
export const TOAST = {
defaultLife: 3000,
errorLife: 5000,
} as const;
// Date formats
export const DATE_FORMAT = {
display: 'YYYY-MM-DD',
api: 'YYYY-MM-DD',
datetime: 'YYYY-MM-DD HH:mm',
weekStart: 1,
} as const;
// Timesheet rules
export const TIMESHEET_RULES = {
maxDailyHours: 24,
warnDailyHours: 10,
defaultDailyHours: 8,
maxWeeklyHours: 52,
} as const;
// Roles
export const ROLES = {
SA: 'SA',
PM: 'PM',
PCM: 'PCM',
PTK: 'PTK',
DL: 'DL',
USER: 'USER',
} as const;
// Timesheet status
export const TIMESHEET_STATUS: Record<string, { label: string; severity: string }> = {
DRAFT: { label: '작성중', severity: 'secondary' },
SUBMITTED: { label: '제출됨', severity: 'info' },
DL_APPROVED: { label: 'DL승인', severity: 'warning' },
APPROVED: { label: '승인', severity: 'success' },
REJECTED: { label: '반려', severity: 'danger' },
};
// Project status
export const PROJECT_STATUS: Record<string, { label: string; severity: string }> = {
ACTIVE: { label: '진행중', severity: 'success' },
CLOSED: { label: '종료', severity: 'secondary' },
HOLD: { label: '보류', severity: 'warning' },
};
// Entry types
export const ENTRY_TYPES: Record<string, { label: string; icon: string }> = {
NON_PROJECT: { label: 'Non-Project', icon: 'pi pi-calendar' },
OTHER_PROJECT: { label: 'Other Project', icon: 'pi pi-briefcase' },
EPC: { label: 'EPC Project', icon: 'pi pi-building' },
};
// Non-Project categories
export const NP_CATEGORIES = [
{ value: 'ANNUAL_LEAVE', label: '연차' },
{ value: 'SICK_LEAVE', label: '병가' },
{ value: 'TRAINING', label: '교육' },
{ value: 'ADMIN', label: '행정' },
{ value: 'PUBLIC_HOLIDAY', label: '공휴일' },
{ value: 'OTHER', label: '기타' },
] as const;
// Sidebar menu
export interface MenuItem {
label: string;
icon: string;
to?: string;
roles: string[];
items?: { label: string; to: string; roles: string[] }[];
}
export const MENU_ITEMS: MenuItem[] = [
{
label: '대시보드',
icon: 'pi pi-home',
to: '/dashboard',
roles: ['SA', 'PM', 'PCM', 'PTK', 'DL', 'USER'],
},
{
label: '시수 관리',
icon: 'pi pi-clock',
roles: ['SA', 'PM', 'DL', 'USER'],
items: [
{ label: '시수 입력', to: '/timesheets', roles: ['USER', 'DL', 'PM', 'SA'] },
{ label: '시수 이력', to: '/timesheets/history', roles: ['USER', 'DL', 'PM', 'SA'] },
{ label: 'Excel 업로드', to: '/timesheets/upload', roles: ['USER'] },
],
},
{
label: '결재',
icon: 'pi pi-check-square',
roles: ['DL', 'PM', 'SA'],
items: [
{ label: '결재 대기', to: '/approvals', roles: ['DL', 'PM', 'SA'] },
{ label: '결재 이력', to: '/approvals/history', roles: ['DL', 'PM', 'SA'] },
],
},
{
label: '프로젝트',
icon: 'pi pi-briefcase',
roles: ['SA', 'PM', 'PCM'],
items: [
{ label: '프로젝트 목록', to: '/projects', roles: ['SA', 'PM', 'PCM'] },
{ label: 'WBS 관리', to: '/wbs', roles: ['SA', 'PM', 'PCM'] },
{ label: 'TEAL 관리', to: '/teal', roles: ['SA', 'PM', 'PCM'] },
],
},
{
label: '리포트',
icon: 'pi pi-chart-bar',
to: '/reports',
roles: ['SA', 'PM', 'PCM', 'DL'],
},
{
label: '사용자 관리',
icon: 'pi pi-users',
to: '/users',
roles: ['SA', 'PTK'],
},
{
label: '시스템 설정',
icon: 'pi pi-cog',
to: '/settings',
roles: ['SA'],
},
];

파일 보기

@@ -0,0 +1,16 @@
import { useMemo } from 'react';
import { useAuthStore } from '@/modules/auth/auth.store';
export function useCurrentUser() {
const currentUser = useAuthStore((s) => s.currentUser);
const roles = useMemo(() => currentUser?.roles ?? [], [currentUser]);
const isAuthenticated = !!currentUser;
const hasRole = (role: string) => roles.includes(role);
const hasAnyRole = (...checkRoles: string[]) => checkRoles.some((r) => roles.includes(r));
return { currentUser, isAuthenticated, roles, hasRole, hasAnyRole };
}