예약 관리 시스템 - Spring + Vue
예약 관리 시스템 완성 - 프론트엔드(Vue 3 + Vuetify)
그랜파 개발자
2025. 5. 29. 10:47
프로젝트
npm init vue@latest
Project name (target directory):
│ reservation
cd reservation
npm install
npm install vue-router@4 vuetify@3 axios
plugins - axios.js
// src/plugins/axios.js
import axios from 'axios'
import { useAuthStore } from '@/stores/authStore'
axios.interceptors.request.use((config) => {
const auth = useAuthStore()
if (auth.token) {
config.headers.Authorization = `Bearer ${auth.token}`
}
return config
}, (error) => {
return Promise.reject(error)
})
export default axios
plugins - vuetify.js
// src/plugins/vuetify.js
import 'vuetify/styles'
import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
import { aliases, mdi } from 'vuetify/iconsets/mdi'
import '@mdi/font/css/materialdesignicons.css'
export default createVuetify({
components,
directives,
icons: {
defaultSet: 'mdi',
aliases,
sets: { mdi },
},
})
router- index.js
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router';
import Home from '@/views/HomeView.vue' //./HomeView.vue';
import SignUp from '@/views/SignUp.vue';
import Login from '@/views/Login.vue';
import Reservation from '@/views/Reservation.vue';
import AdminReservations from '@/views/AdminReservations.vue';
import UserReservations from '@/views/UserReservations.vue';
const routes = [
{ path: '/', name: 'Home', component: Home },
{ path: '/signup', name: 'SignUp', component: SignUp },
{ path: '/login', name: 'Login', component: Login },
{ path: '/reservation', name: 'Reservation', component: Reservation },
{ path: '/admin', name: 'AdminReservations', component: AdminReservations },
{ path: '/user', name: 'UserReservations', component: UserReservations },
];
const router = createRouter({
history: createWebHistory(),
routes,
});
export default router;
main.js
// src/main.js
import { createApp } from 'vue';
import { createPinia } from 'pinia'
import App from './App.vue';
import router from './router';
import vuetify from './plugins/vuetify'
const app = createApp(App)
const pinia = createPinia()
app.use(vuetify)
app.use(pinia)
app.use(router)
app.mount('#app')
stores - auth.js
// src/stores/auth.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import axios from 'axios'
//const API_URL = 'http://localhost:8080/api/auth'
const API_URL = 'http://localhost:8080/api'
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('token'))
const name = ref(localStorage.getItem('name'))
const role = ref(localStorage.getItem('role'))
const isLoggedIn = computed(() => !!token.value)
// Axios 인스턴스 생성
const api = axios.create({
baseURL: API_URL,
})
// 요청 시 토큰 헤더 추가
api.interceptors.request.use(config => {
if (token.value) {
config.headers.Authorization = `Bearer ${token.value}`
}
return config
})
// 응답 인터셉터: 401 발생 시 로그아웃 처리
api.interceptors.response.use(
res => res,
err => {
if (err.response?.status === 401) {
logout()
alert('세션이 만료되어 로그아웃됩니다. 다시 로그인해주세요.')
}
return Promise.reject(err)
}
)
async function login(credentials) {
try {
const res = await api.post('/auth/login', credentials)
token.value = res.data.token
name.value = res.data.name
role.value = res.data.role
localStorage.setItem('token', token.value)
localStorage.setItem('name', name.value)
localStorage.setItem('role', role.value)
} catch (err) {
logout()
throw err
}
}
async function signUp(user) {
try {
console.log(user);
const res = await api.post('/auth/register', user)
return res.data
} catch (err) {
throw err
}
}
function autoLogin() {
token.value = localStorage.getItem('token')
name.value = localStorage.getItem('name')
role.value = localStorage.getItem('role')
}
function logout() {
token.value = null
name.value = null
role.value = null
localStorage.removeItem('token')
localStorage.removeItem('name')
localStorage.removeItem('role')
}
return {
token,
name,
role,
isLoggedIn,
login,
signUp,
autoLogin,
logout,
api, // 필요하면 외부에서 직접 API 호출도 가능
}
})
stores - reservationStore.js
// src/stores/reservationStore.js
import { defineStore } from 'pinia'
import axios from 'axios'
const API_URL = 'http://localhost:8080/api/auth'
const API_ADMIN_URL = 'http://localhost:8080/api/admin'
export const useReservationStore = defineStore('reservation', () => {
const getToken = () => localStorage.getItem('token')
// 예약 제출
const submitReservation = async (formData) => {
const token = getToken()
try {
const response = await axios.post(
`${API_URL}/reservation`,
formData,
{
headers: { Authorization: `Bearer ${token}` },
}
)
return response.data
} catch (err) {
throw err
}
}
// 관리자 - 예약 전체 조회
const fetchReservations = async (date) => {
try {
const token = getToken()
if (!token) throw new Error('인증 토큰이 없습니다.')
const params = date ? { date } : {}
const response = await axios.get(`${API_ADMIN_URL}/reservations`, {
headers: { Authorization: `Bearer ${token}` },
params,
})
return response.data
} catch (err) {
console.error('예약 목록 조회 실패:', err)
throw err
}
}
// 관리자 - 상태 변경
const updateStatus = async (id, status) => {
try {
const token = getToken()
const endpoint =
status === 'APPROVED'
? `${API_ADMIN_URL}/reservation/${id}/approve`
: `${API_ADMIN_URL}/reservation/${id}/reject`
await axios.post(endpoint, null, {
headers: { Authorization: `Bearer ${token}` },
})
await fetchReservations()
} catch (err) {
console.error('상태 변경 실패', err)
}
}
// 사용자 - 내 예약 조회
const fetchMyReservations = async () => {
const token = getToken()
try {
const response = await axios.get(`${API_URL}/reservations/my`, {
headers: { Authorization: `Bearer ${token}` },
})
return response.data
} catch (err) {
console.error('내 예약 조회 실패:', err)
throw err
}
}
// 사용자 - 예약 취소
const cancelReservation = async (reservationId) => {
const token = getToken()
try {
await axios.patch(
`${API_URL}/reservations/${reservationId}/cancel`,
null,
{
headers: { Authorization: `Bearer ${token}` },
}
)
} catch (err) {
console.error('예약 취소 실패:', err)
throw err
}
}
return {
submitReservation,
fetchReservations,
updateStatus,
fetchMyReservations,
cancelReservation,
}
})
App.vue
<!-- src/App.vue -->
<template>
<v-app>
<v-app-bar app color="primary" dark>
<v-toolbar-title>예약관리 시스템</v-toolbar-title>
<v-spacer />
<template v-if="auth.isLoggedIn">
<v-btn icon>
<v-icon>mdi-account</v-icon>
</v-btn>
<span class="mr-4">{{ auth.name }} ({{ auth.role }})</span>
<v-btn text to="/">홈</v-btn>
<v-btn text to="/reservation">예약</v-btn>
<v-btn text to="/user">예약조회</v-btn>
<v-btn text to="/admin">관리자</v-btn>
<v-btn text @click="logout"><v-icon>mdi-logout</v-icon></v-btn>
</template>
<template v-else>
<v-btn text to="/">홈</v-btn>
<v-btn text to="/signup">회원가입</v-btn>
<v-btn text to="/login"><v-icon>mdi-login</v-icon></v-btn>
</template>
</v-app-bar>
<v-main>
<v-container class="mt-5">
<router-view />
</v-container>
</v-main>
</v-app>
</template>
<script setup>
import { useAuthStore } from '@/stores/authStore'
import { useRouter } from 'vue-router'
const auth = useAuthStore()
const router = useRouter()
function logout() {
auth.logout()
router.push('/login')
}
</script>
views - HomeView.vue
<!-- src/views/HomeView.vue-->
<template>
<v-container class="fill-height" fluid>
<v-row align="center" justify="center">
<v-col cols="12" md="6">
<v-card>
<v-card-title>예약 관리 시스템</v-card-title>
<v-card-text>
<p>Spring Boot + Mysql + Vue 3 + Vuetify 풀스택 예제 홈.</p>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</template>
views - SignUp.vu
<!-- src/views/SignUp.vue-->
<template>
<v-container>
<v-row justify="center">
<v-col cols="12" md="6">
<v-card>
<v-card-title>회원가입</v-card-title>
<v-card-text>
<v-form @submit.prevent="submitSignUp" ref="form">
<v-text-field v-model="user.email" label="이메일" type="email" required />
<v-text-field v-model="user.password" label="비밀번호" type="password" required />
<v-text-field v-model="user.name" label="사용자 이름" required />
<v-btn type="submit" color="primary" :loading="loading" block>가입하기</v-btn>
</v-form>
<v-alert v-if="error" type="error" class="mt-4">{{ error }}</v-alert>
<v-alert v-if="success" type="success" class="mt-4">{{ success }}</v-alert>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</template>
<script setup>
import { ref } from 'vue'
import { useAuthStore } from '@/stores/authStore'
const auth = useAuthStore()
const user = ref({
name: '',
email: '',
password: '',
})
const loading = ref(false)
const error = ref('')
const success = ref('')
const submitSignUp = async () => {
loading.value = true
error.value = ''
success.value = ''
try {
const response = await auth.signUp(user.value)
console.log(response);
if (response.success) {
success.value = response.message || '회원가입 성공! 로그인 페이지로 이동합니다.'
setTimeout(() => {
window.location.href = '/login'
}, 1500)
}
} catch (e) {
error.value = e.response?.data?.message || '회원가입 실패'
} finally {
loading.value = false
}
}
</script>
views - Login.vue
<!-- src/views/Login.vue-->
<template>
<v-container>
<v-row justify="center">
<v-col cols="12" md="6">
<v-card>
<v-card-title>로그인</v-card-title>
<div class="pa-4">
<v-form @submit.prevent="submitLogin">
<v-text-field v-model="email" label="이메일" type="email" required />
<v-text-field v-model="password" label="비밀번호" type="password" required />
<v-btn type="submit" color="primary" :loading="loading" block>로그인</v-btn>
</v-form>
<v-alert v-if="error" type="error" class="mt-4">{{ error }}</v-alert>
</div>
</v-card>
</v-col>
</v-row>
</v-container>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
const email = ref('')
const password = ref('')
const loading = ref(false)
const error = ref('')
const auth = useAuthStore()
const router = useRouter()
const submitLogin = async () => {
loading.value = true
error.value = ''
try {
const response = await auth.login({ email: email.value, password: password.value })
router.push('/')
} catch (e) {
error.value = e.response?.data?.message || '로그인 실패'
} finally {
loading.value = false
}
}
</script>
views - Reservation.vue
<!-- src/views/Reservation.vue -->
<template>
<v-form @submit.prevent="submitReservation" ref="formRef">
<v-container>
<!-- 날짜 선택 -->
<v-row dense class="py-1">
<v-col cols="12">
<v-text-field
label="예약 날짜"
v-model="form.date"
type="date"
:rules="[rules.required, rules.notPast]"
required
dense
/>
</v-col>
</v-row>
<!-- 시간 선택 영역 -->
<v-row>
<v-col cols="12">
<v-label class="mb-2 font-weight-bold">시간 선택</v-label>
<v-input
:rules="[rules.required]"
v-model="form.timeSlots"
class="py-1"
>
<template #default>
<v-checkbox
v-for="slot in allTimeSlots"
:key="slot"
v-model="form.timeSlots"
:label="slot"
:value="slot"
color="primary"
hide-details
/>
</template>
</v-input>
</v-col>
</v-row>
<!-- 메모 -->
<v-row dense class="py-1">
<v-col cols="12">
<v-textarea
label="메모"
v-model="form.memo"
auto-grow
clearable
rows="2"
class="text-caption"
/>
</v-col>
</v-row>
<!-- 버튼 -->
<v-row dense class="py-1">
<v-col cols="12">
<v-btn type="submit" color="primary" block>예약하기</v-btn>
</v-col>
</v-row>
<!-- 예약 성공 모달 -->
<v-dialog v-model="dialog" max-width="400" @click:outside="closeDialog" @keydown.esc="closeDialog">
<v-card v-if="reservationResult">
<v-card-title class="headline">예약 완료</v-card-title>
<v-card-text>
<v-table>
<tbody>
<tr>
<th class="text-left">예약 ID</th>
<td>{{ reservationResult.id }}</td>
</tr>
<tr>
<th class="text-left">예약 날짜</th>
<td>{{ reservationResult.date }}</td>
</tr>
<tr>
<th class="text-left">시간</th>
<td>{{ reservationResult.timeSlots?.join(', ') || '' }}</td>
</tr>
<tr>
<th class="text-left">메모</th>
<td>{{ reservationResult.memo || '없음' }}</td>
</tr>
<tr>
<th class="text-left">상태</th>
<td>{{ reservationResult.status }}</td>
</tr>
<tr>
<th class="text-left">생성 시간</th>
<td>{{ formatDateTime(reservationResult.createdAt) }}</td>
</tr>
</tbody>
</v-table>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" text @click="closeDialog">확인</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</v-form>
</template>
<script setup>
import { ref } from 'vue'
import { useReservationStore } from '@/stores/reservationStore'
const reservationStore = useReservationStore()
const formRef = ref()
const form = ref({
date: '',
timeSlots: [],
memo: '',
})
const allTimeSlots = [
'09:00~10:00',
'10:00~11:00',
'11:00~12:00',
'13:00~14:00',
'14:00~15:00',
]
const rules = {
required: v => (Array.isArray(v) ? v.length > 0 : !!v) || '필수 입력 항목입니다',
notPast: v => {
if (!v) return true
const selectedDate = new Date(v)
const today = new Date()
today.setHours(0, 0, 0, 0) // 오늘 00:00으로 초기화
return selectedDate >= today || '오늘 이전 날짜는 선택할 수 없습니다'
}
}
// 모달 열림 상태
const dialog = ref(false)
// 예약 결과 저장용
const reservationResult = ref(null)
const submitReservation = async () => {
const { valid } = await formRef.value.validate()
if (!valid) return
try {
const res = await reservationStore.submitReservation(form.value)
reservationResult.value = res // 결과 저장
dialog.value = true // 모달 열기
} catch (err) {
alert('예약 실패: ' + (err.response?.data?.message || err.message))
}
}
const closeDialog = () => {
dialog.value = false
reservationResult.value = null
// 필요하면 폼 초기화
form.value = { date: '', timeSlots: [], memo: '' }
}
const formatDateTime = (value) => {
const date = new Date(value)
return date.toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
</script>
<style scoped>
/* 테이블 셀에 여백 주기 */
.v-simple-table th,
.v-simple-table td {
padding: 12px 16px;
}
</style>
views - UserReservations.vue
<!-- src/views/UserReservations.vue -->
<template>
<v-container>
<h2 class="text-h5 mb-4">내 예약 목록</h2>
<v-card
v-for="reservation in reservations"
:key="reservation.id"
class="mb-4"
>
<v-card-title>
예약 ID: {{ reservation.id }} - {{ reservation.date }} - 상태: {{ statusText(reservation.status) }}
</v-card-title>
<v-card-text class="memo-text">
<b>메모</b>: {{ reservation.memo }}
<br />
<b>시간 슬롯: </b>
<ul>
<li v-for="slot in reservation.timeSlots" :key="slot.id">{{ slot.timeSlot }}</li>
</ul>
</v-card-text>
<v-card-actions>
<v-btn
color="red"
@click="cancelReservation(reservation.id)"
:disabled="reservation.status !== 'PENDING'"
>
예약 취소
</v-btn>
</v-card-actions>
</v-card>
</v-container>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useReservationStore } from '@/stores/reservationStore'
const reservationStore = useReservationStore()
const reservations = ref([])
const statusText = (status) => {
const map = {
PENDING: '대기 중',
APPROVED: '승인됨',
REJECTED: '거부됨',
CANCELLED: '취소됨'
}
return map[status] || '알 수 없음'
}
const fetchMyReservations = async () => {
try {
const res = await reservationStore.fetchMyReservations() // 사용자 본인 예약 목록 가져오기
reservations.value = res
} catch (err) {
console.error('내 예약 목록 조회 실패', err)
}
}
onMounted(() => {
fetchMyReservations()
})
const cancelReservation = async (reservationId) => {
try {
await reservationStore.cancelReservation(reservationId)
await fetchMyReservations()
} catch (err) {
console.error('예약 취소 실패', err)
alert('예약 취소 중 오류가 발생했습니다.')
}
}
</script>
<style scoped>
.memo-text {
white-space: pre-wrap; /* 줄바꿈과 공백을 유지 */
word-break: break-word; /* 단어가 너무 길면 줄바꿈 */
}
</style>
views - AdminReservations.vue
<!-- src/views/AdminReservations.vue -->
<template>
<v-container>
<!-- 날짜 선택 -->
<v-menu
v-model="menu"
:close-on-content-click="false"
transition="scale-transition"
offset-y
max-width="290px"
min-width="290px"
>
<template #activator="{ props }">
<v-text-field
v-model="selectedDate"
label="예약 날짜 선택 (날짜 선택 전에는 모든 예약)"
prepend-icon="mdi-calendar"
readonly
v-bind="props"
></v-text-field>
</template>
<v-date-picker
v-model="selectedDate"
@update:modelValue="onDateChange"
/>
</v-menu>
<!-- 예약 카드 -->
<v-card class="mb-4" v-for="reservation in reservations" :key="reservation.id">
<v-card-title>
예약 ID: {{ reservation.id }} - {{ reservation.date }} - 상태: {{ statusText(reservation.status) }}
</v-card-title>
<v-card-text>
메모: {{ reservation.memo }}
<br />
시간 슬롯:
<ul>
<li v-for="slot in reservation.timeSlots" :key="slot.id">{{ slot.timeSlot }}</li>
</ul>
</v-card-text>
<v-card-actions>
<v-btn
color="green"
@click="updateStatus(reservation.id, 'APPROVED')"
:disabled="reservation.status !== 'PENDING'"
>
승인
</v-btn>
<v-btn
color="red"
@click="updateStatus(reservation.id, 'REJECTED')"
:disabled="reservation.status !== 'PENDING'"
>
거부
</v-btn>
</v-card-actions>
</v-card>
</v-container>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useReservationStore } from '@/stores/reservationStore'
const reservationStore = useReservationStore()
const reservations = ref([])
const selectedDate = ref('')
const menu = ref(false)
const statusText = (status) => {
const map = {
PENDING: '대기 중',
APPROVED: '승인',
REJECTED: '거부',
CANCELLED: '취소됨'
};
return map[status] || '알 수 없음';
};
const fetchAllReservations = async () => {
try {
//if (!selectedDate.value) {
// reservations.value = []
//return
//}
//console.log(selectedDate.value);
// 날짜 필터를 서버 API 호출에 전달
const res = await reservationStore.fetchReservations(selectedDate.value)
reservations.value = res
} catch (err) {
console.error('예약 목록 조회 실패', err)
}
}
onMounted(() => {
fetchAllReservations()
})
// 날짜가 바뀔 때마다 예약 목록 다시 조회
const formatDateTime = (value) => {
const date = new Date(value)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const onDateChange = (date) => {
selectedDate.value = formatDateTime(date)
menu.value = false // 달력 닫기
fetchAllReservations()
}
// ✅ 예약 상태 업데이트
const updateStatus = async (reservationId, newStatus) => {
try {
await reservationStore.updateStatus(reservationId, newStatus)
await fetchAllReservations() // 변경 후 목록 다시 불러오기
} catch (err) {
console.error('예약 상태 변경 실패', err)
alert('예약 상태 변경 중 오류가 발생했습니다.')
}
}
</script>