feat: WTM 멀티프로젝트 플랫폼 구축 (BE + FE 전체 구현)

Phase 0: wbx-spring-core 라이브러리 전환
- java-library 플러그인, WbxAutoConfiguration, Admin 조건부 활성화
- 루트 settings.gradle + build.gradle (멀티모듈)

Phase 1: wtm-api 모듈 생성
- 23개 JPA Entity, 14개 Controller, 79개 API 엔드포인트
- Flyway V100~V107 MySQL 마이그레이션
- TimesheetRuleEngine, TimesheetApprovalHandler, P6WbsParser

Phase 2: wtm-frontend (Vue 3 + PrimeVue 4)
- 10개 도메인 모듈, 17개 View, 5개 서브컴포넌트
- 반응형 레이아웃 (AppLayout, AppSidebar, AppTopbar)
- BaseCrudTable, BaseFormDialog, BasePageHeader 표준 컴포넌트
- JWT 인터셉터, 역할 기반 메뉴 필터링

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
이 Commit은 다음에 포함되어 있습니다:
2026-03-25 21:01:43 +09:00
부모 783865266b
커밋 df723f1d59
533개의 변경된 파일15528개의 추가작업 그리고 154개의 파일을 삭제

1
wtm-frontend/.env 일반 파일
파일 보기

@@ -0,0 +1 @@
VITE_APP_TITLE=WTM - Work Time Manager

파일 보기

@@ -0,0 +1,2 @@
VITE_API_BASE_URL=http://localhost:8080
VITE_APP_ENV=development

파일 보기

@@ -0,0 +1,2 @@
VITE_API_BASE_URL=https://wtmgr.hanwhaocean.com
VITE_APP_ENV=production

24
wtm-frontend/.gitignore 벤더링됨 일반 파일
파일 보기

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

파일 보기

@@ -0,0 +1,9 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "all",
"printWidth": 100,
"endOfLine": "lf",
"vueIndentScriptAndStyle": false
}

5
wtm-frontend/README.md 일반 파일
파일 보기

@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

파일 보기

@@ -0,0 +1,25 @@
// Flat config (ESLint 9.x)
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import pluginVue from 'eslint-plugin-vue';
import eslintConfigPrettier from 'eslint-config-prettier';
export default tseslint.config(
{ ignores: ['dist/', 'node_modules/'] },
js.configs.recommended,
...tseslint.configs.recommended,
...pluginVue.configs['flat/recommended'],
{
files: ['**/*.vue'],
languageOptions: { parserOptions: { parser: tseslint.parser } },
},
{
rules: {
'vue/multi-word-component-names': 'off',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/consistent-type-imports': 'error',
'no-console': ['warn', { allow: ['warn', 'error'] }],
},
},
eslintConfigPrettier,
);

13
wtm-frontend/index.html 일반 파일
파일 보기

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<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-frontend</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/app/main.ts"></script>
</body>
</html>

3944
wtm-frontend/package-lock.json 생성됨 일반 파일

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Diff 로드

38
wtm-frontend/package.json 일반 파일
파일 보기

@@ -0,0 +1,38 @@
{
"name": "wtm-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@primeuix/themes": "^2.0.3",
"@primevue/forms": "^4.5.4",
"@vueuse/core": "^14.2.1",
"axios": "^1.13.6",
"chart.js": "^4.5.1",
"pinia": "^3.0.4",
"primevue": "^4.5.4",
"vue": "^3.5.30",
"vue-router": "^5.0.4"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.12.0",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/tsconfig": "^0.9.0",
"eslint": "^10.1.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-vue": "^10.8.0",
"globals": "^17.4.0",
"prettier": "^3.8.1",
"sass": "^1.98.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.2",
"vite": "^8.0.1",
"vue-tsc": "^3.2.5"
}
}

하나 이상의 줄이 너무 길어 파일 Diff가 숨겨졌습니다

이후

너비:  |  높이:  |  크기: 9.3 KiB

파일 보기

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

이후

너비:  |  높이:  |  크기: 4.9 KiB

10
wtm-frontend/src/app/App.vue 일반 파일
파일 보기

@@ -0,0 +1,10 @@
<script setup lang="ts">
import Toast from 'primevue/toast';
import ConfirmDialog from 'primevue/confirmdialog';
</script>
<template>
<Toast />
<ConfirmDialog />
<router-view />
</template>

14
wtm-frontend/src/app/main.ts 일반 파일
파일 보기

@@ -0,0 +1,14 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';
import { setupPrimeVue } from './plugins/primevue';
import '@/assets/styles/main.scss';
const app = createApp(App);
app.use(createPinia());
app.use(router);
setupPrimeVue(app);
app.mount('#app');

파일 보기

@@ -0,0 +1,23 @@
import PrimeVue from 'primevue/config';
import Aura from '@primeuix/themes/aura';
import ConfirmationService from 'primevue/confirmationservice';
import ToastService from 'primevue/toastservice';
import DialogService from 'primevue/dialogservice';
import Tooltip from 'primevue/tooltip';
import type { App } from 'vue';
export function setupPrimeVue(app: App) {
app.use(PrimeVue, {
theme: {
preset: Aura,
options: {
darkModeSelector: '.app-dark',
},
},
ripple: true,
});
app.use(ConfirmationService);
app.use(ToastService);
app.use(DialogService);
app.directive('tooltip', Tooltip);
}

파일 보기

@@ -0,0 +1,43 @@
import { createRouter, createWebHistory } from 'vue-router';
import { authGuard } from '@/core/auth/auth.guard';
import { authRoutes } from '@/modules/auth/auth.routes';
import { dashboardRoutes } from '@/modules/dashboard/dashboard.routes';
import { userRoutes } from '@/modules/user/user.routes';
import { projectRoutes } from '@/modules/project/project.routes';
import { wbsRoutes } from '@/modules/wbs/wbs.routes';
import { tealRoutes } from '@/modules/teal/teal.routes';
import { timesheetRoutes } from '@/modules/timesheet/timesheet.routes';
import { approvalRoutes } from '@/modules/approval/approval.routes';
import { reportRoutes } from '@/modules/report/report.routes';
import { settingsRoutes } from '@/modules/settings/settings.routes';
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
...authRoutes,
{
path: '/',
component: () => import('@/core/components/AppLayout.vue'),
beforeEnter: authGuard,
redirect: '/dashboard',
children: [
...dashboardRoutes,
...userRoutes,
...projectRoutes,
...wbsRoutes,
...tealRoutes,
...timesheetRoutes,
...approvalRoutes,
...reportRoutes,
...settingsRoutes,
],
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('@/core/components/NotFoundView.vue'),
},
],
});
export default router;

바이너리 파일은 표시되지 않습니다.

이후

너비:  |  높이:  |  크기: 44 KiB

파일 보기

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

하나 이상의 줄이 너무 길어 파일 Diff가 숨겨졌습니다

이후

너비:  |  높이:  |  크기: 8.5 KiB

파일 보기

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

이후

너비:  |  높이:  |  크기: 496 B

파일 보기

@@ -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,64 @@
import axios from 'axios';
import type { InternalAxiosRequestConfig, AxiosError } from 'axios';
import { authService } from '@/core/auth/auth.service';
import router from '@/app/router';
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();
router.push({ name: 'login' });
return Promise.reject(error);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
},
);
export default api;

파일 보기

@@ -0,0 +1,10 @@
import type { NavigationGuardWithThis } from 'vue-router';
import { authService } from './auth.service';
export const authGuard: NavigationGuardWithThis<undefined> = (_to, _from, next) => {
if (authService.isAuthenticated()) {
next();
} else {
next({ name: 'login' });
}
};

파일 보기

@@ -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.accessToken, data.refreshToken);
return data.accessToken;
},
};

파일 보기

@@ -0,0 +1,18 @@
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;
user: AuthUser;
}

파일 보기

@@ -0,0 +1,137 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { useBreakpoints } from '@vueuse/core';
import { useRouter } from 'vue-router';
import AppSidebar from './AppSidebar.vue';
import AppTopbar from './AppTopbar.vue';
import { BREAKPOINTS, LAYOUT } from '@/core/constants/app.constants';
const breakpoints = useBreakpoints(BREAKPOINTS);
const isMobile = breakpoints.smaller('tablet');
const isTablet = breakpoints.between('tablet', 'desktop');
const isDesktop = breakpoints.greaterOrEqual('desktop');
// 사이드바 상태
const sidebarVisible = ref(true);
const sidebarCollapsed = ref(false);
// 반응형 자동 조절
watch(isMobile, (mobile) => {
if (mobile) {
sidebarVisible.value = false;
sidebarCollapsed.value = false;
} else {
sidebarVisible.value = true;
}
}, { immediate: true });
watch(isTablet, (tablet) => {
if (tablet) sidebarCollapsed.value = true;
}, { immediate: true });
watch(isDesktop, (desktop) => {
if (desktop) {
sidebarVisible.value = true;
sidebarCollapsed.value = false;
}
}, { immediate: true });
function toggleSidebar() {
if (isMobile.value) {
sidebarVisible.value = !sidebarVisible.value;
} else {
sidebarCollapsed.value = !sidebarCollapsed.value;
}
}
// 모바일에서 라우트 변경 시 사이드바 닫기
const router = useRouter();
router.afterEach(() => {
if (isMobile.value) sidebarVisible.value = false;
});
const contentMarginLeft = computed(() => {
if (isMobile.value) return '0';
return sidebarCollapsed.value
? `${LAYOUT.sidebarCollapsedWidth}px`
: `${LAYOUT.sidebarWidth}px`;
});
</script>
<template>
<div class="app-layout">
<!-- Overlay (모바일 사이드바 열림 ) -->
<div
v-if="isMobile && sidebarVisible"
class="app-layout__overlay"
@click="sidebarVisible = false"
/>
<!-- Sidebar -->
<AppSidebar
:visible="sidebarVisible"
:collapsed="sidebarCollapsed"
:mobile="isMobile"
/>
<!-- Main -->
<div
class="app-layout__main"
:style="{ marginLeft: contentMarginLeft }"
>
<AppTopbar @toggle-sidebar="toggleSidebar" />
<main class="app-layout__content">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</main>
</div>
</div>
</template>
<style lang="scss" scoped>
@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);
}
}
}
// Page transition
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

파일 보기

@@ -0,0 +1,131 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import PanelMenu from 'primevue/panelmenu';
import { MENU_ITEMS, LAYOUT } from '@/core/constants/app.constants';
import { useCurrentUser } from '@/core/composables/useCurrentUser';
const props = defineProps<{
visible: boolean;
collapsed: boolean;
mobile: boolean;
}>();
const { currentUser } = useCurrentUser();
const router = useRouter();
// 역할 기반 메뉴 필터링
const filteredMenu = computed(() => {
const userRoles = currentUser.value?.roles ?? [];
return filterByRole(MENU_ITEMS, userRoles);
});
function filterByRole(items: typeof MENU_ITEMS, roles: string[]) {
return items
.filter((item) => item.roles.some((r) => roles.includes(r)))
.map((item) => ({
...item,
command: item.to ? () => router.push(item.to) : undefined,
items: item.items
? item.items
.filter((sub) => sub.roles.some((r) => roles.includes(r)))
.map((sub) => ({
...sub,
command: () => router.push(sub.to),
}))
: undefined,
}));
}
const sidebarWidth = computed(() =>
props.collapsed ? `${LAYOUT.sidebarCollapsedWidth}px` : `${LAYOUT.sidebarWidth}px`,
);
</script>
<template>
<aside
class="app-sidebar"
:class="{
'app-sidebar--visible': visible,
'app-sidebar--collapsed': collapsed,
'app-sidebar--mobile': mobile,
}"
:style="{ width: sidebarWidth }"
>
<!-- Logo -->
<div class="app-sidebar__header">
<img src="@/assets/images/logo.svg" alt="WTM" class="app-sidebar__logo" />
<span v-if="!collapsed" class="app-sidebar__title">WTM</span>
</div>
<!-- Menu -->
<nav class="app-sidebar__nav">
<PanelMenu :model="filteredMenu" class="app-sidebar__menu" />
</nav>
</aside>
</template>
<style lang="scss" scoped>
@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 :deep(.p-panelmenu-header-content span),
.app-sidebar__nav :deep(.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 :deep(.p-panelmenu) {
border: none;
background: transparent;
}
}
</style>

