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