- 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>
323 줄
9.4 KiB
Vue
323 줄
9.4 KiB
Vue
<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>
|