279 lines
9.2 KiB
Vue
279 lines
9.2 KiB
Vue
<template>
|
|
<div>
|
|
<AppHeader />
|
|
|
|
<div class="container-fluid py-4">
|
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
<h4 class="mb-0">
|
|
<NuxtLink to="/maintenance" class="text-decoration-none text-muted me-2"><i class="bi bi-arrow-left"></i></NuxtLink>
|
|
<i class="bi bi-graph-up me-2"></i>유지보수 통계
|
|
</h4>
|
|
</div>
|
|
|
|
<!-- 필터 -->
|
|
<div class="card mb-4">
|
|
<div class="card-body py-2">
|
|
<div class="row g-2 align-items-center">
|
|
<div class="col-auto"><label class="col-form-label">년도</label></div>
|
|
<div class="col-auto">
|
|
<select class="form-select form-select-sm" v-model="filter.year" @change="loadStats">
|
|
<option v-for="y in years" :key="y" :value="y">{{ y }}</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-auto"><label class="col-form-label">월</label></div>
|
|
<div class="col-auto">
|
|
<select class="form-select form-select-sm" v-model="filter.month" @change="loadStats">
|
|
<option value="">전체</option>
|
|
<option v-for="m in 12" :key="m" :value="m">{{ m }}월</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-auto"><label class="col-form-label">프로젝트</label></div>
|
|
<div class="col-3">
|
|
<select class="form-select form-select-sm" v-model="filter.projectId" @change="loadStats">
|
|
<option value="">전체</option>
|
|
<option v-for="p in projects" :key="p.projectId" :value="p.projectId">{{ p.projectName }}</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="isLoading" class="text-center py-5">
|
|
<div class="spinner-border text-primary"></div>
|
|
</div>
|
|
|
|
<div v-else>
|
|
<!-- 요약 카드 -->
|
|
<div class="row mb-4">
|
|
<div class="col-md-3">
|
|
<div class="card text-bg-primary">
|
|
<div class="card-body text-center">
|
|
<h2 class="mb-1">{{ stats.summary?.total || 0 }}</h2>
|
|
<div>전체</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card text-bg-success">
|
|
<div class="card-body text-center">
|
|
<h2 class="mb-1">{{ stats.summary?.completed || 0 }}</h2>
|
|
<div>완료</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card text-bg-warning">
|
|
<div class="card-body text-center">
|
|
<h2 class="mb-1">{{ stats.summary?.inProgress || 0 }}</h2>
|
|
<div>진행중</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card text-bg-secondary">
|
|
<div class="card-body text-center">
|
|
<h2 class="mb-1">{{ stats.summary?.pending || 0 }}</h2>
|
|
<div>대기</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<!-- 월별 추이 -->
|
|
<div class="col-md-8 mb-4">
|
|
<div class="card h-100">
|
|
<div class="card-header"><strong>월별 추이</strong></div>
|
|
<div class="card-body">
|
|
<canvas ref="monthlyChart"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 유형별 -->
|
|
<div class="col-md-4 mb-4">
|
|
<div class="card h-100">
|
|
<div class="card-header"><strong>유형별</strong></div>
|
|
<div class="card-body">
|
|
<canvas ref="typeChart"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 프로젝트별 -->
|
|
<div class="col-md-6 mb-4">
|
|
<div class="card">
|
|
<div class="card-header"><strong>프로젝트별 TOP 10</strong></div>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm mb-0">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th>프로젝트</th>
|
|
<th class="text-end">전체</th>
|
|
<th class="text-end">완료</th>
|
|
<th class="text-end">완료율</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="p in stats.byProject" :key="p.projectId">
|
|
<td>{{ p.projectName }}</td>
|
|
<td class="text-end">{{ p.total }}</td>
|
|
<td class="text-end">{{ p.completed }}</td>
|
|
<td class="text-end">{{ p.total > 0 ? Math.round(p.completed / p.total * 100) : 0 }}%</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 담당자별 -->
|
|
<div class="col-md-6 mb-4">
|
|
<div class="card">
|
|
<div class="card-header"><strong>담당자별 TOP 10</strong></div>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm mb-0">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th>담당자</th>
|
|
<th class="text-end">전체</th>
|
|
<th class="text-end">완료</th>
|
|
<th class="text-end">완료율</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="a in stats.byAssignee" :key="a.employeeId">
|
|
<td>{{ a.employeeName }}</td>
|
|
<td class="text-end">{{ a.total }}</td>
|
|
<td class="text-end">{{ a.completed }}</td>
|
|
<td class="text-end">{{ a.total > 0 ? Math.round(a.completed / a.total * 100) : 0 }}%</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { Chart, registerables } from 'chart.js'
|
|
|
|
Chart.register(...registerables)
|
|
|
|
const { fetchCurrentUser } = useAuth()
|
|
const router = useRouter()
|
|
|
|
interface Project { projectId: number; projectName: string }
|
|
|
|
const projects = ref<Project[]>([])
|
|
const stats = ref<any>({})
|
|
const isLoading = ref(true)
|
|
|
|
const currentYear = new Date().getFullYear()
|
|
const years = [currentYear, currentYear - 1, currentYear - 2]
|
|
|
|
const filter = ref({
|
|
year: currentYear,
|
|
month: '',
|
|
projectId: ''
|
|
})
|
|
|
|
const monthlyChart = ref<HTMLCanvasElement | null>(null)
|
|
const typeChart = ref<HTMLCanvasElement | null>(null)
|
|
let monthlyChartInstance: Chart | null = null
|
|
let typeChartInstance: Chart | null = null
|
|
|
|
onMounted(async () => {
|
|
const user = await fetchCurrentUser()
|
|
if (!user) { router.push('/login'); return }
|
|
await loadProjects()
|
|
await loadStats()
|
|
})
|
|
|
|
async function loadProjects() {
|
|
try {
|
|
const res = await $fetch<{ projects: Project[] }>('/api/project/list')
|
|
projects.value = res.projects || []
|
|
} catch (e) { console.error(e) }
|
|
}
|
|
|
|
async function loadStats() {
|
|
isLoading.value = true
|
|
try {
|
|
const res = await $fetch<any>('/api/maintenance/stats', {
|
|
query: {
|
|
year: filter.value.year,
|
|
month: filter.value.month || undefined,
|
|
projectId: filter.value.projectId || undefined
|
|
}
|
|
})
|
|
stats.value = res
|
|
await nextTick()
|
|
renderCharts()
|
|
} catch (e) { console.error(e) }
|
|
finally { isLoading.value = false }
|
|
}
|
|
|
|
function renderCharts() {
|
|
// 월별 추이 차트
|
|
if (monthlyChart.value) {
|
|
if (monthlyChartInstance) monthlyChartInstance.destroy()
|
|
|
|
const labels = Array.from({ length: 12 }, (_, i) => `${i + 1}월`)
|
|
const totalData = new Array(12).fill(0)
|
|
const completedData = new Array(12).fill(0)
|
|
|
|
for (const m of stats.value.monthlyTrend || []) {
|
|
totalData[m.month - 1] = m.total
|
|
completedData[m.month - 1] = m.completed
|
|
}
|
|
|
|
monthlyChartInstance = new Chart(monthlyChart.value, {
|
|
type: 'bar',
|
|
data: {
|
|
labels,
|
|
datasets: [
|
|
{ label: '전체', data: totalData, backgroundColor: 'rgba(54, 162, 235, 0.6)' },
|
|
{ label: '완료', data: completedData, backgroundColor: 'rgba(75, 192, 192, 0.6)' }
|
|
]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
plugins: { legend: { position: 'top' } }
|
|
}
|
|
})
|
|
}
|
|
|
|
// 유형별 차트
|
|
if (typeChart.value) {
|
|
if (typeChartInstance) typeChartInstance.destroy()
|
|
|
|
const typeLabels: Record<string, string> = {
|
|
bug: '버그', feature: '기능', inquiry: '문의', other: '기타'
|
|
}
|
|
|
|
const labels = (stats.value.byType || []).map((t: any) => typeLabels[t.taskType] || t.taskType)
|
|
const data = (stats.value.byType || []).map((t: any) => t.count)
|
|
|
|
typeChartInstance = new Chart(typeChart.value, {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels,
|
|
datasets: [{
|
|
data,
|
|
backgroundColor: ['#dc3545', '#0d6efd', '#ffc107', '#6c757d']
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
plugins: { legend: { position: 'right' } }
|
|
}
|
|
})
|
|
}
|
|
}
|
|
</script>
|