Changeset f6ed6e4
- Timestamp:
- 08/22/26 19:00:38 (6 hours ago)
- Branches:
- master
- Parents:
- ae83647
- Files:
-
- 3 added
- 14 edited
-
petify-backend/sql/ddl.sql (modified) (3 diffs)
-
petify-backend/sql/dml.sql (modified) (1 diff)
-
petify-backend/src/main/java/com/petify/petify/api/VetClinicsController.java (modified) (4 diffs)
-
petify-backend/src/main/java/com/petify/petify/config/SecurityConfig.java (modified) (1 diff)
-
petify-backend/src/main/java/com/petify/petify/domain/UserReview.java (modified) (2 diffs)
-
petify-backend/src/main/java/com/petify/petify/domain/VetClinic.java (modified) (2 diffs)
-
petify-backend/src/main/java/com/petify/petify/dto/CreateReviewRequest.java (modified) (2 diffs)
-
petify-backend/src/main/java/com/petify/petify/dto/UpdateClinicScheduleRequest.java (added)
-
petify-backend/src/main/java/com/petify/petify/dto/VetClinicDTO.java (modified) (2 diffs)
-
petify-backend/src/main/java/com/petify/petify/service/AppointmentService.java (modified) (7 diffs)
-
petify-backend/src/main/java/com/petify/petify/service/ReviewService.java (modified) (5 diffs)
-
petify-backend/src/main/resources/db/migration/V11__Add_user_review_interaction_type.sql (added)
-
petify-backend/src/main/resources/db/migration/V12__Add_clinic_work_schedule.sql (added)
-
petify-frontend/src/api/profile.ts (modified) (2 diffs)
-
petify-frontend/src/api/reviews.ts (modified) (2 diffs)
-
petify-frontend/src/views/ClinicDashboardView.vue (modified) (19 diffs)
-
petify-frontend/src/views/OwnerProfileView.vue (modified) (7 diffs)
Legend:
- Unmodified
- Added
- Removed
-
petify-backend/sql/ddl.sql
rae83647 rf6ed6e4 107 107 city VARCHAR(80) NOT NULL, 108 108 address VARCHAR(200) NOT NULL, 109 work_days VARCHAR(120), 110 start_time TIME, 111 end_time TIME, 109 112 user_id BIGINT UNIQUE, 110 113 application_id BIGINT UNIQUE, … … 227 230 review_id BIGINT, 228 231 target_user_id BIGINT NOT NULL, 232 interaction_type VARCHAR(40) NOT NULL, 229 233 230 234 CONSTRAINT user_reviews_PK PRIMARY KEY (review_id), … … 234 238 CONSTRAINT user_reviews_target_FK FOREIGN KEY (target_user_id) 235 239 REFERENCES users(user_id) 236 ON DELETE RESTRICT 240 ON DELETE RESTRICT, 241 CONSTRAINT user_reviews_interaction_type_CHK CHECK ( 242 interaction_type IN ('EVENT', 'PERSONAL_INTERACTION', 'ONLINE', 'PHONE_CALL', 'OTHER') 243 ) 237 244 ); 238 245 -
petify-backend/sql/dml.sql
rae83647 rf6ed6e4 116 116 VALUES (1, (SELECT clinic_id FROM vet_clinics WHERE name='Happy Paws Clinic')); 117 117 118 INSERT INTO user_reviews (review_id, target_user_id ) VALUES119 (2, (SELECT user_id FROM users WHERE username='client.igor') ),120 (3, (SELECT user_id FROM users WHERE username='client.mila') );118 INSERT INTO user_reviews (review_id, target_user_id, interaction_type) VALUES 119 (2, (SELECT user_id FROM users WHERE username='client.igor'), 'PERSONAL_INTERACTION'), 120 (3, (SELECT user_id FROM users WHERE username='client.mila'), 'ONLINE'); 121 121 122 122 INSERT INTO health_records (animal_id, appointment_id, type, description, date) VALUES -
petify-backend/src/main/java/com/petify/petify/api/VetClinicsController.java
rae83647 rf6ed6e4 2 2 3 3 import com.petify.petify.domain.VetClinic; 4 import com.petify.petify.dto.UpdateClinicScheduleRequest; 4 5 import com.petify.petify.dto.VetClinicDTO; 5 6 import com.petify.petify.repo.VetClinicRepository; 6 7 import org.springframework.http.ResponseEntity; 7 8 import org.springframework.web.bind.annotation.GetMapping; 9 import org.springframework.web.bind.annotation.PutMapping; 8 10 import org.springframework.web.bind.annotation.RequestHeader; 9 11 import org.springframework.web.bind.annotation.RequestMapping; 12 import org.springframework.web.bind.annotation.RequestBody; 10 13 import org.springframework.web.bind.annotation.RestController; 11 14 15 import java.time.LocalTime; 16 import java.time.format.DateTimeParseException; 17 import java.util.Arrays; 18 import java.util.Set; 12 19 import java.util.List; 13 20 import java.util.Map; … … 17 24 @RequestMapping("/api/clinics") 18 25 public class VetClinicsController { 26 27 private static final Set<String> ALLOWED_WORK_DAYS = Set.of( 28 "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY" 29 ); 19 30 20 31 private final VetClinicRepository vetClinicRepository; … … 40 51 } 41 52 53 @PutMapping("/my/schedule") 54 public ResponseEntity<?> updateMyClinicSchedule( 55 @RequestHeader("X-User-Id") Long userId, 56 @RequestBody UpdateClinicScheduleRequest request) { 57 try { 58 VetClinic clinic = vetClinicRepository.findByUserId(userId) 59 .orElseThrow(() -> new RuntimeException("User is not linked to a clinic")); 60 61 String workDays = normalizeWorkDays(request.getWorkDays()); 62 LocalTime startTime = parseTime(request.getStartTime(), "Start time"); 63 LocalTime endTime = parseTime(request.getEndTime(), "End time"); 64 65 if (!startTime.isBefore(endTime)) { 66 throw new RuntimeException("Start time must be before end time"); 67 } 68 69 if (java.time.Duration.between(startTime, endTime).toMinutes() < 30) { 70 throw new RuntimeException("Clinic schedule must contain at least one 30-minute slot"); 71 } 72 73 clinic.setWorkDays(workDays); 74 clinic.setStartTime(startTime); 75 clinic.setEndTime(endTime); 76 77 return ResponseEntity.ok(mapToDTO(vetClinicRepository.save(clinic))); 78 } catch (RuntimeException e) { 79 return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); 80 } 81 } 82 42 83 private VetClinicDTO mapToDTO(VetClinic clinic) { 43 84 return new VetClinicDTO( … … 45 86 clinic.getName(), 46 87 clinic.getCity(), 47 clinic.getAddress() 88 clinic.getAddress(), 89 clinic.getWorkDays(), 90 clinic.getStartTime() != null ? clinic.getStartTime().toString() : null, 91 clinic.getEndTime() != null ? clinic.getEndTime().toString() : null 48 92 ); 93 } 94 95 private String normalizeWorkDays(String workDays) { 96 if (workDays == null || workDays.isBlank()) { 97 throw new RuntimeException("Choose at least one working day"); 98 } 99 100 List<String> normalized = Arrays.stream(workDays.split(",")) 101 .map(String::trim) 102 .filter(day -> !day.isBlank()) 103 .map(String::toUpperCase) 104 .distinct() 105 .toList(); 106 107 if (normalized.isEmpty()) { 108 throw new RuntimeException("Choose at least one working day"); 109 } 110 111 for (String day : normalized) { 112 if (!ALLOWED_WORK_DAYS.contains(day)) { 113 throw new RuntimeException("Invalid working day: " + day); 114 } 115 } 116 117 return String.join(",", normalized); 118 } 119 120 private LocalTime parseTime(String value, String label) { 121 if (value == null || value.isBlank()) { 122 throw new RuntimeException(label + " is required"); 123 } 124 125 try { 126 return LocalTime.parse(value); 127 } catch (DateTimeParseException e) { 128 throw new RuntimeException(label + " must be in HH:mm format"); 129 } 49 130 } 50 131 } -
petify-backend/src/main/java/com/petify/petify/config/SecurityConfig.java
rae83647 rf6ed6e4 96 96 .requestMatchers(HttpMethod.GET, "/api/clinics").permitAll() 97 97 .requestMatchers(HttpMethod.GET, "/api/clinics/my").permitAll() 98 .requestMatchers(HttpMethod.PUT, "/api/clinics/my/schedule").permitAll() 98 99 .requestMatchers(HttpMethod.GET, "/api/notifications/my").permitAll() 99 100 -
petify-backend/src/main/java/com/petify/petify/domain/UserReview.java
rae83647 rf6ed6e4 19 19 private Long targetUserId; 20 20 21 @Column(name = "interaction_type", nullable = false, length = 40) 22 private String interactionType; 23 21 24 public UserReview() {} 22 25 23 public UserReview(Review review, Long targetUserId ) {26 public UserReview(Review review, Long targetUserId, String interactionType) { 24 27 this.review = review; // DO NOT set reviewId 25 28 this.targetUserId = targetUserId; 29 this.interactionType = interactionType; 26 30 } 27 31 … … 51 55 this.targetUserId = targetUserId; 52 56 } 57 58 public String getInteractionType() { 59 return interactionType; 60 } 61 62 public void setInteractionType(String interactionType) { 63 this.interactionType = interactionType; 64 } 53 65 } 54 66 -
petify-backend/src/main/java/com/petify/petify/domain/VetClinic.java
rae83647 rf6ed6e4 4 4 import lombok.Getter; 5 5 import lombok.Setter; 6 7 import java.time.LocalTime; 6 8 7 9 @Setter … … 37 39 private String address; 38 40 41 @Column(name = "work_days") 42 private String workDays; 43 44 @Column(name = "start_time") 45 private LocalTime startTime; 46 47 @Column(name = "end_time") 48 private LocalTime endTime; 49 39 50 public VetClinic() {} 40 51 -
petify-backend/src/main/java/com/petify/petify/dto/CreateReviewRequest.java
rae83647 rf6ed6e4 9 9 private Integer rating; 10 10 private String comment; 11 private String interactionType; 11 12 12 13 public CreateReviewRequest() { … … 18 19 } 19 20 21 public CreateReviewRequest(Integer rating, String comment, String interactionType) { 22 this.rating = rating; 23 this.comment = comment; 24 this.interactionType = interactionType; 25 } 26 20 27 } -
petify-backend/src/main/java/com/petify/petify/dto/VetClinicDTO.java
rae83647 rf6ed6e4 11 11 private String city; 12 12 private String address; 13 private String workDays; 14 private String startTime; 15 private String endTime; 16 private Boolean scheduleComplete; 13 17 14 18 public VetClinicDTO() { … … 22 26 } 23 27 28 public VetClinicDTO( 29 Long clinicId, 30 String name, 31 String city, 32 String address, 33 String workDays, 34 String startTime, 35 String endTime 36 ) { 37 this.clinicId = clinicId; 38 this.name = name; 39 this.city = city; 40 this.address = address; 41 this.workDays = workDays; 42 this.startTime = startTime; 43 this.endTime = endTime; 44 this.scheduleComplete = workDays != null && !workDays.isBlank() && startTime != null && endTime != null; 45 } 46 24 47 } 25 48 -
petify-backend/src/main/java/com/petify/petify/service/AppointmentService.java
rae83647 rf6ed6e4 31 31 import java.time.LocalTime; 32 32 import java.time.format.DateTimeFormatter; 33 import java.util.Arrays; 33 34 import java.util.List; 34 35 import java.util.Set; … … 38 39 39 40 private static final Logger logger = LoggerFactory.getLogger(AppointmentService.class); 40 private static final LocalTime CLINIC_DAY_START = LocalTime.of(9, 0);41 private static final LocalTime CLINIC_DAY_END = LocalTime.of(17, 0);42 41 private static final int SLOT_MINUTES = 30; 43 42 private static final List<String> NON_BLOCKING_STATUSES = List.of("CANCELLED", "CANCELED", "NO_SHOW"); … … 84 83 } 85 84 86 if (!vetClinicRepository.existsById(request.getClinicId())) { 87 throw new RuntimeException("Vet clinic not found"); 88 } 85 VetClinic clinic = vetClinicRepository.findById(request.getClinicId()) 86 .orElseThrow(() -> new RuntimeException("Vet clinic not found")); 89 87 90 88 LocalDateTime appointmentTime = LocalDateTime.parse(request.getDateTime()); 91 if (!isClinicSlotAvailable( request.getClinicId(), appointmentTime)) {89 if (!isClinicSlotAvailable(clinic, appointmentTime)) { 92 90 throw new RuntimeException("Selected appointment slot is no longer available"); 93 91 } … … 210 208 } 211 209 212 if (!vetClinicRepository.existsById(clinicId)) { 213 throw new RuntimeException("Vet clinic not found"); 214 } 215 216 LocalDateTime dayStart = date.atTime(CLINIC_DAY_START); 217 LocalDateTime dayEnd = date.atTime(CLINIC_DAY_END); 210 VetClinic clinic = vetClinicRepository.findById(clinicId) 211 .orElseThrow(() -> new RuntimeException("Vet clinic not found")); 212 requireCompleteSchedule(clinic); 213 214 if (!isWorkingDay(clinic, date)) { 215 return List.of(); 216 } 217 218 LocalDateTime dayStart = date.atTime(clinic.getStartTime()); 219 LocalDateTime dayEnd = date.atTime(clinic.getEndTime()); 218 220 Set<LocalDateTime> bookedSlots = appointmentRepository 219 221 .findByClinicIdAndDateTimeBetweenAndStatusNotInOrderByDateTimeAsc( … … 257 259 } 258 260 259 if (!vetClinicRepository.existsById(clinicId)) { 260 throw new RuntimeException("Vet clinic not found"); 261 } 262 263 LocalDateTime dayStart = date.atTime(CLINIC_DAY_START); 264 LocalDateTime dayEnd = date.atTime(CLINIC_DAY_END); 261 VetClinic clinic = vetClinicRepository.findById(clinicId) 262 .orElseThrow(() -> new RuntimeException("Vet clinic not found")); 263 requireCompleteSchedule(clinic); 264 265 if (!isWorkingDay(clinic, date)) { 266 return List.of(); 267 } 268 269 LocalDateTime dayStart = date.atTime(clinic.getStartTime()); 270 LocalDateTime dayEnd = date.atTime(clinic.getEndTime()); 265 271 266 272 return unavailableSlotRepository … … 282 288 } 283 289 284 if (!vetClinicRepository.existsById(clinicId)) { 285 throw new RuntimeException("Vet clinic not found"); 286 } 290 VetClinic clinic = vetClinicRepository.findById(clinicId) 291 .orElseThrow(() -> new RuntimeException("Vet clinic not found")); 287 292 288 293 LocalDateTime slotTime = LocalDateTime.parse(request.getDateTime()); 289 if (!isValidWorkingSlot( slotTime)) {290 throw new RuntimeException("Unavailable slot must be a future 30-minute slot between 09:00 and 17:00");294 if (!isValidWorkingSlot(clinic, slotTime)) { 295 throw new RuntimeException("Unavailable slot must be a future 30-minute slot inside your clinic working schedule"); 291 296 } 292 297 … … 332 337 } 333 338 334 private boolean isClinicSlotAvailable( Long clinicId, LocalDateTime appointmentTime) {335 if (!isValidWorkingSlot( appointmentTime)) {339 private boolean isClinicSlotAvailable(VetClinic clinic, LocalDateTime appointmentTime) { 340 if (!isValidWorkingSlot(clinic, appointmentTime)) { 336 341 return false; 337 342 } 338 343 339 344 return !appointmentRepository.existsByClinicIdAndDateTimeAndStatusNotIn( 340 clinic Id,345 clinic.getClinicId(), 341 346 appointmentTime, 342 347 NON_BLOCKING_STATUSES 343 ) && !unavailableSlotRepository.existsByClinicIdAndDateTime(clinicId, appointmentTime); 344 } 345 346 private boolean isValidWorkingSlot(LocalDateTime appointmentTime) { 348 ) && !unavailableSlotRepository.existsByClinicIdAndDateTime(clinic.getClinicId(), appointmentTime); 349 } 350 351 private boolean isValidWorkingSlot(VetClinic clinic, LocalDateTime appointmentTime) { 352 requireCompleteSchedule(clinic); 347 353 LocalTime time = appointmentTime.toLocalTime(); 348 return !appointmentTime.isBefore(LocalDateTime.now()) 349 && !time.isBefore(CLINIC_DAY_START) 350 && time.isBefore(CLINIC_DAY_END) 351 && appointmentTime.getMinute() % SLOT_MINUTES == 0 354 long minutesFromStart = java.time.Duration.between(clinic.getStartTime(), time).toMinutes(); 355 356 return isWorkingDay(clinic, appointmentTime.toLocalDate()) 357 && !appointmentTime.isBefore(LocalDateTime.now()) 358 && !time.isBefore(clinic.getStartTime()) 359 && time.isBefore(clinic.getEndTime()) 360 && minutesFromStart >= 0 361 && minutesFromStart % SLOT_MINUTES == 0 352 362 && appointmentTime.getSecond() == 0 353 363 && appointmentTime.getNano() == 0; 364 } 365 366 private void requireCompleteSchedule(VetClinic clinic) { 367 if (clinic.getWorkDays() == null || clinic.getWorkDays().isBlank() 368 || clinic.getStartTime() == null 369 || clinic.getEndTime() == null) { 370 throw new RuntimeException("This clinic has not added working days and hours yet"); 371 } 372 373 if (!clinic.getStartTime().isBefore(clinic.getEndTime())) { 374 throw new RuntimeException("Clinic working hours are invalid"); 375 } 376 } 377 378 private boolean isWorkingDay(VetClinic clinic, LocalDate date) { 379 String day = date.getDayOfWeek().name(); 380 return Arrays.stream(clinic.getWorkDays().split(",")) 381 .map(String::trim) 382 .map(String::toUpperCase) 383 .anyMatch(day::equals); 354 384 } 355 385 -
petify-backend/src/main/java/com/petify/petify/service/ReviewService.java
rae83647 rf6ed6e4 19 19 20 20 import java.time.LocalDateTime; 21 import java.util.Set; 21 22 import java.util.List; 22 23 import java.util.stream.Collectors; … … 26 27 27 28 private static final Logger logger = LoggerFactory.getLogger(ReviewService.class); 29 private static final Set<String> USER_REVIEW_INTERACTION_TYPES = Set.of( 30 "EVENT", 31 "PERSONAL_INTERACTION", 32 "ONLINE", 33 "PHONE_CALL", 34 "OTHER" 35 ); 28 36 29 37 private final ReviewRepository reviewRepository; … … 58 66 public ReviewDTO createReview(Long reviewerId, Long targetUserId, CreateReviewRequest request) { 59 67 logger.info("==== START createReview ===="); 60 // Validate rating 61 if (request.getRating() == null || request.getRating() < 1 || request.getRating() > 5) { 62 logger.error(" VALIDATION FAILED: Invalid rating: {}", request.getRating()); 63 throw new RuntimeException("Rating must be between 1 and 5"); 64 } 68 validateReviewRequest(request); 69 String interactionType = normalizeUserReviewInteractionType(request.getInteractionType()); 65 70 66 71 … … 118 123 119 124 userReview.setTargetUserId(targetUserId); 125 userReview.setInteractionType(interactionType); 120 126 121 127 // Save UserReview to database with flush … … 285 291 } 286 292 293 private String normalizeUserReviewInteractionType(String interactionType) { 294 if (interactionType == null || interactionType.isBlank()) { 295 throw new RuntimeException("Please choose how you interacted with this user"); 296 } 297 298 String normalized = interactionType.trim().toUpperCase(); 299 if (!USER_REVIEW_INTERACTION_TYPES.contains(normalized)) { 300 throw new RuntimeException("Interaction type must be one of: EVENT, PERSONAL_INTERACTION, ONLINE, PHONE_CALL, OTHER"); 301 } 302 303 return normalized; 304 } 305 287 306 private User getReviewer(Long reviewerId) { 288 307 return userRepository.findById(reviewerId) -
petify-frontend/src/api/profile.ts
rae83647 rf6ed6e4 35 35 city: string 36 36 address: string 37 workDays?: string 38 startTime?: string 39 endTime?: string 40 scheduleComplete?: boolean 37 41 } 38 42 … … 464 468 } 465 469 470 export async function updateMyClinicSchedule( 471 userId: number, 472 data: { 473 workDays: string 474 startTime: string 475 endTime: string 476 } 477 ): Promise<VetClinic> { 478 const url = joinUrl(getBaseUrl(), `/api/clinics/my/schedule`) 479 const response = await fetch(url, { 480 method: 'PUT', 481 headers: { 482 'Content-Type': 'application/json', 483 'X-User-Id': String(userId), 484 }, 485 body: JSON.stringify(data), 486 }) 487 488 if (!response.ok) { 489 const error = await response.json() 490 throw new Error(error.error || `Failed to update clinic schedule: ${response.statusText}`) 491 } 492 493 return await response.json() 494 } 495 466 496 export async function getClinicAvailableSlots(clinicId: number, date: string): Promise<AppointmentSlot[]> { 467 497 const url = joinUrl(getBaseUrl(), `/api/appointments/clinics/${clinicId}/available-slots?date=${encodeURIComponent(date)}`) -
petify-frontend/src/api/reviews.ts
rae83647 rf6ed6e4 20 20 } 21 21 22 export type UserReviewInteractionType = 23 | 'EVENT' 24 | 'PERSONAL_INTERACTION' 25 | 'ONLINE' 26 | 'PHONE_CALL' 27 | 'OTHER' 28 22 29 export async function createReview( 23 30 targetUserId: number, 24 31 userId: number, 25 32 rating: number, 26 comment: string 33 comment: string, 34 interactionType: UserReviewInteractionType 27 35 ): Promise<Review> { 28 36 const url = joinUrl(getBaseUrl(), `/api/reviews/${targetUserId}`) … … 36 44 rating, 37 45 comment, 46 interactionType, 38 47 }), 39 48 }) -
petify-frontend/src/views/ClinicDashboardView.vue
rae83647 rf6ed6e4 8 8 <p v-if="clinic" class="clinic-subtitle">{{ clinic.name }} - {{ clinic.city }}, {{ clinic.address }}</p> 9 9 </div> 10 <div class="toolbar">10 <div v-if="canUseSchedule" class="toolbar"> 11 11 <button class="btn btn-outline-secondary btn-sm" type="button" @click="goToPreviousDay"> 12 12 Previous day … … 34 34 35 35 <template v-else> 36 <section v-if="clinic && !canUseSchedule" class="setup-panel"> 37 <div> 38 <p class="eyebrow">Required setup</p> 39 <h2>Add your working schedule</h2> 40 <p class="setup-copy"> 41 Users can book appointment slots only after your clinic adds working days and opening hours. 42 </p> 43 </div> 44 45 <form class="schedule-form" @submit.prevent="saveSchedule"> 46 <fieldset class="days-fieldset"> 47 <legend>Working days</legend> 48 <label v-for="day in workDayOptions" :key="day.value" class="day-check"> 49 <input 50 v-model="scheduleForm.workDays" 51 type="checkbox" 52 :value="day.value" 53 /> 54 <span>{{ day.label }}</span> 55 </label> 56 </fieldset> 57 58 <div class="time-grid"> 59 <div class="form-group"> 60 <label class="form-label" for="startTime">Start time</label> 61 <input id="startTime" v-model="scheduleForm.startTime" class="form-control" type="time" required /> 62 </div> 63 <div class="form-group"> 64 <label class="form-label" for="endTime">End time</label> 65 <input id="endTime" v-model="scheduleForm.endTime" class="form-control" type="time" required /> 66 </div> 67 </div> 68 69 <div v-if="scheduleSetupError" class="alert alert-danger">{{ scheduleSetupError }}</div> 70 <button class="btn btn-primary" type="submit" :disabled="isSavingSchedule"> 71 {{ isSavingSchedule ? 'Saving...' : 'Save schedule' }} 72 </button> 73 </form> 74 </section> 75 76 <template v-else> 36 77 <div class="summary-strip"> 37 78 <div class="summary-item"> … … 59 100 60 101 <div class="slot-grid"> 102 <div v-if="daySlots.length === 0" class="panel-empty full-width"> 103 This is not a working day for your clinic. 104 </div> 61 105 <div 62 106 v-for="slot in daySlots" … … 131 175 </aside> 132 176 </div> 177 </template> 133 178 </template> 134 179 </section> … … 147 192 getMyNotifications, 148 193 markMyClinicAppointmentNoShow, 194 updateMyClinicSchedule, 149 195 type AppNotification, 150 196 type AppointmentSlot, … … 174 220 const notifications = ref<AppNotification[]>([]) 175 221 const isLoading = ref(false) 222 const isSavingSchedule = ref(false) 176 223 const updatingAppointmentId = ref<number | null>(null) 177 224 const accessError = ref('') 178 225 const scheduleError = ref('') 226 const scheduleSetupError = ref('') 179 227 const notificationsError = ref('') 180 228 const NON_BLOCKING_STATUSES = new Set(['CANCELLED', 'CANCELED', 'NO_SHOW']) … … 182 230 183 231 const canUseDashboard = computed(() => auth.isAuthenticated && auth.user?.userType === 'CLINIC') 232 const canUseSchedule = computed(() => Boolean(clinic.value?.scheduleComplete)) 233 const scheduleForm = ref({ 234 workDays: [] as string[], 235 startTime: '09:00', 236 endTime: '17:00', 237 }) 238 const workDayOptions = [ 239 { value: 'MONDAY', label: 'Mon' }, 240 { value: 'TUESDAY', label: 'Tue' }, 241 { value: 'WEDNESDAY', label: 'Wed' }, 242 { value: 'THURSDAY', label: 'Thu' }, 243 { value: 'FRIDAY', label: 'Fri' }, 244 { value: 'SATURDAY', label: 'Sat' }, 245 { value: 'SUNDAY', label: 'Sun' }, 246 ] 184 247 185 248 const appointmentsByDateTime = computed(() => { … … 205 268 const now = new Date() 206 269 207 for (let hour = 9; hour < 17; hour += 1) { 208 for (const minute of [0, 30]) { 209 const dateTime = `${selectedDate.value}T${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}` 210 const label = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}` 270 for (const time of getClinicTimesForDate(selectedDate.value)) { 271 const dateTime = `${selectedDate.value}T${time}` 272 const label = time 211 273 const key = normalizeDateTime(dateTime) 212 274 const appointment = appointmentsByDateTime.value.get(key) … … 252 314 }) 253 315 } 254 }255 316 } 256 317 … … 274 335 const slots: AppointmentSlot[] = [] 275 336 276 for (let hour = 9; hour < 17; hour += 1) { 277 for (const minute of [0, 30]) { 278 const dateTime = `${date}T${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}` 337 for (const time of getClinicTimesForDate(date)) { 338 const dateTime = `${date}T${time}` 279 339 const key = normalizeDateTime(dateTime) 280 340 if (new Date(dateTime).getTime() < now.getTime()) continue … … 282 342 slots.push({ 283 343 dateTime, 284 label: `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`,344 label: time, 285 345 }) 286 }287 346 } 288 347 … … 291 350 292 351 async function loadSchedule() { 293 if (!auth.user?.userId || !canUseDashboard.value || ! selectedDate.value) return352 if (!auth.user?.userId || !canUseDashboard.value || !canUseSchedule.value || !selectedDate.value) return 294 353 295 354 const requestId = ++latestScheduleRequest … … 319 378 } 320 379 380 async function saveSchedule() { 381 if (!auth.user?.userId) return 382 if (scheduleForm.value.workDays.length === 0) { 383 scheduleSetupError.value = 'Choose at least one working day' 384 return 385 } 386 387 if (!scheduleForm.value.startTime || !scheduleForm.value.endTime) { 388 scheduleSetupError.value = 'Start and end time are required' 389 return 390 } 391 392 if (scheduleForm.value.startTime >= scheduleForm.value.endTime) { 393 scheduleSetupError.value = 'Start time must be before end time' 394 return 395 } 396 397 try { 398 isSavingSchedule.value = true 399 scheduleSetupError.value = '' 400 clinic.value = await updateMyClinicSchedule(auth.user.userId, { 401 workDays: scheduleForm.value.workDays.join(','), 402 startTime: scheduleForm.value.startTime, 403 endTime: scheduleForm.value.endTime, 404 }) 405 hydrateScheduleForm() 406 await loadSchedule() 407 } catch (error) { 408 scheduleSetupError.value = error instanceof Error ? error.message : 'Failed to save clinic schedule' 409 } finally { 410 isSavingSchedule.value = false 411 } 412 } 413 321 414 async function loadNotifications() { 322 415 if (!auth.user?.userId || !canUseDashboard.value) return … … 417 510 } 418 511 512 function hydrateScheduleForm() { 513 scheduleForm.value = { 514 workDays: clinic.value?.workDays ? clinic.value.workDays.split(',').map((day) => day.trim()).filter(Boolean) : [], 515 startTime: clinic.value?.startTime?.slice(0, 5) || '09:00', 516 endTime: clinic.value?.endTime?.slice(0, 5) || '17:00', 517 } 518 } 519 520 function getClinicTimesForDate(date: string): string[] { 521 if (!clinic.value?.workDays || !clinic.value.startTime || !clinic.value.endTime) return [] 522 if (!isClinicWorkingDay(date)) return [] 523 524 const times: string[] = [] 525 const start = timeToMinutes(clinic.value.startTime) 526 const end = timeToMinutes(clinic.value.endTime) 527 528 for (let minutes = start; minutes < end; minutes += 30) { 529 times.push(minutesToTime(minutes)) 530 } 531 532 return times 533 } 534 535 function isClinicWorkingDay(date: string): boolean { 536 const day = new Date(`${date}T00:00:00`).toLocaleDateString('en-US', { weekday: 'long' }).toUpperCase() 537 return clinic.value?.workDays?.split(',').map((item) => item.trim().toUpperCase()).includes(day) ?? false 538 } 539 540 function timeToMinutes(time: string): number { 541 const parts = time.slice(0, 5).split(':').map(Number) 542 const hours = parts[0] ?? 0 543 const minutes = parts[1] ?? 0 544 return hours * 60 + minutes 545 } 546 547 function minutesToTime(total: number): string { 548 const hours = Math.floor(total / 60) 549 const minutes = total % 60 550 return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}` 551 } 552 419 553 function formatDateTime(value: string): string { 420 554 return new Date(value).toLocaleString('en-US', { … … 439 573 try { 440 574 clinic.value = await getMyClinic(auth.user.userId) 575 hydrateScheduleForm() 441 576 accessError.value = '' 442 577 } catch (error) { … … 445 580 } 446 581 447 await Promise.all([loadSchedule(), loadNotifications()]) 582 if (canUseSchedule.value) { 583 await Promise.all([loadSchedule(), loadNotifications()]) 584 } else { 585 await loadNotifications() 586 } 448 587 }) 449 588 … … 510 649 .summary-strip, 511 650 .schedule-section, 512 .appointments-panel { 651 .appointments-panel, 652 .setup-panel { 513 653 background: white; 514 654 border: 1px solid #e2e8f0; … … 529 669 color: #718096; 530 670 margin: 0; 671 } 672 673 .setup-panel { 674 display: grid; 675 gap: 24px; 676 padding: 28px; 677 } 678 679 .setup-panel h2 { 680 color: #1a202c; 681 font-size: 1.45rem; 682 margin: 0 0 8px; 683 } 684 685 .setup-copy { 686 color: #718096; 687 margin: 0; 688 } 689 690 .schedule-form { 691 display: grid; 692 gap: 20px; 693 max-width: 720px; 694 } 695 696 .days-fieldset { 697 border: 0; 698 display: flex; 699 flex-wrap: wrap; 700 gap: 10px; 701 margin: 0; 702 padding: 0; 703 } 704 705 .days-fieldset legend { 706 color: #2d3748; 707 font-weight: 700; 708 margin-bottom: 8px; 709 width: 100%; 710 } 711 712 .day-check { 713 align-items: center; 714 border: 1px solid #cbd5e0; 715 border-radius: 8px; 716 cursor: pointer; 717 display: inline-flex; 718 gap: 8px; 719 padding: 9px 12px; 720 } 721 722 .day-check:has(input:checked) { 723 background: #fff7ed; 724 border-color: #f97316; 725 color: #9a3412; 726 } 727 728 .time-grid { 729 display: grid; 730 grid-template-columns: repeat(2, minmax(0, 180px)); 731 gap: 16px; 732 } 733 734 .form-group { 735 display: grid; 736 gap: 8px; 737 } 738 739 .form-label { 740 color: #2d3748; 741 font-weight: 700; 531 742 } 532 743 … … 625 836 } 626 837 838 .full-width { 839 grid-column: 1 / -1; 840 } 841 627 842 .slot-card { 628 843 border: 1px solid #e2e8f0; -
petify-frontend/src/views/OwnerProfileView.vue
rae83647 rf6ed6e4 208 208 209 209 <div class="form-group"> 210 <label class="form-label" for="interactionType">How did you interact?</label> 211 <select 212 id="interactionType" 213 v-model="newReview.interactionType" 214 class="form-control" 215 required 216 > 217 <option value="" disabled>Select an option</option> 218 <option 219 v-for="option in interactionTypeOptions" 220 :key="option.value" 221 :value="option.value" 222 > 223 {{ option.label }} 224 </option> 225 </select> 226 </div> 227 228 <div class="form-group"> 210 229 <label class="form-label" for="comment">Comment</label> 211 230 <textarea … … 226 245 type="submit" 227 246 class="btn btn-primary" 228 :disabled="isSubmittingReview || newReview.rating === 0 "247 :disabled="isSubmittingReview || newReview.rating === 0 || !newReview.interactionType" 229 248 > 230 249 <span v-if="isSubmittingReview">Submitting...</span> … … 287 306 import { useRoute, RouterLink } from 'vue-router' 288 307 import { getUserProfile, getUserListings, getUserPets, loadUserVerificationStatus } from '../api/profile' 289 import { createReview, getReviewsByOwner, deleteReview as deleteReviewAPI } from '../api/reviews' 308 import { 309 createReview, 310 getReviewsByOwner, 311 deleteReview as deleteReviewAPI, 312 type UserReviewInteractionType, 313 } from '../api/reviews' 290 314 import { useAuthStore } from '../stores/auth' 291 315 … … 305 329 const isSubmittingReview = ref(false) 306 330 const reviewError = ref<string | null>(null) 307 const newReview = ref({ 331 const interactionTypeOptions: Array<{ value: UserReviewInteractionType; label: string }> = [ 332 { value: 'EVENT', label: 'Met at an event' }, 333 { value: 'PERSONAL_INTERACTION', label: 'Personal interaction' }, 334 { value: 'ONLINE', label: 'Online interaction' }, 335 { value: 'PHONE_CALL', label: 'Phone call' }, 336 { value: 'OTHER', label: 'Other' }, 337 ] 338 339 const newReview = ref<{ 340 rating: number 341 comment: string 342 interactionType: UserReviewInteractionType | '' 343 }>({ 308 344 rating: 0, 309 345 comment: '', 346 interactionType: '', 310 347 }) 311 348 … … 445 482 } 446 483 484 if (!newReview.value.interactionType) { 485 reviewError.value = 'Please choose how you interacted with this user' 486 return 487 } 488 447 489 isSubmittingReview.value = true 448 490 reviewError.value = null … … 453 495 auth.user.userId, 454 496 newReview.value.rating, 455 newReview.value.comment 497 newReview.value.comment, 498 newReview.value.interactionType 456 499 ) 457 500 … … 459 502 newReview.value.rating = 0 460 503 newReview.value.comment = '' 504 newReview.value.interactionType = '' 461 505 await loadReviews() 462 506 } catch (err) {
Note:
See TracChangeset
for help on using the changeset viewer.
