작업계획서대로 진행
This commit is contained in:
278
frontend/maintenance/stats.vue
Normal file
278
frontend/maintenance/stats.vue
Normal file
@@ -0,0 +1,278 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user