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은 다음에 포함되어 있습니다:
1
wtm-frontend-react/.env
일반 파일
1
wtm-frontend-react/.env
일반 파일
@@ -0,0 +1 @@
|
||||
VITE_APP_TITLE=WTM - Work Time Manager
|
||||
@@ -0,0 +1,2 @@
|
||||
VITE_API_BASE_URL=
|
||||
VITE_APP_ENV=development
|
||||
@@ -0,0 +1,2 @@
|
||||
VITE_API_BASE_URL=https://wtmgr.hanwhaocean.com
|
||||
VITE_APP_ENV=production
|
||||
13
wtm-frontend-react/index.html
일반 파일
13
wtm-frontend-react/index.html
일반 파일
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WTM - Work Time Manager</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/app/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
38
wtm-frontend-react/package.json
일반 파일
38
wtm-frontend-react/package.json
일반 파일
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "wtm-frontend-react",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.9",
|
||||
"chart.js": "^4.4.7",
|
||||
"primeicons": "^7.0.0",
|
||||
"primereact": "^10.8.5",
|
||||
"react": "^18.3.1",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.16.0",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint": "^9.16.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"globals": "^15.14.0",
|
||||
"sass": "^1.82.0",
|
||||
"typescript": "~5.6.3",
|
||||
"typescript-eslint": "^8.18.0",
|
||||
"vite": "^6.0.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { router } from './router';
|
||||
|
||||
export default function App() {
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
21
wtm-frontend-react/src/app/main.tsx
일반 파일
21
wtm-frontend-react/src/app/main.tsx
일반 파일
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { PrimeReactProvider } from 'primereact/api';
|
||||
import App from './App';
|
||||
|
||||
import 'primereact/resources/themes/lara-light-blue/theme.css';
|
||||
import 'primereact/resources/primereact.min.css';
|
||||
import 'primeicons/primeicons.css';
|
||||
import '@/assets/styles/main.scss';
|
||||
|
||||
const primeReactConfig = {
|
||||
ripple: true,
|
||||
};
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<PrimeReactProvider value={primeReactConfig}>
|
||||
<App />
|
||||
</PrimeReactProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,68 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||
import AppLayout from '@/core/components/AppLayout';
|
||||
import AuthGuard from '@/core/components/AuthGuard';
|
||||
import NotFoundView from '@/core/components/NotFoundView';
|
||||
|
||||
// Lazy-loaded views
|
||||
const LoginView = lazy(() => import('@/modules/auth/views/LoginView'));
|
||||
const ForgotPasswordView = lazy(() => import('@/modules/auth/views/ForgotPasswordView'));
|
||||
const ChangePasswordView = lazy(() => import('@/modules/auth/views/ChangePasswordView'));
|
||||
const DashboardView = lazy(() => import('@/modules/dashboard/views/DashboardView'));
|
||||
const TimesheetWeekView = lazy(() => import('@/modules/timesheet/views/TimesheetWeekView'));
|
||||
const TimesheetHistoryView = lazy(() => import('@/modules/timesheet/views/TimesheetHistoryView'));
|
||||
const TimesheetUploadView = lazy(() => import('@/modules/timesheet/views/TimesheetUploadView'));
|
||||
const ApprovalPendingView = lazy(() => import('@/modules/approval/views/ApprovalPendingView'));
|
||||
const ApprovalHistoryView = lazy(() => import('@/modules/approval/views/ApprovalHistoryView'));
|
||||
const ProjectListView = lazy(() => import('@/modules/project/views/ProjectListView'));
|
||||
const ProjectDetailView = lazy(() => import('@/modules/project/views/ProjectDetailView'));
|
||||
const WbsTreeView = lazy(() => import('@/modules/wbs/views/WbsTreeView'));
|
||||
const TealListView = lazy(() => import('@/modules/teal/views/TealListView'));
|
||||
const ReportView = lazy(() => import('@/modules/report/views/ReportView'));
|
||||
const UserListView = lazy(() => import('@/modules/user/views/UserListView'));
|
||||
const UserDetailView = lazy(() => import('@/modules/user/views/UserDetailView'));
|
||||
const SettingsView = lazy(() => import('@/modules/settings/views/SettingsView'));
|
||||
|
||||
function SuspenseWrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Suspense fallback={<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}><ProgressSpinner /></div>}>
|
||||
{children}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
// Auth routes (no layout)
|
||||
{ path: '/login', element: <SuspenseWrapper><LoginView /></SuspenseWrapper> },
|
||||
{ path: '/forgot-password', element: <SuspenseWrapper><ForgotPasswordView /></SuspenseWrapper> },
|
||||
|
||||
// Protected routes (with layout)
|
||||
{
|
||||
path: '/',
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<AppLayout />
|
||||
</AuthGuard>
|
||||
),
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/dashboard" replace /> },
|
||||
{ path: 'dashboard', element: <SuspenseWrapper><DashboardView /></SuspenseWrapper> },
|
||||
{ path: 'change-password', element: <SuspenseWrapper><ChangePasswordView /></SuspenseWrapper> },
|
||||
{ path: 'timesheets', element: <SuspenseWrapper><TimesheetWeekView /></SuspenseWrapper> },
|
||||
{ path: 'timesheets/history', element: <SuspenseWrapper><TimesheetHistoryView /></SuspenseWrapper> },
|
||||
{ path: 'timesheets/upload', element: <SuspenseWrapper><TimesheetUploadView /></SuspenseWrapper> },
|
||||
{ path: 'approvals', element: <SuspenseWrapper><ApprovalPendingView /></SuspenseWrapper> },
|
||||
{ path: 'approvals/history', element: <SuspenseWrapper><ApprovalHistoryView /></SuspenseWrapper> },
|
||||
{ path: 'projects', element: <SuspenseWrapper><ProjectListView /></SuspenseWrapper> },
|
||||
{ path: 'projects/:id', element: <SuspenseWrapper><ProjectDetailView /></SuspenseWrapper> },
|
||||
{ path: 'wbs', element: <SuspenseWrapper><WbsTreeView /></SuspenseWrapper> },
|
||||
{ path: 'teal', element: <SuspenseWrapper><TealListView /></SuspenseWrapper> },
|
||||
{ path: 'reports', element: <SuspenseWrapper><ReportView /></SuspenseWrapper> },
|
||||
{ path: 'users', element: <SuspenseWrapper><UserListView /></SuspenseWrapper> },
|
||||
{ path: 'users/:id', element: <SuspenseWrapper><UserDetailView /></SuspenseWrapper> },
|
||||
{ path: 'settings', element: <SuspenseWrapper><SettingsView /></SuspenseWrapper> },
|
||||
{ path: '*', element: <NotFoundView /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="6" fill="#3B82F6"/>
|
||||
<text x="50%" y="55%" dominant-baseline="central" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" font-weight="bold" fill="white">W</text>
|
||||
</svg>
|
||||
|
이후 너비: | 높이: | 크기: 296 B |
@@ -0,0 +1,49 @@
|
||||
@use 'variables' as *;
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: $space-md;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
|
||||
@media (max-width: $bp-mobile) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.col-1 { grid-column: span 1; }
|
||||
.col-2 { grid-column: span 2; }
|
||||
.col-3 { grid-column: span 3; }
|
||||
.col-4 { grid-column: span 4; }
|
||||
.col-6 { grid-column: span 6; }
|
||||
.col-8 { grid-column: span 8; }
|
||||
.col-12 { grid-column: span 12; }
|
||||
|
||||
@media (max-width: $bp-mobile) {
|
||||
[class^='col-'] {
|
||||
grid-column: span 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-xs;
|
||||
|
||||
&__label {
|
||||
font-size: $font-size-sm;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
&--required::after {
|
||||
content: ' *';
|
||||
color: $color-danger;
|
||||
}
|
||||
}
|
||||
&__error {
|
||||
font-size: $font-size-xs;
|
||||
color: $color-danger;
|
||||
}
|
||||
&__hint {
|
||||
font-size: $font-size-xs;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// PrimeReact theme overrides
|
||||
@@ -0,0 +1,53 @@
|
||||
// Breakpoints
|
||||
$bp-mobile: 576px;
|
||||
$bp-tablet: 768px;
|
||||
$bp-desktop: 992px;
|
||||
$bp-wide: 1200px;
|
||||
$bp-ultra: 1400px;
|
||||
|
||||
// Layout
|
||||
$sidebar-width: 260px;
|
||||
$sidebar-collapsed-width: 64px;
|
||||
$topbar-height: 56px;
|
||||
$page-padding-x: 1.5rem;
|
||||
$page-padding-y: 1.25rem;
|
||||
|
||||
// Spacing (8px base)
|
||||
$space-xs: 0.25rem;
|
||||
$space-sm: 0.5rem;
|
||||
$space-md: 1rem;
|
||||
$space-lg: 1.5rem;
|
||||
$space-xl: 2rem;
|
||||
$space-2xl: 3rem;
|
||||
|
||||
// Typography
|
||||
$font-size-xs: 0.75rem;
|
||||
$font-size-sm: 0.875rem;
|
||||
$font-size-base: 1rem;
|
||||
$font-size-lg: 1.125rem;
|
||||
$font-size-xl: 1.25rem;
|
||||
$font-size-2xl: 1.5rem;
|
||||
|
||||
// Border Radius
|
||||
$radius-sm: 6px;
|
||||
$radius-md: 8px;
|
||||
$radius-lg: 12px;
|
||||
|
||||
// Z-Index
|
||||
$z-sidebar: 100;
|
||||
$z-topbar: 110;
|
||||
$z-overlay: 200;
|
||||
$z-dialog: 300;
|
||||
$z-toast: 400;
|
||||
|
||||
// Semantic Colors (PrimeReact tokens)
|
||||
$color-surface: var(--p-surface-0);
|
||||
$color-surface-card: var(--p-surface-0);
|
||||
$color-surface-hover: var(--p-surface-100);
|
||||
$color-border: var(--p-surface-200);
|
||||
$color-text: var(--p-text-color);
|
||||
$color-text-muted: var(--p-text-muted-color);
|
||||
$color-primary: var(--p-primary-color);
|
||||
$color-danger: var(--p-red-500);
|
||||
$color-success: var(--p-green-500);
|
||||
$color-warning: var(--p-yellow-500);
|
||||
@@ -0,0 +1,39 @@
|
||||
@use 'variables' as v;
|
||||
@use 'form-grid';
|
||||
@use 'overrides';
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--p-font-family);
|
||||
color: var(--p-text-color);
|
||||
background: var(--p-surface-ground);
|
||||
}
|
||||
|
||||
.text-center { text-align: center; }
|
||||
.text-right { text-align: right; }
|
||||
.text-muted { color: var(--p-text-muted-color); }
|
||||
.text-sm { font-size: v.$font-size-sm; }
|
||||
.text-xs { font-size: v.$font-size-xs; }
|
||||
|
||||
.card {
|
||||
background: var(--p-surface-0);
|
||||
border: 1px solid var(--p-surface-200);
|
||||
border-radius: v.$radius-md;
|
||||
padding: v.$space-lg;
|
||||
|
||||
@media (max-width: v.$bp-mobile) {
|
||||
padding: v.$space-md;
|
||||
border-radius: v.$radius-sm;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import api from '@/core/api/axios';
|
||||
|
||||
const BASE = '/api/wtm/approvals';
|
||||
|
||||
export const approvalService = {
|
||||
getPending: () => api.get(`${BASE}/pending`),
|
||||
approve: (id: number, comment?: string) => api.post(`${BASE}/${id}/approve`, { comment }),
|
||||
reject: (id: number, comment?: string) => api.post(`${BASE}/${id}/reject`, { comment }),
|
||||
batchApprove: (ids: number[]) => api.post(`${BASE}/batch-approve`, { ids }),
|
||||
addComment: (id: number, comment: string) => api.post(`${BASE}/${id}/comments`, { comment }),
|
||||
getById: (id: number) => api.get(`${BASE}/${id}`),
|
||||
getHistory: (params?: Record<string, unknown>) => api.get(`${BASE}/history`, { params }),
|
||||
getOverdue: () => api.get(`${BASE}/overdue`),
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface Approval {
|
||||
id: number;
|
||||
timesheetId: number;
|
||||
requesterId: number;
|
||||
requesterName: string;
|
||||
projectName?: string;
|
||||
weekStartDate: string;
|
||||
weekEndDate: string;
|
||||
totalHours: number;
|
||||
status: string;
|
||||
submittedAt?: string;
|
||||
approvedAt?: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export interface ApprovalLine {
|
||||
id: number;
|
||||
approvalId: number;
|
||||
approverId: number;
|
||||
approverName: string;
|
||||
sequence: number;
|
||||
status: string;
|
||||
comment?: string;
|
||||
actedAt?: string;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Column } from 'primereact/column';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Calendar } from 'primereact/calendar';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import BaseCrudTable from '@/core/components/BaseCrudTable';
|
||||
import BasePageHeader from '@/core/components/BasePageHeader';
|
||||
import { approvalService } from '../approval.service';
|
||||
import { TIMESHEET_STATUS } from '@/core/constants/app.constants';
|
||||
|
||||
export default function ApprovalHistoryView() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [history, setHistory] = useState<Record<string, unknown>[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
const [dateFrom, setDateFrom] = useState<Date | null>(null);
|
||||
const [dateTo, setDateTo] = useState<Date | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
const params: Record<string, unknown> = {};
|
||||
if (statusFilter) params.status = statusFilter;
|
||||
if (dateFrom) params.from = dateFrom.toISOString().slice(0, 10);
|
||||
if (dateTo) params.to = dateTo.toISOString().slice(0, 10);
|
||||
|
||||
approvalService.getHistory(params)
|
||||
.then(({ data }) => setHistory((data as { items?: unknown[] }).items as Record<string, unknown>[] ?? data as Record<string, unknown>[]))
|
||||
.catch(() => setHistory([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [statusFilter, dateFrom, dateTo]);
|
||||
|
||||
const statusOptions = Object.entries(TIMESHEET_STATUS).map(([key, val]) => ({ label: val.label, value: key }));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BasePageHeader title="결재 이력" />
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', flexWrap: 'wrap' }}>
|
||||
<Calendar value={dateFrom} onChange={(e) => setDateFrom(e.value as Date)} placeholder="시작일" dateFormat="yy-mm-dd" />
|
||||
<Calendar value={dateTo} onChange={(e) => setDateTo(e.value as Date)} placeholder="종료일" dateFormat="yy-mm-dd" />
|
||||
<Dropdown value={statusFilter} options={statusOptions} onChange={(e) => setStatusFilter(e.value)} placeholder="상태" showClear />
|
||||
</div>
|
||||
<BaseCrudTable value={history} loading={loading}>
|
||||
<Column field="requesterName" header="요청자" sortable />
|
||||
<Column field="projectName" header="프로젝트" sortable />
|
||||
<Column field="weekStartDate" header="주 시작일" sortable />
|
||||
<Column field="totalHours" header="시수" body={(row) => `${row.totalHours}h`} />
|
||||
<Column field="status" header="상태" body={(row) => {
|
||||
const s = TIMESHEET_STATUS[row.status as string];
|
||||
return <Tag value={s?.label ?? (row.status as string)} severity={(s?.severity ?? 'secondary') as 'success' | 'info' | 'warning' | 'danger' | 'secondary'} />;
|
||||
}} />
|
||||
<Column field="approvedAt" header="처리일" sortable />
|
||||
</BaseCrudTable>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Column } from 'primereact/column';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import BaseCrudTable from '@/core/components/BaseCrudTable';
|
||||
import BasePageHeader from '@/core/components/BasePageHeader';
|
||||
import { approvalService } from '../approval.service';
|
||||
import { TIMESHEET_STATUS } from '@/core/constants/app.constants';
|
||||
import type { Approval } from '../approval.types';
|
||||
|
||||
export default function ApprovalPendingView() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [approvals, setApprovals] = useState<Approval[]>([]);
|
||||
const [selected, setSelected] = useState<Approval[]>([]);
|
||||
|
||||
function load() {
|
||||
setLoading(true);
|
||||
approvalService.getPending()
|
||||
.then(({ data }) => setApprovals((data as { items?: Approval[] }).items ?? data as Approval[]))
|
||||
.catch(() => setApprovals([]))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function handleApprove(id: number) {
|
||||
await approvalService.approve(id);
|
||||
load();
|
||||
}
|
||||
|
||||
async function handleReject(id: number) {
|
||||
await approvalService.reject(id);
|
||||
load();
|
||||
}
|
||||
|
||||
async function batchApprove() {
|
||||
if (selected.length === 0) return;
|
||||
await approvalService.batchApprove(selected.map((s) => s.id));
|
||||
setSelected([]);
|
||||
load();
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BasePageHeader title="결재 대기" />
|
||||
<BaseCrudTable
|
||||
value={approvals}
|
||||
loading={loading}
|
||||
selectionMode="checkbox"
|
||||
toolbarLeft={
|
||||
<Button label="일괄 승인" icon="pi pi-check-circle" size="small" disabled={selected.length === 0} onClick={batchApprove} />
|
||||
}
|
||||
>
|
||||
<Column selectionMode="multiple" headerStyle={{ width: '3em' }} />
|
||||
<Column field="requesterName" header="요청자" sortable />
|
||||
<Column field="projectName" header="프로젝트" sortable />
|
||||
<Column field="weekStartDate" header="주 시작일" sortable />
|
||||
<Column field="totalHours" header="시수" body={(row) => `${row.totalHours}h`} sortable />
|
||||
<Column field="status" header="상태" body={(row) => {
|
||||
const s = TIMESHEET_STATUS[row.status];
|
||||
return <Tag value={s?.label ?? row.status} severity={(s?.severity ?? 'secondary') as 'success' | 'info' | 'warning' | 'danger' | 'secondary'} />;
|
||||
}} />
|
||||
<Column header="액션" body={(row) => (
|
||||
<div style={{ display: 'flex', gap: '0.25rem' }}>
|
||||
<Button icon="pi pi-check" severity="success" text size="small" onClick={() => handleApprove(row.id)} />
|
||||
<Button icon="pi pi-times" severity="danger" text size="small" onClick={() => handleReject(row.id)} />
|
||||
</div>
|
||||
)} />
|
||||
</BaseCrudTable>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import api from '@/core/api/axios';
|
||||
import type { LoginRequest, LoginResponse, AuthUser } from '@/core/auth/auth.types';
|
||||
|
||||
const BASE = '/api/wtm/auth';
|
||||
|
||||
export const authApi = {
|
||||
login: (data: LoginRequest) => api.post<LoginResponse>(`${BASE}/login`, data),
|
||||
me: () => api.get<AuthUser>(`${BASE}/me/profile`),
|
||||
refresh: (refreshToken: string) => api.post(`${BASE}/refresh`, { refreshToken }),
|
||||
logout: () => api.post(`${BASE}/logout`),
|
||||
resetPassword: (email: string) => api.post(`${BASE}/password/reset`, { email }),
|
||||
changePassword: (data: { currentPassword: string; newPassword: string }) =>
|
||||
api.put(`${BASE}/password/change`, data),
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { create } from 'zustand';
|
||||
import { authService as tokenService } from '@/core/auth/auth.service';
|
||||
import { authApi } from './auth.service';
|
||||
import type { AuthUser } from '@/core/auth/auth.types';
|
||||
|
||||
interface AuthState {
|
||||
currentUser: AuthUser | null;
|
||||
loading: boolean;
|
||||
unreadCount: number;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
fetchMe: () => Promise<void>;
|
||||
logout: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
function mapUser(u: Record<string, unknown>): AuthUser {
|
||||
return {
|
||||
id: u.id as number,
|
||||
email: u.email as string,
|
||||
fullName: (u.full_name ?? u.fullName ?? '') as string,
|
||||
roles: Array.isArray(u.roles) && u.roles.length ? u.roles : (u.is_admin ? ['SA'] : ['USER']),
|
||||
departmentId: (u.department_id ?? u.departmentId) as number | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
currentUser: null,
|
||||
loading: false,
|
||||
unreadCount: 0,
|
||||
|
||||
login: async (email, password) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const { data } = await authApi.login({ email, password });
|
||||
const accessToken = (data.access_token ?? data.accessToken) as string;
|
||||
const refreshToken = (data.refresh_token ?? data.refreshToken) as string;
|
||||
tokenService.setTokens(accessToken, refreshToken);
|
||||
set({ currentUser: mapUser(data.user as unknown as Record<string, unknown>) });
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchMe: async () => {
|
||||
try {
|
||||
const { data } = await authApi.me();
|
||||
set({ currentUser: mapUser(data as unknown as Record<string, unknown>) });
|
||||
} catch {
|
||||
tokenService.clearTokens();
|
||||
set({ currentUser: null });
|
||||
window.location.href = '/login';
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
tokenService.clearTokens();
|
||||
set({ currentUser: null });
|
||||
window.location.href = '/login';
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
set({ currentUser: null, loading: false, unreadCount: 0 });
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1 @@
|
||||
export type { AuthUser, LoginRequest, LoginResponse } from '@/core/auth/auth.types';
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Password } from 'primereact/password';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Card } from 'primereact/card';
|
||||
import { Message } from 'primereact/message';
|
||||
import BasePageHeader from '@/core/components/BasePageHeader';
|
||||
import { authApi } from '../auth.service';
|
||||
|
||||
export default function ChangePasswordView() {
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState<{ severity: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setMessage(null);
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
setMessage({ severity: 'error', text: '비밀번호는 8자 이상이어야 합니다.' });
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setMessage({ severity: 'error', text: '새 비밀번호가 일치하지 않습니다.' });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await authApi.changePassword({ currentPassword, newPassword });
|
||||
setMessage({ severity: 'success', text: '비밀번호가 변경되었습니다.' });
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
} catch {
|
||||
setMessage({ severity: 'error', text: '비밀번호 변경에 실패했습니다.' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BasePageHeader title="비밀번호 변경" />
|
||||
<Card style={{ maxWidth: '480px' }}>
|
||||
{message && <Message severity={message.severity} text={message.text} style={{ width: '100%', marginBottom: '1rem' }} />}
|
||||
<form onSubmit={onSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<div className="form-field">
|
||||
<label className="form-field__label">현재 비밀번호</label>
|
||||
<Password value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} feedback={false} toggleMask inputStyle={{ width: '100%' }} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label className="form-field__label">새 비밀번호</label>
|
||||
<Password value={newPassword} onChange={(e) => setNewPassword(e.target.value)} feedback={false} toggleMask inputStyle={{ width: '100%' }} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label className="form-field__label">새 비밀번호 확인</label>
|
||||
<Password value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} feedback={false} toggleMask inputStyle={{ width: '100%' }} />
|
||||
</div>
|
||||
<Button type="submit" label="변경" icon="pi pi-check" loading={loading} />
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Card } from 'primereact/card';
|
||||
|
||||
export default function ForgotPasswordView() {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh', padding: '1rem' }}>
|
||||
<Card title="비밀번호 찾기" style={{ width: '100%', maxWidth: '420px' }}>
|
||||
<p style={{ color: 'var(--p-text-muted-color)' }}>비밀번호 찾기 기능은 준비 중입니다.</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
@use '@/assets/styles/variables' as *;
|
||||
|
||||
.login-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: var(--p-surface-50);
|
||||
padding: $space-md;
|
||||
|
||||
&__card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $space-lg;
|
||||
}
|
||||
|
||||
&__logo {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: $font-size-2xl;
|
||||
font-weight: 700;
|
||||
margin: $space-sm 0 0;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-muted;
|
||||
margin: $space-xs 0 0;
|
||||
}
|
||||
|
||||
&__form {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-md;
|
||||
}
|
||||
|
||||
&__submit {
|
||||
margin-top: $space-sm;
|
||||
}
|
||||
|
||||
&__links {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__link {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-primary;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { Password } from 'primereact/password';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Card } from 'primereact/card';
|
||||
import { Message } from 'primereact/message';
|
||||
import { useAuthStore } from '../auth.store';
|
||||
import './LoginView.scss';
|
||||
|
||||
export default function LoginView() {
|
||||
const login = useAuthStore((s) => s.login);
|
||||
const loading = useAuthStore((s) => s.loading);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function onLogin(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate('/dashboard');
|
||||
} catch (err: unknown) {
|
||||
const axiosErr = err as { response?: { data?: { detail?: string } } };
|
||||
setError(axiosErr?.response?.data?.detail ?? '로그인에 실패했습니다. 이메일과 비밀번호를 확인하세요.');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<Card className="login-page__card">
|
||||
<div className="login-page__content">
|
||||
<div className="login-page__logo">
|
||||
<i className="pi pi-clock" style={{ fontSize: '2.5rem', color: 'var(--p-primary-color)' }} />
|
||||
<h1 className="login-page__title">WTM</h1>
|
||||
<p className="login-page__subtitle">Work Time Manager</p>
|
||||
</div>
|
||||
|
||||
{error && <Message severity="error" text={error} style={{ width: '100%' }} />}
|
||||
|
||||
<form className="login-page__form" onSubmit={onLogin}>
|
||||
<div className="form-field">
|
||||
<label className="form-field__label">이메일</label>
|
||||
<InputText
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
type="email"
|
||||
placeholder="user@hanwha.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label className="form-field__label">비밀번호</label>
|
||||
<Password
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="비밀번호 입력"
|
||||
feedback={false}
|
||||
toggleMask
|
||||
inputStyle={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
label="로그인"
|
||||
icon="pi pi-sign-in"
|
||||
loading={loading}
|
||||
className="login-page__submit"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div className="login-page__links">
|
||||
<Link to="/forgot-password" className="login-page__link">
|
||||
비밀번호 찾기
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import api from '@/core/api/axios';
|
||||
|
||||
const BASE = '/api/wtm/home';
|
||||
|
||||
export const dashboardService = {
|
||||
getDashboard: () => api.get(`${BASE}/dashboard`),
|
||||
getNotifications: () => api.get(`${BASE}/notifications`),
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface DashboardStat {
|
||||
label: string;
|
||||
value: number;
|
||||
icon: string;
|
||||
trend?: number;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
@use '@/assets/styles/variables' as *;
|
||||
|
||||
.dashboard-view {
|
||||
&__loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: $space-2xl;
|
||||
}
|
||||
|
||||
&__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: $space-md;
|
||||
margin-bottom: $space-lg;
|
||||
|
||||
@media (max-width: $bp-tablet) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
@media (max-width: $bp-mobile) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: $space-md;
|
||||
|
||||
@media (max-width: $bp-tablet) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
&__chart-wrapper {
|
||||
height: 280px;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-md;
|
||||
|
||||
&__icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: $radius-lg;
|
||||
background: var(--p-primary-100);
|
||||
color: var(--p-primary-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: $font-size-xl;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: $font-size-2xl;
|
||||
font-weight: 700;
|
||||
color: $color-text;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
&__trend {
|
||||
margin-left: auto;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: 600;
|
||||
&--up { color: $color-success; }
|
||||
&--down { color: $color-danger; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Card } from 'primereact/card';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||
import { Chart } from 'primereact/chart';
|
||||
import BasePageHeader from '@/core/components/BasePageHeader';
|
||||
import { dashboardService } from '../dashboard.service';
|
||||
import { TIMESHEET_STATUS } from '@/core/constants/app.constants';
|
||||
import type { DashboardStat } from '../dashboard.types';
|
||||
import './DashboardView.scss';
|
||||
|
||||
const defaultStats: DashboardStat[] = [
|
||||
{ label: '금주 시수', value: 0, icon: 'pi pi-clock' },
|
||||
{ label: '미제출 건수', value: 0, icon: 'pi pi-exclamation-triangle' },
|
||||
{ label: '결재 대기', value: 0, icon: 'pi pi-check-square' },
|
||||
{ label: '프로젝트 수', value: 0, icon: 'pi pi-briefcase' },
|
||||
];
|
||||
|
||||
export default function DashboardView() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [stats, setStats] = useState<DashboardStat[]>(defaultStats);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [weeklyHoursData, setWeeklyHoursData] = useState<any>(null);
|
||||
const [pendingApprovals, setPendingApprovals] = useState<Record<string, unknown>[]>([]);
|
||||
|
||||
const chartOptions = useMemo(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
y: { beginAtZero: true, title: { display: true, text: '시간 (h)' } },
|
||||
x: { title: { display: true, text: '요일' } },
|
||||
},
|
||||
}), []);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
dashboardService.getDashboard()
|
||||
.then(({ data }) => {
|
||||
setStats(data.stats ?? defaultStats);
|
||||
setPendingApprovals(data.pendingApprovals ?? []);
|
||||
setWeeklyHoursData({
|
||||
labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
|
||||
datasets: [{
|
||||
label: '시수',
|
||||
backgroundColor: 'var(--p-primary-color)',
|
||||
data: data.weeklyHours ?? [0, 0, 0, 0, 0, 0],
|
||||
}],
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setStats(defaultStats);
|
||||
setWeeklyHoursData({
|
||||
labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
|
||||
datasets: [{ label: '시수', backgroundColor: 'var(--p-primary-color)', data: [0, 0, 0, 0, 0, 0] }],
|
||||
});
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
function statusSeverity(status: string) {
|
||||
return (TIMESHEET_STATUS[status]?.severity ?? 'secondary') as 'success' | 'info' | 'warning' | 'danger' | 'secondary';
|
||||
}
|
||||
function statusLabel(status: string) {
|
||||
return TIMESHEET_STATUS[status]?.label ?? status;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
<BasePageHeader title="대시보드" subtitle="금주 시수 현황 및 결재 현황" />
|
||||
<div className="dashboard-view__loading"><ProgressSpinner /></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dashboard-view">
|
||||
<BasePageHeader title="대시보드" subtitle="금주 시수 현황 및 결재 현황" />
|
||||
|
||||
<div className="dashboard-view__stats">
|
||||
{stats.map((stat, idx) => (
|
||||
<Card key={idx} className="dashboard-view__stat-card">
|
||||
<div className="stat-card">
|
||||
<div className="stat-card__icon"><i className={stat.icon} /></div>
|
||||
<div className="stat-card__info">
|
||||
<span className="stat-card__value">{stat.value}</span>
|
||||
<span className="stat-card__label">{stat.label}</span>
|
||||
</div>
|
||||
{stat.trend != null && (
|
||||
<div className={`stat-card__trend ${stat.trend > 0 ? 'stat-card__trend--up' : stat.trend < 0 ? 'stat-card__trend--down' : ''}`}>
|
||||
<i className={stat.trend > 0 ? 'pi pi-arrow-up' : stat.trend < 0 ? 'pi pi-arrow-down' : 'pi pi-minus'} />
|
||||
{Math.abs(stat.trend)}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="dashboard-view__grid">
|
||||
<Card title="금주 시수 현황" className="dashboard-view__chart-card">
|
||||
<div className="dashboard-view__chart-wrapper">
|
||||
{weeklyHoursData && <Chart type="bar" data={weeklyHoursData as object} options={chartOptions} />}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="결재 대기 목록" className="dashboard-view__approvals-card">
|
||||
<DataTable value={pendingApprovals} rows={5} paginator={pendingApprovals.length > 5} size="small" stripedRows
|
||||
emptyMessage={<div style={{ textAlign: 'center', padding: '1rem', color: 'var(--p-text-muted-color)' }}>결재 대기 건이 없습니다.</div>}
|
||||
>
|
||||
<Column field="requesterName" header="요청자" />
|
||||
<Column field="projectName" header="프로젝트" />
|
||||
<Column field="totalHours" header="시수" body={(row) => `${row.totalHours}h`} />
|
||||
<Column field="status" header="상태" body={(row) => <Tag value={statusLabel(row.status)} severity={statusSeverity(row.status)} />} />
|
||||
</DataTable>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { InputTextarea } from 'primereact/inputtextarea';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import BaseFormDialog from '@/core/components/BaseFormDialog';
|
||||
import type { Project } from '../project.types';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onHide: () => void;
|
||||
project: Project | null;
|
||||
onSave: (data: Partial<Project>) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function ProjectFormDialog({ visible, onHide, project, onSave }: Props) {
|
||||
const [code, setCode] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [type, setType] = useState('');
|
||||
const [status, setStatus] = useState('ACTIVE');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (project) {
|
||||
setCode(project.code);
|
||||
setName(project.name);
|
||||
setDescription(project.description ?? '');
|
||||
setType(project.type ?? '');
|
||||
setStatus(project.status);
|
||||
} else {
|
||||
setCode(''); setName(''); setDescription(''); setType(''); setStatus('ACTIVE');
|
||||
}
|
||||
}, [project, visible]);
|
||||
|
||||
async function handleSubmit() {
|
||||
setLoading(true);
|
||||
try {
|
||||
await onSave({ code, name, description, type, status });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const typeOptions = [
|
||||
{ label: 'EPC', value: 'EPC' },
|
||||
{ label: 'Other', value: 'OTHER' },
|
||||
{ label: 'Internal', value: 'INTERNAL' },
|
||||
];
|
||||
const statusOptions = [
|
||||
{ label: '진행중', value: 'ACTIVE' },
|
||||
{ label: '종료', value: 'CLOSED' },
|
||||
{ label: '보류', value: 'HOLD' },
|
||||
];
|
||||
|
||||
return (
|
||||
<BaseFormDialog visible={visible} onHide={onHide} title={project ? '프로젝트 수정' : '프로젝트 생성'} loading={loading} onSubmit={handleSubmit}>
|
||||
<div className="form-grid">
|
||||
<div className="col-6 form-field">
|
||||
<label className="form-field__label form-field__label--required">코드</label>
|
||||
<InputText value={code} onChange={(e) => setCode(e.target.value)} disabled={!!project} />
|
||||
</div>
|
||||
<div className="col-6 form-field">
|
||||
<label className="form-field__label form-field__label--required">프로젝트명</label>
|
||||
<InputText value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="col-6 form-field">
|
||||
<label className="form-field__label">유형</label>
|
||||
<Dropdown value={type} options={typeOptions} onChange={(e) => setType(e.value)} placeholder="선택" />
|
||||
</div>
|
||||
<div className="col-6 form-field">
|
||||
<label className="form-field__label">상태</label>
|
||||
<Dropdown value={status} options={statusOptions} onChange={(e) => setStatus(e.value)} />
|
||||
</div>
|
||||
<div className="col-12 form-field">
|
||||
<label className="form-field__label">설명</label>
|
||||
<InputTextarea value={description} onChange={(e) => setDescription(e.target.value)} rows={3} />
|
||||
</div>
|
||||
</div>
|
||||
</BaseFormDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import api from '@/core/api/axios';
|
||||
|
||||
const BASE = '/api/wtm/projects';
|
||||
|
||||
export const projectService = {
|
||||
getAll: (params?: Record<string, unknown>) => api.get(BASE, { params }),
|
||||
getById: (id: number) => api.get(`${BASE}/${id}`),
|
||||
create: (data: unknown) => api.post(BASE, data),
|
||||
update: (id: number, data: unknown) => api.put(`${BASE}/${id}`, data),
|
||||
getMy: () => api.get(`${BASE}/my`),
|
||||
getMembers: (id: number) => api.get(`${BASE}/${id}/members`),
|
||||
getAssignments: (id: number) => api.get(`${BASE}/${id}/assignments`),
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface Project {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
type?: string;
|
||||
status: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
managerId?: number;
|
||||
managerName?: string;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Card } from 'primereact/card';
|
||||
import BasePageHeader from '@/core/components/BasePageHeader';
|
||||
|
||||
export default function ProjectDetailView() {
|
||||
return (
|
||||
<div>
|
||||
<BasePageHeader title="프로젝트 상세" />
|
||||
<Card>
|
||||
<p style={{ color: 'var(--p-text-muted-color)' }}>프로젝트 상세 페이지는 준비 중입니다.</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Column } from 'primereact/column';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import BaseCrudTable from '@/core/components/BaseCrudTable';
|
||||
import BasePageHeader from '@/core/components/BasePageHeader';
|
||||
import ProjectFormDialog from '../components/ProjectFormDialog';
|
||||
import { projectService } from '../project.service';
|
||||
import { PROJECT_STATUS } from '@/core/constants/app.constants';
|
||||
import type { Project } from '../project.types';
|
||||
|
||||
export default function ProjectListView() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [dialogVisible, setDialogVisible] = useState(false);
|
||||
const [editProject, setEditProject] = useState<Project | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
function load() {
|
||||
setLoading(true);
|
||||
projectService.getAll()
|
||||
.then(({ data }) => setProjects((data as { items?: Project[] }).items ?? data as Project[]))
|
||||
.catch(() => setProjects([]))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
function openCreate() {
|
||||
setEditProject(null);
|
||||
setDialogVisible(true);
|
||||
}
|
||||
|
||||
function openEdit(p: Project) {
|
||||
setEditProject(p);
|
||||
setDialogVisible(true);
|
||||
}
|
||||
|
||||
async function onSave(data: Partial<Project>) {
|
||||
if (editProject) {
|
||||
await projectService.update(editProject.id, data);
|
||||
} else {
|
||||
await projectService.create(data);
|
||||
}
|
||||
setDialogVisible(false);
|
||||
load();
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BasePageHeader title="프로젝트 목록" actions={<Button label="프로젝트 생성" icon="pi pi-plus" size="small" onClick={openCreate} />} />
|
||||
<BaseCrudTable value={projects} loading={loading} globalFilterFields={['code', 'name']}
|
||||
onRowSelect={(row) => navigate(`/projects/${row.id}`)}>
|
||||
<Column field="code" header="코드" sortable />
|
||||
<Column field="name" header="프로젝트명" sortable />
|
||||
<Column field="type" header="유형" sortable />
|
||||
<Column field="status" header="상태" body={(row) => {
|
||||
const s = PROJECT_STATUS[row.status];
|
||||
return <Tag value={s?.label ?? row.status} severity={(s?.severity ?? 'secondary') as 'success' | 'warning' | 'secondary'} />;
|
||||
}} sortable />
|
||||
<Column field="managerName" header="PM" />
|
||||
<Column header="" body={(row) => <Button icon="pi pi-pencil" text size="small" onClick={() => openEdit(row)} />} style={{ width: '4rem' }} />
|
||||
</BaseCrudTable>
|
||||
|
||||
<ProjectFormDialog visible={dialogVisible} onHide={() => setDialogVisible(false)} project={editProject} onSave={onSave} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import api from '@/core/api/axios';
|
||||
|
||||
const BASE = '/api/wtm/reports';
|
||||
|
||||
export const reportService = {
|
||||
getProjectHours: (params: Record<string, unknown>) => api.get(`${BASE}/project-hours`, { params }),
|
||||
exportProjectHours: (params: Record<string, unknown>) => api.get(`${BASE}/project-hours/export`, { params, responseType: 'blob' }),
|
||||
getWbsHours: (params: Record<string, unknown>) => api.get(`${BASE}/wbs-hours`, { params }),
|
||||
exportWbsHours: (params: Record<string, unknown>) => api.get(`${BASE}/wbs-hours/export`, { params, responseType: 'blob' }),
|
||||
getPhaseRatio: (params: Record<string, unknown>) => api.get(`${BASE}/phase-ratio`, { params }),
|
||||
getNpRatio: (params: Record<string, unknown>) => api.get(`${BASE}/np-ratio`, { params }),
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface ReportFilter {
|
||||
projectId?: number;
|
||||
from?: string;
|
||||
to?: string;
|
||||
groupBy?: string;
|
||||
wbsLevel?: number;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { Calendar } from 'primereact/calendar';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Card } from 'primereact/card';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
import { Chart } from 'primereact/chart';
|
||||
import BasePageHeader from '@/core/components/BasePageHeader';
|
||||
import { reportService } from '../report.service';
|
||||
import { projectService } from '@/modules/project/project.service';
|
||||
|
||||
export default function ReportView() {
|
||||
const [projects, setProjects] = useState<{ id: number; name: string }[]>([]);
|
||||
const [projectId, setProjectId] = useState<number | null>(null);
|
||||
const [dateFrom, setDateFrom] = useState<Date | null>(null);
|
||||
const [dateTo, setDateTo] = useState<Date | null>(null);
|
||||
const [groupBy, setGroupBy] = useState('project');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
|
||||
const groupByOptions = [
|
||||
{ label: '프로젝트', value: 'project' },
|
||||
{ label: 'WBS', value: 'wbs' },
|
||||
{ label: '사용자', value: 'user' },
|
||||
{ label: '월별', value: 'month' },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
projectService.getAll()
|
||||
.then(({ data }) => setProjects((data as { items?: unknown[] }).items as { id: number; name: string }[] ?? data as { id: number; name: string }[]))
|
||||
.catch(() => setProjects([]));
|
||||
}, []);
|
||||
|
||||
function search() {
|
||||
setLoading(true);
|
||||
const params: Record<string, unknown> = { groupBy };
|
||||
if (projectId) params.projectId = projectId;
|
||||
if (dateFrom) params.from = dateFrom.toISOString().slice(0, 10);
|
||||
if (dateTo) params.to = dateTo.toISOString().slice(0, 10);
|
||||
|
||||
reportService.getProjectHours(params)
|
||||
.then(({ data }) => setData(Array.isArray(data) ? data : (data as { items?: unknown[] }).items as Record<string, unknown>[] ?? []))
|
||||
.catch(() => setData([]))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
function exportExcel() {
|
||||
const params: Record<string, unknown> = { groupBy };
|
||||
if (projectId) params.projectId = projectId;
|
||||
if (dateFrom) params.from = dateFrom.toISOString().slice(0, 10);
|
||||
if (dateTo) params.to = dateTo.toISOString().slice(0, 10);
|
||||
|
||||
reportService.exportProjectHours(params).then(({ data: blob }) => {
|
||||
const url = URL.createObjectURL(blob as Blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'report.xlsx';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
}
|
||||
|
||||
const chartData = useMemo(() => ({
|
||||
labels: data.map((d) => (d.label ?? d.name ?? '') as string),
|
||||
datasets: [{
|
||||
label: '시수',
|
||||
backgroundColor: 'var(--p-primary-color)',
|
||||
data: data.map((d) => (d.totalHours ?? d.hours ?? 0) as number),
|
||||
}],
|
||||
}), [data]);
|
||||
|
||||
const chartOptions = useMemo(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: { y: { beginAtZero: true } },
|
||||
}), []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BasePageHeader title="리포트" />
|
||||
|
||||
<Card style={{ marginBottom: '1rem' }}>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||
<div className="form-field">
|
||||
<label className="form-field__label">프로젝트</label>
|
||||
<Dropdown value={projectId} options={projects.map((p) => ({ label: p.name, value: p.id }))} onChange={(e) => setProjectId(e.value)} placeholder="전체" showClear />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label className="form-field__label">시작일</label>
|
||||
<Calendar value={dateFrom} onChange={(e) => setDateFrom(e.value as Date)} dateFormat="yy-mm-dd" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label className="form-field__label">종료일</label>
|
||||
<Calendar value={dateTo} onChange={(e) => setDateTo(e.value as Date)} dateFormat="yy-mm-dd" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label className="form-field__label">그룹</label>
|
||||
<Dropdown value={groupBy} options={groupByOptions} onChange={(e) => setGroupBy(e.value)} />
|
||||
</div>
|
||||
<Button label="조회" icon="pi pi-search" onClick={search} loading={loading} />
|
||||
<Button label="Excel" icon="pi pi-file-excel" severity="success" outlined onClick={exportExcel} disabled={data.length === 0} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{data.length > 0 && (
|
||||
<>
|
||||
<Card style={{ marginBottom: '1rem' }}>
|
||||
<div style={{ height: '300px' }}>
|
||||
<Chart type="bar" data={chartData} options={chartOptions} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<DataTable value={data} size="small" stripedRows showGridlines paginator rows={20}>
|
||||
<Column field="label" header="구분" sortable body={(row) => row.label ?? row.name ?? '-'} />
|
||||
<Column field="totalHours" header="총 시수" sortable body={(row) => `${row.totalHours ?? row.hours ?? 0}h`} />
|
||||
<Column field="userCount" header="인원" sortable />
|
||||
</DataTable>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { InputSwitch } from 'primereact/inputswitch';
|
||||
import BaseFormDialog from '@/core/components/BaseFormDialog';
|
||||
import type { OverheadType } from '../settings.types';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onHide: () => void;
|
||||
overheadType: OverheadType | null;
|
||||
onSave: (data: Partial<OverheadType>) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function OverheadTypeDialog({ visible, onHide, overheadType, onSave }: Props) {
|
||||
const [code, setCode] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (overheadType) {
|
||||
setCode(overheadType.code);
|
||||
setName(overheadType.name);
|
||||
setIsActive(overheadType.isActive);
|
||||
} else {
|
||||
setCode(''); setName(''); setIsActive(true);
|
||||
}
|
||||
}, [overheadType, visible]);
|
||||
|
||||
async function handleSubmit() {
|
||||
setLoading(true);
|
||||
try {
|
||||
await onSave({ code, name, isActive });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseFormDialog visible={visible} onHide={onHide} title={overheadType ? 'Overhead Type 수정' : 'Overhead Type 추가'} loading={loading} onSubmit={handleSubmit}>
|
||||
<div className="form-field">
|
||||
<label className="form-field__label form-field__label--required">코드</label>
|
||||
<InputText value={code} onChange={(e) => setCode(e.target.value)} disabled={!!overheadType} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label className="form-field__label form-field__label--required">이름</label>
|
||||
<InputText value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
{overheadType && (
|
||||
<div className="form-field">
|
||||
<label className="form-field__label">활성 상태</label>
|
||||
<InputSwitch checked={isActive} onChange={(e) => setIsActive(e.value ?? false)} />
|
||||
</div>
|
||||
)}
|
||||
</BaseFormDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import api from '@/core/api/axios';
|
||||
|
||||
const BASE = '/api/wtm';
|
||||
|
||||
export const settingsService = {
|
||||
getOverheadTypes: () => api.get(`${BASE}/overhead-types`),
|
||||
createOverheadType: (data: unknown) => api.post(`${BASE}/overhead-types`, data),
|
||||
updateOverheadType: (id: number, data: unknown) => api.put(`${BASE}/overhead-types/${id}`, data),
|
||||
getWorkRules: () => api.get(`${BASE}/work-rules`),
|
||||
updateWorkRules: (data: unknown) => api.put(`${BASE}/work-rules`, data),
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
export interface OverheadType {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface WorkRule {
|
||||
id: number;
|
||||
minDailyHours: number;
|
||||
maxWeeklyHours: number;
|
||||
location?: string;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { TabView, TabPanel } from 'primereact/tabview';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Column } from 'primereact/column';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Card } from 'primereact/card';
|
||||
import { InputNumber } from 'primereact/inputnumber';
|
||||
import BaseCrudTable from '@/core/components/BaseCrudTable';
|
||||
import BasePageHeader from '@/core/components/BasePageHeader';
|
||||
import OverheadTypeDialog from '../components/OverheadTypeDialog';
|
||||
import { settingsService } from '../settings.service';
|
||||
import type { OverheadType, WorkRule } from '../settings.types';
|
||||
|
||||
export default function SettingsView() {
|
||||
const [overheadTypes, setOverheadTypes] = useState<OverheadType[]>([]);
|
||||
const [workRule, setWorkRule] = useState<WorkRule>({ id: 0, minDailyHours: 8, maxWeeklyHours: 52 });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dialogVisible, setDialogVisible] = useState(false);
|
||||
const [editOt, setEditOt] = useState<OverheadType | null>(null);
|
||||
const [savingRule, setSavingRule] = useState(false);
|
||||
|
||||
function loadOverheadTypes() {
|
||||
settingsService.getOverheadTypes()
|
||||
.then(({ data }) => setOverheadTypes(Array.isArray(data) ? data : (data as { items?: OverheadType[] }).items ?? []))
|
||||
.catch(() => setOverheadTypes([]));
|
||||
}
|
||||
|
||||
function loadWorkRules() {
|
||||
settingsService.getWorkRules()
|
||||
.then(({ data }) => {
|
||||
const rules = Array.isArray(data) ? data : [data];
|
||||
if (rules.length > 0) setWorkRule(rules[0] as WorkRule);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
Promise.all([loadOverheadTypes(), loadWorkRules()]).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
function openCreate() { setEditOt(null); setDialogVisible(true); }
|
||||
function openEdit(ot: OverheadType) { setEditOt(ot); setDialogVisible(true); }
|
||||
|
||||
async function onSaveOt(data: Partial<OverheadType>) {
|
||||
if (editOt) {
|
||||
await settingsService.updateOverheadType(editOt.id, data);
|
||||
} else {
|
||||
await settingsService.createOverheadType(data);
|
||||
}
|
||||
setDialogVisible(false);
|
||||
loadOverheadTypes();
|
||||
}
|
||||
|
||||
async function saveWorkRule() {
|
||||
setSavingRule(true);
|
||||
try {
|
||||
await settingsService.updateWorkRules(workRule);
|
||||
} finally {
|
||||
setSavingRule(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BasePageHeader title="시스템 설정" />
|
||||
<TabView>
|
||||
<TabPanel header="Overhead Types">
|
||||
<BaseCrudTable value={overheadTypes} loading={loading}
|
||||
toolbarLeft={<Button label="추가" icon="pi pi-plus" size="small" onClick={openCreate} />}>
|
||||
<Column field="code" header="코드" sortable />
|
||||
<Column field="name" header="이름" sortable />
|
||||
<Column field="isActive" header="상태" body={(row) => <Tag value={row.isActive ? '활성' : '비활성'} severity={row.isActive ? 'success' : 'secondary'} />} />
|
||||
<Column header="" body={(row) => <Button icon="pi pi-pencil" text size="small" onClick={() => openEdit(row)} />} style={{ width: '4rem' }} />
|
||||
</BaseCrudTable>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel header="Work Rules">
|
||||
<Card>
|
||||
<div className="form-grid">
|
||||
<div className="col-4 form-field">
|
||||
<label className="form-field__label">최소 일일 시수</label>
|
||||
<InputNumber value={workRule.minDailyHours} onValueChange={(e) => setWorkRule({ ...workRule, minDailyHours: e.value ?? 0 })} min={0} max={24} suffix="h" />
|
||||
</div>
|
||||
<div className="col-4 form-field">
|
||||
<label className="form-field__label">최대 주간 시수</label>
|
||||
<InputNumber value={workRule.maxWeeklyHours} onValueChange={(e) => setWorkRule({ ...workRule, maxWeeklyHours: e.value ?? 0 })} min={0} max={168} suffix="h" />
|
||||
</div>
|
||||
<div className="col-4 form-field">
|
||||
<label className="form-field__label">Location</label>
|
||||
<span style={{ padding: '0.5rem 0' }}>{workRule.location ?? '-'}</span>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<Button label="저장" icon="pi pi-save" loading={savingRule} onClick={saveWorkRule} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
</TabView>
|
||||
|
||||
<OverheadTypeDialog visible={dialogVisible} onHide={() => setDialogVisible(false)} overheadType={editOt} onSave={onSaveOt} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useState } from 'react';
|
||||
import { FileUpload, type FileUploadSelectEvent } from 'primereact/fileupload';
|
||||
import { Calendar } from 'primereact/calendar';
|
||||
import BaseFormDialog from '@/core/components/BaseFormDialog';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onHide: () => void;
|
||||
onUpload: (file: File, effectiveDate: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function TealUploadDialog({ visible, onHide, onUpload }: Props) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [effectiveDate, setEffectiveDate] = useState<Date | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
function onSelect(e: FileUploadSelectEvent) {
|
||||
setFile(e.files[0] ?? null);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!file || !effectiveDate) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await onUpload(file, effectiveDate.toISOString().slice(0, 10));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseFormDialog visible={visible} onHide={onHide} title="TEAL 업로드" loading={loading} onSubmit={handleSubmit} submitLabel="업로드">
|
||||
<div className="form-field">
|
||||
<label className="form-field__label form-field__label--required">TEAL 파일</label>
|
||||
<FileUpload mode="basic" accept=".xls,.xlsx,.csv" maxFileSize={10000000} onSelect={onSelect} chooseLabel="파일 선택" auto={false} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label className="form-field__label form-field__label--required">적용일</label>
|
||||
<Calendar value={effectiveDate} onChange={(e) => setEffectiveDate(e.value as Date)} dateFormat="yy-mm-dd" />
|
||||
</div>
|
||||
</BaseFormDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import api from '@/core/api/axios';
|
||||
|
||||
export const tealService = {
|
||||
upload: (projectId: number, file: File, effectiveDate: string) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('effectiveDate', effectiveDate);
|
||||
return api.post(`/api/wtm/projects/${projectId}/teal/upload`, formData, { headers: { 'Content-Type': 'multipart/form-data' } });
|
||||
},
|
||||
getVersions: (projectId: number) => api.get(`/api/wtm/projects/${projectId}/teal/versions`),
|
||||
getActive: (projectId: number) => api.get(`/api/wtm/projects/${projectId}/teal/active`),
|
||||
getByWbs: (projectId: number, wbsId: number) => api.get(`/api/wtm/projects/${projectId}/teal/by-wbs/${wbsId}`),
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
export interface TealEntry {
|
||||
id: number;
|
||||
activityCode: string;
|
||||
activityName: string;
|
||||
discipline?: string;
|
||||
canonicalWbsId?: number;
|
||||
}
|
||||
|
||||
export interface TealVersion {
|
||||
id: number;
|
||||
projectId: number;
|
||||
versionNumber: number;
|
||||
effectiveDate: string;
|
||||
status: string;
|
||||
entryCount?: number;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Column } from 'primereact/column';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import BaseCrudTable from '@/core/components/BaseCrudTable';
|
||||
import BasePageHeader from '@/core/components/BasePageHeader';
|
||||
import TealUploadDialog from '../components/TealUploadDialog';
|
||||
import { tealService } from '../teal.service';
|
||||
import { projectService } from '@/modules/project/project.service';
|
||||
import type { TealEntry, TealVersion } from '../teal.types';
|
||||
|
||||
export default function TealListView() {
|
||||
const [projects, setProjects] = useState<{ id: number; name: string; code: string }[]>([]);
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<number | null>(null);
|
||||
const [versions, setVersions] = useState<TealVersion[]>([]);
|
||||
const [selectedVersionId, setSelectedVersionId] = useState<number | null>(null);
|
||||
const [entries, setEntries] = useState<TealEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploadVisible, setUploadVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
projectService.getAll()
|
||||
.then(({ data }) => setProjects((data as { items?: unknown[] }).items as { id: number; name: string; code: string }[] ?? data as { id: number; name: string; code: string }[]))
|
||||
.catch(() => setProjects([]));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProjectId) { setVersions([]); setEntries([]); return; }
|
||||
tealService.getVersions(selectedProjectId)
|
||||
.then(({ data }) => setVersions(data as TealVersion[]))
|
||||
.catch(() => setVersions([]));
|
||||
}, [selectedProjectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProjectId || !selectedVersionId) { setEntries([]); return; }
|
||||
setLoading(true);
|
||||
tealService.getActive(selectedProjectId)
|
||||
.then(({ data }) => setEntries(data as TealEntry[]))
|
||||
.catch(() => setEntries([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [selectedProjectId, selectedVersionId]);
|
||||
|
||||
async function handleUpload(file: File, effectiveDate: string) {
|
||||
if (!selectedProjectId) return;
|
||||
await tealService.upload(selectedProjectId, file, effectiveDate);
|
||||
setUploadVisible(false);
|
||||
const { data } = await tealService.getVersions(selectedProjectId);
|
||||
setVersions(data as TealVersion[]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BasePageHeader title="TEAL 관리" actions={<Button label="TEAL 업로드" icon="pi pi-upload" size="small" disabled={!selectedProjectId} onClick={() => setUploadVisible(true)} />} />
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', flexWrap: 'wrap' }}>
|
||||
<Dropdown value={selectedProjectId} options={projects.map((p) => ({ label: `${p.code} - ${p.name}`, value: p.id }))} onChange={(e) => { setSelectedProjectId(e.value); setSelectedVersionId(null); }} placeholder="프로젝트 선택" style={{ minWidth: '250px' }} />
|
||||
<Dropdown value={selectedVersionId} options={versions.map((v) => ({ label: `v${v.versionNumber} (${v.effectiveDate})`, value: v.id }))} onChange={(e) => setSelectedVersionId(e.value)} placeholder="버전 선택" disabled={!selectedProjectId} />
|
||||
{versions.find((v) => v.id === selectedVersionId) && (
|
||||
<Tag value={versions.find((v) => v.id === selectedVersionId)?.status ?? ''} severity={versions.find((v) => v.id === selectedVersionId)?.status === 'ACTIVE' ? 'success' : 'secondary'} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<BaseCrudTable value={entries} loading={loading} globalFilterFields={['activityCode', 'activityName', 'discipline']}>
|
||||
<Column field="activityCode" header="Activity Code" sortable />
|
||||
<Column field="activityName" header="Activity Name" sortable />
|
||||
<Column field="discipline" header="Discipline" sortable />
|
||||
</BaseCrudTable>
|
||||
|
||||
<TealUploadDialog visible={uploadVisible} onHide={() => setUploadVisible(false)} onUpload={handleUpload} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { InputNumber } from 'primereact/inputnumber';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { Button } from 'primereact/button';
|
||||
import { NP_CATEGORIES } from '@/core/constants/app.constants';
|
||||
import type { EntryType } from '../timesheet.types';
|
||||
|
||||
interface EntryRow {
|
||||
_uid: number;
|
||||
entryType: EntryType;
|
||||
npCategory?: string;
|
||||
otherProjectId?: number;
|
||||
epcProjectId?: number;
|
||||
canonicalWbsId?: number;
|
||||
tealEntryId?: number;
|
||||
hours: Record<string, number>;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
entry: EntryRow;
|
||||
projects: { id: number; name: string }[];
|
||||
days: string[];
|
||||
dayLabels: string[];
|
||||
disabled?: boolean;
|
||||
onUpdate: (updated: Partial<EntryRow>) => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
export default function TimesheetEntryRow({ entry, projects, days, dayLabels, disabled, onUpdate, onRemove }: Props) {
|
||||
const rowTotal = Object.values(entry.hours).reduce((a, b) => a + b, 0);
|
||||
|
||||
function setHour(day: string, val: number | null) {
|
||||
onUpdate({ hours: { ...entry.hours, [day]: val ?? 0 } });
|
||||
}
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td>
|
||||
{entry.entryType === 'NON_PROJECT' && (
|
||||
<Dropdown
|
||||
value={entry.npCategory}
|
||||
options={NP_CATEGORIES.map((c) => ({ label: c.label, value: c.value }))}
|
||||
onChange={(e) => onUpdate({ npCategory: e.value })}
|
||||
placeholder="카테고리 선택"
|
||||
disabled={disabled}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
)}
|
||||
{entry.entryType === 'OTHER_PROJECT' && (
|
||||
<Dropdown
|
||||
value={entry.otherProjectId}
|
||||
options={projects.map((p) => ({ label: p.name, value: p.id }))}
|
||||
onChange={(e) => onUpdate({ otherProjectId: e.value })}
|
||||
placeholder="프로젝트 선택"
|
||||
disabled={disabled}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
)}
|
||||
{entry.entryType === 'EPC' && (
|
||||
<Dropdown
|
||||
value={entry.epcProjectId}
|
||||
options={projects.map((p) => ({ label: p.name, value: p.id }))}
|
||||
onChange={(e) => onUpdate({ epcProjectId: e.value })}
|
||||
placeholder="프로젝트 선택"
|
||||
disabled={disabled}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
{days.map((day, i) => (
|
||||
<td key={dayLabels[i]} style={{ textAlign: 'center' }}>
|
||||
<InputNumber
|
||||
value={entry.hours[day] ?? 0}
|
||||
onValueChange={(e) => setHour(day, e.value ?? 0)}
|
||||
min={0}
|
||||
max={16}
|
||||
step={0.5}
|
||||
disabled={disabled}
|
||||
inputStyle={{ width: '60px', textAlign: 'center' }}
|
||||
minFractionDigits={1}
|
||||
maxFractionDigits={1}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td style={{ textAlign: 'center', fontWeight: 600 }}>{rowTotal.toFixed(1)}</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<Button icon="pi pi-times" text rounded severity="danger" size="small" disabled={disabled} onClick={onRemove} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import api from '@/core/api/axios';
|
||||
|
||||
const BASE = '/api/wtm/timesheets';
|
||||
|
||||
export const timesheetService = {
|
||||
getWeekly: (weekStart: string) => api.get(`${BASE}/week`, { params: { weekStart } }),
|
||||
saveEntry: (tsId: number, entry: unknown) => api.post(`${BASE}/${tsId}/entries`, entry),
|
||||
saveBatch: (tsId: number, entries: unknown[]) => api.put(`${BASE}/${tsId}/entries/batch`, { entries }),
|
||||
deleteEntry: (tsId: number, entryId: number) => api.delete(`${BASE}/${tsId}/entries/${entryId}`),
|
||||
submit: (tsId: number) => api.post(`${BASE}/${tsId}/submit`),
|
||||
getHistory: (params: Record<string, unknown>) => api.get(`${BASE}/history`, { params }),
|
||||
upload: (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return api.post(`${BASE}/upload`, formData, { headers: { 'Content-Type': 'multipart/form-data' } });
|
||||
},
|
||||
downloadTemplate: () => api.get(`${BASE}/template`, { responseType: 'blob' }),
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
export type TimesheetStatus = 'DRAFT' | 'SUBMITTED' | 'DL_APPROVED' | 'APPROVED' | 'REJECTED';
|
||||
export type EntryType = 'NON_PROJECT' | 'OTHER_PROJECT' | 'EPC';
|
||||
|
||||
export interface TimesheetEntry {
|
||||
id?: number;
|
||||
entryType: EntryType;
|
||||
entryDate: string;
|
||||
hours: number;
|
||||
npCategory?: string;
|
||||
otherProjectId?: number;
|
||||
epcProjectId?: number;
|
||||
canonicalWbsId?: number;
|
||||
tealEntryId?: number;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface Timesheet {
|
||||
id: number;
|
||||
userId: number;
|
||||
weekStartDate: string;
|
||||
weekEndDate: string;
|
||||
status: TimesheetStatus;
|
||||
totalHours: number;
|
||||
submittedAt?: string;
|
||||
entries: TimesheetEntry[];
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Column } from 'primereact/column';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { Calendar } from 'primereact/calendar';
|
||||
import BaseCrudTable from '@/core/components/BaseCrudTable';
|
||||
import BasePageHeader from '@/core/components/BasePageHeader';
|
||||
import { timesheetService } from '../timesheet.service';
|
||||
import { TIMESHEET_STATUS } from '@/core/constants/app.constants';
|
||||
|
||||
export default function TimesheetHistoryView() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [history, setHistory] = useState<Record<string, unknown>[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
const [dateFrom, setDateFrom] = useState<Date | null>(null);
|
||||
const [dateTo, setDateTo] = useState<Date | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
const params: Record<string, unknown> = {};
|
||||
if (statusFilter) params.status = statusFilter;
|
||||
if (dateFrom) params.from = dateFrom.toISOString().slice(0, 10);
|
||||
if (dateTo) params.to = dateTo.toISOString().slice(0, 10);
|
||||
|
||||
timesheetService.getHistory(params)
|
||||
.then(({ data }) => setHistory((data as { items?: unknown[] }).items as Record<string, unknown>[] ?? data as Record<string, unknown>[]))
|
||||
.catch(() => setHistory([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [statusFilter, dateFrom, dateTo]);
|
||||
|
||||
const statusOptions = Object.entries(TIMESHEET_STATUS).map(([key, val]) => ({ label: val.label, value: key }));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BasePageHeader title="시수 이력" />
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', flexWrap: 'wrap' }}>
|
||||
<Calendar value={dateFrom} onChange={(e) => setDateFrom(e.value as Date)} placeholder="시작일" dateFormat="yy-mm-dd" />
|
||||
<Calendar value={dateTo} onChange={(e) => setDateTo(e.value as Date)} placeholder="종료일" dateFormat="yy-mm-dd" />
|
||||
<Dropdown value={statusFilter} options={statusOptions} onChange={(e) => setStatusFilter(e.value)} placeholder="상태" showClear />
|
||||
</div>
|
||||
<BaseCrudTable value={history} loading={loading} rowsPerPage={10}>
|
||||
<Column field="weekStartDate" header="시작일" sortable />
|
||||
<Column field="weekEndDate" header="종료일" sortable />
|
||||
<Column field="totalHours" header="총 시수" body={(row) => `${row.totalHours}h`} sortable />
|
||||
<Column field="status" header="상태" body={(row) => {
|
||||
const s = TIMESHEET_STATUS[row.status as string];
|
||||
return <Tag value={s?.label ?? row.status} severity={(s?.severity ?? 'secondary') as 'success' | 'info' | 'warning' | 'danger' | 'secondary'} />;
|
||||
}} />
|
||||
<Column field="submittedAt" header="제출일" sortable />
|
||||
<Column header="" body={(row) => <a onClick={() => navigate(`/timesheets?week=${row.weekStartDate}`)} style={{ cursor: 'pointer', color: 'var(--p-primary-color)' }}>상세</a>} />
|
||||
</BaseCrudTable>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Card } from 'primereact/card';
|
||||
import BasePageHeader from '@/core/components/BasePageHeader';
|
||||
|
||||
export default function TimesheetUploadView() {
|
||||
return (
|
||||
<div>
|
||||
<BasePageHeader title="Excel 업로드" />
|
||||
<Card>
|
||||
<p style={{ color: 'var(--p-text-muted-color)' }}>Excel 업로드 기능은 준비 중입니다.</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
@use '@/assets/styles/variables' as *;
|
||||
|
||||
.timesheet-week-view {
|
||||
&__week-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-sm;
|
||||
margin-bottom: $space-md;
|
||||
}
|
||||
|
||||
&__table-wrapper {
|
||||
overflow-x: auto;
|
||||
margin-top: $space-sm;
|
||||
}
|
||||
|
||||
&__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
th, td {
|
||||
border: 1px solid $color-border;
|
||||
padding: $space-xs $space-sm;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
||||
thead th {
|
||||
background: var(--p-surface-100);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
tfoot td {
|
||||
background: var(--p-surface-50);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&__summary {
|
||||
margin-top: $space-md;
|
||||
}
|
||||
|
||||
&__summary-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: $space-md;
|
||||
}
|
||||
|
||||
&__summary-actions {
|
||||
display: flex;
|
||||
gap: $space-sm;
|
||||
}
|
||||
|
||||
&__warnings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-xs;
|
||||
margin-top: $space-md;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Calendar } from 'primereact/calendar';
|
||||
import { TabView, TabPanel } from 'primereact/tabview';
|
||||
import { Card } from 'primereact/card';
|
||||
import { Message } from 'primereact/message';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||
import BasePageHeader from '@/core/components/BasePageHeader';
|
||||
import TimesheetEntryRow from '../components/TimesheetEntryRow';
|
||||
import { timesheetService } from '../timesheet.service';
|
||||
import { projectService } from '@/modules/project/project.service';
|
||||
import { TIMESHEET_RULES, TIMESHEET_STATUS, ENTRY_TYPES } from '@/core/constants/app.constants';
|
||||
import type { Timesheet, TimesheetEntry, EntryType } from '../timesheet.types';
|
||||
import './TimesheetWeekView.scss';
|
||||
|
||||
interface EntryRow {
|
||||
_uid: number;
|
||||
entryType: EntryType;
|
||||
npCategory?: string;
|
||||
otherProjectId?: number;
|
||||
epcProjectId?: number;
|
||||
canonicalWbsId?: number;
|
||||
tealEntryId?: number;
|
||||
hours: Record<string, number>;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
let uidCounter = 0;
|
||||
|
||||
function getMonday(d: Date): Date {
|
||||
const date = new Date(d);
|
||||
const day = date.getDay();
|
||||
const diff = date.getDate() - day + (day === 0 ? -6 : 1);
|
||||
date.setDate(diff);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date;
|
||||
}
|
||||
function formatDate(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
function addDays(d: Date, n: number): Date {
|
||||
const r = new Date(d);
|
||||
r.setDate(r.getDate() + n);
|
||||
return r;
|
||||
}
|
||||
|
||||
function entriesToRows(entries: TimesheetEntry[]): EntryRow[] {
|
||||
const grouped = new Map<string, EntryRow>();
|
||||
for (const e of entries) {
|
||||
const key = `${e.entryType}-${e.npCategory ?? ''}-${e.otherProjectId ?? ''}-${e.epcProjectId ?? ''}-${e.canonicalWbsId ?? ''}-${e.tealEntryId ?? ''}`;
|
||||
if (!grouped.has(key)) {
|
||||
grouped.set(key, {
|
||||
_uid: ++uidCounter,
|
||||
entryType: e.entryType,
|
||||
npCategory: e.npCategory,
|
||||
otherProjectId: e.otherProjectId,
|
||||
epcProjectId: e.epcProjectId,
|
||||
canonicalWbsId: e.canonicalWbsId,
|
||||
tealEntryId: e.tealEntryId,
|
||||
hours: {},
|
||||
remark: e.remark,
|
||||
});
|
||||
}
|
||||
grouped.get(key)!.hours[e.entryDate] = e.hours;
|
||||
}
|
||||
return Array.from(grouped.values());
|
||||
}
|
||||
|
||||
export default function TimesheetWeekView() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [weekStart, setWeekStart] = useState<Date>(getMonday(new Date()));
|
||||
const [timesheet, setTimesheet] = useState<Timesheet | null>(null);
|
||||
const [entryRows, setEntryRows] = useState<EntryRow[]>([]);
|
||||
const [projects, setProjects] = useState<{ id: number; name: string }[]>([]);
|
||||
|
||||
const weekEnd = useMemo(() => addDays(weekStart, 5), [weekStart]);
|
||||
const weekLabel = `${formatDate(weekStart)} ~ ${formatDate(weekEnd)}`;
|
||||
const days = useMemo(() => Array.from({ length: 6 }, (_, i) => formatDate(addDays(weekStart, i))), [weekStart]);
|
||||
const dayLabels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
const totalHours = useMemo(() =>
|
||||
entryRows.reduce((sum, row) => sum + Object.values(row.hours).reduce((a, b) => a + b, 0), 0),
|
||||
[entryRows],
|
||||
);
|
||||
|
||||
const dailyTotals = useMemo(() => {
|
||||
const totals: Record<string, number> = {};
|
||||
for (const d of days) {
|
||||
totals[d] = entryRows.reduce((sum, row) => sum + (row.hours[d] ?? 0), 0);
|
||||
}
|
||||
return totals;
|
||||
}, [entryRows, days]);
|
||||
|
||||
const warnings = useMemo(() => {
|
||||
const msgs: string[] = [];
|
||||
for (const [date, total] of Object.entries(dailyTotals)) {
|
||||
if (total > TIMESHEET_RULES.warnDailyHours) msgs.push(`${date}: 일 ${total}시간 입력 - 기준(${TIMESHEET_RULES.defaultDailyHours}h) 초과`);
|
||||
if (total > TIMESHEET_RULES.maxDailyHours) msgs.push(`${date}: 일 최대 ${TIMESHEET_RULES.maxDailyHours}시간 초과!`);
|
||||
}
|
||||
if (totalHours > TIMESHEET_RULES.maxWeeklyHours) msgs.push(`주간 합계 ${totalHours}시간 - 최대 ${TIMESHEET_RULES.maxWeeklyHours}h 초과!`);
|
||||
return msgs;
|
||||
}, [dailyTotals, totalHours]);
|
||||
|
||||
const isEditable = !timesheet || timesheet.status === 'DRAFT' || timesheet.status === 'REJECTED';
|
||||
const statusInfo = timesheet ? TIMESHEET_STATUS[timesheet.status] : null;
|
||||
|
||||
const loadWeek = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await timesheetService.getWeekly(formatDate(weekStart));
|
||||
const ts = data as Timesheet;
|
||||
setTimesheet(ts);
|
||||
setEntryRows(entriesToRows(ts.entries ?? []));
|
||||
} catch {
|
||||
setTimesheet(null);
|
||||
setEntryRows([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [weekStart]);
|
||||
|
||||
useEffect(() => { loadWeek(); }, [loadWeek]);
|
||||
|
||||
useEffect(() => {
|
||||
projectService.getMy()
|
||||
.then(({ data }) => setProjects((data as { items?: unknown[] }).items as { id: number; name: string }[] ?? data as { id: number; name: string }[]))
|
||||
.catch(() => setProjects([]));
|
||||
}, []);
|
||||
|
||||
function rowsToEntries() {
|
||||
const entries: unknown[] = [];
|
||||
for (const row of entryRows) {
|
||||
for (const [date, hours] of Object.entries(row.hours)) {
|
||||
if (hours > 0) entries.push({ entryType: row.entryType, entryDate: date, hours, npCategory: row.npCategory, otherProjectId: row.otherProjectId, epcProjectId: row.epcProjectId, canonicalWbsId: row.canonicalWbsId, tealEntryId: row.tealEntryId, remark: row.remark });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function addRow(type: EntryType) {
|
||||
setEntryRows((prev) => [...prev, { _uid: ++uidCounter, entryType: type, hours: {} }]);
|
||||
}
|
||||
function removeRow(uid: number) {
|
||||
setEntryRows((prev) => prev.filter((r) => r._uid !== uid));
|
||||
}
|
||||
function updateRow(uid: number, updated: Partial<EntryRow>) {
|
||||
setEntryRows((prev) => prev.map((r) => r._uid === uid ? { ...r, ...updated } : r));
|
||||
}
|
||||
|
||||
async function saveDraft() {
|
||||
if (!timesheet) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await timesheetService.saveBatch(timesheet.id, rowsToEntries());
|
||||
await loadWeek();
|
||||
} catch { /* handled */ }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
|
||||
async function submitTimesheet() {
|
||||
if (!timesheet) return;
|
||||
if (warnings.some((w) => w.includes('초과!'))) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await timesheetService.saveBatch(timesheet.id, rowsToEntries());
|
||||
await timesheetService.submit(timesheet.id);
|
||||
await loadWeek();
|
||||
} catch { /* handled */ }
|
||||
finally { setSubmitting(false); }
|
||||
}
|
||||
|
||||
const tabTypes: EntryType[] = ['NON_PROJECT', 'OTHER_PROJECT', 'EPC'];
|
||||
|
||||
return (
|
||||
<div className="timesheet-week-view">
|
||||
<BasePageHeader title="시수 입력" subtitle={weekLabel}
|
||||
actions={statusInfo ? <Tag value={statusInfo.label} severity={statusInfo.severity as 'success' | 'info' | 'warning' | 'danger' | 'secondary'} /> : undefined}
|
||||
/>
|
||||
|
||||
<div className="timesheet-week-view__week-picker">
|
||||
<Button icon="pi pi-chevron-left" text rounded onClick={() => setWeekStart(addDays(weekStart, -7))} />
|
||||
<Calendar value={weekStart} onChange={(e) => e.value && setWeekStart(getMonday(e.value as Date))} dateFormat="yy-mm-dd" style={{ width: '160px' }} />
|
||||
<Button icon="pi pi-chevron-right" text rounded onClick={() => setWeekStart(addDays(weekStart, 7))} />
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}><ProgressSpinner /></div>
|
||||
) : (
|
||||
<>
|
||||
<TabView>
|
||||
{tabTypes.map((tabKey) => (
|
||||
<TabPanel key={tabKey} header={ENTRY_TYPES[tabKey]?.label ?? tabKey}>
|
||||
<div className="timesheet-week-view__table-wrapper">
|
||||
<table className="timesheet-week-view__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ minWidth: 200 }}>{tabKey === 'NON_PROJECT' ? '카테고리' : tabKey === 'OTHER_PROJECT' ? '프로젝트' : '프로젝트 / WBS / TEAL'}</th>
|
||||
{dayLabels.map((label, i) => (
|
||||
<th key={label} style={{ width: 80, textAlign: 'center' }}>{label}<br /><small>{days[i]?.slice(5)}</small></th>
|
||||
))}
|
||||
<th style={{ width: 70, textAlign: 'center' }}>합계</th>
|
||||
<th style={{ width: 50 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entryRows.filter((r) => r.entryType === tabKey).map((row) => (
|
||||
<TimesheetEntryRow key={row._uid} entry={row} projects={projects} days={days} dayLabels={dayLabels} disabled={!isEditable}
|
||||
onUpdate={(u) => updateRow(row._uid, u)} onRemove={() => removeRow(row._uid)} />
|
||||
))}
|
||||
{entryRows.filter((r) => r.entryType === tabKey).length === 0 && (
|
||||
<tr><td colSpan={dayLabels.length + 3} style={{ textAlign: 'center', padding: '1.5rem', color: 'var(--p-text-muted-color)' }}>항목이 없습니다. 아래 버튼으로 추가하세요.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td><strong>소계</strong></td>
|
||||
{days.map((day) => (<td key={day} style={{ textAlign: 'center' }}>{dailyTotals[day]?.toFixed(1) ?? '0.0'}</td>))}
|
||||
<td style={{ textAlign: 'center' }}><strong>{totalHours.toFixed(1)}h</strong></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<div style={{ marginTop: '0.5rem' }}>
|
||||
<Button label="+ 행 추가" text size="small" disabled={!isEditable} onClick={() => addRow(tabKey)} />
|
||||
</div>
|
||||
</TabPanel>
|
||||
))}
|
||||
</TabView>
|
||||
|
||||
<Card className="timesheet-week-view__summary">
|
||||
<div className="timesheet-week-view__summary-row">
|
||||
<span>주간 합계: <strong>{totalHours.toFixed(1)}</strong> / {TIMESHEET_RULES.maxWeeklyHours}h</span>
|
||||
<div className="timesheet-week-view__summary-actions">
|
||||
<Button label="임시 저장" severity="secondary" icon="pi pi-save" loading={saving} disabled={!isEditable} onClick={saveDraft} />
|
||||
<Button label="제출 (결재 요청)" icon="pi pi-send" loading={submitting} disabled={!isEditable} onClick={submitTimesheet} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{warnings.length > 0 && (
|
||||
<div className="timesheet-week-view__warnings">
|
||||
{warnings.map((w, i) => <Message key={i} severity={w.includes('초과!') ? 'error' : 'warn'} text={w} />)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { MultiSelect } from 'primereact/multiselect';
|
||||
import { InputSwitch } from 'primereact/inputswitch';
|
||||
import BaseFormDialog from '@/core/components/BaseFormDialog';
|
||||
import { ROLES } from '@/core/constants/app.constants';
|
||||
import type { User } from '../user.types';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onHide: () => void;
|
||||
user: User | null;
|
||||
onSave: (data: Partial<User>) => Promise<void>;
|
||||
}
|
||||
|
||||
const DISCIPLINE_OPTIONS = [
|
||||
'Piping', 'Electrical', 'Instrument', 'Civil', 'Structural',
|
||||
'Mechanical', 'Process', 'HSE', 'QA/QC', 'Other',
|
||||
].map((d) => ({ label: d, value: d }));
|
||||
|
||||
const ROLE_OPTIONS = Object.values(ROLES).map((r) => ({ label: r, value: r }));
|
||||
|
||||
export default function UserFormDialog({ visible, onHide, user, onSave }: Props) {
|
||||
const [fullName, setFullName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [employeeId, setEmployeeId] = useState('');
|
||||
const [department, setDepartment] = useState('');
|
||||
const [discipline, setDiscipline] = useState('');
|
||||
const [roles, setRoles] = useState<string[]>([]);
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setFullName(user.fullName);
|
||||
setEmail(user.email);
|
||||
setEmployeeId(user.employeeId ?? '');
|
||||
setDepartment(user.department ?? '');
|
||||
setDiscipline(user.discipline ?? '');
|
||||
setRoles(user.roles);
|
||||
setIsActive(user.isActive);
|
||||
} else {
|
||||
setFullName(''); setEmail(''); setEmployeeId(''); setDepartment('');
|
||||
setDiscipline(''); setRoles([]); setIsActive(true);
|
||||
}
|
||||
}, [user, visible]);
|
||||
|
||||
async function handleSubmit() {
|
||||
setLoading(true);
|
||||
try {
|
||||
await onSave({ fullName, email, employeeId, department, discipline, roles, isActive });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseFormDialog visible={visible} onHide={onHide} title={user ? '사용자 수정' : '사용자 생성'} loading={loading} onSubmit={handleSubmit}>
|
||||
<div className="form-grid">
|
||||
<div className="col-6 form-field">
|
||||
<label className="form-field__label form-field__label--required">이름</label>
|
||||
<InputText value={fullName} onChange={(e) => setFullName(e.target.value)} />
|
||||
</div>
|
||||
<div className="col-6 form-field">
|
||||
<label className="form-field__label form-field__label--required">이메일</label>
|
||||
<InputText value={email} onChange={(e) => setEmail(e.target.value)} type="email" />
|
||||
</div>
|
||||
<div className="col-6 form-field">
|
||||
<label className="form-field__label">사번</label>
|
||||
<InputText value={employeeId} onChange={(e) => setEmployeeId(e.target.value)} />
|
||||
</div>
|
||||
<div className="col-6 form-field">
|
||||
<label className="form-field__label">부서</label>
|
||||
<InputText value={department} onChange={(e) => setDepartment(e.target.value)} />
|
||||
</div>
|
||||
<div className="col-6 form-field">
|
||||
<label className="form-field__label">Discipline</label>
|
||||
<Dropdown value={discipline} options={DISCIPLINE_OPTIONS} onChange={(e) => setDiscipline(e.value)} placeholder="선택" showClear />
|
||||
</div>
|
||||
<div className="col-6 form-field">
|
||||
<label className="form-field__label">역할</label>
|
||||
<MultiSelect value={roles} options={ROLE_OPTIONS} onChange={(e) => setRoles(e.value)} placeholder="선택" />
|
||||
</div>
|
||||
{user && (
|
||||
<div className="col-6 form-field">
|
||||
<label className="form-field__label">활성 상태</label>
|
||||
<InputSwitch checked={isActive} onChange={(e) => setIsActive(e.value ?? false)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BaseFormDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import api from '@/core/api/axios';
|
||||
|
||||
const BASE = '/api/wtm/users';
|
||||
|
||||
export const userService = {
|
||||
getAll: (params?: Record<string, unknown>) => api.get(BASE, { params }),
|
||||
getById: (id: number) => api.get(`${BASE}/${id}`),
|
||||
update: (id: number, data: unknown) => api.put(`${BASE}/${id}`, data),
|
||||
updateRoles: (id: number, roles: string[]) => api.put(`${BASE}/${id}/roles`, { roles }),
|
||||
uploadInternal: (file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
return api.post(`${BASE}/upload/internal`, fd, { headers: { 'Content-Type': 'multipart/form-data' } });
|
||||
},
|
||||
uploadSubcontractor: (file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
return api.post(`${BASE}/upload/subcontractor`, fd, { headers: { 'Content-Type': 'multipart/form-data' } });
|
||||
},
|
||||
downloadTemplate: () => api.get(`${BASE}/template`, { responseType: 'blob' }),
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
username?: string;
|
||||
fullName: string;
|
||||
employeeId?: string;
|
||||
department?: string;
|
||||
discipline?: string;
|
||||
location?: string;
|
||||
roles: string[];
|
||||
isActive: boolean;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Card } from 'primereact/card';
|
||||
import BasePageHeader from '@/core/components/BasePageHeader';
|
||||
|
||||
export default function UserDetailView() {
|
||||
return (
|
||||
<div>
|
||||
<BasePageHeader title="사용자 상세" />
|
||||
<Card>
|
||||
<p style={{ color: 'var(--p-text-muted-color)' }}>사용자 상세 페이지는 준비 중입니다.</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Column } from 'primereact/column';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import BaseCrudTable from '@/core/components/BaseCrudTable';
|
||||
import BasePageHeader from '@/core/components/BasePageHeader';
|
||||
import UserFormDialog from '../components/UserFormDialog';
|
||||
import { userService } from '../user.service';
|
||||
import type { User } from '../user.types';
|
||||
|
||||
const ROLE_SEVERITY: Record<string, string> = {
|
||||
SA: 'danger', PM: 'warning', PCM: 'info', DL: 'success', PTK: 'secondary', USER: 'contrast',
|
||||
};
|
||||
|
||||
export default function UserListView() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [dialogVisible, setDialogVisible] = useState(false);
|
||||
const [editUser, setEditUser] = useState<User | null>(null);
|
||||
|
||||
function load() {
|
||||
setLoading(true);
|
||||
userService.getAll()
|
||||
.then(({ data }) => setUsers((data as { items?: User[] }).items ?? data as User[]))
|
||||
.catch(() => setUsers([]))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
function openEdit(u: User) {
|
||||
setEditUser(u);
|
||||
setDialogVisible(true);
|
||||
}
|
||||
|
||||
async function onSave(data: Partial<User>) {
|
||||
if (editUser) {
|
||||
await userService.update(editUser.id, data);
|
||||
}
|
||||
setDialogVisible(false);
|
||||
load();
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BasePageHeader title="사용자 관리" />
|
||||
<BaseCrudTable value={users} loading={loading} globalFilterFields={['fullName', 'email', 'employeeId', 'department']}>
|
||||
<Column field="fullName" header="이름" sortable />
|
||||
<Column field="email" header="이메일" sortable />
|
||||
<Column field="employeeId" header="사번" sortable />
|
||||
<Column field="department" header="부서" sortable />
|
||||
<Column field="discipline" header="Discipline" sortable />
|
||||
<Column field="roles" header="역할" body={(row) => (
|
||||
<div style={{ display: 'flex', gap: '0.25rem', flexWrap: 'wrap' }}>
|
||||
{(row.roles as string[]).map((r: string) => (
|
||||
<Tag key={r} value={r} severity={(ROLE_SEVERITY[r] ?? 'secondary') as 'danger' | 'warning' | 'info' | 'success' | 'secondary'} />
|
||||
))}
|
||||
</div>
|
||||
)} />
|
||||
<Column field="isActive" header="상태" body={(row) => (
|
||||
<Tag value={row.isActive ? '활성' : '비활성'} severity={row.isActive ? 'success' : 'secondary'} />
|
||||
)} />
|
||||
<Column header="" body={(row) => <Button icon="pi pi-pencil" text size="small" onClick={() => openEdit(row)} />} style={{ width: '4rem' }} />
|
||||
</BaseCrudTable>
|
||||
|
||||
<UserFormDialog visible={dialogVisible} onHide={() => setDialogVisible(false)} user={editUser} onSave={onSave} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useState } from 'react';
|
||||
import { FileUpload, type FileUploadSelectEvent } from 'primereact/fileupload';
|
||||
import { Calendar } from 'primereact/calendar';
|
||||
import BaseFormDialog from '@/core/components/BaseFormDialog';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onHide: () => void;
|
||||
onUpload: (file: File, effectiveDate: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function WbsUploadDialog({ visible, onHide, onUpload }: Props) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [effectiveDate, setEffectiveDate] = useState<Date | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
function onSelect(e: FileUploadSelectEvent) {
|
||||
setFile(e.files[0] ?? null);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!file || !effectiveDate) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await onUpload(file, effectiveDate.toISOString().slice(0, 10));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseFormDialog visible={visible} onHide={onHide} title="P6 WBS 업로드" loading={loading} onSubmit={handleSubmit} submitLabel="업로드">
|
||||
<div className="form-field">
|
||||
<label className="form-field__label form-field__label--required">WBS 파일</label>
|
||||
<FileUpload mode="basic" accept=".xml,.xer" maxFileSize={10000000} onSelect={onSelect} chooseLabel="파일 선택" auto={false} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label className="form-field__label form-field__label--required">적용일</label>
|
||||
<Calendar value={effectiveDate} onChange={(e) => setEffectiveDate(e.value as Date)} dateFormat="yy-mm-dd" />
|
||||
</div>
|
||||
</BaseFormDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Tree } from 'primereact/tree';
|
||||
import type { TreeNode } from 'primereact/treenode';
|
||||
import { Card } from 'primereact/card';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||
import BasePageHeader from '@/core/components/BasePageHeader';
|
||||
import WbsUploadDialog from '../components/WbsUploadDialog';
|
||||
import { wbsService } from '../wbs.service';
|
||||
import { projectService } from '@/modules/project/project.service';
|
||||
import type { WbsVersion, WbsNode } from '../wbs.types';
|
||||
|
||||
function nodesToTreeNodes(nodes: WbsNode[]): TreeNode[] {
|
||||
return nodes.map((n) => ({
|
||||
key: String(n.id),
|
||||
label: `${n.code} - ${n.name}`,
|
||||
children: n.children ? nodesToTreeNodes(n.children) : [],
|
||||
}));
|
||||
}
|
||||
|
||||
export default function WbsTreeView() {
|
||||
const [projects, setProjects] = useState<{ id: number; name: string; code: string }[]>([]);
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<number | null>(null);
|
||||
const [versions, setVersions] = useState<WbsVersion[]>([]);
|
||||
const [selectedVersion, setSelectedVersion] = useState<number | null>(null);
|
||||
const [treeNodes, setTreeNodes] = useState<TreeNode[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploadVisible, setUploadVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
projectService.getAll()
|
||||
.then(({ data }) => setProjects((data as { items?: unknown[] }).items as { id: number; name: string; code: string }[] ?? data as { id: number; name: string; code: string }[]))
|
||||
.catch(() => setProjects([]));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProjectId) { setVersions([]); return; }
|
||||
wbsService.getVersions(selectedProjectId)
|
||||
.then(({ data }) => setVersions(data as WbsVersion[]))
|
||||
.catch(() => setVersions([]));
|
||||
}, [selectedProjectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProjectId || !selectedVersion) { setTreeNodes([]); return; }
|
||||
setLoading(true);
|
||||
wbsService.getVersionNodes(selectedProjectId, selectedVersion)
|
||||
.then(({ data }) => setTreeNodes(nodesToTreeNodes(data as WbsNode[])))
|
||||
.catch(() => setTreeNodes([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [selectedProjectId, selectedVersion]);
|
||||
|
||||
async function handleUpload(file: File, effectiveDate: string) {
|
||||
if (!selectedProjectId) return;
|
||||
await wbsService.upload(selectedProjectId, file, effectiveDate);
|
||||
setUploadVisible(false);
|
||||
const { data } = await wbsService.getVersions(selectedProjectId);
|
||||
setVersions(data as WbsVersion[]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BasePageHeader title="WBS 관리" actions={<Button label="P6 업로드" icon="pi pi-upload" size="small" disabled={!selectedProjectId} onClick={() => setUploadVisible(true)} />} />
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', flexWrap: 'wrap' }}>
|
||||
<Dropdown value={selectedProjectId} options={projects.map((p) => ({ label: `${p.code} - ${p.name}`, value: p.id }))} onChange={(e) => { setSelectedProjectId(e.value); setSelectedVersion(null); }} placeholder="프로젝트 선택" style={{ minWidth: '250px' }} />
|
||||
<Dropdown value={selectedVersion} options={versions.map((v) => ({ label: `v${v.versionNumber} (${v.effectiveDate})`, value: v.versionNumber }))} onChange={(e) => setSelectedVersion(e.value)} placeholder="버전 선택" disabled={!selectedProjectId} />
|
||||
{versions.find((v) => v.versionNumber === selectedVersion) && (
|
||||
<Tag value={versions.find((v) => v.versionNumber === selectedVersion)?.status ?? ''} severity={versions.find((v) => v.versionNumber === selectedVersion)?.status === 'ACTIVE' ? 'success' : 'secondary'} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}><ProgressSpinner /></div>
|
||||
) : treeNodes.length > 0 ? (
|
||||
<Tree value={treeNodes} />
|
||||
) : (
|
||||
<p style={{ textAlign: 'center', color: 'var(--p-text-muted-color)', padding: '2rem' }}>프로젝트와 버전을 선택하세요.</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<WbsUploadDialog visible={uploadVisible} onHide={() => setUploadVisible(false)} onUpload={handleUpload} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import api from '@/core/api/axios';
|
||||
|
||||
export const wbsService = {
|
||||
upload: (projectId: number, file: File, effectiveDate: string) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('effectiveDate', effectiveDate);
|
||||
return api.post(`/api/wtm/projects/${projectId}/wbs/upload`, formData, { headers: { 'Content-Type': 'multipart/form-data' } });
|
||||
},
|
||||
getVersions: (projectId: number) => api.get(`/api/wtm/projects/${projectId}/wbs/versions`),
|
||||
getVersionNodes: (projectId: number, version: number) => api.get(`/api/wtm/projects/${projectId}/wbs/versions/${version}`),
|
||||
activateVersion: (projectId: number, version: number) => api.post(`/api/wtm/projects/${projectId}/wbs/versions/${version}/activate`),
|
||||
getCanonicalWbs: (projectId: number) => api.get(`/api/wtm/projects/${projectId}/canonical-wbs`),
|
||||
compareVersions: (projectId: number, v1: number, v2: number) => api.get(`/api/wtm/projects/${projectId}/wbs/compare`, { params: { v1, v2 } }),
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface WbsNode {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
level: number;
|
||||
parentId?: number;
|
||||
children?: WbsNode[];
|
||||
}
|
||||
|
||||
export interface WbsVersion {
|
||||
id: number;
|
||||
projectId: number;
|
||||
versionNumber: number;
|
||||
effectiveDate: string;
|
||||
status: string;
|
||||
nodeCount?: number;
|
||||
}
|
||||
11
wtm-frontend-react/src/vite-env.d.ts
벤더링됨
일반 파일
11
wtm-frontend-react/src/vite-env.d.ts
벤더링됨
일반 파일
@@ -0,0 +1,11 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string;
|
||||
readonly VITE_APP_TITLE: string;
|
||||
readonly VITE_APP_ENV: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
25
wtm-frontend-react/tsconfig.app.json
일반 파일
25
wtm-frontend-react/tsconfig.app.json
일반 파일
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./src/vite-env.d.ts","./src/app/app.tsx","./src/app/main.tsx","./src/app/router.tsx","./src/core/api/api.types.ts","./src/core/api/axios.ts","./src/core/auth/auth.service.ts","./src/core/auth/auth.types.ts","./src/core/components/applayout.tsx","./src/core/components/appsidebar.tsx","./src/core/components/apptopbar.tsx","./src/core/components/authguard.tsx","./src/core/components/basecrudtable.tsx","./src/core/components/baseformdialog.tsx","./src/core/components/basepageheader.tsx","./src/core/components/notfoundview.tsx","./src/core/constants/app.constants.ts","./src/core/hooks/usecurrentuser.ts","./src/modules/approval/approval.service.ts","./src/modules/approval/approval.types.ts","./src/modules/approval/views/approvalhistoryview.tsx","./src/modules/approval/views/approvalpendingview.tsx","./src/modules/auth/auth.service.ts","./src/modules/auth/auth.store.ts","./src/modules/auth/auth.types.ts","./src/modules/auth/views/changepasswordview.tsx","./src/modules/auth/views/forgotpasswordview.tsx","./src/modules/auth/views/loginview.tsx","./src/modules/dashboard/dashboard.service.ts","./src/modules/dashboard/dashboard.types.ts","./src/modules/dashboard/views/dashboardview.tsx","./src/modules/project/project.service.ts","./src/modules/project/project.types.ts","./src/modules/project/components/projectformdialog.tsx","./src/modules/project/views/projectdetailview.tsx","./src/modules/project/views/projectlistview.tsx","./src/modules/report/report.service.ts","./src/modules/report/report.types.ts","./src/modules/report/views/reportview.tsx","./src/modules/settings/settings.service.ts","./src/modules/settings/settings.types.ts","./src/modules/settings/components/overheadtypedialog.tsx","./src/modules/settings/views/settingsview.tsx","./src/modules/teal/teal.service.ts","./src/modules/teal/teal.types.ts","./src/modules/teal/components/tealuploaddialog.tsx","./src/modules/teal/views/teallistview.tsx","./src/modules/timesheet/timesheet.service.ts","./src/modules/timesheet/timesheet.types.ts","./src/modules/timesheet/components/timesheetentryrow.tsx","./src/modules/timesheet/views/timesheethistoryview.tsx","./src/modules/timesheet/views/timesheetuploadview.tsx","./src/modules/timesheet/views/timesheetweekview.tsx","./src/modules/user/user.service.ts","./src/modules/user/user.types.ts","./src/modules/user/components/userformdialog.tsx","./src/modules/user/views/userdetailview.tsx","./src/modules/user/views/userlistview.tsx","./src/modules/wbs/wbs.service.ts","./src/modules/wbs/wbs.types.ts","./src/modules/wbs/components/wbsuploaddialog.tsx","./src/modules/wbs/views/wbstreeview.tsx"],"version":"5.6.3"}
|
||||
7
wtm-frontend-react/tsconfig.json
일반 파일
7
wtm-frontend-react/tsconfig.json
일반 파일
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./vite.config.ts"],"version":"5.6.3"}
|
||||
32
wtm-frontend-react/vite.config.ts
일반 파일
32
wtm-frontend-react/vite.config.ts
일반 파일
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { fileURLToPath, URL } from 'node:url';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8081',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: ['quill'],
|
||||
output: {
|
||||
manualChunks: {
|
||||
'vendor-react': ['react', 'react-dom', 'react-router-dom', 'zustand'],
|
||||
'vendor-primereact': ['primereact'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
새 Issue에서 참조
사용자 차단