Ignore:
Timestamp:
08/22/26 19:00:38 (13 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

Location:
petify-frontend/src/views
Files:
2 edited

Legend:

Unmodified
Added
Removed
  • 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.