Files
weeklyreport/frontend/feedback/index.vue
2026-01-05 03:09:11 +09:00

406 lines
12 KiB
Vue

<template>
<div>
<AppHeader />
<div class="container-fluid py-4">
<!-- 헤더 -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="mb-1"><i class="bi bi-lightbulb me-2"></i>개선의견</h4>
<p class="text-muted mb-0">파일럿 프로젝트 피드백을 남겨주세요</p>
</div>
<button class="btn btn-primary" @click="openCreateModal">
<i class="bi bi-plus-lg me-1"></i>의견 작성
</button>
</div>
<!-- 필터 -->
<div class="mb-4">
<div class="btn-group">
<button
class="btn"
:class="filter === '' ? 'btn-primary' : 'btn-outline-primary'"
@click="setFilter('')"
>전체</button>
<button
v-for="cat in categories"
:key="cat.value"
class="btn"
:class="filter === cat.value ? 'btn-primary' : 'btn-outline-primary'"
@click="setFilter(cat.value)"
>
{{ cat.icon }} {{ cat.label }}
</button>
</div>
</div>
<!-- 카드 목록 -->
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 row-cols-xl-4 g-3" v-if="feedbacks.length > 0">
<div class="col" v-for="fb in feedbacks" :key="fb.feedbackId">
<div class="card h-100" :class="{ 'border-success': fb.isResolved }">
<div class="card-body">
<!-- 카테고리 뱃지 -->
<div class="d-flex justify-content-between align-items-start mb-2">
<span :class="getCategoryBadge(fb.category)">
{{ getCategoryIcon(fb.category) }} {{ getCategoryLabel(fb.category) }}
</span>
<div class="dropdown" v-if="fb.isOwner">
<button class="btn btn-sm btn-link text-muted p-0" data-bs-toggle="dropdown">
<i class="bi bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="#" @click.prevent="openEditModal(fb)">
<i class="bi bi-pencil me-2"></i>수정
</a></li>
<li><a class="dropdown-item text-danger" href="#" @click.prevent="confirmDelete(fb)">
<i class="bi bi-trash me-2"></i>삭제
</a></li>
</ul>
</div>
</div>
<!-- 내용 -->
<p class="card-text">{{ fb.content }}</p>
</div>
<div class="card-footer bg-transparent">
<div class="d-flex justify-content-between align-items-center">
<div class="small text-muted">
{{ fb.authorName }} · {{ formatDate(fb.createdAt) }}
</div>
<button
class="btn btn-sm"
:class="fb.isLiked ? 'btn-primary' : 'btn-outline-secondary'"
@click="toggleLike(fb)"
>
<i class="bi" :class="fb.isLiked ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up'"></i>
{{ fb.likeCount }}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 상태 -->
<div class="text-center py-5 text-muted" v-else-if="!isLoading">
<i class="bi bi-chat-square-text display-1"></i>
<p class="mt-3 mb-0">아직 등록된 의견이 없습니다.</p>
<button class="btn btn-primary mt-3" @click="openCreateModal">
번째 의견 남기기
</button>
</div>
<!-- 로딩 -->
<div class="text-center py-5" v-if="isLoading">
<span class="spinner-border"></span>
</div>
<!-- 페이지네이션 -->
<nav class="mt-4" v-if="pagination.totalPages > 1">
<ul class="pagination justify-content-center">
<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 class="modal fade" :class="{ show: showModal }" :style="{ display: showModal ? 'block' : 'none' }">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-lightbulb me-2"></i>{{ isEdit ? '의견 수정' : '의견 작성' }}
</h5>
<button type="button" class="btn-close" @click="closeModal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">카테고리 <span class="text-danger">*</span></label>
<div class="btn-group w-100">
<button
v-for="cat in categories"
:key="cat.value"
type="button"
class="btn"
:class="form.category === cat.value ? 'btn-primary' : 'btn-outline-primary'"
@click="form.category = cat.value"
>
{{ cat.icon }} {{ cat.label }}
</button>
</div>
</div>
<div class="mb-3">
<label class="form-label">내용 <span class="text-danger">*</span></label>
<textarea
class="form-control"
rows="4"
v-model="form.content"
placeholder="개선 의견을 작성해주세요..."
maxlength="500"
></textarea>
<div class="form-text text-end">{{ form.content.length }}/500</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">취소</button>
<button type="button" class="btn btn-primary" @click="submitFeedback" :disabled="isSaving">
<span v-if="isSaving" class="spinner-border spinner-border-sm me-1"></span>
{{ isEdit ? '수정' : '등록' }}
</button>
</div>
</div>
</div>
</div>
<div class="modal-backdrop fade show" v-if="showModal" @click="closeModal"></div>
<!-- 삭제 확인 모달 -->
<div class="modal fade" :class="{ show: showDeleteModal }" :style="{ display: showDeleteModal ? 'block' : 'none' }">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title text-danger">삭제 확인</h5>
<button type="button" class="btn-close" @click="showDeleteModal = false"></button>
</div>
<div class="modal-body">
의견을 삭제하시겠습니까?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="showDeleteModal = false">취소</button>
<button type="button" class="btn btn-danger" @click="deleteFeedback">삭제</button>
</div>
</div>
</div>
</div>
<div class="modal-backdrop fade show" v-if="showDeleteModal" @click="showDeleteModal = false"></div>
</div>
</template>
<script setup lang="ts">
const { fetchCurrentUser } = useAuth()
const router = useRouter()
const feedbacks = ref<any[]>([])
const isLoading = ref(true)
const isSaving = ref(false)
const filter = ref('')
const showModal = ref(false)
const showDeleteModal = ref(false)
const isEdit = ref(false)
const editTarget = ref<any>(null)
const deleteTarget = ref<any>(null)
const pagination = ref({
page: 1,
limit: 12,
total: 0,
totalPages: 0
})
const form = ref({
category: 'FEATURE',
content: ''
})
const categories = [
{ value: 'FEATURE', label: '기능요청', icon: '🔵' },
{ value: 'UI', label: 'UI개선', icon: '🟢' },
{ value: 'BUG', label: '버그신고', icon: '🔴' },
{ value: 'ETC', label: '기타', icon: '🟡' }
]
const visiblePages = computed(() => {
const total = pagination.value.totalPages
const current = pagination.value.page
const pages: number[] = []
let start = Math.max(1, current - 2)
let end = Math.min(total, start + 4)
start = Math.max(1, end - 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 loadFeedbacks()
})
async function loadFeedbacks() {
isLoading.value = true
try {
const res = await $fetch<any>('/api/feedback/list', {
query: {
page: pagination.value.page,
limit: pagination.value.limit,
category: filter.value
}
})
feedbacks.value = res.feedbacks
pagination.value = res.pagination
} catch (e) {
console.error(e)
} finally {
isLoading.value = false
}
}
function setFilter(cat: string) {
filter.value = cat
pagination.value.page = 1
loadFeedbacks()
}
function goPage(p: number) {
if (p < 1 || p > pagination.value.totalPages) return
pagination.value.page = p
loadFeedbacks()
}
function openCreateModal() {
isEdit.value = false
editTarget.value = null
form.value = { category: 'FEATURE', content: '' }
showModal.value = true
}
function openEditModal(fb: any) {
isEdit.value = true
editTarget.value = fb
form.value = { category: fb.category, content: fb.content }
showModal.value = true
}
function closeModal() {
showModal.value = false
editTarget.value = null
}
async function submitFeedback() {
if (!form.value.category || !form.value.content.trim()) {
alert('카테고리와 내용을 입력해주세요.')
return
}
isSaving.value = true
try {
if (isEdit.value && editTarget.value) {
await $fetch(`/api/feedback/${editTarget.value.feedbackId}/update`, {
method: 'PUT',
body: form.value
})
} else {
await $fetch('/api/feedback/create', {
method: 'POST',
body: form.value
})
}
closeModal()
await loadFeedbacks()
} catch (e: any) {
alert(e.data?.message || '저장에 실패했습니다.')
} finally {
isSaving.value = false
}
}
function confirmDelete(fb: any) {
deleteTarget.value = fb
showDeleteModal.value = true
}
async function deleteFeedback() {
if (!deleteTarget.value) return
try {
await $fetch(`/api/feedback/${deleteTarget.value.feedbackId}/delete`, {
method: 'DELETE'
})
showDeleteModal.value = false
deleteTarget.value = null
await loadFeedbacks()
} catch (e: any) {
alert(e.data?.message || '삭제에 실패했습니다.')
}
}
async function toggleLike(fb: any) {
try {
const res = await $fetch<any>(`/api/feedback/${fb.feedbackId}/like`, {
method: 'POST'
})
fb.isLiked = res.isLiked
fb.likeCount = res.likeCount
} catch (e) {
console.error(e)
}
}
function getCategoryBadge(cat: string) {
const badges: Record<string, string> = {
'FEATURE': 'badge bg-primary',
'UI': 'badge bg-success',
'BUG': 'badge bg-danger',
'ETC': 'badge bg-warning text-dark'
}
return badges[cat] || 'badge bg-secondary'
}
function getCategoryIcon(cat: string) {
const icons: Record<string, string> = {
'FEATURE': '🔵',
'UI': '🟢',
'BUG': '🔴',
'ETC': '🟡'
}
return icons[cat] || ''
}
function getCategoryLabel(cat: string) {
const labels: Record<string, string> = {
'FEATURE': '기능요청',
'UI': 'UI개선',
'BUG': '버그신고',
'ETC': '기타'
}
return labels[cat] || cat
}
function formatDate(dateStr: string) {
if (!dateStr) return ''
const d = new Date(dateStr)
return `${d.getMonth() + 1}/${d.getDate()}`
}
</script>
<style scoped>
.modal.show {
background: rgba(0, 0, 0, 0.5);
}
.card {
transition: transform 0.2s;
}
.card:hover {
transform: translateY(-2px);
}
</style>