Changeset f6ed6e4


Ignore:
Timestamp:
08/22/26 19:00:38 (6 hours ago)
Author:
veronika-ils <ilioskaveronika@…>
Branches:
master
Parents:
ae83647
Message:

feat: The app is consistent with the changes made in phase 1 and phase 2

Files:
3 added
14 edited

Legend:

Unmodified
Added
Removed
  • petify-backend/sql/ddl.sql

    rae83647 rf6ed6e4  
    107107                             city      VARCHAR(80)  NOT NULL,
    108108                             address   VARCHAR(200) NOT NULL,
     109                             work_days VARCHAR(120),
     110                             start_time TIME,
     111                             end_time   TIME,
    109112                             user_id   BIGINT       UNIQUE,
    110113                             application_id BIGINT UNIQUE,
     
    227230                              review_id      BIGINT,
    228231                              target_user_id BIGINT NOT NULL,
     232                              interaction_type VARCHAR(40) NOT NULL,
    229233
    230234                              CONSTRAINT user_reviews_PK PRIMARY KEY (review_id),
     
    234238                              CONSTRAINT user_reviews_target_FK FOREIGN KEY (target_user_id)
    235239                                  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                              )
    237244);
    238245
  • petify-backend/sql/dml.sql

    rae83647 rf6ed6e4  
    116116VALUES (1, (SELECT clinic_id FROM vet_clinics WHERE name='Happy Paws Clinic'));
    117117
    118 INSERT INTO user_reviews (review_id, target_user_id) VALUES
    119                                                          (2, (SELECT user_id FROM users WHERE username='client.igor')),
    120                                                          (3, (SELECT user_id FROM users WHERE username='client.mila'));
     118INSERT 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');
    121121
    122122INSERT INTO health_records (animal_id, appointment_id, type, description, date) VALUES
  • petify-backend/src/main/java/com/petify/petify/api/VetClinicsController.java

    rae83647 rf6ed6e4  
    22
    33import com.petify.petify.domain.VetClinic;
     4import com.petify.petify.dto.UpdateClinicScheduleRequest;
    45import com.petify.petify.dto.VetClinicDTO;
    56import com.petify.petify.repo.VetClinicRepository;
    67import org.springframework.http.ResponseEntity;
    78import org.springframework.web.bind.annotation.GetMapping;
     9import org.springframework.web.bind.annotation.PutMapping;
    810import org.springframework.web.bind.annotation.RequestHeader;
    911import org.springframework.web.bind.annotation.RequestMapping;
     12import org.springframework.web.bind.annotation.RequestBody;
    1013import org.springframework.web.bind.annotation.RestController;
    1114
     15import java.time.LocalTime;
     16import java.time.format.DateTimeParseException;
     17import java.util.Arrays;
     18import java.util.Set;
    1219import java.util.List;
    1320import java.util.Map;
     
    1724@RequestMapping("/api/clinics")
    1825public class VetClinicsController {
     26
     27    private static final Set<String> ALLOWED_WORK_DAYS = Set.of(
     28        "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"
     29    );
    1930
    2031    private final VetClinicRepository vetClinicRepository;
     
    4051    }
    4152
     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
    4283    private VetClinicDTO mapToDTO(VetClinic clinic) {
    4384        return new VetClinicDTO(
     
    4586            clinic.getName(),
    4687            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
    4892        );
     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        }
    49130    }
    50131}
  • petify-backend/src/main/java/com/petify/petify/config/SecurityConfig.java

    rae83647 rf6ed6e4  
    9696                        .requestMatchers(HttpMethod.GET, "/api/clinics").permitAll()
    9797                        .requestMatchers(HttpMethod.GET, "/api/clinics/my").permitAll()
     98                        .requestMatchers(HttpMethod.PUT, "/api/clinics/my/schedule").permitAll()
    9899                        .requestMatchers(HttpMethod.GET, "/api/notifications/my").permitAll()
    99100
  • petify-backend/src/main/java/com/petify/petify/domain/UserReview.java

    rae83647 rf6ed6e4  
    1919    private Long targetUserId;
    2020
     21    @Column(name = "interaction_type", nullable = false, length = 40)
     22    private String interactionType;
     23
    2124    public UserReview() {}
    2225
    23     public UserReview(Review review, Long targetUserId) {
     26    public UserReview(Review review, Long targetUserId, String interactionType) {
    2427        this.review = review;        // DO NOT set reviewId
    2528        this.targetUserId = targetUserId;
     29        this.interactionType = interactionType;
    2630    }
    2731
     
    5155        this.targetUserId = targetUserId;
    5256    }
     57
     58    public String getInteractionType() {
     59        return interactionType;
     60    }
     61
     62    public void setInteractionType(String interactionType) {
     63        this.interactionType = interactionType;
     64    }
    5365}
    5466
  • petify-backend/src/main/java/com/petify/petify/domain/VetClinic.java

    rae83647 rf6ed6e4  
    44import lombok.Getter;
    55import lombok.Setter;
     6
     7import java.time.LocalTime;
    68
    79@Setter
     
    3739    private String address;
    3840
     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
    3950    public VetClinic() {}
    4051
  • petify-backend/src/main/java/com/petify/petify/dto/CreateReviewRequest.java

    rae83647 rf6ed6e4  
    99    private Integer rating;
    1010    private String comment;
     11    private String interactionType;
    1112
    1213    public CreateReviewRequest() {
     
    1819    }
    1920
     21    public CreateReviewRequest(Integer rating, String comment, String interactionType) {
     22        this.rating = rating;
     23        this.comment = comment;
     24        this.interactionType = interactionType;
     25    }
     26
    2027}
  • petify-backend/src/main/java/com/petify/petify/dto/VetClinicDTO.java

    rae83647 rf6ed6e4  
    1111    private String city;
    1212    private String address;
     13    private String workDays;
     14    private String startTime;
     15    private String endTime;
     16    private Boolean scheduleComplete;
    1317
    1418    public VetClinicDTO() {
     
    2226    }
    2327
     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
    2447}
    2548
  • petify-backend/src/main/java/com/petify/petify/service/AppointmentService.java

    rae83647 rf6ed6e4  
    3131import java.time.LocalTime;
    3232import java.time.format.DateTimeFormatter;
     33import java.util.Arrays;
    3334import java.util.List;
    3435import java.util.Set;
     
    3839
    3940    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);
    4241    private static final int SLOT_MINUTES = 30;
    4342    private static final List<String> NON_BLOCKING_STATUSES = List.of("CANCELLED", "CANCELED", "NO_SHOW");
     
    8483        }
    8584
    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"));
    8987
    9088        LocalDateTime appointmentTime = LocalDateTime.parse(request.getDateTime());
    91         if (!isClinicSlotAvailable(request.getClinicId(), appointmentTime)) {
     89        if (!isClinicSlotAvailable(clinic, appointmentTime)) {
    9290            throw new RuntimeException("Selected appointment slot is no longer available");
    9391        }
     
    210208        }
    211209
    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());
    218220        Set<LocalDateTime> bookedSlots = appointmentRepository
    219221            .findByClinicIdAndDateTimeBetweenAndStatusNotInOrderByDateTimeAsc(
     
    257259        }
    258260
    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());
    265271
    266272        return unavailableSlotRepository
     
    282288        }
    283289
    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"));
    287292
    288293        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");
    291296        }
    292297
     
    332337    }
    333338
    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)) {
    336341            return false;
    337342        }
    338343
    339344        return !appointmentRepository.existsByClinicIdAndDateTimeAndStatusNotIn(
    340             clinicId,
     345            clinic.getClinicId(),
    341346            appointmentTime,
    342347            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);
    347353        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
    352362            && appointmentTime.getSecond() == 0
    353363            && 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);
    354384    }
    355385
  • petify-backend/src/main/java/com/petify/petify/service/ReviewService.java

    rae83647 rf6ed6e4  
    1919
    2020import java.time.LocalDateTime;
     21import java.util.Set;
    2122import java.util.List;
    2223import java.util.stream.Collectors;
     
    2627
    2728    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    );
    2836
    2937    private final ReviewRepository reviewRepository;
     
    5866    public ReviewDTO createReview(Long reviewerId, Long targetUserId, CreateReviewRequest request) {
    5967        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());
    6570
    6671
     
    118123
    119124        userReview.setTargetUserId(targetUserId);
     125        userReview.setInteractionType(interactionType);
    120126
    121127        // Save UserReview to database with flush
     
    285291    }
    286292
     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
    287306    private User getReviewer(Long reviewerId) {
    288307        return userRepository.findById(reviewerId)
  • petify-frontend/src/api/profile.ts

    rae83647 rf6ed6e4  
    3535  city: string
    3636  address: string
     37  workDays?: string
     38  startTime?: string
     39  endTime?: string
     40  scheduleComplete?: boolean
    3741}
    3842
     
    464468}
    465469
     470export 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
    466496export async function getClinicAvailableSlots(clinicId: number, date: string): Promise<AppointmentSlot[]> {
    467497  const url = joinUrl(getBaseUrl(), `/api/appointments/clinics/${clinicId}/available-slots?date=${encodeURIComponent(date)}`)
  • petify-frontend/src/api/reviews.ts

    rae83647 rf6ed6e4  
    2020}
    2121
     22export type UserReviewInteractionType =
     23  | 'EVENT'
     24  | 'PERSONAL_INTERACTION'
     25  | 'ONLINE'
     26  | 'PHONE_CALL'
     27  | 'OTHER'
     28
    2229export async function createReview(
    2330  targetUserId: number,
    2431  userId: number,
    2532  rating: number,
    26   comment: string
     33  comment: string,
     34  interactionType: UserReviewInteractionType
    2735): Promise<Review> {
    2836  const url = joinUrl(getBaseUrl(), `/api/reviews/${targetUserId}`)
     
    3644      rating,
    3745      comment,
     46      interactionType,
    3847    }),
    3948  })
  • petify-frontend/src/views/ClinicDashboardView.vue

    rae83647 rf6ed6e4  
    88          <p v-if="clinic" class="clinic-subtitle">{{ clinic.name }} - {{ clinic.city }}, {{ clinic.address }}</p>
    99        </div>
    10         <div class="toolbar">
     10        <div v-if="canUseSchedule" class="toolbar">
    1111          <button class="btn btn-outline-secondary btn-sm" type="button" @click="goToPreviousDay">
    1212            Previous day
     
    3434
    3535      <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>
    3677        <div class="summary-strip">
    3778          <div class="summary-item">
     
    59100
    60101            <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>
    61105              <div
    62106                v-for="slot in daySlots"
     
    131175          </aside>
    132176        </div>
     177        </template>
    133178      </template>
    134179    </section>
     
    147192  getMyNotifications,
    148193  markMyClinicAppointmentNoShow,
     194  updateMyClinicSchedule,
    149195  type AppNotification,
    150196  type AppointmentSlot,
     
    174220const notifications = ref<AppNotification[]>([])
    175221const isLoading = ref(false)
     222const isSavingSchedule = ref(false)
    176223const updatingAppointmentId = ref<number | null>(null)
    177224const accessError = ref('')
    178225const scheduleError = ref('')
     226const scheduleSetupError = ref('')
    179227const notificationsError = ref('')
    180228const NON_BLOCKING_STATUSES = new Set(['CANCELLED', 'CANCELED', 'NO_SHOW'])
     
    182230
    183231const canUseDashboard = computed(() => auth.isAuthenticated && auth.user?.userType === 'CLINIC')
     232const canUseSchedule = computed(() => Boolean(clinic.value?.scheduleComplete))
     233const scheduleForm = ref({
     234  workDays: [] as string[],
     235  startTime: '09:00',
     236  endTime: '17:00',
     237})
     238const 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]
    184247
    185248const appointmentsByDateTime = computed(() => {
     
    205268  const now = new Date()
    206269
    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
    211273      const key = normalizeDateTime(dateTime)
    212274      const appointment = appointmentsByDateTime.value.get(key)
     
    252314        })
    253315      }
    254     }
    255316  }
    256317
     
    274335  const slots: AppointmentSlot[] = []
    275336
    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}`
    279339      const key = normalizeDateTime(dateTime)
    280340      if (new Date(dateTime).getTime() < now.getTime()) continue
     
    282342      slots.push({
    283343        dateTime,
    284         label: `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`,
     344        label: time,
    285345      })
    286     }
    287346  }
    288347
     
    291350
    292351async function loadSchedule() {
    293   if (!auth.user?.userId || !canUseDashboard.value || !selectedDate.value) return
     352  if (!auth.user?.userId || !canUseDashboard.value || !canUseSchedule.value || !selectedDate.value) return
    294353
    295354  const requestId = ++latestScheduleRequest
     
    319378}
    320379
     380async 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
    321414async function loadNotifications() {
    322415  if (!auth.user?.userId || !canUseDashboard.value) return
     
    417510}
    418511
     512function 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
     520function 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
     535function 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
     540function 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
     547function 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
    419553function formatDateTime(value: string): string {
    420554  return new Date(value).toLocaleString('en-US', {
     
    439573  try {
    440574    clinic.value = await getMyClinic(auth.user.userId)
     575    hydrateScheduleForm()
    441576    accessError.value = ''
    442577  } catch (error) {
     
    445580  }
    446581
    447   await Promise.all([loadSchedule(), loadNotifications()])
     582  if (canUseSchedule.value) {
     583    await Promise.all([loadSchedule(), loadNotifications()])
     584  } else {
     585    await loadNotifications()
     586  }
    448587})
    449588
     
    510649.summary-strip,
    511650.schedule-section,
    512 .appointments-panel {
     651.appointments-panel,
     652.setup-panel {
    513653  background: white;
    514654  border: 1px solid #e2e8f0;
     
    529669  color: #718096;
    530670  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;
    531742}
    532743
     
    625836}
    626837
     838.full-width {
     839  grid-column: 1 / -1;
     840}
     841
    627842.slot-card {
    628843  border: 1px solid #e2e8f0;
  • petify-frontend/src/views/OwnerProfileView.vue

    rae83647 rf6ed6e4  
    208208
    209209                  <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">
    210229                    <label class="form-label" for="comment">Comment</label>
    211230                    <textarea
     
    226245                      type="submit"
    227246                      class="btn btn-primary"
    228                       :disabled="isSubmittingReview || newReview.rating === 0"
     247                      :disabled="isSubmittingReview || newReview.rating === 0 || !newReview.interactionType"
    229248                    >
    230249                      <span v-if="isSubmittingReview">Submitting...</span>
     
    287306import { useRoute, RouterLink } from 'vue-router'
    288307import { getUserProfile, getUserListings, getUserPets, loadUserVerificationStatus } from '../api/profile'
    289 import { createReview, getReviewsByOwner, deleteReview as deleteReviewAPI } from '../api/reviews'
     308import {
     309  createReview,
     310  getReviewsByOwner,
     311  deleteReview as deleteReviewAPI,
     312  type UserReviewInteractionType,
     313} from '../api/reviews'
    290314import { useAuthStore } from '../stores/auth'
    291315
     
    305329const isSubmittingReview = ref(false)
    306330const reviewError = ref<string | null>(null)
    307 const newReview = ref({
     331const 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
     339const newReview = ref<{
     340  rating: number
     341  comment: string
     342  interactionType: UserReviewInteractionType | ''
     343}>({
    308344  rating: 0,
    309345  comment: '',
     346  interactionType: '',
    310347})
    311348
     
    445482  }
    446483
     484  if (!newReview.value.interactionType) {
     485    reviewError.value = 'Please choose how you interacted with this user'
     486    return
     487  }
     488
    447489  isSubmittingReview.value = true
    448490  reviewError.value = null
     
    453495      auth.user.userId,
    454496      newReview.value.rating,
    455       newReview.value.comment
     497      newReview.value.comment,
     498      newReview.value.interactionType
    456499    )
    457500
     
    459502    newReview.value.rating = 0
    460503    newReview.value.comment = ''
     504    newReview.value.interactionType = ''
    461505    await loadReviews()
    462506  } catch (err) {
Note: See TracChangeset for help on using the changeset viewer.