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

@@ -43,6 +43,19 @@
</div>
</div>
<!-- AI 요약 -->
<div class="card mb-4" v-if="summary.aiSummary">
<div class="card-header bg-primary bg-opacity-10">
<i class="bi bi-robot me-2"></i><strong>AI 요약</strong>
<small class="text-muted ms-2" v-if="summary.aiSummaryAt">
({{ formatDateTime(summary.aiSummaryAt) }})
</small>
</div>
<div class="card-body">
<div class="ai-summary" v-html="renderMarkdown(summary.aiSummary)"></div>
</div>
</div>
<!-- PM 검토 영역 -->
<div class="card mb-4">
<div class="card-header">
@@ -121,17 +134,29 @@
<i class="bi bi-check-circle me-1"></i>금주 실적
</label>
<div class="p-2 bg-light rounded">
<pre class="mb-0 small" style="white-space: pre-wrap;">{{ report.workDescription || '-' }}</pre>
<div v-if="report.workTasks && report.workTasks.length > 0">
<div v-for="(task, idx) in report.workTasks" :key="idx" class="mb-1">
<span class="badge me-1" :class="task.isCompleted ? 'bg-success' : 'bg-warning'">
{{ task.isCompleted ? '완료' : '진행' }}
</span>
<span class="small" style="white-space: pre-wrap;">{{ task.description }}</span>
<span class="text-muted small ms-1">({{ task.hours }}h)</span>
</div>
</div>
<div v-else class="small text-muted">-</div>
</div>
</div>
<!-- 차주 계획 -->
<div class="mb-3" v-if="report.planDescription">
<div class="mb-3" v-if="report.planTasks && report.planTasks.length > 0">
<label class="form-label text-muted small">
<i class="bi bi-calendar-event me-1"></i>차주 계획
</label>
<div class="p-2 bg-light rounded">
<pre class="mb-0 small" style="white-space: pre-wrap;">{{ report.planDescription }}</pre>
<div v-for="(task, idx) in report.planTasks" :key="idx" class="mb-1">
<span class="small" style="white-space: pre-wrap;">{{ task.description }}</span>
<span class="text-muted small ms-1">({{ task.hours }}h)</span>
</div>
</div>
</div>
@@ -237,4 +262,38 @@ function formatDateTime(dateStr: string) {
const d = new Date(dateStr)
return d.toLocaleString('ko-KR')
}
// 간단한 마크다운 렌더링
function renderMarkdown(text: string): string {
if (!text) return ''
return text
// 헤더
.replace(/^### (.+)$/gm, '<h5 class="mt-3 mb-2">$1</h5>')
.replace(/^## (.+)$/gm, '<h4 class="mt-3 mb-2">$1</h4>')
.replace(/^# (.+)$/gm, '<h3 class="mt-3 mb-2">$1</h3>')
// 볼드
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
// 이탤릭
.replace(/\*(.+?)\*/g, '<em>$1</em>')
// 리스트
.replace(/^- (.+)$/gm, '<li>$1</li>')
.replace(/(<li>.*<\/li>\n?)+/g, '<ul class="mb-2">$&</ul>')
// 줄바꿈
.replace(/\n/g, '<br>')
}
</script>
<style scoped>
.ai-summary {
line-height: 1.7;
}
.ai-summary h3, .ai-summary h4, .ai-summary h5 {
color: #333;
}
.ai-summary ul {
padding-left: 1.5rem;
}
.ai-summary li {
margin-bottom: 0.25rem;
}
</style>

View File

@@ -0,0 +1,426 @@
<template>
<div>
<AppHeader />
<div class="container-fluid py-4">
<div class="mb-4">
<NuxtLink to="/report/summary" class="text-decoration-none">
<i class="bi bi-arrow-left me-1"></i> 목록으로
</NuxtLink>
</div>
<div v-if="weekInfo">
<!-- 주차 헤더 -->
<div class="card mb-4">
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="bi bi-calendar-week me-2"></i>
{{ weekInfo.reportYear }} {{ weekInfo.reportWeek }}주차 취합 보고서
</h5>
<button class="btn btn-light btn-sm" @click="doReaggregate" :disabled="isReaggregating">
<span v-if="isReaggregating">
<span class="spinner-border spinner-border-sm me-1"></span>처리중...
</span>
<span v-else>
<i class="bi bi-arrow-repeat me-1"></i>취합 다시하기
</span>
</button>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3">
<label class="form-label text-muted small">기간</label>
<p class="mb-0 fw-bold">{{ formatDate(weekInfo.weekStartDate) }} ~ {{ formatDate(weekInfo.weekEndDate) }}</p>
</div>
<div class="col-md-3">
<label class="form-label text-muted small">프로젝트</label>
<p class="mb-0 fw-bold">{{ weekInfo.totalProjects }}</p>
</div>
<div class="col-md-3">
<label class="form-label text-muted small"> 투입시간</label>
<p class="mb-0 fw-bold">{{ formatHours(weekInfo.totalWorkHours) }}</p>
</div>
<div class="col-md-3 text-end">
<button class="btn btn-outline-primary btn-sm" @click="exportToExcel">
<i class="bi bi-file-earmark-excel me-1"></i>Excel 다운로드
</button>
</div>
</div>
</div>
</div>
<!-- 프로젝트별 실적/계획 테이블 -->
<div class="card mb-4">
<div class="card-header">
<i class="bi bi-table me-2"></i>프로젝트별 주간보고
</div>
<div class="table-responsive">
<table class="table table-bordered mb-0 summary-table">
<thead class="table-light">
<tr>
<th style="width: 280px; min-width: 280px;" class="align-middle text-center">프로젝트</th>
<th class="align-middle text-center">
금주 실적
<i class="bi bi-robot text-info ms-1" title="AI 요약"></i>
</th>
<th class="align-middle text-center">
차주 계획
<i class="bi bi-robot text-info ms-1" title="AI 요약"></i>
</th>
</tr>
</thead>
<tbody>
<tr v-for="proj in projects" :key="proj.projectId">
<td class="project-cell">
<div class="fw-bold text-primary mb-2">{{ proj.projectName }}</div>
<!-- 인원별 실적/계획 미니 테이블 -->
<table class="table table-sm table-bordered mb-0 mini-table" v-if="proj.memberHours?.length > 0">
<thead>
<tr class="table-secondary">
<th class="text-center" style="width: 40%">개발자</th>
<th class="text-center" style="width: 30%">실적</th>
<th class="text-center" style="width: 30%">계획</th>
</tr>
</thead>
<tbody>
<tr v-for="(mh, idx) in proj.memberHours" :key="idx">
<td class="small">{{ mh.name }}</td>
<td class="text-center">
<span class="badge bg-primary">{{ formatMemberHours(mh.workHours) }}</span>
</td>
<td class="text-center">
<span class="badge bg-info">{{ formatMemberHours(mh.planHours) }}</span>
</td>
</tr>
</tbody>
<tfoot>
<tr class="table-light fw-bold">
<td class="text-center small">합계</td>
<td class="text-center">
<span class="badge bg-primary">{{ formatMemberHours(sumHours(proj.memberHours, 'workHours')) }}</span>
</td>
<td class="text-center">
<span class="badge bg-info">{{ formatMemberHours(sumHours(proj.memberHours, 'planHours')) }}</span>
</td>
</tr>
</tfoot>
</table>
</td>
<!-- 금주 실적 -->
<td class="task-cell">
<div v-if="!showRaw[proj.projectId]?.work">
<div class="ai-badge mb-2">
<i class="bi bi-robot me-1"></i>AI 요약
</div>
<div class="ai-content" v-html="renderMarkdown(proj.aiWorkSummary || '요약 없음')"></div>
<button class="btn btn-sm btn-link p-0 mt-2" @click="toggleRaw(proj.projectId, 'work')">
<i class="bi bi-list-ul me-1"></i>원문보기 ({{ proj.workTasks.length }})
</button>
</div>
<div v-else>
<button class="btn btn-sm btn-link p-0 mb-2" @click="toggleRaw(proj.projectId, 'work')">
<i class="bi bi-robot me-1"></i>AI 요약보기
</button>
<div v-if="proj.workTasks.length > 0">
<div v-for="(task, idx) in proj.workTasks" :key="'w'+idx" class="task-item">
<span class="badge me-1" :class="task.isCompleted ? 'bg-success' : 'bg-warning'">
{{ task.isCompleted ? '완료' : '진행' }}
</span>
<span class="task-desc">{{ task.description }}</span>
<span class="text-muted small ms-1">({{ task.authorName }}, {{ task.hours }}h)</span>
</div>
</div>
<div v-else class="text-muted">-</div>
</div>
</td>
<!-- 차주 계획 -->
<td class="task-cell">
<div v-if="!showRaw[proj.projectId]?.plan">
<div class="ai-badge mb-2">
<i class="bi bi-robot me-1"></i>AI 요약
</div>
<div class="ai-content" v-html="renderMarkdown(proj.aiPlanSummary || '요약 없음')"></div>
<button class="btn btn-sm btn-link p-0 mt-2" @click="toggleRaw(proj.projectId, 'plan')">
<i class="bi bi-list-ul me-1"></i>원문보기 ({{ proj.planTasks.length }})
</button>
</div>
<div v-else>
<button class="btn btn-sm btn-link p-0 mb-2" @click="toggleRaw(proj.projectId, 'plan')">
<i class="bi bi-robot me-1"></i>AI 요약보기
</button>
<div v-if="proj.planTasks.length > 0">
<div v-for="(task, idx) in proj.planTasks" :key="'p'+idx" class="task-item">
<span class="task-desc">{{ task.description }}</span>
<span class="text-muted small ms-1">({{ task.authorName }}, {{ task.hours }}h)</span>
</div>
</div>
<div v-else class="text-muted">-</div>
</div>
</td>
</tr>
<tr v-if="projects.length === 0">
<td colspan="3" class="text-center py-5 text-muted">
데이터가 없습니다.
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 인원별 시간 현황 -->
<div class="card">
<div class="card-header">
<i class="bi bi-people me-2"></i>인원별 시간 현황
</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>이름</th>
<th class="text-center" style="width: 120px">금주 수행</th>
<th class="text-center" style="width: 120px">차주 계획</th>
<th class="text-center" style="width: 150px">차주 여유</th>
<th style="width: 200px">여유율</th>
</tr>
</thead>
<tbody>
<tr v-for="m in members" :key="m.employeeId">
<td>
<i class="bi bi-person me-1"></i>{{ m.employeeName }}
</td>
<td class="text-center">
<span class="badge bg-primary">{{ formatMemberHours(m.workHours) }}</span>
</td>
<td class="text-center">
<span class="badge bg-info">{{ formatMemberHours(m.planHours) }}</span>
</td>
<td class="text-center">
<span class="badge" :class="getAvailableClass(m.availableHours)">
{{ formatMemberHours(m.availableHours) }}
</span>
</td>
<td>
<div class="progress" style="height: 20px;">
<div
class="progress-bar"
:class="getProgressClass(m.planHours)"
:style="{ width: Math.min(100, (m.planHours / 40) * 100) + '%' }"
>
{{ Math.round((m.planHours / 40) * 100) }}%
</div>
</div>
</td>
</tr>
<tr v-if="members.length === 0">
<td colspan="5" class="text-center py-3 text-muted">
데이터가 없습니다.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="text-center py-5" v-else-if="isLoading">
<div class="spinner-border text-primary"></div>
<p class="mt-2 text-muted">로딩중...</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
const router = useRouter()
const route = useRoute()
const { fetchCurrentUser } = useAuth()
const weekInfo = ref<any>(null)
const projects = ref<any[]>([])
const members = ref<any[]>([])
const isLoading = ref(true)
const isReaggregating = ref(false)
const showRaw = reactive<Record<number, { work: boolean, plan: boolean }>>({})
onMounted(async () => {
const user = await fetchCurrentUser()
if (!user) {
router.push('/login')
return
}
await loadData()
})
async function loadData() {
isLoading.value = true
try {
const year = route.params.year
const week = route.params.week
const res = await $fetch<{ weekInfo: any; projects: any[]; members: any[] }>('/api/report/summary/week/detail', {
query: { year, week }
})
weekInfo.value = res.weekInfo
projects.value = res.projects || []
members.value = res.members || []
for (const p of projects.value) {
showRaw[p.projectId] = { work: false, plan: false }
}
} catch (e: any) {
alert(e.data?.message || '데이터를 불러오는데 실패했습니다.')
router.push('/report/summary')
} finally {
isLoading.value = false
}
}
async function doReaggregate() {
if (!confirm('해당 주차의 모든 프로젝트를 다시 취합하시겠습니까?\nAI 요약이 새로 생성됩니다.')) {
return
}
isReaggregating.value = true
try {
const projectIds = projects.value.map(p => p.projectId)
await $fetch('/api/report/summary/aggregate', {
method: 'POST',
body: {
projectIds,
reportYear: weekInfo.value.reportYear,
reportWeek: weekInfo.value.reportWeek
}
})
alert('취합이 완료되었습니다.')
await loadData()
} catch (e: any) {
alert(e.data?.message || '취합에 실패했습니다.')
} finally {
isReaggregating.value = false
}
}
function toggleRaw(projectId: number, type: 'work' | 'plan') {
if (!showRaw[projectId]) {
showRaw[projectId] = { work: false, plan: false }
}
showRaw[projectId][type] = !showRaw[projectId][type]
}
function formatDate(dateStr: string) {
if (!dateStr) return '-'
const d = new Date(dateStr)
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
}
function formatHours(hours: number): string {
if (!hours || hours <= 0) return '0h'
const days = Math.floor(hours / 8)
const remain = hours % 8
if (days === 0) return `${remain}h`
if (remain === 0) return `${days}`
return `${days}${remain}h`
}
function formatMemberHours(hours: number): string {
if (!hours || hours <= 0) return '0h'
const days = Math.floor(hours / 8)
const remain = hours % 8
if (days === 0) return `${hours}h`
if (remain === 0) return `${days}`
return `${days}${remain}h`
}
function sumHours(members: any[], key: string): number {
if (!members || members.length === 0) return 0
return members.reduce((sum, m) => sum + (m[key] || 0), 0)
}
function renderMarkdown(text: string): string {
if (!text) return ''
return text
.replace(/^### (.+)$/gm, '<strong>$1</strong><br>')
.replace(/^## (.+)$/gm, '<strong>$1</strong><br>')
.replace(/^# (.+)$/gm, '<strong>$1</strong><br>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/^- (.+)$/gm, '• $1<br>')
.replace(/\n/g, '<br>')
}
function getAvailableClass(hours: number): string {
if (hours >= 16) return 'bg-success'
if (hours >= 8) return 'bg-warning text-dark'
return 'bg-danger'
}
function getProgressClass(planHours: number): string {
const percent = (planHours / 40) * 100
if (percent >= 100) return 'bg-danger'
if (percent >= 80) return 'bg-warning'
return 'bg-success'
}
function exportToExcel() {
alert('Excel 다운로드 기능은 준비 중입니다.')
}
</script>
<style scoped>
.summary-table {
table-layout: fixed;
}
.project-cell {
vertical-align: top;
background-color: #f8f9fa;
padding: 0.75rem;
}
.task-cell {
vertical-align: top;
padding: 0.75rem;
}
.task-item {
padding: 0.25rem 0;
border-bottom: 1px dashed #eee;
}
.task-item:last-child {
border-bottom: none;
}
.task-desc {
white-space: pre-wrap;
word-break: break-word;
}
.ai-badge {
display: inline-block;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 0.15rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
}
.ai-content {
font-size: 0.9rem;
line-height: 1.6;
color: #333;
}
.mini-table {
font-size: 0.8rem;
}
.mini-table th {
padding: 0.25rem 0.5rem;
font-weight: 600;
}
.mini-table td {
padding: 0.2rem 0.4rem;
}
.mini-table .badge {
font-size: 0.7rem;
}
</style>

View File

@@ -6,7 +6,7 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4><i class="bi bi-collection me-2"></i>취합 보고서</h4>
<p class="text-muted mb-0">프로젝트별 주간보고 취합 목록</p>
<p class="text-muted mb-0">주차별 취합 보고서 목록</p>
</div>
<button class="btn btn-primary" @click="showAggregateModal = true">
<i class="bi bi-plus-lg me-1"></i> 취합하기
@@ -16,81 +16,68 @@
<!-- 필터 -->
<div class="card mb-4">
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">프로젝트</label>
<select class="form-select" v-model="filter.projectId">
<option value="">전체</option>
<option v-for="p in projects" :key="p.projectId" :value="p.projectId">
{{ p.projectName }}
</option>
</select>
</div>
<div class="row g-3 align-items-end">
<div class="col-md-2">
<label class="form-label">연도</label>
<select class="form-select" v-model="filter.year">
<select class="form-select" v-model="filter.year" @change="loadWeeklyList">
<option v-for="y in years" :key="y" :value="y">{{ y }}</option>
</select>
</div>
<div class="col-md-2 d-flex align-items-end">
<button class="btn btn-outline-secondary" @click="loadSummaries">
<i class="bi bi-search me-1"></i> 조회
</button>
</div>
</div>
</div>
</div>
<!-- 취합 목록 -->
<!-- 주차별 목록 -->
<div class="card">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th style="width: 80px">주차</th>
<th style="width: 120px">주차</th>
<th style="width: 180px">기간</th>
<th>프로젝트</th>
<th style="width: 150px">기간</th>
<th style="width: 100px">참여인원</th>
<th style="width: 100px"> 시간</th>
<th style="width: 100px">상태</th>
<th style="width: 150px">취합일시</th>
<th style="width: 80px">상세</th>
</tr>
</thead>
<tbody>
<tr v-for="summary in summaries" :key="summary.summaryId">
<tr v-for="week in weeklyList" :key="week.reportWeek">
<td>
<strong>W{{ String(summary.reportWeek).padStart(2, '0') }}</strong>
</td>
<td>{{ summary.projectName }}</td>
<td>
<small>{{ formatDateRange(summary.weekStartDate, summary.weekEndDate) }}</small>
<strong class="text-primary">{{ week.reportWeek }}주차</strong>
</td>
<td>
<span class="badge bg-primary">{{ summary.memberCount }}</span>
<small>{{ formatDateRange(week.weekStartDate, week.weekEndDate) }}</small>
</td>
<td>
{{ summary.totalWorkHours ? summary.totalWorkHours + 'h' : '-' }}
</td>
<td>
<span :class="getStatusBadgeClass(summary.summaryStatus)">
{{ getStatusText(summary.summaryStatus) }}
<span class="badge bg-secondary me-1" v-for="p in week.projects.slice(0, 3)" :key="p">
{{ p }}
</span>
<span v-if="week.projects.length > 3" class="text-muted small">
+{{ week.projects.length - 3 }}
</span>
</td>
<td>
<small>{{ formatDateTime(summary.aggregatedAt) }}</small>
<span class="badge bg-primary">{{ week.totalMembers }}</span>
</td>
<td>
{{ week.totalWorkHours ? formatHours(week.totalWorkHours) : '-' }}
</td>
<td>
<small>{{ formatDateTime(week.latestAggregatedAt) }}</small>
</td>
<td>
<NuxtLink
:to="`/report/summary/${summary.summaryId}`"
:to="`/report/summary/${filter.year}/${week.reportWeek}`"
class="btn btn-sm btn-outline-primary"
>
<i class="bi bi-eye"></i>
</NuxtLink>
</td>
</tr>
<tr v-if="summaries.length === 0">
<td colspan="8" class="text-center py-5 text-muted">
<tr v-if="weeklyList.length === 0">
<td colspan="7" class="text-center py-5 text-muted">
<i class="bi bi-inbox display-4"></i>
<p class="mt-2 mb-0">취합된 보고서가 없습니다.</p>
</td>
@@ -105,7 +92,7 @@
<div class="modal fade" :class="{ show: showAggregateModal }"
:style="{ display: showAggregateModal ? 'block' : 'none' }"
tabindex="-1">
<div class="modal-dialog">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
@@ -114,39 +101,76 @@
<button type="button" class="btn-close" @click="showAggregateModal = false"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">프로젝트 <span class="text-danger">*</span></label>
<select class="form-select" v-model="aggregateForm.projectId">
<option value="">선택하세요</option>
<option v-for="p in projects" :key="p.projectId" :value="p.projectId">
{{ p.projectName }}
</option>
</select>
</div>
<div class="row">
<div class="col-6">
<div class="row mb-3">
<div class="col-4">
<label class="form-label">연도 <span class="text-danger">*</span></label>
<select class="form-select" v-model="aggregateForm.reportYear">
<select class="form-select" v-model="aggregateForm.reportYear" @change="loadAvailableProjects">
<option v-for="y in years" :key="y" :value="y">{{ y }}</option>
</select>
</div>
<div class="col-6">
<div class="col-8">
<label class="form-label">주차 <span class="text-danger">*</span></label>
<select class="form-select" v-model="aggregateForm.reportWeek">
<option v-for="w in 53" :key="w" :value="w">W{{ String(w).padStart(2, '0') }}</option>
<select class="form-select" v-model="aggregateForm.reportWeek" @change="loadAvailableProjects">
<option v-for="w in 53" :key="w" :value="w">
{{ w }}주차 ({{ getWeekDateRange(aggregateForm.reportYear, w) }})
</option>
</select>
</div>
</div>
<div class="alert alert-info mt-3 mb-0">
<!-- 프로젝트 선택 -->
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label mb-0">프로젝트 선택 <span class="text-danger">*</span></label>
<div>
<button type="button" class="btn btn-sm btn-outline-primary me-1" @click="selectAllAvailable">전체 선택</button>
<button type="button" class="btn btn-sm btn-outline-secondary" @click="deselectAllAvailable">전체 해제</button>
</div>
</div>
<div v-if="isLoadingProjects" class="text-center py-3">
<span class="spinner-border spinner-border-sm me-1"></span> 프로젝트 조회 ...
</div>
<div v-else-if="availableProjects.length === 0" class="alert alert-warning mb-0">
<i class="bi bi-exclamation-triangle me-2"></i>
해당 주차에 제출된 주간보고가 없습니다.
</div>
<div v-else class="border rounded p-3" style="max-height: 300px; overflow-y: auto;">
<div class="row">
<div class="col-md-6" v-for="proj in availableProjects" :key="proj.projectId">
<div class="form-check mb-2">
<input type="checkbox" class="form-check-input"
:id="'agg-proj-' + proj.projectId"
:value="proj.projectId"
v-model="aggregateForm.selectedProjectIds" />
<label class="form-check-label" :for="'agg-proj-' + proj.projectId">
{{ proj.projectName }}
<small class="text-muted">({{ proj.reportCount }})</small>
</label>
</div>
</div>
</div>
</div>
<div class="text-muted small mt-2" v-if="availableProjects.length > 0">
{{ aggregateForm.selectedProjectIds.length }} / {{ availableProjects.length }} 프로젝트 선택됨
</div>
</div>
<div class="alert alert-info mb-0" v-if="aggregateForm.selectedProjectIds.length > 0">
<i class="bi bi-info-circle me-2"></i>
선택한 프로젝트/주차의 제출된 보고서를 취합합니다.
{{ aggregateForm.reportYear }} {{ aggregateForm.reportWeek }}주차,
{{ aggregateForm.selectedProjectIds.length }} 프로젝트의 보고서를 취합합니다.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="showAggregateModal = false">
취소
</button>
<button type="button" class="btn btn-primary" @click="doAggregate" :disabled="isAggregating">
<button type="button" class="btn btn-primary" @click="doAggregate"
:disabled="isAggregating || aggregateForm.selectedProjectIds.length === 0">
<span v-if="isAggregating">
<span class="spinner-border spinner-border-sm me-1"></span>취합 ...
</span>
@@ -163,6 +187,8 @@
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
const { fetchCurrentUser } = useAuth()
const { getCurrentWeekInfo } = useWeekCalc()
const router = useRouter()
@@ -172,20 +198,20 @@ const currentWeek = getCurrentWeekInfo()
const years = [currentYear, currentYear - 1, currentYear - 2]
const filter = ref({
projectId: '',
year: currentYear
})
const summaries = ref<any[]>([])
const projects = ref<any[]>([])
const weeklyList = ref<any[]>([])
// 취합 모달
const showAggregateModal = ref(false)
const isAggregating = ref(false)
const isLoadingProjects = ref(false)
const availableProjects = ref<any[]>([])
const aggregateForm = ref({
projectId: '',
reportYear: currentYear,
reportWeek: currentWeek.week > 1 ? currentWeek.week - 1 : 1 // 기본값: 지난주
reportWeek: currentWeek.week > 1 ? currentWeek.week - 1 : 1,
selectedProjectIds: [] as number[]
})
onMounted(async () => {
@@ -195,51 +221,59 @@ onMounted(async () => {
return
}
await loadProjects()
await loadSummaries()
await loadWeeklyList()
})
async function loadProjects() {
async function loadWeeklyList() {
try {
const res = await $fetch<{ projects: any[] }>('/api/project/list')
projects.value = res.projects || []
const res = await $fetch<{ weeks: any[] }>('/api/report/summary/weekly-list', {
query: { year: filter.value.year }
})
weeklyList.value = res.weeks || []
} catch (e) {
console.error('Load projects error:', e)
console.error('Load weekly list error:', e)
}
}
async function loadSummaries() {
async function loadAvailableProjects() {
isLoadingProjects.value = true
try {
const query: Record<string, any> = { year: filter.value.year }
if (filter.value.projectId) query.projectId = filter.value.projectId
const res = await $fetch<{ summaries: any[] }>('/api/report/summary/list', { query })
summaries.value = res.summaries || []
const res = await $fetch<{ projects: any[] }>('/api/report/summary/available-projects', {
query: {
year: aggregateForm.value.reportYear,
week: aggregateForm.value.reportWeek
}
})
availableProjects.value = res.projects || []
aggregateForm.value.selectedProjectIds = availableProjects.value.map(p => p.projectId)
} catch (e) {
console.error('Load summaries error:', e)
console.error('Load available projects error:', e)
availableProjects.value = []
} finally {
isLoadingProjects.value = false
}
}
async function doAggregate() {
if (!aggregateForm.value.projectId) {
if (aggregateForm.value.selectedProjectIds.length === 0) {
alert('프로젝트를 선택해주세요.')
return
}
isAggregating.value = true
try {
const res = await $fetch<{ success: boolean; memberCount: number }>('/api/report/summary/aggregate', {
const res = await $fetch<{ success: boolean; summaryCount: number; totalMembers: number }>('/api/report/summary/aggregate', {
method: 'POST',
body: {
projectId: parseInt(aggregateForm.value.projectId as string),
projectIds: aggregateForm.value.selectedProjectIds,
reportYear: aggregateForm.value.reportYear,
reportWeek: aggregateForm.value.reportWeek
}
})
alert(`취합 완료! (${res.memberCount}의 보고서)`)
alert(`취합 완료! (${res.summaryCount}개 프로젝트, 총 ${res.totalMembers}명)`)
showAggregateModal.value = false
await loadSummaries()
await loadWeeklyList()
} catch (e: any) {
alert(e.data?.message || '취합에 실패했습니다.')
} finally {
@@ -247,23 +281,38 @@ async function doAggregate() {
}
}
function getStatusBadgeClass(status: string) {
const classes: Record<string, string> = {
'AGGREGATED': 'badge bg-info',
'REVIEWED': 'badge bg-success'
}
return classes[status] || 'badge bg-secondary'
function selectAllAvailable() {
aggregateForm.value.selectedProjectIds = availableProjects.value.map(p => p.projectId)
}
function getStatusText(status: string) {
const texts: Record<string, string> = {
'AGGREGATED': '취합완료',
'REVIEWED': '검토완료'
function deselectAllAvailable() {
aggregateForm.value.selectedProjectIds = []
}
watch(showAggregateModal, (val) => {
if (val) {
loadAvailableProjects()
}
return texts[status] || status
})
function getWeekDateRange(year: number, week: number): 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)
const fmt = (d: Date) => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
return `${fmt(monday)}~${fmt(sunday)}`
}
function formatDateRange(start: string, end: string) {
if (!start || !end) return '-'
const s = new Date(start)
const e = new Date(end)
return `${s.getMonth()+1}/${s.getDate()} ~ ${e.getMonth()+1}/${e.getDate()}`
@@ -272,10 +321,16 @@ function formatDateRange(start: string, end: string) {
function formatDateTime(dateStr: string) {
if (!dateStr) return '-'
const d = new Date(dateStr)
return d.toLocaleString('ko-KR', {
month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit'
})
return d.toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
}
function formatHours(hours: number): string {
if (!hours || hours <= 0) return '0h'
const days = Math.floor(hours / 8)
const remain = hours % 8
if (days === 0) return `${remain}h`
if (remain === 0) return `${days}`
return `${days}${remain}h`
}
</script>

View File

@@ -18,11 +18,27 @@
</span>
</h4>
<p class="text-muted mb-0">
<span class="me-3">{{ report.authorName }}</span>
{{ report.reportYear }} {{ report.reportWeek }}주차
({{ formatDate(report.weekStartDate) }} ~ {{ formatDate(report.weekEndDate) }})
</p>
</div>
<div class="d-flex gap-2">
<div class="d-flex gap-2 align-items-center">
<!-- 이전/다음 보고서 -->
<div class="btn-group me-2">
<NuxtLink v-if="prevReport" :to="`/report/weekly/${prevReport.reportId}`" class="btn btn-outline-secondary" :title="prevReport.authorName">
<i class="bi bi-chevron-left"></i> 이전
</NuxtLink>
<button v-else class="btn btn-outline-secondary" disabled>
<i class="bi bi-chevron-left"></i> 이전
</button>
<NuxtLink v-if="nextReport" :to="`/report/weekly/${nextReport.reportId}`" class="btn btn-outline-secondary" :title="nextReport.authorName">
다음 <i class="bi bi-chevron-right"></i>
</NuxtLink>
<button v-else class="btn btn-outline-secondary" disabled>
다음 <i class="bi bi-chevron-right"></i>
</button>
</div>
<NuxtLink to="/report/weekly" class="btn btn-outline-secondary">목록</NuxtLink>
<button v-if="canEdit" class="btn btn-primary" @click="isEditing = !isEditing">
{{ isEditing ? '취소' : '수정' }}
@@ -31,44 +47,81 @@
<span v-if="isSubmitting" class="spinner-border spinner-border-sm me-1"></span>
제출
</button>
<button v-if="canDelete" class="btn btn-outline-danger" @click="handleDelete" :disabled="isDeleting">
<span v-if="isDeleting" class="spinner-border spinner-border-sm me-1"></span>
<i v-else class="bi bi-trash me-1"></i>삭제
</button>
</div>
</div>
<!-- 보기 모드 -->
<div v-if="!isEditing">
<!-- 프로젝트별 실적 -->
<div class="card mb-4">
<div class="card-header">
<strong><i class="bi bi-folder me-2"></i>프로젝트별 실적</strong>
<span class="badge bg-secondary ms-2">{{ projects.length }}</span>
<!-- 프로젝트별 Task -->
<div v-for="proj in projects" :key="proj.projectId" class="card mb-4">
<div class="card-header bg-light">
<i class="bi bi-folder2 me-2"></i>
<strong>{{ proj.projectName }}</strong>
<small class="text-muted ms-2">({{ proj.projectCode }})</small>
</div>
<div class="card-body">
<div v-for="(proj, idx) in projects" :key="proj.detailId"
:class="{ 'border-top pt-3 mt-3': idx > 0 }">
<h6 class="mb-3">
<i class="bi bi-folder2 me-1"></i>
{{ proj.projectName }}
<small class="text-muted">({{ proj.projectCode }})</small>
</h6>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label text-muted small">금주 실적</label>
<div class="bg-light rounded p-3" style="white-space: pre-wrap;">{{ proj.workDescription || '-' }}</div>
<div class="row">
<!-- 금주 실적 -->
<div class="col-md-6">
<h6 class="text-primary mb-3">
<i class="bi bi-check2-square me-1"></i>금주 실적
<span class="badge bg-primary ms-1">{{ formatHoursDisplay(getProjectWorkHours(proj)) }}</span>
</h6>
<div v-if="proj.workTasks.length === 0" class="text-muted">-</div>
<div v-else class="list-group list-group-flush">
<div v-for="task in proj.workTasks" :key="task.taskId" class="list-group-item px-0 py-2 d-flex justify-content-between align-items-start">
<div>
<span class="badge me-2" :class="task.isCompleted ? 'bg-success' : 'bg-warning text-dark'">
{{ task.isCompleted ? '완료' : '진행' }}
</span>
<span style="white-space: pre-wrap;">{{ task.description }}</span>
</div>
<span class="badge bg-light text-dark">{{ formatHours(task.hours) }}</span>
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label text-muted small">차주 계획</label>
<div class="bg-light rounded p-3" style="white-space: pre-wrap;">{{ proj.planDescription || '-' }}</div>
</div>
<!-- 차주 계획 -->
<div class="col-md-6">
<h6 class="text-success mb-3">
<i class="bi bi-calendar-check me-1"></i>차주 계획
<span class="badge bg-success ms-1">{{ formatHoursDisplay(getProjectPlanHours(proj)) }}</span>
</h6>
<div v-if="proj.planTasks.length === 0" class="text-muted">-</div>
<div v-else class="list-group list-group-flush">
<div v-for="task in proj.planTasks" :key="task.taskId" class="list-group-item px-0 py-2 d-flex justify-content-between align-items-start">
<span style="white-space: pre-wrap;">{{ task.description }}</span>
<span class="badge bg-light text-dark">{{ formatHours(task.hours) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 총계 -->
<div class="card mb-4 border-primary">
<div class="card-body py-2">
<div class="row text-center">
<div class="col">
<span class="text-muted">금주 실적 합계</span>
<h5 class="mb-0 text-primary">{{ formatHoursDisplay(totalWorkHours) }}</h5>
</div>
<div class="col">
<span class="text-muted">차주 계획 합계</span>
<h5 class="mb-0 text-success">{{ formatHoursDisplay(totalPlanHours) }}</h5>
</div>
</div>
</div>
</div>
<!-- 공통 사항 -->
<div class="card mb-4">
<div class="card-header">
<strong><i class="bi bi-chat-left-text me-2"></i>공통 사항</strong>
</div>
<div class="card-header"><strong><i class="bi bi-chat-left-text me-2"></i>공통 사항</strong></div>
<div class="card-body">
<div class="row">
<div class="col-md-4 mb-3">
@@ -86,36 +139,135 @@
</div>
</div>
</div>
<!-- PMO AI 리뷰 -->
<div class="card mb-4 border-info">
<div class="card-header bg-info bg-opacity-10 d-flex justify-content-between align-items-center">
<strong><i class="bi bi-robot me-2"></i>PMO AI 리뷰</strong>
<button class="btn btn-sm btn-outline-info" @click="requestAiReview" :disabled="isReviewing">
<span v-if="isReviewing" class="spinner-border spinner-border-sm me-1"></span>
<i v-else class="bi bi-arrow-repeat me-1"></i>
{{ report.aiReview ? '리뷰 재요청' : '리뷰 요청' }}
</button>
</div>
<div class="card-body">
<div v-if="report.aiReview" class="ai-review-content" v-html="renderMarkdown(report.aiReview)"></div>
<div v-else class="text-muted text-center py-3">
<i class="bi bi-chat-left-dots me-2"></i>
아직 AI 리뷰가 없습니다. 리뷰 요청 버튼을 클릭하세요.
</div>
<div v-if="report.aiReviewAt" class="text-muted small mt-3 text-end">
<i class="bi bi-clock me-1"></i>리뷰 생성: {{ formatDateTime(report.aiReviewAt) }}
</div>
</div>
</div>
</div>
<!-- 수정 모드 -->
<form v-else @submit.prevent="handleUpdate">
<!-- 프로젝트별 실적 -->
<!-- 주차 정보 수정 -->
<div class="card mb-4">
<div class="card-header"><strong>보고 주차</strong></div>
<div class="card-body">
<div class="row align-items-end">
<div class="col-auto">
<div class="input-group">
<button type="button" class="btn btn-outline-secondary" @click="changeEditWeek(-1)">
<i class="bi bi-chevron-left"></i>
</button>
<span class="input-group-text bg-white" style="min-width: 160px;">
<strong>{{ editForm.reportYear }} {{ editForm.reportWeek }}주차</strong>
</span>
<button type="button" class="btn btn-outline-secondary" @click="changeEditWeek(1)">
<i class="bi bi-chevron-right"></i>
</button>
</div>
</div>
<div class="col-auto">
<span class="text-muted">
{{ editForm.weekStartDate }} ~ {{ editForm.weekEndDate }}
</span>
</div>
</div>
</div>
</div>
<!-- 프로젝트별 Task -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<strong><i class="bi bi-folder me-2"></i>프로젝트별 실적</strong>
<strong><i class="bi bi-folder me-2"></i>프로젝트별 실적/계획</strong>
<button type="button" class="btn btn-sm btn-outline-primary" @click="showProjectModal = true">
<i class="bi bi-plus"></i> 프로젝트 추가
</button>
</div>
<div class="card-body">
<div v-for="(proj, idx) in editForm.projects" :key="idx" class="border rounded p-3 mb-3">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<strong>{{ proj.projectName }}</strong>
<small class="text-muted ms-2">({{ proj.projectCode }})</small>
</div>
<button type="button" class="btn btn-sm btn-outline-danger" @click="removeProject(idx)">
<i class="bi bi-x"></i> 삭제
<div v-for="(group, gIdx) in editProjectGroups" :key="group.projectId" class="border rounded mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<span>
<i class="bi bi-folder2 me-2"></i>
<strong>{{ group.projectName }}</strong>
<small class="text-muted ms-2">({{ group.projectCode }})</small>
</span>
<button type="button" class="btn btn-sm btn-outline-danger" @click="removeEditProjectGroup(gIdx)">
<i class="bi bi-x"></i>
</button>
</div>
<div class="mb-3">
<label class="form-label">금주 실적</label>
<textarea class="form-control" rows="3" v-model="proj.workDescription"></textarea>
</div>
<div>
<label class="form-label"> 계획</label>
<textarea class="form-control" rows="3" v-model="proj.planDescription"></textarea>
<div class="card-body">
<div class="row">
<!-- 금주 실적 -->
<div class="col-md-6">
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label mb-0 fw-bold text-primary"> 실적</label>
<button type="button" class="btn btn-sm btn-outline-primary" @click="addEditTask(group.projectId, 'WORK')">
<i class="bi bi-plus"></i>
</button>
</div>
<div v-for="(task, tIdx) in getEditWorkTasks(group.projectId)" :key="'work-'+tIdx" class="mb-2">
<div class="d-flex gap-2 align-items-start">
<div class="form-check pt-1">
<input type="checkbox" class="form-check-input" v-model="task.isCompleted"
:id="'edit-work-chk-'+group.projectId+'-'+tIdx" />
<label class="form-check-label small" :for="'edit-work-chk-'+group.projectId+'-'+tIdx"
:class="task.isCompleted ? 'text-success' : 'text-warning'">
{{ task.isCompleted ? '완료' : '진행' }}
</label>
</div>
<textarea class="form-control form-control-sm" v-model="task.description" rows="2" placeholder="작업 내용"></textarea>
<div class="text-nowrap">
<input type="number" class="form-control form-control-sm text-end mb-1" style="width: 70px;"
v-model.number="task.hours" min="0" step="0.5" placeholder="h" />
<div class="small text-muted text-end">{{ formatHours(task.hours) }}</div>
</div>
<button type="button" class="btn btn-sm btn-outline-danger" @click="removeEditTask(group.projectId, 'WORK', tIdx)">
<i class="bi bi-x"></i>
</button>
</div>
</div>
</div>
<!-- 차주 계획 -->
<div class="col-md-6">
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label mb-0 fw-bold text-success">차주 계획</label>
<button type="button" class="btn btn-sm btn-outline-success" @click="addEditTask(group.projectId, 'PLAN')">
<i class="bi bi-plus"></i>
</button>
</div>
<div v-for="(task, tIdx) in getEditPlanTasks(group.projectId)" :key="'plan-'+tIdx" class="mb-2">
<div class="d-flex gap-2 align-items-start">
<textarea class="form-control form-control-sm" v-model="task.description" rows="2" placeholder="계획 내용"></textarea>
<div class="text-nowrap">
<input type="number" class="form-control form-control-sm text-end mb-1" style="width: 70px;"
v-model.number="task.hours" min="0" step="0.5" placeholder="h" />
<div class="small text-muted text-end">{{ formatHours(task.hours) }}</div>
</div>
<button type="button" class="btn btn-sm btn-outline-danger" @click="removeEditTask(group.projectId, 'PLAN', tIdx)">
<i class="bi bi-x"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -123,21 +275,21 @@
<!-- 공통 사항 -->
<div class="card mb-4">
<div class="card-header">
<strong><i class="bi bi-chat-left-text me-2"></i>공통 사항</strong>
</div>
<div class="card-header"><strong><i class="bi bi-chat-left-text me-2"></i>공통 사항</strong></div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">이슈/리스크</label>
<textarea class="form-control" rows="3" v-model="editForm.issueDescription"></textarea>
</div>
<div class="mb-3">
<label class="form-label">휴가일정</label>
<textarea class="form-control" rows="2" v-model="editForm.vacationDescription"></textarea>
</div>
<div>
<label class="form-label">기타사항</label>
<textarea class="form-control" rows="2" v-model="editForm.remarkDescription"></textarea>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">이슈/리스크</label>
<textarea class="form-control" rows="3" v-model="editForm.issueDescription"></textarea>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">휴가일정</label>
<textarea class="form-control" rows="3" v-model="editForm.vacationDescription"></textarea>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">기타사항</label>
<textarea class="form-control" rows="3" v-model="editForm.remarkDescription"></textarea>
</div>
</div>
</div>
</div>
@@ -154,7 +306,7 @@
</div>
<!-- 프로젝트 선택 모달 -->
<div class="modal fade" :class="{ show: showProjectModal }" :style="{ display: showProjectModal ? 'block' : 'none' }" tabindex="-1">
<div class="modal fade" :class="{ show: showProjectModal }" :style="{ display: showProjectModal ? 'block' : 'none' }">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
@@ -168,7 +320,7 @@
<div v-else class="list-group">
<button type="button" class="list-group-item list-group-item-action"
v-for="p in availableProjects" :key="p.projectId"
@click="addProject(p)">
@click="addEditProjectGroup(p)">
<strong>{{ p.projectName }}</strong>
<small class="text-muted ms-2">({{ p.projectCode }})</small>
</button>
@@ -186,35 +338,73 @@ const { currentUser, fetchCurrentUser } = useAuth()
const router = useRouter()
const route = useRoute()
const reportId = route.params.id as string
const reportId = computed(() => route.params.id as string)
const report = ref<any>(null)
const projects = ref<any[]>([])
const allProjects = ref<any[]>([])
const prevReport = ref<any>(null)
const nextReport = ref<any>(null)
const isLoading = ref(true)
const isEditing = ref(false)
const isSaving = ref(false)
const isSubmitting = ref(false)
const isDeleting = ref(false)
const isReviewing = ref(false)
const showProjectModal = ref(false)
interface EditProjectItem {
const isAdmin = computed(() => currentUser.value?.employeeEmail === 'coziny@gmail.com')
interface EditTask {
projectId: number
projectCode: string
projectName: string
workDescription: string
planDescription: string
taskType: 'WORK' | 'PLAN'
description: string
hours: number
isCompleted: boolean
}
const editForm = ref({
projects: [] as EditProjectItem[],
reportYear: 0,
reportWeek: 0,
weekStartDate: '',
weekEndDate: '',
tasks: [] as EditTask[],
issueDescription: '',
vacationDescription: '',
remarkDescription: ''
})
const editProjectGroups = computed(() => {
const projectIds = [...new Set(editForm.value.tasks.map(t => t.projectId))]
return projectIds.map(pid => {
const proj = allProjects.value.find(p => p.projectId === pid)
return {
projectId: pid,
projectCode: proj?.projectCode || '',
projectName: proj?.projectName || ''
}
})
})
const availableProjects = computed(() => {
const usedIds = editProjectGroups.value.map(g => g.projectId)
return allProjects.value.filter(p => !usedIds.includes(p.projectId))
})
const totalWorkHours = computed(() => {
return projects.value.reduce((sum, proj) => sum + getProjectWorkHours(proj), 0)
})
const totalPlanHours = computed(() => {
return projects.value.reduce((sum, proj) => sum + getProjectPlanHours(proj), 0)
})
const canEdit = computed(() => {
if (!report.value || !currentUser.value) return false
return report.value.authorId === currentUser.value.employeeId && report.value.reportStatus === 'DRAFT'
// 관리자는 항상 수정 가능
if (isAdmin.value) return true
// 본인 보고서이고 AGGREGATED가 아니면 수정 가능
return report.value.authorId === currentUser.value.employeeId && report.value.reportStatus !== 'AGGREGATED'
})
const canSubmit = computed(() => {
@@ -222,9 +412,10 @@ const canSubmit = computed(() => {
return report.value.authorId === currentUser.value.employeeId && report.value.reportStatus === 'DRAFT'
})
const availableProjects = computed(() => {
const addedIds = editForm.value.projects.map(p => p.projectId)
return allProjects.value.filter(p => !addedIds.includes(p.projectId))
const canDelete = computed(() => {
if (!report.value || !currentUser.value) return false
if (isAdmin.value) return true
return report.value.authorId === currentUser.value.employeeId && report.value.reportStatus !== 'AGGREGATED'
})
onMounted(async () => {
@@ -240,9 +431,11 @@ onMounted(async () => {
async function loadReport() {
isLoading.value = true
try {
const res = await $fetch<any>(`/api/report/weekly/${reportId}/detail`)
const res = await $fetch<any>(`/api/report/weekly/${reportId.value}/detail`)
report.value = res.report
projects.value = res.projects
prevReport.value = res.prevReport
nextReport.value = res.nextReport
} catch (e: any) {
alert(e.data?.message || '보고서를 불러올 수 없습니다.')
router.push('/report/weekly')
@@ -251,6 +444,12 @@ async function loadReport() {
}
}
// route param 변경 시 다시 로드
watch(reportId, async () => {
isEditing.value = false
await loadReport()
})
async function loadAllProjects() {
try {
const res = await $fetch<any>('/api/project/list')
@@ -262,14 +461,35 @@ async function loadAllProjects() {
watch(isEditing, (val) => {
if (val) {
// 기존 데이터를 editForm으로 변환
const tasks: EditTask[] = []
for (const proj of projects.value) {
for (const task of proj.workTasks) {
tasks.push({
projectId: proj.projectId,
taskType: 'WORK',
description: task.description,
hours: task.hours,
isCompleted: task.isCompleted !== false
})
}
for (const task of proj.planTasks) {
tasks.push({
projectId: proj.projectId,
taskType: 'PLAN',
description: task.description,
hours: task.hours,
isCompleted: true
})
}
}
editForm.value = {
projects: projects.value.map(p => ({
projectId: p.projectId,
projectCode: p.projectCode,
projectName: p.projectName,
workDescription: p.workDescription || '',
planDescription: p.planDescription || ''
})),
reportYear: report.value.reportYear,
reportWeek: report.value.reportWeek,
weekStartDate: report.value.weekStartDate,
weekEndDate: report.value.weekEndDate,
tasks,
issueDescription: report.value.issueDescription || '',
vacationDescription: report.value.vacationDescription || '',
remarkDescription: report.value.remarkDescription || ''
@@ -277,36 +497,167 @@ watch(isEditing, (val) => {
}
})
function addProject(p: any) {
editForm.value.projects.push({
projectId: p.projectId,
projectCode: p.projectCode,
projectName: p.projectName,
workDescription: '',
planDescription: ''
// 프로젝트별 시간 계산
function getProjectWorkHours(proj: any) {
return proj.workTasks.reduce((sum: number, t: any) => sum + (t.hours || 0), 0)
}
function getProjectPlanHours(proj: any) {
return proj.planTasks.reduce((sum: number, t: any) => sum + (t.hours || 0), 0)
}
// 수정 모드 주차 변경
function changeEditWeek(delta: number) {
let year = editForm.value.reportYear
let week = editForm.value.reportWeek + delta
// 주차 범위 조정
if (week < 1) {
year--
week = getWeeksInYear(year)
} else if (week > getWeeksInYear(year)) {
year++
week = 1
}
editForm.value.reportYear = year
editForm.value.reportWeek = week
// 해당 주차의 월요일~일요일 계산
const { monday, sunday } = getWeekDates(year, week)
editForm.value.weekStartDate = monday
editForm.value.weekEndDate = sunday
}
// 연도의 총 주차 수 계산
function getWeeksInYear(year: number): number {
const dec31 = new Date(year, 11, 31)
const dayOfWeek = dec31.getDay()
// 12월 31일이 목요일 이후면 53주, 아니면 52주
return dayOfWeek >= 4 || dayOfWeek === 0 ? 53 : 52
}
// 연도와 주차로 해당 주의 월요일~일요일 계산
function getWeekDates(year: number, week: number): { monday: string, sunday: string } {
// 해당 연도의 첫 번째 목요일이 속한 주가 1주차
const jan4 = new Date(year, 0, 4)
const jan4DayOfWeek = jan4.getDay() || 7 // 일요일=7로 변환
// 1주차의 월요일
const week1Monday = new Date(jan4)
week1Monday.setDate(jan4.getDate() - jan4DayOfWeek + 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: formatDateStr(monday),
sunday: formatDateStr(sunday)
}
}
function formatDateStr(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}`
}
// 수정 모드 함수들
function getEditWorkTasks(projectId: number) {
return editForm.value.tasks.filter(t => t.projectId === projectId && t.taskType === 'WORK')
}
function getEditPlanTasks(projectId: number) {
return editForm.value.tasks.filter(t => t.projectId === projectId && t.taskType === 'PLAN')
}
function addEditProjectGroup(project: any) {
editForm.value.tasks.push({
projectId: project.projectId,
taskType: 'WORK',
description: '',
hours: 0,
isCompleted: true
})
editForm.value.tasks.push({
projectId: project.projectId,
taskType: 'PLAN',
description: '',
hours: 0,
isCompleted: true
})
showProjectModal.value = false
}
function removeProject(idx: number) {
editForm.value.projects.splice(idx, 1)
function removeEditProjectGroup(gIdx: number) {
const group = editProjectGroups.value[gIdx]
editForm.value.tasks = editForm.value.tasks.filter(t => t.projectId !== group.projectId)
}
function addEditTask(projectId: number, taskType: 'WORK' | 'PLAN') {
editForm.value.tasks.push({ projectId, taskType, description: '', hours: 0, isCompleted: true })
}
function removeEditTask(projectId: number, taskType: 'WORK' | 'PLAN', idx: number) {
const tasks = editForm.value.tasks.filter(t => t.projectId === projectId && t.taskType === taskType)
if (tasks.length <= 1) return
const targetTask = tasks[idx]
const targetIndex = editForm.value.tasks.indexOf(targetTask)
if (targetIndex > -1) {
editForm.value.tasks.splice(targetIndex, 1)
}
}
// 시간 표시 함수
function formatHours(hours: number): string {
if (!hours || hours <= 0) return '-'
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`
}
function formatHoursDisplay(hours: number): string {
if (!hours || hours <= 0) return '0h'
const days = Math.floor(hours / 8)
const remainHours = hours % 8
if (days === 0) return `${remainHours}시간`
if (remainHours === 0) return `${days}`
return `${days}${remainHours}시간`
}
async function handleUpdate() {
if (editForm.value.projects.length === 0) {
alert('최소 1개 이상의 프로젝트가 필요합니다.')
const validTasks = editForm.value.tasks.filter(t => t.description.trim())
if (validTasks.length === 0) {
alert('최소 1개 이상의 Task를 입력해주세요.')
return
}
isSaving.value = true
try {
await $fetch(`/api/report/weekly/${reportId}/update`, {
await $fetch(`/api/report/weekly/${reportId.value}/update`, {
method: 'PUT',
body: {
projects: editForm.value.projects.map(p => ({
projectId: p.projectId,
workDescription: p.workDescription,
planDescription: p.planDescription
reportYear: editForm.value.reportYear,
reportWeek: editForm.value.reportWeek,
weekStartDate: editForm.value.weekStartDate,
weekEndDate: editForm.value.weekEndDate,
tasks: validTasks.map(t => ({
projectId: t.projectId,
taskType: t.taskType,
taskDescription: t.description,
taskHours: t.hours || 0,
isCompleted: t.isCompleted
})),
issueDescription: editForm.value.issueDescription,
vacationDescription: editForm.value.vacationDescription,
@@ -328,7 +679,7 @@ async function handleSubmit() {
isSubmitting.value = true
try {
await $fetch(`/api/report/weekly/${reportId}/submit`, { method: 'POST' })
await $fetch(`/api/report/weekly/${reportId.value}/submit`, { method: 'POST' })
alert('제출되었습니다.')
await loadReport()
} catch (e: any) {
@@ -338,6 +689,58 @@ async function handleSubmit() {
}
}
async function handleDelete() {
const authorName = report.value?.authorName || ''
const weekInfo = `${report.value?.reportYear}${report.value?.reportWeek}주차`
if (!confirm(`정말 삭제하시겠습니까?\n\n작성자: ${authorName}\n주차: ${weekInfo}\n\n삭제된 보고서는 복구할 수 없습니다.`)) return
isDeleting.value = true
try {
await $fetch(`/api/report/weekly/${reportId.value}/delete`, { method: 'DELETE' })
alert('삭제되었습니다.')
router.push('/report/weekly')
} catch (e: any) {
alert(e.data?.message || '삭제에 실패했습니다.')
} finally {
isDeleting.value = false
}
}
async function requestAiReview() {
isReviewing.value = true
try {
const res = await $fetch<{ review: string; reviewedAt: string }>('/api/report/review', {
method: 'POST',
body: { reportId: parseInt(reportId.value) }
})
report.value.aiReview = res.review
report.value.aiReviewAt = res.reviewedAt
} catch (e: any) {
alert(e.data?.message || 'AI 리뷰 요청에 실패했습니다.')
} finally {
isReviewing.value = false
}
}
function renderMarkdown(text: string): string {
if (!text) return ''
return text
.replace(/^### (.+)$/gm, '<h6 class="fw-bold mt-3">$1</h6>')
.replace(/^## (.+)$/gm, '<h5 class="fw-bold mt-3">$1</h5>')
.replace(/^# (.+)$/gm, '<h5 class="fw-bold mt-3">$1</h5>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/^- (.+)$/gm, '• $1<br>')
.replace(/\n/g, '<br>')
}
function formatDateTime(dateStr: string): string {
if (!dateStr) return ''
const d = new Date(dateStr)
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`
}
function formatDate(dateStr: string) {
if (!dateStr) return ''
return dateStr.split('T')[0]
@@ -366,4 +769,12 @@ function getStatusText(status: string) {
.modal.show {
background-color: rgba(0, 0, 0, 0.5);
}
.ai-review-content {
line-height: 1.8;
font-size: 0.95rem;
}
.ai-review-content h5, .ai-review-content h6 {
color: #0d6efd;
margin-top: 1rem;
}
</style>

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>

View File

@@ -2,62 +2,154 @@
<div>
<AppHeader />
<div class="container py-4">
<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> 주간보고
<i class="bi bi-journal-text me-2"></i>주간보고
</h4>
<NuxtLink to="/report/weekly/write" class="btn btn-primary">
<i class="bi bi-plus me-1"></i>작성하기
</NuxtLink>
<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">
<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-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">
<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-md-2">
<label class="form-label small text-muted">프로젝트</label>
<select class="form-select form-select-sm" v-model="filters.projectId" @change="loadReports">
<option value="">전체</option>
<option v-for="proj in projects" :key="proj.projectId" :value="proj.projectId">
{{ proj.projectName }}
</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.year" @change="loadReports">
<option value="">전체</option>
<option v-for="y in yearOptions" :key="y" :value="y">{{ y }}</option>
</select>
</div>
<!-- 기간 -->
<div class="col-md-2">
<label class="form-label small text-muted">시작일</label>
<input type="date" class="form-control form-control-sm" v-model="filters.startDate" @change="loadReports">
</div>
<div class="col-md-2">
<label class="form-label small text-muted">종료일</label>
<input type="date" class="form-control form-control-sm" v-model="filters.endDate" @change="loadReports">
</div>
<!-- 상태 -->
<div class="col-md-1">
<label class="form-label small text-muted">상태</label>
<select class="form-select form-select-sm" v-model="filters.status" @change="loadReports">
<option value="">전체</option>
<option value="DRAFT">작성중</option>
<option value="SUBMITTED">제출완료</option>
<option value="AGGREGATED">취합완료</option>
</select>
</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: 150px">주차</th>
<th style="width: 200px">기간</th>
<th style="width: 120px">주차</th>
<th style="width: 180px">기간</th>
<th v-if="isAdmin" style="width: 120px">작성자</th>
<th>프로젝트</th>
<th style="width: 100px">상태</th>
<th style="width: 150px">작성</th>
<th style="width: 90px">상태</th>
<th style="width: 100px">제출</th>
</tr>
</thead>
<tbody>
<tr v-if="isLoading">
<td colspan="5" class="text-center py-4">
<td :colspan="isAdmin ? 6 : 5" 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="5" class="text-center py-5 text-muted">
<td :colspan="isAdmin ? 6 : 5" class="text-center py-5 text-muted">
<i class="bi bi-inbox display-4"></i>
<p class="mt-2 mb-0">작성한 주간보고가 없습니다.</p>
<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>
<strong>{{ r.reportYear }} {{ r.reportWeek }}</strong>
</td>
<td class="small">
{{ formatDate(r.weekStartDate) }} ~ {{ formatDate(r.weekEndDate) }}
</td>
<td v-if="isAdmin">
<span class="badge bg-secondary">{{ r.authorName }}</span>
</td>
<td>{{ formatDate(r.weekStartDate) }} ~ {{ formatDate(r.weekEndDate) }}</td>
<td>
<span class="badge bg-primary">{{ r.projectCount }} 프로젝트</span>
<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>
<span :class="getStatusBadgeClass(r.reportStatus)">
{{ getStatusText(r.reportStatus) }}
</span>
</td>
<td>{{ formatDateTime(r.createdAt) }}</td>
<td class="small">{{ formatDateTime(r.submittedAt || r.createdAt) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card-footer text-muted small" v-if="reports.length > 0">
{{ reports.length }}
</div>
</div>
</div>
</div>
@@ -68,7 +160,23 @@ const { fetchCurrentUser } = useAuth()
const router = useRouter()
const reports = ref<any[]>([])
const employees = ref<any[]>([])
const projects = ref<any[]>([])
const isLoading = ref(true)
const isAdmin = ref(false)
const currentYear = new Date().getFullYear()
const yearOptions = [currentYear, currentYear - 1, currentYear - 2]
const filters = ref({
viewAll: false,
authorId: '',
projectId: '',
year: '',
startDate: '',
endDate: '',
status: ''
})
onMounted(async () => {
const user = await fetchCurrentUser()
@@ -76,14 +184,52 @@ onMounted(async () => {
router.push('/login')
return
}
isAdmin.value = user.employeeEmail === 'coziny@gmail.com'
// 직원, 프로젝트 목록 로드 (관리자용)
if (isAdmin.value) {
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 res = await $fetch<any>('/api/report/weekly/list')
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.projectId) params.append('projectId', filters.value.projectId)
if (filters.value.year) params.append('year', filters.value.year)
if (filters.value.startDate) params.append('startDate', filters.value.startDate)
if (filters.value.endDate) params.append('endDate', filters.value.endDate)
if (filters.value.status) params.append('status', filters.value.status)
const res = await $fetch<any>(`/api/report/weekly/list?${params.toString()}`)
reports.value = res.reports || []
// 일반 사용자도 프로젝트 필터 사용할 수 있도록
if (!isAdmin.value && projects.value.length === 0) {
const projRes = await $fetch<any>('/api/project/list')
projects.value = projRes.projects || []
}
} catch (e) {
console.error(e)
} finally {
@@ -91,15 +237,28 @@ async function loadReports() {
}
}
function resetFilters() {
filters.value = {
viewAll: false,
authorId: '',
projectId: '',
year: '',
startDate: '',
endDate: '',
status: ''
}
loadReports()
}
function formatDate(dateStr: string) {
if (!dateStr) return ''
return dateStr.split('T')[0]
return dateStr.split('T')[0].replace(/-/g, '.')
}
function formatDateTime(dateStr: string) {
if (!dateStr) return ''
if (!dateStr) return '-'
const d = new Date(dateStr)
return d.toLocaleDateString('ko-KR')
return `${d.getMonth() + 1}/${d.getDate()}`
}
function getStatusBadgeClass(status: string) {
@@ -120,3 +279,9 @@ function getStatusText(status: string) {
return texts[status] || status
}
</script>
<style scoped>
.form-label {
margin-bottom: 0.25rem;
}
</style>

View File

@@ -3,47 +3,146 @@
<AppHeader />
<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-journal-plus me-2"></i>주간보고 작성
</h4>
<span class="text-muted">{{ weekInfo.weekString }} ({{ weekInfo.startDateStr }} ~ {{ weekInfo.endDateStr }})</span>
</div>
<h4 class="mb-4">
<i class="bi bi-pencil-square me-2"></i>주간보고 작성
</h4>
<form @submit.prevent="handleSubmit">
<!-- 프로젝트별 실적 -->
<!-- 주차 정보 -->
<div class="card mb-4">
<div class="card-header"><strong>보고 주차</strong></div>
<div class="card-body">
<div class="row align-items-end">
<div class="col-auto">
<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>{{ form.reportYear }} {{ form.reportWeek }}주차</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">
<div class="input-group">
<input type="date" class="form-control" v-model="form.weekStartDate" @change="updateWeekFromDate" />
<span class="input-group-text">~</span>
<input type="date" class="form-control" v-model="form.weekEndDate" readonly />
</div>
</div>
<div class="col-auto">
<button type="button" class="btn btn-outline-primary btn-sm" @click="setLastWeek">지난주</button>
<button type="button" class="btn btn-outline-secondary btn-sm ms-1" @click="setThisWeek">이번주</button>
</div>
</div>
</div>
</div>
<!-- 프로젝트별 Task -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<strong><i class="bi bi-folder me-2"></i>프로젝트별 실적</strong>
<strong><i class="bi bi-folder me-2"></i>프로젝트별 실적/계획</strong>
<button type="button" class="btn btn-sm btn-outline-primary" @click="showProjectModal = true">
<i class="bi bi-plus"></i> 프로젝트 추가
</button>
</div>
<div class="card-body">
<div v-if="form.projects.length === 0" class="text-center text-muted py-4">
<i class="bi bi-inbox display-4"></i>
<p class="mt-2 mb-0">프로젝트를 추가해주세요.</p>
<div v-if="projectGroups.length === 0" class="text-center text-muted py-4">
프로젝트를 추가해주세요.
</div>
<div v-for="(proj, idx) in form.projects" :key="idx" class="border rounded p-3 mb-3">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<strong>{{ proj.projectName }}</strong>
<small class="text-muted ms-2">({{ proj.projectCode }})</small>
</div>
<button type="button" class="btn btn-sm btn-outline-danger" @click="removeProject(idx)">
<i class="bi bi-x"></i> 삭제
<div v-for="(group, gIdx) in projectGroups" :key="group.projectId" class="border rounded mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<span>
<i class="bi bi-folder2 me-2"></i>
<strong>{{ group.projectName }}</strong>
<small class="text-muted ms-2">({{ group.projectCode }})</small>
</span>
<button type="button" class="btn btn-sm btn-outline-danger" @click="removeProjectGroup(gIdx)">
<i class="bi bi-x"></i>
</button>
</div>
<div class="mb-3">
<label class="form-label">금주 실적</label>
<textarea class="form-control" rows="3" v-model="proj.workDescription"
placeholder="이번 주에 수행한 업무를 작성해주세요."></textarea>
<div class="card-body">
<div class="row">
<!-- 금주 실적 -->
<div class="col-md-6">
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label mb-0 fw-bold text-primary">
<i class="bi bi-check2-square me-1"></i>금주 실적
<span class="badge bg-primary ms-1">{{ formatHoursDisplay(getGroupWorkHours(group)) }}</span>
</label>
<button type="button" class="btn btn-sm btn-outline-primary" @click="addTask(group.projectId, 'WORK')">
<i class="bi bi-plus"></i>
</button>
</div>
<div v-for="(task, tIdx) in getWorkTasks(group.projectId)" :key="'work-'+tIdx" class="mb-2">
<div class="d-flex gap-2 align-items-start">
<div class="form-check pt-1">
<input type="checkbox" class="form-check-input" v-model="task.isCompleted"
:id="'work-chk-'+group.projectId+'-'+tIdx" />
<label class="form-check-label small" :for="'work-chk-'+group.projectId+'-'+tIdx"
:class="task.isCompleted ? 'text-success' : 'text-warning'">
{{ task.isCompleted ? '완료' : '진행' }}
</label>
</div>
<textarea class="form-control form-control-sm" v-model="task.description" rows="2" placeholder="작업 내용"></textarea>
<div class="text-nowrap">
<input type="number" class="form-control form-control-sm text-end mb-1" style="width: 70px;"
v-model.number="task.hours" min="0" step="0.5" placeholder="h" />
<div class="small text-muted text-end">{{ formatHours(task.hours) }}</div>
</div>
<button type="button" class="btn btn-sm btn-outline-danger" @click="removeTask(group.projectId, 'WORK', tIdx)">
<i class="bi bi-x"></i>
</button>
</div>
</div>
</div>
<!-- 차주 계획 -->
<div class="col-md-6">
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label mb-0 fw-bold text-success">
<i class="bi bi-calendar-check me-1"></i>차주 계획
<span class="badge bg-success ms-1">{{ formatHoursDisplay(getGroupPlanHours(group)) }}</span>
</label>
<button type="button" class="btn btn-sm btn-outline-success" @click="addTask(group.projectId, 'PLAN')">
<i class="bi bi-plus"></i>
</button>
</div>
<div v-for="(task, tIdx) in getPlanTasks(group.projectId)" :key="'plan-'+tIdx" class="mb-2">
<div class="d-flex gap-2 align-items-start">
<textarea class="form-control form-control-sm" v-model="task.description" rows="2" placeholder="계획 내용"></textarea>
<div class="text-nowrap">
<input type="number" class="form-control form-control-sm text-end mb-1" style="width: 70px;"
v-model.number="task.hours" min="0" step="0.5" placeholder="h" />
<div class="small text-muted text-end">{{ formatHours(task.hours) }}</div>
</div>
<button type="button" class="btn btn-sm btn-outline-danger" @click="removeTask(group.projectId, 'PLAN', tIdx)">
<i class="bi bi-x"></i>
</button>
</div>
</div>
</div>
</div>
</div>
<div>
<label class="form-label">차주 계획</label>
<textarea class="form-control" rows="3" v-model="proj.planDescription"
placeholder="다음 주에 수행할 업무를 작성해주세요."></textarea>
</div>
</div>
</div>
<!-- 총계 -->
<div class="card mb-4 border-primary" v-if="form.tasks.length > 0">
<div class="card-body py-2">
<div class="row text-center">
<div class="col">
<span class="text-muted">금주 실적 합계</span>
<h5 class="mb-0 text-primary">{{ formatHoursDisplay(totalWorkHours) }}</h5>
</div>
<div class="col">
<span class="text-muted">차주 계획 합계</span>
<h5 class="mb-0 text-success">{{ formatHoursDisplay(totalPlanHours) }}</h5>
</div>
</div>
</div>
@@ -51,24 +150,21 @@
<!-- 공통 사항 -->
<div class="card mb-4">
<div class="card-header">
<strong><i class="bi bi-chat-left-text me-2"></i>공통 사항</strong>
</div>
<div class="card-header"><strong><i class="bi bi-chat-left-text me-2"></i>공통 사항</strong></div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">이슈/리스크</label>
<textarea class="form-control" rows="3" v-model="form.issueDescription"
placeholder="진행 중 발생한 이슈나 리스크를 작성해주세요."></textarea>
</div>
<div class="mb-3">
<label class="form-label">휴가일정</label>
<textarea class="form-control" rows="2" v-model="form.vacationDescription"
placeholder="예: 1/6(월) 연차, 1/8(수) 오후 반차"></textarea>
</div>
<div>
<label class="form-label">기타사항</label>
<textarea class="form-control" rows="2" v-model="form.remarkDescription"
placeholder="기타 전달사항이 있으면 작성해주세요."></textarea>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">이슈/리스크</label>
<textarea class="form-control" rows="3" v-model="form.issueDescription"></textarea>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">휴가일정</label>
<textarea class="form-control" rows="3" v-model="form.vacationDescription"></textarea>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">기타사항</label>
<textarea class="form-control" rows="3" v-model="form.remarkDescription"></textarea>
</div>
</div>
</div>
</div>
@@ -76,16 +172,16 @@
<!-- 버튼 -->
<div class="d-flex justify-content-end gap-2">
<NuxtLink to="/report/weekly" class="btn btn-secondary">취소</NuxtLink>
<button type="submit" class="btn btn-primary" :disabled="isSubmitting || form.projects.length === 0">
<span v-if="isSubmitting" class="spinner-border spinner-border-sm me-1"></span>
임시저장
<button type="submit" class="btn btn-primary" :disabled="isSaving || !canSubmit">
<span v-if="isSaving" class="spinner-border spinner-border-sm me-1"></span>
저장
</button>
</div>
</form>
</div>
<!-- 프로젝트 선택 모달 -->
<div class="modal fade" :class="{ show: showProjectModal }" :style="{ display: showProjectModal ? 'block' : 'none' }" tabindex="-1">
<div class="modal fade" :class="{ show: showProjectModal }" :style="{ display: showProjectModal ? 'block' : 'none' }">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
@@ -93,16 +189,13 @@
<button type="button" class="btn-close" @click="showProjectModal = false"></button>
</div>
<div class="modal-body">
<div v-if="isLoadingProjects" class="text-center py-4">
<span class="spinner-border spinner-border-sm me-2"></span>로딩 ...
</div>
<div v-else-if="availableProjects.length === 0" class="text-center text-muted py-4">
<div v-if="availableProjects.length === 0" class="text-center text-muted py-4">
추가할 있는 프로젝트가 없습니다.
</div>
<div v-else class="list-group">
<button type="button" class="list-group-item list-group-item-action"
v-for="p in availableProjects" :key="p.projectId"
@click="addProject(p)">
@click="addProjectGroup(p)">
<strong>{{ p.projectName }}</strong>
<small class="text-muted ms-2">({{ p.projectCode }})</small>
</button>
@@ -117,103 +210,258 @@
<script setup lang="ts">
const { fetchCurrentUser } = useAuth()
const { getCurrentWeekInfo } = useWeekCalc()
const router = useRouter()
const weekInfo = getCurrentWeekInfo()
interface TaskItem {
projectId: number
taskType: 'WORK' | 'PLAN'
description: string
hours: number
isCompleted: boolean
}
interface ProjectItem {
interface ProjectGroup {
projectId: number
projectCode: string
projectName: string
workDescription: string
planDescription: string
}
const allProjects = ref<any[]>([])
const showProjectModal = ref(false)
const isSaving = ref(false)
const form = ref({
projects: [] as ProjectItem[],
reportYear: new Date().getFullYear(),
reportWeek: 1,
weekStartDate: '',
weekEndDate: '',
tasks: [] as TaskItem[],
issueDescription: '',
vacationDescription: '',
remarkDescription: ''
})
const allProjects = ref<any[]>([])
const isLoadingProjects = ref(false)
const isSubmitting = ref(false)
const showProjectModal = ref(false)
// 아직 추가하지 않은 프로젝트만
const availableProjects = computed(() => {
const addedIds = form.value.projects.map(p => p.projectId)
return allProjects.value.filter(p => !addedIds.includes(p.projectId))
const projectGroups = computed<ProjectGroup[]>(() => {
const projectIds = [...new Set(form.value.tasks.map(t => t.projectId))]
return projectIds.map(pid => {
const proj = allProjects.value.find(p => p.projectId === pid)
return {
projectId: pid,
projectCode: proj?.projectCode || '',
projectName: proj?.projectName || ''
}
})
})
const availableProjects = computed(() => {
const usedIds = projectGroups.value.map(g => g.projectId)
return allProjects.value.filter(p => !usedIds.includes(p.projectId))
})
const totalWorkHours = computed(() =>
form.value.tasks.filter(t => t.taskType === 'WORK').reduce((sum, t) => sum + (t.hours || 0), 0)
)
const totalPlanHours = computed(() =>
form.value.tasks.filter(t => t.taskType === 'PLAN').reduce((sum, t) => sum + (t.hours || 0), 0)
)
const canSubmit = computed(() => form.value.tasks.some(t => t.description.trim()))
onMounted(async () => {
const user = await fetchCurrentUser()
if (!user) {
router.push('/login')
return
}
loadProjects()
await loadProjects()
setLastWeek()
})
async function loadProjects() {
isLoadingProjects.value = true
try {
const res = await $fetch<any>('/api/project/list')
allProjects.value = res.projects || []
} catch (e) {
console.error(e)
} finally {
isLoadingProjects.value = false
}
}
function addProject(p: any) {
form.value.projects.push({
projectId: p.projectId,
projectCode: p.projectCode,
projectName: p.projectName,
workDescription: '',
planDescription: ''
// 주차 관련 함수들
function getMonday(date: Date): Date {
const d = new Date(date)
const day = d.getDay()
const diff = d.getDate() - day + (day === 0 ? -6 : 1)
d.setDate(diff)
return d
}
function getSunday(monday: Date): Date {
const d = new Date(monday)
d.setDate(d.getDate() + 6)
return d
}
function formatDate(date: Date): string {
return date.toISOString().split('T')[0]
}
function getWeekNumber(date: Date): { year: number; week: number } {
const d = new Date(date)
d.setHours(0, 0, 0, 0)
d.setDate(d.getDate() + 3 - (d.getDay() + 6) % 7)
const week1 = new Date(d.getFullYear(), 0, 4)
const weekNum = 1 + Math.round(((d.getTime() - week1.getTime()) / 86400000 - 3 + (week1.getDay() + 6) % 7) / 7)
return { year: d.getFullYear(), week: weekNum }
}
function setWeekDates(monday: Date) {
const sunday = getSunday(monday)
const weekInfo = getWeekNumber(monday)
form.value.weekStartDate = formatDate(monday)
form.value.weekEndDate = formatDate(sunday)
form.value.reportYear = weekInfo.year
form.value.reportWeek = weekInfo.week
}
function changeWeek(delta: number) {
const currentMonday = new Date(form.value.weekStartDate)
currentMonday.setDate(currentMonday.getDate() + (delta * 7))
setWeekDates(currentMonday)
}
function setLastWeek() {
const today = new Date()
const lastWeekMonday = getMonday(today)
lastWeekMonday.setDate(lastWeekMonday.getDate() - 7)
setWeekDates(lastWeekMonday)
}
function setThisWeek() {
const today = new Date()
const thisWeekMonday = getMonday(today)
setWeekDates(thisWeekMonday)
}
function updateWeekFromDate() {
const startDate = new Date(form.value.weekStartDate)
const monday = getMonday(startDate)
setWeekDates(monday)
}
// Task 관련 함수들
function getWorkTasks(projectId: number) {
return form.value.tasks.filter(t => t.projectId === projectId && t.taskType === 'WORK')
}
function getPlanTasks(projectId: number) {
return form.value.tasks.filter(t => t.projectId === projectId && t.taskType === 'PLAN')
}
function getGroupWorkHours(group: ProjectGroup) {
return getWorkTasks(group.projectId).reduce((sum, t) => sum + (t.hours || 0), 0)
}
function getGroupPlanHours(group: ProjectGroup) {
return getPlanTasks(group.projectId).reduce((sum, t) => sum + (t.hours || 0), 0)
}
function addProjectGroup(project: any) {
// 기본 Task 1개씩 추가
form.value.tasks.push({
projectId: project.projectId,
taskType: 'WORK',
description: '',
hours: 0,
isCompleted: true
})
form.value.tasks.push({
projectId: project.projectId,
taskType: 'PLAN',
description: '',
hours: 0,
isCompleted: true
})
showProjectModal.value = false
}
function removeProject(idx: number) {
form.value.projects.splice(idx, 1)
function removeProjectGroup(gIdx: number) {
const group = projectGroups.value[gIdx]
form.value.tasks = form.value.tasks.filter(t => t.projectId !== group.projectId)
}
function addTask(projectId: number, taskType: 'WORK' | 'PLAN') {
form.value.tasks.push({ projectId, taskType, description: '', hours: 0, isCompleted: true })
}
function removeTask(projectId: number, taskType: 'WORK' | 'PLAN', idx: number) {
const tasks = form.value.tasks.filter(t => t.projectId === projectId && t.taskType === taskType)
if (tasks.length <= 1) return // 최소 1개는 유지
const targetTask = tasks[idx]
const targetIndex = form.value.tasks.indexOf(targetTask)
if (targetIndex > -1) {
form.value.tasks.splice(targetIndex, 1)
}
}
// 시간 표시 함수
function formatHours(hours: number): string {
if (!hours || hours <= 0) return '-'
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`
}
function formatHoursDisplay(hours: number): string {
if (!hours || hours <= 0) return '0h'
const days = Math.floor(hours / 8)
const remainHours = hours % 8
if (days === 0) return `${remainHours}시간`
if (remainHours === 0) return `${days}`
return `${days}${remainHours}시간`
}
async function handleSubmit() {
if (form.value.projects.length === 0) {
alert('최소 1개 이상의 프로젝트를 추가해주세요.')
const validTasks = form.value.tasks.filter(t => t.description.trim())
if (validTasks.length === 0) {
alert('최소 1개 이상의 Task를 입력해주세요.')
return
}
isSubmitting.value = true
isSaving.value = true
try {
const res = await $fetch<any>('/api/report/weekly/create', {
await $fetch('/api/report/weekly/create', {
method: 'POST',
body: {
reportYear: weekInfo.year,
reportWeek: weekInfo.week,
projects: form.value.projects.map(p => ({
projectId: p.projectId,
workDescription: p.workDescription,
planDescription: p.planDescription
reportYear: form.value.reportYear,
reportWeek: form.value.reportWeek,
weekStartDate: form.value.weekStartDate,
weekEndDate: form.value.weekEndDate,
tasks: validTasks.map(t => ({
projectId: t.projectId,
taskType: t.taskType,
taskDescription: t.description,
taskHours: t.hours || 0,
isCompleted: t.taskType === 'WORK' ? t.isCompleted : undefined
})),
issueDescription: form.value.issueDescription,
vacationDescription: form.value.vacationDescription,
remarkDescription: form.value.remarkDescription
}
})
alert('저장되었습니다.')
router.push(`/report/weekly/${res.reportId}`)
alert('주간보고가 작성되었습니다.')
router.push('/report/weekly')
} catch (e: any) {
alert(e.data?.message || '저장에 실패했습니다.')
} finally {
isSubmitting.value = false
isSaving.value = false
}
}
</script>