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은 다음에 포함되어 있습니다:
@@ -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 };
|
||||
}
|
||||
새 Issue에서 참조
사용자 차단