기능구현중
This commit is contained in:
@@ -138,9 +138,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 오른쪽: 회의 내용 -->
|
||||
<!-- 오른쪽: 회의 내용 + AI 분석 -->
|
||||
<div class="col-md-8">
|
||||
<div class="card h-100">
|
||||
<!-- 회의 내용 -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<strong>회의 내용</strong>
|
||||
</div>
|
||||
@@ -149,9 +150,9 @@
|
||||
v-if="isEditing"
|
||||
class="form-control border-0 h-100"
|
||||
v-model="form.rawContent"
|
||||
style="min-height: 500px; resize: none;"
|
||||
style="min-height: 300px; resize: none;"
|
||||
></textarea>
|
||||
<div v-else class="p-3" style="min-height: 500px; white-space: pre-wrap;">{{ meeting.rawContent || '(내용 없음)' }}</div>
|
||||
<div v-else class="p-3" style="min-height: 200px; white-space: pre-wrap;">{{ meeting.rawContent || '(내용 없음)' }}</div>
|
||||
</div>
|
||||
<div v-if="isEditing" class="card-footer d-flex justify-content-end">
|
||||
<button class="btn btn-secondary me-2" @click="cancelEdit">취소</button>
|
||||
@@ -163,6 +164,94 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI 분석 결과 -->
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i class="bi bi-robot me-2"></i><strong>AI 분석 결과</strong>
|
||||
<span v-if="meeting.aiStatus === 'CONFIRMED'" class="badge bg-success ms-2">확정됨</span>
|
||||
<span v-else-if="meeting.aiStatus === 'PENDING'" class="badge bg-warning ms-2">미확정</span>
|
||||
<span v-else class="badge bg-secondary ms-2">미분석</span>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
v-if="meeting.aiStatus !== 'CONFIRMED'"
|
||||
class="btn btn-sm btn-outline-primary me-2"
|
||||
@click="analyzeContent"
|
||||
:disabled="isAnalyzing"
|
||||
>
|
||||
<span v-if="isAnalyzing"><span class="spinner-border spinner-border-sm me-1"></span></span>
|
||||
<i v-else class="bi bi-magic me-1"></i>
|
||||
{{ meeting.aiStatus === 'PENDING' ? '재분석' : 'AI 분석' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="meeting.aiStatus === 'PENDING' && aiResult"
|
||||
class="btn btn-sm btn-success"
|
||||
@click="confirmAnalysis"
|
||||
:disabled="isConfirming"
|
||||
>
|
||||
<span v-if="isConfirming"><span class="spinner-border spinner-border-sm me-1"></span></span>
|
||||
<i v-else class="bi bi-check-lg me-1"></i>확정하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- 분석 결과 없음 -->
|
||||
<div v-if="!aiResult" class="text-center text-muted py-4">
|
||||
<i class="bi bi-robot display-4"></i>
|
||||
<p class="mt-2">AI 분석을 실행해주세요.</p>
|
||||
</div>
|
||||
|
||||
<!-- 분석 결과 표시 -->
|
||||
<div v-else>
|
||||
<!-- 요약 -->
|
||||
<div class="alert alert-info mb-3">
|
||||
<i class="bi bi-lightbulb me-2"></i>
|
||||
<strong>요약:</strong> {{ aiResult.summary }}
|
||||
</div>
|
||||
|
||||
<!-- 안건 목록 -->
|
||||
<div v-for="agenda in aiResult.agendas" :key="agenda.no" class="border rounded p-3 mb-3">
|
||||
<h6 class="mb-2">
|
||||
<span class="badge bg-secondary me-2">{{ agenda.no }}</span>
|
||||
{{ agenda.title }}
|
||||
<span :class="getStatusBadge(agenda.status)" class="ms-2">{{ getStatusText(agenda.status) }}</span>
|
||||
</h6>
|
||||
<p class="text-muted small mb-2">{{ agenda.content }}</p>
|
||||
<p v-if="agenda.decision" class="mb-2">
|
||||
<i class="bi bi-check-circle text-success me-1"></i>
|
||||
<strong>결정:</strong> {{ agenda.decision }}
|
||||
</p>
|
||||
|
||||
<!-- TODO 항목 -->
|
||||
<div v-if="agenda.todos && agenda.todos.length > 0" class="mt-2">
|
||||
<div v-for="(todo, tIdx) in agenda.todos" :key="tIdx" class="form-check">
|
||||
<input
|
||||
v-if="meeting.aiStatus === 'PENDING'"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
:id="`todo-${agenda.no}-${tIdx}`"
|
||||
v-model="selectedTodos"
|
||||
:value="{ agendaNo: agenda.no, todoIndex: tIdx, title: todo.title, assignee: todo.assignee }"
|
||||
/>
|
||||
<label class="form-check-label" :for="`todo-${agenda.no}-${tIdx}`">
|
||||
<i class="bi bi-check2-square text-primary me-1"></i>
|
||||
{{ todo.title }}
|
||||
<span v-if="todo.assignee" class="badge bg-light text-dark ms-1">@{{ todo.assignee }}</span>
|
||||
</label>
|
||||
<small class="d-block text-muted ms-4">{{ todo.reason }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="meeting.aiStatus === 'PENDING'" class="alert alert-warning mb-0">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
TODO로 생성할 항목을 선택한 후 [확정하기]를 클릭하세요.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -241,6 +330,12 @@ const isLoading = ref(true)
|
||||
const isEditing = ref(false)
|
||||
const isSaving = ref(false)
|
||||
|
||||
// AI 분석 관련
|
||||
const aiResult = ref<any>(null)
|
||||
const isAnalyzing = ref(false)
|
||||
const isConfirming = ref(false)
|
||||
const selectedTodos = ref<any[]>([])
|
||||
|
||||
const form = ref({
|
||||
meetingTitle: '',
|
||||
meetingType: 'PROJECT' as 'PROJECT' | 'INTERNAL',
|
||||
@@ -285,6 +380,20 @@ async function loadMeeting() {
|
||||
attendees.value = res.attendees || []
|
||||
agendas.value = res.agendas || []
|
||||
|
||||
// AI 분석 결과 로드
|
||||
if (res.meeting.aiSummary) {
|
||||
try {
|
||||
aiResult.value = typeof res.meeting.aiSummary === 'string'
|
||||
? JSON.parse(res.meeting.aiSummary)
|
||||
: res.meeting.aiSummary
|
||||
} catch (e) {
|
||||
aiResult.value = null
|
||||
}
|
||||
} else {
|
||||
aiResult.value = null
|
||||
}
|
||||
selectedTodos.value = []
|
||||
|
||||
// 폼 초기화
|
||||
form.value = {
|
||||
meetingTitle: res.meeting.meetingTitle,
|
||||
@@ -428,6 +537,63 @@ function formatDate(dateStr: string) {
|
||||
const d = new Date(dateStr)
|
||||
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
|
||||
}
|
||||
|
||||
// AI 분석
|
||||
async function analyzeContent() {
|
||||
if (!meeting.value?.rawContent) {
|
||||
alert('분석할 회의 내용이 없습니다.')
|
||||
return
|
||||
}
|
||||
|
||||
isAnalyzing.value = true
|
||||
try {
|
||||
const res = await $fetch<{ result: any }>(`/api/meeting/${meetingId.value}/analyze`, { method: 'POST' })
|
||||
aiResult.value = res.result
|
||||
meeting.value.aiStatus = 'PENDING'
|
||||
selectedTodos.value = []
|
||||
alert('AI 분석이 완료되었습니다.')
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || 'AI 분석에 실패했습니다.')
|
||||
} finally {
|
||||
isAnalyzing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmAnalysis() {
|
||||
if (selectedTodos.value.length === 0) {
|
||||
if (!confirm('선택된 TODO가 없습니다. 그래도 확정하시겠습니까?')) return
|
||||
}
|
||||
|
||||
isConfirming.value = true
|
||||
try {
|
||||
const res = await $fetch<{ message: string }>(`/api/meeting/${meetingId.value}/confirm`, {
|
||||
method: 'POST',
|
||||
body: { selectedTodos: selectedTodos.value }
|
||||
})
|
||||
meeting.value.aiStatus = 'CONFIRMED'
|
||||
alert(res.message)
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || '확정에 실패했습니다.')
|
||||
} finally {
|
||||
isConfirming.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusBadge(status: string) {
|
||||
return {
|
||||
'DECIDED': 'badge bg-success',
|
||||
'PENDING': 'badge bg-warning',
|
||||
'IN_PROGRESS': 'badge bg-info'
|
||||
}[status] || 'badge bg-secondary'
|
||||
}
|
||||
|
||||
function getStatusText(status: string) {
|
||||
return {
|
||||
'DECIDED': '결정됨',
|
||||
'PENDING': '미결정',
|
||||
'IN_PROGRESS': '진행중'
|
||||
}[status] || status
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
</div>
|
||||
|
||||
<!-- PM/PL 이력 -->
|
||||
<div class="card">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-person-badge me-2"></i>PM/PL 담당 이력</span>
|
||||
<button class="btn btn-sm btn-primary" @click="showAssignModal = true">
|
||||
@@ -161,6 +161,44 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 저장소 관리 -->
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-git me-2"></i>저장소 관리</span>
|
||||
<button class="btn btn-sm btn-primary" @click="openRepoModal()">
|
||||
<i class="bi bi-plus"></i> 저장소 추가
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-bordered mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 70px">타입</th>
|
||||
<th>저장소명</th>
|
||||
<th>경로</th>
|
||||
<th style="width: 100px">브랜치</th>
|
||||
<th style="width: 100px">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in repositories" :key="r.repoId">
|
||||
<td><span :class="r.serverType === 'GIT' ? 'badge bg-dark' : 'badge bg-warning text-dark'">{{ r.serverType }}</span></td>
|
||||
<td>{{ r.repoName }}</td>
|
||||
<td><small class="text-muted">{{ r.serverName }} ></small> {{ r.repoPath }}</td>
|
||||
<td>{{ r.defaultBranch || '-' }}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-secondary me-1" @click="openRepoModal(r)" title="수정"><i class="bi bi-pencil"></i></button>
|
||||
<button class="btn btn-sm btn-outline-danger" @click="deleteRepo(r.repoId)" title="삭제"><i class="bi bi-trash"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="repositories.length === 0">
|
||||
<td colspan="5" class="text-center text-muted py-3">등록된 저장소가 없습니다.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
@@ -237,6 +275,55 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show" v-if="showAssignModal"></div>
|
||||
|
||||
<!-- 저장소 추가/수정 모달 -->
|
||||
<div class="modal fade" :class="{ show: showRepoModal }" :style="{ display: showRepoModal ? 'block' : 'none' }">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ repoForm.repoId ? '저장소 수정' : '저장소 추가' }}</h5>
|
||||
<button type="button" class="btn-close" @click="showRepoModal = false"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">VCS 서버 <span class="text-danger">*</span></label>
|
||||
<select class="form-select" v-model="repoForm.serverId">
|
||||
<option value="">선택하세요</option>
|
||||
<option v-for="s in vcsServers" :key="s.serverId" :value="s.serverId">[{{ s.serverType }}] {{ s.serverName }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">저장소명</label>
|
||||
<input type="text" class="form-control" v-model="repoForm.repoName" placeholder="표시용 이름" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">저장소 경로 <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" v-model="repoForm.repoPath" placeholder="/owner/repo.git 또는 /svn/project/trunk" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">전체 URL</label>
|
||||
<input type="text" class="form-control" v-model="repoForm.repoUrl" placeholder="https://github.com/owner/repo.git" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">기본 브랜치 (Git)</label>
|
||||
<input type="text" class="form-control" v-model="repoForm.defaultBranch" placeholder="main" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">설명</label>
|
||||
<textarea class="form-control" v-model="repoForm.description" rows="2"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="showRepoModal = false">취소</button>
|
||||
<button type="button" class="btn btn-primary" @click="saveRepo" :disabled="isSavingRepo">
|
||||
<span v-if="isSavingRepo"><span class="spinner-border spinner-border-sm me-1"></span></span>
|
||||
<i class="bi bi-check-lg me-1" v-else></i>저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show" v-if="showRepoModal"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -275,10 +362,25 @@ const assignForm = ref({
|
||||
changeReason: ''
|
||||
})
|
||||
|
||||
// 저장소 관련
|
||||
const repositories = ref<any[]>([])
|
||||
const vcsServers = ref<any[]>([])
|
||||
const showRepoModal = ref(false)
|
||||
const isSavingRepo = ref(false)
|
||||
const repoForm = ref({
|
||||
repoId: null as number | null,
|
||||
serverId: '',
|
||||
repoName: '',
|
||||
repoPath: '',
|
||||
repoUrl: '',
|
||||
defaultBranch: 'main',
|
||||
description: ''
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const user = await fetchCurrentUser()
|
||||
if (!user) { router.push('/login'); return }
|
||||
await Promise.all([loadProject(), loadBusinesses(), loadEmployees()])
|
||||
await Promise.all([loadProject(), loadBusinesses(), loadEmployees(), loadVcsServers(), loadRepositories()])
|
||||
})
|
||||
|
||||
async function loadProject() {
|
||||
@@ -310,6 +412,68 @@ async function loadEmployees() {
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function loadVcsServers() {
|
||||
try {
|
||||
const res = await $fetch<{ servers: any[] }>('/api/vcs-server/list')
|
||||
vcsServers.value = res.servers || []
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function loadRepositories() {
|
||||
try {
|
||||
const res = await $fetch<{ repositories: any[] }>(`/api/repository/list?projectId=${route.params.id}`)
|
||||
repositories.value = res.repositories || []
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
function openRepoModal(repo?: any) {
|
||||
if (repo) {
|
||||
repoForm.value = {
|
||||
repoId: repo.repoId,
|
||||
serverId: repo.serverId?.toString() || '',
|
||||
repoName: repo.repoName || '',
|
||||
repoPath: repo.repoPath || '',
|
||||
repoUrl: repo.repoUrl || '',
|
||||
defaultBranch: repo.defaultBranch || 'main',
|
||||
description: repo.description || ''
|
||||
}
|
||||
} else {
|
||||
repoForm.value = { repoId: null, serverId: '', repoName: '', repoPath: '', repoUrl: '', defaultBranch: 'main', description: '' }
|
||||
}
|
||||
showRepoModal.value = true
|
||||
}
|
||||
|
||||
async function saveRepo() {
|
||||
if (!repoForm.value.serverId || !repoForm.value.repoPath) {
|
||||
alert('VCS 서버와 저장소 경로는 필수입니다.')
|
||||
return
|
||||
}
|
||||
isSavingRepo.value = true
|
||||
try {
|
||||
if (repoForm.value.repoId) {
|
||||
await $fetch(`/api/repository/${repoForm.value.repoId}`, { method: 'PUT', body: repoForm.value })
|
||||
} else {
|
||||
await $fetch('/api/repository/create', { method: 'POST', body: { ...repoForm.value, projectId: Number(route.params.id) } })
|
||||
}
|
||||
showRepoModal.value = false
|
||||
await loadRepositories()
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || '저장에 실패했습니다.')
|
||||
} finally {
|
||||
isSavingRepo.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRepo(repoId: number) {
|
||||
if (!confirm('이 저장소를 삭제하시겠습니까?')) return
|
||||
try {
|
||||
await $fetch(`/api/repository/${repoId}`, { method: 'DELETE' })
|
||||
await loadRepositories()
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || '삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
function toggleEdit() {
|
||||
if (!isEditing.value) {
|
||||
form.value = {
|
||||
|
||||
@@ -527,6 +527,43 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show" v-if="showMaintenanceModal"></div>
|
||||
|
||||
<!-- TODO 연계 확인 모달 -->
|
||||
<div class="modal fade" :class="{ show: showTodoLinkModal }" :style="{ display: showTodoLinkModal ? 'block' : 'none' }">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-check2-square me-2 text-success"></i>TODO 완료 확인</h5>
|
||||
<button type="button" class="btn-close" @click="closeTodoLinkModal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-muted small">작성한 실적과 유사한 TODO가 발견되었습니다. 완료 처리할 항목을 선택하세요.</p>
|
||||
<div class="list-group">
|
||||
<label v-for="todo in similarTodos" :key="todo.todoId" class="list-group-item list-group-item-action">
|
||||
<div class="d-flex align-items-start">
|
||||
<input type="checkbox" class="form-check-input me-2 mt-1" v-model="todo.selected" />
|
||||
<div class="flex-grow-1">
|
||||
<div class="fw-bold">{{ todo.todoTitle }}</div>
|
||||
<small class="text-muted">
|
||||
{{ todo.projectName || '프로젝트 없음' }}
|
||||
<span v-if="todo.dueDate"> · 마감: {{ todo.dueDate.split('T')[0] }}</span>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeTodoLinkModal">건너뛰기</button>
|
||||
<button type="button" class="btn btn-success" @click="completeTodos" :disabled="!hasSelectedTodos || isLinkingTodos">
|
||||
<span v-if="isLinkingTodos" class="spinner-border spinner-border-sm me-1"></span>
|
||||
<i v-else class="bi bi-check-lg me-1"></i>완료 처리
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show" v-if="showTodoLinkModal"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -599,6 +636,13 @@ const generatedTasks = ref<{ description: string; taskType: string; sourceTaskId
|
||||
|
||||
const selectedMaintenanceCount = computed(() => maintenanceTasks.value.filter(t => t.selected).length)
|
||||
|
||||
// TODO 연계 모달
|
||||
const showTodoLinkModal = ref(false)
|
||||
const similarTodos = ref<any[]>([])
|
||||
const savedReportId = ref<number | null>(null)
|
||||
const isLinkingTodos = ref(false)
|
||||
const hasSelectedTodos = computed(() => similarTodos.value.some(t => t.selected))
|
||||
|
||||
const form = ref({
|
||||
reportYear: new Date().getFullYear(),
|
||||
reportWeek: 1,
|
||||
@@ -958,7 +1002,7 @@ async function handleSubmit() {
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
await $fetch('/api/report/weekly/create', {
|
||||
const res = await $fetch<{ reportId: number }>('/api/report/weekly/create', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
reportYear: form.value.reportYear,
|
||||
@@ -977,8 +1021,17 @@ async function handleSubmit() {
|
||||
remarkDescription: form.value.remarkDescription
|
||||
}
|
||||
})
|
||||
|
||||
toast.success('주간보고가 작성되었습니다.')
|
||||
router.push('/report/weekly')
|
||||
savedReportId.value = res.reportId
|
||||
|
||||
// 완료된 WORK 실적에 대해 유사 TODO 검색
|
||||
const completedWorks = validTasks.filter(t => t.taskType === 'WORK' && t.isCompleted)
|
||||
if (completedWorks.length > 0) {
|
||||
await searchSimilarTodos(completedWorks)
|
||||
} else {
|
||||
router.push('/report/weekly')
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e.data?.message || '저장에 실패했습니다.')
|
||||
} finally {
|
||||
@@ -986,6 +1039,71 @@ async function handleSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
// === TODO 연계 기능 ===
|
||||
async function searchSimilarTodos(completedWorks: TaskItem[]) {
|
||||
try {
|
||||
const allTodos: any[] = []
|
||||
|
||||
for (const work of completedWorks) {
|
||||
const res = await $fetch<{ todos: any[] }>('/api/todo/report/similar', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
taskDescription: work.description,
|
||||
projectId: work.projectId
|
||||
}
|
||||
})
|
||||
if (res.todos && res.todos.length > 0) {
|
||||
for (const todo of res.todos) {
|
||||
if (!allTodos.find(t => t.todoId === todo.todoId)) {
|
||||
allTodos.push({ ...todo, selected: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allTodos.length > 0) {
|
||||
similarTodos.value = allTodos
|
||||
showTodoLinkModal.value = true
|
||||
} else {
|
||||
router.push('/report/weekly')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('TODO 검색 실패:', e)
|
||||
router.push('/report/weekly')
|
||||
}
|
||||
}
|
||||
|
||||
function closeTodoLinkModal() {
|
||||
showTodoLinkModal.value = false
|
||||
router.push('/report/weekly')
|
||||
}
|
||||
|
||||
async function completeTodos() {
|
||||
const selected = similarTodos.value.filter(t => t.selected)
|
||||
if (selected.length === 0) return
|
||||
|
||||
isLinkingTodos.value = true
|
||||
try {
|
||||
for (const todo of selected) {
|
||||
await $fetch('/api/todo/report/link', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
todoId: todo.todoId,
|
||||
weeklyReportId: savedReportId.value,
|
||||
markCompleted: true
|
||||
}
|
||||
})
|
||||
}
|
||||
toast.success(`${selected.length}개의 TODO가 완료 처리되었습니다.`)
|
||||
showTodoLinkModal.value = false
|
||||
router.push('/report/weekly')
|
||||
} catch (e: any) {
|
||||
toast.error('TODO 완료 처리에 실패했습니다.')
|
||||
} finally {
|
||||
isLinkingTodos.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// === textarea 자동 높이 조절 ===
|
||||
function autoResize(e: Event) {
|
||||
const textarea = e.target as HTMLTextAreaElement
|
||||
|
||||
@@ -39,6 +39,8 @@
|
||||
<label class="btn btn-outline-secondary btn-sm" for="statusProgress">진행</label>
|
||||
<input type="radio" class="btn-check" name="status" id="statusCompleted" value="COMPLETED" v-model="filter.status" @change="loadTodos">
|
||||
<label class="btn btn-outline-secondary btn-sm" for="statusCompleted">완료</label>
|
||||
<input type="radio" class="btn-check" name="status" id="statusDiscarded" value="DISCARDED" v-model="filter.status" @change="loadTodos">
|
||||
<label class="btn btn-outline-secondary btn-sm" for="statusDiscarded">폐기</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
@@ -146,7 +148,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button v-if="isEdit" type="button" class="btn btn-outline-danger me-auto" @click="deleteTodo">삭제</button>
|
||||
<div class="me-auto" v-if="isEdit">
|
||||
<button type="button" class="btn btn-success me-1" @click="completeTodo" v-if="form.status !== 'COMPLETED'">
|
||||
<i class="bi bi-check-lg me-1"></i>완료
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-dark me-1" @click="discardTodo" v-if="form.status !== 'DISCARDED'">
|
||||
<i class="bi bi-x-lg me-1"></i>폐기
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger" @click="deleteTodo">삭제</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" @click="showModal = false">취소</button>
|
||||
<button type="button" class="btn btn-primary" @click="saveTodo" :disabled="isSaving">
|
||||
{{ isSaving ? '저장 중...' : '저장' }}
|
||||
@@ -315,11 +325,36 @@ async function deleteTodo() {
|
||||
}
|
||||
}
|
||||
|
||||
async function completeTodo() {
|
||||
if (!editTodoId.value) return
|
||||
try {
|
||||
await $fetch(`/api/todo/${editTodoId.value}/complete`, { method: 'PUT' })
|
||||
showModal.value = false
|
||||
await loadTodos()
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || '완료 처리에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
async function discardTodo() {
|
||||
if (!editTodoId.value) return
|
||||
const reason = prompt('폐기 사유를 입력하세요 (선택):', '')
|
||||
if (reason === null) return // 취소
|
||||
try {
|
||||
await $fetch(`/api/todo/${editTodoId.value}/discard`, { method: 'PUT', body: { reason } })
|
||||
showModal.value = false
|
||||
await loadTodos()
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || '폐기 처리에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusBadge(status: string) {
|
||||
const badges: Record<string, string> = {
|
||||
PENDING: 'badge bg-secondary',
|
||||
IN_PROGRESS: 'badge bg-primary',
|
||||
COMPLETED: 'badge bg-success',
|
||||
DISCARDED: 'badge bg-dark',
|
||||
CANCELLED: 'badge bg-dark'
|
||||
}
|
||||
return badges[status] || 'badge bg-secondary'
|
||||
@@ -330,6 +365,7 @@ function getStatusLabel(status: string) {
|
||||
PENDING: '대기',
|
||||
IN_PROGRESS: '진행',
|
||||
COMPLETED: '완료',
|
||||
DISCARDED: '폐기',
|
||||
CANCELLED: '취소'
|
||||
}
|
||||
return labels[status] || status
|
||||
|
||||
Reference in New Issue
Block a user