sposchedule

Форк
1
/
HomeView.vue 
653 строки · 19.2 Кб
1
<script setup lang="ts">
2
  import ScheduleItem from '@/components/schedule/PublicScheduleItem.vue';
3
  import { dateRegex, reducedWeekDays } from '@/composables/constants';
4
  import { usePublicBellsQuery } from '@/queries/bells';
5
  import { useBuildingsQuery } from '@/queries/buildings';
6
  import { useGroupsPublicQuery } from '@/queries/groups';
7
  import {
8
    useCoursesQuery,
9
    usePublicSchedulesQuery,
10
  } from '@/queries/schedules';
11
  import router from '@/router';
12
  import { useAuthStore } from '@/stores/auth';
13
  import { useSchedulePublicStore } from '@/stores/schedulePublic';
14
  import { useDateFormat, useDebounceFn } from '@vueuse/core';
15
  import { storeToRefs } from 'pinia';
16
  import Button from 'primevue/button';
17
  import DatePicker from 'primevue/datepicker';
18
  import InputText from 'primevue/inputtext';
19
  import Select from 'primevue/select';
20
  import Skeleton from 'primevue/skeleton';
21
  import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
22
  import { useRoute } from 'vue-router';
23

24
  const route = useRoute();
25
  const scheduleStore = useSchedulePublicStore();
26
  const { course, date, schedulesChanges, selectedGroup } =
27
    storeToRefs(scheduleStore);
28
  const { setSchedulesChanges } = scheduleStore;
29

30
  const isoDate = computed(() => {
31
    return date.value ? useDateFormat(date.value, 'DD.MM.YYYY').value : null;
32
  });
33

34
  const cabinet = ref('');
35
  const searchedCabinet = ref('');
36
  const teacher = ref('');
37
  const searchedTeacher = ref('');
38
  const subject = ref('');
39
  const searchedSubject = ref('');
40

41
  const debouncedCabinetFn = useDebounceFn(
42
    () => (searchedCabinet.value = cabinet.value),
43
    500,
44
    { maxWait: 1000 }
45
  );
46
  const debouncedTeacherFn = useDebounceFn(
47
    () => (searchedTeacher.value = teacher.value),
48
    500,
49
    { maxWait: 1000 }
50
  );
51
  const debouncedSubjectFn = useDebounceFn(
52
    () => (searchedSubject.value = subject.value),
53
    500,
54
    { maxWait: 1000 }
55
  );
56

57
  const coursesWithLabel = computed(() => {
58
    return (
59
      courses.value?.map(course => ({
60
        label: `${course.course} курс`,
61
        value: course.course,
62
      })) || []
63
    );
64
  });
65

66
  const selectedCourse = computed(() => {
67
    return course.value;
68
  });
69

70
  const building = ref(null);
71
  const { data: buildingsFethed } = useBuildingsQuery();
72
  const buildings = computed(() => {
73
    return (
74
      buildingsFethed.value?.map(building => ({
75
        value: building.name,
76
        label: `${building.name} корпус`,
77
      })) || []
78
    );
79
  });
80

81
  const { data: courses } = useCoursesQuery(building);
82

83
  const {
84
    data: changesSchedules,
85
    isFetched,
86
    isError,
87
    isLoading,
88
  } = usePublicSchedulesQuery(
89
    isoDate,
90
    building,
91
    selectedCourse,
92
    selectedGroup,
93
    searchedCabinet,
94
    searchedTeacher,
95
    searchedSubject
96
  );
97

98
  const { data: groups } = useGroupsPublicQuery(
99
    selectedGroup,
100
    building,
101
    course
102
  );
103

104
  const updateQueryParams = () => {
105
    router.replace({
106
      query: {
107
        ...route.query,
108
        date: isoDate.value || undefined,
109
        building: building.value || undefined,
110
        course: selectedCourse.value || undefined,
111
        group: selectedGroup.value || undefined,
112
      },
113
    });
114
  };
115

116
  watch(changesSchedules, setSchedulesChanges);
117

118
  watch(
119
    [isoDate, selectedCourse, selectedGroup, building],
120
    () => {
121
      updateQueryParams();
122
    },
123
    { deep: true }
124
  );
125

