1ㅊㅏ완료

This commit is contained in:
2026-01-05 02:00:13 +09:00
parent 1bbad6efa7
commit 185161db16
30 changed files with 4331 additions and 837 deletions

View File

@@ -0,0 +1,294 @@
<template>
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0"><i class="bi bi-collection me-2"></i>주간보고 취합</h4>
<NuxtLink to="/report/weekly" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i> 목록
</NuxtLink>
</div>
<!-- 조회 조건 -->
<div class="card mb-4">
<div class="card-body">
<div class="row align-items-end">
<div class="col-auto">
<label class="form-label small text-muted">보고 주차</label>
<div class="input-group">
<button type="button" class="btn btn-outline-secondary" @click="changeWeek(-1)">
<i class="bi bi-chevron-left"></i>
</button>
<span class="input-group-text bg-white" style="min-width: 160px;">
<strong>{{ selectedYear }} {{ selectedWeek }}주차</strong>
</span>
<button type="button" class="btn btn-outline-secondary" @click="changeWeek(1)">
<i class="bi bi-chevron-right"></i>
</button>
</div>
</div>
<div class="col-auto">
<span class="text-muted">{{ weekStartDate }} ~ {{ weekEndDate }}</span>
</div>
<div class="col-auto">
<button class="btn btn-primary" @click="loadAggregate" :disabled="isLoading">
<span v-if="isLoading" class="spinner-border spinner-border-sm me-1"></span>
<i v-else class="bi bi-search me-1"></i> 조회
</button>
</div>
</div>
</div>
</div>
<!-- 프로젝트 선택 -->
<div class="card mb-4" v-if="availableProjects.length > 0">
<div class="card-header d-flex justify-content-between align-items-center">
<strong><i class="bi bi-folder-check me-2"></i>프로젝트 선택</strong>
<div>
<button class="btn btn-sm btn-outline-primary me-2" @click="selectAllProjects">전체 선택</button>
<button class="btn btn-sm btn-outline-secondary" @click="deselectAllProjects">전체 해제</button>
</div>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3 col-sm-6 mb-2" v-for="proj in availableProjects" :key="proj.projectId">
<div class="form-check">
<input type="checkbox" class="form-check-input"
:id="'proj-' + proj.projectId"
:value="proj.projectId"
v-model="selectedProjectIds"
@change="filterProjects" />
<label class="form-check-label" :for="'proj-' + proj.projectId">
{{ proj.projectName }}
</label>
</div>
</div>
</div>
<div class="text-muted small mt-2">
{{ selectedProjectIds.length }} / {{ availableProjects.length }} 프로젝트 선택됨
· {{ reportCount }} 보고서
</div>
</div>
</div>
<!-- 취합 결과 -->
<div v-if="!isLoading && filteredProjects.length > 0">
<div class="card mb-4" v-for="proj in filteredProjects" :key="proj.projectId">
<div class="card-header bg-light">
<div class="d-flex justify-content-between align-items-center">
<span>
<i class="bi bi-folder2 me-2"></i>
<strong>{{ proj.projectName }}</strong>
<small class="text-muted ms-2">({{ proj.projectCode }})</small>
</span>
<span class="text-muted small">
실적 {{ formatHours(proj.totalWorkHours) }} · 계획 {{ formatHours(proj.totalPlanHours) }}
</span>
</div>
</div>
<div class="card-body">
<div class="row">
<!-- 금주 실적 -->
<div class="col-md-6">
<h6 class="text-primary mb-3"><i class="bi bi-check-circle me-1"></i>금주 실적</h6>
<div v-if="proj.workTasks.length === 0" class="text-muted small">-</div>
<div v-for="(task, idx) in proj.workTasks" :key="'work-'+idx" class="mb-2 pb-2 border-bottom">
<div class="d-flex justify-content-between">
<span>
<span class="badge me-2" :class="task.isCompleted ? 'bg-success' : 'bg-warning'">
{{ task.isCompleted ? '완료' : '진행' }}
</span>
<span style="white-space: pre-wrap;">{{ task.description }}</span>
</span>
<span class="text-nowrap ms-2">
<span class="badge bg-secondary">{{ task.authorName }}</span>
<span class="text-muted small ms-1">{{ task.hours }}h</span>
</span>
</div>
</div>
</div>
<!-- 차주 계획 -->
<div class="col-md-6">
<h6 class="text-success mb-3"><i class="bi bi-calendar-event me-1"></i>차주 계획</h6>
<div v-if="proj.planTasks.length === 0" class="text-muted small">-</div>
<div v-for="(task, idx) in proj.planTasks" :key="'plan-'+idx" class="mb-2 pb-2 border-bottom">
<div class="d-flex justify-content-between">
<span style="white-space: pre-wrap;">{{ task.description }}</span>
<span class="text-nowrap ms-2">
<span class="badge bg-secondary">{{ task.authorName }}</span>
<span class="text-muted small ms-1">{{ task.hours }}h</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 결과 -->
<div v-else-if="!isLoading && isLoaded" class="text-center text-muted py-5">
<i class="bi bi-inbox fs-1"></i>
<p class="mt-2">해당 주차에 등록된 주간보고가 없습니다.</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const { fetchCurrentUser, isAdmin } = useAuth()
const isLoading = ref(false)
const isLoaded = ref(false)
const reportCount = ref(0)
// 주차 선택
const selectedYear = ref(new Date().getFullYear())
const selectedWeek = ref(1)
const weekStartDate = ref('')
const weekEndDate = ref('')
// 프로젝트 선택
const availableProjects = ref<any[]>([])
const selectedProjectIds = ref<number[]>([])
const allProjects = ref<any[]>([])
// 취합 결과
const filteredProjects = computed(() => {
return allProjects.value.filter(p => selectedProjectIds.value.includes(p.projectId))
})
onMounted(async () => {
const user = await fetchCurrentUser()
if (!user) {
router.push('/login')
return
}
if (!isAdmin.value) {
alert('관리자만 접근할 수 있습니다.')
router.push('/report/weekly')
return
}
// 현재 주차 계산
initCurrentWeek()
await loadAggregate()
})
function initCurrentWeek() {
const now = new Date()
const jan4 = new Date(now.getFullYear(), 0, 4)
const jan4Day = jan4.getDay() || 7
const week1Monday = new Date(jan4)
week1Monday.setDate(jan4.getDate() - jan4Day + 1)
const diff = now.getTime() - week1Monday.getTime()
const weekNum = Math.floor(diff / (7 * 24 * 60 * 60 * 1000)) + 1
selectedYear.value = now.getFullYear()
selectedWeek.value = weekNum > 0 ? weekNum : 1
updateWeekDates()
}
function changeWeek(delta: number) {
let year = selectedYear.value
let week = selectedWeek.value + delta
if (week < 1) {
year--
week = getWeeksInYear(year)
} else if (week > getWeeksInYear(year)) {
year++
week = 1
}
selectedYear.value = year
selectedWeek.value = week
updateWeekDates()
}
function getWeeksInYear(year: number): number {
const dec31 = new Date(year, 11, 31)
const dayOfWeek = dec31.getDay()
return dayOfWeek >= 4 || dayOfWeek === 0 ? 53 : 52
}
function updateWeekDates() {
const { monday, sunday } = getWeekDates(selectedYear.value, selectedWeek.value)
weekStartDate.value = monday
weekEndDate.value = sunday
}
function getWeekDates(year: number, week: number): { monday: string, sunday: string } {
const jan4 = new Date(year, 0, 4)
const jan4Day = jan4.getDay() || 7
const week1Monday = new Date(jan4)
week1Monday.setDate(jan4.getDate() - jan4Day + 1)
const monday = new Date(week1Monday)
monday.setDate(week1Monday.getDate() + (week - 1) * 7)
const sunday = new Date(monday)
sunday.setDate(monday.getDate() + 6)
return {
monday: formatDate(monday),
sunday: formatDate(sunday)
}
}
function formatDate(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
async function loadAggregate() {
isLoading.value = true
try {
const res = await $fetch<any>('/api/report/weekly/aggregate', {
params: {
year: selectedYear.value,
week: selectedWeek.value
}
})
reportCount.value = res.reportCount
availableProjects.value = res.availableProjects
allProjects.value = res.projects
// 기본으로 모든 프로젝트 선택
selectedProjectIds.value = res.availableProjects.map((p: any) => p.projectId)
isLoaded.value = true
} catch (e: any) {
alert(e.data?.message || '조회에 실패했습니다.')
} finally {
isLoading.value = false
}
}
function filterProjects() {
// 체크박스 변경 시 자동 필터링 (computed로 처리)
}
function selectAllProjects() {
selectedProjectIds.value = availableProjects.value.map(p => p.projectId)
}
function deselectAllProjects() {
selectedProjectIds.value = []
}
function formatHours(hours: number): string {
if (!hours || hours <= 0) return '0h'
const days = Math.floor(hours / 8)
const remainHours = hours % 8
if (days === 0) return `${remainHours}h`
if (remainHours === 0) return `${days}`
return `${days}${remainHours}h`
}
</script>