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

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

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

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

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

파일 보기

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