작업계획서대로 진행
This commit is contained in:
437
frontend/meeting/[id].vue
Normal file
437
frontend/meeting/[id].vue
Normal file
@@ -0,0 +1,437 @@
|
||||
<template>
|
||||
<div>
|
||||
<AppHeader />
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h4>
|
||||
<i class="bi bi-journal-text me-2"></i>
|
||||
{{ isEditing ? '회의록 수정' : '회의록 상세' }}
|
||||
</h4>
|
||||
<p class="text-muted mb-0">
|
||||
{{ meeting?.meetingTitle }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<NuxtLink to="/meeting" class="btn btn-outline-secondary me-2">
|
||||
<i class="bi bi-arrow-left me-1"></i> 목록
|
||||
</NuxtLink>
|
||||
<button v-if="!isEditing" class="btn btn-primary" @click="isEditing = true">
|
||||
<i class="bi bi-pencil me-1"></i> 수정
|
||||
</button>
|
||||
<button v-if="!isEditing" class="btn btn-outline-danger ms-2" @click="deleteMeeting">
|
||||
<i class="bi bi-trash me-1"></i> 삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="text-center py-5">
|
||||
<div class="spinner-border text-primary"></div>
|
||||
<p class="mt-2 text-muted">로딩 중...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="meeting" class="row">
|
||||
<!-- 왼쪽: 기본 정보 -->
|
||||
<div class="col-md-4">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<strong>기본 정보</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">회의 제목</label>
|
||||
<input
|
||||
v-if="isEditing"
|
||||
type="text"
|
||||
class="form-control"
|
||||
v-model="form.meetingTitle"
|
||||
/>
|
||||
<p v-else class="form-control-plaintext">{{ meeting.meetingTitle }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">회의 유형</label>
|
||||
<div v-if="isEditing" class="btn-group w-100" role="group">
|
||||
<input type="radio" class="btn-check" id="type-project" value="PROJECT" v-model="form.meetingType" />
|
||||
<label class="btn btn-outline-primary" for="type-project">프로젝트</label>
|
||||
<input type="radio" class="btn-check" id="type-internal" value="INTERNAL" v-model="form.meetingType" />
|
||||
<label class="btn btn-outline-info" for="type-internal">내부업무</label>
|
||||
</div>
|
||||
<p v-else class="form-control-plaintext">
|
||||
<span :class="meeting.meetingType === 'PROJECT' ? 'badge bg-primary' : 'badge bg-info'">
|
||||
{{ meeting.meetingType === 'PROJECT' ? '프로젝트 회의' : '내부업무 회의' }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" v-if="form.meetingType === 'PROJECT' || meeting.projectName">
|
||||
<label class="form-label">프로젝트</label>
|
||||
<select v-if="isEditing && form.meetingType === 'PROJECT'" class="form-select" v-model="form.projectId">
|
||||
<option value="">선택</option>
|
||||
<option v-for="p in projects" :key="p.projectId" :value="p.projectId">{{ p.projectName }}</option>
|
||||
</select>
|
||||
<p v-else class="form-control-plaintext">{{ meeting.projectName || '-' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">회의 일자</label>
|
||||
<input v-if="isEditing" type="date" class="form-control" v-model="form.meetingDate" />
|
||||
<p v-else class="form-control-plaintext">{{ formatDate(meeting.meetingDate) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-6">
|
||||
<label class="form-label">시작</label>
|
||||
<input v-if="isEditing" type="time" class="form-control" v-model="form.startTime" />
|
||||
<p v-else class="form-control-plaintext">{{ meeting.startTime?.slice(0,5) || '-' }}</p>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">종료</label>
|
||||
<input v-if="isEditing" type="time" class="form-control" v-model="form.endTime" />
|
||||
<p v-else class="form-control-plaintext">{{ meeting.endTime?.slice(0,5) || '-' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">장소</label>
|
||||
<input v-if="isEditing" type="text" class="form-control" v-model="form.location" />
|
||||
<p v-else class="form-control-plaintext">{{ meeting.location || '-' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-0">
|
||||
<label class="form-label">작성자</label>
|
||||
<p class="form-control-plaintext">{{ meeting.authorName }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 참석자 -->
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>참석자 ({{ attendees.length }}명)</strong>
|
||||
<div v-if="isEditing">
|
||||
<button class="btn btn-sm btn-outline-primary me-1" @click="showEmployeeModal = true">
|
||||
<i class="bi bi-person-plus"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @click="addExternalAttendee">
|
||||
<i class="bi bi-person-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li v-for="(att, idx) in displayAttendees" :key="idx" class="list-group-item d-flex justify-content-between">
|
||||
<div>
|
||||
<i :class="att.isExternal ? 'bi bi-person text-secondary' : 'bi bi-person-fill text-primary'" class="me-1"></i>
|
||||
{{ att.isExternal ? att.externalName : att.employeeName }}
|
||||
<small v-if="att.isExternal && att.externalCompany" class="text-muted">({{ att.externalCompany }})</small>
|
||||
<small v-if="!att.isExternal && att.company" class="text-muted">({{ att.company }})</small>
|
||||
</div>
|
||||
<button v-if="isEditing" class="btn btn-sm btn-link text-danger" @click="removeAttendee(idx)">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="displayAttendees.length === 0" class="list-group-item text-center text-muted">
|
||||
참석자 없음
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 오른쪽: 회의 내용 -->
|
||||
<div class="col-md-8">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<strong>회의 내용</strong>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<textarea
|
||||
v-if="isEditing"
|
||||
class="form-control border-0 h-100"
|
||||
v-model="form.rawContent"
|
||||
style="min-height: 500px; resize: none;"
|
||||
></textarea>
|
||||
<div v-else class="p-3" style="min-height: 500px; 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>
|
||||
<button class="btn btn-primary" @click="updateMeeting" :disabled="isSaving">
|
||||
<span v-if="isSaving">
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>저장 중...
|
||||
</span>
|
||||
<span v-else><i class="bi bi-check-lg me-1"></i>저장</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 직원 선택 모달 -->
|
||||
<div class="modal fade" :class="{ show: showEmployeeModal }" :style="{ display: showEmployeeModal ? 'block' : 'none' }">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">내부 참석자 추가</h5>
|
||||
<button type="button" class="btn-close" @click="showEmployeeModal = false"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="text" class="form-control mb-3" v-model="employeeSearch" placeholder="이름 검색..." />
|
||||
<div style="max-height: 300px; overflow-y: auto;">
|
||||
<div v-for="emp in filteredEmployees" :key="emp.employeeId" class="form-check">
|
||||
<input class="form-check-input" type="checkbox" :id="`emp-${emp.employeeId}`" :value="emp.employeeId" v-model="selectedEmployeeIds" />
|
||||
<label class="form-check-label" :for="`emp-${emp.employeeId}`">
|
||||
{{ emp.employeeName }} <small class="text-muted">({{ emp.company }})</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="showEmployeeModal = false">취소</button>
|
||||
<button type="button" class="btn btn-primary" @click="addSelectedEmployees">추가</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show" v-if="showEmployeeModal"></div>
|
||||
|
||||
<!-- 외부 참석자 모달 -->
|
||||
<div class="modal fade" :class="{ show: showExternalModal }" :style="{ display: showExternalModal ? 'block' : 'none' }">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">외부 참석자 추가</h5>
|
||||
<button type="button" class="btn-close" @click="showExternalModal = false"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">이름 <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" v-model="externalForm.name" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">소속</label>
|
||||
<input type="text" class="form-control" v-model="externalForm.company" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="showExternalModal = false">취소</button>
|
||||
<button type="button" class="btn btn-primary" @click="confirmExternalAttendee">추가</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show" v-if="showExternalModal"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { fetchCurrentUser } = useAuth()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const meetingId = computed(() => Number(route.params.id))
|
||||
|
||||
const meeting = ref<any>(null)
|
||||
const attendees = ref<any[]>([])
|
||||
const agendas = ref<any[]>([])
|
||||
const projects = ref<any[]>([])
|
||||
const employees = ref<any[]>([])
|
||||
|
||||
const isLoading = ref(true)
|
||||
const isEditing = ref(false)
|
||||
const isSaving = ref(false)
|
||||
|
||||
const form = ref({
|
||||
meetingTitle: '',
|
||||
meetingType: 'PROJECT' as 'PROJECT' | 'INTERNAL',
|
||||
projectId: '' as string | number,
|
||||
meetingDate: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
location: '',
|
||||
rawContent: '',
|
||||
attendees: [] as any[]
|
||||
})
|
||||
|
||||
const displayAttendees = computed(() => isEditing.value ? form.value.attendees : attendees.value)
|
||||
|
||||
// 모달
|
||||
const showEmployeeModal = ref(false)
|
||||
const showExternalModal = ref(false)
|
||||
const employeeSearch = ref('')
|
||||
const selectedEmployeeIds = ref<number[]>([])
|
||||
const externalForm = ref({ name: '', company: '' })
|
||||
|
||||
const filteredEmployees = computed(() => {
|
||||
if (!employeeSearch.value) return employees.value
|
||||
return employees.value.filter(e => e.employeeName.toLowerCase().includes(employeeSearch.value.toLowerCase()))
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const user = await fetchCurrentUser()
|
||||
if (!user) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
|
||||
await Promise.all([loadMeeting(), loadProjects(), loadEmployees()])
|
||||
})
|
||||
|
||||
async function loadMeeting() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await $fetch<any>(`/api/meeting/${meetingId.value}/detail`)
|
||||
meeting.value = res.meeting
|
||||
attendees.value = res.attendees || []
|
||||
agendas.value = res.agendas || []
|
||||
|
||||
// 폼 초기화
|
||||
form.value = {
|
||||
meetingTitle: res.meeting.meetingTitle,
|
||||
meetingType: res.meeting.meetingType,
|
||||
projectId: res.meeting.projectId || '',
|
||||
meetingDate: res.meeting.meetingDate?.split('T')[0] || '',
|
||||
startTime: res.meeting.startTime?.slice(0, 5) || '',
|
||||
endTime: res.meeting.endTime?.slice(0, 5) || '',
|
||||
location: res.meeting.location || '',
|
||||
rawContent: res.meeting.rawContent || '',
|
||||
attendees: res.attendees.map((a: any) => ({
|
||||
employeeId: a.employeeId,
|
||||
employeeName: a.employeeName,
|
||||
company: a.company,
|
||||
externalName: a.externalName,
|
||||
externalCompany: a.externalCompany,
|
||||
isExternal: a.isExternal
|
||||
}))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Load meeting error:', e)
|
||||
alert('회의록을 불러올 수 없습니다.')
|
||||
router.push('/meeting')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const res = await $fetch<{ projects: any[] }>('/api/project/list')
|
||||
projects.value = res.projects || []
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEmployees() {
|
||||
try {
|
||||
const res = await $fetch<{ employees: any[] }>('/api/employee/list')
|
||||
employees.value = res.employees || []
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
isEditing.value = false
|
||||
// 폼 초기화
|
||||
form.value.attendees = attendees.value.map(a => ({ ...a }))
|
||||
}
|
||||
|
||||
function addSelectedEmployees() {
|
||||
for (const empId of selectedEmployeeIds.value) {
|
||||
if (form.value.attendees.some(a => a.employeeId === empId)) continue
|
||||
const emp = employees.value.find(e => e.employeeId === empId)
|
||||
if (emp) {
|
||||
form.value.attendees.push({
|
||||
employeeId: emp.employeeId,
|
||||
employeeName: emp.employeeName,
|
||||
company: emp.company,
|
||||
isExternal: false
|
||||
})
|
||||
}
|
||||
}
|
||||
selectedEmployeeIds.value = []
|
||||
showEmployeeModal.value = false
|
||||
}
|
||||
|
||||
function addExternalAttendee() {
|
||||
externalForm.value = { name: '', company: '' }
|
||||
showExternalModal.value = true
|
||||
}
|
||||
|
||||
function confirmExternalAttendee() {
|
||||
if (!externalForm.value.name) {
|
||||
alert('이름을 입력하세요.')
|
||||
return
|
||||
}
|
||||
form.value.attendees.push({
|
||||
externalName: externalForm.value.name,
|
||||
externalCompany: externalForm.value.company,
|
||||
isExternal: true
|
||||
})
|
||||
showExternalModal.value = false
|
||||
}
|
||||
|
||||
function removeAttendee(idx: number) {
|
||||
form.value.attendees.splice(idx, 1)
|
||||
}
|
||||
|
||||
async function updateMeeting() {
|
||||
if (!form.value.meetingTitle) {
|
||||
alert('회의 제목을 입력하세요.')
|
||||
return
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
await $fetch(`/api/meeting/${meetingId.value}/update`, {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
meetingTitle: form.value.meetingTitle,
|
||||
meetingType: form.value.meetingType,
|
||||
projectId: form.value.projectId || undefined,
|
||||
meetingDate: form.value.meetingDate,
|
||||
startTime: form.value.startTime || undefined,
|
||||
endTime: form.value.endTime || undefined,
|
||||
location: form.value.location || undefined,
|
||||
rawContent: form.value.rawContent || undefined,
|
||||
attendees: form.value.attendees.map(a => ({
|
||||
employeeId: a.employeeId,
|
||||
externalName: a.externalName,
|
||||
externalCompany: a.externalCompany
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
isEditing.value = false
|
||||
await loadMeeting()
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || e.message || '수정에 실패했습니다.')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteMeeting() {
|
||||
if (!confirm('정말 삭제하시겠습니까?')) return
|
||||
|
||||
try {
|
||||
await $fetch(`/api/meeting/${meetingId.value}/delete`, { method: 'DELETE' })
|
||||
router.push('/meeting')
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || e.message || '삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
if (!dateStr) return ''
|
||||
const d = new Date(dateStr)
|
||||
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal.show {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
</style>
|
||||
300
frontend/meeting/index.vue
Normal file
300
frontend/meeting/index.vue
Normal file
@@ -0,0 +1,300 @@
|
||||
<template>
|
||||
<div>
|
||||
<AppHeader />
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<!-- 헤더 -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="mb-0">
|
||||
<i class="bi bi-journal-text me-2"></i>회의록
|
||||
</h4>
|
||||
<NuxtLink to="/meeting/write" class="btn btn-primary">
|
||||
<i class="bi bi-plus-lg me-1"></i>회의록 작성
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- 검색 -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-body py-2">
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col-1 text-end"><label class="col-form-label">제목</label></div>
|
||||
<div class="col-2">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control form-control-sm"
|
||||
v-model="filter.keyword"
|
||||
placeholder="제목 또는 내용"
|
||||
@keyup.enter="loadMeetings"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-1 text-end"><label class="col-form-label">유형</label></div>
|
||||
<div class="col-2">
|
||||
<div class="btn-group" role="group">
|
||||
<input type="radio" class="btn-check" name="meetingType" id="typeAll" value="" v-model="filter.meetingType" @change="loadMeetings">
|
||||
<label class="btn btn-outline-secondary btn-sm" for="typeAll">전체</label>
|
||||
<input type="radio" class="btn-check" name="meetingType" id="typeProject" value="PROJECT" v-model="filter.meetingType" @change="loadMeetings">
|
||||
<label class="btn btn-outline-secondary btn-sm" for="typeProject">프로젝트</label>
|
||||
<input type="radio" class="btn-check" name="meetingType" id="typeInternal" value="INTERNAL" v-model="filter.meetingType" @change="loadMeetings">
|
||||
<label class="btn btn-outline-secondary btn-sm" for="typeInternal">내부</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-1 text-end"><label class="col-form-label">프로젝트</label></div>
|
||||
<div class="col-2">
|
||||
<select class="form-select form-select-sm" v-model="filter.projectId">
|
||||
<option value="">전체</option>
|
||||
<option v-for="p in projects" :key="p.projectId" :value="p.projectId">
|
||||
{{ p.projectName }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-2 align-items-center mt-1">
|
||||
<div class="col-1 text-end"><label class="col-form-label">기간</label></div>
|
||||
<div class="col-2">
|
||||
<input type="date" class="form-control form-control-sm" v-model="filter.startDate" />
|
||||
</div>
|
||||
<div class="col-auto px-1">~</div>
|
||||
<div class="col-2">
|
||||
<input type="date" class="form-control form-control-sm" v-model="filter.endDate" />
|
||||
</div>
|
||||
<div class="col-1"></div>
|
||||
<div class="col-2">
|
||||
<button class="btn btn-primary btn-sm me-1" @click="loadMeetings">
|
||||
<i class="bi bi-search me-1"></i>조회
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @click="resetSearch">
|
||||
<i class="bi bi-arrow-counterclockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 회의록 목록 -->
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>회의록 목록 총 <strong>{{ pagination.total }}</strong>건</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-bordered mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 50px" class="text-center">No</th>
|
||||
<th style="width: 110px">회의일</th>
|
||||
<th>제목</th>
|
||||
<th style="width: 80px" class="text-center">유형</th>
|
||||
<th style="width: 150px">프로젝트</th>
|
||||
<th style="width: 80px" class="text-center">참석자</th>
|
||||
<th style="width: 100px">작성자</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="isLoading">
|
||||
<td colspan="7" class="text-center py-4">
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>로딩 중...
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else-if="meetings.length === 0">
|
||||
<td colspan="7" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-inbox display-4"></i>
|
||||
<p class="mt-2 mb-0">조회된 회의록이 없습니다.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else v-for="(meeting, idx) in meetings" :key="meeting.meetingId">
|
||||
<td class="text-center">{{ (pagination.page - 1) * pagination.pageSize + idx + 1 }}</td>
|
||||
<td>
|
||||
{{ formatDate(meeting.meetingDate) }}
|
||||
<br v-if="meeting.startTime" />
|
||||
<small v-if="meeting.startTime" class="text-muted">
|
||||
{{ meeting.startTime?.slice(0, 5) }}
|
||||
<span v-if="meeting.endTime">~{{ meeting.endTime?.slice(0, 5) }}</span>
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
<NuxtLink :to="`/meeting/${meeting.meetingId}`" class="text-decoration-none">
|
||||
{{ meeting.meetingTitle }}
|
||||
</NuxtLink>
|
||||
<span v-if="meeting.aiStatus === 'CONFIRMED'" class="badge bg-success ms-1">AI</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span :class="getTypeBadgeClass(meeting.meetingType)">
|
||||
{{ getTypeText(meeting.meetingType) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ meeting.projectName || '-' }}</td>
|
||||
<td class="text-center">
|
||||
<span class="badge bg-light text-dark">{{ meeting.attendeeCount }}명</span>
|
||||
</td>
|
||||
<td>{{ meeting.authorName }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 페이지네이션 -->
|
||||
<div class="card-footer d-flex justify-content-between align-items-center" v-if="pagination.totalPages > 1">
|
||||
<small class="text-muted">
|
||||
전체 {{ pagination.total }}건 중 {{ (pagination.page - 1) * pagination.pageSize + 1 }} -
|
||||
{{ Math.min(pagination.page * pagination.pageSize, pagination.total) }}건
|
||||
</small>
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
<li class="page-item" :class="{ disabled: pagination.page === 1 }">
|
||||
<a class="page-link" href="#" @click.prevent="goPage(pagination.page - 1)">이전</a>
|
||||
</li>
|
||||
<li
|
||||
class="page-item"
|
||||
v-for="p in visiblePages"
|
||||
:key="p"
|
||||
:class="{ active: p === pagination.page }"
|
||||
>
|
||||
<a class="page-link" href="#" @click.prevent="goPage(p)">{{ p }}</a>
|
||||
</li>
|
||||
<li class="page-item" :class="{ disabled: pagination.page === pagination.totalPages }">
|
||||
<a class="page-link" href="#" @click.prevent="goPage(pagination.page + 1)">다음</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { fetchCurrentUser } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
interface Meeting {
|
||||
meetingId: number
|
||||
meetingTitle: string
|
||||
meetingDate: string
|
||||
startTime: string | null
|
||||
endTime: string | null
|
||||
meetingType: string
|
||||
projectId: number | null
|
||||
projectName: string | null
|
||||
attendeeCount: number
|
||||
authorId: number
|
||||
authorName: string
|
||||
aiStatus: string | null
|
||||
}
|
||||
|
||||
interface Project {
|
||||
projectId: number
|
||||
projectName: string
|
||||
}
|
||||
|
||||
const meetings = ref<Meeting[]>([])
|
||||
const projects = ref<Project[]>([])
|
||||
const isLoading = ref(false)
|
||||
|
||||
const filter = ref({
|
||||
keyword: '',
|
||||
meetingType: '',
|
||||
projectId: '',
|
||||
startDate: '',
|
||||
endDate: ''
|
||||
})
|
||||
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
totalPages: 0
|
||||
})
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const pages: number[] = []
|
||||
const total = pagination.value.totalPages
|
||||
const current = pagination.value.page
|
||||
|
||||
let start = Math.max(1, current - 2)
|
||||
let end = Math.min(total, current + 2)
|
||||
|
||||
if (end - start < 4) {
|
||||
if (start === 1) end = Math.min(total, 5)
|
||||
else start = Math.max(1, total - 4)
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i++) pages.push(i)
|
||||
return pages
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const user = await fetchCurrentUser()
|
||||
if (!user) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
await loadProjects()
|
||||
await loadMeetings()
|
||||
})
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const res = await $fetch<{ projects: Project[] }>('/api/project/list')
|
||||
projects.value = res.projects || []
|
||||
} catch (e) {
|
||||
console.error('Load projects error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMeetings() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await $fetch<any>('/api/meeting/list', {
|
||||
query: {
|
||||
keyword: filter.value.keyword || undefined,
|
||||
meetingType: filter.value.meetingType || undefined,
|
||||
projectId: filter.value.projectId || undefined,
|
||||
startDate: filter.value.startDate || undefined,
|
||||
endDate: filter.value.endDate || undefined,
|
||||
page: pagination.value.page,
|
||||
pageSize: pagination.value.pageSize
|
||||
}
|
||||
})
|
||||
meetings.value = res.meetings || []
|
||||
pagination.value.total = res.pagination?.total || 0
|
||||
pagination.value.totalPages = res.pagination?.totalPages || 0
|
||||
} catch (e) {
|
||||
console.error('Load meetings error:', e)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
filter.value = {
|
||||
keyword: '',
|
||||
meetingType: '',
|
||||
projectId: '',
|
||||
startDate: '',
|
||||
endDate: ''
|
||||
}
|
||||
pagination.value.page = 1
|
||||
loadMeetings()
|
||||
}
|
||||
|
||||
function goPage(page: number) {
|
||||
if (page < 1 || page > pagination.value.totalPages) return
|
||||
pagination.value.page = page
|
||||
loadMeetings()
|
||||
}
|
||||
|
||||
function getTypeBadgeClass(type: string) {
|
||||
return type === 'PROJECT' ? 'badge bg-primary' : 'badge bg-info'
|
||||
}
|
||||
|
||||
function getTypeText(type: string) {
|
||||
return type === 'PROJECT' ? '프로젝트' : '내부'
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | null) {
|
||||
if (!dateStr) return ''
|
||||
const d = new Date(dateStr)
|
||||
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
|
||||
}
|
||||
</script>
|
||||
389
frontend/meeting/write.vue
Normal file
389
frontend/meeting/write.vue
Normal file
@@ -0,0 +1,389 @@
|
||||
<template>
|
||||
<div>
|
||||
<AppHeader />
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h4><i class="bi bi-pencil-square me-2"></i>회의록 작성</h4>
|
||||
<p class="text-muted mb-0">회의 내용을 기록하세요</p>
|
||||
</div>
|
||||
<NuxtLink to="/meeting" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i> 목록으로
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- 왼쪽: 기본 정보 -->
|
||||
<div class="col-md-4">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<strong>기본 정보</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">회의 제목 <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" v-model="form.meetingTitle" placeholder="회의 제목 입력" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">회의 유형 <span class="text-danger">*</span></label>
|
||||
<div class="btn-group w-100" role="group">
|
||||
<input type="radio" class="btn-check" id="type-project" value="PROJECT" v-model="form.meetingType" />
|
||||
<label class="btn btn-outline-primary" for="type-project">프로젝트 회의</label>
|
||||
<input type="radio" class="btn-check" id="type-internal" value="INTERNAL" v-model="form.meetingType" />
|
||||
<label class="btn btn-outline-info" for="type-internal">내부업무 회의</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" v-if="form.meetingType === 'PROJECT'">
|
||||
<label class="form-label">프로젝트 <span class="text-danger">*</span></label>
|
||||
<select class="form-select" v-model="form.projectId">
|
||||
<option value="">프로젝트 선택</option>
|
||||
<option v-for="p in projects" :key="p.projectId" :value="p.projectId">
|
||||
{{ p.projectName }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">회의 일자 <span class="text-danger">*</span></label>
|
||||
<input type="date" class="form-control" v-model="form.meetingDate" />
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-6">
|
||||
<label class="form-label">시작 시간</label>
|
||||
<input type="time" class="form-control" v-model="form.startTime" />
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">종료 시간</label>
|
||||
<input type="time" class="form-control" v-model="form.endTime" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">장소</label>
|
||||
<input type="text" class="form-control" v-model="form.location" placeholder="회의 장소" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 참석자 -->
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>참석자</strong>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-primary me-1" @click="showEmployeeModal = true">
|
||||
<i class="bi bi-person-plus"></i> 내부
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @click="addExternalAttendee">
|
||||
<i class="bi bi-person-plus"></i> 외부
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<ul class="list-group list-group-flush">
|
||||
<li
|
||||
v-for="(att, idx) in form.attendees"
|
||||
:key="idx"
|
||||
class="list-group-item d-flex justify-content-between align-items-center"
|
||||
>
|
||||
<div>
|
||||
<span v-if="att.employeeId">
|
||||
<i class="bi bi-person-fill text-primary me-1"></i>
|
||||
{{ att.employeeName }}
|
||||
<small class="text-muted">({{ att.company }})</small>
|
||||
</span>
|
||||
<span v-else>
|
||||
<i class="bi bi-person text-secondary me-1"></i>
|
||||
{{ att.externalName }}
|
||||
<small class="text-muted" v-if="att.externalCompany">({{ att.externalCompany }})</small>
|
||||
</span>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-link text-danger" @click="removeAttendee(idx)">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="form.attendees.length === 0" class="list-group-item text-center text-muted py-4">
|
||||
참석자를 추가하세요
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 오른쪽: 회의 내용 -->
|
||||
<div class="col-md-8">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<strong>회의 내용</strong>
|
||||
<small class="text-muted ms-2">(자유롭게 작성하면 AI가 정리해줍니다)</small>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<!-- TODO: Tiptap 에디터로 교체 -->
|
||||
<textarea
|
||||
class="form-control border-0 h-100"
|
||||
v-model="form.rawContent"
|
||||
placeholder="회의 내용을 자유롭게 작성하세요...
|
||||
|
||||
예시:
|
||||
- 김철수: 이번 주 진행 상황 공유
|
||||
- 프론트엔드 개발 80% 완료
|
||||
- 백엔드 API 연동 필요
|
||||
- 다음 주 목표: 테스트 환경 구축
|
||||
- 미결정: 배포 일정 (추후 논의)"
|
||||
style="min-height: 500px; resize: none;"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<button class="btn btn-secondary me-2" @click="router.push('/meeting')">취소</button>
|
||||
<button class="btn btn-primary" @click="saveMeeting" :disabled="isSaving">
|
||||
<span v-if="isSaving">
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>저장 중...
|
||||
</span>
|
||||
<span v-else>
|
||||
<i class="bi bi-check-lg me-1"></i>저장
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 직원 선택 모달 -->
|
||||
<div class="modal fade" :class="{ show: showEmployeeModal }" :style="{ display: showEmployeeModal ? 'block' : 'none' }">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">내부 참석자 추가</h5>
|
||||
<button type="button" class="btn-close" @click="showEmployeeModal = false"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control mb-3"
|
||||
v-model="employeeSearch"
|
||||
placeholder="이름 검색..."
|
||||
/>
|
||||
<div style="max-height: 300px; overflow-y: auto;">
|
||||
<div
|
||||
v-for="emp in filteredEmployees"
|
||||
:key="emp.employeeId"
|
||||
class="form-check"
|
||||
>
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
:id="`emp-${emp.employeeId}`"
|
||||
:value="emp.employeeId"
|
||||
v-model="selectedEmployeeIds"
|
||||
/>
|
||||
<label class="form-check-label" :for="`emp-${emp.employeeId}`">
|
||||
{{ emp.employeeName }}
|
||||
<small class="text-muted">({{ emp.company }})</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="showEmployeeModal = false">취소</button>
|
||||
<button type="button" class="btn btn-primary" @click="addSelectedEmployees">추가</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show" v-if="showEmployeeModal"></div>
|
||||
|
||||
<!-- 외부 참석자 입력 모달 -->
|
||||
<div class="modal fade" :class="{ show: showExternalModal }" :style="{ display: showExternalModal ? 'block' : 'none' }">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">외부 참석자 추가</h5>
|
||||
<button type="button" class="btn-close" @click="showExternalModal = false"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">이름 <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" v-model="externalForm.name" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">소속</label>
|
||||
<input type="text" class="form-control" v-model="externalForm.company" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="showExternalModal = false">취소</button>
|
||||
<button type="button" class="btn btn-primary" @click="confirmExternalAttendee">추가</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show" v-if="showExternalModal"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { fetchCurrentUser } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
interface Attendee {
|
||||
employeeId?: number
|
||||
employeeName?: string
|
||||
company?: string
|
||||
externalName?: string
|
||||
externalCompany?: string
|
||||
}
|
||||
|
||||
const projects = ref<any[]>([])
|
||||
const employees = ref<any[]>([])
|
||||
const isSaving = ref(false)
|
||||
|
||||
const form = ref({
|
||||
meetingTitle: '',
|
||||
meetingType: 'PROJECT' as 'PROJECT' | 'INTERNAL',
|
||||
projectId: '' as string | number,
|
||||
meetingDate: new Date().toISOString().split('T')[0],
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
location: '',
|
||||
rawContent: '',
|
||||
attendees: [] as Attendee[]
|
||||
})
|
||||
|
||||
// 직원 선택 모달
|
||||
const showEmployeeModal = ref(false)
|
||||
const employeeSearch = ref('')
|
||||
const selectedEmployeeIds = ref<number[]>([])
|
||||
|
||||
const filteredEmployees = computed(() => {
|
||||
if (!employeeSearch.value) return employees.value
|
||||
const keyword = employeeSearch.value.toLowerCase()
|
||||
return employees.value.filter(e => e.employeeName.toLowerCase().includes(keyword))
|
||||
})
|
||||
|
||||
// 외부 참석자 모달
|
||||
const showExternalModal = ref(false)
|
||||
const externalForm = ref({ name: '', company: '' })
|
||||
|
||||
onMounted(async () => {
|
||||
const user = await fetchCurrentUser()
|
||||
if (!user) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
|
||||
await Promise.all([loadProjects(), loadEmployees()])
|
||||
})
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const res = await $fetch<{ projects: any[] }>('/api/project/list')
|
||||
projects.value = res.projects || []
|
||||
} catch (e) {
|
||||
console.error('Load projects error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEmployees() {
|
||||
try {
|
||||
const res = await $fetch<{ employees: any[] }>('/api/employee/list')
|
||||
employees.value = res.employees || []
|
||||
} catch (e) {
|
||||
console.error('Load employees error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function addSelectedEmployees() {
|
||||
for (const empId of selectedEmployeeIds.value) {
|
||||
// 이미 추가된 경우 스킵
|
||||
if (form.value.attendees.some(a => a.employeeId === empId)) continue
|
||||
|
||||
const emp = employees.value.find(e => e.employeeId === empId)
|
||||
if (emp) {
|
||||
form.value.attendees.push({
|
||||
employeeId: emp.employeeId,
|
||||
employeeName: emp.employeeName,
|
||||
company: emp.company
|
||||
})
|
||||
}
|
||||
}
|
||||
selectedEmployeeIds.value = []
|
||||
showEmployeeModal.value = false
|
||||
}
|
||||
|
||||
function addExternalAttendee() {
|
||||
externalForm.value = { name: '', company: '' }
|
||||
showExternalModal.value = true
|
||||
}
|
||||
|
||||
function confirmExternalAttendee() {
|
||||
if (!externalForm.value.name) {
|
||||
alert('이름을 입력하세요.')
|
||||
return
|
||||
}
|
||||
form.value.attendees.push({
|
||||
externalName: externalForm.value.name,
|
||||
externalCompany: externalForm.value.company
|
||||
})
|
||||
showExternalModal.value = false
|
||||
}
|
||||
|
||||
function removeAttendee(idx: number) {
|
||||
form.value.attendees.splice(idx, 1)
|
||||
}
|
||||
|
||||
async function saveMeeting() {
|
||||
// 유효성 검사
|
||||
if (!form.value.meetingTitle) {
|
||||
alert('회의 제목을 입력하세요.')
|
||||
return
|
||||
}
|
||||
if (!form.value.meetingDate) {
|
||||
alert('회의 일자를 선택하세요.')
|
||||
return
|
||||
}
|
||||
if (form.value.meetingType === 'PROJECT' && !form.value.projectId) {
|
||||
alert('프로젝트를 선택하세요.')
|
||||
return
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
const res = await $fetch<{ success: boolean; meetingId: number }>('/api/meeting/create', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
meetingTitle: form.value.meetingTitle,
|
||||
meetingType: form.value.meetingType,
|
||||
projectId: form.value.projectId || undefined,
|
||||
meetingDate: form.value.meetingDate,
|
||||
startTime: form.value.startTime || undefined,
|
||||
endTime: form.value.endTime || undefined,
|
||||
location: form.value.location || undefined,
|
||||
rawContent: form.value.rawContent || undefined,
|
||||
attendees: form.value.attendees.map(a => ({
|
||||
employeeId: a.employeeId,
|
||||
externalName: a.externalName,
|
||||
externalCompany: a.externalCompany
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
if (res.success) {
|
||||
router.push(`/meeting/${res.meetingId}`)
|
||||
}
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || e.message || '저장에 실패했습니다.')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal.show {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user