예약 관리 시스템 - Spring + Vue

예약 관리 시스템 (Spring Boot 3 + Vue 3) - 예약 조회(사용자)

그랜파 개발자 2025. 5. 29. 10:11

회원 예약 조회 및 취소

회원은 자신의 예약을 조회할 수 있습니다.

자신의 예약을 취소할 수 있습니다.

 

‘예약조회’를 누르면, 예약 조회 페이지로 이동하고, 

자신의 모든 예약을 볼 수 있습니다.

각 예약에는 ‘예약 취소’ 버튼이 있어 이것을 누르면 예약이 취소 됩니다.

 



- 프론트엔드 -

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>

 

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,
  }
})

 

 

- 백엔드 - 

SecurityConfig.java

package com.example.reservation.config;

import lombok.RequiredArgsConstructor;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.*;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.*;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.*;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;

import java.util.List;

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

    private static final Logger logger = LoggerFactory.getLogger(JwtFilter.class);

    private final JwtFilter jwtFilter;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.csrf(csrf -> csrf.disable())
            .cors(cors -> cors.configurationSource(request -> {
                CorsConfiguration corsConfig = new CorsConfiguration();
                corsConfig.setAllowedOrigins(List.of("http://localhost:5173"));
                corsConfig.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
                corsConfig.setAllowedHeaders(List.of("Authorization", "Content-Type"));
                corsConfig.setAllowCredentials(true);
                return corsConfig;
            }))
            .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
        		.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()  // OPTIONS 요청 허용
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .requestMatchers("/api/auth/**").permitAll()
                .anyRequest().authenticated()
            );

        http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }


    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }


    @Bean
    public AuthenticationManager authenticationManager(
        AuthenticationConfiguration config
    ) throws Exception {
        return config.getAuthenticationManager();
    }
}

 

ReservationService.java

package com.example.reservation.service;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;

import org.springframework.stereotype.Service;

import com.example.reservation.dto.ReservationRequest;
import com.example.reservation.dto.ReservationResponse;
import com.example.reservation.model.Reservation;
import com.example.reservation.model.ReservationStatus;
import com.example.reservation.model.ReservationTimeSlot;
import com.example.reservation.model.User;
import com.example.reservation.repository.ReservationRepository;
import com.example.reservation.repository.UserRepository;

import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class ReservationService {

    private final ReservationRepository reservationRepository;
    private final UserRepository userRepository;

    /**
     * 사용자: 예약 생성
     */
    @Transactional
    public ReservationResponse createReservation(String email, ReservationRequest request) {
        User user = userRepository.findByEmail(email).orElseThrow();

        Reservation reservation = new Reservation();
        reservation.setDate(LocalDate.parse(request.getDate()));
        reservation.setUser(user);
        reservation.setMemo(request.getMemo());
        reservation.setStatus(ReservationStatus.PENDING);
        reservation.setCreatedAt(LocalDateTime.now());

        List<ReservationTimeSlot> slotEntities = request.getTimeSlots().stream()
            .map(ts -> {
                ReservationTimeSlot slot = new ReservationTimeSlot();
                slot.setTimeSlot(ts);
                slot.setReservation(reservation);  // 필수
                return slot;
            }).collect(Collectors.toList());

        reservation.setTimeSlots(slotEntities);

        Reservation saved = reservationRepository.save(reservation);

        return new ReservationResponse(
                saved.getId(),
                saved.getDate().toString(),
                saved.getTimeSlots().stream().map(ReservationTimeSlot::getTimeSlot).toList(),
                saved.getMemo(),
                saved.getStatus().name(),
                saved.getCreatedAt().toString()
        );
    }
    
    /**
     * 관리자: 예약 상태 변경 (APPROVED, REJECTED, CANCELLED 등)
     */
    @Transactional
    public Reservation updateReservationStatus(Long reservationId, ReservationStatus status) {
        Reservation reservation = reservationRepository.findById(reservationId)
                .orElseThrow(() -> new RuntimeException("Reservation not found"));

        reservation.setStatus(status);
        return reservationRepository.save(reservation);
    }

    /**
     * 관리자: 전체 예약 목록 조회
     */
    public List<Reservation> getAllReservations() {
        return reservationRepository.findAll();
    }
    
    public List<Reservation> getReservationsByDate(String date) {
        // date 형식에 맞게 파싱 후 DB 쿼리 (예: '2025-05-27')
    	LocalDate localDate = LocalDate.parse(date);  // 문자열 → LocalDate 변환
        return reservationRepository.findByDate(localDate);
    }

    /**
     * 사용자: 내 예약 목록 조회
     */
    public List<Reservation> getReservationsByUser(String email) {
    	User user = userRepository.findByEmail(email).orElseThrow();
        return reservationRepository.findByUser(user);
    }
    
    public void cancelReservation(Long reservationId, String email) {
        Reservation reservation = reservationRepository.findById(reservationId)
                .orElseThrow(() -> new RuntimeException("예약을 찾을 수 없습니다."));

        // 클라이언트에서 로그인 후 예약을 취소하므로 본인의 예약만 취소를 한다.
        // 승인 또는 거부가 된 예약도 취소할 수 있다.
//        if (!reservation.getEmail().equals(email)) {
//            throw new SecurityException("본인의 예약만 취소할 수 있습니다.");
//        }

//        if (reservation.getStatus() != ReservationStatus.PENDING) {
//           throw new IllegalStateException("대기 중인 예약만 취소할 수 있습니다.");
//        }

        reservation.setStatus(ReservationStatus.CANCELLED);
        reservationRepository.save(reservation);
    }
}

 

