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

@@ -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>