126
  watch(
127
    building,
128
    () => {
129
      course.value = null;
130
      selectedGroup.value = null;
131
    },
132
    { flush: 'sync' }
133
  );
134

135
  watch(
136
    course,
137
    () => {
138
      selectedGroup.value = null;
139
    },
140
    { flush: 'sync' }
141
  );
142

143
  onMounted(() => {
144
    const {
145
      date: queryDate,
146
      building: queryBuilding,
147
      course: queryCourse,
148
      group: queryGroup,
149
    } = route.query as Partial<{
150
      date: string;
151
      building: string;
152
      course: string;
153
      group: string;
154
    }>;
155

156
    if (queryDate && dateRegex.test(queryDate)) {
157
      const [day, month, year] = queryDate.split('.').map(Number);
158
      date.value = new Date(year, month - 1, day);
159
    } else {
160
      date.value = new Date();
161
    }
162

163
    building.value = queryBuilding || null;
164
    course.value = queryCourse ? Number(queryCourse) : null;
165

166
    selectedGroup.value = queryGroup || null;
167

168
    updateQueryParams();
169
  });
170

171
  const formattedDate = computed(() => {
172
    return date.value ? useDateFormat(date.value, 'DD.MM.YYYY').value : null;
173
  });
174

175
  const { data: publicBells, isFetched: isFetchedBells } = usePublicBellsQuery(
176
    building,
177
    formattedDate
178
  );
179

180
  const authStore = useAuthStore();
181
  const { isAuth } = storeToRefs(authStore);
182

183
  const headerRef = ref(null);
184
  const headerHeight = ref(0);
185

186
  const updateHeaderHeight = () => {
187
    if (headerRef.value) {
188
      headerHeight.value = headerRef.value.offsetHeight;
189
    }
190
  };
191

192
  // Используем ResizeObserver для отслеживания изменений размеров header
193
  let resizeObserver = null;
194

195
  onMounted(() => {
196
    resizeObserver = new ResizeObserver(updateHeaderHeight);
197
    if (headerRef.value) {
198
      resizeObserver.observe(headerRef.value);
199
      // Обновляем высоту при первой загрузке
200
      updateHeaderHeight();
201
    }
202
  });
203

204
  onBeforeUnmount(() => {
205
    if (resizeObserver && headerRef.value) {
206
      resizeObserver.unobserve(headerRef.value);
207
    }
208
  });
209

210
  const showFilters = ref(false);
211

212
  function toggleFilters() {
213
    showFilters.value = !showFilters.value;
214
    searchedCabinet.value = '';
215
    cabinet.value = '';
216
    searchedTeacher.value = '';
217
    teacher.value = '';
218
  }
219

220
  const mergedBells = computed(() => {
221
    // Функция для сравнения periods между разными корпусами
222
    const periodsEqual = (periods1, periods2) => {
223
      if (periods1.length !== periods2.length) return false;
224
      return periods1.every((p1, index) => {
225
        const p2 = periods2[index];
226
        return (
227
          p1.index === p2.index &&
228
          p1.has_break === p2.has_break &&
229
          p1.period_from === p2.period_from &&
230
          p1.period_to === p2.period_to &&
231
          p1.period_from_after === p2.period_from_after &&
232
          p1.period_to_after === p2.period_to_after
233
        );
234
      });
235
    };
236

237
    // Группируем звонки по одинаковым периодам
238
    const grouped = [];
239

240
    publicBells.value?.forEach(bell => {
241
      // Находим группу, у которой совпадают periods
242
      let group = grouped.find(g =>
243
        periodsEqual(g.bells.periods, bell.periods)
244
      );
245

246
      if (group) {
247
        // Если такая группа найдена, добавляем туда здание
248
        group.building += `, ${bell.building}`;
249
      } else {
250
        // Если группа не найдена, создаем новую
251
        grouped.push({
252
          building: String(bell.building),
253
          bells: bell,
254
        });
255
      }
256
    });
257

258
    return grouped;
259
  });
260

261
  const getIndexesFromBells = computed(() => {
262
    const indexes = new Set<number>();
263
    mergedBells.value?.forEach(bell => {
264
      bell.bells.periods.forEach(period => {
265
        indexes.add(period.index);
266
      });
267
    });
268
    return Array.from(indexes).sort((a, b) => a - b);
269
  });
270

