ㅋㅓ밋
This commit is contained in:
15
.run/dev.run.xml
Normal file
15
.run/dev.run.xml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="dev" type="js.build_tools.npm" nameIsGenerated="true">
|
||||||
|
<package-json value="$PROJECT_DIR$/package.json" />
|
||||||
|
<command value="run" />
|
||||||
|
<scripts>
|
||||||
|
<script value="dev" />
|
||||||
|
</scripts>
|
||||||
|
<node-interpreter value="project" />
|
||||||
|
<envs />
|
||||||
|
<EXTENSION ID="com.intellij.javascript.debugger.execution.StartBrowserRunConfigurationExtension">
|
||||||
|
<browser with-js-debugger="true" />
|
||||||
|
</EXTENSION>
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
39
backend/api/feedback/[id]/delete.delete.ts
Normal file
39
backend/api/feedback/[id]/delete.delete.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { query, execute } from '../../../utils/db'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개선의견 삭제
|
||||||
|
* DELETE /api/feedback/[id]/delete
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const userId = getCookie(event, 'user_id')
|
||||||
|
if (!userId) {
|
||||||
|
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const feedbackId = getRouterParam(event, 'id')
|
||||||
|
if (!feedbackId) {
|
||||||
|
throw createError({ statusCode: 400, message: '피드백 ID가 필요합니다.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 본인 확인
|
||||||
|
const feedback = await query<any>(`
|
||||||
|
SELECT author_id FROM wr_feedback WHERE feedback_id = $1
|
||||||
|
`, [feedbackId])
|
||||||
|
|
||||||
|
if (!feedback[0]) {
|
||||||
|
throw createError({ statusCode: 404, message: '의견을 찾을 수 없습니다.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (feedback[0].author_id !== parseInt(userId)) {
|
||||||
|
throw createError({ statusCode: 403, message: '본인의 의견만 삭제할 수 있습니다.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 공감 먼저 삭제 (CASCADE로 자동 삭제되지만 명시적으로)
|
||||||
|
await execute(`DELETE FROM wr_feedback_like WHERE feedback_id = $1`, [feedbackId])
|
||||||
|
await execute(`DELETE FROM wr_feedback WHERE feedback_id = $1`, [feedbackId])
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: '삭제되었습니다.'
|
||||||
|
}
|
||||||
|
})
|
||||||
66
backend/api/feedback/[id]/like.post.ts
Normal file
66
backend/api/feedback/[id]/like.post.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { query, execute, queryOne } from '../../../utils/db'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개선의견 공감 토글
|
||||||
|
* POST /api/feedback/[id]/like
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const userId = getCookie(event, 'user_id')
|
||||||
|
if (!userId) {
|
||||||
|
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const feedbackId = getRouterParam(event, 'id')
|
||||||
|
if (!feedbackId) {
|
||||||
|
throw createError({ statusCode: 400, message: '피드백 ID가 필요합니다.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 피드백 존재 확인
|
||||||
|
const feedback = await query<any>(`
|
||||||
|
SELECT feedback_id FROM wr_feedback WHERE feedback_id = $1
|
||||||
|
`, [feedbackId])
|
||||||
|
|
||||||
|
if (!feedback[0]) {
|
||||||
|
throw createError({ statusCode: 404, message: '의견을 찾을 수 없습니다.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 공감했는지 확인
|
||||||
|
const existing = await query<any>(`
|
||||||
|
SELECT 1 FROM wr_feedback_like WHERE feedback_id = $1 AND employee_id = $2
|
||||||
|
`, [feedbackId, userId])
|
||||||
|
|
||||||
|
let isLiked: boolean
|
||||||
|
let likeCount: number
|
||||||
|
|
||||||
|
if (existing[0]) {
|
||||||
|
// 공감 취소
|
||||||
|
await execute(`
|
||||||
|
DELETE FROM wr_feedback_like WHERE feedback_id = $1 AND employee_id = $2
|
||||||
|
`, [feedbackId, userId])
|
||||||
|
await execute(`
|
||||||
|
UPDATE wr_feedback SET like_count = like_count - 1 WHERE feedback_id = $1
|
||||||
|
`, [feedbackId])
|
||||||
|
isLiked = false
|
||||||
|
} else {
|
||||||
|
// 공감 추가
|
||||||
|
await execute(`
|
||||||
|
INSERT INTO wr_feedback_like (feedback_id, employee_id) VALUES ($1, $2)
|
||||||
|
`, [feedbackId, userId])
|
||||||
|
await execute(`
|
||||||
|
UPDATE wr_feedback SET like_count = like_count + 1 WHERE feedback_id = $1
|
||||||
|
`, [feedbackId])
|
||||||
|
isLiked = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최신 카운트 조회
|
||||||
|
const updated = await queryOne<any>(`
|
||||||
|
SELECT like_count FROM wr_feedback WHERE feedback_id = $1
|
||||||
|
`, [feedbackId])
|
||||||
|
likeCount = updated.like_count
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
isLiked,
|
||||||
|
likeCount
|
||||||
|
}
|
||||||
|
})
|
||||||
57
backend/api/feedback/[id]/update.put.ts
Normal file
57
backend/api/feedback/[id]/update.put.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { query, execute } from '../../../utils/db'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개선의견 수정
|
||||||
|
* PUT /api/feedback/[id]/update
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const userId = getCookie(event, 'user_id')
|
||||||
|
if (!userId) {
|
||||||
|
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const feedbackId = getRouterParam(event, 'id')
|
||||||
|
if (!feedbackId) {
|
||||||
|
throw createError({ statusCode: 400, message: '피드백 ID가 필요합니다.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 본인 확인
|
||||||
|
const feedback = await query<any>(`
|
||||||
|
SELECT author_id FROM wr_feedback WHERE feedback_id = $1
|
||||||
|
`, [feedbackId])
|
||||||
|
|
||||||
|
if (!feedback[0]) {
|
||||||
|
throw createError({ statusCode: 404, message: '의견을 찾을 수 없습니다.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (feedback[0].author_id !== parseInt(userId)) {
|
||||||
|
throw createError({ statusCode: 403, message: '본인의 의견만 수정할 수 있습니다.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readBody<{
|
||||||
|
category?: string
|
||||||
|
content?: string
|
||||||
|
}>(event)
|
||||||
|
|
||||||
|
if (!body.content?.trim()) {
|
||||||
|
throw createError({ statusCode: 400, message: '내용을 입력해주세요.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const validCategories = ['FEATURE', 'UI', 'BUG', 'ETC']
|
||||||
|
if (body.category && !validCategories.includes(body.category)) {
|
||||||
|
throw createError({ statusCode: 400, message: '올바른 카테고리를 선택해주세요.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
await execute(`
|
||||||
|
UPDATE wr_feedback
|
||||||
|
SET category = COALESCE($1, category),
|
||||||
|
content = $2,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE feedback_id = $3
|
||||||
|
`, [body.category, body.content.trim(), feedbackId])
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: '수정되었습니다.'
|
||||||
|
}
|
||||||
|
})
|
||||||
38
backend/api/feedback/create.post.ts
Normal file
38
backend/api/feedback/create.post.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { query, queryOne } from '../../utils/db'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개선의견 작성
|
||||||
|
* POST /api/feedback/create
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const userId = getCookie(event, 'user_id')
|
||||||
|
if (!userId) {
|
||||||
|
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readBody<{
|
||||||
|
category: string
|
||||||
|
content: string
|
||||||
|
}>(event)
|
||||||
|
|
||||||
|
if (!body.category || !body.content?.trim()) {
|
||||||
|
throw createError({ statusCode: 400, message: '카테고리와 내용을 입력해주세요.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const validCategories = ['FEATURE', 'UI', 'BUG', 'ETC']
|
||||||
|
if (!validCategories.includes(body.category)) {
|
||||||
|
throw createError({ statusCode: 400, message: '올바른 카테고리를 선택해주세요.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await queryOne<any>(`
|
||||||
|
INSERT INTO wr_feedback (author_id, category, content)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING feedback_id
|
||||||
|
`, [userId, body.category, body.content.trim()])
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
feedbackId: result.feedback_id,
|
||||||
|
message: '의견이 등록되었습니다.'
|
||||||
|
}
|
||||||
|
})
|
||||||
103
backend/api/feedback/list.get.ts
Normal file
103
backend/api/feedback/list.get.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { query } from '../../utils/db'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개선의견 목록 조회
|
||||||
|
* GET /api/feedback/list
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const userId = getCookie(event, 'user_id')
|
||||||
|
if (!userId) {
|
||||||
|
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = getQuery(event)
|
||||||
|
const page = parseInt(q.page as string) || 1
|
||||||
|
const limit = parseInt(q.limit as string) || 12
|
||||||
|
const category = q.category as string || ''
|
||||||
|
const offset = (page - 1) * limit
|
||||||
|
|
||||||
|
// 조건 구성
|
||||||
|
let whereClause = ''
|
||||||
|
const countParams: any[] = []
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
whereClause = 'WHERE f.category = $1'
|
||||||
|
countParams.push(category)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전체 개수
|
||||||
|
const countResult = await query<any>(`
|
||||||
|
SELECT COUNT(*) as total FROM wr_feedback f ${whereClause}
|
||||||
|
`, countParams)
|
||||||
|
const total = parseInt(countResult[0].total)
|
||||||
|
|
||||||
|
// 목록 조회
|
||||||
|
let feedbacks: any[]
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
feedbacks = await query<any>(`
|
||||||
|
SELECT
|
||||||
|
f.feedback_id,
|
||||||
|
f.author_id,
|
||||||
|
e.employee_name as author_name,
|
||||||
|
f.category,
|
||||||
|
f.content,
|
||||||
|
f.like_count,
|
||||||
|
f.is_resolved,
|
||||||
|
f.created_at,
|
||||||
|
f.updated_at,
|
||||||
|
EXISTS(
|
||||||
|
SELECT 1 FROM wr_feedback_like fl
|
||||||
|
WHERE fl.feedback_id = f.feedback_id AND fl.employee_id = $1
|
||||||
|
) as is_liked
|
||||||
|
FROM wr_feedback f
|
||||||
|
JOIN wr_employee_info e ON f.author_id = e.employee_id
|
||||||
|
WHERE f.category = $2
|
||||||
|
ORDER BY f.created_at DESC
|
||||||
|
LIMIT $3 OFFSET $4
|
||||||
|
`, [userId, category, limit, offset])
|
||||||
|
} else {
|
||||||
|
feedbacks = await query<any>(`
|
||||||
|
SELECT
|
||||||
|
f.feedback_id,
|
||||||
|
f.author_id,
|
||||||
|
e.employee_name as author_name,
|
||||||
|
f.category,
|
||||||
|
f.content,
|
||||||
|
f.like_count,
|
||||||
|
f.is_resolved,
|
||||||
|
f.created_at,
|
||||||
|
f.updated_at,
|
||||||
|
EXISTS(
|
||||||
|
SELECT 1 FROM wr_feedback_like fl
|
||||||
|
WHERE fl.feedback_id = f.feedback_id AND fl.employee_id = $1
|
||||||
|
) as is_liked
|
||||||
|
FROM wr_feedback f
|
||||||
|
JOIN wr_employee_info e ON f.author_id = e.employee_id
|
||||||
|
ORDER BY f.created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
|
`, [userId, limit, offset])
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
feedbacks: feedbacks.map((f: any) => ({
|
||||||
|
feedbackId: f.feedback_id,
|
||||||
|
authorId: f.author_id,
|
||||||
|
authorName: f.author_name,
|
||||||
|
category: f.category,
|
||||||
|
content: f.content,
|
||||||
|
likeCount: f.like_count,
|
||||||
|
isResolved: f.is_resolved,
|
||||||
|
createdAt: f.created_at,
|
||||||
|
updatedAt: f.updated_at,
|
||||||
|
isLiked: f.is_liked,
|
||||||
|
isOwner: f.author_id === parseInt(userId)
|
||||||
|
})),
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -37,6 +37,11 @@
|
|||||||
<i class="bi bi-people me-1"></i> 직원관리
|
<i class="bi bi-people me-1"></i> 직원관리
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<NuxtLink class="nav-link" to="/feedback" active-class="active">
|
||||||
|
<i class="bi bi-lightbulb me-1"></i> 개선의견
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
<!-- 관리자 메뉴 (coziny@gmail.com 전용) -->
|
<!-- 관리자 메뉴 (coziny@gmail.com 전용) -->
|
||||||
<li class="nav-item dropdown" v-if="isAdmin">
|
<li class="nav-item dropdown" v-if="isAdmin">
|
||||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
|
|||||||
405
frontend/feedback/index.vue
Normal file
405
frontend/feedback/index.vue
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
<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>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
"description": "주간업무보고 시스템",
|
"description": "주간업무보고 시스템",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nuxt dev",
|
"dev": "nuxt dev --port 2026",
|
||||||
"build": "nuxt build",
|
"build": "nuxt build",
|
||||||
"generate": "nuxt generate",
|
"generate": "nuxt generate",
|
||||||
"preview": "nuxt preview",
|
"preview": "nuxt preview",
|
||||||
|
|||||||
Reference in New Issue
Block a user