diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 374c9bc..49d56cc 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -8,7 +8,7 @@ on: - "backend/settings.gradle.kts" - "backend/Dockerfile" branches: - - main + - dev jobs: makeTagAndRelease: runs-on: ubuntu-latest diff --git a/FETCH_HEAD b/FETCH_HEAD new file mode 100644 index 0000000..e69de29 diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 62bd885..896365d 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -1,7 +1,7 @@ version: '3.8' services: - mysql: + mysql-anidoc: # 서비스명을 DB 전용으로 명시 image: mysql:8.0 restart: always environment: @@ -9,25 +9,9 @@ services: MYSQL_DATABASE: ${MYSQL_DATABASE} MYSQL_USER: ${MYSQL_USER} MYSQL_PASSWORD: ${MYSQL_PASSWORD} - command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci networks: - common ports: - - "3306:3306" - - app1: - build: - context: . - dockerfile: Dockerfile - depends_on: - - mysql - ports: - - "8080:8080" - networks: - - common - -networks: - common: - driver: bridge + - "3307:3306" # 타 프로젝트 DB와 충돌 방지 diff --git a/backend/src/main/java/com/petner/anidoc/AnidocApplication.java b/backend/src/main/java/com/petner/anidoc/AnidocApplication.java index a89280f..0471027 100644 --- a/backend/src/main/java/com/petner/anidoc/AnidocApplication.java +++ b/backend/src/main/java/com/petner/anidoc/AnidocApplication.java @@ -4,12 +4,14 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import java.util.TimeZone; + @EnableJpaAuditing @SpringBootApplication public class AnidocApplication { public static void main(String[] args) { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); SpringApplication.run(AnidocApplication.class, args); } - } diff --git a/backend/src/main/java/com/petner/anidoc/domain/user/notification/service/NotificationService.java b/backend/src/main/java/com/petner/anidoc/domain/user/notification/service/NotificationService.java index 8133513..4e16cf1 100644 --- a/backend/src/main/java/com/petner/anidoc/domain/user/notification/service/NotificationService.java +++ b/backend/src/main/java/com/petner/anidoc/domain/user/notification/service/NotificationService.java @@ -1,25 +1,19 @@ package com.petner.anidoc.domain.user.notification.service; import com.petner.anidoc.domain.user.notification.dto.PetInfoDto; -import com.petner.anidoc.domain.user.notification.dto.ReservationNotificationDto; import com.petner.anidoc.domain.user.notification.dto.VaccinationNotificationDto; import com.petner.anidoc.domain.user.notification.entity.Notification; import com.petner.anidoc.domain.user.notification.entity.NotificationType; import com.petner.anidoc.domain.user.notification.repository.NotificationRepository; -import com.petner.anidoc.domain.user.notification.util.NotificationMessageUtil; import com.petner.anidoc.domain.user.notification.util.VaccinationNotificationHelper; import com.petner.anidoc.domain.user.user.entity.User; -import com.petner.anidoc.domain.user.user.entity.UserRole; import com.petner.anidoc.domain.user.user.repository.UserRepository; -import com.petner.anidoc.domain.vet.reservation.entity.Reservation; -import com.petner.anidoc.domain.vet.reservation.entity.ReservationStatus; import com.petner.anidoc.domain.vet.reservation.repository.ReservationRepository; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @@ -196,3 +190,6 @@ public long getUnreadCount(Long userId) { return notificationRepository.countByUserIdAndIsReadFalse(userId); } } + + + diff --git a/backend/src/main/java/com/petner/anidoc/domain/user/notification/service/SseEmitters.java b/backend/src/main/java/com/petner/anidoc/domain/user/notification/service/SseEmitters.java index 3040386..512ac85 100644 --- a/backend/src/main/java/com/petner/anidoc/domain/user/notification/service/SseEmitters.java +++ b/backend/src/main/java/com/petner/anidoc/domain/user/notification/service/SseEmitters.java @@ -54,7 +54,6 @@ public SseEmitter add(Long userId, SseEmitter emitter) { if (list != null) list.remove(emitter); }); - emitter.onError(e -> { List list = this.emitters.get(userId); if (list != null) list.remove(emitter); @@ -118,7 +117,8 @@ public void noti(Long userId, String eventName, Object data) { }catch (ClientAbortException e){ deadEmitters.add(emitter); }catch (IOException e) { - throw new RuntimeException(e); + log.debug("SSE 연결 끊어짐 - userId: {}, 메시지: {}", userId, e.getMessage()); + deadEmitters.add(emitter); } } userEmitters.removeAll(deadEmitters); diff --git a/backend/src/main/java/com/petner/anidoc/domain/user/user/controller/AdminController.java b/backend/src/main/java/com/petner/anidoc/domain/user/user/controller/AdminController.java new file mode 100644 index 0000000..11f6a3c --- /dev/null +++ b/backend/src/main/java/com/petner/anidoc/domain/user/user/controller/AdminController.java @@ -0,0 +1,60 @@ +package com.petner.anidoc.domain.user.user.controller; + + +import com.petner.anidoc.domain.user.user.dto.UserResponseDto; +import com.petner.anidoc.domain.user.user.service.UserService; +import com.petner.anidoc.domain.user.user.service.UserStatusService; +import com.petner.anidoc.global.security.SecurityUser; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.security.Security; +import java.util.List; + +@RestController +@RequestMapping("/api/admins") +@RequiredArgsConstructor + +public class AdminController { + + private final UserService userService; + private final UserStatusService userStatusService; + + // ✅ 승인 대기 목록 조회 + @Operation(summary = "승인 대기 목록 조회", description = "의료진 가입 승인 대기 중인 사용자 목록을 조회합니다.") + @GetMapping("/pending-approvals") + @PreAuthorize("hasRole('ROLE_ADMIN')") + public ResponseEntity> getPendingApprovals(){ + List pendingUsers = userStatusService.getPendingApprovalUsers(); + return ResponseEntity.ok(pendingUsers); + } + + // ✅ 승인 완료 처리 + @PostMapping("/approve/{id}") + @PreAuthorize("hasRole('ROLE_ADMIN')") + public ResponseEntity approveUser( + @PathVariable Long id, + @AuthenticationPrincipal SecurityUser admin){ + + userStatusService.approveUser(id, admin.getId()); + return ResponseEntity.ok("사용자 승인 완료"); + } + + // ✅ 승인 거절 + @DeleteMapping("/reject/{id}") + @PreAuthorize("hasRole('ROLE_ADMIN')") + public ResponseEntity rejectUser( + @PathVariable Long id, + @AuthenticationPrincipal SecurityUser admin) { + userStatusService.rejectUser(id, admin.getId()); + return ResponseEntity.ok("사용자 승인 거부"); + } + + +} + + diff --git a/backend/src/main/java/com/petner/anidoc/domain/user/user/controller/UserController.java b/backend/src/main/java/com/petner/anidoc/domain/user/user/controller/UserController.java index 74f507a..47ebab8 100644 --- a/backend/src/main/java/com/petner/anidoc/domain/user/user/controller/UserController.java +++ b/backend/src/main/java/com/petner/anidoc/domain/user/user/controller/UserController.java @@ -15,6 +15,7 @@ import jakarta.validation.Valid; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -99,7 +100,6 @@ public ResponseEntity login(@Valid @RequestBody LoginRequestDt throw new CustomException(ErrorCode.LOGIN_FAILED); } - // TODO : 에러 코드 세분화(USER가 존재하지 않습니다, 비밀번호가 다릅니다 등) } @@ -111,6 +111,8 @@ public ResponseEntity logout(@CookieValue(value = "accessToken", require if (accessToken == null || !authTokenService.isValid(accessToken)){ return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("유효하지 않은 토큰입니다."); } + + userService.logout(accessToken); @@ -151,7 +153,7 @@ public ResponseEntity getUserProfile(@AuthenticationPrinc // ✅ 의료진 조회 - @Operation(summary = "의료진 목록 조회", description = "근무 중인 의료진 목록을 조회합니다.") + @Operation(summary = "의료진 목록 조회", description = "의료진 목록을 조회합니다.") @GetMapping("/staff") public ResponseEntity> getStaffList( @RequestParam(value = "onlyAvailable", defaultValue = "false") boolean onlyAvailable) { @@ -160,14 +162,13 @@ public ResponseEntity> getStaffList( return ResponseEntity.ok(staffList); } - //✅ 비밀번호 일치 확인 @PostMapping("/verify-password") public ResponseEntity> verifyCurrentPassword( @RequestBody PasswordVerificationRequest request, @AuthenticationPrincipal SecurityUser securityUser) { - User user = userService.getUserById(securityUser.getId()); - try { + User user = userService.getUserById(securityUser.getId()); + try { boolean isValid = userService.verifyCurrentPassword(user, request.getPassword()); Map response = new HashMap<>(); @@ -200,7 +201,7 @@ public ResponseEntity getMyStatus(@AuthenticationPrincipal SecurityU @PutMapping("/me/status") public ResponseEntity updateMyStatus(@AuthenticationPrincipal SecurityUser securityUser, - @RequestParam UserStatus status){ + @RequestParam UserStatus status){ userService.updateMyStatus(securityUser.getId(),status); return ResponseEntity.ok("상태 변경이 반영되었습니다."); } diff --git a/backend/src/main/java/com/petner/anidoc/domain/user/user/dto/StaffResponseDto.java b/backend/src/main/java/com/petner/anidoc/domain/user/user/dto/StaffResponseDto.java index ae68f3a..5178c67 100644 --- a/backend/src/main/java/com/petner/anidoc/domain/user/user/dto/StaffResponseDto.java +++ b/backend/src/main/java/com/petner/anidoc/domain/user/user/dto/StaffResponseDto.java @@ -14,12 +14,16 @@ public class StaffResponseDto { private Long id; private String name; + private String email; + private String phoneNumber; private UserStatus status; public static StaffResponseDto fromEntity(User user) { return StaffResponseDto.builder() .id(user.getId()) .name(user.getName()) + .email(user.getEmail()) + .phoneNumber(user.getPhoneNumber()) .status(user.getStatus()) .build(); } diff --git a/backend/src/main/java/com/petner/anidoc/domain/user/user/entity/ApprovalStatus.java b/backend/src/main/java/com/petner/anidoc/domain/user/user/entity/ApprovalStatus.java new file mode 100644 index 0000000..41a1606 --- /dev/null +++ b/backend/src/main/java/com/petner/anidoc/domain/user/user/entity/ApprovalStatus.java @@ -0,0 +1,7 @@ +package com.petner.anidoc.domain.user.user.entity; + +public enum ApprovalStatus { + PENDING, // 승인 대기 + APPROVED, // 승인됨 + REJECTED // 승인 거부 +} diff --git a/backend/src/main/java/com/petner/anidoc/domain/user/user/entity/User.java b/backend/src/main/java/com/petner/anidoc/domain/user/user/entity/User.java index cb7cc2b..4a24f03 100644 --- a/backend/src/main/java/com/petner/anidoc/domain/user/user/entity/User.java +++ b/backend/src/main/java/com/petner/anidoc/domain/user/user/entity/User.java @@ -56,6 +56,10 @@ public class User extends BaseEntity implements UserDetails { @Column(name = "status") private UserStatus status; + @Enumerated(EnumType.STRING) + @Column(name ="approval_status") + private ApprovalStatus approvalStatus; + @Enumerated(EnumType.STRING) @Column(name = "sso_provider") private SsoProvider ssoProvider; @@ -133,4 +137,14 @@ public void updateRefreshToken(String refreshToken){ public void updateStatus(UserStatus status) { this.status = status; } + + public void updateBasicInfo(String name, String phoneNumber, String emergencyContact, UserRole role, VetInfo vetInfo) { + this.name = name; + this.phoneNumber = phoneNumber; + this.emergencyContact = emergencyContact; + this.role = role; + this.vetInfo = vetInfo; + } + + } diff --git a/backend/src/main/java/com/petner/anidoc/domain/user/user/entity/UserStatus.java b/backend/src/main/java/com/petner/anidoc/domain/user/user/entity/UserStatus.java index 3d44919..08a2bce 100644 --- a/backend/src/main/java/com/petner/anidoc/domain/user/user/entity/UserStatus.java +++ b/backend/src/main/java/com/petner/anidoc/domain/user/user/entity/UserStatus.java @@ -1,5 +1,5 @@ package com.petner.anidoc.domain.user.user.entity; public enum UserStatus { - ON_DUTY, AWAY,OFF + ON_DUTY, AWAY,OFFLINE } diff --git a/backend/src/main/java/com/petner/anidoc/domain/user/user/repository/UserRepository.java b/backend/src/main/java/com/petner/anidoc/domain/user/user/repository/UserRepository.java index ed7adbb..64d67af 100644 --- a/backend/src/main/java/com/petner/anidoc/domain/user/user/repository/UserRepository.java +++ b/backend/src/main/java/com/petner/anidoc/domain/user/user/repository/UserRepository.java @@ -1,5 +1,6 @@ package com.petner.anidoc.domain.user.user.repository; +import com.petner.anidoc.domain.user.user.entity.ApprovalStatus; import com.petner.anidoc.domain.user.user.entity.User; import com.petner.anidoc.domain.user.user.entity.UserRole; import com.petner.anidoc.domain.user.user.entity.UserStatus; @@ -25,20 +26,14 @@ public interface UserRepository extends JpaRepository { @Query("SELECT u FROM User u WHERE u.role = :role AND u.status = :status") List findByRoleAndStatus(@Param("role") UserRole role, @Param("status") UserStatus status); + // 승인된 특정 역할의 사용자 조회 + List findByRoleAndApprovalStatus(UserRole role, ApprovalStatus approvalStatus); + + // 승인된 특정 역할과 상태의 사용자 조회 + List findByRoleAndApprovalStatusAndStatus(UserRole role, ApprovalStatus approvalStatus, UserStatus status); + + List findByRoleAndApprovalStatusAndStatusIn(UserRole role, ApprovalStatus approvalStatus, List statuses); - @Modifying - @Transactional - @Query("UPDATE User u SET u.name = :name, u.phoneNumber = :phoneNumber, " + - "u.emergencyContact = :emergencyContact," + - "u.role = :role, " + - " u.vetInfo = :vetInfo, u.updatedAt = CURRENT_TIMESTAMP " + - "WHERE u.id = :id") - void updateUserBasicInfo(@Param("id") Long id, - @Param("name") String name, - @Param("phoneNumber") String phoneNumber, - @Param("emergencyContact") String emergencyContact, - @Param("role") UserRole role, - @Param("vetInfo") VetInfo vetInfo); } diff --git a/backend/src/main/java/com/petner/anidoc/domain/user/user/service/UserService.java b/backend/src/main/java/com/petner/anidoc/domain/user/user/service/UserService.java index 952f8e9..2af1d82 100644 --- a/backend/src/main/java/com/petner/anidoc/domain/user/user/service/UserService.java +++ b/backend/src/main/java/com/petner/anidoc/domain/user/user/service/UserService.java @@ -1,10 +1,7 @@ package com.petner.anidoc.domain.user.user.service; import com.petner.anidoc.domain.user.user.dto.*; -import com.petner.anidoc.domain.user.user.entity.SsoProvider; -import com.petner.anidoc.domain.user.user.entity.User; -import com.petner.anidoc.domain.user.user.entity.UserRole; -import com.petner.anidoc.domain.user.user.entity.UserStatus; +import com.petner.anidoc.domain.user.user.entity.*; import com.petner.anidoc.domain.user.user.repository.UserRepository; import com.petner.anidoc.domain.vet.vet.entity.VetInfo; import com.petner.anidoc.domain.vet.vet.repository.VetInfoRepository; @@ -17,6 +14,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.Optional; @@ -39,6 +37,7 @@ public class UserService { private final VetInfoRepository vetInfoRepository; private final AuthTokenService authTokenService; private final PasswordEncoder passwordEncoder; + private final UserStatusService userStatusService; // ✅ 이메일 중복 검사 @@ -71,17 +70,15 @@ public User register(UserSignUpRequestDto dto){ .emergencyContact(dto.getEmergencyContact()) .vetInfo(vetInfo) .build(); - // 의료진인 경우 상태 설정 if (dto.getRole() == UserRole.ROLE_STAFF) { - user.updateStatus(UserStatus.ON_DUTY); + user.updateStatus(UserStatus.OFFLINE); + user.setApprovalStatus(ApprovalStatus.PENDING); } return userRepository.save(user); } - //TODO: 비밀번호 확인 기능 - // ✅ 일반 로그인 @Transactional public UserResponseDto login(LoginRequestDto loginDto){ @@ -97,6 +94,8 @@ public UserResponseDto login(LoginRequestDto loginDto){ throw new CustomException(ErrorCode.PASSWORD_MISMATCH); } + userStatusService.checkLoginUser(user); + // refreshToken 생성 String refreshToken = authTokenService.generateRefreshToken(user); @@ -104,10 +103,32 @@ public UserResponseDto login(LoginRequestDto loginDto){ user.updateRefreshToken(refreshToken); userRepository.save(user); - return UserResponseDto.fromEntity(user); } + // 소셜 로그인 + @Transactional + public User authenticateUserByToken(String accessToken) { + // 토큰 검증 + Map payload = authTokenService.payload(accessToken); + if (payload == null) { + throw new CustomException(ErrorCode.INVALID_TOKEN); + } + + // 사용자 ID 추출 + Long userId = ((Number) payload.get("id")).longValue(); + + // DB에서 사용자 조회 + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + // 로그인 상태 확인 및 설정 + userStatusService.checkLoginUser(user); + + return user; + } + + // ✅ 엑세스 토큰 생성 public String genAccessToken(User user){ return authTokenService.genAccessToken(user); @@ -122,6 +143,7 @@ public void logout(String accessToken) { User user = userRepository.findById(tokenUser.getId()) .orElseThrow(()-> new CustomException(ErrorCode.USER_NOT_FOUND)); user.updateRefreshToken(null); + user.setStatus(UserStatus.OFFLINE); userRepository.save(user); } catch (Exception e) { throw new CustomException(ErrorCode.LOGOUT_FAILED); @@ -134,6 +156,7 @@ public void deleteUser(long userId) { userRepository.deleteById(userId); } + // 📍 조회 // ✅ 이메일로 사용자 조회 @Transactional(readOnly = true) @@ -190,19 +213,33 @@ public List getStaffList(boolean onlyAvailable) { List staffList; if (onlyAvailable) { - // 근무 중인 의료진만 조회 - staffList = userRepository.findByRoleAndStatus(UserRole.ROLE_STAFF, UserStatus.ON_DUTY); + // 승인되고 근무 중인 의료진만 조회 + List statuses = Arrays.asList( + UserStatus.ON_DUTY, + UserStatus.OFFLINE + ); + + staffList = userRepository.findByRoleAndApprovalStatusAndStatusIn( + UserRole.ROLE_STAFF, + ApprovalStatus.APPROVED, + statuses + ); } else { - // 모든 의료진 조회 - staffList = userRepository.findByRole(UserRole.ROLE_STAFF); + // 승인된 모든 의료진 조회 + staffList = userRepository.findByRoleAndApprovalStatus( + UserRole.ROLE_STAFF, + ApprovalStatus.APPROVED + ); } + return staffList.stream() .map(StaffResponseDto::fromEntity) .collect(Collectors.toList()); } public void modify(User user, @NotBlank String email){ + user.setEmail(email); } @@ -213,6 +250,7 @@ public User join(String email, String socialId, SsoProvider provider){ throw new RuntimeException("해당 email은 이미 사용중입니다."); }); + User user = User.builder() .name("Temp_name") .email(email) @@ -271,21 +309,26 @@ public User updateSocialUser(Long userId, SocialSignUpRequestDto updateDto) { .orElseThrow(() -> new RuntimeException("병원 정보를 찾을 수 없습니다.")); } - // Repository의 업데이트 메서드 사용 - userRepository.updateUserBasicInfo( - userId, + // 기본 정보 업데이트 + user.updateBasicInfo( updateDto.getName(), updateDto.getPhoneNumber(), updateDto.getEmergencyContact(), updateDto.getRole(), - updateDto.getVetInfo() + vetInfo ); - // 업데이트된 사용자 정보 반환 - return userRepository.findById(userId).orElseThrow(); + // 의료진일 경우 상태 및 승인 설정 + if (updateDto.getRole() == UserRole.ROLE_STAFF) { + user.updateStatus(UserStatus.OFFLINE); + user.setApprovalStatus(ApprovalStatus.PENDING); + } + + return user; } + // 비밀번호 체크 @Transactional public boolean verifyCurrentPassword(User user, String inputPassword) { @@ -305,15 +348,17 @@ public boolean verifyCurrentPassword(User user, String inputPassword) { // 📍 status 관련 service // 내 상태 변경 + @Transactional public void updateMyStatus(Long id, UserStatus newStatus){ User user = userRepository.findById(id) .orElseThrow(()-> new RuntimeException("사용자 없음")); - user.setStatus(newStatus); - userRepository.save(user); - } + user.setStatus(newStatus); + userRepository.save(user); + } // 내 상태 조회 + @Transactional public UserStatus getStatus(Long id) { return userRepository.findById(id) .orElseThrow(()-> new RuntimeException("사용자를 찾을 수 없습니다.")) diff --git a/backend/src/main/java/com/petner/anidoc/domain/user/user/service/UserStatusService.java b/backend/src/main/java/com/petner/anidoc/domain/user/user/service/UserStatusService.java new file mode 100644 index 0000000..713ac1f --- /dev/null +++ b/backend/src/main/java/com/petner/anidoc/domain/user/user/service/UserStatusService.java @@ -0,0 +1,83 @@ +package com.petner.anidoc.domain.user.user.service; + +import com.petner.anidoc.domain.user.user.dto.UserResponseDto; +import com.petner.anidoc.domain.user.user.entity.ApprovalStatus; +import com.petner.anidoc.domain.user.user.entity.User; +import com.petner.anidoc.domain.user.user.entity.UserRole; +import com.petner.anidoc.domain.user.user.entity.UserStatus; +import com.petner.anidoc.domain.user.user.repository.UserRepository; +import com.petner.anidoc.global.exception.CustomException; +import com.petner.anidoc.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor + +public class UserStatusService { + + private final UserRepository userRepository; + + // 📍 가입 승인 + + // ✅ 의료진 가입 승인 + @Transactional + public void approveUser(Long userId, Long adminId){ + User user = userRepository.findById(userId) + .orElseThrow(()-> new CustomException(ErrorCode.USER_NOT_FOUND)); + + if (user.getRole() != UserRole.ROLE_STAFF){ + throw new CustomException(ErrorCode.INVALID_USER_ROLE); + + } + + user.setApprovalStatus(ApprovalStatus.APPROVED); + userRepository.save(user); + } + + // ✅`승인 거부 + @Transactional + public void rejectUser(Long userId, Long adminId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + userRepository.delete(user); + userRepository.flush(); + } + + // ✅`승인 대기 중인 사용자 목록 조회 + @Transactional(readOnly = true) + public List getPendingApprovalUsers() { + List pendingUsers = userRepository + .findByRoleAndApprovalStatus(UserRole.ROLE_STAFF, ApprovalStatus.PENDING); + + return pendingUsers.stream() + .map(UserResponseDto::fromEntity) + .collect(Collectors.toList()); + } + + + // ✅ 로그인 시 상태 설정 및 승인 검증 + @Transactional + public void checkLoginUser(User user){ + // 상태 설정 + user.setStatus(UserStatus.ON_DUTY); + + // 승인 상태 확인 + if (user.getRole() == UserRole.ROLE_STAFF && + user.getApprovalStatus() != ApprovalStatus.APPROVED) { + + if (user.getApprovalStatus() == ApprovalStatus.PENDING) { + throw new CustomException(ErrorCode.APPROVAL_PENDING); + } else if (user.getApprovalStatus() == ApprovalStatus.REJECTED) { + throw new CustomException(ErrorCode.APPROVAL_REJECTED); + } + } + } +} diff --git a/backend/src/main/java/com/petner/anidoc/domain/vet/vaccination/controller/DoctorPetVaccineController.java b/backend/src/main/java/com/petner/anidoc/domain/vet/vaccination/controller/DoctorPetVaccineController.java index c453b23..611d57d 100644 --- a/backend/src/main/java/com/petner/anidoc/domain/vet/vaccination/controller/DoctorPetVaccineController.java +++ b/backend/src/main/java/com/petner/anidoc/domain/vet/vaccination/controller/DoctorPetVaccineController.java @@ -105,7 +105,7 @@ public ResponseEntity deleteVaccination( @AuthenticationPrincipal UserDetails currentUser ) { User currentDoctor = userRepository.findByEmail(currentUser.getUsername()) - .orElseThrow(()-> new RuntimeException("사용자 정보를 찾을 수 없습니다.")); + .orElseThrow(()-> new RuntimeException("사용자 정보를 찾을 수 없습니다.")); doctorPetVaccineService.deleteVaccination(vaccinationId, currentDoctor); return ResponseEntity.ok().body("예방접종 기록이 삭제되었습니다."); diff --git a/backend/src/main/java/com/petner/anidoc/domain/vet/vaccination/service/DoctorPetVaccineService.java b/backend/src/main/java/com/petner/anidoc/domain/vet/vaccination/service/DoctorPetVaccineService.java index 5c25da4..b998cde 100644 --- a/backend/src/main/java/com/petner/anidoc/domain/vet/vaccination/service/DoctorPetVaccineService.java +++ b/backend/src/main/java/com/petner/anidoc/domain/vet/vaccination/service/DoctorPetVaccineService.java @@ -25,7 +25,7 @@ @Service @RequiredArgsConstructor public class DoctorPetVaccineService { - private final VaccinationRepository vaccineRepository; + private final VaccinationRepository vaccinationRepository; private final PetRepository petRepository; private final ReservationRepository reservationRepository; private final UserRepository userRepository; @@ -45,7 +45,7 @@ public Vaccination registerVaccination(Long petId, DoctorPetVaccineRequestDTO do throw new IllegalArgumentException("해당 예약은 이 반려동물의 예약이 아닙니다."); } - if (vaccineRepository.findByReservationId(doctorPetVaccineRequestDTO.getReservationId()).isPresent()) { + if (vaccinationRepository.findByReservationId(doctorPetVaccineRequestDTO.getReservationId()).isPresent()) { throw new IllegalStateException("이미 해당 예약에 대한 예방접종 기록이 존재합니다."); } @@ -62,12 +62,12 @@ public Vaccination registerVaccination(Long petId, DoctorPetVaccineRequestDTO do .notes(doctorPetVaccineRequestDTO.getNotes()) .build(); - return vaccineRepository.save(vaccination); + return vaccinationRepository.save(vaccination); } //수정 @Transactional public DoctorPetVaccineResponseDTO updateVaccine(Long vaccinationId, DoctorPetVaccineRequestDTO doctorPetVaccineRequestDTO, User currentDoctor){ - Vaccination vaccination = vaccineRepository.findById(vaccinationId) + Vaccination vaccination = vaccinationRepository.findById(vaccinationId) .orElseThrow(()-> new RuntimeException("예방접종 기록이 없습니다.")); //권한(동일한 의료진인지 확인) if (!vaccination.getDoctor().getId().equals(currentDoctor.getId())) { @@ -91,7 +91,7 @@ public DoctorPetVaccineResponseDTO updateVaccine(Long vaccinationId, DoctorPetVa //전체 조회 @Transactional(readOnly = true) public List findAllVaccinations() { - List vaccinations = vaccineRepository.findAll(); + List vaccinations = vaccinationRepository.findAll(); return vaccinations.stream() .map(DoctorPetVaccineResponseDTO::new) .collect(Collectors.toList()); @@ -100,7 +100,7 @@ public List findAllVaccinations() { //상세 조회 @Transactional(readOnly = true) public DoctorPetVaccineResponseDTO findVaccinationById(Long vaccinationId) { - Vaccination vaccination = vaccineRepository.findById(vaccinationId) + Vaccination vaccination = vaccinationRepository.findById(vaccinationId) .orElseThrow(() -> new RuntimeException("예방접종 기록이 없습니다.")); return new DoctorPetVaccineResponseDTO(vaccination); } @@ -108,7 +108,7 @@ public DoctorPetVaccineResponseDTO findVaccinationById(Long vaccinationId) { // 예약별 예방접종 기록 조회 @Transactional(readOnly = true) public DoctorPetVaccineResponseDTO findVaccinationByReservationId(Long reservationId) { - Vaccination vaccination = vaccineRepository.findByReservationId(reservationId) + Vaccination vaccination = vaccinationRepository.findByReservationId(reservationId) .orElseThrow(() -> new EntityNotFoundException("해당 예약에 대한 예방접종 기록이 없습니다.")); return new DoctorPetVaccineResponseDTO(vaccination); } @@ -116,19 +116,19 @@ public DoctorPetVaccineResponseDTO findVaccinationByReservationId(Long reservati //삭제 @Transactional public void deleteVaccination(Long vaccinationId, User currentDoctor) { - Vaccination vaccination = vaccineRepository.findById(vaccinationId) + Vaccination vaccination = vaccinationRepository.findById(vaccinationId) .orElseThrow(() -> new RuntimeException("예방접종 기록이 없습니다.")); //권한(동일한 의료진인지 확인) if (!vaccination.getDoctor().getId().equals(currentDoctor.getId())){ throw new AccessDeniedException("본인이 등록한 예방접종만 삭제할 수 있습니다."); } - vaccineRepository.delete(vaccination); + vaccinationRepository.delete(vaccination); } @Transactional(readOnly = true) public VaccinationStatusDto getVaccinationStatusByReservationId(Long reservationId) { - Optional vaccination = vaccineRepository.findByReservationId(reservationId); + Optional vaccination = vaccinationRepository.findByReservationId(reservationId); return vaccination.map(value -> new VaccinationStatusDto(true, value.getStatus().toString())) .orElseGet(() -> new VaccinationStatusDto(false, null)); } diff --git a/backend/src/main/java/com/petner/anidoc/global/exception/ErrorCode.java b/backend/src/main/java/com/petner/anidoc/global/exception/ErrorCode.java index a96fd5b..514e01b 100644 --- a/backend/src/main/java/com/petner/anidoc/global/exception/ErrorCode.java +++ b/backend/src/main/java/com/petner/anidoc/global/exception/ErrorCode.java @@ -21,6 +21,11 @@ public enum ErrorCode { PASSWORD_MISMATCH(HttpStatus.BAD_REQUEST, "비밀번호가 일치하지 않습니다."), EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT,"이미 존재하는 이메일입니다."), + // 사용자 승인 관련 오류 + APPROVAL_PENDING(HttpStatus.UNAUTHORIZED, "승인 대기 중입니다. 관리자 승인 후 로그인 가능합니다."), + APPROVAL_REJECTED(HttpStatus.UNAUTHORIZED, "가입 승인이 거부되었습니다. 관리자에게 문의하세요."), + INVALID_USER_ROLE(HttpStatus.BAD_REQUEST, "승인 대상이 아닌 사용자입니다."), + // 로그인 중 오류 LOGIN_FAILED(HttpStatus.CONFLICT,"로그인에 실패했습니다."), diff --git a/backend/src/main/java/com/petner/anidoc/global/init/UserInitializer.java b/backend/src/main/java/com/petner/anidoc/global/init/UserInitializer.java index 2f5daac..032e0f6 100644 --- a/backend/src/main/java/com/petner/anidoc/global/init/UserInitializer.java +++ b/backend/src/main/java/com/petner/anidoc/global/init/UserInitializer.java @@ -10,9 +10,11 @@ import com.petner.anidoc.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; import org.springframework.boot.CommandLineRunner; +import org.springframework.core.annotation.Order; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; +@Order(2) @Component @RequiredArgsConstructor public class UserInitializer implements CommandLineRunner { diff --git a/backend/src/main/java/com/petner/anidoc/global/init/VetInfoInitializer.java b/backend/src/main/java/com/petner/anidoc/global/init/VetInfoInitializer.java index df0c42a..c21591f 100644 --- a/backend/src/main/java/com/petner/anidoc/global/init/VetInfoInitializer.java +++ b/backend/src/main/java/com/petner/anidoc/global/init/VetInfoInitializer.java @@ -4,10 +4,12 @@ import com.petner.anidoc.domain.vet.vet.repository.VetInfoRepository; import lombok.RequiredArgsConstructor; import org.springframework.boot.CommandLineRunner; +import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import java.time.LocalDate; +@Order(1) @Component @RequiredArgsConstructor public class VetInfoInitializer implements CommandLineRunner { diff --git a/backend/src/main/resources/META-INF/MANIFEST.MF b/backend/src/main/resources/META-INF/MANIFEST.MF new file mode 100644 index 0000000..34c2b6d --- /dev/null +++ b/backend/src/main/resources/META-INF/MANIFEST.MF @@ -0,0 +1,3 @@ +Manifest-Version: 1.0 +Main-Class: com.petner.anidoc.AnidocApplication + diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml index 5e07cb1..4fe4d55 100644 --- a/backend/src/main/resources/application-prod.yml +++ b/backend/src/main/resources/application-prod.yml @@ -10,7 +10,7 @@ custom: spring: datasource: - url: jdbc:mysql://mysql_1:3306/anidocdb + url: jdbc:mysql://mysql_1:3306/anidocdb?serverTimezone=Asia/Seoul username: petner password: petner driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/frontend/FETCH_HEAD b/frontend/FETCH_HEAD new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/admin/reservations/page.tsx b/frontend/src/app/admin/reservations/page.tsx index f18c10c..cb335fa 100644 --- a/frontend/src/app/admin/reservations/page.tsx +++ b/frontend/src/app/admin/reservations/page.tsx @@ -1,11 +1,12 @@ "use client"; -import { Suspense, useState, useEffect } from "react"; +import { useState, useEffect, Suspense } from "react"; import { useSearchParams } from "next/navigation"; import { useUser } from "@/contexts/UserContext"; import ReservationStatus from "@/components/ReservationStatus"; import { User, Check, X, Clock } from "lucide-react"; import StatisticsPanel from "@/components/statistics/StatisticsPanel"; +import { toast } from "react-hot-toast"; // 예약 타입 interface Reservation { @@ -32,183 +33,21 @@ interface Doctor { status: "ON_DUTY" | "ON_LEAVE"; } -function ReservationTable({ - selectedDate, - selectedDateReservations, - handleUpdateStatus, - loading, - setAssigningDoctor, -}: { - selectedDate: string; - selectedDateReservations: Reservation[]; - handleUpdateStatus: (id: number, status: string) => void; - loading: boolean; - setAssigningDoctor: ( - data: { reservationId: number; doctorId: number | null } | null - ) => void; -}) { - // 테이블 컴포넌트 로직 - return ( -
-
- - - - - - - - - - - - - - - {selectedDateReservations.length === 0 ? ( - - - - ) : ( - selectedDateReservations.map((reservation) => ( - - - - - - - - - - - )) - )} - -
- 예약 시간 - - 환자명 - - 반려동물명 - - 진료 유형 - - 담당의 - - 예약 상태 - - 메모 - - 관리 -
- 선택한 날짜에 예약이 없습니다. -
-
- - - {reservation.reservationTime.substring(0, 5)} - -
-
-
- {reservation.userName} -
-
-
- {reservation.petName} -
-
- - {reservation.type === "GENERAL" ? "일반진료" : "예방접종"} - - - {reservation.doctorName ? ( -
- {reservation.doctorName} -
- ) : ( - - )} -
- - {reservation.status === "APPROVED" - ? "승인됨" - : reservation.status === "PENDING" - ? "대기중" - : "거절됨"} - - -
- {reservation.symptom ? ( -
- {reservation.symptom} -
- ) : ( - 메모 없음 - )} -
-
-
- {reservation.status === "PENDING" && ( - <> - - - - )} - {reservation.status !== "PENDING" && ( - 처리완료 - )} -
-
-
-
- ); -} - -function ReservationContent() { +function ReservationManagementContent() { const { user } = useUser(); const searchParams = useSearchParams(); const [initialDate, setInitialDate] = useState(); - const [key, setKey] = useState(0); + const [key, setKey] = useState(0); // 강제 리렌더링용 + + useEffect(() => { + const date = searchParams.get("date"); + + if (date) { + setInitialDate(date); + setKey((prev) => prev + 1); // 컴포넌트 강제 리렌더링 + } + }, [searchParams]); + const [selectedDateReservations, setSelectedDateReservations] = useState< Reservation[] >([]); @@ -220,16 +59,6 @@ function ReservationContent() { doctorId: number | null; } | null>(null); - // URL 파라미터 처리 - useEffect(() => { - const date = searchParams.get("date"); - - if (date) { - setInitialDate(date); - setKey((prev) => prev + 1); // 컴포넌트 강제 리렌더링 - } - }, [searchParams]); - // 의사 목록 조회 useEffect(() => { if (!user) return; @@ -303,16 +132,16 @@ function ReservationContent() { setSelectedDateReservations(updatedReservations); setAssigningDoctor(null); - alert("담당의가 성공적으로 배정되었습니다."); + toast.success("담당의가 성공적으로 배정되었습니다."); } catch (error: any) { console.error("의사 배정 오류:", error); - alert(error.message || "담당의 배정 중 오류가 발생했습니다."); + toast.error("담당의 배정 중 오류가 발생했습니다."); } finally { setLoading(false); } }; - // ⭐ 수정된 예약 상태 변경 처리 - 안전한 JSON 파싱 및 자동 재시도 로직 + // 예약 상태 변경 처리 - 안전한 JSON 파싱 및 자동 재시도 로직 const handleUpdateStatus = async ( reservationId: number, newStatus: string, @@ -348,37 +177,37 @@ function ReservationContent() { console.log("응답 상태:", response.status); console.log("Content-Type:", response.headers.get("content-type")); - // ⭐ 응답 상태 확인 + // 응답 상태 확인 if (!response.ok) { console.error("HTTP 오류:", response.status); const errorText = await response.text(); throw new Error(`HTTP ${response.status}: ${errorText}`); } - // ⭐ 204 No Content 응답 처리 + // 204 No Content 응답 처리 if (response.status === 204) { console.log("빈 응답 수신 (204) - 성공으로 처리"); const statusText = newStatus === "APPROVED" ? "승인" : "거절"; - alert(`예약이 성공적으로 ${statusText}되었습니다.`); + toast.success(`예약이 성공적으로 ${statusText}되었습니다.`); window.location.reload(); return; } - // ⭐ 응답 텍스트 먼저 확인 + // 응답 텍스트 먼저 확인 const responseText = await response.text(); console.log("응답 본문 길이:", responseText.length); console.log("응답 본문 미리보기:", responseText.substring(0, 100)); - // ⭐ 빈 응답 처리 + // 빈 응답 처리 if (!responseText || responseText.trim() === "") { console.warn("빈 응답 수신 - 성공으로 간주"); const statusText = newStatus === "APPROVED" ? "승인" : "거절"; - alert(`예약이 성공적으로 ${statusText}되었습니다.`); + toast.success(`예약이 성공적으로 ${statusText}되었습니다.`); window.location.reload(); return; } - // ⭐ HTML 응답 체크 + // HTML 응답 체크 if ( responseText.trim().startsWith("") || responseText.trim().startsWith("") @@ -389,7 +218,7 @@ function ReservationContent() { ); } - // ⭐ JSON 파싱 시도 + // JSON 파싱 시도 let updatedReservation; try { updatedReservation = JSON.parse(responseText); @@ -398,7 +227,7 @@ function ReservationContent() { console.error("JSON 파싱 오류:", parseError); console.error("파싱 실패한 응답:", responseText); - // ⭐ JSON 파싱 실패 시 재시도 (한 번만) + // JSON 파싱 실패 시 재시도 (한 번만) if ( !isRetry && parseError.message.includes("Unexpected end of JSON input") @@ -415,7 +244,7 @@ function ReservationContent() { throw new Error("응답 형식이 올바르지 않습니다: " + parseError.message); } - // ⭐ 성공 처리 + // 성공 처리 console.log("예약 상태 변경 성공:", updatedReservation); // 예약 목록 업데이트 @@ -429,11 +258,11 @@ function ReservationContent() { setSelectedDateReservations(updatedReservations); const statusText = newStatus === "APPROVED" ? "승인" : "거절"; - alert(`예약이 성공적으로 ${statusText}되었습니다.`); + toast.success(`예약이 성공적으로 ${statusText}되었습니다.`); } catch (error: any) { console.error("예약 상태 변경 실패:", error); - // ⭐ 네트워크 오류 처리 + // 네트워크 오류 처리 if (error.message.includes("Failed to fetch")) { if (!isRetry) { console.warn("네트워크 오류로 인한 자동 재시도..."); @@ -442,19 +271,19 @@ function ReservationContent() { }, 2000); // 2초 후 재시도 return; } else { - alert( + toast.error( "네트워크 연결을 확인해주세요. 백엔드 서버가 실행 중인지 확인하세요." ); } } else if (error.message.includes("JSON")) { - alert( + toast.error( "서버 응답 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요." ); } else { - alert(error.message || "상태 변경 중 오류가 발생했습니다."); + toast.error("상태 변경 중 오류가 발생했습니다."); } - // ⭐ 재시도였는데도 실패한 경우 + // 재시도였는데도 실패한 경우 if (isRetry) { console.error("재시도도 실패했습니다."); } @@ -468,6 +297,7 @@ function ReservationContent() { return (
+ {/* 왼쪽 예약 현황 영역 */}
+
+
+ + + + + + + + + + + + + + + {selectedDateReservations.length === 0 ? ( + + + + ) : ( + selectedDateReservations.map((reservation) => ( + + + + + + + + + + + )) + )} + +
+ 예약 시간 + + 환자명 + + 반려동물명 + + 진료 유형 + + 담당의 + + 예약 상태 + + 메모 + + 관리 +
+ 선택한 날짜에 예약이 없습니다. +
+
+ + + {reservation.reservationTime.substring(0, 5)} + +
+
+
+ {reservation.userName} +
+
+
+ {reservation.petName} +
+
+ + {reservation.type === "GENERAL" + ? "일반진료" + : "예방접종"} + + + {reservation.doctorName ? ( +
+ {reservation.doctorName} +
+ ) : ( + + )} +
+ + {reservation.status === "APPROVED" + ? "승인됨" + : reservation.status === "PENDING" + ? "대기중" + : "거절됨"} + + +
+ {reservation.symptom ? ( +
+ {reservation.symptom} +
+ ) : ( + + 메모 없음 + + )} +
+
+
+ {reservation.status === "PENDING" && ( + <> + + + + )} + {reservation.status !== "PENDING" && ( + + 처리완료 + + )} +
+
+
+
)}
@@ -494,14 +487,79 @@ function ReservationContent() { {/* 통계 패널 */}
+ + {/* 의사 배정 모달 */} + {assigningDoctor && ( +
+
+

+ + 담당 의료진 배정 +

+ +
+ + +

+ * 진료 가능한 의료진만 표시됩니다. +

+
+ +
+ + +
+
+
+ )} +
+ ); +} + +function LoadingSpinner() { + return ( +
+
+ 로딩 중...
); } export default function ReservationManagement() { return ( - Loading...}> - + }> + ); } diff --git a/frontend/src/app/auth-register/staff/page.tsx b/frontend/src/app/auth-register/staff/page.tsx index 146bb50..57bad8f 100644 --- a/frontend/src/app/auth-register/staff/page.tsx +++ b/frontend/src/app/auth-register/staff/page.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import HospitalCombobox from "@/components/HospitalCombobox"; import { debounce } from "lodash"; +import { toast } from "react-hot-toast"; export default function UserRegisterPage() { const router = useRouter(); @@ -188,9 +189,11 @@ export default function UserRegisterPage() { const data = await response.json(); // 회원가입 성공 + toast.success("회원가입에 성공했습니다."); router.push("/"); // 대시보드로 이동 } catch (error) { console.error("회원가입 정보 업데이트 오류:", error); + toast.error("회원가입 정보 업데이트에 실패했습니다."); setError( error instanceof Error ? error.message diff --git a/frontend/src/app/auth-register/user/page.tsx b/frontend/src/app/auth-register/user/page.tsx index ce37bc8..2385120 100644 --- a/frontend/src/app/auth-register/user/page.tsx +++ b/frontend/src/app/auth-register/user/page.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import HospitalCombobox from "@/components/HospitalCombobox"; import { debounce } from "lodash"; +import { toast } from "react-hot-toast"; export default function UserRegisterPage() { const router = useRouter(); @@ -194,9 +195,11 @@ export default function UserRegisterPage() { const data = await response.json(); // 회원가입 성공 + toast.success("회원가입에 성공했습니다."); router.push("/"); // 대시보드로 이동 } catch (error) { console.error("회원가입 정보 업데이트 오류:", error); + toast.error("회원가입 정보 업데이트에 실패했습니다."); setError( error instanceof Error ? error.message diff --git a/frontend/src/app/doctorpet/page.tsx b/frontend/src/app/doctorpet/page.tsx index 0595eda..b7b8250 100644 --- a/frontend/src/app/doctorpet/page.tsx +++ b/frontend/src/app/doctorpet/page.tsx @@ -3,6 +3,7 @@ import { Search, Eye, Trash2, Edit, Mars, Venus } from "lucide-react"; import { useState, useEffect } from "react"; import PetDetailModal from "@/components/doctorpetchange/DoctorChange"; +import { toast } from "react-hot-toast"; // Gender enum 추가 export enum Gender { @@ -485,10 +486,10 @@ const DoctorPetManagement = () => { ); setSelectedItems([]); setSelectAll(false); - alert("선택한 항목이 삭제되었습니다."); + toast.success("선택한 항목이 삭제되었습니다."); } catch (error) { console.error("Delete error:", error); - alert("삭제 중 오류가 발생했습니다."); + toast.error("삭제 중 오류가 발생했습니다."); } }; diff --git a/frontend/src/app/doctorpetvaccine/page.tsx b/frontend/src/app/doctorpetvaccine/page.tsx index 0dc9607..99b31fd 100644 --- a/frontend/src/app/doctorpetvaccine/page.tsx +++ b/frontend/src/app/doctorpetvaccine/page.tsx @@ -5,6 +5,7 @@ import { useState, useEffect, useMemo } from "react"; import PetVaccineRegist from "@/components/doctorpetvaccine/PetVaccineRegist"; import VaccineChange from "@/components/doctorpetvaccine/change/VaccineChange"; import { useUser } from "@/contexts/UserContext"; +import { toast } from "react-hot-toast"; // Gender enum 추가 enum Gender { @@ -418,7 +419,7 @@ const DoctorPetVaccineManagement = () => { const petVaccines = getAllVaccinesForPet(petId); if (petVaccines.length === 0) { - alert(`${petName}의 백신 기록이 없습니다.`); + toast.error(`${petName}의 백신 기록이 없습니다.`); return; } @@ -426,7 +427,7 @@ const DoctorPetVaccineManagement = () => { setIsHistoryModalOpen(true); } catch (error) { console.error("백신 기록 조회 중 오류:", error); - alert(`${petName}의 백신 기록을 불러오는데 실패했습니다.`); + toast.error(`${petName}의 백신 기록을 불러오는데 실패했습니다.`); } finally { setHistoryLoading(false); } @@ -470,7 +471,7 @@ const DoctorPetVaccineManagement = () => { // 403 에러 처리 추가 if (response.status === 403) { - alert("본인이 등록한 예방접종만 삭제할 수 있습니다."); + toast.error("본인이 등록한 예방접종만 삭제할 수 있습니다."); return; } @@ -486,10 +487,10 @@ const DoctorPetVaccineManagement = () => { ); setSelectedPetHistory(updatedVaccines); - alert("백신 정보가 성공적으로 삭제되었습니다."); + toast.success("백신 정보가 성공적으로 삭제되었습니다."); } catch (error) { console.error("Error deleting vaccine:", error); - alert("백신 정보 삭제에 실패했습니다"); + toast.error("백신 정보 삭제에 실패했습니다"); } }; @@ -593,14 +594,10 @@ const DoctorPetVaccineManagement = () => { await fetchPets(); setIsVaccineModalOpen(false); - alert("백신이 성공적으로 등록되었습니다."); + toast.success("백신이 성공적으로 등록되었습니다."); } catch (error) { console.error("Error registering vaccine:", error); - alert( - `접종 등록에 실패했습니다: ${ - error instanceof Error ? error.message : String(error) - }` - ); + toast.error("접종 등록에 실패했습니다"); } }; @@ -637,20 +634,20 @@ const DoctorPetVaccineManagement = () => { setIsVaccineModalOpen(true); } else { console.error("Pet not found"); - alert("반려동물 정보를 찾을 수 없습니다."); + toast.error("반려동물 정보를 찾을 수 없습니다."); } }; // 백신 등록 처리 함수 - 날짜 형식 처리 추가 const handleVaccineSubmit = async (data: any): Promise => { if (!selectedPet?.id) { - alert("반려동물 정보를 찾을 수 없습니다."); + toast.error("반려동물 정보를 찾을 수 없습니다."); return; } // 데이터 검증 if (!data.vaccinationDate) { - alert("접종일을 입력해주세요."); + toast.error("접종일을 입력해주세요."); return; } diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index dce9b08..1418ff3 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -3,7 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import ClientLayout from "@/components/ClientLayout"; import { UserProvider } from "@/contexts/UserContext"; - +import { Toaster } from "react-hot-toast"; const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"], @@ -30,6 +30,40 @@ export default function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} antialiased`} > + {children} diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index a345587..468f21b 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, Suspense } from "react"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { useUser } from "@/contexts/UserContext"; +import { toast } from "react-hot-toast"; // 로그인 컨텐츠 컴포넌트 function LoginContent() { @@ -43,12 +44,26 @@ function LoginContent() { const userData = await response.json(); // 실제 유저 데이터가 있을 때만 로그인 처리 if (userData && userData.id) { + toast.success("로그인에 성공했습니다."); login(); router.push("/"); } + } else { + // 에러 응답 처리 + const errorData = await response.json(); + throw new Error(errorData.message || "소셜 로그인에 실패했습니다."); } } catch (error) { console.error("로그인 상태 확인 실패:", error); + toast.error("로그인에 실패했습니다."); + + setError( + error instanceof Error + ? error.message + : "소셜 로그인에 실패했습니다. 관리자 승인 후 로그인이 가능합니다." + ); + // 에러 발생 시 로그인 페이지에 머무르기 + return; } }; @@ -79,6 +94,7 @@ function LoginContent() { const data = await response.json(); + toast.success("로그인에 성공했습니다."); // 로그인 성공 시 UserContext 업데이트 login(); @@ -86,9 +102,7 @@ function LoginContent() { router.push("/"); } catch (error) { console.error("로그인 오류:", error); - setError( - error instanceof Error ? error.message : "로그인에 실패했습니다." - ); + toast.error("로그인에 실패했습니다."); } finally { setIsLoading(false); } diff --git a/frontend/src/app/medicines/page.tsx b/frontend/src/app/medicines/page.tsx index 1b3f849..d4171a4 100644 --- a/frontend/src/app/medicines/page.tsx +++ b/frontend/src/app/medicines/page.tsx @@ -20,6 +20,7 @@ import { ChevronLeft, ChevronRight, } from "lucide-react"; +import { toast } from "react-hot-toast"; export default function MedicineInventoryPage() { const router = useRouter(); @@ -71,7 +72,7 @@ export default function MedicineInventoryPage() { setAllMedicines(data); } catch (error) { console.error("약품 목록 로드 오류:", error); - alert("약품 목록을 불러오는데 실패했습니다."); + toast.error("약품 목록을 불러오는데 실패했습니다."); } finally { setLoading(false); } @@ -176,13 +177,13 @@ export default function MedicineInventoryPage() { throw new Error("약품 삭제에 실패했습니다."); } - alert("약품이 삭제되었습니다."); + toast.success("약품이 삭제되었습니다."); // 전체 데이터 다시 로드 fetchAllMedicines(); } catch (error) { console.error("약품 삭제 오류:", error); - alert("약품 삭제에 실패했습니다."); + toast.error("약품 삭제에 실패했습니다."); } }; diff --git a/frontend/src/app/notices/[id]/edit/page.tsx b/frontend/src/app/notices/[id]/edit/page.tsx index b4c8703..3d72f50 100644 --- a/frontend/src/app/notices/[id]/edit/page.tsx +++ b/frontend/src/app/notices/[id]/edit/page.tsx @@ -4,6 +4,7 @@ import { useRouter, useParams } from "next/navigation"; import { useState, useEffect } from "react"; import { ArrowLeft } from "lucide-react"; import { useUser } from "@/contexts/UserContext"; +import { toast } from "react-hot-toast"; interface NoticeForm { title: string; @@ -54,7 +55,7 @@ export default function EditNoticePage() { useEffect(() => { // 관리자 권한 체크 if (!user || user.userRole !== "ROLE_ADMIN") { - alert("관리자만 접근할 수 있습니다."); + toast.error("관리자만 접근할 수 있습니다."); router.push("/notices"); return; } @@ -76,7 +77,7 @@ export default function EditNoticePage() { }); } catch (error) { console.error("공지사항 로드 오류:", error); - alert("공지사항을 불러오는데 실패했습니다."); + toast.error("공지사항을 불러오는데 실패했습니다."); router.push("/notices"); } }; @@ -102,11 +103,11 @@ export default function EditNoticePage() { throw new Error("공지사항 수정에 실패했습니다."); } - alert("공지사항이 수정되었습니다."); + toast.success("공지사항이 수정되었습니다."); router.push(`/notices/${params.id}`); } catch (error) { console.error("공지사항 수정 오류:", error); - alert("공지사항 수정에 실패했습니다."); + toast.error("공지사항 수정에 실패했습니다."); } finally { setSubmitting(false); } diff --git a/frontend/src/app/notices/[id]/page.tsx b/frontend/src/app/notices/[id]/page.tsx index 939f228..2246667 100644 --- a/frontend/src/app/notices/[id]/page.tsx +++ b/frontend/src/app/notices/[id]/page.tsx @@ -4,6 +4,7 @@ import { useUser } from "@/contexts/UserContext"; import { ArrowLeft, Pencil, Trash2 } from "lucide-react"; import { useParams, useRouter } from "next/navigation"; import { useEffect, useState } from "react"; +import { toast } from "react-hot-toast"; interface Notice { id: number; @@ -52,11 +53,7 @@ export default function NoticeDetailPage() { setNotice(data); } catch (error) { console.error("공지사항 로드 오류:", error); - alert( - error instanceof Error - ? error.message - : "공지사항을 불러오는데 실패했습니다." - ); + toast.error("공지사항을 불러오는데 실패했습니다."); router.push("/notices"); } }; @@ -115,11 +112,11 @@ export default function NoticeDetailPage() { throw new Error("공지사항 삭제에 실패했습니다."); } - alert("공지사항이 삭제되었습니다."); + toast.success("공지사항이 삭제되었습니다."); router.push("/notices"); } catch (error) { console.error("공지사항 삭제 오류:", error); - alert("공지사항 삭제에 실패했습니다."); + toast.error("공지사항 삭제에 실패했습니다."); } } }} diff --git a/frontend/src/app/notices/new/page.tsx b/frontend/src/app/notices/new/page.tsx index 8d57c3e..d2628e4 100644 --- a/frontend/src/app/notices/new/page.tsx +++ b/frontend/src/app/notices/new/page.tsx @@ -4,6 +4,7 @@ import { useRouter } from "next/navigation"; import { useState, useEffect } from "react"; import { ArrowLeft } from "lucide-react"; import { useUser } from "@/contexts/UserContext"; +import { toast } from "react-hot-toast"; interface NoticeForm { title: string; @@ -23,13 +24,13 @@ export default function CreateNoticePage() { useEffect(() => { // 마운트 시점에 권한 체크 if (!user) { - alert("로그인이 필요합니다."); + toast.error("로그인이 필요합니다."); router.push("/login"); return; } if (user.userRole !== "ROLE_ADMIN") { - alert("관리자만 공지사항을 작성할 수 있습니다."); + toast.error("관리자만 공지사항을 작성할 수 있습니다."); router.push("/notices"); } }, [user, router]); @@ -59,13 +60,13 @@ export default function CreateNoticePage() { } if (!user) { - alert("로그인이 필요합니다."); + toast.error("로그인이 필요합니다."); router.push("/login"); return; } if (user.userRole !== "ROLE_ADMIN") { - alert("관리자만 공지사항을 작성할 수 있습니다."); + toast.error("관리자만 공지사항을 작성할 수 있습니다."); router.push("/notices"); return; } @@ -137,7 +138,7 @@ export default function CreateNoticePage() { console.log("공지사항 등록 성공:", data); - alert("공지사항이 등록되었습니다."); + toast.success("공지사항이 등록되었습니다."); // 성공 시 목록으로 이동 (약간의 딜레이 후) setTimeout(() => { @@ -150,7 +151,7 @@ export default function CreateNoticePage() { ? error.message : "공지사항 등록에 실패했습니다."; setError(errorMessage); - alert(errorMessage); + toast.error("공지사항 등록에 실패했습니다."); } finally { setSubmitting(false); } diff --git a/frontend/src/app/ownerpet/page.tsx b/frontend/src/app/ownerpet/page.tsx index e8473eb..6b4e864 100644 --- a/frontend/src/app/ownerpet/page.tsx +++ b/frontend/src/app/ownerpet/page.tsx @@ -5,6 +5,7 @@ import { Camera, Heart, ImageOff, Edit, Clipboard, Trash2 } from "lucide-react"; import Pet from "@/components/pet/PetRegist"; import OwnerPetVaccineView from "@/components/ownerpetvaccine/OwnerPetVaccineView"; import { useUser } from "@/contexts/UserContext"; +import { toast } from "react-hot-toast"; interface Pet { id: number; @@ -286,7 +287,7 @@ const PetManagement = () => { setCurrentPage(1); } catch (error) { console.error("데이터 로딩 실패:", error); - alert("반려동물 목록을 불러오는데 실패했습니다."); + toast.error("반려동물 목록을 불러오는데 실패했습니다."); } finally { setLoading(false); } @@ -326,7 +327,7 @@ const PetManagement = () => { setShowPetModal(true); } catch (error) { console.error("상세 정보 로딩 실패:", error); - alert("반려동물 정보를 불러오는데 실패했습니다."); + toast.error("반려동물 정보를 불러오는데 실패했습니다."); } }; @@ -354,10 +355,10 @@ const PetManagement = () => { // 삭제 성공 시 목록 새로고침 fetchPets(); - alert("반려동물이 삭제되었습니다."); + toast.success("반려동물이 삭제되었습니다."); } catch (error) { console.error("삭제 실패:", error); - alert("반려동물 삭제에 실패했습니다."); + toast.error("반려동물 삭제에 실패했습니다."); } }; @@ -399,7 +400,7 @@ const PetManagement = () => { setShowVaccineModal(true); } catch (error) { console.error("백신 기록 조회 실패:", error); - alert("백신 기록을 불러오는데 실패했습니다."); + toast.error("백신 기록을 불러오는데 실패했습니다."); } finally { setVaccineLoading(false); } diff --git a/frontend/src/app/register/staff/page.tsx b/frontend/src/app/register/staff/page.tsx index da92d95..5d5b43e 100644 --- a/frontend/src/app/register/staff/page.tsx +++ b/frontend/src/app/register/staff/page.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import HospitalCombobox from "@/components/HospitalCombobox"; import { debounce } from "lodash"; +import { toast } from "react-hot-toast"; export default function StaffRegisterPage() { const router = useRouter(); @@ -289,7 +290,7 @@ export default function StaffRegisterPage() { } // 회원가입 성공 - alert( + toast.success( "회원가입이 완료되었습니다. 관리자 승인 후 이용 가능합니다. 로그인 페이지로 이동합니다." ); router.push("/login"); diff --git a/frontend/src/app/register/user/page.tsx b/frontend/src/app/register/user/page.tsx index f1dfd11..6555a57 100644 --- a/frontend/src/app/register/user/page.tsx +++ b/frontend/src/app/register/user/page.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import HospitalCombobox from "@/components/HospitalCombobox"; import { debounce } from "lodash"; +import { toast } from "react-hot-toast"; export default function UserRegisterPage() { const router = useRouter(); @@ -288,7 +289,7 @@ export default function UserRegisterPage() { } // 회원가입 성공 - alert("회원가입이 완료되었습니다. 로그인 페이지로 이동합니다."); + toast.success("회원가입이 완료되었습니다. 로그인 페이지로 이동합니다."); router.push("/login"); } catch (error) { console.error("회원가입 오류:", error); diff --git a/frontend/src/app/reservation/[id]/page.tsx b/frontend/src/app/reservation/[id]/page.tsx index 4a0f9de..ed050a3 100644 --- a/frontend/src/app/reservation/[id]/page.tsx +++ b/frontend/src/app/reservation/[id]/page.tsx @@ -13,6 +13,7 @@ import { User, Calendar, } from "lucide-react"; +import { toast } from "react-hot-toast"; interface Reservation { id: number; @@ -56,7 +57,7 @@ export default function ReservationDetailPage() { setReservation(data); } catch (error) { console.error("예약 정보 로드 오류:", error); - alert("예약 정보를 불러오는데 오류가 발생했습니다."); + toast.error("예약 정보를 불러오는데 오류가 발생했습니다."); } finally { setLoading(false); } diff --git a/frontend/src/app/reservation/page.tsx b/frontend/src/app/reservation/page.tsx index 619a643..2a8ba5c 100644 --- a/frontend/src/app/reservation/page.tsx +++ b/frontend/src/app/reservation/page.tsx @@ -6,6 +6,7 @@ import { useUser } from "@/contexts/UserContext"; import { formatDate } from "@/utils/formatDate"; import { Calendar as CalendarIcon, Clock, Dog, FileText } from "lucide-react"; import Link from "next/link"; +import { toast } from "react-hot-toast"; // 반려동물 타입 interface Pet { @@ -69,7 +70,7 @@ export default function CreateReservation() { } } catch (error) { console.error("반려동물 정보 로드 오류:", error); - alert("반려동물 정보를 불러오는데 실패했습니다."); + toast.error("반려동물 정보를 불러오는데 실패했습니다."); } finally { setLoading(false); } @@ -112,7 +113,7 @@ export default function CreateReservation() { } } catch (error) { console.error("예약 가능 시간 로드 오류:", error); - alert("예약 가능 시간을 불러오는데 실패했습니다."); + toast.error("예약 가능 시간을 불러오는데 실패했습니다."); } finally { setLoading(false); } @@ -132,12 +133,12 @@ export default function CreateReservation() { } if (!selectedPet || !selectedDate || !selectedTime || !type) { - alert("모든 필수 항목을 입력해주세요."); + toast.error("모든 필수 항목을 입력해주세요."); return; } if (!user?.id) { - alert("사용자 정보가 없습니다."); + toast.error("사용자 정보가 없습니다."); return; } @@ -184,12 +185,11 @@ export default function CreateReservation() { throw new Error(errorMessage); } - console.log("예약 등록 성공"); - alert("예약 등록에 성공했습니다."); + toast.success("예약 등록에 성공했습니다."); router.push("/"); } catch (error: any) { console.error("예약 등록 오류:", error); - alert(error.message || "예약 등록 중 오류가 발생했습니다."); + toast.error("예약 등록 중 오류가 발생했습니다."); } finally { // 2초 후에 버튼 다시 활성화 (안전장치) setTimeout(() => { diff --git a/frontend/src/app/staff-management/page.tsx b/frontend/src/app/staff-management/page.tsx index 765db9f..1075d40 100644 --- a/frontend/src/app/staff-management/page.tsx +++ b/frontend/src/app/staff-management/page.tsx @@ -10,7 +10,6 @@ export default function StaffManagementPage() { const router = useRouter(); useEffect(() => { - // 권한 체크 if ( !user || (user.userRole !== "ROLE_STAFF" && user.userRole !== "ROLE_ADMIN") diff --git a/frontend/src/components/ClientLayout.tsx b/frontend/src/components/ClientLayout.tsx index adf6db3..30e3712 100644 --- a/frontend/src/components/ClientLayout.tsx +++ b/frontend/src/components/ClientLayout.tsx @@ -2,6 +2,7 @@ import { usePathname } from "next/navigation"; import { ReactNode } from "react"; +import { Toaster } from "react-hot-toast"; import Header from "./Header"; import Sidebar from "./Sidebar"; diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index b107233..72e035b 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -700,7 +700,6 @@ export default function Header() { {user?.name || "사용자"} - {user?.userRole === "ROLE_ADMIN" && " 관리자"} {user?.userRole === "ROLE_STAFF" && " 의료진"} {user?.userRole === "ROLE_USER" && " 보호자"} diff --git a/frontend/src/components/ProfileSidebar.tsx b/frontend/src/components/ProfileSidebar.tsx index 43fef0a..813c18b 100644 --- a/frontend/src/components/ProfileSidebar.tsx +++ b/frontend/src/components/ProfileSidebar.tsx @@ -1,22 +1,23 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useUser } from "@/contexts/UserContext"; import { User, Lock, Calendar } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -type OnlineStatus = "online" | "away" | "offline"; +type UserStatus = "ON_DUTY" | "AWAY" | "OFFLINE"; export default function ProfileSidebar() { const { user } = useUser(); - const [status, setStatus] = useState("online"); + const [status, setStatus] = useState(); + const [isStatusLoading, setIsStatusLoading] = useState(false); const pathname = usePathname(); const statusInfo = { - online: { text: "온라인", color: "bg-green-500" }, - away: { text: "자리 비움", color: "bg-yellow-500" }, - offline: { text: "오프라인", color: "bg-red-500" }, + ON_DUTY: { text: "근무중", color: "bg-green-500" }, + AWAY: { text: "자리 비움", color: "bg-yellow-500" }, + OFFLINE: { text: "오프라인", color: "bg-red-500" }, }; const menuItems = [ @@ -25,6 +26,84 @@ export default function ProfileSidebar() { const isActive = (path: string) => pathname === path; + // 내 상태 조회 + const fetchMyStatus = async () => { + if (!user?.id) return; + + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/users/me/status`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + } + ); + + if (response.ok) { + const status = await response.json(); + if ( + status && + (status === "ON_DUTY" || status === "AWAY" || status === "OFFLINE") + ) { + setStatus(status); + } + } else { + console.error("상태 조회 실패:", response.status); + } + } catch (error) { + console.error("상태 조회 중 오류 발생:", error); + } + }; + + // 내 상태 변경 + const updateMyStatus = async (newStatus: UserStatus) => { + setIsStatusLoading(true); + + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/users/me/status?status=${newStatus}`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + // 필요한 경우 Authorization 헤더 추가 + // 'Authorization': `Bearer ${token}`, + }, + credentials: "include", // 쿠키 기반 인증을 위해 추가 + } + ); + + if (response.ok) { + setStatus(newStatus); + const message = await response.text(); + console.log(message); // "상태 변경이 반영되었습니다." + } else { + console.error("상태 변경 실패:", response.status); + // 실패 시 이전 상태로 되돌리기 + await fetchMyStatus(); + } + } catch (error) { + console.error("상태 변경 중 오류 발생:", error); + // 실패 시 이전 상태로 되돌리기 + await fetchMyStatus(); + } finally { + setIsStatusLoading(false); + } + }; + + // 컴포넌트 마운트 시 현재 상태 조회 + useEffect(() => { + fetchMyStatus(); + }, []); + + const handleStatusChange = (e: React.ChangeEvent) => { + const newStatus = e.target.value as UserStatus; + updateMyStatus(newStatus); + }; + return (