파일 보기

@@ -0,0 +1,102 @@
<script setup lang="ts">
import Button from 'primevue/button';
import Avatar from 'primevue/avatar';
import Menu from 'primevue/menu';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useCurrentUser } from '@/core/composables/useCurrentUser';
const emit = defineEmits<{ 'toggle-sidebar': [] }>();
const { currentUser } = useCurrentUser();
const router = useRouter();
const userMenu = ref();
const userMenuItems = [
{ label: '내 정보', icon: 'pi pi-user', command: () => router.push('/profile') },
{ label: '비밀번호 변경', icon: 'pi pi-key', command: () => router.push('/change-password') },
{ separator: true },
{ label: '로그아웃', icon: 'pi pi-sign-out', command: () => router.push('/login') },
];
function toggleUserMenu(event: Event) {
userMenu.value.toggle(event);
}
</script>
<template>
<header class="app-topbar">
<div class="app-topbar__left">
<Button
icon="pi pi-bars"
text
rounded
severity="secondary"
@click="emit('toggle-sidebar')"
/>
</div>
<div class="app-topbar__right">
<!-- 알림 -->
<Button
icon="pi pi-bell"
text
rounded
severity="secondary"
class="app-topbar__notify-btn"
/>
<!-- 사용자 메뉴 -->
<Button text rounded @click="toggleUserMenu" class="app-topbar__user-btn">
<Avatar
:label="currentUser?.fullName?.charAt(0) ?? '?'"
shape="circle"
size="normal"
/>
<span class="app-topbar__username">{{ currentUser?.fullName }}</span>
</Button>
<Menu ref="userMenu" :model="userMenuItems" popup />
</div>
</header>
</template>
<style lang="scss" scoped>
@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;
}
}
}
</style>

파일 보기

@@ -0,0 +1,138 @@
<script setup lang="ts" generic="T extends Record<string, any>">
import DataTable from 'primevue/datatable';
import InputText from 'primevue/inputtext';
import IconField from 'primevue/iconfield';
import InputIcon from 'primevue/inputicon';
import { ref } from 'vue';
import { PAGINATION } from '@/core/constants/app.constants';
const props = withDefaults(
defineProps<{
value: T[];
loading?: boolean;
totalRecords?: number;
dataKey?: string;
globalFilterFields?: string[];
paginator?: boolean;
rowsPerPage?: number;
emptyMessage?: string;
selectionMode?: 'single' | 'multiple';
exportFilename?: string;
}>(),
{
loading: false,
dataKey: 'id',
paginator: true,
rowsPerPage: PAGINATION.defaultPageSize,
emptyMessage: '데이터가 없습니다.',
},
);
const emit = defineEmits<{
'row-select': [row: T];
'page': [event: any];
}>();
const globalFilter = ref('');
const dt = ref();
function exportCSV() {
dt.value?.exportCSV();
}
defineExpose({ exportCSV });
</script>
<template>
<div class="crud-table">
<!-- Toolbar -->
<div class="crud-table__toolbar">
<div class="crud-table__toolbar-left">
<slot name="toolbar-left" />
</div>
<div class="crud-table__toolbar-right">
<slot name="toolbar-right" />
<IconField v-if="globalFilterFields?.length">
<InputIcon class="pi pi-search" />
<InputText
v-model="globalFilter"
placeholder="검색..."
size="small"
/>
</IconField>
</div>
</div>
<!-- DataTable -->
<DataTable
ref="dt"
:value="value"
:loading="loading"
:dataKey="dataKey"
:paginator="paginator"
:rows="rowsPerPage"
:rowsPerPageOptions="[...PAGINATION.pageSizeOptions]"
:totalRecords="totalRecords"
:globalFilterFields="globalFilterFields"
:globalFilter="globalFilter"
:selectionMode="selectionMode"
:exportFilename="exportFilename"
removableSort
stripedRows
showGridlines
responsiveLayout="scroll"
size="small"
class="crud-table__datatable"
@row-select="(e: any) => emit('row-select', e.data)"
@page="(e: any) => emit('page', e)"
>
<template #empty>
<div class="crud-table__empty">
<i class="pi pi-inbox" style="font-size: 2rem" />
<p>{{ emptyMessage }}</p>
</div>
</template>
<slot />
</DataTable>
</div>
</template>
<style lang="scss" scoped>
@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;
}
}
}
</style>

파일 보기

@@ -0,0 +1,81 @@
<script setup lang="ts">
import Dialog from 'primevue/dialog';
import Button from 'primevue/button';
const props = withDefaults(
defineProps<{
visible: boolean;
title: string;
width?: string;
loading?: boolean;
submitLabel?: string;
cancelLabel?: string;
}>(),
{
width: '540px',
loading: false,
submitLabel: '저장',
cancelLabel: '취소',
},
);
const emit = defineEmits<{
'update:visible': [value: boolean];
submit: [];
}>();
function close() {
emit('update:visible', false);
}
</script>
<template>
<Dialog
:visible="visible"
:header="title"
:style="{ width: width, maxWidth: '95vw' }"
modal
:closable="!loading"
:draggable="false"
@update:visible="emit('update:visible', $event)"
>
<div class="form-dialog__body">
<slot />
</div>
<template #footer>
<div class="form-dialog__footer">
<Button
:label="cancelLabel"
severity="secondary"
text
:disabled="loading"
@click="close"
/>
<Button
:label="submitLabel"
:loading="loading"
@click="emit('submit')"
/>
</div>
</template>
</Dialog>
</template>
<style lang="scss" scoped>
@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;
}
}
</style>

파일 보기

@@ -0,0 +1,58 @@
<script setup lang="ts">
defineProps<{
title: string;
subtitle?: string;
}>();
</script>
<template>
<div class="page-header">
<div class="page-header__text">
<h1 class="page-header__title">{{ title }}</h1>
<p v-if="subtitle" class="page-header__subtitle">{{ subtitle }}</p>
</div>
<div class="page-header__actions">
<slot name="actions" />
</div>
</div>
</template>
<style lang="scss" scoped>
@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;
}
}
}
</style>

파일 보기

@@ -0,0 +1,7 @@
<template>
<div style="text-align: center; padding: 4rem;">
<h1>404</h1>
<p>페이지를 찾을 없습니다.</p>
<router-link to="/">홈으로</router-link>
</div>
</template>

파일 보기

@@ -0,0 +1,20 @@
import { computed } from 'vue';
import { useAuthStore } from '@/modules/auth/auth.store';
export function useCurrentUser() {
const authStore = useAuthStore();
const currentUser = computed(() => authStore.currentUser);
const isAuthenticated = computed(() => !!authStore.currentUser);
const roles = computed(() => authStore.currentUser?.roles ?? []);
function hasRole(role: string): boolean {
return roles.value.includes(role);
}
function hasAnyRole(requiredRoles: string[]): boolean {
return requiredRoles.some((r) => roles.value.includes(r));
}
return { currentUser, isAuthenticated, roles, hasRole, hasAnyRole };
}

파일 보기

@@ -0,0 +1,143 @@
// 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: 'warn' },
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: 'warn' },
};
// 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 const MENU_ITEMS = [
{
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'],
},
];

9
wtm-frontend/src/env.d.ts 벤더링됨 일반 파일
파일 보기

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string;
readonly VITE_APP_TITLE: string;
readonly VITE_APP_ENV: 'development' | 'staging' | 'production';
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

파일 보기

@@ -0,0 +1,5 @@
import type { RouteRecordRaw } from 'vue-router';
export const approvalRoutes: RouteRecordRaw[] = [
{ path: '/approvals', name: 'approval-pending', component: () => import('./views/ApprovalPendingView.vue'), meta: { title: '결재 대기' } },
{ path: '/approvals/history', name: 'approval-history', component: () => import('./views/ApprovalHistoryView.vue'), meta: { title: '결재 이력' } },
];

파일 보기

@@ -0,0 +1,12 @@
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: (p?: Record<string, unknown>) => api.get(`${BASE}/history`, { params: p }),
getOverdue: () => api.get(`${BASE}/overdue`),
};

파일 보기

@@ -0,0 +1,7 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
export const useApprovalStore = defineStore('approval', () => {
const pending = ref<unknown[]>([]);
const loading = ref(false);
return { pending, loading };
});

파일 보기

@@ -0,0 +1,2 @@
export interface Approval { id: number; timesheetId: number; requesterId: number; status: string; submittedAt: string; }
export interface ApprovalLine { id: number; approverId: number; approvalOrder: number; roleCode: string; status: string; }

파일 보기

@@ -0,0 +1,128 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import Dialog from 'primevue/dialog';
import Button from 'primevue/button';
import Textarea from 'primevue/textarea';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import Tag from 'primevue/tag';
import Divider from 'primevue/divider';
import { TIMESHEET_STATUS } from '@/core/constants/app.constants';
const props = defineProps<{
visible: boolean;
approval: any;
loading?: boolean;
}>();
const emit = defineEmits<{
'update:visible': [value: boolean];
approve: [comment: string];
reject: [comment: string];
}>();
const comment = ref('');
watch(
() => props.visible,
(v) => {
if (v) comment.value = '';
},
);
function statusSeverity(status: string) {
return (TIMESHEET_STATUS as Record<string, any>)[status]?.severity ?? 'secondary';
}
function statusLabel(status: string) {
return (TIMESHEET_STATUS as Record<string, any>)[status]?.label ?? status;
}
</script>
<template>
<Dialog
:visible="visible"
header="결재 상세"
:style="{ width: '720px', maxWidth: '95vw' }"
modal
:closable="!loading"
@update:visible="emit('update:visible', $event)"
>
<div v-if="approval" class="approval-detail">
<!-- Summary info -->
<div class="approval-detail__info">
<div class="form-grid">
<div class="col-4">
<div class="form-field">
<label class="form-field__label">요청자</label>
<span>{{ approval.requesterName ?? '-' }}</span>
</div>
</div>
<div class="col-4">
<div class="form-field">
<label class="form-field__label">기간</label>
<span>{{ approval.weekStartDate ?? '-' }} ~ {{ approval.weekEndDate ?? '-' }}</span>
</div>
</div>
<div class="col-4">
<div class="form-field">
<label class="form-field__label">상태</label>
<Tag :value="statusLabel(approval.status)" :severity="statusSeverity(approval.status)" />
</div>
</div>
</div>
</div>
<Divider />
<!-- Timesheet entries table -->
<DataTable :value="approval.entries ?? []" size="small" stripedRows>
<Column field="entryDate" header="일자" sortable style="min-width: 100px" />
<Column field="entryType" header="유형" style="min-width: 100px" />
<Column field="projectName" header="프로젝트" style="min-width: 120px" />
<Column field="hours" header="시수" style="min-width: 70px">
<template #body="{ data }">{{ data.hours }}h</template>
</Column>
<Column field="remark" header="비고" style="min-width: 100px" />
<template #empty>
<div style="text-align: center; padding: 1rem; color: var(--p-text-muted-color);">
시수 항목이 없습니다.
</div>
</template>
</DataTable>
<div style="text-align: right; margin-top: 0.5rem; font-weight: 600;">
시수: {{ approval.totalHours ?? 0 }}h
</div>
<Divider />
<!-- Comment -->
<div class="form-field">
<label class="form-field__label">코멘트</label>
<Textarea v-model="comment" rows="3" placeholder="코멘트를 입력하세요 (반려 시 필수)" fluid />
</div>
</div>
<template #footer>
<div style="display: flex; justify-content: flex-end; gap: 0.5rem;">
<Button label="닫기" severity="secondary" text :disabled="loading" @click="emit('update:visible', false)" />
<Button label="반려" severity="danger" icon="pi pi-times" :loading="loading" @click="emit('reject', comment)" />
<Button label="승인" severity="success" icon="pi pi-check" :loading="loading" @click="emit('approve', comment)" />
</div>
</template>
</Dialog>
</template>
<style lang="scss" scoped>
@use '@/assets/styles/variables' as *;
.approval-detail {
display: flex;
flex-direction: column;
gap: $space-md;
&__info {
padding: $space-sm 0;
}
}
</style>

