357 lines
12 KiB
Vue
357 lines
12 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">
|
|
<i class="bi bi-journal-text me-2"></i>주간보고
|
|
</h4>
|
|
<div class="d-flex gap-2">
|
|
<NuxtLink :to="`/report/weekly/write?year=${filters.year}&week=${filters.week}`" class="btn btn-primary">
|
|
<i class="bi bi-plus me-1"></i>작성하기
|
|
</NuxtLink>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 필터 영역 -->
|
|
<div class="card mb-4">
|
|
<div class="card-body">
|
|
<div class="row g-3 align-items-end">
|
|
<!-- 작성자 -->
|
|
<div class="col-md-2">
|
|
<label class="form-label small text-muted">작성자</label>
|
|
<select class="form-select form-select-sm" v-model="filters.authorId" @change="loadReports">
|
|
<option value="">전체</option>
|
|
<option v-for="emp in employees" :key="emp.employeeId" :value="emp.employeeId">
|
|
{{ emp.employeeName }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- 연도/주차 -->
|
|
<div class="col-auto">
|
|
<div class="d-flex align-items-center gap-2">
|
|
<button class="btn btn-outline-secondary btn-sm" @click="changeWeek(-1)" title="이전 주차">
|
|
<i class="bi bi-chevron-left"></i>
|
|
</button>
|
|
<select class="form-select form-select-sm" style="width: 100px;" v-model="filters.year" @change="onYearChange">
|
|
<option v-for="y in yearOptions" :key="y" :value="y">{{ y }}년</option>
|
|
</select>
|
|
<select class="form-select form-select-sm" style="width: auto;" v-model="filters.week" @change="loadReports">
|
|
<option v-for="opt in weekOptionsWithDates" :key="opt.week" :value="opt.week">
|
|
{{ opt.label }}
|
|
</option>
|
|
</select>
|
|
<button class="btn btn-outline-secondary btn-sm" @click="changeWeek(1)" title="다음 주차" :disabled="isCurrentWeek">
|
|
<i class="bi bi-chevron-right"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 초기화 -->
|
|
<div class="col-auto">
|
|
<button class="btn btn-outline-secondary btn-sm" @click="resetFilters">
|
|
<i class="bi bi-arrow-counterclockwise me-1"></i>초기화
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 목록 -->
|
|
<div class="card">
|
|
<div class="card-body p-0">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover mb-0">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th style="width: 120px">주차</th>
|
|
<th style="width: 180px">기간</th>
|
|
<th>프로젝트</th>
|
|
<th v-if="isAdmin" style="width: 80px">작성자</th>
|
|
<th style="width: 90px">상태</th>
|
|
<th style="width: 130px">작성/수정</th>
|
|
<th style="width: 130px">제출일시</th>
|
|
<th style="width: 70px">품질</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="isLoading">
|
|
<td :colspan="isAdmin ? 8 : 7" class="text-center py-4">
|
|
<span class="spinner-border spinner-border-sm me-2"></span>로딩 중...
|
|
</td>
|
|
</tr>
|
|
<tr v-else-if="reports.length === 0">
|
|
<td :colspan="isAdmin ? 8 : 7" class="text-center py-5 text-muted">
|
|
<i class="bi bi-inbox display-4"></i>
|
|
<p class="mt-2 mb-0">조회된 주간보고가 없습니다.</p>
|
|
</td>
|
|
</tr>
|
|
<tr v-else v-for="r in reports" :key="r.reportId"
|
|
@click="router.push(`/report/weekly/${r.reportId}`)"
|
|
style="cursor: pointer;">
|
|
<td>
|
|
<strong>{{ r.reportYear }}년 {{ r.reportWeek }}주</strong>
|
|
</td>
|
|
<td class="small">
|
|
{{ formatDate(r.weekStartDate) }} ~ {{ formatDate(r.weekEndDate) }}
|
|
</td>
|
|
<td>
|
|
<span class="text-truncate d-inline-block" style="max-width: 400px;" :title="r.projectNames">
|
|
{{ r.projectNames || '-' }}
|
|
</span>
|
|
<span class="badge bg-light text-dark ms-1">{{ r.projectCount }}건</span>
|
|
</td>
|
|
<td v-if="isAdmin">{{ r.authorName }}</td>
|
|
<td>
|
|
<span :class="getStatusBadgeClass(r.reportStatus)">
|
|
{{ getStatusText(r.reportStatus) }}
|
|
</span>
|
|
</td>
|
|
<td class="small">
|
|
<div>{{ formatShortDateTime(r.createdAt) }}</div>
|
|
<div v-if="r.updatedAt && r.updatedAt !== r.createdAt" class="text-muted">
|
|
{{ formatShortDateTime(r.updatedAt) }}
|
|
</div>
|
|
</td>
|
|
<td class="small">
|
|
{{ r.submittedAt ? formatShortDateTime(r.submittedAt) : '-' }}
|
|
</td>
|
|
<td>
|
|
<span v-if="r.aiReview" class="fw-bold" :class="getQualityTextClass(r.aiReview)">
|
|
{{ getQualityGrade(r.aiReview) }}
|
|
</span>
|
|
<span v-else class="text-muted">-</span>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<div class="card-footer text-muted small" v-if="reports.length > 0">
|
|
총 {{ reports.length }}건
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
const { fetchCurrentUser, hasRole } = useAuth()
|
|
const { getCurrentWeekInfo, getActualCurrentWeekInfo, getWeekDates, getWeeksInYear, changeWeek: calcChangeWeek } = useWeekCalc()
|
|
const router = useRouter()
|
|
const route = useRoute()
|
|
|
|
const reports = ref<any[]>([])
|
|
const employees = ref<any[]>([])
|
|
const projects = ref<any[]>([])
|
|
const isLoading = ref(true)
|
|
const isAdmin = computed(() => hasRole('ROLE_ADMIN'))
|
|
|
|
const currentWeek = getCurrentWeekInfo()
|
|
const actualCurrentWeek = getActualCurrentWeekInfo() // 실제 현재 주차
|
|
const currentYear = new Date().getFullYear()
|
|
const yearOptions = [currentYear, currentYear - 1, currentYear - 2]
|
|
|
|
// 현재 주차인지 확인 (다음 버튼 비활성화용)
|
|
const isCurrentWeek = computed(() =>
|
|
filters.value.year === actualCurrentWeek.year && filters.value.week === actualCurrentWeek.week
|
|
)
|
|
|
|
// 주차 옵션 (날짜 포함, 현재 주차까지만)
|
|
const weekOptionsWithDates = computed(() => {
|
|
const weeksInYear = getWeeksInYear(filters.value.year)
|
|
// 현재 연도면 현재 주차까지만, 과거 연도면 전체
|
|
const maxWeek = filters.value.year === actualCurrentWeek.year
|
|
? actualCurrentWeek.week
|
|
: weeksInYear
|
|
|
|
return Array.from({ length: maxWeek }, (_, i) => {
|
|
const week = i + 1
|
|
const weekInfo = getWeekDates(filters.value.year, week)
|
|
const startMD = weekInfo.startDateStr.slice(5).replace('-', '/')
|
|
const endMD = weekInfo.endDateStr.slice(5).replace('-', '/')
|
|
return {
|
|
week,
|
|
label: `${week}주차 (${startMD}~${endMD})`
|
|
}
|
|
})
|
|
})
|
|
|
|
const filters = ref({
|
|
authorId: '',
|
|
year: currentWeek.year,
|
|
week: currentWeek.week
|
|
})
|
|
|
|
// 주차 변경
|
|
function changeWeek(delta: number) {
|
|
const result = calcChangeWeek(filters.value.year, filters.value.week, delta)
|
|
|
|
// 미래 주차로 이동 방지
|
|
if (result.year > actualCurrentWeek.year) return
|
|
if (result.year === actualCurrentWeek.year && result.week > actualCurrentWeek.week) return
|
|
|
|
filters.value.year = result.year
|
|
filters.value.week = result.week
|
|
loadReports()
|
|
}
|
|
|
|
// 연도 변경 시 주차 범위 조정
|
|
function onYearChange() {
|
|
const maxWeek = getWeeksInYear(filters.value.year)
|
|
if (filters.value.week > maxWeek) {
|
|
filters.value.week = maxWeek
|
|
}
|
|
loadReports()
|
|
}
|
|
|
|
onMounted(async () => {
|
|
const user = await fetchCurrentUser()
|
|
if (!user) {
|
|
router.push('/login')
|
|
return
|
|
}
|
|
|
|
// URL 쿼리 파라미터가 있으면 필터에 적용
|
|
if (route.query.year && route.query.week) {
|
|
filters.value.year = parseInt(route.query.year as string)
|
|
filters.value.week = parseInt(route.query.week as string)
|
|
}
|
|
|
|
// 직원 목록 로드
|
|
await loadFilterOptions()
|
|
|
|
loadReports()
|
|
})
|
|
|
|
async function loadFilterOptions() {
|
|
try {
|
|
// 직원 목록
|
|
const empRes = await $fetch<any>('/api/employee/list')
|
|
employees.value = empRes.employees || []
|
|
|
|
// 프로젝트 목록
|
|
const projRes = await $fetch<any>('/api/project/list')
|
|
projects.value = projRes.projects || []
|
|
} catch (e) {
|
|
console.error(e)
|
|
}
|
|
}
|
|
|
|
async function loadReports() {
|
|
isLoading.value = true
|
|
try {
|
|
const params = new URLSearchParams()
|
|
|
|
if (filters.value.authorId) params.append('authorId', filters.value.authorId)
|
|
if (filters.value.year) params.append('year', String(filters.value.year))
|
|
if (filters.value.week) params.append('week', String(filters.value.week))
|
|
|
|
const res = await $fetch<any>(`/api/report/weekly/list?${params.toString()}`)
|
|
reports.value = res.reports || []
|
|
} catch (e) {
|
|
console.error(e)
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
function resetFilters() {
|
|
filters.value = {
|
|
authorId: '',
|
|
year: currentWeek.year,
|
|
week: currentWeek.week
|
|
}
|
|
loadReports()
|
|
}
|
|
|
|
function formatDate(dateStr: string) {
|
|
if (!dateStr) return ''
|
|
return dateStr.split('T')[0].replace(/-/g, '.')
|
|
}
|
|
|
|
function formatDateTime(dateStr: string) {
|
|
if (!dateStr) return '-'
|
|
const d = new Date(dateStr)
|
|
return `${d.getMonth() + 1}/${d.getDate()}`
|
|
}
|
|
|
|
function formatSubmittedAt(dateStr: string) {
|
|
if (!dateStr) return '-'
|
|
const d = new Date(dateStr)
|
|
const days = ['일', '월', '화', '수', '목', '금', '토']
|
|
const year = d.getFullYear()
|
|
const month = String(d.getMonth() + 1).padStart(2, '0')
|
|
const date = String(d.getDate()).padStart(2, '0')
|
|
const day = days[d.getDay()]
|
|
const hours = String(d.getHours()).padStart(2, '0')
|
|
const minutes = String(d.getMinutes()).padStart(2, '0')
|
|
const seconds = String(d.getSeconds()).padStart(2, '0')
|
|
return `${year}-${month}-${date}(${day}) ${hours}:${minutes}:${seconds}`
|
|
}
|
|
|
|
function getStatusBadgeClass(status: string) {
|
|
const classes: Record<string, string> = {
|
|
'DRAFT': 'badge bg-warning',
|
|
'SUBMITTED': 'badge bg-success',
|
|
'AGGREGATED': 'badge bg-success'
|
|
}
|
|
return classes[status] || 'badge bg-secondary'
|
|
}
|
|
|
|
function getStatusText(status: string) {
|
|
const texts: Record<string, string> = {
|
|
'DRAFT': '작성중',
|
|
'SUBMITTED': '제출',
|
|
'AGGREGATED': '제출'
|
|
}
|
|
return texts[status] || status
|
|
}
|
|
|
|
// 짧은 날짜시간 형식 (MM/DD HH:mm)
|
|
function formatShortDateTime(dateStr: string) {
|
|
if (!dateStr) return '-'
|
|
const d = new Date(dateStr)
|
|
const month = String(d.getMonth() + 1).padStart(2, '0')
|
|
const date = String(d.getDate()).padStart(2, '0')
|
|
const hours = String(d.getHours()).padStart(2, '0')
|
|
const minutes = String(d.getMinutes()).padStart(2, '0')
|
|
return `${month}/${date} ${hours}:${minutes}`
|
|
}
|
|
|
|
// 품질 등급 추출
|
|
function getQualityGrade(aiReview: string): string {
|
|
if (!aiReview) return '-'
|
|
try {
|
|
const data = JSON.parse(aiReview)
|
|
const score = data.overall || 0
|
|
if (score >= 8) return '우수'
|
|
if (score >= 5) return '적합'
|
|
return '미흡'
|
|
} catch {
|
|
return '-'
|
|
}
|
|
}
|
|
|
|
// 품질 등급 색상 (적합/우수=녹색, 미흡=노랑)
|
|
function getQualityTextClass(aiReview: string): string {
|
|
if (!aiReview) return ''
|
|
try {
|
|
const data = JSON.parse(aiReview)
|
|
const score = data.overall || 0
|
|
if (score >= 5) return 'text-success'
|
|
return 'text-warning'
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.form-label {
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
</style>
|