From e66478cac47a1f8121a8e7047405a535b0f76c5a Mon Sep 17 00:00:00 2001 From: unknownproperty Date: Fri, 26 Jun 2026 01:48:11 +0300 Subject: [PATCH] feat(modules.calendar): add 7-days schedule view --- packages/modules.calendar/index.ts | 4 + packages/modules.calendar/src/hooks/index.ts | 5 + .../src/hooks/useScheduleViewMode.ts | 48 ++++++++ packages/modules.calendar/src/ui/Calendar.tsx | 101 +++++++++++++--- .../ScheduleKanban/ScheduleKanban.tsx | 15 ++- .../modules.profile/src/ui/Customization.tsx | 56 ++++++++- .../ui/Calendar/ClassroomScheduleContext.tsx | 7 ++ .../ui/Calendar/ClassroomScheduleParts.tsx | 109 +++++++++++++++--- .../ui/Overview/UpcomingLessonsSection.tsx | 5 +- 9 files changed, 308 insertions(+), 42 deletions(-) create mode 100644 packages/modules.calendar/src/hooks/useScheduleViewMode.ts diff --git a/packages/modules.calendar/index.ts b/packages/modules.calendar/index.ts index f5e31e74..f9708aa0 100644 --- a/packages/modules.calendar/index.ts +++ b/packages/modules.calendar/index.ts @@ -22,7 +22,11 @@ export { useCancelLessonModal, useChangeLessonModal, useLessonInfoModal, + useScheduleViewMode, + type ScheduleViewMode, + SCHEDULE_VIEW_MODE_STORAGE_KEY, } from './src/hooks'; +export { COLUMN_MIN_WIDTH, KANBAN_SCROLL_INNER_PADDING_END_PX } from './src/hooks/useKanbanColumns'; export { DayLessonListMetaProvider, useDayLessonListMeta, diff --git a/packages/modules.calendar/src/hooks/index.ts b/packages/modules.calendar/src/hooks/index.ts index 5f83832b..b48f9ba7 100644 --- a/packages/modules.calendar/src/hooks/index.ts +++ b/packages/modules.calendar/src/hooks/index.ts @@ -13,3 +13,8 @@ export { findCalendarEventByInstanceId, useOpenLessonByInstanceWhenLoaded, } from './useOpenLessonByInstanceWhenLoaded'; +export { + useScheduleViewMode, + type ScheduleViewMode, + SCHEDULE_VIEW_MODE_STORAGE_KEY, +} from './useScheduleViewMode'; diff --git a/packages/modules.calendar/src/hooks/useScheduleViewMode.ts b/packages/modules.calendar/src/hooks/useScheduleViewMode.ts new file mode 100644 index 00000000..9c1854c4 --- /dev/null +++ b/packages/modules.calendar/src/hooks/useScheduleViewMode.ts @@ -0,0 +1,48 @@ +import { useCallback, useEffect, useState } from 'react'; + +export type ScheduleViewMode = 'auto' | 'full-week'; + +export const SCHEDULE_VIEW_MODE_STORAGE_KEY = 'xi_schedule_view_mode'; + +/** Кастомное событие для синхронизации внутри одной вкладки */ +const CHANGE_EVENT = 'xi:schedule-view-mode-change'; + +const readFromStorage = (): ScheduleViewMode => { + try { + const raw = localStorage.getItem(SCHEDULE_VIEW_MODE_STORAGE_KEY); + if (raw === 'full-week') return 'full-week'; + } catch { + // ignore + } + return 'auto'; +}; + +export const writeScheduleViewMode = (mode: ScheduleViewMode) => { + try { + localStorage.setItem(SCHEDULE_VIEW_MODE_STORAGE_KEY, mode); + } catch { + // ignore + } + window.dispatchEvent(new CustomEvent(CHANGE_EVENT)); +}; + +export const useScheduleViewMode = () => { + const [viewMode, setViewModeState] = useState(readFromStorage); + + useEffect(() => { + const handler = () => setViewModeState(readFromStorage()); + window.addEventListener(CHANGE_EVENT, handler); + // Также слушаем storage для синхронизации между вкладками + window.addEventListener('storage', handler); + return () => { + window.removeEventListener(CHANGE_EVENT, handler); + window.removeEventListener('storage', handler); + }; + }, []); + + const setViewMode = useCallback((mode: ScheduleViewMode) => { + writeScheduleViewMode(mode); + }, []); + + return { viewMode, setViewMode }; +}; diff --git a/packages/modules.calendar/src/ui/Calendar.tsx b/packages/modules.calendar/src/ui/Calendar.tsx index 981c0c4e..72884c90 100644 --- a/packages/modules.calendar/src/ui/Calendar.tsx +++ b/packages/modules.calendar/src/ui/Calendar.tsx @@ -1,12 +1,23 @@ import { useEffect, useRef } from 'react'; import { ScheduleKanban, ScheduleMobileView } from './components'; import { CalendarHeader } from './Header'; -import { useIsMobile } from '../hooks'; -import { useKanbanColumns } from '../hooks/useKanbanColumns'; +import { useIsMobile, useScheduleViewMode } from '../hooks'; +import { + useKanbanColumns, + COLUMN_MIN_WIDTH, + KANBAN_SCROLL_INNER_PADDING_END_PX, +} from '../hooks/useKanbanColumns'; import type { ChangeLessonFormData } from 'features.lesson.change'; import type { ICalendarEvent } from './types'; import { useCalendarSchedule } from './CalendarScheduleContext'; +const COLUMN_GAP = 28; +const FULL_WEEK_DAYS = 7; +const FULL_WEEK_MIN_WIDTH = + FULL_WEEK_DAYS * COLUMN_MIN_WIDTH + + (FULL_WEEK_DAYS - 1) * COLUMN_GAP + + KANBAN_SCROLL_INNER_PADDING_END_PX; + type CalendarModuleProps = { /** Вызывается при клике на кнопку добавления занятия (в хедере или в колонке канбана). Дата колонки передаётся при клике в канбане. */ onAddLessonClick?: (date?: Date) => void; @@ -25,22 +36,51 @@ export const CalendarModule = ({ }: CalendarModuleProps) => { const isMobile = useIsMobile(); const containerRef = useRef(null); - const { weekDays, weekStart, goToPrev, goToNext, goToVisibleWindowForDate, setVisibleCount } = - useCalendarSchedule(); + const { + weekDays, + weekStart, + goToPrev, + goToNext, + goToWeekStart, + goToVisibleWindowForDate, + setVisibleCount, + } = useCalendarSchedule(); + + const { viewMode } = useScheduleViewMode(); + const isFullWeek = viewMode === 'full-week'; + const { columnWidth, visibleCount, hasMeasured } = useKanbanColumns( containerRef, weekDays.length, ); - const effectiveVisibleCount = Math.max(1, visibleCount); - // До первого замера ResizeObserver передаём пустой массив дней, чтобы ScheduleKanban - // не рендерил LessonCard (и не запускал useGetClassroom) до определения реального числа колонок. - // useLayoutEffect в useKanbanColumns гарантирует, что hasMeasured=true до первого пейнта. - const kanbanVisibleDays = hasMeasured ? weekDays.slice(0, effectiveVisibleCount) : []; + const effectiveAutoCount = Math.max(1, visibleCount); + + // В режиме full-week всегда отображаем все 7 дней; сообщаем об этом контексту + // чтобы данные загружались для полной недели. + const effectiveVisibleCount = isFullWeek ? FULL_WEEK_DAYS : effectiveAutoCount; + + // В режиме full-week weekStart всегда должен быть понедельником. + // getDay() === 1 — понедельник, goToWeekStart вызовет startOfWeek(..., { weekStartsOn: 1 }). + useEffect(() => { + if (!isFullWeek || weekStart.getDay() === 1) return; + goToWeekStart(weekStart); + }, [isFullWeek, weekStart, goToWeekStart]); useEffect(() => { setVisibleCount(effectiveVisibleCount); }, [effectiveVisibleCount, setVisibleCount]); + // До первого замера ResizeObserver передаём пустой массив дней, чтобы ScheduleKanban + // не рендерил LessonCard (и не запускал useGetClassroom) до определения реального числа колонок. + // useLayoutEffect в useKanbanColumns гарантирует, что hasMeasured=true до первого пейнта. + const kanbanVisibleDays = isFullWeek + ? weekDays + : hasMeasured + ? weekDays.slice(0, effectiveAutoCount) + : []; + + const kanbanColumnWidth = isFullWeek ? COLUMN_MIN_WIDTH : columnWidth; + if (isMobile) { return (
@@ -60,19 +100,44 @@ export const CalendarModule = ({ visibleDayCount={effectiveVisibleCount} onPrev={() => goToPrev(effectiveVisibleCount)} onNext={() => goToNext(effectiveVisibleCount)} - onWeekSelect={(date, count) => goToVisibleWindowForDate(date, count)} + onWeekSelect={(date, count) => + isFullWeek ? goToWeekStart(date) : goToVisibleWindowForDate(date, count) + } onAddLessonClick={onAddLessonClick != null ? () => onAddLessonClick() : undefined} showDateTime={showDateTimeInHeader} />
-
- +
+ {isFullWeek ? ( +
+ +
+ ) : ( + + )}
diff --git a/packages/modules.calendar/src/ui/components/ScheduleKanban/ScheduleKanban.tsx b/packages/modules.calendar/src/ui/components/ScheduleKanban/ScheduleKanban.tsx index 9f680b8f..d49d39b4 100644 --- a/packages/modules.calendar/src/ui/components/ScheduleKanban/ScheduleKanban.tsx +++ b/packages/modules.calendar/src/ui/components/ScheduleKanban/ScheduleKanban.tsx @@ -37,6 +37,8 @@ type ScheduleKanbanProps = { openLessonInstanceId?: string | null; /** Сообщить странице, что диплинк обработан (событие найдено или нет) */ onOpenLessonInstanceConsumed?: () => void; + /** Разрешить горизонтальный overflow (для режима 7 дней с прокруткой на уровне родителя) */ + allowHorizontalOverflow?: boolean; }; const getEventsForDay = ( @@ -83,6 +85,7 @@ export const ScheduleKanban: FC = ({ hideLessonCardClassroomAndSubject = false, openLessonInstanceId, onOpenLessonInstanceConsumed, + allowHorizontalOverflow = false, }) => { const { t } = useTranslation('calendar'); const eventsByDate = useEventsByDate(); @@ -135,7 +138,12 @@ export const ScheduleKanban: FC = ({ n > 0 ? `repeat(${n}, minmax(${Math.max(columnWidth, 0)}px, 1fr))` : '1fr'; return ( -
+
{/* Заголовки дней — без скролла */}
= ({ {/* Один общий вертикальный скролл для всех колонок */}
diff --git a/packages/modules.profile/src/ui/Customization.tsx b/packages/modules.profile/src/ui/Customization.tsx index d14bed12..5b355600 100644 --- a/packages/modules.profile/src/ui/Customization.tsx +++ b/packages/modules.profile/src/ui/Customization.tsx @@ -1,5 +1,5 @@ import { Badge } from '@xipkg/badge'; -import { Palette } from '@xipkg/icons'; +import { Calendar, Palette } from '@xipkg/icons'; import { useMediaQuery } from '@xipkg/utils'; import { Select, @@ -9,8 +9,21 @@ import { SelectTrigger, SelectValue, } from '@xipkg/select'; +import { Toggle } from '@xipkg/toggle'; import { useTheme, type ThemeItemT, type ThemeT } from 'common.theme'; import { useSupportModalStore } from 'common.ui'; +import { useCallback, useState } from 'react'; + +const SCHEDULE_VIEW_MODE_KEY = 'xi_schedule_view_mode'; +const SCHEDULE_VIEW_MODE_CHANGE_EVENT = 'xi:schedule-view-mode-change'; + +const readScheduleViewMode = (): boolean => { + try { + return localStorage.getItem(SCHEDULE_VIEW_MODE_KEY) === 'full-week'; + } catch { + return false; + } +}; const ThemeOptionLabel = ({ item }: { item: ThemeItemT }) => ( @@ -33,6 +46,18 @@ export const Customization = () => { const openSupportModal = useSupportModalStore((state) => state.open); const selectedTheme = themes.find((item) => item.value === theme); + const [isFullWeek, setIsFullWeekState] = useState(readScheduleViewMode); + + const handleFullWeekToggle = useCallback((checked: boolean) => { + try { + localStorage.setItem(SCHEDULE_VIEW_MODE_KEY, checked ? 'full-week' : 'auto'); + } catch { + // ignore + } + window.dispatchEvent(new CustomEvent(SCHEDULE_VIEW_MODE_CHANGE_EVENT)); + setIsFullWeekState(checked); + }, []); + return ( <> {!isMobile && ( @@ -88,6 +113,35 @@ export const Customization = () => {

+ + {!isMobile && ( +
+
+ Расписание +
+
+
+
+ +
+ + Показывать все 7 дней + + + Все дни недели (Пн–Вс) с горизонтальной прокруткой + +
+
+ +
+
+
+ )} ); }; diff --git a/packages/pages.classroom/src/ui/Calendar/ClassroomScheduleContext.tsx b/packages/pages.classroom/src/ui/Calendar/ClassroomScheduleContext.tsx index 4816882c..72369f43 100644 --- a/packages/pages.classroom/src/ui/Calendar/ClassroomScheduleContext.tsx +++ b/packages/pages.classroom/src/ui/Calendar/ClassroomScheduleContext.tsx @@ -67,6 +67,13 @@ export const useClassroomSchedule = () => { return ctx; }; +/** + * Безопасный вариант для компонентов, которые могут рендериться в момент + * монтирования/размонтирования провайдера (например, вне вкладки Расписания). + */ +// eslint-disable-next-line react-refresh/only-export-components +export const useClassroomScheduleOptional = () => useContext(ClassroomScheduleContext); + type ClassroomScheduleProviderProps = { children: ReactNode; onAddLessonClick?: (date?: Date) => void; diff --git a/packages/pages.classroom/src/ui/Calendar/ClassroomScheduleParts.tsx b/packages/pages.classroom/src/ui/Calendar/ClassroomScheduleParts.tsx index 66ce33c1..d3d0a1d7 100644 --- a/packages/pages.classroom/src/ui/Calendar/ClassroomScheduleParts.tsx +++ b/packages/pages.classroom/src/ui/Calendar/ClassroomScheduleParts.tsx @@ -1,18 +1,47 @@ import { useEffect, useRef } from 'react'; -import { CalendarWeekNav, ScheduleKanban, useKanbanColumns } from 'modules.calendar'; +import { + CalendarWeekNav, + COLUMN_MIN_WIDTH, + KANBAN_SCROLL_INNER_PADDING_END_PX, + SCHEDULE_VIEW_MODE_STORAGE_KEY, + ScheduleKanban, + useKanbanColumns, + useScheduleViewMode, +} from 'modules.calendar'; import type { ChangeLessonFormData, ICalendarEvent } from 'modules.calendar'; import { useClassroomSchedule } from './ClassroomScheduleContext'; +const COLUMN_GAP = 28; +const FULL_WEEK_DAYS = 7; +const FULL_WEEK_MIN_WIDTH = + FULL_WEEK_DAYS * COLUMN_MIN_WIDTH + + (FULL_WEEK_DAYS - 1) * COLUMN_GAP + + KANBAN_SCROLL_INNER_PADDING_END_PX; + export const CalendarScheduleToolbar = () => { - const { visibleDayCount, weekStart, goToPrev, goToNext, goToVisibleWindowForDate } = - useClassroomSchedule(); + const { + visibleDayCount, + weekStart, + goToPrev, + goToNext, + goToWeekStart, + goToVisibleWindowForDate, + } = useClassroomSchedule(); + + // Читаем напрямую — toolbar ренедерится при смене visibleDayCount через контекст + const isFullWeek = + typeof window !== 'undefined' && + localStorage.getItem(SCHEDULE_VIEW_MODE_STORAGE_KEY) === 'full-week'; + return ( goToPrev(visibleDayCount)} onNext={() => goToNext(visibleDayCount)} - onWeekSelect={goToVisibleWindowForDate} + onWeekSelect={(date, count) => + isFullWeek ? goToWeekStart(date) : goToVisibleWindowForDate(date, count) + } /> ); }; @@ -30,28 +59,70 @@ export const CalendarScheduleKanban = ({ openLessonInstanceId, onOpenLessonInstanceConsumed, }: CalendarScheduleKanbanProps) => { - const { weekDays, onAddLessonClick, setVisibleCount } = useClassroomSchedule(); + const { weekDays, weekStart, onAddLessonClick, setVisibleCount, goToWeekStart } = + useClassroomSchedule(); + + const { viewMode } = useScheduleViewMode(); + const isFullWeek = viewMode === 'full-week'; const containerRef = useRef(null); const { columnWidth, visibleCount } = useKanbanColumns(containerRef, weekDays.length); - const visibleDays = weekDays.slice(0, visibleCount); + const effectiveAutoCount = Math.max(1, visibleCount); + + const effectiveVisibleCount = isFullWeek ? FULL_WEEK_DAYS : effectiveAutoCount; + // В режиме full-week weekStart всегда должен быть понедельником. + // getDay() === 1 — понедельник, goToWeekStart вызовет startOfWeek(..., { weekStartsOn: 1 }). useEffect(() => { - setVisibleCount(visibleCount); - }, [visibleCount, setVisibleCount]); + if (!isFullWeek || weekStart.getDay() === 1) return; + goToWeekStart(weekStart); + }, [isFullWeek, weekStart, goToWeekStart]); + + useEffect(() => { + setVisibleCount(effectiveVisibleCount); + }, [effectiveVisibleCount, setVisibleCount]); + + const visibleDays = isFullWeek ? weekDays : weekDays.slice(0, effectiveAutoCount); + const kanbanColumnWidth = isFullWeek ? COLUMN_MIN_WIDTH : columnWidth; return ( -
- onAddLessonClick(date) : undefined} - onLessonReschedule={onLessonReschedule} - onSaveLesson={onSaveLesson} - hideLessonCardClassroomAndSubject - openLessonInstanceId={openLessonInstanceId ?? null} - onOpenLessonInstanceConsumed={onOpenLessonInstanceConsumed} - /> +
+ {isFullWeek ? ( +
+ onAddLessonClick(date) : undefined} + onLessonReschedule={onLessonReschedule} + onSaveLesson={onSaveLesson} + hideLessonCardClassroomAndSubject + openLessonInstanceId={openLessonInstanceId ?? null} + onOpenLessonInstanceConsumed={onOpenLessonInstanceConsumed} + allowHorizontalOverflow + /> +
+ ) : ( + onAddLessonClick(date) : undefined} + onLessonReschedule={onLessonReschedule} + onSaveLesson={onSaveLesson} + hideLessonCardClassroomAndSubject + openLessonInstanceId={openLessonInstanceId ?? null} + onOpenLessonInstanceConsumed={onOpenLessonInstanceConsumed} + /> + )}
); }; diff --git a/packages/pages.classroom/src/ui/Overview/UpcomingLessonsSection.tsx b/packages/pages.classroom/src/ui/Overview/UpcomingLessonsSection.tsx index dbf2ff9e..cd20641b 100644 --- a/packages/pages.classroom/src/ui/Overview/UpcomingLessonsSection.tsx +++ b/packages/pages.classroom/src/ui/Overview/UpcomingLessonsSection.tsx @@ -20,7 +20,7 @@ import { type SoleRescheduleTarget, } from 'features.lesson.move'; import { CancelLessonModal, type LessonSchedulerMetaForCancel } from 'features.lesson.cancel'; -import { useClassroomSchedule } from '../Calendar/ClassroomScheduleContext'; +import { useClassroomScheduleOptional } from '../Calendar/ClassroomScheduleContext'; import { UpcomingLessonCard } from './UpcomingLessonCard'; import { UpcomingLessonCardSkeleton } from './UpcomingLessonCardSkeleton'; @@ -110,7 +110,8 @@ function getSoleTarget(item: ScheduleItem, classroomId: number): SoleRescheduleT export const UpcomingLessonsSection = () => { const { data: user, isLoading: isUserLoading } = useCurrentUser(); const isTutor = user?.default_layout === 'tutor'; - const { onAddLessonClick } = useClassroomSchedule(); + const scheduleCtx = useClassroomScheduleOptional(); + const onAddLessonClick = scheduleCtx?.onAddLessonClick; const { classroomId: classroomIdParam } = useParams({ from: '/(app)/_layout/classrooms/$classroomId/', });