대시보드와 주간보고 기능 업데이트

This commit is contained in:
2026-01-10 14:40:01 +09:00
parent 0dd4b561f0
commit e4627caa4c
26 changed files with 3329 additions and 1720 deletions

View File

@@ -8,10 +8,7 @@
<i class="bi bi-journal-text me-2"></i>주간보고
</h4>
<div class="d-flex gap-2">
<NuxtLink v-if="isAdmin" to="/report/summary" class="btn btn-outline-primary">
<i class="bi bi-collection me-1"></i>취합하기
</NuxtLink>
<NuxtLink to="/report/weekly/write" class="btn btn-primary">
<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>
@@ -21,16 +18,8 @@
<div class="card mb-4">
<div class="card-body">
<div class="row g-3 align-items-end">
<!-- 전체보기 (관리자만) -->
<div class="col-auto" v-if="isAdmin">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="viewAll" v-model="filters.viewAll" @change="loadReports">
<label class="form-check-label" for="viewAll">전체 보기</label>
</div>
</div>
<!-- 작성자 -->
<div class="col-md-2" v-if="isAdmin">
<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>
@@ -40,21 +29,24 @@
</select>
</div>
<!-- 연도 -->
<div class="col-md-1">
<label class="form-label small text-muted">연도</label>
<select class="form-select form-select-sm" v-model="filters.year" @change="loadReports">
<option v-for="y in yearOptions" :key="y" :value="y">{{ y }}</option>
</select>
</div>
<!-- 주차 -->
<div class="col-md-1">
<label class="form-label small text-muted">주차</label>
<select class="form-select form-select-sm" v-model="filters.week" @change="loadReports">
<option value="">전체</option>
<option v-for="w in weekOptions" :key="w" :value="w">{{ w }}</option>
</select>
<!-- 연도/주차 -->
<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>
<!-- 초기화 -->
@@ -76,20 +68,22 @@
<tr>
<th style="width: 120px">주차</th>
<th style="width: 180px">기간</th>
<th v-if="isAdmin" style="width: 120px">작성자</th>
<th>프로젝트</th>
<th v-if="isAdmin" style="width: 80px">작성자</th>
<th style="width: 90px">상태</th>
<th style="width: 100px">제출일</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 ? 6 : 5" class="text-center py-4">
<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 ? 6 : 5" class="text-center py-5 text-muted">
<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>
@@ -103,21 +97,33 @@
<td class="small">
{{ formatDate(r.weekStartDate) }} ~ {{ formatDate(r.weekEndDate) }}
</td>
<td v-if="isAdmin">
<span class="badge bg-secondary">{{ r.authorName }}</span>
</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">{{ formatDateTime(r.submittedAt || r.createdAt) }}</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>
@@ -133,24 +139,74 @@
<script setup lang="ts">
const { fetchCurrentUser } = 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 = ref(false)
const currentWeek = getCurrentWeekInfo()
const actualCurrentWeek = getActualCurrentWeekInfo() // 실제 현재 주차
const currentYear = new Date().getFullYear()
const yearOptions = [currentYear, currentYear - 1, currentYear - 2]
const weekOptions = Array.from({ length: 53 }, (_, i) => i + 1)
// 현재 주차인지 확인 (다음 버튼 비활성화용)
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({
viewAll: false,
authorId: '',
year: currentYear,
week: ''
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) {
@@ -160,11 +216,15 @@ onMounted(async () => {
isAdmin.value = user.employeeEmail === 'coziny@gmail.com'
// 직원, 프로젝트 목록 로드 (관리자용)
if (isAdmin.value) {
await loadFilterOptions()
// 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()
})
@@ -187,7 +247,6 @@ async function loadReports() {
try {
const params = new URLSearchParams()
if (filters.value.viewAll) params.append('viewAll', 'true')
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))
@@ -203,10 +262,9 @@ async function loadReports() {
function resetFilters() {
filters.value = {
viewAll: false,
authorId: '',
year: currentYear,
week: ''
year: currentWeek.year,
week: currentWeek.week
}
loadReports()
}
@@ -222,11 +280,25 @@ function formatDateTime(dateStr: string) {
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-secondary',
'DRAFT': 'badge bg-warning',
'SUBMITTED': 'badge bg-success',
'AGGREGATED': 'badge bg-info'
'AGGREGATED': 'badge bg-success'
}
return classes[status] || 'badge bg-secondary'
}
@@ -234,11 +306,49 @@ function getStatusBadgeClass(status: string) {
function getStatusText(status: string) {
const texts: Record<string, string> = {
'DRAFT': '작성중',
'SUBMITTED': '제출완료',
'AGGREGATED': '취합완료'
'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>