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

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

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

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

파일 보기

@@ -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>

파일 보기

@@ -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} />;
}

파일 보기

@@ -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 벤더링됨 일반 파일
파일 보기

@@ -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;
}

파일 보기

@@ -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"}

파일 보기

@@ -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"}

파일 보기

@@ -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'],
},
},
},
},
});