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은 다음에 포함되어 있습니다:
@@ -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에서 참조
사용자 차단