예약 관리 시스템 - 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>