Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/modules.calendar/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions packages/modules.calendar/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,8 @@ export {
findCalendarEventByInstanceId,
useOpenLessonByInstanceWhenLoaded,
} from './useOpenLessonByInstanceWhenLoaded';
export {
useScheduleViewMode,
type ScheduleViewMode,
SCHEDULE_VIEW_MODE_STORAGE_KEY,
} from './useScheduleViewMode';
48 changes: 48 additions & 0 deletions packages/modules.calendar/src/hooks/useScheduleViewMode.ts
Original file line number Diff line number Diff line change
@@ -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<ScheduleViewMode>(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 };
};
101 changes: 83 additions & 18 deletions packages/modules.calendar/src/ui/Calendar.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -25,22 +36,51 @@ export const CalendarModule = ({
}: CalendarModuleProps) => {
const isMobile = useIsMobile();
const containerRef = useRef<HTMLDivElement>(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 (
<div className="flex h-[calc(100dvh-64px)] min-h-0 flex-col">
Expand All @@ -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}
/>
<div className="bg-gray-0 flex min-h-0 min-w-0 flex-1 flex-col rounded-tl-2xl pl-4">
<div ref={containerRef} className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<ScheduleKanban
visibleDays={kanbanVisibleDays}
columnWidth={columnWidth}
onAddLessonClick={onAddLessonClick}
onLessonReschedule={onLessonReschedule}
onSaveLesson={onSaveLesson}
/>
<div
ref={containerRef}
className={
isFullWeek
? 'flex min-h-0 flex-1 overflow-x-auto overflow-y-hidden'
: 'flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden'
}
>
{isFullWeek ? (
<div
style={{ minWidth: FULL_WEEK_MIN_WIDTH }}
className="flex h-full min-h-0 flex-1 flex-col"
>
<ScheduleKanban
visibleDays={kanbanVisibleDays}
columnWidth={kanbanColumnWidth}
onAddLessonClick={onAddLessonClick}
onLessonReschedule={onLessonReschedule}
onSaveLesson={onSaveLesson}
allowHorizontalOverflow
/>
</div>
) : (
<ScheduleKanban
visibleDays={kanbanVisibleDays}
columnWidth={kanbanColumnWidth}
onAddLessonClick={onAddLessonClick}
onLessonReschedule={onLessonReschedule}
onSaveLesson={onSaveLesson}
/>
)}
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ type ScheduleKanbanProps = {
openLessonInstanceId?: string | null;
/** Сообщить странице, что диплинк обработан (событие найдено или нет) */
onOpenLessonInstanceConsumed?: () => void;
/** Разрешить горизонтальный overflow (для режима 7 дней с прокруткой на уровне родителя) */
allowHorizontalOverflow?: boolean;
};

const getEventsForDay = (
Expand Down Expand Up @@ -83,6 +85,7 @@ export const ScheduleKanban: FC<ScheduleKanbanProps> = ({
hideLessonCardClassroomAndSubject = false,
openLessonInstanceId,
onOpenLessonInstanceConsumed,
allowHorizontalOverflow = false,
}) => {
const { t } = useTranslation('calendar');
const eventsByDate = useEventsByDate();
Expand Down Expand Up @@ -135,7 +138,12 @@ export const ScheduleKanban: FC<ScheduleKanbanProps> = ({
n > 0 ? `repeat(${n}, minmax(${Math.max(columnWidth, 0)}px, 1fr))` : '1fr';

return (
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div
className={cn(
'flex h-full min-h-0 flex-1 flex-col',
allowHorizontalOverflow ? 'overflow-y-hidden' : 'min-w-0 overflow-hidden',
)}
>
{/* Заголовки дней — без скролла */}
<div
className="grid shrink-0 gap-x-7 pr-3"
Expand Down Expand Up @@ -190,7 +198,10 @@ export const ScheduleKanban: FC<ScheduleKanbanProps> = ({
{/* Один общий вертикальный скролл для всех колонок */}
<div
ref={scrollAreaRef}
className="flex min-h-0 flex-1 [scrollbar-gutter:stable] flex-col overflow-x-hidden overflow-y-auto pr-3"
className={cn(
'flex min-h-0 flex-1 [scrollbar-gutter:stable] flex-col overflow-y-auto pr-3',
allowHorizontalOverflow ? 'overflow-x-visible' : 'overflow-x-hidden',
)}
>
<div className="box-border flex min-h-full min-w-0 flex-1 flex-col">
<div className="grid flex-1 items-stretch gap-x-7 pb-4" style={{ gridTemplateColumns }}>
Expand Down
56 changes: 55 additions & 1 deletion packages/modules.profile/src/ui/Customization.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 }) => (
<span className="flex items-center gap-2">
Expand All @@ -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<boolean>(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 && (
Expand Down Expand Up @@ -88,6 +113,35 @@ export const Customization = () => {
</p>
</div>
</div>

{!isMobile && (
<div className="border-gray-80 mt-4 flex w-full flex-col rounded-2xl border p-1">
<div className="flex w-full flex-col p-3">
<span className="text-xl font-semibold dark:text-gray-100">Расписание</span>
</div>
<div className="mt-2 flex w-full flex-col gap-3 p-3">
<div className="flex w-full flex-row items-center justify-between gap-4">
<div className="flex flex-row gap-4">
<Calendar className="fill-brand-80" />
<div className="flex flex-col gap-0.5">
<span className="text-base leading-[24px] font-semibold dark:text-gray-100">
Показывать все 7 дней
</span>
<span className="text-gray-60 text-s-base">
Все дни недели (Пн–Вс) с горизонтальной прокруткой
</span>
</div>
</div>
<Toggle
checked={isFullWeek}
size="s"
onCheckedChange={handleFullWeekToggle}
className="shrink-0"
/>
</div>
</div>
</div>
)}
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading