From 66935b04f8daad871ca3cc0d6c98fe07abb6f54b Mon Sep 17 00:00:00 2001 From: Max Ronzhin Date: Sun, 26 Jul 2026 23:34:49 +0300 Subject: [PATCH] =?UTF-8?q?refactor(calendar):=20=D1=87=D0=B8=D0=BF=20?= =?UTF-8?q?=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D1=8F=20=E2=80=94=20=D1=82?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B8=20=D0=BA=D0=B0=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B4=D0=B0=D1=88=D0=B8=D0=BA,=20=D0=B7=D0=B0=D0=B3=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D0=BE=D0=BA=20=D0=BF=D0=B5=D1=80=D0=B8=D0=BE?= =?UTF-8?q?=D0=B4=D0=B0=20=D0=B1=D0=B5=D0=B7=20=C2=AB=D0=B3.=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В renderEventContent убраны бейдж статуса и время (переехали в ConferenceOccurrenceDialog) — остались название с обрезкой по многоточию (min-width:0/overflow:hidden по цепочке флекс-контейнеров, чинит вылезание текста за границы ячейки) и, у владельца конференции, карандашик редактирования (myConferenceById/onEditClick, stopPropagation, чтобы не срабатывал клик по чипу). Клик по чипу — всегда информационное окно, включая свои конференции. titleFormat для dayGridWeek/dayGridMonth построен на Intl.DateTimeFormat вместо ruLocale-форматтера — тот всегда добавляет «г.» после года. --- .../calendar/ConferenceCalendar.tsx | 156 ++++++++++++------ frontend/src/styles/calendar.css | 37 ++++- 2 files changed, 137 insertions(+), 56 deletions(-) diff --git a/frontend/src/components/calendar/ConferenceCalendar.tsx b/frontend/src/components/calendar/ConferenceCalendar.tsx index 2efc020..5504826 100644 --- a/frontend/src/components/calendar/ConferenceCalendar.tsx +++ b/frontend/src/components/calendar/ConferenceCalendar.tsx @@ -5,8 +5,8 @@ import type { EventClickArg, EventHoveringArg } from '@fullcalendar/core' import dayGridPlugin from '@fullcalendar/daygrid' import interactionPlugin from '@fullcalendar/interaction' import ruLocale from '@fullcalendar/core/locales/ru' -import { CheckCircle2, ChevronLeft, ChevronRight, Clock, Lock, Play, Repeat } from 'lucide-react' -import type { OccurrenceOut } from '@/api/conferences' +import { ChevronLeft, ChevronRight, Lock, Pencil } from 'lucide-react' +import type { ConferenceOut, OccurrenceOut } from '@/api/conferences' import { ConferenceHoverCard } from '@/components/ui/ConferenceHoverCard' /* @@ -35,6 +35,16 @@ const HIDDEN_DAYS_NONE: number[] = [] */ const eventFocusCleanups = new WeakMap void>() +/** + * Актуальный колбэк редактирования из последнего рендера + * `ConferenceCalendar`. `renderEventContent` обязан оставаться одной и той же + * функцией на всё время жизни модуля (см. комментарий выше про + * resetOptions/бесконечный цикл), поэтому не может напрямую замкнуть пропс + * компонента — вместо этого читает его отсюда, а компонент синхронизирует + * переменную эффектом при каждом изменении пропса. + */ +let latestOnEditClick: (conference: ConferenceOut) => void = () => {} + export type CalendarViewMode = 'dayGridWeek' | 'dayGridMonth' interface ConferenceCalendarProps { @@ -42,74 +52,113 @@ interface ConferenceCalendarProps { onViewChange: (view: CalendarViewMode) => void occurrences: OccurrenceOut[] isLoading: boolean + /** Владельческие конференции текущего пользователя (`/conferences/my`, только свои — `is_owner`) — определяет, показывать ли карандашик на чипе. */ + myConferenceById: Map /** Сообщает видимый диапазон сетки (UTC ISO) — используется для запроса вхождений. */ onRangeChange: (fromIso: string, toIso: string) => void onOccurrenceClick: (occurrence: OccurrenceOut) => void + onEditClick: (conference: ConferenceOut) => void } -type BadgeKind = 'scheduled' | 'pinned' | 'live' | 'ended' - -function occurrenceBadge(occ: OccurrenceOut, start: Date | null, end: Date | null): { kind: BadgeKind; label: string } { - const now = new Date() - if (start && end && now >= start && now <= end) return { kind: 'live', label: 'Идёт сейчас' } - if (end && now > end) return { kind: 'ended', label: 'Завершена' } - if (occ.is_pinned) return { kind: 'pinned', label: 'Закреплена' } - return { kind: 'scheduled', label: 'Запланирована' } -} - -const BADGE_ICON: Record = { - scheduled: Clock, - pinned: Repeat, - live: Play, - ended: CheckCircle2, -} - -/** Не зависит от пропсов/состояния компонента — модульная константа-функция (см. комментарий выше про стабильность identity). */ +/** + * Не зависит от пропсов/состояния компонента — модульная константа-функция + * (см. комментарий выше про стабильность identity). В чипе — только название + * (обрезается многоточием по цепочке min-width:0/overflow:hidden ниже по + * дереву, см. calendar.css) и, у владельца, карандашик редактирования; время/ + * статус/номер — только в информационном окне (`ConferenceOccurrenceDialog`). + */ function renderEventContent(arg: EventContentArg) { const occ = arg.event.extendedProps.occurrence as OccurrenceOut - const start = arg.event.start - const end = arg.event.end - const badge = occurrenceBadge(occ, start, end) - const Icon = BADGE_ICON[badge.kind] - const timeLabel = - start && end - ? `${start.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}–${end.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}` - : '' + const ownedConference = arg.event.extendedProps.ownedConference as ConferenceOut | undefined return ( -
- - - {timeLabel} +
{occ.is_closed && + {ownedConference && ( + + )}
) } +/** «г.» — сноска Intl.DateTimeFormat('ru-RU', ...) после года при формате year+month(+day); в подписи периода не нужна. */ +const YEAR_SUFFIX_RE = /\s*г\.(?=\s|$)/g + +/** + * Форма аргумента, который FullCalendar передаёт функции-`titleFormat` + * (`VerboseFormattingArg` из @fullcalendar/core/internal-common — не + * реэкспортируется из публичного `@fullcalendar/core`, поэтому типизируем + * структурно сами, только нужные поля). + */ +interface TitleFormatArg { + start: { marker: Date } + end?: { marker: Date } +} + +/** + * Строит titleFormat-функцию FullCalendar на Intl.DateTimeFormat вместо + * ruLocale-зависимого форматтера — тот всегда добавляет «г.» после года. + * FullCalendar передаёт границы диапазона уже с учётом exclusivity (конец + * периода минус 1мс, см. `dateEnv.formatRange` в @fullcalendar/core) — этого + * достаточно, чтобы Intl.formatRange сам корректно схлопнул одинаковые + * месяц/год и красиво отформатировал переход через месяц/год. + */ +function buildTitleFormatter(options: Intl.DateTimeFormatOptions) { + const formatter = new Intl.DateTimeFormat('ru-RU', { ...options, timeZone: 'UTC' }) + return (arg: TitleFormatArg): string => { + const raw = arg.end ? formatter.formatRange(arg.start.marker, arg.end.marker) : formatter.format(arg.start.marker) + return raw.replace(YEAR_SUFFIX_RE, '') + } +} + +const CALENDAR_VIEWS = { + dayGridWeek: { titleFormat: buildTitleFormatter({ year: 'numeric', month: 'long', day: 'numeric' }) }, + dayGridMonth: { titleFormat: buildTitleFormatter({ year: 'numeric', month: 'long' }) }, +} + /** * Обёртка над FullCalendar (см. design/mockups/calendar.html): недельная * (только будни, 5 колонок) / месячная сетка вхождений конференций, свой * тулбар (вид, навигация по датам) вместо встроенного headerToolbar. - * События рендерятся как «чипы» (`.conf-chip`) со статус-бейджем §4.6 - * DESIGN_SYSTEM.md — вместо позиционирования по времени (timeGrid), т.к. - * менеджер конференций показывает список вхождений по дням, не занятость - * переговорной по часам (переговорных комнат в модели нет). + * События рендерятся как «чипы» (`.conf-chip`) — только название (+карандашик + * у владельца), статус/время/номер — в информационном окне по клику + * (`ConferenceOccurrenceDialog`) — вместо позиционирования по времени + * (timeGrid), т.к. менеджер конференций показывает список вхождений по дням, + * не занятость переговорной по часам (переговорных комнат в модели нет). */ export function ConferenceCalendar({ view, onViewChange, occurrences, isLoading, + myConferenceById, onRangeChange, onOccurrenceClick, + onEditClick, }: ConferenceCalendarProps) { const calendarRef = useRef(null) const titleRef = useRef(null) + // См. комментарий у `latestOnEditClick` в начале файла — синхронизируем + // переменную вместо того, чтобы замыкать пропс напрямую в renderEventContent. + useEffect(() => { + latestOnEditClick = onEditClick + }, [onEditClick]) + // Ховер-карточка конференции — состояние держим внутри // компонента (не поднимаем в CalendarPage), чтобы колбэки FullCalendar ниже // оставались стабильными по identity между рендерами (см. комментарий в @@ -159,20 +208,24 @@ export function ConferenceCalendar({ calendarRef.current?.getApi().today() } - // Мемоизировано по `occurrences` — иначе на каждый рендер создавался бы - // новый массив-`events` той же длины/содержимого, и FullCalendar считал бы - // его «изменившейся опцией» (см. комментарий у модульных констант выше). + // Мемоизировано по `occurrences`/`myConferenceById` — иначе на каждый рендер + // создавался бы новый массив-`events` той же длины/содержимого, и + // FullCalendar считал бы его «изменившейся опцией» (см. комментарий у + // модульных констант выше). const events: EventInput[] = useMemo( () => - occurrences.map((occ) => ({ - id: `${occ.conference_id}:${occ.starts_at}`, - title: occ.title ?? 'Конференция без названия', - start: occ.starts_at, - end: occ.ends_at, - classNames: ['conf-chip'], - extendedProps: { occurrence: occ }, - })), - [occurrences], + occurrences.map((occ) => { + const owned = myConferenceById.get(occ.conference_id) + return { + id: `${occ.conference_id}:${occ.starts_at}`, + title: occ.title ?? 'Конференция без названия', + start: occ.starts_at, + end: occ.ends_at, + classNames: ['conf-chip'], + extendedProps: { occurrence: occ, ownedConference: owned?.is_owner ? owned : undefined }, + } + }), + [occurrences, myConferenceById], ) const handleEventClick = useCallback( @@ -228,6 +281,7 @@ export function ConferenceCalendar({ hiddenDays={view === 'dayGridWeek' ? HIDDEN_DAYS_BUSINESS_WEEK : HIDDEN_DAYS_NONE} height="auto" dayMaxEvents={false} + views={CALENDAR_VIEWS} events={events} eventContent={renderEventContent} eventClick={handleEventClick} diff --git a/frontend/src/styles/calendar.css b/frontend/src/styles/calendar.css index bd763c3..9cb034e 100644 --- a/frontend/src/styles/calendar.css +++ b/frontend/src/styles/calendar.css @@ -70,12 +70,15 @@ * Событие-вхождение конференции — карточка-«чип» в духе .conf-chip мокапа. * FullCalendar оборачивает событие в .fc-daygrid-event (наследует классы из * `classNames`) — переопределяем его внешний вид под чип целиком, без - * отдельного контейнера. + * отдельного контейнера. Содержимое — только название (+карандашик у + * владельца, время/статус/номер — в ConferenceOccurrenceDialog), поэтому чип — + * одна строка (flex row), а не колонка бейдж/время/название, как раньше. */ .vc-calendar .fc-daygrid-event.conf-chip { display: flex !important; - flex-direction: column; - gap: 4px; + align-items: center; + gap: 8px; + min-width: 0; background: var(--color-surface) !important; border: 1px solid var(--color-border) !important; border-radius: var(--radius-md) !important; @@ -88,10 +91,34 @@ .vc-calendar .fc-daygrid-event.conf-chip .fc-event-time, .vc-calendar .fc-daygrid-event.conf-chip .fc-event-title { display: none; } /* заменено собственным eventContent */ -.chip-time { display: block; font: var(--text-caption); color: var(--color-ink-500); text-transform: none; letter-spacing: normal; } -.chip-title { display: flex; align-items: center; gap: 6px; font: var(--text-body); font-weight: 700; color: var(--color-ink-900); } +/* Цепочка min-width:0 + overflow:hidden + text-overflow:ellipsis — без неё + длинное название распирает чип и вылезает за границы ячейки (в узкой + недельной колонке — особенно). `.fc-daygrid-event-harness` — прямой + flex-item `.fc-daygrid-day-events` (флекс-колонка) выше по файлу: по + умолчанию у флекс-айтема min-width:auto (= min-content), из-за чего он не + сжимается уже ширины своего содержимого — тот самый grid/flex-blowout. */ +.vc-calendar .fc-daygrid-event-harness { min-width: 0; } +.chip-row { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; } +.chip-title { display: flex; align-items: center; gap: 6px; font: var(--text-body); font-weight: 700; color: var(--color-ink-900); min-width: 0; flex: 1; } +.chip-title-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } .chip-lock { width: 13px; height: 13px; flex-shrink: 0; } +.chip-edit { + width: 22px; + height: 22px; + flex-shrink: 0; + border-radius: 50%; + border: none; + background: transparent; + color: var(--color-ink-500); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} +.chip-edit svg { width: 13px; height: 13px; } +.chip-edit:hover { background: var(--color-bg-alt); color: var(--color-ink-900); } + .conf-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 9px; border-radius: var(--radius-full); font: var(--text-caption); font-size: 11px; width: fit-content; } .conf-badge.scheduled { background: var(--color-warning-bg); color: var(--color-warning); } .conf-badge.pinned { background: var(--color-bg-alt); color: var(--color-ink-700); border: 1px solid var(--color-border-strong); }