ReservationController.java

package com.example.reservation.controller;

import com.example.reservation.dto.ReservationRequest;
import com.example.reservation.dto.ReservationResponse;
import com.example.reservation.model.Reservation;
import com.example.reservation.model.ReservationStatus;
import com.example.reservation.service.ReservationService;
import com.example.reservation.util.JwtUtil;
import lombok.RequiredArgsConstructor;

import java.util.List;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/")
@CrossOrigin(origins = "http://localhost:5173")
@RequiredArgsConstructor
public class ReservationController {

	private static final Logger logger = LoggerFactory.getLogger(ReservationController.class);

    private final ReservationService reservationService;
    private final JwtUtil jwtUtil;

    @PostMapping("auth/reservation")
    public ReservationResponse create(
            @RequestBody ReservationRequest request,
            @RequestHeader("Authorization") String authHeader
    ) {
        String token = authHeader.replace("Bearer ", "");
        String email = jwtUtil.extractUsername(token);
        return reservationService.createReservation(email, request);
    }
    
    /**
     * ✅ 사용자: 자신의 예약 목록 조회
     */
    @GetMapping("auth/reservations/my")
    @PreAuthorize("hasRole('USER')")
    public List<Reservation> getMyReservations(
    		@RequestHeader("Authorization") String authHeader
    ) {
        String token = authHeader.replace("Bearer ", "");
        String email = jwtUtil.extractUsername(token);
        return reservationService.getReservationsByUser(email);
    }
    
    /**
     * ✅ 사용자: 자신의 예약 취소
     */
    @PatchMapping("auth/reservations/{id}/cancel")
    @PreAuthorize("hasRole('USER')")
    public ResponseEntity<?> cancelReservation(
            @PathVariable Long id,
            @RequestHeader("Authorization") String authHeader
    ) {
        String token = authHeader.replace("Bearer ", "");
        String email = jwtUtil.extractUsername(token);

        reservationService.cancelReservation(id, email);
        return ResponseEntity.ok("예약이 취소되었습니다.");
    }
    
    /**
     * ✅ 관리자: 전체 예약 목록 조회
     */
    @GetMapping("admin/reservations")
    @PreAuthorize("hasRole('ADMIN')")
    public List<Reservation> getAllReservations(
        @RequestParam(required = false) String date
    ) {
        if (date != null && !date.isEmpty()) {
            return reservationService.getReservationsByDate(date);
        }
        return reservationService.getAllReservations();
    }
    
    
    @PreAuthorize("hasRole('ADMIN')")
    @PostMapping("admin/reservation/{id}/approve")
    public ResponseEntity<?> approveReservation(@PathVariable Long id) {
    	logger.info("==== approveReservation : " + id);
        reservationService.updateReservationStatus(id, ReservationStatus.APPROVED);
        return ResponseEntity.ok("Reservation approved");
    }

    @PreAuthorize("hasRole('ADMIN')")
    @PostMapping("admin/reservation/{id}/reject")
    public ResponseEntity<?> rejectReservation(@PathVariable Long id) {
    	logger.info("==== rejectReservation : " + id);
        reservationService.updateReservationStatus(id, ReservationStatus.REJECTED);
        return ResponseEntity.ok("Reservation rejected");
    }
}