271
  const headerHidden = ref(false);
272

273
  function handleDatePickerBtns(day) {
274
    switch (day) {
275
      case 'today':
276
        date.value = new Date();
277
        break;
278

279
      case 'tomorrow':
280
        const tomorrow = new Date();
281
        tomorrow.setDate(tomorrow.getDate() + 1);
282
        date.value = tomorrow;
283
        break;
284
    }
285
  }
286
</script>
287

288
<template>
289
  <div
290
    class="relative max-w-screen-xl mx-auto px-4 py-4 flex flex-col gap-4 scroll-smooth"
291
  >
292
    <div class="fixed bottom-8 right-8 z-50 flex gap-2 flex-col">
293
      <a
294
        title="К звонкам"
295
        class="pi pi-bell text-white dark:text-surface-900 bg-primary-500 rounded-full p-4"
296
        href="#bells"
297
      />
298
      <RouterLink
299
        v-if="isAuth"
300
        replace
301
        title="В панель управления"
302
        class="pi pi-pen-to-square text-white dark:text-surface-900 bg-primary-500 rounded-full p-4"
303
        :to="{ path: '/admin/schedules/changes', query: route.query }"
304
      />
305
    </div>
306

307
    <nav
308
      ref="headerRef"
309
      :class="{ '-translate-y-full': headerHidden }"
310
      class="transition-transform fixed rounded-lg rounded-t-none max-w-screen-xl mx-auto z-50 top-0 left-0 right-0 flex flex-wrap justify-between gap-4 p-4 bg-surface-100 dark:bg-surface-800"
311
    >
312
      <div class="flex flex-col flex-wrap gap-4 justify-between w-full">
313
        <div class="flex flex-wrap gap-2 items-center">
314
          <div class="flex flex-col md:w-auto w-full">
315
            <DatePicker
316
              v-model="date"
317
              append-to="self"
318
              fluid
319
              show-icon
320
              icon-display="input"
321
              :invalid="isError"
322
              date-format="dd.mm.yy"
323
            >
324
              <template #inputicon="slotProps">
325
                <div
326
                  class="flex gap-2 justify-between items-center"
327
                  @click="slotProps.clickCallback"
328
                >
329
                  <small>{{
330
                    reducedWeekDays[
331
                      useDateFormat(date, 'dddd', {
332
                        locales: 'ru-RU',
333
                      }).value
334
                    ]
335
                  }}</small>
336
                  <small>{{ schedulesChanges?.week_type }}</small>
337
                </div>
338
              </template>
339
              <template #footer>
340
                <div class="flex justify-between pt-1">
341
                  <Button
342
                    severity="secondary"
343
                    size="small"
344
                    label="Сегодня"
345
                    @click="handleDatePickerBtns('today')"
346
                  />
347
                  <Button
348
                    severity="secondary"
349
                    size="small"
350
                    label="Завтра"
351
                    @click="handleDatePickerBtns('tomorrow')"
352
                  />
353
                </div>
354
              </template>
355
            </DatePicker>
356
          </div>
357
          <Select
358
            v-model="building"
359
            overlay-class="w-full"
360
            append-to="self"
361
            title="Корпус"
362
            show-clear
363
            :options="buildings"
364
            option-label="label"
365
            option-value="value"
366
            placeholder="Корпус"
367
          />
368
          <Select
369
            v-model="course"
370
            overlay-class="w-full"
371
            append-to="self"
372
            class=""
373
            show-clear
374
            :options="coursesWithLabel"
375
            option-label="label"
376
            option-value="value"
377
            placeholder="Курс"
378
          />
379
          <div class="flex gap-2">
380
            <Select
381
              v-model="selectedGroup"
382
              append-to="self"
383
              empty-filter-message="Группы не найдены"
384
              filter
385
              show-clear
386
              option-value="name"
387
              :options="groups"
388
              option-label="name"
389
              placeholder="Группа"
390
              class="w-full"
391
            />
392
            <Button
393
              title="Фильтры"
394
              severity="secondary"
395
              text
396
              icon="pi pi-sliders-h"
397
              @click="toggleFilters"
398
            />
399
          </div>
400
          <div class="ml-auto self-center">
401
            <div
402
              v-if="schedulesChanges?.last_updated"