파일 보기

@@ -0,0 +1 @@
<template><div class="card"><h1>결재 이력</h1></div></template>

파일 보기

@@ -0,0 +1,166 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import Column from 'primevue/column';
import Button from 'primevue/button';
import Tag from 'primevue/tag';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import BasePageHeader from '@/core/components/BasePageHeader.vue';
import BaseCrudTable from '@/core/components/BaseCrudTable.vue';
import ApprovalDetailDialog from '../components/ApprovalDetailDialog.vue';
import { approvalService } from '../approval.service';
import { TIMESHEET_STATUS } from '@/core/constants/app.constants';
const toast = useToast();
const confirm = useConfirm();
const items = ref<any[]>([]);
const loading = ref(false);
const selectedItems = ref<any[]>([]);
const detailVisible = ref(false);
const detailLoading = ref(false);
const selectedApproval = ref<any>(null);
async function loadPending() {
loading.value = true;
try {
const { data } = await approvalService.getPending();
items.value = Array.isArray(data) ? data : (data as any).items ?? [];
} catch {
toast.add({ severity: 'error', summary: '오류', detail: '결재 대기 목록 로드 실패', life: 5000 });
} finally {
loading.value = false;
}
}
async function openDetail(row: any) {
detailLoading.value = true;
detailVisible.value = true;
try {
const { data } = await approvalService.getById(row.id);
selectedApproval.value = data;
} catch {
selectedApproval.value = row;
} finally {
detailLoading.value = false;
}
}
async function onApprove(comment: string) {
if (!selectedApproval.value) return;
detailLoading.value = true;
try {
await approvalService.approve(selectedApproval.value.id, comment || undefined);
toast.add({ severity: 'success', summary: '승인', detail: '승인되었습니다.', life: 3000 });
detailVisible.value = false;
await loadPending();
} catch {
toast.add({ severity: 'error', summary: '오류', detail: '승인 실패', life: 5000 });
} finally {
detailLoading.value = false;
}
}
async function onReject(comment: string) {
if (!selectedApproval.value) return;
if (!comment.trim()) {
toast.add({ severity: 'warn', summary: '알림', detail: '반려 시 코멘트를 입력해주세요.', life: 3000 });
return;
}
detailLoading.value = true;
try {
await approvalService.reject(selectedApproval.value.id, comment);
toast.add({ severity: 'success', summary: '반려', detail: '반려 처리되었습니다.', life: 3000 });
detailVisible.value = false;
await loadPending();
} catch {
toast.add({ severity: 'error', summary: '오류', detail: '반려 실패', life: 5000 });
} finally {
detailLoading.value = false;
}
}
async function batchApprove() {
if (!selectedItems.value.length) {
toast.add({ severity: 'warn', summary: '알림', detail: '선택된 항목이 없습니다.', life: 3000 });
return;
}
confirm.require({
message: `${selectedItems.value.length}건을 일괄 승인하시겠습니까?`,
header: '일괄 승인',
icon: 'pi pi-check',
accept: async () => {
loading.value = true;
try {
const ids = selectedItems.value.map((i: any) => i.id);
await approvalService.batchApprove(ids);
toast.add({ severity: 'success', summary: '승인', detail: `${ids.length}건 승인 완료`, life: 3000 });
selectedItems.value = [];
await loadPending();
} catch {
toast.add({ severity: 'error', summary: '오류', detail: '일괄 승인 실패', life: 5000 });
} finally {
loading.value = false;
}
},
});
}
function statusSeverity(status: string) {
return (TIMESHEET_STATUS as Record<string, any>)[status]?.severity ?? 'secondary';
}
function statusLabel(status: string) {
return (TIMESHEET_STATUS as Record<string, any>)[status]?.label ?? status;
}
onMounted(loadPending);
</script>
<template>
<div class="approval-pending-view">
<BasePageHeader title="결재 대기" subtitle="시수 결재 요청 목록" />
<BaseCrudTable
:value="items"
:loading="loading"
:globalFilterFields="['requesterName', 'projectName', 'status']"
selectionMode="multiple"
>
<template #toolbar-left>
<Button label="일괄 승인" icon="pi pi-check-circle" severity="success" size="small" @click="batchApprove" />
</template>
<Column selectionMode="multiple" style="width: 40px" />
<Column field="requesterName" header="요청자" sortable style="min-width: 100px" />
<Column field="projectName" header="프로젝트" sortable style="min-width: 140px" />
<Column header="기간" style="min-width: 180px">
<template #body="{ data }">
{{ data.weekStartDate ?? '-' }} ~ {{ data.weekEndDate ?? '-' }}
</template>
</Column>
<Column field="totalHours" header="총시수" sortable style="min-width: 80px">
<template #body="{ data }">{{ data.totalHours ?? 0 }}h</template>
</Column>
<Column field="status" header="상태" sortable style="min-width: 90px">
<template #body="{ data }">
<Tag :value="statusLabel(data.status)" :severity="statusSeverity(data.status)" />
</template>
</Column>
<Column field="submittedAt" header="제출일" sortable style="min-width: 120px" />
<Column header="" style="width: 80px">
<template #body="{ data }">
<Button icon="pi pi-eye" text rounded severity="info" @click="openDetail(data)" />
</template>
</Column>
</BaseCrudTable>
<ApprovalDetailDialog
:visible="detailVisible"
:approval="selectedApproval"
:loading="detailLoading"
@update:visible="detailVisible = $event"
@approve="onApprove"
@reject="onReject"
/>
</div>
</template>

파일 보기

@@ -0,0 +1,7 @@
import type { RouteRecordRaw } from 'vue-router';
export const authRoutes: RouteRecordRaw[] = [
{ path: '/login', name: 'login', component: () => import('./views/LoginView.vue'), meta: { title: '로그인' } },
{ path: '/forgot-password', name: 'forgot-password', component: () => import('./views/ForgotPasswordView.vue'), meta: { title: '비밀번호 찾기' } },
{ path: '/change-password', name: 'change-password', component: () => import('./views/ChangePasswordView.vue'), meta: { title: '비밀번호 변경' } },
];

파일 보기

