refactor(calendar): чип события — только название и карандашик, заголовок периода без «г.»
В renderEventContent убраны бейдж статуса и время (переехали в ConferenceOccurrenceDialog) — остались название с обрезкой по многоточию (min-width:0/overflow:hidden по цепочке флекс-контейнеров, чинит вылезание текста за границы ячейки) и, у владельца конференции, карандашик редактирования (myConferenceById/onEditClick, stopPropagation, чтобы не срабатывал клик по чипу). Клик по чипу — всегда информационное окно, включая свои конференции. titleFormat для dayGridWeek/dayGridMonth построен на Intl.DateTimeFormat вместо ruLocale-форматтера — тот всегда добавляет «г.» после года.
This commit is contained in:
@@ -5,8 +5,8 @@ import type { EventClickArg, EventHoveringArg } from '@fullcalendar/core'
|
|||||||
import dayGridPlugin from '@fullcalendar/daygrid'
|
import dayGridPlugin from '@fullcalendar/daygrid'
|
||||||
import interactionPlugin from '@fullcalendar/interaction'
|
import interactionPlugin from '@fullcalendar/interaction'
|
||||||
import ruLocale from '@fullcalendar/core/locales/ru'
|
import ruLocale from '@fullcalendar/core/locales/ru'
|
||||||
import { CheckCircle2, ChevronLeft, ChevronRight, Clock, Lock, Play, Repeat } from 'lucide-react'
|
import { ChevronLeft, ChevronRight, Lock, Pencil } from 'lucide-react'
|
||||||
import type { OccurrenceOut } from '@/api/conferences'
|
import type { ConferenceOut, OccurrenceOut } from '@/api/conferences'
|
||||||
import { ConferenceHoverCard } from '@/components/ui/ConferenceHoverCard'
|
import { ConferenceHoverCard } from '@/components/ui/ConferenceHoverCard'
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -35,6 +35,16 @@ const HIDDEN_DAYS_NONE: number[] = []
|
|||||||
*/
|
*/
|
||||||
const eventFocusCleanups = new WeakMap<HTMLElement, () => void>()
|
const eventFocusCleanups = new WeakMap<HTMLElement, () => void>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Актуальный колбэк редактирования из последнего рендера
|
||||||
|
* `ConferenceCalendar`. `renderEventContent` обязан оставаться одной и той же
|
||||||
|
* функцией на всё время жизни модуля (см. комментарий выше про
|
||||||
|
* resetOptions/бесконечный цикл), поэтому не может напрямую замкнуть пропс
|
||||||
|
* компонента — вместо этого читает его отсюда, а компонент синхронизирует
|
||||||
|
* переменную эффектом при каждом изменении пропса.
|
||||||
|
*/
|
||||||
|
let latestOnEditClick: (conference: ConferenceOut) => void = () => {}
|
||||||
|
|
||||||
export type CalendarViewMode = 'dayGridWeek' | 'dayGridMonth'
|
export type CalendarViewMode = 'dayGridWeek' | 'dayGridMonth'
|
||||||
|
|
||||||
interface ConferenceCalendarProps {
|
interface ConferenceCalendarProps {
|
||||||
@@ -42,74 +52,113 @@ interface ConferenceCalendarProps {
|
|||||||
onViewChange: (view: CalendarViewMode) => void
|
onViewChange: (view: CalendarViewMode) => void
|
||||||
occurrences: OccurrenceOut[]
|
occurrences: OccurrenceOut[]
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
|
/** Владельческие конференции текущего пользователя (`/conferences/my`, только свои — `is_owner`) — определяет, показывать ли карандашик на чипе. */
|
||||||
|
myConferenceById: Map<string, ConferenceOut>
|
||||||
/** Сообщает видимый диапазон сетки (UTC ISO) — используется для запроса вхождений. */
|
/** Сообщает видимый диапазон сетки (UTC ISO) — используется для запроса вхождений. */
|
||||||
onRangeChange: (fromIso: string, toIso: string) => void
|
onRangeChange: (fromIso: string, toIso: string) => void
|
||||||
onOccurrenceClick: (occurrence: OccurrenceOut) => 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 } {
|
* (см. комментарий выше про стабильность identity). В чипе — только название
|
||||||
const now = new Date()
|
* (обрезается многоточием по цепочке min-width:0/overflow:hidden ниже по
|
||||||
if (start && end && now >= start && now <= end) return { kind: 'live', label: 'Идёт сейчас' }
|
* дереву, см. calendar.css) и, у владельца, карандашик редактирования; время/
|
||||||
if (end && now > end) return { kind: 'ended', label: 'Завершена' }
|
* статус/номер — только в информационном окне (`ConferenceOccurrenceDialog`).
|
||||||
if (occ.is_pinned) return { kind: 'pinned', label: 'Закреплена' }
|
*/
|
||||||
return { kind: 'scheduled', label: 'Запланирована' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const BADGE_ICON: Record<BadgeKind, typeof Clock> = {
|
|
||||||
scheduled: Clock,
|
|
||||||
pinned: Repeat,
|
|
||||||
live: Play,
|
|
||||||
ended: CheckCircle2,
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Не зависит от пропсов/состояния компонента — модульная константа-функция (см. комментарий выше про стабильность identity). */
|
|
||||||
function renderEventContent(arg: EventContentArg) {
|
function renderEventContent(arg: EventContentArg) {
|
||||||
const occ = arg.event.extendedProps.occurrence as OccurrenceOut
|
const occ = arg.event.extendedProps.occurrence as OccurrenceOut
|
||||||
const start = arg.event.start
|
const ownedConference = arg.event.extendedProps.ownedConference as ConferenceOut | undefined
|
||||||
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' })}`
|
|
||||||
: ''
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="chip-row">
|
||||||
<span className={`conf-badge ${badge.kind}`}>
|
|
||||||
<Icon style={{ width: 11, height: 11 }} aria-hidden="true" />
|
|
||||||
{badge.label}
|
|
||||||
</span>
|
|
||||||
<span className="chip-time">{timeLabel}</span>
|
|
||||||
<span className="chip-title">
|
<span className="chip-title">
|
||||||
{occ.is_closed && <Lock className="chip-lock" aria-hidden="true" />}
|
{occ.is_closed && <Lock className="chip-lock" aria-hidden="true" />}
|
||||||
{arg.event.title}
|
<span className="chip-title-text">{arg.event.title}</span>
|
||||||
</span>
|
</span>
|
||||||
|
{ownedConference && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="chip-edit"
|
||||||
|
aria-label="Редактировать конференцию"
|
||||||
|
onClick={(e) => {
|
||||||
|
// Иначе клик долетит и до обработчика чипа (eventClick) — тот
|
||||||
|
// открыл бы информационное окно вместо формы редактирования.
|
||||||
|
e.stopPropagation()
|
||||||
|
latestOnEditClick(ownedConference)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** «г.» — сноска 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): недельная
|
* Обёртка над FullCalendar (см. design/mockups/calendar.html): недельная
|
||||||
* (только будни, 5 колонок) / месячная сетка вхождений конференций, свой
|
* (только будни, 5 колонок) / месячная сетка вхождений конференций, свой
|
||||||
* тулбар (вид, навигация по датам) вместо встроенного headerToolbar.
|
* тулбар (вид, навигация по датам) вместо встроенного headerToolbar.
|
||||||
* События рендерятся как «чипы» (`.conf-chip`) со статус-бейджем §4.6
|
* События рендерятся как «чипы» (`.conf-chip`) — только название (+карандашик
|
||||||
* DESIGN_SYSTEM.md — вместо позиционирования по времени (timeGrid), т.к.
|
* у владельца), статус/время/номер — в информационном окне по клику
|
||||||
* менеджер конференций показывает список вхождений по дням, не занятость
|
* (`ConferenceOccurrenceDialog`) — вместо позиционирования по времени
|
||||||
* переговорной по часам (переговорных комнат в модели нет).
|
* (timeGrid), т.к. менеджер конференций показывает список вхождений по дням,
|
||||||
|
* не занятость переговорной по часам (переговорных комнат в модели нет).
|
||||||
*/
|
*/
|
||||||
export function ConferenceCalendar({
|
export function ConferenceCalendar({
|
||||||
view,
|
view,
|
||||||
onViewChange,
|
onViewChange,
|
||||||
occurrences,
|
occurrences,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
myConferenceById,
|
||||||
onRangeChange,
|
onRangeChange,
|
||||||
onOccurrenceClick,
|
onOccurrenceClick,
|
||||||
|
onEditClick,
|
||||||
}: ConferenceCalendarProps) {
|
}: ConferenceCalendarProps) {
|
||||||
const calendarRef = useRef<FullCalendar | null>(null)
|
const calendarRef = useRef<FullCalendar | null>(null)
|
||||||
const titleRef = useRef<HTMLSpanElement | null>(null)
|
const titleRef = useRef<HTMLSpanElement | null>(null)
|
||||||
|
|
||||||
|
// См. комментарий у `latestOnEditClick` в начале файла — синхронизируем
|
||||||
|
// переменную вместо того, чтобы замыкать пропс напрямую в renderEventContent.
|
||||||
|
useEffect(() => {
|
||||||
|
latestOnEditClick = onEditClick
|
||||||
|
}, [onEditClick])
|
||||||
|
|
||||||
// Ховер-карточка конференции — состояние держим внутри
|
// Ховер-карточка конференции — состояние держим внутри
|
||||||
// компонента (не поднимаем в CalendarPage), чтобы колбэки FullCalendar ниже
|
// компонента (не поднимаем в CalendarPage), чтобы колбэки FullCalendar ниже
|
||||||
// оставались стабильными по identity между рендерами (см. комментарий в
|
// оставались стабильными по identity между рендерами (см. комментарий в
|
||||||
@@ -159,20 +208,24 @@ export function ConferenceCalendar({
|
|||||||
calendarRef.current?.getApi().today()
|
calendarRef.current?.getApi().today()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Мемоизировано по `occurrences` — иначе на каждый рендер создавался бы
|
// Мемоизировано по `occurrences`/`myConferenceById` — иначе на каждый рендер
|
||||||
// новый массив-`events` той же длины/содержимого, и FullCalendar считал бы
|
// создавался бы новый массив-`events` той же длины/содержимого, и
|
||||||
// его «изменившейся опцией» (см. комментарий у модульных констант выше).
|
// FullCalendar считал бы его «изменившейся опцией» (см. комментарий у
|
||||||
|
// модульных констант выше).
|
||||||
const events: EventInput[] = useMemo(
|
const events: EventInput[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
occurrences.map((occ) => ({
|
occurrences.map((occ) => {
|
||||||
|
const owned = myConferenceById.get(occ.conference_id)
|
||||||
|
return {
|
||||||
id: `${occ.conference_id}:${occ.starts_at}`,
|
id: `${occ.conference_id}:${occ.starts_at}`,
|
||||||
title: occ.title ?? 'Конференция без названия',
|
title: occ.title ?? 'Конференция без названия',
|
||||||
start: occ.starts_at,
|
start: occ.starts_at,
|
||||||
end: occ.ends_at,
|
end: occ.ends_at,
|
||||||
classNames: ['conf-chip'],
|
classNames: ['conf-chip'],
|
||||||
extendedProps: { occurrence: occ },
|
extendedProps: { occurrence: occ, ownedConference: owned?.is_owner ? owned : undefined },
|
||||||
})),
|
}
|
||||||
[occurrences],
|
}),
|
||||||
|
[occurrences, myConferenceById],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleEventClick = useCallback(
|
const handleEventClick = useCallback(
|
||||||
@@ -228,6 +281,7 @@ export function ConferenceCalendar({
|
|||||||
hiddenDays={view === 'dayGridWeek' ? HIDDEN_DAYS_BUSINESS_WEEK : HIDDEN_DAYS_NONE}
|
hiddenDays={view === 'dayGridWeek' ? HIDDEN_DAYS_BUSINESS_WEEK : HIDDEN_DAYS_NONE}
|
||||||
height="auto"
|
height="auto"
|
||||||
dayMaxEvents={false}
|
dayMaxEvents={false}
|
||||||
|
views={CALENDAR_VIEWS}
|
||||||
events={events}
|
events={events}
|
||||||
eventContent={renderEventContent}
|
eventContent={renderEventContent}
|
||||||
eventClick={handleEventClick}
|
eventClick={handleEventClick}
|
||||||
|
|||||||
@@ -70,12 +70,15 @@
|
|||||||
* Событие-вхождение конференции — карточка-«чип» в духе .conf-chip мокапа.
|
* Событие-вхождение конференции — карточка-«чип» в духе .conf-chip мокапа.
|
||||||
* FullCalendar оборачивает событие в .fc-daygrid-event (наследует классы из
|
* FullCalendar оборачивает событие в .fc-daygrid-event (наследует классы из
|
||||||
* `classNames`) — переопределяем его внешний вид под чип целиком, без
|
* `classNames`) — переопределяем его внешний вид под чип целиком, без
|
||||||
* отдельного контейнера.
|
* отдельного контейнера. Содержимое — только название (+карандашик у
|
||||||
|
* владельца, время/статус/номер — в ConferenceOccurrenceDialog), поэтому чип —
|
||||||
|
* одна строка (flex row), а не колонка бейдж/время/название, как раньше.
|
||||||
*/
|
*/
|
||||||
.vc-calendar .fc-daygrid-event.conf-chip {
|
.vc-calendar .fc-daygrid-event.conf-chip {
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
background: var(--color-surface) !important;
|
background: var(--color-surface) !important;
|
||||||
border: 1px solid var(--color-border) !important;
|
border: 1px solid var(--color-border) !important;
|
||||||
border-radius: var(--radius-md) !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-time,
|
||||||
.vc-calendar .fc-daygrid-event.conf-chip .fc-event-title { display: none; } /* заменено собственным eventContent */
|
.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; }
|
/* Цепочка min-width:0 + overflow:hidden + text-overflow:ellipsis — без неё
|
||||||
.chip-title { display: flex; align-items: center; gap: 6px; font: var(--text-body); font-weight: 700; color: var(--color-ink-900); }
|
длинное название распирает чип и вылезает за границы ячейки (в узкой
|
||||||
|
недельной колонке — особенно). `.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-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 { 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.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); }
|
.conf-badge.pinned { background: var(--color-bg-alt); color: var(--color-ink-700); border: 1px solid var(--color-border-strong); }
|
||||||
|
|||||||
Reference in New Issue
Block a user