403
              class="flex gap-1 flex-row items-center lg:flex-col lg:gap-0 lg:items-end flex-wrap"
404
            >
405
              <span class="text-xs text-surface-400 leading-none"
406
                >Последние обновление:</span
407
              >
408
              <time
409
                title="Последние обновление"
410
                class="text-sm text-right text-surface-400"
411
                :datetime="schedulesChanges?.last_updated"
412
                >{{
413
                  useDateFormat(
414
                    schedulesChanges?.last_updated,
415
                    'DD.MM.YYYY HH:mm'
416
                  )
417
                }}</time
418
              >
419
            </div>
420
          </div>
421
        </div>
422

423
        <div v-show="showFilters" class="flex flex-wrap gap-2 items-center">
424
          <InputText
425
            v-model="cabinet"
426
            class="w-full md:w-auto"
427
            placeholder="Поиск по кабинету"
428
            @input="debouncedCabinetFn"
429
          />
430
          <InputText
431
            v-model="teacher"
432
            class="w-full md:w-auto"
433
            placeholder="Поиск по преподавателю"
434
            @input="debouncedTeacherFn"
435
          />
436
          <InputText
437
            v-model="subject"
438
            class="w-full md:w-auto"
439
            placeholder="Поиск по предмету"
440
            @input="debouncedSubjectFn"
441
          />
442
          <Button
443
            size="small"
444
            severity="secondary"
445
            label="Основное"
446
            target="_blank"
447
            icon="pi pi-print"
448
            as="router-link"
449
            :to="{
450
              path: '/print/main',
451
            }"
452
          />
453
          <Button
454
            size="small"
455
            severity="secondary"
456
            label="Изменения"
457
            target="_blank"
458
            icon="pi pi-print"
459
            as="router-link"
460
            :to="{
461
              path: '/print/changes',
462
              query: {
463
                date: isoDate,
464
              },
465
            }"
466
          />
467
          <Button
468
            size="small"
469
            severity="secondary"
470
            label="Звонки"
471
            target="_blank"
472
            icon="pi pi-print"
473
            as="router-link"
474
            :to="{
475
              path: '/print/bells',
476
              query: {
477
                date: isoDate,
478
              },
479
            }"
480
          />
481
        </div>
482
      </div>
483

484
      <button
485
        class="absolute left-1/2 -translate-x-1/2"
486
        style="bottom: -24px"
487
        @click="headerHidden = !headerHidden"
488
      >
489
        <svg
490
          class="relative"
491
          width="112"
492
          height="24"
493
          viewBox="0 0 28 6"
494
          fill="none"
495
          xmlns="http://www.w3.org/2000/svg"
496
        >
497
          <path
498
            d="M3.57628e-07 0L27.8028 0C20.64 0 20.1127 5.32394 13.883 5.32394C7.65323 5.32394 6.71518 0 3.57628e-07 0Z"
499
            class="fill-surface-100 dark:fill-surface-800"
500
          />
501
        </svg>
502
        <span
503
          class="pi absolute top-0 left-1/2 -translate-x-1/2"
504
          :class="{
505
            'pi-angle-down': headerHidden,
506
            'pi-angle-up': !headerHidden,
507
          }"
508
        />
509
      </button>
510
    </nav>
511
    <div
512
      :style="{ marginTop: `${headerHidden ? '20' : headerHeight + 20}px` }"
513
      class="flex flex-col gap-4"
514
    >
515
      <span
516
        v-if="isFetched && !schedulesChanges?.schedules.length"
517
        class="text-2xl text-center"
518
        >Ничего не найдено...</span
519
      >
520
      <span v-else-if="isError" class="text-2xl"
521
        >Расписание ещё не выложили, либо в расписании ошибка.</span
522
      >
523
      <div class="schedules">
524
        <template v-if="isLoading">
525
          <div v-for="item in 32" class="schedule">
526
            <Skeleton height="2rem" class="mb-4" />
527
            <Skeleton height="10rem" />
528
          </div>
529
        </template>
530

531
        <template v-else-if="schedulesChanges?.schedules && !isError">
532
          <ScheduleItem
533
            v-for="item in schedulesChanges?.schedules"
534
            :key="item?.id"
535
            class="schedule"
536
            :date="isoDate"
537
            :schedule="item?.schedule"