@@ -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`),
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,45 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { authService as tokenService } from '@/core/auth/auth.service';
import { authApi } from './auth.service';
import type { AuthUser } from '@/core/auth/auth.types';
export const useAuthStore = defineStore('auth', () => {
const currentUser = ref<AuthUser | null>(null);
const loading = ref(false);
const unreadCount = ref(0);
async function login(email: string, password: string) {
loading.value = true;
try {
const { data } = await authApi.login({ email, password });
tokenService.setTokens(data.accessToken, data.refreshToken);
currentUser.value = data.user;
} finally {
loading.value = false;
}
}
async function fetchMe() {
try {
const { data } = await authApi.me();
currentUser.value = data;
} catch {
logout();
}
}
function logout() {
tokenService.clearTokens();
currentUser.value = null;
window.location.href = '/login';
}
function $reset() {
currentUser.value = null;
loading.value = false;
unreadCount.value = 0;
}
return { currentUser, loading, unreadCount, login, fetchMe, logout, $reset };
});

파일 보기

@@ -0,0 +1 @@
export type { AuthUser, LoginRequest, LoginResponse } from '@/core/auth/auth.types';

파일 보기

@@ -0,0 +1,133 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import Password from 'primevue/password';
import Button from 'primevue/button';
import Card from 'primevue/card';
import Message from 'primevue/message';
import { authApi } from '../auth.service';
const router = useRouter();
const currentPassword = ref('');
const newPassword = ref('');
const confirmPassword = ref('');
const error = ref('');
const success = ref('');
const loading = ref(false);
async function onSubmit() {
error.value = '';
success.value = '';
if (newPassword.value !== confirmPassword.value) {
error.value = '새 비밀번호가 일치하지 않습니다.';
return;
}
if (newPassword.value.length < 8) {
error.value = '비밀번호는 최소 8자 이상이어야 합니다.';
return;
}
loading.value = true;
try {
await authApi.changePassword({
currentPassword: currentPassword.value,
newPassword: newPassword.value,
});
success.value = '비밀번호가 변경되었습니다.';
currentPassword.value = '';
newPassword.value = '';
confirmPassword.value = '';
setTimeout(() => router.push('/dashboard'), 1500);
} catch (e: any) {
error.value = e?.response?.data?.detail ?? '비밀번호 변경에 실패했습니다.';
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="change-password-page">
<Card class="change-password-page__card">
<template #title>비밀번호 변경</template>
<template #content>
<Message v-if="error" severity="error" :closable="false" style="width: 100%; margin-bottom: 1rem;">
{{ error }}
</Message>
<Message v-if="success" severity="success" :closable="false" style="width: 100%; margin-bottom: 1rem;">
{{ success }}
</Message>
<form class="change-password-page__form" @submit.prevent="onSubmit">
<div class="form-field">
<label class="form-field__label">현재 비밀번호</label>
<Password
v-model="currentPassword"
placeholder="현재 비밀번호 입력"
:feedback="false"
toggleMask
fluid
:inputStyle="{ width: '100%' }"
/>
</div>
<div class="form-field">
<label class="form-field__label"> 비밀번호</label>
<Password
v-model="newPassword"
placeholder="새 비밀번호 입력"
toggleMask
fluid
:inputStyle="{ width: '100%' }"
/>
</div>
<div class="form-field">
<label class="form-field__label"> 비밀번호 확인</label>
<Password
v-model="confirmPassword"
placeholder="새 비밀번호 다시 입력"
:feedback="false"
toggleMask
fluid
:inputStyle="{ width: '100%' }"
/>
</div>
<Button
type="submit"
label="비밀번호 변경"
icon="pi pi-check"
:loading="loading"
fluid
style="margin-top: 0.5rem;"
/>
</form>
</template>
</Card>
</div>
</template>
<style lang="scss" scoped>
.change-password-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 80vh;
padding: 1rem;
&__card {
width: 100%;
max-width: 480px;
}
&__form {
display: flex;
flex-direction: column;
gap: 1rem;
}
}
</style>

파일 보기

@@ -0,0 +1,5 @@
<template>
<div class="card">
<h1>비밀번호 찾기</h1>
</div>
</template>

파일 보기

@@ -0,0 +1,161 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import InputText from 'primevue/inputtext';
import Password from 'primevue/password';
import Button from 'primevue/button';
import Card from 'primevue/card';
import Message from 'primevue/message';
import { useAuthStore } from '../auth.store';
const authStore = useAuthStore();
const router = useRouter();
const email = ref('');
const password = ref('');
const error = ref('');
async function onLogin() {
error.value = '';
try {
await authStore.login(email.value, password.value);
router.push('/dashboard');
} catch (e: any) {
error.value = e?.response?.data?.detail ?? '로그인에 실패했습니다. 이메일과 비밀번호를 확인하세요.';
}
}
</script>
<template>
<div class="login-page">
<Card class="login-page__card">
<template #content>
<div class="login-page__content">
<!-- Logo -->
<div class="login-page__logo">
<i class="pi pi-clock" style="font-size: 2.5rem; color: var(--p-primary-color);" />
<h1 class="login-page__title">WTM</h1>
<p class="login-page__subtitle">Work Time Manager</p>
</div>
<!-- Error -->
<Message v-if="error" severity="error" :closable="false" style="width: 100%;">
{{ error }}
</Message>
<!-- Form -->
<form class="login-page__form" @submit.prevent="onLogin">
<div class="form-field">
<label class="form-field__label">이메일</label>
<InputText
v-model="email"
type="email"
placeholder="user@hanwha.com"
fluid
required
/>
</div>
<div class="form-field">
<label class="form-field__label">비밀번호</label>
<Password
v-model="password"
placeholder="비밀번호 입력"
:feedback="false"
toggleMask
fluid
:inputStyle="{ width: '100%' }"
/>
</div>
<Button
type="submit"
label="로그인"
icon="pi pi-sign-in"
:loading="authStore.loading"
fluid
class="login-page__submit"
/>
</form>
<div class="login-page__links">
<router-link to="/forgot-password" class="login-page__link">
비밀번호 찾기
</router-link>
</div>
</div>
</template>
</Card>
</div>
</template>
<style lang="scss" scoped>
@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;
:deep(.p-card-content) {
padding: 0;
}
}
&__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;
}
}
}
</style>

파일 보기

@@ -0,0 +1,4 @@
import type { RouteRecordRaw } from 'vue-router';
export const dashboardRoutes: RouteRecordRaw[] = [
{ path: '/dashboard', name: 'dashboard', component: () => import('./views/DashboardView.vue'), meta: { title: '대시보드' } },
];

파일 보기

@@ -0,0 +1,6 @@
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 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
export const useDashboardStore = defineStore('dashboard', () => {
const loading = ref(false);
return { loading };
});

파일 보기

@@ -0,0 +1 @@
export interface DashboardStat { label: string; value: number; icon: string; trend?: number; }

파일 보기

@@ -0,0 +1,236 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import Card from 'primevue/card';
import Chart from 'primevue/chart';
import Tag from 'primevue/tag';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import ProgressSpinner from 'primevue/progressspinner';
import BasePageHeader from '@/core/components/BasePageHeader.vue';
import { dashboardService } from '../dashboard.service';
import { TIMESHEET_STATUS } from '@/core/constants/app.constants';
import type { DashboardStat } from '../dashboard.types';
const loading = ref(false);
const stats = ref<DashboardStat[]>([]);
const weeklyHoursData = ref<any>(null);
const pendingApprovals = ref<any[]>([]);
const chartOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
},
scales: {
y: { beginAtZero: true, title: { display: true, text: '시간 (h)' } },
x: { title: { display: true, text: '요일' } },
},
}));
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' },
];
onMounted(async () => {
loading.value = true;
try {
const { data } = await dashboardService.getDashboard();
stats.value = data.stats ?? defaultStats;
pendingApprovals.value = data.pendingApprovals ?? [];
weeklyHoursData.value = {
labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
datasets: [
{
label: '시수',
backgroundColor: 'var(--p-primary-color)',
data: data.weeklyHours ?? [0, 0, 0, 0, 0, 0],
},
],
};
} catch {
stats.value = defaultStats;
weeklyHoursData.value = {
labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
datasets: [{ label: '시수', backgroundColor: 'var(--p-primary-color)', data: [0, 0, 0, 0, 0, 0] }],
};
} finally {
loading.value = false;
}
});
function statusSeverity(status: string) {
return (TIMESHEET_STATUS as Record<string, any>)[status]?.severity ?? 'secondary';
}
function statusLabel(status: string) {
return (TIMESHEET_STATUS as Record<string, any>)[status]?.label ?? status;
}
</script>
<template>
<div class="dashboard-view">
<BasePageHeader title="대시보드" subtitle="금주 시수 현황 및 결재 현황" />
<div v-if="loading" class="dashboard-view__loading">
<ProgressSpinner />
</div>
<template v-else>
<!-- Stat Cards -->
<div class="dashboard-view__stats">
<Card v-for="(stat, idx) in stats" :key="idx" class="dashboard-view__stat-card">
<template #content>
<div class="stat-card">
<div class="stat-card__icon">
<i :class="stat.icon" />
</div>
<div class="stat-card__info">
<span class="stat-card__value">{{ stat.value }}</span>
<span class="stat-card__label">{{ stat.label }}</span>
</div>
<div v-if="stat.trend != null" class="stat-card__trend" :class="{ 'stat-card__trend--up': stat.trend > 0, 'stat-card__trend--down': stat.trend < 0 }">
<i :class="stat.trend > 0 ? 'pi pi-arrow-up' : stat.trend < 0 ? 'pi pi-arrow-down' : 'pi pi-minus'" />
{{ Math.abs(stat.trend) }}%
</div>
</div>
</template>
</Card>
</div>
<!-- 2-column grid -->
<div class="dashboard-view__grid">
<!-- Weekly Hours Chart -->
<Card class="dashboard-view__chart-card">
<template #title>금주 시수 현황</template>
<template #content>
<div class="dashboard-view__chart-wrapper">
<Chart v-if="weeklyHoursData" type="bar" :data="weeklyHoursData" :options="chartOptions" />
</div>
</template>
</Card>
<!-- Pending Approvals -->
<Card class="dashboard-view__approvals-card">
<template #title>결재 대기 목록</template>
<template #content>
<DataTable :value="pendingApprovals" :rows="5" :paginator="pendingApprovals.length > 5" size="small" stripedRows>
<Column field="requesterName" header="요청자" />
<Column field="projectName" header="프로젝트" />
<Column field="totalHours" header="시수">
<template #body="{ data }">{{ data.totalHours }}h</template>
</Column>
<Column field="status" header="상태">
<template #body="{ data }">
<Tag :value="statusLabel(data.status)" :severity="statusSeverity(data.status)" />
</template>
</Column>
<template #empty>
<div style="text-align: center; padding: 1rem; color: var(--p-text-muted-color);">
결재 대기 건이 없습니다.
</div>
</template>
</DataTable>
</template>
</Card>
</div>
</template>
</div>
</template>
<style lang="scss" scoped>
@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;
}
}
&__stat-card {
:deep(.p-card-body) {
padding: $space-md;
}
:deep(.p-card-content) {
padding: 0;
}
}
&__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; }
}
}
</style>

파일 보기

@@ -0,0 +1,97 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import InputText from 'primevue/inputtext';
import Select from 'primevue/select';
import DatePicker from 'primevue/datepicker';
import BaseFormDialog from '@/core/components/BaseFormDialog.vue';
import type { Project } from '../project.types';
const props = defineProps<{
visible: boolean;
project: Partial<Project> | null;
loading?: boolean;
}>();
const emit = defineEmits<{
'update:visible': [value: boolean];
save: [data: Partial<Project>];
}>();
const form = ref<Partial<Project>>({});
const typeOptions = [
{ label: 'EPC', value: 'EPC' },
{ label: 'PMC', value: 'PMC' },
{ label: 'FEED', value: 'FEED' },
{ label: 'Other', value: 'OTHER' },
];
const statusOptions = [
{ label: '진행중', value: 'ACTIVE' },
{ label: '종료', value: 'CLOSED' },
{ label: '보류', value: 'HOLD' },
];
watch(
() => props.visible,
(v) => {
if (v) {
form.value = props.project ? { ...props.project } : { status: 'ACTIVE' };
}
},
);
function onSubmit() {
emit('save', { ...form.value });
}
</script>
<template>
<BaseFormDialog
:visible="visible"
:title="project?.id ? '프로젝트 수정' : '프로젝트 등록'"
width="680px"
:loading="loading"
@update:visible="emit('update:visible', $event)"
@submit="onSubmit"
>
<div class="form-grid">
<div class="col-4">
<div class="form-field">
<label class="form-field__label form-field__label--required">프로젝트 코드</label>
<InputText v-model="form.projectCode" placeholder="EPU-2025-001" fluid />
</div>
</div>
<div class="col-8">
<div class="form-field">
<label class="form-field__label form-field__label--required">프로젝트명</label>
<InputText v-model="form.name" placeholder="프로젝트명 입력" fluid />
</div>
</div>
<div class="col-6">
<div class="form-field">
<label class="form-field__label form-field__label--required">유형</label>
<Select v-model="form.projectType" :options="typeOptions" optionLabel="label" optionValue="value" placeholder="선택" fluid />
</div>
</div>
<div class="col-6">
<div class="form-field">
<label class="form-field__label form-field__label--required">상태</label>
<Select v-model="form.status" :options="statusOptions" optionLabel="label" optionValue="value" placeholder="선택" fluid />
</div>
</div>
<div class="col-6">
<div class="form-field">
<label class="form-field__label">시작일</label>
<DatePicker :modelValue="form.startDate ? new Date(form.startDate) : null" dateFormat="yy-mm-dd" placeholder="YYYY-MM-DD" fluid @update:modelValue="form.startDate = $event ? ($event as Date).toISOString().slice(0, 10) : undefined" />
</div>
</div>
<div class="col-6">
<div class="form-field">
<label class="form-field__label">종료일</label>
<DatePicker :modelValue="form.endDate ? new Date(form.endDate) : null" dateFormat="yy-mm-dd" placeholder="YYYY-MM-DD" fluid @update:modelValue="form.endDate = $event ? ($event as Date).toISOString().slice(0, 10) : undefined" />
</div>
</div>
</div>
</BaseFormDialog>
</template>

파일 보기

@@ -0,0 +1,5 @@
import type { RouteRecordRaw } from 'vue-router';
export const projectRoutes: RouteRecordRaw[] = [
{ path: '/projects', name: 'project-list', component: () => import('./views/ProjectListView.vue'), meta: { title: '프로젝트' } },
{ path: '/projects/:id', name: 'project-detail', component: () => import('./views/ProjectDetailView.vue'), meta: { title: '프로젝트 상세' } },
];

파일 보기

@@ -0,0 +1,16 @@
import api from '@/core/api/axios';
const BASE = '/api/wtm/projects';
export const projectService = {
getAll: (params?: Record<string, unknown>) => api.get(BASE, { params }),
create: (data: unknown) => api.post(BASE, data),
getById: (id: number) => api.get(`${BASE}/${id}`),
update: (id: number, data: unknown) => api.put(`${BASE}/${id}`, data),
getMy: () => api.get(`${BASE}/my`),
getMembers: (id: number) => api.get(`${BASE}/${id}/members`),
addMember: (id: number, data: unknown) => api.post(`${BASE}/${id}/members`, data),
getAssignments: (id: number) => api.get(`${BASE}/${id}/assignments`),
createAssignment: (id: number, data: unknown) => api.post(`${BASE}/${id}/assignments`, data),
updateAssignment: (id: number, aid: number, data: unknown) => api.put(`${BASE}/${id}/assignments/${aid}`, data),
deleteAssignment: (id: number, aid: number) => api.delete(`${BASE}/${id}/assignments/${aid}`),
getAvailable: (id: number) => api.get(`${BASE}/${id}/assignments/available`),
};

파일 보기

@@ -0,0 +1,7 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
export const useProjectStore = defineStore('project', () => {
const projects = ref<unknown[]>([]);
const loading = ref(false);
return { projects, loading };
});

파일 보기

@@ -0,0 +1 @@
export interface Project { id: number; projectCode: string; name: string; projectType: string; status: string; startDate?: string; endDate?: string; pmUserId?: number; }

파일 보기

@@ -0,0 +1 @@
<template><div class="card"><h1>프로젝트 상세</h1></div></template>

파일 보기

@@ -0,0 +1,125 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import Column from 'primevue/column';
import Button from 'primevue/button';
import Tag from 'primevue/tag';
import { useToast } from 'primevue/usetoast';
import BasePageHeader from '@/core/components/BasePageHeader.vue';
import BaseCrudTable from '@/core/components/BaseCrudTable.vue';
import ProjectFormDialog from '../components/ProjectFormDialog.vue';
import { projectService } from '../project.service';
import { PROJECT_STATUS } from '@/core/constants/app.constants';
import type { Project } from '../project.types';
const toast = useToast();
const projects = ref<Project[]>([]);
const loading = ref(false);
const dialogVisible = ref(false);
const dialogLoading = ref(false);
const selectedProject = ref<Partial<Project> | null>(null);
async function loadProjects() {
loading.value = true;
try {
const { data } = await projectService.getAll();
projects.value = (data as any).items ?? data;
} catch {
toast.add({ severity: 'error', summary: '오류', detail: '프로젝트 목록 로드 실패', life: 5000 });
} finally {
loading.value = false;
}
}
function openCreate() {
selectedProject.value = null;
dialogVisible.value = true;
}
function openEdit(project: Project) {
selectedProject.value = { ...project };
dialogVisible.value = true;
}
async function onSave(data: Partial<Project>) {
dialogLoading.value = true;
try {
if (data.id) {
await projectService.update(data.id, data);
toast.add({ severity: 'success', summary: '성공', detail: '프로젝트가 수정되었습니다.', life: 3000 });
} else {
await projectService.create(data);
toast.add({ severity: 'success', summary: '성공', detail: '프로젝트가 등록되었습니다.', life: 3000 });
}
dialogVisible.value = false;
await loadProjects();
} catch {
toast.add({ severity: 'error', summary: '오류', detail: '저장 실패', life: 5000 });
} finally {
dialogLoading.value = false;
}
}
function statusSeverity(status: string) {
return (PROJECT_STATUS as Record<string, any>)[status]?.severity ?? 'secondary';
}
function statusLabel(status: string) {
return (PROJECT_STATUS as Record<string, any>)[status]?.label ?? status;
}
function typeTag(type: string) {
const map: Record<string, string> = { EPC: 'info', PMC: 'warn', FEED: 'success', OTHER: 'secondary' };
return map[type] ?? 'secondary';
}
onMounted(loadProjects);
</script>
<template>
<div class="project-list-view">
<BasePageHeader title="프로젝트 목록" subtitle="프로젝트 조회 및 관리">
<template #actions>
<Button label="등록" icon="pi pi-plus" @click="openCreate" />
</template>
</BasePageHeader>
<BaseCrudTable
:value="projects"
:loading="loading"
:globalFilterFields="['projectCode', 'name', 'projectType', 'status']"
@row-select="openEdit"
>
<Column field="projectCode" header="프로젝트코드" sortable style="min-width: 140px" />
<Column field="name" header="이름" sortable style="min-width: 180px" />
<Column field="projectType" header="유형" sortable style="min-width: 90px">
<template #body="{ data }">
<Tag :value="data.projectType" :severity="typeTag(data.projectType)" />
</template>
</Column>
<Column field="status" header="상태" sortable style="min-width: 90px">
<template #body="{ data }">
<Tag :value="statusLabel(data.status)" :severity="statusSeverity(data.status)" />
</template>
</Column>
<Column field="pmUserId" header="PM" sortable style="min-width: 100px" />
<Column header="기간" style="min-width: 180px">
<template #body="{ data }">
{{ data.startDate ?? '-' }} ~ {{ data.endDate ?? '-' }}
</template>
</Column>
<Column header="" style="width: 80px">
<template #body="{ data }">
<Button icon="pi pi-pencil" text rounded severity="info" @click="openEdit(data)" />
</template>
</Column>
</BaseCrudTable>
<ProjectFormDialog
:visible="dialogVisible"
:project="selectedProject"
:loading="dialogLoading"
@update:visible="dialogVisible = $event"
@save="onSave"
/>
</div>
</template>

파일 보기

@@ -0,0 +1,4 @@
import type { RouteRecordRaw } from 'vue-router';
export const reportRoutes: RouteRecordRaw[] = [
{ path: '/reports', name: 'reports', component: () => import('./views/ReportView.vue'), meta: { title: '리포트' } },
];

파일 보기

@@ -0,0 +1,10 @@
import api from '@/core/api/axios';
const BASE = '/api/wtm/reports';
export const reportService = {
getProjectHours: (p: Record<string, unknown>) => api.get(`${BASE}/project-hours`, { params: p }),
exportProjectHours: (p: Record<string, unknown>) => api.get(`${BASE}/project-hours/export`, { params: p, responseType: 'blob' }),
getWbsHours: (p: Record<string, unknown>) => api.get(`${BASE}/wbs-hours`, { params: p }),
exportWbsHours: (p: Record<string, unknown>) => api.get(`${BASE}/wbs-hours/export`, { params: p, responseType: 'blob' }),
getPhaseRatio: (p: Record<string, unknown>) => api.get(`${BASE}/phase-ratio`, { params: p }),
getNpRatio: (p: Record<string, unknown>) => api.get(`${BASE}/np-ratio`, { params: p }),
};

파일 보기

@@ -0,0 +1,3 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
export const useReportStore = defineStore('report', () => { const loading = ref(false); return { loading }; });

파일 보기

@@ -0,0 +1 @@
export interface ReportFilter { projectId?: number; from?: string; to?: string; groupBy?: string; wbsLevel?: number; }

파일 보기

@@ -0,0 +1,221 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import Card from 'primevue/card';
import Select from 'primevue/select';
import DatePicker from 'primevue/datepicker';
import Button from 'primevue/button';
import Chart from 'primevue/chart';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import ProgressSpinner from 'primevue/progressspinner';
import { useToast } from 'primevue/usetoast';
import BasePageHeader from '@/core/components/BasePageHeader.vue';
import { reportService } from '../report.service';
import { projectService } from '@/modules/project/project.service';
import type { ReportFilter } from '../report.types';
const toast = useToast();
const loading = ref(false);
const projects = ref<any[]>([]);
const filter = ref<ReportFilter>({ groupBy: 'project' });
const fromDate = ref<Date | null>(null);
const toDate = ref<Date | null>(null);
const chartData = ref<any>(null);
const tableData = ref<any[]>([]);
const groupByOptions = [
{ label: '프로젝트별', value: 'project' },
{ label: 'WBS별', value: 'wbs' },
{ label: '사용자별', value: 'user' },
{ label: '월별', value: 'month' },
];
const chartOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'top' as const },
},
scales: {
y: { beginAtZero: true, title: { display: true, text: '시간 (h)' } },
},
}));
async function loadProjects() {
try {
const { data } = await projectService.getAll();
projects.value = ((data as any).items ?? data ?? []).map((p: any) => ({
label: `${p.projectCode} - ${p.name}`,
value: p.id,
}));
} catch {
projects.value = [];
}
}
function formatDateStr(d: Date | null): string | undefined {
if (!d) return undefined;
return d.toISOString().slice(0, 10);
}
async function search() {
loading.value = true;
try {
const params: Record<string, unknown> = {
...filter.value,
from: formatDateStr(fromDate.value),
to: formatDateStr(toDate.value),
};
const { data } = await reportService.getProjectHours(params);
const result = data as any;
tableData.value = result.rows ?? result.items ?? [];
const labels = tableData.value.map((r: any) => r.label ?? r.name ?? r.projectCode ?? '');
const values = tableData.value.map((r: any) => r.totalHours ?? r.hours ?? 0);
chartData.value = {
labels,
datasets: [
{
label: '시수 (h)',
backgroundColor: 'var(--p-primary-color)',
data: values,
},
],
};
} catch {
toast.add({ severity: 'error', summary: '오류', detail: '리포트 조회 실패', life: 5000 });
chartData.value = null;
tableData.value = [];
} finally {
loading.value = false;
}
}
async function exportExcel() {
try {
const params: Record<string, unknown> = {
...filter.value,
from: formatDateStr(fromDate.value),
to: formatDateStr(toDate.value),
};
const { data } = await reportService.exportProjectHours(params);
const url = window.URL.createObjectURL(new Blob([data as any]));
const link = document.createElement('a');
link.href = url;
link.download = 'report.xlsx';
link.click();
window.URL.revokeObjectURL(url);
} catch {
toast.add({ severity: 'error', summary: '오류', detail: 'Excel 다운로드 실패', life: 5000 });
}
}
loadProjects();
</script>
<template>
<div class="report-view">
<BasePageHeader title="리포트" subtitle="시수 통계 및 분석">
<template #actions>
<Button label="Excel 다운로드" icon="pi pi-file-excel" severity="success" :disabled="!tableData.length" @click="exportExcel" />
</template>
</BasePageHeader>
<!-- Filter Panel -->
<Card class="report-view__filter">
<template #content>
<div class="form-grid">
<div class="col-4">
<div class="form-field">
<label class="form-field__label">프로젝트</label>
<Select
v-model="filter.projectId"
:options="projects"
optionLabel="label"
optionValue="value"
placeholder="전체"
showClear
fluid
/>
</div>
</div>
<div class="col-3">
<div class="form-field">
<label class="form-field__label">시작일</label>
<DatePicker v-model="fromDate" dateFormat="yy-mm-dd" placeholder="YYYY-MM-DD" fluid />
</div>
</div>
<div class="col-3">
<div class="form-field">
<label class="form-field__label">종료일</label>
<DatePicker v-model="toDate" dateFormat="yy-mm-dd" placeholder="YYYY-MM-DD" fluid />
</div>
</div>
<div class="col-2">
<div class="form-field">
<label class="form-field__label">그룹</label>
<Select v-model="filter.groupBy" :options="groupByOptions" optionLabel="label" optionValue="value" fluid />
</div>
</div>
<div class="col-12" style="display: flex; justify-content: flex-end;">
<Button label="조회" icon="pi pi-search" :loading="loading" @click="search" />
</div>
</div>
</template>
</Card>
<div v-if="loading" style="display: flex; justify-content: center; padding: 3rem;">
<ProgressSpinner />
</div>
<template v-else-if="chartData">
<!-- Chart -->
<Card class="report-view__chart">
<template #content>
<div style="height: 320px; position: relative;">
<Chart type="bar" :data="chartData" :options="chartOptions" />
</div>
</template>
</Card>
<!-- Table -->
<Card class="report-view__table">
<template #content>
<DataTable :value="tableData" size="small" stripedRows :paginator="tableData.length > 20" :rows="20" removableSort>
<Column field="label" :header="filter.groupBy === 'project' ? '프로젝트' : filter.groupBy === 'wbs' ? 'WBS' : filter.groupBy === 'user' ? '사용자' : '월'" sortable />
<Column field="totalHours" header="총 시수 (h)" sortable>
<template #body="{ data }">{{ (data.totalHours ?? data.hours ?? 0).toFixed(1) }}h</template>
</Column>
<Column field="userCount" header="인원" sortable v-if="filter.groupBy !== 'user'" />
<Column field="entryCount" header="건수" sortable />
</DataTable>
</template>
</Card>
</template>
<div v-else style="text-align: center; padding: 3rem; color: var(--p-text-muted-color);">
조회 조건을 설정하고 "조회" 버튼을 클릭하세요.
</div>
</div>
</template>
<style lang="scss" scoped>
@use '@/assets/styles/variables' as *;
.report-view {
&__filter {
margin-bottom: $space-md;
}
&__chart {
margin-bottom: $space-md;
}
&__table {
margin-bottom: $space-md;
}
}
</style>

파일 보기

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import InputText from 'primevue/inputtext';
import ToggleSwitch from 'primevue/toggleswitch';
import BaseFormDialog from '@/core/components/BaseFormDialog.vue';
import type { OverheadType } from '../settings.types';
const props = defineProps<{
visible: boolean;
item: Partial<OverheadType> | null;
loading?: boolean;
}>();
const emit = defineEmits<{
'update:visible': [value: boolean];
save: [data: Partial<OverheadType>];
}>();
const form = ref<Partial<OverheadType>>({});
watch(
() => props.visible,
(v) => {
if (v) {
form.value = props.item ? { ...props.item } : { isActive: true };
}
},
);
function onSubmit() {
emit('save', { ...form.value });
}
</script>
<template>
<BaseFormDialog
:visible="visible"
:title="item?.id ? 'Overhead Type 수정' : 'Overhead Type 등록'"
width="480px"
:loading="loading"
@update:visible="emit('update:visible', $event)"
@submit="onSubmit"
>
<div class="form-grid">
<div class="col-6">
<div class="form-field">
<label class="form-field__label form-field__label--required">코드</label>
<InputText v-model="form.code" placeholder="OH-001" fluid />
</div>
</div>
<div class="col-6">
<div class="form-field">
<label class="form-field__label form-field__label--required">이름</label>
<InputText v-model="form.name" placeholder="Overhead Type명" fluid />
</div>
</div>
<div class="col-12" v-if="item?.id">
<div class="form-field">
<label class="form-field__label">활성 상태</label>
<ToggleSwitch v-model="form.isActive" />
</div>
</div>
</div>
</BaseFormDialog>
</template>

파일 보기

@@ -0,0 +1,4 @@
import type { RouteRecordRaw } from 'vue-router';
export const settingsRoutes: RouteRecordRaw[] = [
{ path: '/settings', name: 'settings', component: () => import('./views/SettingsView.vue'), meta: { title: '시스템 설정' } },
];

파일 보기

@@ -0,0 +1,8 @@
import api from '@/core/api/axios';
export const settingsService = {
getOverheadTypes: () => api.get('/api/wtm/overhead-types'),
createOverheadType: (d: unknown) => api.post('/api/wtm/overhead-types', d),
updateOverheadType: (id: number, d: unknown) => api.put(`/api/wtm/overhead-types/${id}`, d),
getWorkRules: () => api.get('/api/wtm/work-rules'),
updateWorkRules: (d: unknown) => api.put('/api/wtm/work-rules', d),
};

파일 보기

@@ -0,0 +1,3 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
export const useSettingsStore = defineStore('settings', () => { const loading = ref(false); return { loading }; });

파일 보기

@@ -0,0 +1,2 @@
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,186 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import Tabs from 'primevue/tabs';
import TabList from 'primevue/tablist';
import Tab from 'primevue/tab';
import TabPanels from 'primevue/tabpanels';
import TabPanel from 'primevue/tabpanel';
import Column from 'primevue/column';
import Button from 'primevue/button';
import Tag from 'primevue/tag';
import Card from 'primevue/card';
import InputNumber from 'primevue/inputnumber';
import { useToast } from 'primevue/usetoast';
import BasePageHeader from '@/core/components/BasePageHeader.vue';
import BaseCrudTable from '@/core/components/BaseCrudTable.vue';
import OverheadTypeDialog from '../components/OverheadTypeDialog.vue';
import { settingsService } from '../settings.service';
import type { OverheadType, WorkRule } from '../settings.types';
const toast = useToast();
const activeTab = ref<string>('overhead');
const loading = ref(false);
// Overhead Types
const overheadTypes = ref<OverheadType[]>([]);
const dialogVisible = ref(false);
const dialogLoading = ref(false);
const selectedItem = ref<Partial<OverheadType> | null>(null);
async function loadOverheadTypes() {
loading.value = true;
try {
const { data } = await settingsService.getOverheadTypes();
overheadTypes.value = Array.isArray(data) ? data : (data as any).items ?? [];
} catch {
toast.add({ severity: 'error', summary: '오류', detail: 'Overhead Types 로드 실패', life: 5000 });
} finally {
loading.value = false;
}
}
function openCreateOH() {
selectedItem.value = null;
dialogVisible.value = true;
}
function openEditOH(item: OverheadType) {
selectedItem.value = { ...item };
dialogVisible.value = true;
}
async function onSaveOH(data: Partial<OverheadType>) {
dialogLoading.value = true;
try {
if (data.id) {
await settingsService.updateOverheadType(data.id, data);
toast.add({ severity: 'success', summary: '성공', detail: '수정되었습니다.', life: 3000 });
} else {
await settingsService.createOverheadType(data);
toast.add({ severity: 'success', summary: '성공', detail: '등록되었습니다.', life: 3000 });
}
dialogVisible.value = false;
await loadOverheadTypes();
} catch {
toast.add({ severity: 'error', summary: '오류', detail: '저장 실패', life: 5000 });
} finally {
dialogLoading.value = false;
}
}
// Work Rules
const workRule = ref<Partial<WorkRule>>({
minDailyHours: 8,
maxWeeklyHours: 52,
});
const ruleSaving = ref(false);
async function loadWorkRules() {
try {
const { data } = await settingsService.getWorkRules();
const rules = Array.isArray(data) ? data[0] : data;
if (rules) workRule.value = { ...rules };
} catch {
// keep defaults
}
}
async function saveWorkRules() {
ruleSaving.value = true;
try {
await settingsService.updateWorkRules(workRule.value);
toast.add({ severity: 'success', summary: '성공', detail: '근무 규칙이 저장되었습니다.', life: 3000 });
} catch {
toast.add({ severity: 'error', summary: '오류', detail: '저장 실패', life: 5000 });
} finally {
ruleSaving.value = false;
}
}
onMounted(() => {
loadOverheadTypes();
loadWorkRules();
});
</script>
<template>
<div class="settings-view">
<BasePageHeader title="시스템 설정" subtitle="Overhead Types 및 근무 규칙 관리" />
<Tabs v-model:value="activeTab">
<TabList>
<Tab value="overhead">Overhead Types</Tab>
<Tab value="rules">Work Rules</Tab>
</TabList>
<TabPanels>
<!-- Overhead Types Tab -->
<TabPanel value="overhead">
<BaseCrudTable
:value="overheadTypes"
:loading="loading"
:globalFilterFields="['code', 'name']"
:paginator="overheadTypes.length > 20"
>
<template #toolbar-left>
<Button label="등록" icon="pi pi-plus" size="small" @click="openCreateOH" />
</template>
<Column field="code" header="코드" sortable style="min-width: 120px" />
<Column field="name" header="이름" sortable style="min-width: 180px" />
<Column field="isActive" header="상태" style="min-width: 80px">
<template #body="{ data }">
<Tag :value="data.isActive ? '활성' : '비활성'" :severity="data.isActive ? 'success' : 'secondary'" />
</template>
</Column>
<Column header="" style="width: 80px">
<template #body="{ data }">
<Button icon="pi pi-pencil" text rounded severity="info" @click="openEditOH(data)" />
</template>
</Column>
</BaseCrudTable>
</TabPanel>
<!-- Work Rules Tab -->
<TabPanel value="rules">
<Card>
<template #content>
<div class="form-grid">
<div class="col-6">
<div class="form-field">
<label class="form-field__label"> 최소 시수 (h)</label>
<InputNumber v-model="workRule.minDailyHours" :min="0" :max="24" :step="0.5" :maxFractionDigits="1" fluid />
</div>
</div>
<div class="col-6">
<div class="form-field">
<label class="form-field__label"> 최대 시수 (h)</label>
<InputNumber v-model="workRule.maxWeeklyHours" :min="0" :max="168" :step="1" fluid />
</div>
</div>
<div class="col-6">
<div class="form-field">
<label class="form-field__label">Location</label>
<InputNumber v-model="workRule.id" disabled fluid />
<span class="form-field__hint">{{ workRule.location ?? 'Default' }}</span>
</div>
</div>
<div class="col-12" style="display: flex; justify-content: flex-end; margin-top: 1rem;">
<Button label="저장" icon="pi pi-save" :loading="ruleSaving" @click="saveWorkRules" />
</div>
</div>
</template>
</Card>
</TabPanel>
</TabPanels>
</Tabs>
<OverheadTypeDialog
:visible="dialogVisible"
:item="selectedItem"
:loading="dialogLoading"
@update:visible="dialogVisible = $event"
@save="onSaveOH"
/>
</div>
</template>

파일 보기

@@ -0,0 +1,4 @@
import type { RouteRecordRaw } from 'vue-router';
export const tealRoutes: RouteRecordRaw[] = [
{ path: '/teal', name: 'teal-list', component: () => import('./views/TealListView.vue'), meta: { title: 'TEAL 관리' } },
];

파일 보기

@@ -0,0 +1,8 @@
import api from '@/core/api/axios';
const BASE = '/api/wtm/projects';
export const tealService = {
upload: (projectId: number, file: File, effectiveDate: string) => { const f = new FormData(); f.append('file', file); f.append('effectiveDate', effectiveDate); return api.post(`${BASE}/${projectId}/teal/upload`, f); },
getVersions: (projectId: number) => api.get(`${BASE}/${projectId}/teal/versions`),
getActive: (projectId: number) => api.get(`${BASE}/${projectId}/teal/active`),
getByWbs: (projectId: number, wbsId: number) => api.get(`${BASE}/${projectId}/teal/by-wbs/${wbsId}`),
};

파일 보기

@@ -0,0 +1,6 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
export const useTealStore = defineStore('teal', () => {
const loading = ref(false);
return { loading };
});

파일 보기

@@ -0,0 +1 @@
export interface TealEntry { id: number; activityCode: string; activityName: string; discipline?: string; canonicalWbsId: number; }

파일 보기

@@ -0,0 +1 @@
<template><div class="card"><h1>TEAL 관리</h1></div></template>

파일 보기

@@ -0,0 +1,160 @@
<script setup lang="ts">
import { computed } from 'vue';
import InputNumber from 'primevue/inputnumber';
import Select from 'primevue/select';
import Button from 'primevue/button';
import { NP_CATEGORIES, TIMESHEET_RULES } from '@/core/constants/app.constants';
import type { EntryType } from '../timesheet.types';
const props = defineProps<{
entry: {
id?: number;
entryType: EntryType;
npCategory?: string;
otherProjectId?: number;
epcProjectId?: number;
canonicalWbsId?: number;
tealEntryId?: number;
hours: Record<string, number>;
remark?: string;
};
projects?: { id: number; name: string; projectCode: string }[];
wbsList?: { id: number; code: string; name: string }[];
tealList?: { id: number; code: string; name: string }[];
days: string[];
dayLabels: string[];
}>();
const emit = defineEmits<{
'update:entry': [entry: any];
remove: [];
}>();
const npCategoryOptions = NP_CATEGORIES.map((c) => ({ label: c.label, value: c.value }));
const rowTotal = computed(() => {
return props.days.reduce((sum, d) => sum + (props.entry.hours[d] ?? 0), 0);
});
function updateHour(day: string, val: number | null) {
const updated = { ...props.entry, hours: { ...props.entry.hours, [day]: val ?? 0 } };
emit('update:entry', updated);
}
function updateField(field: string, val: any) {
emit('update:entry', { ...props.entry, [field]: val });
}
</script>
<template>
<tr class="entry-row">
<!-- Selector columns -->
<td class="entry-row__selector">
<template v-if="entry.entryType === 'NON_PROJECT'">
<Select
:modelValue="entry.npCategory"
:options="npCategoryOptions"
optionLabel="label"
optionValue="value"
placeholder="카테고리"
size="small"
style="min-width: 120px"
@update:modelValue="updateField('npCategory', $event)"
/>
</template>
<template v-else-if="entry.entryType === 'OTHER_PROJECT'">
<Select
:modelValue="entry.otherProjectId"
:options="projects ?? []"
optionLabel="name"
optionValue="id"
placeholder="프로젝트"
size="small"
style="min-width: 140px"
@update:modelValue="updateField('otherProjectId', $event)"
/>
</template>
<template v-else>
<div style="display: flex; gap: 4px; flex-wrap: wrap;">
<Select
:modelValue="entry.epcProjectId"
:options="projects ?? []"
optionLabel="projectCode"
optionValue="id"
placeholder="프로젝트"
size="small"
style="min-width: 130px"
@update:modelValue="updateField('epcProjectId', $event)"
/>
<Select
:modelValue="entry.canonicalWbsId"
:options="wbsList ?? []"
optionLabel="code"
optionValue="id"
placeholder="WBS"
size="small"
style="min-width: 130px"
@update:modelValue="updateField('canonicalWbsId', $event)"
/>
<Select
:modelValue="entry.tealEntryId"
:options="tealList ?? []"
optionLabel="code"
optionValue="id"
placeholder="TEAL"
size="small"
style="min-width: 130px"
@update:modelValue="updateField('tealEntryId', $event)"
/>
</div>
</template>
</td>
<!-- Daily hour inputs -->
<td v-for="day in days" :key="day" class="entry-row__hour">
<InputNumber
:modelValue="entry.hours[day] ?? null"
:min="0"
:max="TIMESHEET_RULES.maxDailyHours"
:maxFractionDigits="1"
:step="0.5"
size="small"
:inputStyle="{ width: '60px', textAlign: 'center' }"
@update:modelValue="updateHour(day, $event)"
/>
</td>
<!-- Row total -->
<td class="entry-row__total">
<strong>{{ rowTotal.toFixed(1) }}h</strong>
</td>
<!-- Remove button -->
<td class="entry-row__action">
<Button icon="pi pi-trash" text rounded severity="danger" size="small" @click="emit('remove')" />
</td>
</tr>
</template>
<style lang="scss" scoped>
@use '@/assets/styles/variables' as *;
.entry-row {
&__selector {
padding: $space-xs $space-sm;
}
&__hour {
padding: $space-xs;
text-align: center;
}
&__total {
padding: $space-xs $space-sm;
text-align: center;
white-space: nowrap;
}
&__action {
padding: $space-xs;
text-align: center;
}
}
</style>

파일 보기

@@ -0,0 +1,6 @@
import type { RouteRecordRaw } from 'vue-router';
export const timesheetRoutes: RouteRecordRaw[] = [
{ path: '/timesheets', name: 'timesheet-week', component: () => import('./views/TimesheetWeekView.vue'), meta: { title: '시수 입력' } },
{ path: '/timesheets/history', name: 'timesheet-history', component: () => import('./views/TimesheetHistoryView.vue'), meta: { title: '시수 이력' } },
{ path: '/timesheets/upload', name: 'timesheet-upload', component: () => import('./views/TimesheetUploadView.vue'), meta: { title: 'Excel 업로드' } },
];

파일 보기

@@ -0,0 +1,12 @@
import api from '@/core/api/axios';
const BASE = '/api/wtm/timesheets';
export const timesheetService = {
getWeekly: (weekStart: string) => api.get(`${BASE}/week`, { params: { weekStart } }),
saveEntry: (tid: number, entry: unknown) => api.post(`${BASE}/${tid}/entries`, entry),
saveBatch: (tid: number, entries: unknown[]) => api.put(`${BASE}/${tid}/entries/batch`, entries),
deleteEntry: (tid: number, eid: number) => api.delete(`${BASE}/${tid}/entries/${eid}`),
submit: (tid: number) => api.post(`${BASE}/${tid}/submit`),
getHistory: (p?: Record<string, unknown>) => api.get(`${BASE}/history`, { params: p }),
uploadExcel: (file: File, weekStart: string) => { const f = new FormData(); f.append('file', file); f.append('weekStart', weekStart); return api.post(`${BASE}/upload`, f, { headers: { 'Content-Type': 'multipart/form-data' } }); },
downloadTemplate: () => api.get(`${BASE}/upload/template`, { responseType: 'blob' }),
};

파일 보기

@@ -0,0 +1,10 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useTimesheetStore = defineStore('timesheet', () => {
const current = ref<unknown>(null);
const loading = ref(false);
const saving = ref(false);
const totalHours = computed(() => 0);
function $reset() { current.value = null; loading.value = false; }
return { current, loading, saving, totalHours, $reset };
});

파일 보기

@@ -0,0 +1,4 @@
export type TimesheetStatus = 'DRAFT' | 'SUBMITTED' | 'DL_APPROVED' | 'APPROVED' | 'REJECTED';
export type EntryType = 'NON_PROJECT' | 'OTHER_PROJECT' | 'EPC';
export interface Timesheet { id: number; userId: number; weekStartDate: string; weekEndDate: string; status: TimesheetStatus; totalHours: number; entries: TimesheetEntry[]; }
export interface TimesheetEntry { id: number; entryType: EntryType; entryDate: string; hours: number; npCategory?: string; otherProjectId?: number; epcProjectId?: number; canonicalWbsId?: number; tealEntryId?: number; remark?: string; }

파일 보기

@@ -0,0 +1 @@
<template><div class="card"><h1>시수 이력</h1></div></template>

파일 보기

@@ -0,0 +1 @@
<template><div class="card"><h1>Excel 업로드</h1></div></template>

파일 보기

@@ -0,0 +1,464 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import Button from 'primevue/button';
import DatePicker from 'primevue/datepicker';
import Tabs from 'primevue/tabs';
import TabList from 'primevue/tablist';
import Tab from 'primevue/tab';
import TabPanels from 'primevue/tabpanels';
import TabPanel from 'primevue/tabpanel';
import Card from 'primevue/card';
import Message from 'primevue/message';
import Tag from 'primevue/tag';
import ProgressSpinner from 'primevue/progressspinner';
import { useToast } from 'primevue/usetoast';
import BasePageHeader from '@/core/components/BasePageHeader.vue';
import TimesheetEntryRow from '../components/TimesheetEntryRow.vue';
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';
const toast = useToast();
const loading = ref(false);
const saving = ref(false);
const submitting = ref(false);
const activeTab = ref<string>('NON_PROJECT');
// Week navigation
const weekStart = ref<Date>(getMonday(new Date()));
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;
}
const weekEnd = computed(() => addDays(weekStart.value, 5));
const weekLabel = computed(() => `${formatDate(weekStart.value)} ~ ${formatDate(weekEnd.value)}`);
const days = computed(() => {
return Array.from({ length: 6 }, (_, i) => formatDate(addDays(weekStart.value, i)));
});
const dayLabels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
function prevWeek() {
weekStart.value = addDays(weekStart.value, -7);
}
function nextWeek() {
weekStart.value = addDays(weekStart.value, 7);
}
// Data
const timesheet = ref<Timesheet | null>(null);
const projects = ref<any[]>([]);
const wbsList = ref<any[]>([]);
const tealList = ref<any[]>([]);
// Entry rows grouped by type
interface EntryRow {
_uid: number;
id?: number;
entryType: EntryType;
npCategory?: string;
otherProjectId?: number;
epcProjectId?: number;
canonicalWbsId?: number;
tealEntryId?: number;
hours: Record<string, number>;
remark?: string;
}
let uidCounter = 0;
const entryRows = ref<EntryRow[]>([]);
const npRows = computed(() => entryRows.value.filter((r) => r.entryType === 'NON_PROJECT'));
const otherRows = computed(() => entryRows.value.filter((r) => r.entryType === 'OTHER_PROJECT'));
const epcRows = computed(() => entryRows.value.filter((r) => r.entryType === 'EPC'));
function rowsForTab(tab: string) {
if (tab === 'NON_PROJECT') return npRows.value;
if (tab === 'OTHER_PROJECT') return otherRows.value;
return epcRows.value;
}
// Totals
const totalHours = computed(() => {
return entryRows.value.reduce((sum, row) => {
return sum + Object.values(row.hours).reduce((a, b) => a + b, 0);
}, 0);
});
const dailyTotals = computed(() => {
const totals: Record<string, number> = {};
for (const d of days.value) {
totals[d] = entryRows.value.reduce((sum, row) => sum + (row.hours[d] ?? 0), 0);
}
return totals;
});
// Warnings
const warnings = computed(() => {
const msgs: string[] = [];
for (const [date, total] of Object.entries(dailyTotals.value)) {
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.value > TIMESHEET_RULES.maxWeeklyHours) {
msgs.push(`주간 합계 ${totalHours.value}시간 - 최대 ${TIMESHEET_RULES.maxWeeklyHours}h 초과!`);
}
return msgs;
});
// Convert server entries to rows
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,
});
}
const row = grouped.get(key)!;
row.hours[e.entryDate] = e.hours;
}
return Array.from(grouped.values());
}
// Convert rows back to entries for saving
function rowsToEntries(): any[] {
const entries: any[] = [];
for (const row of entryRows.value) {
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) {
entryRows.value.push({
_uid: ++uidCounter,
entryType: type,
hours: {},
});
}
function removeRow(uid: number) {
entryRows.value = entryRows.value.filter((r) => r._uid !== uid);
}
function updateRow(uid: number, updated: any) {
const idx = entryRows.value.findIndex((r) => r._uid === uid);
if (idx >= 0) {
entryRows.value[idx] = { ...entryRows.value[idx], ...updated };
}
}
// Load
async function loadWeek() {
loading.value = true;
try {
const { data } = await timesheetService.getWeekly(formatDate(weekStart.value));
timesheet.value = data as Timesheet;
entryRows.value = entriesToRows((data as Timesheet).entries ?? []);
} catch {
timesheet.value = null;
entryRows.value = [];
} finally {
loading.value = false;
}
}
async function loadProjects() {
try {
const { data } = await projectService.getMy();
projects.value = (data as any).items ?? data ?? [];
} catch {
projects.value = [];
}
}
// Save
async function saveDraft() {
if (!timesheet.value) return;
saving.value = true;
try {
const entries = rowsToEntries();
await timesheetService.saveBatch(timesheet.value.id, entries);
toast.add({ severity: 'success', summary: '저장', detail: '임시 저장되었습니다.', life: 3000 });
await loadWeek();
} catch {
toast.add({ severity: 'error', summary: '오류', detail: '저장 실패', life: 5000 });
} finally {
saving.value = false;
}
}
async function submitTimesheet() {
if (!timesheet.value) return;
if (warnings.value.some((w) => w.includes('초과!'))) {
toast.add({ severity: 'error', summary: '오류', detail: '규칙 위반 항목이 있습니다. 수정 후 제출해주세요.', life: 5000 });
return;
}
submitting.value = true;
try {
const entries = rowsToEntries();
await timesheetService.saveBatch(timesheet.value.id, entries);
await timesheetService.submit(timesheet.value.id);
toast.add({ severity: 'success', summary: '제출', detail: '시수가 제출되었습니다. (결재 요청)', life: 3000 });
await loadWeek();
} catch {
toast.add({ severity: 'error', summary: '오류', detail: '제출 실패', life: 5000 });
} finally {
submitting.value = false;
}
}
const isEditable = computed(() => {
if (!timesheet.value) return true;
return timesheet.value.status === 'DRAFT' || timesheet.value.status === 'REJECTED';
});
const statusInfo = computed(() => {
if (!timesheet.value) return null;
return (TIMESHEET_STATUS as Record<string, any>)[timesheet.value.status];
});
watch(weekStart, () => loadWeek());
onMounted(() => {
loadWeek();
loadProjects();
});
</script>
<template>
<div class="timesheet-week-view">
<BasePageHeader title="시수 입력" :subtitle="weekLabel">
<template #actions>
<Tag v-if="statusInfo" :value="statusInfo.label" :severity="statusInfo.severity" />
</template>
</BasePageHeader>
<!-- Week Picker -->
<div class="timesheet-week-view__week-picker">
<Button icon="pi pi-chevron-left" text rounded @click="prevWeek" />
<DatePicker
v-model="weekStart"
dateFormat="yy-mm-dd"
:firstDayOfWeek="1"
style="width: 160px"
/>
<Button icon="pi pi-chevron-right" text rounded @click="nextWeek" />
</div>
<div v-if="loading" style="display: flex; justify-content: center; padding: 3rem;">
<ProgressSpinner />
</div>
<template v-else>
<!-- Tabs -->
<Tabs v-model:value="activeTab">
<TabList>
<Tab value="NON_PROJECT">{{ ENTRY_TYPES.NON_PROJECT.label }}</Tab>
<Tab value="OTHER_PROJECT">{{ ENTRY_TYPES.OTHER_PROJECT.label }}</Tab>
<Tab value="EPC">{{ ENTRY_TYPES.EPC.label }}</Tab>
</TabList>
<TabPanels>
<TabPanel v-for="tabKey in (['NON_PROJECT', 'OTHER_PROJECT', 'EPC'] as EntryType[])" :key="tabKey" :value="tabKey">
<div class="timesheet-week-view__table-wrapper">
<table class="timesheet-week-view__table">
<thead>
<tr>
<th style="min-width: 200px">{{ tabKey === 'NON_PROJECT' ? '카테고리' : tabKey === 'OTHER_PROJECT' ? '프로젝트' : '프로젝트 / WBS / TEAL' }}</th>
<th v-for="(label, i) in dayLabels" :key="i" style="width: 80px; text-align: center">
{{ label }}<br />
<small>{{ days[i]?.slice(5) }}</small>
</th>
<th style="width: 70px; text-align: center">합계</th>
<th style="width: 50px"></th>
</tr>
</thead>
<tbody>
<TimesheetEntryRow
v-for="row in rowsForTab(tabKey)"
:key="row._uid"
:entry="row"
:projects="projects"
:wbsList="wbsList"
:tealList="tealList"
:days="days"
:dayLabels="dayLabels"
@update:entry="updateRow(row._uid, $event)"
@remove="removeRow(row._uid)"
/>
<tr v-if="rowsForTab(tabKey).length === 0">
<td :colspan="dayLabels.length + 3" style="text-align: center; padding: 1.5rem; color: var(--p-text-muted-color);">
항목이 없습니다. 아래 버튼으로 추가하세요.
</td>
</tr>
</tbody>
<tfoot>
<tr>
<td><strong>소계</strong></td>
<td v-for="day in days" :key="day" style="text-align: center;">
{{ dailyTotals[day]?.toFixed(1) ?? '0.0' }}
</td>
<td style="text-align: center;"><strong>{{ totalHours.toFixed(1) }}h</strong></td>
<td></td>
</tr>
</tfoot>
</table>
</div>
<div style="margin-top: 0.5rem;">
<Button
:label="'+ 행 추가'"
text
size="small"
:disabled="!isEditable"
@click="addRow(tabKey)"
/>
</div>
</TabPanel>
</TabPanels>
</Tabs>
<!-- Summary Card -->
<Card class="timesheet-week-view__summary">
<template #content>
<div class="timesheet-week-view__summary-row">
<span>주간 합계: <strong>{{ totalHours.toFixed(1) }}</strong> / {{ TIMESHEET_RULES.maxWeeklyHours }}h</span>
<div class="timesheet-week-view__summary-actions">
<Button
label="임시 저장"
severity="secondary"
icon="pi pi-save"
:loading="saving"
:disabled="!isEditable"
@click="saveDraft"
/>
<Button
label="제출 (결재 요청)"
icon="pi pi-send"
:loading="submitting"
:disabled="!isEditable"
@click="submitTimesheet"
/>
</div>
</div>
</template>
</Card>
<!-- Warnings -->
<div v-if="warnings.length" class="timesheet-week-view__warnings">
<Message v-for="(w, i) in warnings" :key="i" :severity="w.includes('초과!') ? 'error' : 'warn'" :closable="false">
{{ w }}
</Message>
</div>
</template>
</div>
</template>
<style lang="scss" scoped>
@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;
}
}
</style>

파일 보기

@@ -0,0 +1,113 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import InputText from 'primevue/inputtext';
import Select from 'primevue/select';
import MultiSelect from 'primevue/multiselect';
import ToggleSwitch from 'primevue/toggleswitch';
import BaseFormDialog from '@/core/components/BaseFormDialog.vue';
import { ROLES } from '@/core/constants/app.constants';
import type { User } from '../user.types';
const props = defineProps<{
visible: boolean;
user: Partial<User> | null;
loading?: boolean;
}>();
const emit = defineEmits<{
'update:visible': [value: boolean];
save: [data: Partial<User>];
}>();
const form = ref<Partial<User>>({});
const roleOptions = Object.values(ROLES).map((r) => ({ label: r, value: r }));
const disciplineOptions = [
'Piping', 'Electrical', 'Instrument', 'Civil', 'Structural',
'Mechanical', 'Process', 'HSE', 'QA/QC', 'Other',
].map((d) => ({ label: d, value: d }));
watch(
() => props.visible,
(v) => {
if (v) {
form.value = props.user ? { ...props.user } : { roles: [], isActive: true };
}
},
);
function onSubmit() {
emit('save', { ...form.value });
}
</script>
<template>
<BaseFormDialog
:visible="visible"
:title="user?.id ? '사용자 수정' : '사용자 등록'"
width="680px"
:loading="loading"
@update:visible="emit('update:visible', $event)"
@submit="onSubmit"
>
<div class="form-grid">
<div class="col-6">
<div class="form-field">
<label class="form-field__label form-field__label--required">이름</label>
<InputText v-model="form.fullName" placeholder="홍길동" fluid />
</div>
</div>
<div class="col-6">
<div class="form-field">
<label class="form-field__label form-field__label--required">이메일</label>
<InputText v-model="form.email" type="email" placeholder="user@hanwha.com" fluid />
</div>
</div>
<div class="col-4">
<div class="form-field">
<label class="form-field__label">사번</label>
<InputText v-model="form.employeeId" placeholder="EMP001" fluid />
</div>
</div>
<div class="col-4">
<div class="form-field">
<label class="form-field__label">부서</label>
<InputText v-model="form.department" placeholder="배관설계팀" fluid />
</div>
</div>
<div class="col-4">
<div class="form-field">
<label class="form-field__label">Discipline</label>
<Select
v-model="form.discipline"
:options="disciplineOptions"
optionLabel="label"
optionValue="value"
placeholder="선택"
fluid
/>
</div>
</div>
<div class="col-12">
<div class="form-field">
<label class="form-field__label form-field__label--required">역할</label>
<MultiSelect
v-model="form.roles"
:options="roleOptions"
optionLabel="label"
optionValue="value"
placeholder="역할 선택"
display="chip"
fluid
/>
</div>
</div>
<div class="col-12" v-if="user?.id">
<div class="form-field">
<label class="form-field__label">활성 상태</label>
<ToggleSwitch v-model="form.isActive" />
</div>
</div>
</div>
</BaseFormDialog>
</template>

파일 보기

@@ -0,0 +1,5 @@
import type { RouteRecordRaw } from 'vue-router';
export const userRoutes: RouteRecordRaw[] = [
{ path: '/users', name: 'user-list', component: () => import('./views/UserListView.vue'), meta: { title: '사용자 관리' } },
{ path: '/users/:id', name: 'user-detail', component: () => import('./views/UserDetailView.vue'), meta: { title: '사용자 상세' } },
];

파일 보기

@@ -0,0 +1,12 @@
import api from '@/core/api/axios';
import type { PageResponse } from '@/core/api/api.types';
const BASE = '/api/wtm/users';
export const userService = {
getAll: (params?: Record<string, unknown>) => api.get<PageResponse<unknown>>(`${BASE}`, { params }),
getById: (id: number) => api.get(`${BASE}/${id}`),
update: (id: number, data: unknown) => api.put(`${BASE}/${id}`, data),
updateRoles: (id: number, roles: unknown) => api.put(`${BASE}/${id}/roles`, roles),
uploadInternal: (file: File) => { const f = new FormData(); f.append('file', file); return api.post(`${BASE}/upload/internal`, f); },
uploadSubcontractor: (file: File) => { const f = new FormData(); f.append('file', file); return api.post(`${BASE}/upload/subcontractor`, f); },
downloadTemplate: () => api.get(`${BASE}/upload/template`, { responseType: 'blob' }),
};

파일 보기

@@ -0,0 +1,7 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
export const useUserStore = defineStore('user', () => {
const users = ref<unknown[]>([]);
const loading = ref(false);
return { users, loading };
});

파일 보기

@@ -0,0 +1 @@
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 @@
<template><div class="card"><h1>사용자 상세</h1></div></template>

파일 보기

@@ -0,0 +1,115 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import Column from 'primevue/column';
import Button from 'primevue/button';
import Tag from 'primevue/tag';
import { useToast } from 'primevue/usetoast';
import BasePageHeader from '@/core/components/BasePageHeader.vue';
import BaseCrudTable from '@/core/components/BaseCrudTable.vue';
import UserFormDialog from '../components/UserFormDialog.vue';
import { userService } from '../user.service';
import type { User } from '../user.types';
const toast = useToast();
const users = ref<User[]>([]);
const loading = ref(false);
const dialogVisible = ref(false);
const dialogLoading = ref(false);
const selectedUser = ref<Partial<User> | null>(null);
async function loadUsers() {
loading.value = true;
try {
const { data } = await userService.getAll();
users.value = (data as any).items ?? data;
} catch {
toast.add({ severity: 'error', summary: '오류', detail: '사용자 목록 로드 실패', life: 5000 });
} finally {
loading.value = false;
}
}
function openCreate() {
selectedUser.value = null;
dialogVisible.value = true;
}
function openEdit(user: User) {
selectedUser.value = { ...user };
dialogVisible.value = true;
}
async function onSave(data: Partial<User>) {
dialogLoading.value = true;
try {
if (data.id) {
await userService.update(data.id, data);
toast.add({ severity: 'success', summary: '성공', detail: '사용자 정보가 수정되었습니다.', life: 3000 });
} else {
toast.add({ severity: 'info', summary: '안내', detail: '사용자 등록은 Excel 업로드를 이용해주세요.', life: 5000 });
}
dialogVisible.value = false;
await loadUsers();
} catch {
toast.add({ severity: 'error', summary: '오류', detail: '저장 실패', life: 5000 });
} finally {
dialogLoading.value = false;
}
}
function roleSeverity(role: string): string {
const map: Record<string, string> = { SA: 'danger', PM: 'warn', PCM: 'info', DL: 'success', PTK: 'secondary', USER: 'contrast' };
return map[role] ?? 'secondary';
}
onMounted(loadUsers);
</script>
<template>
<div class="user-list-view">
<BasePageHeader title="사용자 관리" subtitle="시스템 사용자 목록 및 역할 관리">
<template #actions>
<Button label="등록" icon="pi pi-plus" @click="openCreate" />
</template>
</BasePageHeader>
<BaseCrudTable
:value="users"
:loading="loading"
:globalFilterFields="['fullName', 'email', 'employeeId', 'department', 'discipline']"
@row-select="openEdit"
>
<Column field="fullName" header="이름" sortable style="min-width: 120px" />
<Column field="email" header="이메일" sortable style="min-width: 180px" />
<Column field="employeeId" header="사번" sortable style="min-width: 100px" />
<Column field="department" header="부서" sortable style="min-width: 120px" />
<Column field="discipline" header="Discipline" sortable style="min-width: 120px" />
<Column field="roles" header="역할" style="min-width: 140px">
<template #body="{ data }">
<div style="display: flex; gap: 4px; flex-wrap: wrap;">
<Tag v-for="role in data.roles" :key="role" :value="role" :severity="roleSeverity(role)" />
</div>
</template>
</Column>
<Column field="isActive" header="상태" style="min-width: 80px">
<template #body="{ data }">
<Tag :value="data.isActive ? '활성' : '비활성'" :severity="data.isActive ? 'success' : 'secondary'" />
</template>
</Column>
<Column header="" style="width: 80px">
<template #body="{ data }">
<Button icon="pi pi-pencil" text rounded severity="info" @click="openEdit(data)" />
</template>
</Column>
</BaseCrudTable>
<UserFormDialog
:visible="dialogVisible"
:user="selectedUser"
:loading="dialogLoading"
@update:visible="dialogVisible = $event"
@save="onSave"
/>
</div>
</template>

파일 보기

@@ -0,0 +1 @@
<template><div class="card"><h1>WBS 관리</h1></div></template>

파일 보기

@@ -0,0 +1,4 @@
import type { RouteRecordRaw } from 'vue-router';
export const wbsRoutes: RouteRecordRaw[] = [
{ path: '/wbs', name: 'wbs-tree', component: () => import('./views/WbsTreeView.vue'), meta: { title: 'WBS 관리' } },
];

파일 보기

@@ -0,0 +1,12 @@
import api from '@/core/api/axios';
const BASE = '/api/wtm/projects';
export const wbsService = {
uploadP6: (projectId: number, file: File, effectiveDate: string) => { const f = new FormData(); f.append('file', file); f.append('effectiveDate', effectiveDate); return api.post(`${BASE}/${projectId}/wbs/upload`, f); },
getVersions: (projectId: number) => api.get(`${BASE}/${projectId}/wbs/versions`),
getVersion: (projectId: number, ver: number) => api.get(`${BASE}/${projectId}/wbs/versions/${ver}`),
activateVersion: (projectId: number, ver: number) => api.post(`${BASE}/${projectId}/wbs/versions/${ver}/activate`),
getCanonicalWbs: (projectId: number) => api.get(`${BASE}/${projectId}/canonical-wbs`),
compare: (projectId: number, a: number, b: number) => api.get(`${BASE}/${projectId}/wbs/compare`, { params: { a, b } }),
getWbsDisciplines: (projectId: number) => api.get(`${BASE}/${projectId}/wbs-disciplines`),
saveWbsDisciplines: (projectId: number, data: unknown) => api.put(`${BASE}/${projectId}/wbs-disciplines`, data),
};

파일 보기

@@ -0,0 +1,6 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
export const useWbsStore = defineStore('wbs', () => {
const loading = ref(false);
return { loading };
});

이 Diff에서 너무 많은 파일이 변경되어 일부 파일이 표시되지 않습니다 더 보기