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은 다음에 포함되어 있습니다:
10
wtm-frontend/src/app/App.vue
일반 파일
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
일반 파일
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);
|
||||
}
|
||||
43
wtm-frontend/src/app/router.ts
일반 파일
43
wtm-frontend/src/app/router.ts
일반 파일
@@ -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;
|
||||
BIN
wtm-frontend/src/assets/hero.png
일반 파일
BIN
wtm-frontend/src/assets/hero.png
일반 파일
바이너리 파일은 표시되지 않습니다.
|
이후 너비: | 높이: | 크기: 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;
|
||||
}
|
||||
}
|
||||
1
wtm-frontend/src/assets/vite.svg
일반 파일
1
wtm-frontend/src/assets/vite.svg
일반 파일
하나 이상의 줄이 너무 길어 파일 Diff가 숨겨졌습니다
|
이후 너비: | 높이: | 크기: 8.5 KiB |
1
wtm-frontend/src/assets/vue.svg
일반 파일
1
wtm-frontend/src/assets/vue.svg
일반 파일
@@ -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;
|
||||
}
|
||||
64
wtm-frontend/src/core/api/axios.ts
일반 파일
64
wtm-frontend/src/core/api/axios.ts
일반 파일
@@ -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
벤더링됨
일반 파일
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 };
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export interface WbsNode { id: number; wbsCode: string; name: string; level: number; parentId?: number; discipline?: string; plannedHours?: number; }
|
||||
export interface WbsVersion { id: number; projectId: number; versionNumber: number; effectiveDate: string; status: string; }
|
||||
새 Issue에서 참조
사용자 차단