feat: FE 화면 구현 완료 + 샘플 데이터 + 결재라인 연동

- WBS/TEAL 화면 실제 구현 (TreeTable, FileUpload, 버전관리)
- 시수이력/결재이력 화면 구현 (DataTable, Filter, Timeline)
- 비밀번호변경 화면 추가
- 로그인 snake_case 응답 매핑 수정
- Vite 프록시 8081 포트 수정
- auth guard에서 fetchMe 자동 호출
- V108 샘플 데이터 (10명 사용자, 4주 시수 215건, 결재 9건)
- 배너 추가 (WBX Spring)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
이 Commit은 다음에 포함되어 있습니다:
2026-03-25 22:17:32 +09:00
부모 df723f1d59
커밋 9707a6eeb1
33개의 변경된 파일2323개의 추가작업 그리고 20개의 파일을 삭제

파일 보기

@@ -1,10 +1,17 @@
import type { NavigationGuardWithThis } from 'vue-router';
import { authService } from './auth.service';
import { useAuthStore } from '@/modules/auth/auth.store';
export const authGuard: NavigationGuardWithThis<undefined> = (_to, _from, next) => {
if (authService.isAuthenticated()) {
next();
} else {
export const authGuard: NavigationGuardWithThis<undefined> = async (_to, _from, next) => {
if (!authService.isAuthenticated()) {
next({ name: 'login' });
return;
}
const authStore = useAuthStore();
if (!authStore.currentUser) {
await authStore.fetchMe();
}
next();
};

파일 보기

@@ -37,7 +37,7 @@ export const authService = {
if (!response.ok) throw new Error('Refresh failed');
const data = await response.json();
this.setTokens(data.accessToken, data.refreshToken);
return data.accessToken;
this.setTokens(data.access_token ?? data.accessToken, data.refresh_token ?? data.refreshToken);
return data.access_token ?? data.accessToken;
},
};

파일 보기

@@ -12,7 +12,9 @@ export interface LoginRequest {
}
export interface LoginResponse {
accessToken: string;
refreshToken: string;
user: AuthUser;
accessToken?: string;
refreshToken?: string;
access_token?: string;
refresh_token?: string;
user: AuthUser & { is_admin?: boolean; full_name?: string; department_id?: number };
}

파일 보기

@@ -1 +1,322 @@
<template><div class="card"><h1>결재 이력</h1></div></template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import Tag from 'primevue/tag';
import Button from 'primevue/button';
import DatePicker from 'primevue/datepicker';
import Select from 'primevue/select';
import Dialog from 'primevue/dialog';
import Timeline from 'primevue/timeline';
import BasePageHeader from '@/core/components/BasePageHeader.vue';
import { approvalService } from '../approval.service';
const loading = ref(false);
const approvals = ref<any[]>([]);
const totalRecords = ref(0);
// Filters
const dateFrom = ref<Date | null>(null);
const dateTo = ref<Date | null>(null);
const statusFilter = ref<string | null>(null);
const page = ref(0);
const rows = ref(20);
const statusOptions = [
{ value: 'APPROVED', label: '승인' },
{ value: 'REJECTED', label: '반려' },
{ value: 'PENDING', label: '대기' },
];
// Detail dialog
const showDetail = ref(false);
const selectedApproval = ref<any>(null);
const approvalLines = ref<any[]>([]);
const detailLoading = ref(false);
async function fetchHistory() {
loading.value = true;
try {
const params: Record<string, unknown> = {
skip: page.value * rows.value,
limit: rows.value,
};
if (dateFrom.value) params.from = formatDate(dateFrom.value);
if (dateTo.value) params.to = formatDate(dateTo.value);
if (statusFilter.value) params.status = statusFilter.value;
const { data } = await approvalService.getHistory(params);
if (data.content) {
approvals.value = data.content;
totalRecords.value = data.totalElements ?? data.content.length;
} else if (data.items) {
approvals.value = data.items;
totalRecords.value = data.total;
} else if (Array.isArray(data)) {
approvals.value = data;
totalRecords.value = data.length;
}
} catch {
approvals.value = [];
} finally {
loading.value = false;
}
}
async function openDetail(approval: any) {
selectedApproval.value = approval;
showDetail.value = true;
detailLoading.value = true;
try {
const { data } = await approvalService.getById(approval.id ?? approval.approvalId);
selectedApproval.value = data;
approvalLines.value = data.lines ?? data.approvalLines ?? [];
} catch {
approvalLines.value = [];
} finally {
detailLoading.value = false;
}
}
function formatDate(d: Date): string {
return d.toISOString().slice(0, 10);
}
function formatDateTime(dt: string | null): string {
if (!dt) return '-';
return dt.substring(0, 16).replace('T', ' ');
}
function onPage(event: any) {
page.value = event.page;
rows.value = event.rows;
fetchHistory();
}
function applyFilter() {
page.value = 0;
fetchHistory();
}
function clearFilter() {
dateFrom.value = null;
dateTo.value = null;
statusFilter.value = null;
page.value = 0;
fetchHistory();
}
function getStatusSeverity(status: string): string {
const map: Record<string, string> = {
APPROVED: 'success',
REJECTED: 'danger',
PENDING: 'warn',
};
return map[status] ?? 'secondary';
}
function getStatusLabel(status: string): string {
const map: Record<string, string> = {
APPROVED: '승인',
REJECTED: '반려',
PENDING: '대기',
};
return map[status] ?? status;
}
onMounted(() => fetchHistory());
</script>
<template>
<div>
<BasePageHeader title="결재 이력" subtitle="결재 처리 내역을 조회합니다." />
<div class="card">
<!-- Filter -->
<div class="history-filter">
<div class="history-filter__fields">
<div class="history-filter__field">
<label class="text-sm">시작일</label>
<DatePicker v-model="dateFrom" dateFormat="yy-mm-dd" placeholder="시작일" showIcon fluid />
</div>
<div class="history-filter__field">
<label class="text-sm">종료일</label>
<DatePicker v-model="dateTo" dateFormat="yy-mm-dd" placeholder="종료일" showIcon fluid />
</div>
<div class="history-filter__field">
<label class="text-sm">상태</label>
<Select
v-model="statusFilter"
:options="statusOptions"
optionLabel="label"
optionValue="value"
placeholder="전체"
showClear
fluid
/>
</div>
</div>
<div class="history-filter__actions">
<Button label="조회" icon="pi pi-search" size="small" @click="applyFilter" />
<Button label="초기화" icon="pi pi-times" size="small" severity="secondary" text @click="clearFilter" />
</div>
</div>
<!-- Table -->
<DataTable
:value="approvals"
:loading="loading"
:paginator="true"
:rows="rows"
:totalRecords="totalRecords"
:lazy="true"
:rowsPerPageOptions="[10, 20, 50]"
dataKey="id"
stripedRows
size="small"
@page="onPage"
>
<template #empty>
<div style="text-align: center; padding: 2rem; color: var(--p-text-muted-color);">
<i class="pi pi-inbox" style="font-size: 2rem;" />
<p>결재 이력이 없습니다.</p>
</div>
</template>
<Column field="requesterName" header="요청자" style="width: 120px">
<template #body="{ data }">
{{ data.requesterName ?? data.requester_name ?? `사용자 #${data.requesterId}` }}
</template>
</Column>
<Column field="timesheetId" header="시수 ID" style="width: 90px" />
<Column field="projectName" header="프로젝트" style="width: 180px">
<template #body="{ data }">
{{ data.projectName ?? data.project_name ?? '-' }}
</template>
</Column>
<Column field="status" header="상태" style="width: 100px">
<template #body="{ data }">
<Tag :value="getStatusLabel(data.status)" :severity="getStatusSeverity(data.status)" />
</template>
</Column>
<Column field="submittedAt" header="제출일" style="width: 150px">
<template #body="{ data }">
{{ formatDateTime(data.submittedAt ?? data.submitted_at) }}
</template>
</Column>
<Column field="completedAt" header="완료일" style="width: 150px">
<template #body="{ data }">
{{ formatDateTime(data.completedAt ?? data.completed_at) }}
</template>
</Column>
<Column header="상세" style="width: 70px">
<template #body="{ data }">
<Button icon="pi pi-eye" text rounded size="small" @click="openDetail(data)" />
</template>
</Column>
</DataTable>
</div>
<!-- Detail Dialog -->
<Dialog
v-model:visible="showDetail"
header="결재 상세"
:style="{ width: '600px', maxWidth: '95vw' }"
modal
>
<div v-if="detailLoading" style="text-align: center; padding: 2rem;">
<i class="pi pi-spin pi-spinner" style="font-size: 2rem;" />
</div>
<div v-else-if="selectedApproval">
<div class="detail-info">
<div class="detail-info__row">
<span class="text-sm text-muted">요청자</span>
<strong>{{ selectedApproval.requesterName ?? `사용자 #${selectedApproval.requesterId}` }}</strong>
</div>
<div class="detail-info__row">
<span class="text-sm text-muted">상태</span>
<Tag :value="getStatusLabel(selectedApproval.status)" :severity="getStatusSeverity(selectedApproval.status)" />
</div>
<div class="detail-info__row">
<span class="text-sm text-muted">제출일</span>
<span>{{ formatDateTime(selectedApproval.submittedAt ?? selectedApproval.submitted_at) }}</span>
</div>
</div>
<h4 style="margin: 1.5rem 0 0.5rem;">결재 라인</h4>
<Timeline :value="approvalLines" align="left">
<template #content="{ item }">
<div class="timeline-item">
<Tag
:value="getStatusLabel(item.status)"
:severity="getStatusSeverity(item.status)"
style="margin-right: 0.5rem;"
/>
<strong>{{ item.roleCode }}</strong>
<span class="text-sm text-muted" style="margin-left: 0.5rem;">
{{ item.actedAt ? formatDateTime(item.actedAt) : '대기중' }}
</span>
</div>
</template>
</Timeline>
</div>
</Dialog>
</div>
</template>
<style lang="scss" scoped>
@use '@/assets/styles/variables' as *;
.history-filter {
display: flex;
justify-content: space-between;
align-items: flex-end;
flex-wrap: wrap;
gap: $space-md;
margin-bottom: $space-lg;
&__fields {
display: flex;
gap: $space-md;
flex-wrap: wrap;
}
&__field {
display: flex;
flex-direction: column;
gap: $space-xs;
min-width: 160px;
}
&__actions {
display: flex;
gap: $space-sm;
}
@media (max-width: $bp-mobile) {
flex-direction: column;
align-items: stretch;
&__fields { flex-direction: column; }
&__actions { justify-content: flex-end; }
}
}
.detail-info {
display: flex;
flex-direction: column;
gap: $space-sm;
&__row {
display: flex;
justify-content: space-between;
align-items: center;
padding: $space-xs 0;
border-bottom: 1px solid var(--p-surface-200);
}
}
.timeline-item {
display: flex;
align-items: center;
}
</style>

파일 보기

@@ -13,17 +13,29 @@ export const useAuthStore = defineStore('auth', () => {
loading.value = true;
try {
const { data } = await authApi.login({ email, password });
tokenService.setTokens(data.accessToken, data.refreshToken);
currentUser.value = data.user;
const accessToken = (data.access_token ?? data.accessToken) as string;
const refreshToken = (data.refresh_token ?? data.refreshToken) as string;
tokenService.setTokens(accessToken, refreshToken);
currentUser.value = mapUser(data.user);
} finally {
loading.value = false;
}
}
function mapUser(u: any): AuthUser {
return {
id: u.id,
email: u.email,
fullName: u.full_name ?? u.fullName ?? '',
roles: u.roles?.length ? u.roles : (u.is_admin ? ['SA'] : ['USER']),
departmentId: u.department_id ?? u.departmentId,
};
}
async function fetchMe() {
try {
const { data } = await authApi.me();
currentUser.value = data;
currentUser.value = mapUser(data);
} catch {
logout();
}

파일 보기

@@ -0,0 +1,93 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import FileUpload from 'primevue/fileupload';
import DatePicker from 'primevue/datepicker';
import BaseFormDialog from '@/core/components/BaseFormDialog.vue';
const props = defineProps<{
visible: boolean;
loading?: boolean;
}>();
const emit = defineEmits<{
'update:visible': [value: boolean];
upload: [payload: { file: File; effectiveDate: string }];
}>();
const selectedFile = ref<File | null>(null);
const effectiveDate = ref<Date | null>(null);
watch(
() => props.visible,
(v) => {
if (v) {
selectedFile.value = null;
effectiveDate.value = null;
}
},
);
function onFileSelect(event: any) {
const files = event.files ?? event;
if (Array.isArray(files) && files.length > 0) {
selectedFile.value = files[0];
}
}
function formatDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
function onSubmit() {
if (!selectedFile.value || !effectiveDate.value) return;
emit('upload', {
file: selectedFile.value,
effectiveDate: formatDate(effectiveDate.value),
});
}
</script>
<template>
<BaseFormDialog
:visible="visible"
title="TEAL 업로드"
width="580px"
:loading="loading"
submitLabel="업로드"
@update:visible="emit('update:visible', $event)"
@submit="onSubmit"
>
<div class="form-grid">
<div class="col-12">
<div class="form-field">
<label class="form-field__label form-field__label--required">TEAL 파일</label>
<FileUpload
mode="basic"
accept=".xls,.xlsx,.csv"
:maxFileSize="10000000"
chooseLabel="파일 선택"
:auto="false"
@select="onFileSelect"
/>
<small v-if="selectedFile" class="form-field__hint">
{{ selectedFile.name }}
</small>
</div>
</div>
<div class="col-12">
<div class="form-field">
<label class="form-field__label form-field__label--required">적용일</label>
<DatePicker
v-model="effectiveDate"
dateFormat="yy-mm-dd"
placeholder="적용일 선택"
fluid
/>
</div>
</div>
</div>
</BaseFormDialog>
</template>

파일 보기

@@ -0,0 +1,75 @@
<script setup lang="ts">
import Select from 'primevue/select';
import Tag from 'primevue/tag';
import type { TealVersion } from '../teal.store';
defineProps<{
modelValue: TealVersion | null;
versions: TealVersion[];
loading?: boolean;
}>();
const emit = defineEmits<{
'update:modelValue': [value: TealVersion | null];
}>();
function statusSeverity(status: string): string {
const map: Record<string, string> = {
DRAFT: 'warn',
ACTIVE: 'success',
ARCHIVED: 'secondary',
};
return map[status] ?? 'secondary';
}
function statusLabel(status: string): string {
const map: Record<string, string> = {
DRAFT: '초안',
ACTIVE: '활성',
ARCHIVED: '보관',
};
return map[status] ?? status;
}
</script>
<template>
<Select
:modelValue="modelValue"
:options="versions"
:loading="loading"
optionLabel="versionNumber"
placeholder="버전 선택"
fluid
@update:modelValue="emit('update:modelValue', $event)"
>
<template #value="{ value }">
<div v-if="value" class="teal-version-option">
<span>v{{ value.versionNumber }}</span>
<Tag
:value="statusLabel(value.status)"
:severity="statusSeverity(value.status)"
style="margin-left: 8px"
/>
</div>
<span v-else>버전 선택</span>
</template>
<template #option="{ option }">
<div class="teal-version-option">
<span>v{{ option.versionNumber }} ({{ option.effectiveDate }})</span>
<Tag
:value="statusLabel(option.status)"
:severity="statusSeverity(option.status)"
style="margin-left: 8px"
/>
</div>
</template>
</Select>
</template>
<style lang="scss" scoped>
.teal-version-option {
display: flex;
align-items: center;
gap: 4px;
}
</style>

파일 보기

@@ -1,6 +1,60 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { tealService } from './teal.service';
import type { TealEntry } from './teal.types';
export interface TealVersion {
id: number;
projectId: number;
versionNumber: number;
effectiveDate: string;
status: string;
entryCount?: number;
}
export const useTealStore = defineStore('teal', () => {
const loading = ref(false);
return { loading };
const versions = ref<TealVersion[]>([]);
const entries = ref<TealEntry[]>([]);
const selectedProjectId = ref<number | null>(null);
async function fetchVersions(projectId: number) {
loading.value = true;
try {
const { data } = await tealService.getVersions(projectId);
versions.value = (data as any).items ?? data;
} finally {
loading.value = false;
}
}
async function fetchActive(projectId: number) {
loading.value = true;
try {
const { data } = await tealService.getActive(projectId);
entries.value = (data as any).items ?? data;
} finally {
loading.value = false;
}
}
async function upload(projectId: number, file: File, effectiveDate: string) {
loading.value = true;
try {
await tealService.upload(projectId, file, effectiveDate);
await fetchVersions(projectId);
} finally {
loading.value = false;
}
}
return {
loading,
versions,
entries,
selectedProjectId,
fetchVersions,
fetchActive,
upload,
};
});

파일 보기

@@ -1 +1,211 @@
<template><div class="card"><h1>TEAL 관리</h1></div></template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import Column from 'primevue/column';
import Select from 'primevue/select';
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 TealUploadDialog from '../components/TealUploadDialog.vue';
import TealVersionSelect from '../components/TealVersionSelect.vue';
import { useTealStore } from '../teal.store';
import type { TealVersion } from '../teal.store';
import { projectService } from '@/modules/project/project.service';
import type { Project } from '@/modules/project/project.types';
const toast = useToast();
const tealStore = useTealStore();
const projects = ref<Project[]>([]);
const selectedProject = ref<Project | null>(null);
const selectedVersion = ref<TealVersion | null>(null);
const uploadDialogVisible = ref(false);
const uploadLoading = ref(false);
const disciplineFilter = ref<string | null>(null);
const disciplineOptions = [
'Piping', 'Electrical', 'Instrument', 'Civil', 'Structural',
'Mechanical', 'Process', 'HSE', 'QA/QC', 'Other',
].map((d) => ({ label: d, value: d }));
const filteredEntries = computed(() => {
if (!disciplineFilter.value) return tealStore.entries;
return tealStore.entries.filter(
(e: any) => e.discipline === disciplineFilter.value,
);
});
async function loadProjects() {
try {
const { data } = await projectService.getAll();
projects.value = (data as any).items ?? data;
} catch {
toast.add({ severity: 'error', summary: '오류', detail: '프로젝트 목록 로드 실패', life: 5000 });
}
}
async function loadVersions() {
if (!selectedProject.value) return;
try {
await tealStore.fetchVersions(selectedProject.value.id);
selectedVersion.value = null;
} catch {
toast.add({ severity: 'error', summary: '오류', detail: 'TEAL 버전 목록 로드 실패', life: 5000 });
}
}
async function loadEntries() {
if (!selectedProject.value) return;
try {
await tealStore.fetchActive(selectedProject.value.id);
} catch {
toast.add({ severity: 'error', summary: '오류', detail: 'TEAL 항목 로드 실패', life: 5000 });
}
}
async function onUpload(payload: { file: File; effectiveDate: string }) {
if (!selectedProject.value) return;
uploadLoading.value = true;
try {
await tealStore.upload(selectedProject.value.id, payload.file, payload.effectiveDate);
toast.add({ severity: 'success', summary: '성공', detail: 'TEAL 파일이 업로드되었습니다.', life: 3000 });
uploadDialogVisible.value = false;
await loadEntries();
} catch {
toast.add({ severity: 'error', summary: '오류', detail: 'TEAL 업로드 실패', life: 5000 });
} finally {
uploadLoading.value = false;
}
}
watch(selectedProject, () => {
loadVersions();
loadEntries();
});
watch(selectedVersion, () => {
if (selectedProject.value) {
loadEntries();
}
});
onMounted(loadProjects);
</script>
<template>
<div class="teal-list-view">
<BasePageHeader title="TEAL 관리">
<template #actions>
<Button
label="TEAL 업로드"
icon="pi pi-upload"
:disabled="!selectedProject"
@click="uploadDialogVisible = true"
/>
</template>
</BasePageHeader>
<!-- Top bar: selectors -->
<div class="teal-list-view__toolbar">
<div class="teal-list-view__selector">
<label class="teal-list-view__label">프로젝트</label>
<Select
v-model="selectedProject"
:options="projects"
optionLabel="name"
placeholder="프로젝트 선택"
fluid
style="min-width: 240px"
/>
</div>
<div class="teal-list-view__selector">
<label class="teal-list-view__label">버전</label>
<TealVersionSelect
v-model="selectedVersion"
:versions="tealStore.versions"
:loading="tealStore.loading"
style="min-width: 200px"
/>
</div>
<div class="teal-list-view__selector">
<label class="teal-list-view__label">Discipline</label>
<Select
v-model="disciplineFilter"
:options="disciplineOptions"
optionLabel="label"
optionValue="value"
placeholder="전체"
showClear
fluid
style="min-width: 160px"
/>
</div>
</div>
<!-- DataTable -->
<BaseCrudTable
:value="filteredEntries"
:loading="tealStore.loading"
:globalFilterFields="['activityCode', 'activityName', 'discipline']"
emptyMessage="프로젝트를 선택해 주세요."
>
<Column field="activityCode" header="Activity Code" sortable style="min-width: 160px" />
<Column field="activityName" header="Activity Name" sortable style="min-width: 240px" />
<Column field="canonicalWbsCode" header="WBS Code" sortable style="min-width: 160px">
<template #body="{ data }">
{{ (data as any).canonicalWbsCode ?? (data as any).wbsCode ?? '-' }}
</template>
</Column>
<Column field="discipline" header="Discipline" sortable style="min-width: 120px" />
<Column header="상태" style="min-width: 90px">
<template #body="{ data }">
<Tag
:value="(data as any).isActive === false ? '비활성' : '활성'"
:severity="(data as any).isActive === false ? 'secondary' : 'success'"
/>
</template>
</Column>
</BaseCrudTable>
<TealUploadDialog
:visible="uploadDialogVisible"
:loading="uploadLoading"
@update:visible="uploadDialogVisible = $event"
@upload="onUpload"
/>
</div>
</template>
<style lang="scss" scoped>
@use '@/assets/styles/variables' as *;
.teal-list-view {
&__toolbar {
display: flex;
align-items: flex-end;
gap: $space-md;
flex-wrap: wrap;
margin-bottom: $space-lg;
}
&__selector {
display: flex;
flex-direction: column;
gap: $space-xs;
}
&__label {
font-size: $font-size-sm;
font-weight: 600;
color: $color-text-muted;
}
@media (max-width: $bp-mobile) {
&__toolbar {
flex-direction: column;
align-items: stretch;
}
}
}
</style>

파일 보기

@@ -1 +1,220 @@
<template><div class="card"><h1>시수 이력</h1></div></template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import Tag from 'primevue/tag';
import Button from 'primevue/button';
import DatePicker from 'primevue/datepicker';
import Select from 'primevue/select';
import BasePageHeader from '@/core/components/BasePageHeader.vue';
import { timesheetService } from '../timesheet.service';
import { TIMESHEET_STATUS } from '@/core/constants/app.constants';
const loading = ref(false);
const timesheets = ref<any[]>([]);
const totalRecords = ref(0);
// Filters
const dateFrom = ref<Date | null>(null);
const dateTo = ref<Date | null>(null);
const statusFilter = ref<string | null>(null);
const statusOptions = Object.entries(TIMESHEET_STATUS).map(([value, { label }]) => ({
value,
label,
}));
const page = ref(0);
const rows = ref(20);
async function fetchHistory() {
loading.value = true;
try {
const params: Record<string, unknown> = {
skip: page.value * rows.value,
limit: rows.value,
};
if (dateFrom.value) params.from = formatDate(dateFrom.value);
if (dateTo.value) params.to = formatDate(dateTo.value);
if (statusFilter.value) params.status = statusFilter.value;
const { data } = await timesheetService.getHistory(params);
if (data.items) {
timesheets.value = data.items;
totalRecords.value = data.total;
} else if (data.content) {
timesheets.value = data.content;
totalRecords.value = data.totalElements ?? data.content.length;
} else if (Array.isArray(data)) {
timesheets.value = data;
totalRecords.value = data.length;
}
} catch {
timesheets.value = [];
} finally {
loading.value = false;
}
}
function formatDate(d: Date): string {
return d.toISOString().slice(0, 10);
}
function onPage(event: any) {
page.value = event.page;
rows.value = event.rows;
fetchHistory();
}
function applyFilter() {
page.value = 0;
fetchHistory();
}
function clearFilter() {
dateFrom.value = null;
dateTo.value = null;
statusFilter.value = null;
page.value = 0;
fetchHistory();
}
function getStatusLabel(status: string) {
return TIMESHEET_STATUS[status]?.label ?? status;
}
function getStatusSeverity(status: string): string {
return (TIMESHEET_STATUS[status]?.severity as string) ?? 'secondary';
}
onMounted(() => fetchHistory());
</script>
<template>
<div>
<BasePageHeader title="시수 이력" subtitle="제출 및 승인된 시수 내역을 조회합니다." />
<div class="card">
<!-- Filter -->
<div class="history-filter">
<div class="history-filter__fields">
<div class="history-filter__field">
<label class="text-sm">시작일</label>
<DatePicker v-model="dateFrom" dateFormat="yy-mm-dd" placeholder="시작일" showIcon fluid />
</div>
<div class="history-filter__field">
<label class="text-sm">종료일</label>
<DatePicker v-model="dateTo" dateFormat="yy-mm-dd" placeholder="종료일" showIcon fluid />
</div>
<div class="history-filter__field">
<label class="text-sm">상태</label>
<Select
v-model="statusFilter"
:options="statusOptions"
optionLabel="label"
optionValue="value"
placeholder="전체"
showClear
fluid
/>
</div>
</div>
<div class="history-filter__actions">
<Button label="조회" icon="pi pi-search" size="small" @click="applyFilter" />
<Button label="초기화" icon="pi pi-times" size="small" severity="secondary" text @click="clearFilter" />
</div>
</div>
<!-- Table -->
<DataTable
:value="timesheets"
:loading="loading"
:paginator="true"
:rows="rows"
:totalRecords="totalRecords"
:lazy="true"
:rowsPerPageOptions="[10, 20, 50]"
dataKey="id"
stripedRows
size="small"
@page="onPage"
>
<template #empty>
<div style="text-align: center; padding: 2rem; color: var(--p-text-muted-color);">
<i class="pi pi-inbox" style="font-size: 2rem;" />
<p>시수 이력이 없습니다.</p>
</div>
</template>
<Column field="weekStartDate" header="주간 시작일" sortable style="width: 130px" />
<Column field="weekEndDate" header="주간 종료일" sortable style="width: 130px" />
<Column field="totalHours" header="총 시수" sortable style="width: 100px">
<template #body="{ data }">
<strong>{{ data.totalHours?.toFixed(1) ?? '0.0' }}h</strong>
</template>
</Column>
<Column field="status" header="상태" sortable style="width: 120px">
<template #body="{ data }">
<Tag :value="getStatusLabel(data.status)" :severity="getStatusSeverity(data.status)" />
</template>
</Column>
<Column field="submittedAt" header="제출일" style="width: 160px">
<template #body="{ data }">
{{ data.submittedAt ? data.submittedAt.substring(0, 16).replace('T', ' ') : '-' }}
</template>
</Column>
<Column header="관리" style="width: 80px">
<template #body="{ data }">
<router-link :to="`/timesheets?week=${data.weekStartDate}`">
<Button icon="pi pi-eye" text rounded size="small" v-tooltip="'상세 보기'" />
</router-link>
</template>
</Column>
</DataTable>
</div>
</div>
</template>
<style lang="scss" scoped>
@use '@/assets/styles/variables' as *;
.history-filter {
display: flex;
justify-content: space-between;
align-items: flex-end;
flex-wrap: wrap;
gap: $space-md;
margin-bottom: $space-lg;
&__fields {
display: flex;
gap: $space-md;
flex-wrap: wrap;
}
&__field {
display: flex;
flex-direction: column;
gap: $space-xs;
min-width: 160px;
}
&__actions {
display: flex;
gap: $space-sm;
}
@media (max-width: $bp-mobile) {
flex-direction: column;
align-items: stretch;
&__fields {
flex-direction: column;
}
&__actions {
justify-content: flex-end;
}
}
}
</style>

파일 보기

@@ -0,0 +1,108 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import FileUpload from 'primevue/fileupload';
import DatePicker from 'primevue/datepicker';
import Textarea from 'primevue/textarea';
import BaseFormDialog from '@/core/components/BaseFormDialog.vue';
const props = defineProps<{
visible: boolean;
loading?: boolean;
}>();
const emit = defineEmits<{
'update:visible': [value: boolean];
upload: [payload: { file: File; effectiveDate: string; description: string }];
}>();
const selectedFile = ref<File | null>(null);
const effectiveDate = ref<Date | null>(null);
const description = ref('');
watch(
() => props.visible,
(v) => {
if (v) {
selectedFile.value = null;
effectiveDate.value = null;
description.value = '';
}
},
);
function onFileSelect(event: any) {
const files = event.files ?? event;
if (Array.isArray(files) && files.length > 0) {
selectedFile.value = files[0];
}
}
function formatDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
function onSubmit() {
if (!selectedFile.value || !effectiveDate.value) return;
emit('upload', {
file: selectedFile.value,
effectiveDate: formatDate(effectiveDate.value),
description: description.value,
});
}
</script>
<template>
<BaseFormDialog
:visible="visible"
title="P6 WBS 업로드"
width="580px"
:loading="loading"
submitLabel="업로드"
@update:visible="emit('update:visible', $event)"
@submit="onSubmit"
>
<div class="form-grid">
<div class="col-12">
<div class="form-field">
<label class="form-field__label form-field__label--required">P6 파일</label>
<FileUpload
mode="basic"
accept=".xls,.xlsx,.csv"
:maxFileSize="10000000"
chooseLabel="파일 선택"
:auto="false"
@select="onFileSelect"
/>
<small v-if="selectedFile" class="form-field__hint">
{{ selectedFile.name }}
</small>
</div>
</div>
<div class="col-12">
<div class="form-field">
<label class="form-field__label form-field__label--required">적용일</label>
<DatePicker
v-model="effectiveDate"
dateFormat="yy-mm-dd"
placeholder="적용일 선택"
fluid
/>
</div>
</div>
<div class="col-12">
<div class="form-field">
<label class="form-field__label">설명</label>
<Textarea
v-model="description"
rows="3"
placeholder="버전 설명을 입력하세요"
fluid
/>
</div>
</div>
</div>
</BaseFormDialog>
</template>

파일 보기

@@ -0,0 +1,75 @@
<script setup lang="ts">
import Select from 'primevue/select';
import Tag from 'primevue/tag';
import type { WbsVersion } from '../wbs.types';
defineProps<{
modelValue: WbsVersion | null;
versions: WbsVersion[];
loading?: boolean;
}>();
const emit = defineEmits<{
'update:modelValue': [value: WbsVersion | null];
}>();
function statusSeverity(status: string): string {
const map: Record<string, string> = {
DRAFT: 'warn',
ACTIVE: 'success',
ARCHIVED: 'secondary',
};
return map[status] ?? 'secondary';
}
function statusLabel(status: string): string {
const map: Record<string, string> = {
DRAFT: '초안',
ACTIVE: '활성',
ARCHIVED: '보관',
};
return map[status] ?? status;
}
</script>
<template>
<Select
:modelValue="modelValue"
:options="versions"
:loading="loading"
optionLabel="versionNumber"
placeholder="버전 선택"
fluid
@update:modelValue="emit('update:modelValue', $event)"
>
<template #value="{ value }">
<div v-if="value" class="wbs-version-option">
<span>v{{ value.versionNumber }}</span>
<Tag
:value="statusLabel(value.status)"
:severity="statusSeverity(value.status)"
style="margin-left: 8px"
/>
</div>
<span v-else>버전 선택</span>
</template>
<template #option="{ option }">
<div class="wbs-version-option">
<span>v{{ option.versionNumber }} ({{ option.effectiveDate }})</span>
<Tag
:value="statusLabel(option.status)"
:severity="statusSeverity(option.status)"
style="margin-left: 8px"
/>
</div>
</template>
</Select>
</template>
<style lang="scss" scoped>
.wbs-version-option {
display: flex;
align-items: center;
gap: 4px;
}
</style>

파일 보기

@@ -1 +1,297 @@
<template><div class="card"><h1>WBS 관리</h1></div></template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import TreeTable from 'primevue/treetable';
import Column from 'primevue/column';
import Select from 'primevue/select';
import Button from 'primevue/button';
import Tag from 'primevue/tag';
import { useToast } from 'primevue/usetoast';
import BasePageHeader from '@/core/components/BasePageHeader.vue';
import WbsUploadDialog from '../components/WbsUploadDialog.vue';
import WbsVersionSelect from '../components/WbsVersionSelect.vue';
import { useWbsStore } from '../wbs.store';
import { projectService } from '@/modules/project/project.service';
import type { Project } from '@/modules/project/project.types';
import type { WbsVersion, WbsNode } from '../wbs.types';
const toast = useToast();
const wbsStore = useWbsStore();
const projects = ref<Project[]>([]);
const selectedProject = ref<Project | null>(null);
const selectedVersion = ref<WbsVersion | null>(null);
const uploadDialogVisible = ref(false);
const uploadLoading = ref(false);
// Build tree nodes for PrimeVue TreeTable
interface TreeNode {
key: string;
data: WbsNode;
children: TreeNode[];
}
const treeNodes = computed<TreeNode[]>(() => {
const nodes = wbsStore.nodes;
if (!nodes || nodes.length === 0) return [];
const nodeMap = new Map<string, TreeNode>();
const roots: TreeNode[] = [];
// Create tree node objects
for (const node of nodes) {
nodeMap.set(node.wbsCode, {
key: node.wbsCode,
data: node,
children: [],
});
}
// Build hierarchy
for (const node of nodes) {
const treeNode = nodeMap.get(node.wbsCode)!;
const parentCode = deriveParentCode(node.wbsCode, node.level);
if (parentCode && nodeMap.has(parentCode)) {
nodeMap.get(parentCode)!.children.push(treeNode);
} else {
roots.push(treeNode);
}
}
return roots;
});
function deriveParentCode(wbsCode: string, level: number): string | null {
if (level <= 1) return null;
const lastDot = wbsCode.lastIndexOf('.');
return lastDot > 0 ? wbsCode.substring(0, lastDot) : null;
}
const canActivate = computed(() => {
return selectedVersion.value?.status === 'DRAFT';
});
async function loadProjects() {
try {
const { data } = await projectService.getAll();
projects.value = (data as any).items ?? data;
} catch {
toast.add({ severity: 'error', summary: '오류', detail: '프로젝트 목록 로드 실패', life: 5000 });
}
}
async function loadVersions() {
if (!selectedProject.value) return;
try {
await wbsStore.fetchVersions(selectedProject.value.id);
selectedVersion.value = null;
} catch {
toast.add({ severity: 'error', summary: '오류', detail: 'WBS 버전 목록 로드 실패', life: 5000 });
}
}
async function loadNodes() {
if (!selectedProject.value || !selectedVersion.value) return;
try {
await wbsStore.fetchNodes(selectedProject.value.id, selectedVersion.value.versionNumber);
} catch {
toast.add({ severity: 'error', summary: '오류', detail: 'WBS 노드 로드 실패', life: 5000 });
}
}
async function onActivate() {
if (!selectedProject.value || !selectedVersion.value) return;
try {
await wbsStore.activateVersion(selectedProject.value.id, selectedVersion.value.versionNumber);
toast.add({ severity: 'success', summary: '성공', detail: 'WBS 버전이 활성화되었습니다.', life: 3000 });
selectedVersion.value = null;
} catch {
toast.add({ severity: 'error', summary: '오류', detail: '버전 활성화 실패', life: 5000 });
}
}
async function onUpload(payload: { file: File; effectiveDate: string; description: string }) {
if (!selectedProject.value) return;
uploadLoading.value = true;
try {
await wbsStore.uploadWbs(selectedProject.value.id, payload.file, payload.effectiveDate);
toast.add({ severity: 'success', summary: '성공', detail: 'P6 WBS 파일이 업로드되었습니다.', life: 3000 });
uploadDialogVisible.value = false;
} catch {
toast.add({ severity: 'error', summary: '오류', detail: 'WBS 업로드 실패', life: 5000 });
} finally {
uploadLoading.value = false;
}
}
function statusSeverity(status: string): string {
const map: Record<string, string> = {
DRAFT: 'warn',
ACTIVE: 'success',
ARCHIVED: 'secondary',
};
return map[status] ?? 'secondary';
}
function statusLabel(status: string): string {
const map: Record<string, string> = {
DRAFT: '초안',
ACTIVE: '활성',
ARCHIVED: '보관',
};
return map[status] ?? status;
}
watch(selectedProject, () => {
loadVersions();
wbsStore.nodes = [];
});
watch(selectedVersion, () => {
if (selectedVersion.value) {
loadNodes();
} else {
wbsStore.nodes = [];
}
});
onMounted(loadProjects);
</script>
<template>
<div class="wbs-tree-view">
<BasePageHeader title="WBS 관리">
<template #actions>
<Button
label="P6 업로드"
icon="pi pi-upload"
:disabled="!selectedProject"
@click="uploadDialogVisible = true"
/>
</template>
</BasePageHeader>
<!-- Top bar: selectors -->
<div class="wbs-tree-view__toolbar">
<div class="wbs-tree-view__selector">
<label class="wbs-tree-view__label">프로젝트</label>
<Select
v-model="selectedProject"
:options="projects"
optionLabel="name"
placeholder="프로젝트 선택"
fluid
style="min-width: 240px"
/>
</div>
<div class="wbs-tree-view__selector">
<label class="wbs-tree-view__label">버전</label>
<WbsVersionSelect
v-model="selectedVersion"
:versions="wbsStore.versions"
:loading="wbsStore.loading"
style="min-width: 200px"
/>
</div>
<Button
v-if="canActivate"
label="활성화"
icon="pi pi-check"
severity="success"
@click="onActivate"
/>
</div>
<!-- TreeTable -->
<div class="wbs-tree-view__content">
<TreeTable
:value="treeNodes"
:loading="wbsStore.loading"
removableSort
stripedRows
showGridlines
responsiveLayout="scroll"
size="small"
>
<template #empty>
<div class="wbs-tree-view__empty">
<i class="pi pi-inbox" style="font-size: 2rem" />
<p>프로젝트와 버전을 선택해 주세요.</p>
</div>
</template>
<Column field="wbsCode" header="WBS Code" sortable style="min-width: 200px" expander />
<Column field="name" header="이름" sortable style="min-width: 200px" />
<Column field="level" header="Level" sortable style="min-width: 80px" />
<Column field="discipline" header="Discipline" sortable style="min-width: 120px" />
<Column field="plannedHours" header="계획시수" sortable style="min-width: 100px">
<template #body="{ node }">
{{ node.data.plannedHours != null ? node.data.plannedHours.toLocaleString() : '-' }}
</template>
</Column>
<Column header="상태" style="min-width: 80px">
<template #body>
<Tag
v-if="selectedVersion"
:value="statusLabel(selectedVersion.status)"
:severity="statusSeverity(selectedVersion.status)"
/>
</template>
</Column>
</TreeTable>
</div>
<WbsUploadDialog
:visible="uploadDialogVisible"
:loading="uploadLoading"
@update:visible="uploadDialogVisible = $event"
@upload="onUpload"
/>
</div>
</template>
<style lang="scss" scoped>
@use '@/assets/styles/variables' as *;
.wbs-tree-view {
&__toolbar {
display: flex;
align-items: flex-end;
gap: $space-md;
flex-wrap: wrap;
margin-bottom: $space-lg;
}
&__selector {
display: flex;
flex-direction: column;
gap: $space-xs;
}
&__label {
font-size: $font-size-sm;
font-weight: 600;
color: $color-text-muted;
}
&__content {
background: var(--surface-card);
border-radius: $radius-md;
overflow: hidden;
}
&__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: $space-sm;
padding: $space-2xl;
color: $color-text-muted;
}
@media (max-width: $bp-mobile) {
&__toolbar {
flex-direction: column;
align-items: stretch;
}
}
}
</style>

파일 보기

@@ -1,6 +1,64 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { wbsService } from './wbs.service';
import type { WbsVersion, WbsNode } from './wbs.types';
export const useWbsStore = defineStore('wbs', () => {
const loading = ref(false);
return { loading };
const versions = ref<WbsVersion[]>([]);
const nodes = ref<WbsNode[]>([]);
const selectedProjectId = ref<number | null>(null);
const selectedVersion = ref<WbsVersion | null>(null);
async function fetchVersions(projectId: number) {
loading.value = true;
try {
const { data } = await wbsService.getVersions(projectId);
versions.value = (data as any).items ?? data;
} finally {
loading.value = false;
}
}
async function fetchNodes(projectId: number, versionNumber: number) {
loading.value = true;
try {
const { data } = await wbsService.getVersion(projectId, versionNumber);
nodes.value = (data as any).nodes ?? (data as any).items ?? data;
} finally {
loading.value = false;
}
}
async function uploadWbs(projectId: number, file: File, effectiveDate: string) {
loading.value = true;
try {
await wbsService.uploadP6(projectId, file, effectiveDate);
await fetchVersions(projectId);
} finally {
loading.value = false;
}
}
async function activateVersion(projectId: number, versionNumber: number) {
loading.value = true;
try {
await wbsService.activateVersion(projectId, versionNumber);
await fetchVersions(projectId);
} finally {
loading.value = false;
}
}
return {
loading,
versions,
nodes,
selectedProjectId,
selectedVersion,
fetchVersions,
fetchNodes,
uploadWbs,
activateVersion,
};
});