sposchedule
653 строки · 19.2 Кб
1<script setup lang="ts">
2import ScheduleItem from '@/components/schedule/PublicScheduleItem.vue';
3import { dateRegex, reducedWeekDays } from '@/composables/constants';
4import { usePublicBellsQuery } from '@/queries/bells';
5import { useBuildingsQuery } from '@/queries/buildings';
6import { useGroupsPublicQuery } from '@/queries/groups';
7import {
8useCoursesQuery,
9usePublicSchedulesQuery,
10} from '@/queries/schedules';
11import router from '@/router';
12import { useAuthStore } from '@/stores/auth';
13import { useSchedulePublicStore } from '@/stores/schedulePublic';
14import { useDateFormat, useDebounceFn } from '@vueuse/core';
15import { storeToRefs } from 'pinia';
16import Button from 'primevue/button';
17import DatePicker from 'primevue/datepicker';
18import InputText from 'primevue/inputtext';
19import Select from 'primevue/select';
20import Skeleton from 'primevue/skeleton';
21import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
22import { useRoute } from 'vue-router';
23
24const route = useRoute();
25const scheduleStore = useSchedulePublicStore();
26const { course, date, schedulesChanges, selectedGroup } =
27storeToRefs(scheduleStore);
28const { setSchedulesChanges } = scheduleStore;
29
30const isoDate = computed(() => {
31return date.value ? useDateFormat(date.value, 'DD.MM.YYYY').value : null;
32});
33
34const cabinet = ref('');
35const searchedCabinet = ref('');
36const teacher = ref('');
37const searchedTeacher = ref('');
38const subject = ref('');
39const searchedSubject = ref('');
40
41const debouncedCabinetFn = useDebounceFn(
42() => (searchedCabinet.value = cabinet.value),
43500,
44{ maxWait: 1000 }
45);
46const debouncedTeacherFn = useDebounceFn(
47() => (searchedTeacher.value = teacher.value),
48500,
49{ maxWait: 1000 }
50);
51const debouncedSubjectFn = useDebounceFn(
52() => (searchedSubject.value = subject.value),
53500,
54{ maxWait: 1000 }
55);
56
57const coursesWithLabel = computed(() => {
58return (
59courses.value?.map(course => ({
60label: `${course.course} курс`,
61value: course.course,
62})) || []
63);
64});
65
66const selectedCourse = computed(() => {
67return course.value;
68});
69
70const building = ref(null);
71const { data: buildingsFethed } = useBuildingsQuery();
72const buildings = computed(() => {
73return (
74buildingsFethed.value?.map(building => ({
75value: building.name,
76label: `${building.name} корпус`,
77})) || []
78);
79});
80
81const { data: courses } = useCoursesQuery(building);
82
83const {
84data: changesSchedules,
85isFetched,
86isError,
87isLoading,
88} = usePublicSchedulesQuery(
89isoDate,
90building,
91selectedCourse,
92selectedGroup,
93searchedCabinet,
94searchedTeacher,
95searchedSubject
96);
97
98const { data: groups } = useGroupsPublicQuery(
99selectedGroup,
100building,
101course
102);
103
104const updateQueryParams = () => {
105router.replace({
106query: {
107...route.query,
108date: isoDate.value || undefined,
109building: building.value || undefined,
110course: selectedCourse.value || undefined,
111group: selectedGroup.value || undefined,
112},
113});
114};
115
116watch(changesSchedules, setSchedulesChanges);
117
118watch(
119[isoDate, selectedCourse, selectedGroup, building],
120() => {
121updateQueryParams();
122},
123{ deep: true }
124);
125
126watch(
127building,
128() => {
129course.value = null;
130selectedGroup.value = null;
131},
132{ flush: 'sync' }
133);
134
135watch(
136course,
137() => {
138selectedGroup.value = null;
139},
140{ flush: 'sync' }
141);
142
143onMounted(() => {
144const {
145date: queryDate,
146building: queryBuilding,
147course: queryCourse,
148group: queryGroup,
149} = route.query as Partial<{
150date: string;
151building: string;
152course: string;
153group: string;
154}>;
155
156if (queryDate && dateRegex.test(queryDate)) {
157const [day, month, year] = queryDate.split('.').map(Number);
158date.value = new Date(year, month - 1, day);
159} else {
160date.value = new Date();
161}
162
163building.value = queryBuilding || null;
164course.value = queryCourse ? Number(queryCourse) : null;
165
166selectedGroup.value = queryGroup || null;
167
168updateQueryParams();
169});
170
171const formattedDate = computed(() => {
172return date.value ? useDateFormat(date.value, 'DD.MM.YYYY').value : null;
173});
174
175const { data: publicBells, isFetched: isFetchedBells } = usePublicBellsQuery(
176building,
177formattedDate
178);
179
180const authStore = useAuthStore();
181const { isAuth } = storeToRefs(authStore);
182
183const headerRef = ref(null);
184const headerHeight = ref(0);
185
186const updateHeaderHeight = () => {
187if (headerRef.value) {
188headerHeight.value = headerRef.value.offsetHeight;
189}
190};
191
192// Используем ResizeObserver для отслеживания изменений размеров header
193let resizeObserver = null;
194
195onMounted(() => {
196resizeObserver = new ResizeObserver(updateHeaderHeight);
197if (headerRef.value) {
198resizeObserver.observe(headerRef.value);
199// Обновляем высоту при первой загрузке
200updateHeaderHeight();
201}
202});
203
204onBeforeUnmount(() => {
205if (resizeObserver && headerRef.value) {
206resizeObserver.unobserve(headerRef.value);
207}
208});
209
210const showFilters = ref(false);
211
212function toggleFilters() {
213showFilters.value = !showFilters.value;
214searchedCabinet.value = '';
215cabinet.value = '';
216searchedTeacher.value = '';
217teacher.value = '';
218}
219
220const mergedBells = computed(() => {
221// Функция для сравнения periods между разными корпусами
222const periodsEqual = (periods1, periods2) => {
223if (periods1.length !== periods2.length) return false;
224return periods1.every((p1, index) => {
225const p2 = periods2[index];
226return (
227p1.index === p2.index &&
228p1.has_break === p2.has_break &&
229p1.period_from === p2.period_from &&
230p1.period_to === p2.period_to &&
231p1.period_from_after === p2.period_from_after &&
232p1.period_to_after === p2.period_to_after
233);
234});
235};
236
237// Группируем звонки по одинаковым периодам
238const grouped = [];
239
240publicBells.value?.forEach(bell => {
241// Находим группу, у которой совпадают periods
242let group = grouped.find(g =>
243periodsEqual(g.bells.periods, bell.periods)
244);
245
246if (group) {
247// Если такая группа найдена, добавляем туда здание
248group.building += `, ${bell.building}`;
249} else {
250// Если группа не найдена, создаем новую
251grouped.push({
252building: String(bell.building),
253bells: bell,
254});
255}
256});
257
258return grouped;
259});
260
261const getIndexesFromBells = computed(() => {
262const indexes = new Set<number>();
263mergedBells.value?.forEach(bell => {
264bell.bells.periods.forEach(period => {
265indexes.add(period.index);
266});
267});
268return Array.from(indexes).sort((a, b) => a - b);
269});
270
271const headerHidden = ref(false);
272
273function handleDatePickerBtns(day) {
274switch (day) {
275case 'today':
276date.value = new Date();
277break;
278
279case 'tomorrow':
280const tomorrow = new Date();
281tomorrow.setDate(tomorrow.getDate() + 1);
282date.value = tomorrow;
283break;
284}
285}
286</script>
287
288<template>
289<div
290class="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
294title="К звонкам"
295class="pi pi-bell text-white dark:text-surface-900 bg-primary-500 rounded-full p-4"
296href="#bells"
297/>
298<RouterLink
299v-if="isAuth"
300replace
301title="В панель управления"
302class="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
308ref="headerRef"
309:class="{ '-translate-y-full': headerHidden }"
310class="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
316v-model="date"
317append-to="self"
318fluid
319show-icon
320icon-display="input"
321:invalid="isError"
322date-format="dd.mm.yy"
323>
324<template #inputicon="slotProps">
325<div
326class="flex gap-2 justify-between items-center"
327@click="slotProps.clickCallback"
328>
329<small>{{
330reducedWeekDays[
331useDateFormat(date, 'dddd', {
332locales: '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
342severity="secondary"
343size="small"
344label="Сегодня"
345@click="handleDatePickerBtns('today')"
346/>
347<Button
348severity="secondary"
349size="small"
350label="Завтра"
351@click="handleDatePickerBtns('tomorrow')"
352/>
353</div>
354</template>
355</DatePicker>
356</div>
357<Select
358v-model="building"
359overlay-class="w-full"
360append-to="self"
361title="Корпус"
362show-clear
363:options="buildings"
364option-label="label"
365option-value="value"
366placeholder="Корпус"
367/>
368<Select
369v-model="course"
370overlay-class="w-full"
371append-to="self"
372class=""
373show-clear
374:options="coursesWithLabel"
375option-label="label"
376option-value="value"
377placeholder="Курс"
378/>
379<div class="flex gap-2">
380<Select
381v-model="selectedGroup"
382append-to="self"
383empty-filter-message="Группы не найдены"
384filter
385show-clear
386option-value="name"
387:options="groups"
388option-label="name"
389placeholder="Группа"
390class="w-full"
391/>
392<Button
393title="Фильтры"
394severity="secondary"
395text
396icon="pi pi-sliders-h"
397@click="toggleFilters"
398/>
399</div>
400<div class="ml-auto self-center">
401<div
402v-if="schedulesChanges?.last_updated"
403class="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
409title="Последние обновление"
410class="text-sm text-right text-surface-400"
411:datetime="schedulesChanges?.last_updated"
412>{{
413useDateFormat(
414schedulesChanges?.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
425v-model="cabinet"
426class="w-full md:w-auto"
427placeholder="Поиск по кабинету"
428@input="debouncedCabinetFn"
429/>
430<InputText
431v-model="teacher"
432class="w-full md:w-auto"
433placeholder="Поиск по преподавателю"
434@input="debouncedTeacherFn"
435/>
436<InputText
437v-model="subject"
438class="w-full md:w-auto"
439placeholder="Поиск по предмету"
440@input="debouncedSubjectFn"
441/>
442<Button
443size="small"
444severity="secondary"
445label="Основное"
446target="_blank"
447icon="pi pi-print"
448as="router-link"
449:to="{
450path: '/print/main',
451}"
452/>
453<Button
454size="small"
455severity="secondary"
456label="Изменения"
457target="_blank"
458icon="pi pi-print"
459as="router-link"
460:to="{
461path: '/print/changes',
462query: {
463date: isoDate,
464},
465}"
466/>
467<Button
468size="small"
469severity="secondary"
470label="Звонки"
471target="_blank"
472icon="pi pi-print"
473as="router-link"
474:to="{
475path: '/print/bells',
476query: {
477date: isoDate,
478},
479}"
480/>
481</div>
482</div>
483
484<button
485class="absolute left-1/2 -translate-x-1/2"
486style="bottom: -24px"
487@click="headerHidden = !headerHidden"
488>
489<svg
490class="relative"
491width="112"
492height="24"
493viewBox="0 0 28 6"
494fill="none"
495xmlns="http://www.w3.org/2000/svg"
496>
497<path
498d="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"
499class="fill-surface-100 dark:fill-surface-800"
500/>
501</svg>
502<span
503class="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` }"
513class="flex flex-col gap-4"
514>
515<span
516v-if="isFetched && !schedulesChanges?.schedules.length"
517class="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
533v-for="item in schedulesChanges?.schedules"
534:key="item?.id"
535class="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
551v-if="publicBells?.type"
552:class="{
553'text-green-400 ': publicBells?.type !== 'main',
554'text-surface-400 ': publicBells?.type === 'main',
555}"
556class="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}"
584class="text-sm text-right rounded-lg"
585>{{
586bell.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
598v-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
612v-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 {
628display: grid;
629row-gap: 2rem;
630column-gap: 10px;
631grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
632}
633
634.schedules > *:only-child {
635justify-self: center;
636width: 300px;
637}
638
639.bells-table {
640border-collapse: collapse;
641}
642
643.bells-table td {
644padding: 0.75rem 1rem;
645}
646
647@media screen and (max-width: 768px) {
648.schedules > *:only-child {
649justify-self: center;
650width: 100%;
651}
652}
653</style>
654