작업계획서대로 진행

This commit is contained in:
2026-01-11 10:50:51 +09:00
parent 5cda181cc5
commit d4620dc1fa
39 changed files with 3344 additions and 120 deletions

View File

@@ -9,6 +9,9 @@
<i class="bi bi-tools me-2"></i>유지보수 업무
</h4>
<div>
<NuxtLink to="/maintenance/stats" class="btn btn-outline-secondary me-2">
<i class="bi bi-graph-up me-1"></i>통계
</NuxtLink>
<NuxtLink to="/maintenance/upload" class="btn btn-outline-primary me-2">
<i class="bi bi-upload me-1"></i>일괄 등록
</NuxtLink>

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