기능구현중

This commit is contained in:
2026-01-11 13:47:33 +09:00
parent f5bf084afc
commit d56154d5d2
13 changed files with 948 additions and 46 deletions

View File

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

View File

@@ -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 }} &gt;</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 = {

View File

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

View File

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