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 +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>