작업계획서대로 진행
This commit is contained in:
204
frontend/business-report/[id].vue
Normal file
204
frontend/business-report/[id].vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div>
|
||||
<AppHeader />
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="mb-0">
|
||||
<NuxtLink to="/business-report" class="text-decoration-none text-muted me-2"><i class="bi bi-arrow-left"></i></NuxtLink>
|
||||
<i class="bi bi-file-earmark-text me-2"></i>{{ report?.businessName }} - {{ report?.reportYear }}-W{{ String(report?.reportWeek || 0).padStart(2, '0') }}
|
||||
</h4>
|
||||
<div v-if="report">
|
||||
<span :class="report.status === 'confirmed' ? 'badge bg-success me-3' : 'badge bg-warning me-3'">
|
||||
{{ report.status === 'confirmed' ? '확정' : '작성중' }}
|
||||
</span>
|
||||
<button class="btn btn-success" @click="confirmReport" v-if="report.status !== 'confirmed'" :disabled="isConfirming">
|
||||
<i class="bi bi-check-lg me-1"></i>확정
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="text-center py-5">
|
||||
<div class="spinner-border text-primary"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="report" class="row">
|
||||
<div class="col-md-8">
|
||||
<!-- 요약 -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>취합 요약</strong>
|
||||
<button class="btn btn-sm btn-outline-primary" @click="toggleEdit" v-if="report.status !== 'confirmed'">
|
||||
{{ isEditing ? '취소' : '수정' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div v-if="!isEditing">
|
||||
<div class="mb-3" v-if="report.manualSummary">
|
||||
<label class="text-muted small">최종 요약 (수정됨)</label>
|
||||
<div style="white-space: pre-wrap;">{{ report.manualSummary }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-muted small">AI 생성 요약</label>
|
||||
<div style="white-space: pre-wrap;" :class="{ 'text-muted': report.manualSummary }">{{ report.aiSummary }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<label class="form-label">요약 수정</label>
|
||||
<textarea class="form-control" v-model="editSummary" rows="8" placeholder="AI 요약을 수정하세요..."></textarea>
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-primary btn-sm" @click="saveSummary" :disabled="isSaving">
|
||||
<span v-if="isSaving"><span class="spinner-border spinner-border-sm me-1"></span></span>
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 실적 목록 -->
|
||||
<div class="card">
|
||||
<div class="card-header"><strong>포함된 실적 목록</strong></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-bordered mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 120px">프로젝트</th>
|
||||
<th style="width: 80px">담당자</th>
|
||||
<th>실적 내용</th>
|
||||
<th style="width: 60px" class="text-center">시간</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in tasks" :key="t.taskId">
|
||||
<td>{{ t.projectName }}</td>
|
||||
<td>{{ t.employeeName }}</td>
|
||||
<td>{{ t.taskDescription }}</td>
|
||||
<td class="text-center">{{ t.taskHours || '-' }}</td>
|
||||
</tr>
|
||||
<tr v-if="tasks.length === 0">
|
||||
<td colspan="4" class="text-center py-3 text-muted">실적이 없습니다.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<!-- 정보 -->
|
||||
<div class="card">
|
||||
<div class="card-header"><strong>보고서 정보</strong></div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">사업</span>
|
||||
<NuxtLink :to="`/business/${report.businessId}`">{{ report.businessName }}</NuxtLink>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">주차</span>
|
||||
<span>{{ report.reportYear }}-W{{ String(report.reportWeek).padStart(2, '0') }}</span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">기간</span>
|
||||
<span>{{ formatDate(report.weekStartDate) }} ~ {{ formatDate(report.weekEndDate) }}</span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">실적 건수</span>
|
||||
<span>{{ tasks.length }}건</span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">생성자</span>
|
||||
<span>{{ report.createdByName || '-' }}</span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span class="text-muted">생성일</span>
|
||||
<span>{{ formatDateTime(report.createdAt) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { fetchCurrentUser } = useAuth()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const report = ref<any>(null)
|
||||
const tasks = ref<any[]>([])
|
||||
const isLoading = ref(true)
|
||||
const isEditing = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const isConfirming = ref(false)
|
||||
const editSummary = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
const user = await fetchCurrentUser()
|
||||
if (!user) { router.push('/login'); return }
|
||||
await loadReport()
|
||||
})
|
||||
|
||||
async function loadReport() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await $fetch<{ report: any; tasks: any[] }>(`/api/business-report/${route.params.id}/detail`)
|
||||
report.value = res.report
|
||||
tasks.value = res.tasks || []
|
||||
} catch (e: any) {
|
||||
alert('보고서를 불러올 수 없습니다.')
|
||||
router.push('/business-report')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleEdit() {
|
||||
if (!isEditing.value) {
|
||||
editSummary.value = report.value.manualSummary || report.value.aiSummary || ''
|
||||
}
|
||||
isEditing.value = !isEditing.value
|
||||
}
|
||||
|
||||
async function saveSummary() {
|
||||
isSaving.value = true
|
||||
try {
|
||||
await $fetch(`/api/business-report/${route.params.id}/update`, {
|
||||
method: 'PUT',
|
||||
body: { manualSummary: editSummary.value }
|
||||
})
|
||||
isEditing.value = false
|
||||
await loadReport()
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || '저장에 실패했습니다.')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmReport() {
|
||||
if (!confirm('보고서를 확정하시겠습니까? 확정 후에는 수정할 수 없습니다.')) return
|
||||
isConfirming.value = true
|
||||
try {
|
||||
await $fetch(`/api/business-report/${route.params.id}/confirm`, { method: 'PUT' })
|
||||
await loadReport()
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || '확정에 실패했습니다.')
|
||||
} finally {
|
||||
isConfirming.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(d: string) {
|
||||
if (!d) return '-'
|
||||
return d.split('T')[0]
|
||||
}
|
||||
|
||||
function formatDateTime(d: string) {
|
||||
if (!d) return '-'
|
||||
const dt = new Date(d)
|
||||
return `${dt.getFullYear()}-${String(dt.getMonth()+1).padStart(2,'0')}-${String(dt.getDate()).padStart(2,'0')} ${String(dt.getHours()).padStart(2,'0')}:${String(dt.getMinutes()).padStart(2,'0')}`
|
||||
}
|
||||
</script>
|
||||
272
frontend/business-report/index.vue
Normal file
272
frontend/business-report/index.vue
Normal file
@@ -0,0 +1,272 @@
|
||||
<template>
|
||||
<div>
|
||||
<AppHeader />
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="mb-0"><i class="bi bi-file-earmark-text me-2"></i>사업 주간보고 취합</h4>
|
||||
</div>
|
||||
|
||||
<!-- 조건 -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-body py-2">
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col-1 text-end"><label class="col-form-label">사업</label></div>
|
||||
<div class="col-3">
|
||||
<select class="form-select form-select-sm" v-model="filter.businessId" @change="loadReports">
|
||||
<option value="">전체</option>
|
||||
<option v-for="b in businesses" :key="b.businessId" :value="b.businessId">{{ b.businessName }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-1 text-end"><label class="col-form-label">년도</label></div>
|
||||
<div class="col-1">
|
||||
<select class="form-select form-select-sm" v-model="filter.year" @change="loadReports">
|
||||
<option v-for="y in years" :key="y" :value="y">{{ y }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<button class="btn btn-primary btn-sm" @click="showGenerateModal = true" :disabled="!filter.businessId">
|
||||
<i class="bi bi-plus-lg me-1"></i>취합 생성
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 목록 -->
|
||||
<div class="card">
|
||||
<div class="card-header">취합 목록</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-bordered mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 50px" class="text-center">No</th>
|
||||
<th>사업명</th>
|
||||
<th style="width: 100px" class="text-center">주차</th>
|
||||
<th style="width: 180px">기간</th>
|
||||
<th>AI 요약 (미리보기)</th>
|
||||
<th style="width: 80px" class="text-center">상태</th>
|
||||
<th style="width: 100px">생성일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="isLoading">
|
||||
<td colspan="7" class="text-center py-4"><span class="spinner-border spinner-border-sm me-2"></span>로딩 중...</td>
|
||||
</tr>
|
||||
<tr v-else-if="reports.length === 0">
|
||||
<td colspan="7" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-inbox display-4"></i>
|
||||
<p class="mt-2 mb-0">취합된 보고서가 없습니다.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else v-for="(r, idx) in reports" :key="r.businessReportId">
|
||||
<td class="text-center">{{ idx + 1 }}</td>
|
||||
<td>
|
||||
<NuxtLink :to="`/business-report/${r.businessReportId}`" class="text-decoration-none">
|
||||
{{ r.businessName }}
|
||||
</NuxtLink>
|
||||
</td>
|
||||
<td class="text-center">{{ r.reportYear }}-W{{ String(r.reportWeek).padStart(2, '0') }}</td>
|
||||
<td>{{ formatDate(r.weekStartDate) }} ~ {{ formatDate(r.weekEndDate) }}</td>
|
||||
<td><small class="text-muted">{{ truncate(r.manualSummary || r.aiSummary, 50) }}</small></td>
|
||||
<td class="text-center">
|
||||
<span :class="r.status === 'confirmed' ? 'badge bg-success' : 'badge bg-warning'">
|
||||
{{ r.status === 'confirmed' ? '확정' : '작성중' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ formatDate(r.createdAt) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 취합 생성 모달 -->
|
||||
<div class="modal fade" :class="{ show: showGenerateModal }" :style="{ display: showGenerateModal ? 'block' : 'none' }">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">주간보고 취합 생성</h5>
|
||||
<button type="button" class="btn-close" @click="showGenerateModal = 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="generateForm.businessId" disabled>
|
||||
<option v-for="b in businesses" :key="b.businessId" :value="b.businessId">{{ b.businessName }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">주차 <span class="text-danger">*</span></label>
|
||||
<select class="form-select" v-model="generateForm.week">
|
||||
<option v-for="w in weeks" :key="w.week" :value="w">
|
||||
{{ w.year }}-W{{ String(w.week).padStart(2, '0') }} ({{ w.startDate }} ~ {{ w.endDate }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="alert alert-info mb-0">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
선택한 주차에 해당하는 프로젝트 실적을 AI가 요약합니다.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="showGenerateModal = false">취소</button>
|
||||
<button type="button" class="btn btn-primary" @click="generateReport" :disabled="isGenerating">
|
||||
<span v-if="isGenerating"><span class="spinner-border spinner-border-sm me-1"></span>생성 중...</span>
|
||||
<span v-else><i class="bi bi-magic me-1"></i>AI 취합 생성</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show" v-if="showGenerateModal"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { fetchCurrentUser } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
interface Business { businessId: number; businessName: string }
|
||||
interface Report {
|
||||
businessReportId: number
|
||||
businessId: number
|
||||
businessName: string
|
||||
reportYear: number
|
||||
reportWeek: number
|
||||
weekStartDate: string
|
||||
weekEndDate: string
|
||||
aiSummary: string
|
||||
manualSummary: string
|
||||
status: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const businesses = ref<Business[]>([])
|
||||
const reports = ref<Report[]>([])
|
||||
const isLoading = ref(false)
|
||||
const showGenerateModal = ref(false)
|
||||
const isGenerating = ref(false)
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
const years = [currentYear, currentYear - 1]
|
||||
|
||||
const filter = ref({
|
||||
businessId: '',
|
||||
year: currentYear
|
||||
})
|
||||
|
||||
const weeks = computed(() => {
|
||||
const result = []
|
||||
const now = new Date()
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const d = new Date(now.getTime() - i * 7 * 24 * 60 * 60 * 1000)
|
||||
const { year, week, startDate, endDate } = getWeekInfo(d)
|
||||
result.push({ year, week, startDate, endDate })
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const generateForm = ref({
|
||||
businessId: '',
|
||||
week: null as any
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const user = await fetchCurrentUser()
|
||||
if (!user) { router.push('/login'); return }
|
||||
await loadBusinesses()
|
||||
await loadReports()
|
||||
})
|
||||
|
||||
watch(() => filter.value.businessId, (v) => {
|
||||
generateForm.value.businessId = v
|
||||
if (weeks.value.length > 0) {
|
||||
generateForm.value.week = weeks.value[0]
|
||||
}
|
||||
})
|
||||
|
||||
async function loadBusinesses() {
|
||||
try {
|
||||
const res = await $fetch<{ businesses: Business[] }>('/api/business/list')
|
||||
businesses.value = res.businesses || []
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function loadReports() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await $fetch<{ reports: Report[] }>('/api/business-report/list', {
|
||||
query: {
|
||||
businessId: filter.value.businessId || undefined,
|
||||
year: filter.value.year
|
||||
}
|
||||
})
|
||||
reports.value = res.reports || []
|
||||
} catch (e) { console.error(e) }
|
||||
finally { isLoading.value = false }
|
||||
}
|
||||
|
||||
async function generateReport() {
|
||||
if (!generateForm.value.businessId || !generateForm.value.week) {
|
||||
alert('사업과 주차를 선택하세요.')
|
||||
return
|
||||
}
|
||||
isGenerating.value = true
|
||||
try {
|
||||
const w = generateForm.value.week
|
||||
const res = await $fetch<{ report: any }>('/api/business-report/generate', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
businessId: Number(generateForm.value.businessId),
|
||||
reportYear: w.year,
|
||||
reportWeek: w.week,
|
||||
weekStartDate: w.startDate,
|
||||
weekEndDate: w.endDate
|
||||
}
|
||||
})
|
||||
showGenerateModal.value = false
|
||||
await loadReports()
|
||||
router.push(`/business-report/${res.report.businessReportId}`)
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || '취합 생성에 실패했습니다.')
|
||||
} finally {
|
||||
isGenerating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getWeekInfo(date: Date) {
|
||||
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 week = 1 + Math.round(((d.getTime() - week1.getTime()) / 86400000 - 3 + (week1.getDay() + 6) % 7) / 7)
|
||||
|
||||
const monday = new Date(date)
|
||||
monday.setDate(monday.getDate() - (monday.getDay() + 6) % 7)
|
||||
const sunday = new Date(monday)
|
||||
sunday.setDate(sunday.getDate() + 6)
|
||||
|
||||
return {
|
||||
year: d.getFullYear(),
|
||||
week,
|
||||
startDate: monday.toISOString().split('T')[0],
|
||||
endDate: sunday.toISOString().split('T')[0]
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(d: string) {
|
||||
if (!d) return '-'
|
||||
return d.split('T')[0]
|
||||
}
|
||||
|
||||
function truncate(s: string, len: number) {
|
||||
if (!s) return '-'
|
||||
return s.length > len ? s.substring(0, len) + '...' : s
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal.show { background: rgba(0, 0, 0, 0.5); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user