| 1 | <template>
|
|---|
| 2 | <div class="appointments-calendar">
|
|---|
| 3 | <div class="calendar-header">
|
|---|
| 4 | <button class="btn btn-outline-secondary btn-sm" type="button" @click="goPrevMonth">
|
|---|
| 5 | Prev
|
|---|
| 6 | </button>
|
|---|
| 7 |
|
|---|
| 8 | <div
|
|---|
| 9 | v-if="!isEditingMonth"
|
|---|
| 10 | class="calendar-title"
|
|---|
| 11 | title="Double-click to change month and year"
|
|---|
| 12 | @dblclick="startEditingMonth"
|
|---|
| 13 | >
|
|---|
| 14 | {{ monthLabel }}
|
|---|
| 15 | </div>
|
|---|
| 16 |
|
|---|
| 17 | <div v-else class="month-year-editor">
|
|---|
| 18 | <select v-model.number="editMonth" class="form-control form-control-sm month-select">
|
|---|
| 19 | <option v-for="m in months" :key="m.value" :value="m.value">
|
|---|
| 20 | {{ m.label }}
|
|---|
| 21 | </option>
|
|---|
| 22 | </select>
|
|---|
| 23 |
|
|---|
| 24 | <input
|
|---|
| 25 | ref="yearInputRef"
|
|---|
| 26 | v-model.number="editYear"
|
|---|
| 27 | type="number"
|
|---|
| 28 | class="form-control form-control-sm year-input"
|
|---|
| 29 | min="1900"
|
|---|
| 30 | max="2100"
|
|---|
| 31 | @keyup.enter="applyMonthYearInput"
|
|---|
| 32 | @keyup.esc="cancelEditingMonth"
|
|---|
| 33 | />
|
|---|
| 34 |
|
|---|
| 35 | <button class="btn btn-sm btn-primary" type="button" @click="applyMonthYearInput">
|
|---|
| 36 | Go
|
|---|
| 37 | </button>
|
|---|
| 38 |
|
|---|
| 39 | <button class="btn btn-sm btn-outline-secondary" type="button" @click="cancelEditingMonth">
|
|---|
| 40 | Cancel
|
|---|
| 41 | </button>
|
|---|
| 42 | </div>
|
|---|
| 43 |
|
|---|
| 44 | <button class="btn btn-outline-secondary btn-sm" type="button" @click="goNextMonth">
|
|---|
| 45 | Next
|
|---|
| 46 | </button>
|
|---|
| 47 | </div>
|
|---|
| 48 |
|
|---|
| 49 | <div class="calendar-grid">
|
|---|
| 50 | <div v-for="dayName in dayNames" :key="dayName" class="calendar-cell calendar-day-name">
|
|---|
| 51 | {{ dayName }}
|
|---|
| 52 | </div>
|
|---|
| 53 |
|
|---|
| 54 | <button
|
|---|
| 55 | v-for="day in calendarDays"
|
|---|
| 56 | :key="day.key"
|
|---|
| 57 | class="calendar-cell calendar-day"
|
|---|
| 58 | :class="{
|
|---|
| 59 | 'is-empty': !day.date,
|
|---|
| 60 | 'is-selected': day.dateKey === selectedDate,
|
|---|
| 61 | 'has-appointments': day.hasAppointments,
|
|---|
| 62 | }"
|
|---|
| 63 | type="button"
|
|---|
| 64 | :disabled="!day.date"
|
|---|
| 65 | @click="selectDay(day.dateKey)"
|
|---|
| 66 | >
|
|---|
| 67 | <span v-if="day.date" class="day-number">{{ day.dayNumber }}</span>
|
|---|
| 68 | <span v-if="day.hasAppointments" class="day-dot"></span>
|
|---|
| 69 | </button>
|
|---|
| 70 | </div>
|
|---|
| 71 | </div>
|
|---|
| 72 | </template>
|
|---|
| 73 |
|
|---|
| 74 | <script setup lang="ts">
|
|---|
| 75 | import { computed, nextTick, ref } from 'vue'
|
|---|
| 76 |
|
|---|
| 77 | type AppointmentDay = {
|
|---|
| 78 | appointmentId: number
|
|---|
| 79 | dateTime: string
|
|---|
| 80 | }
|
|---|
| 81 |
|
|---|
| 82 | const props = defineProps<{
|
|---|
| 83 | appointments: AppointmentDay[]
|
|---|
| 84 | selectedDate: string
|
|---|
| 85 | }>()
|
|---|
| 86 |
|
|---|
| 87 | const emit = defineEmits<{
|
|---|
| 88 | (e: 'select', dateKey: string): void
|
|---|
| 89 | }>()
|
|---|
| 90 |
|
|---|
| 91 | const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
|---|
| 92 | const monthCursor = ref(new Date())
|
|---|
| 93 |
|
|---|
| 94 | const isEditingMonth = ref(false)
|
|---|
| 95 | const editMonth = ref(new Date().getMonth())
|
|---|
| 96 | const editYear = ref(new Date().getFullYear())
|
|---|
| 97 | const yearInputRef = ref<HTMLInputElement | null>(null)
|
|---|
| 98 |
|
|---|
| 99 | const months = [
|
|---|
| 100 | { value: 0, label: 'January' },
|
|---|
| 101 | { value: 1, label: 'February' },
|
|---|
| 102 | { value: 2, label: 'March' },
|
|---|
| 103 | { value: 3, label: 'April' },
|
|---|
| 104 | { value: 4, label: 'May' },
|
|---|
| 105 | { value: 5, label: 'June' },
|
|---|
| 106 | { value: 6, label: 'July' },
|
|---|
| 107 | { value: 7, label: 'August' },
|
|---|
| 108 | { value: 8, label: 'September' },
|
|---|
| 109 | { value: 9, label: 'October' },
|
|---|
| 110 | { value: 10, label: 'November' },
|
|---|
| 111 | { value: 11, label: 'December' },
|
|---|
| 112 | ]
|
|---|
| 113 |
|
|---|
| 114 | const appointmentDateKeys = computed(() => {
|
|---|
| 115 | const keys = new Set<string>()
|
|---|
| 116 |
|
|---|
| 117 | props.appointments.forEach((appt) => {
|
|---|
| 118 | keys.add(toDateKey(new Date(appt.dateTime)))
|
|---|
| 119 | })
|
|---|
| 120 |
|
|---|
| 121 | return keys
|
|---|
| 122 | })
|
|---|
| 123 |
|
|---|
| 124 | const monthLabel = computed(() => {
|
|---|
| 125 | return monthCursor.value.toLocaleDateString('en-US', {
|
|---|
| 126 | month: 'long',
|
|---|
| 127 | year: 'numeric',
|
|---|
| 128 | })
|
|---|
| 129 | })
|
|---|
| 130 |
|
|---|
| 131 | const calendarDays = computed(() => {
|
|---|
| 132 | const year = monthCursor.value.getFullYear()
|
|---|
| 133 | const month = monthCursor.value.getMonth()
|
|---|
| 134 |
|
|---|
| 135 | const firstDay = new Date(year, month, 1)
|
|---|
| 136 | const lastDay = new Date(year, month + 1, 0)
|
|---|
| 137 |
|
|---|
| 138 | const startOffset = firstDay.getDay()
|
|---|
| 139 | const totalDays = lastDay.getDate()
|
|---|
| 140 |
|
|---|
| 141 | const cells: Array<{
|
|---|
| 142 | key: string
|
|---|
| 143 | date?: Date
|
|---|
| 144 | dateKey?: string
|
|---|
| 145 | dayNumber?: number
|
|---|
| 146 | hasAppointments: boolean
|
|---|
| 147 | }> = []
|
|---|
| 148 |
|
|---|
| 149 | for (let i = 0; i < startOffset; i++) {
|
|---|
| 150 | cells.push({
|
|---|
| 151 | key: `empty-${i}`,
|
|---|
| 152 | hasAppointments: false,
|
|---|
| 153 | })
|
|---|
| 154 | }
|
|---|
| 155 |
|
|---|
| 156 | for (let day = 1; day <= totalDays; day++) {
|
|---|
| 157 | const date = new Date(year, month, day)
|
|---|
| 158 | const dateKey = toDateKey(date)
|
|---|
| 159 |
|
|---|
| 160 | cells.push({
|
|---|
| 161 | key: dateKey,
|
|---|
| 162 | date,
|
|---|
| 163 | dateKey,
|
|---|
| 164 | dayNumber: day,
|
|---|
| 165 | hasAppointments: appointmentDateKeys.value.has(dateKey),
|
|---|
| 166 | })
|
|---|
| 167 | }
|
|---|
| 168 |
|
|---|
| 169 | const remainder = cells.length % 7
|
|---|
| 170 | if (remainder !== 0) {
|
|---|
| 171 | for (let i = 0; i < 7 - remainder; i++) {
|
|---|
| 172 | cells.push({
|
|---|
| 173 | key: `tail-${i}`,
|
|---|
| 174 | hasAppointments: false,
|
|---|
| 175 | })
|
|---|
| 176 | }
|
|---|
| 177 | }
|
|---|
| 178 |
|
|---|
| 179 | return cells
|
|---|
| 180 | })
|
|---|
| 181 |
|
|---|
| 182 | function startEditingMonth() {
|
|---|
| 183 | editMonth.value = monthCursor.value.getMonth()
|
|---|
| 184 | editYear.value = monthCursor.value.getFullYear()
|
|---|
| 185 | isEditingMonth.value = true
|
|---|
| 186 |
|
|---|
| 187 | nextTick(() => {
|
|---|
| 188 | yearInputRef.value?.focus()
|
|---|
| 189 | })
|
|---|
| 190 | }
|
|---|
| 191 |
|
|---|
| 192 | function applyMonthYearInput() {
|
|---|
| 193 | if (!editYear.value || editYear.value < 1900 || editYear.value > 2100) {
|
|---|
| 194 | return
|
|---|
| 195 | }
|
|---|
| 196 |
|
|---|
| 197 | monthCursor.value = new Date(editYear.value, editMonth.value, 1)
|
|---|
| 198 | isEditingMonth.value = false
|
|---|
| 199 | }
|
|---|
| 200 |
|
|---|
| 201 | function cancelEditingMonth() {
|
|---|
| 202 | isEditingMonth.value = false
|
|---|
| 203 | }
|
|---|
| 204 |
|
|---|
| 205 | function toDateKey(date: Date): string {
|
|---|
| 206 | const year = date.getFullYear()
|
|---|
| 207 | const month = String(date.getMonth() + 1).padStart(2, '0')
|
|---|
| 208 | const day = String(date.getDate()).padStart(2, '0')
|
|---|
| 209 |
|
|---|
| 210 | return `${year}-${month}-${day}`
|
|---|
| 211 | }
|
|---|
| 212 |
|
|---|
| 213 | function selectDay(dateKey?: string) {
|
|---|
| 214 | if (!dateKey) return
|
|---|
| 215 | emit('select', dateKey)
|
|---|
| 216 | }
|
|---|
| 217 |
|
|---|
| 218 | function goPrevMonth() {
|
|---|
| 219 | const date = new Date(monthCursor.value)
|
|---|
| 220 | date.setMonth(date.getMonth() - 1)
|
|---|
| 221 | monthCursor.value = date
|
|---|
| 222 | }
|
|---|
| 223 |
|
|---|
| 224 | function goNextMonth() {
|
|---|
| 225 | const date = new Date(monthCursor.value)
|
|---|
| 226 | date.setMonth(date.getMonth() + 1)
|
|---|
| 227 | monthCursor.value = date
|
|---|
| 228 | }
|
|---|
| 229 | </script>
|
|---|
| 230 |
|
|---|
| 231 | <style scoped>
|
|---|
| 232 | .appointments-calendar {
|
|---|
| 233 | border: 1px solid #e2e8f0;
|
|---|
| 234 | border-radius: 12px;
|
|---|
| 235 | padding: 16px;
|
|---|
| 236 | background: #fff;
|
|---|
| 237 | }
|
|---|
| 238 |
|
|---|
| 239 | .calendar-header {
|
|---|
| 240 | display: flex;
|
|---|
| 241 | align-items: center;
|
|---|
| 242 | justify-content: space-between;
|
|---|
| 243 | gap: 12px;
|
|---|
| 244 | margin-bottom: 12px;
|
|---|
| 245 | }
|
|---|
| 246 |
|
|---|
| 247 | .calendar-title {
|
|---|
| 248 | font-weight: 700;
|
|---|
| 249 | color: #1a202c;
|
|---|
| 250 | cursor: pointer;
|
|---|
| 251 | min-width: 180px;
|
|---|
| 252 | text-align: center;
|
|---|
| 253 | }
|
|---|
| 254 |
|
|---|
| 255 | .month-year-editor {
|
|---|
| 256 | display: flex;
|
|---|
| 257 | align-items: center;
|
|---|
| 258 | gap: 8px;
|
|---|
| 259 | flex: 1;
|
|---|
| 260 | justify-content: center;
|
|---|
| 261 | }
|
|---|
| 262 |
|
|---|
| 263 | .month-select {
|
|---|
| 264 | max-width: 150px;
|
|---|
| 265 | }
|
|---|
| 266 |
|
|---|
| 267 | .year-input {
|
|---|
| 268 | max-width: 100px;
|
|---|
| 269 | }
|
|---|
| 270 |
|
|---|
| 271 | .calendar-grid {
|
|---|
| 272 | display: grid;
|
|---|
| 273 | grid-template-columns: repeat(7, 1fr);
|
|---|
| 274 | gap: 6px;
|
|---|
| 275 | }
|
|---|
| 276 |
|
|---|
| 277 | .calendar-cell {
|
|---|
| 278 | border: 1px solid #e2e8f0;
|
|---|
| 279 | border-radius: 8px;
|
|---|
| 280 | min-height: 44px;
|
|---|
| 281 | background: #f8fafc;
|
|---|
| 282 | display: flex;
|
|---|
| 283 | align-items: center;
|
|---|
| 284 | justify-content: center;
|
|---|
| 285 | position: relative;
|
|---|
| 286 | }
|
|---|
| 287 |
|
|---|
| 288 | .calendar-day-name {
|
|---|
| 289 | background: transparent;
|
|---|
| 290 | border: none;
|
|---|
| 291 | font-weight: 600;
|
|---|
| 292 | color: #4a5568;
|
|---|
| 293 | min-height: 24px;
|
|---|
| 294 | }
|
|---|
| 295 |
|
|---|
| 296 | .calendar-day {
|
|---|
| 297 | cursor: pointer;
|
|---|
| 298 | transition: all 0.2s ease;
|
|---|
| 299 | }
|
|---|
| 300 |
|
|---|
| 301 | .calendar-day:disabled {
|
|---|
| 302 | cursor: default;
|
|---|
| 303 | background: transparent;
|
|---|
| 304 | border: none;
|
|---|
| 305 | }
|
|---|
| 306 |
|
|---|
| 307 | .calendar-day.has-appointments {
|
|---|
| 308 | border-color: #f97316;
|
|---|
| 309 | }
|
|---|
| 310 |
|
|---|
| 311 | .calendar-day.is-selected {
|
|---|
| 312 | background: #f97316;
|
|---|
| 313 | color: #fff;
|
|---|
| 314 | border-color: #f97316;
|
|---|
| 315 | }
|
|---|
| 316 |
|
|---|
| 317 | .day-number {
|
|---|
| 318 | font-weight: 600;
|
|---|
| 319 | }
|
|---|
| 320 |
|
|---|
| 321 | .day-dot {
|
|---|
| 322 | width: 6px;
|
|---|
| 323 | height: 6px;
|
|---|
| 324 | border-radius: 50%;
|
|---|
| 325 | background: #f97316;
|
|---|
| 326 | position: absolute;
|
|---|
| 327 | bottom: 6px;
|
|---|
| 328 | }
|
|---|
| 329 |
|
|---|
| 330 | .calendar-day.is-selected .day-dot {
|
|---|
| 331 | background: #fff;
|
|---|
| 332 | }
|
|---|
| 333 | </style>
|
|---|