538
            :semester="item?.semester"
539
            :type="item?.schedule?.type"
540
            :group-name="item?.group_name"
541
            :lessons="item?.schedule?.lessons"
542
            :week-type="item?.week_type"
543
            :published="item?.schedule?.published"
544
          />
545
        </template>
546
      </div>
547
    </div>
548
    <div class="flex flex-col gap-2 items-center w-full">
549
      <h1 id="bells" class="text-2xl font-bold text-center py-2">Звонки</h1>
550
      <span
551
        v-if="publicBells?.type"
552
        :class="{
553
          'text-green-400 ': publicBells?.type !== 'main',
554
          'text-surface-400 ': publicBells?.type === 'main',
555
        }"
556
        class="text-sm text-right py-1 px-2 rounded-lg"
557
        >{{ publicBells?.type === 'main' ? 'Основное' : 'Изменения' }}</span
558
      >
559
      <div class="">
560
        <h2 v-if="!publicBells && isFetchedBells" class="text-2xl text-center">
561
          На эту дату расписание звонков не найдено
562
        </h2>
563
        <div v-if="publicBells" class="">
564
          <table class="bells-table dark:bg-surface-900 bg-surface-50 rounded">
565
            <thead>
566
              <tr>
567
                <th>
568
                  <div class="flex gap-2 flex-col text-xs p-2">
569
                    <span class="self-end">Корпус</span>
570
                    <span class="border rotate-12" />
571
                    <span class="self-start">№ пары</span>
572
                  </div>
573
                </th>
574
                <th v-for="bell in mergedBells" :key="bell?.building">
575
                  <div class="flex flex-col gap-1 items-center">
576
                    <span>
577
                      {{ bell?.building }}
578
                    </span>
579
                    <span
580
                      :class="{
581
                        'text-green-400 ': bell.bells?.type !== 'main',
582
                        'text-surface-400 ': bell.bells?.type === 'main',
583
                      }"
584
                      class="text-sm text-right rounded-lg"
585
                      >{{
586
                        bell.bells?.type === 'main' ? 'Основное' : 'Изменения'
587
                      }}</span
588
                    >
589
                  </div>
590
                </th>
591
              </tr>
592
            </thead>
593
            <tbody>
594
              <tr v-for="index in getIndexesFromBells" :key="index" class="">
595
                <td class="text-center py-4 font-bold">{{ index }} пара</td>
596
                <template v-for="bell in mergedBells" :key="bell?.building">
597
                  <template
598
                    v-for="period in bell.bells.periods"
599
                    :key="period.index"
600
                  >
601
                    <td v-if="period?.index === index">
602
                      <div>
603
                        {{ period.period_from }} - {{ period.period_to }}
604
                      </div>
605
                      <div v-if="period?.period_from_after">
606
                        {{ period.period_from_after }} -
607
                        {{ period.period_to_after }}
608
                      </div>
609
                    </td>
610
                  </template>
611
                  <td
612
                    v-if="
613
                      !bell.bells.periods.find(period => period.index === index)
614
                    "
615
                  />
616
                </template>
617
              </tr>
618
            </tbody>
619
          </table>
620
        </div>
621
      </div>
622
    </div>
623
  </div>
624
</template>
625

626
<style scoped>
627
  .schedules {
628
    display: grid;
629
    row-gap: 2rem;
630
    column-gap: 10px;
631
    grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
632
  }
633

634
  .schedules > *:only-child {
635
    justify-self: center;
636
    width: 300px;
637
  }
638

639
  .bells-table {
640
    border-collapse: collapse;
641
  }
642

643
  .bells-table td {
644
    padding: 0.75rem 1rem;
645
  }
646

647
  @media screen and (max-width: 768px) {
648
    .schedules > *:only-child {
649
      justify-self: center;
650
      width: 100%;
651
    }
652
  }
653
</style>
654

Использование cookies

Мы используем файлы cookie в соответствии с Политикой конфиденциальности и Политикой использования cookies.

Нажимая кнопку «Принимаю», Вы даете АО «СберТех» согласие на обработку Ваших персональных данных в целях совершенствования нашего веб-сайта и Сервиса GitVerse, а также повышения удобства их использования.

Запретить использование cookies Вы можете самостоятельно в настройках Вашего браузера.