refactor(calendar): форма создания/редактирования конференции — в модальное окно

Раньше ConferenceFormCard всегда висела в сайдбаре двухколоночной сетки
.layout-grid; теперь открывается в модалке (по образцу
ConferenceOccurrenceDialog: modal-overlay/modal-panel, закрытие по клику на
оверлей, Escape и возврат фокуса — новый общий хук useModalDismiss).
.layout-grid стала одноколоночной — календарь занимает всю ширину.
This commit is contained in:
2026-07-26 23:33:52 +03:00
parent 904db83450
commit 07e5aba9c9
5 changed files with 120 additions and 56 deletions

View File

@@ -5,6 +5,7 @@ import { ShellTopbar } from '@/components/layout/ShellTopbar'
import { ConferenceCalendar, type CalendarViewMode } from '@/components/calendar/ConferenceCalendar'
import { ConferenceFormCard } from '@/components/calendar/ConferenceFormCard'
import { ConferenceOccurrenceDialog } from '@/components/calendar/ConferenceOccurrenceDialog'
import { useModalDismiss } from '@/hooks/useModalDismiss'
import { getCalendarOccurrences, getMyConferences, type ConferenceOut, type OccurrenceOut } from '@/api/conferences'
import { pluralizeConferences } from '@/lib/pluralize'
import '@/styles/lobby.css'
@@ -22,17 +23,17 @@ const EMPTY_OCCURRENCES: OccurrenceOut[] = []
* Календарь-менеджер запланированных/закреплённых конференций
* (см. design/mockups/calendar.html).
*
* Редактирование в сайдбаре доступно только для «своих» вхождений — тех,
* что присутствуют в `/conferences/my` (закреплённые и предстоящие разовые
* владельца; там же полная модель — recurrence/duration, которых нет в
* `OccurrenceOut` календарной выборки). Остальные вхождения по клику
* открывают лёгкий информационный диалог с входом и ссылкой на «Мои
* конференции» — согласованное отклонение от макета, вызванное отсутствием
* в контракте эндпоинта получения одной конференции по id.
* Форма создания/редактирования (`ConferenceFormCard`) живёт в модалке, не в
* сетке страницы — календарь занимает всю ширину. Клик по чипу (в т.ч. по
* своей конференции) открывает информационное окно (`ConferenceOccurrenceDialog`);
* редактирование — отдельное действие: карандашик на чипе (только у
* владельца, см. `myConferenceById`) или кнопка «Редактировать» в самом
* информационном окне.
*/
export function CalendarPage() {
const [view, setView] = useState<CalendarViewMode>('dayGridWeek')
const [range, setRange] = useState<VisibleRange | null>(null)
const [isFormOpen, setIsFormOpen] = useState(false)
const [editingConference, setEditingConference] = useState<ConferenceOut | null>(null)
const [occurrenceDialogTarget, setOccurrenceDialogTarget] = useState<OccurrenceOut | null>(null)
@@ -45,21 +46,18 @@ export function CalendarPage() {
const { data: myConferences } = useQuery({ queryKey: ['conferences', 'my'], queryFn: getMyConferences })
const myConferenceById = useMemo(() => new Map((myConferences ?? []).map((c) => [c.id, c])), [myConferences])
const handleOccurrenceClick = useCallback(
(occurrence: OccurrenceOut) => {
// `/conferences/my` может содержать и конференции, куда пользователь
// только приглашён участником (не организатор) — редактирование
// открываем только владельцу, иначе — тот же
// информационный диалог, что и для чужих вхождений.
const owned = myConferenceById.get(occurrence.conference_id)
if (owned?.is_owner) {
setEditingConference(owned)
} else {
setOccurrenceDialogTarget(occurrence)
}
},
[myConferenceById],
)
const openCreateForm = useCallback(() => {
setEditingConference(null)
setIsFormOpen(true)
}, [])
const openEditForm = useCallback((conference: ConferenceOut) => {
setEditingConference(conference)
setIsFormOpen(true)
setOccurrenceDialogTarget(null)
}, [])
const closeForm = useCallback(() => setIsFormOpen(false), [])
// Бэйл-аут по значению (не по ссылке): FullCalendar вызывает `datesSet`
// повторно всякий раз, когда меняется identity его пропсов (см. подробный
@@ -71,6 +69,8 @@ export function CalendarPage() {
setRange((prev) => (prev && prev.fromIso === fromIso && prev.toIso === toIso ? prev : { fromIso, toIso }))
}, [])
const dialogOwnedConference = occurrenceDialogTarget ? myConferenceById.get(occurrenceDialogTarget.conference_id) : undefined
return (
<div className="page-shell">
<ShellTopbar />
@@ -83,7 +83,7 @@ export function CalendarPage() {
разовую встречу или закрепите постоянную конференцию с повторением
</p>
</div>
<button type="button" className="btn btn-primary" onClick={() => setEditingConference(null)}>
<button type="button" className="btn btn-primary" onClick={openCreateForm}>
<Plus style={{ width: 16, height: 16 }} aria-hidden="true" />
Новая конференция
</button>
@@ -95,23 +95,57 @@ export function CalendarPage() {
onViewChange={setView}
occurrences={occurrences ?? EMPTY_OCCURRENCES}
isLoading={occurrencesLoading}
myConferenceById={myConferenceById}
onRangeChange={handleRangeChange}
onOccurrenceClick={handleOccurrenceClick}
/>
<ConferenceFormCard
key={editingConference?.id ?? 'create'}
conference={editingConference ?? undefined}
onSaved={() => setEditingConference(null)}
onDeleted={() => setEditingConference(null)}
onCancelEdit={() => setEditingConference(null)}
onOccurrenceClick={setOccurrenceDialogTarget}
onEditClick={openEditForm}
/>
</div>
</main>
{isFormOpen && (
<ConferenceFormModal
conference={editingConference}
onSaved={closeForm}
onDeleted={closeForm}
onClose={closeForm}
/>
)}
{occurrenceDialogTarget && (
<ConferenceOccurrenceDialog occurrence={occurrenceDialogTarget} onClose={() => setOccurrenceDialogTarget(null)} />
<ConferenceOccurrenceDialog
occurrence={occurrenceDialogTarget}
ownedConference={dialogOwnedConference?.is_owner ? dialogOwnedConference : undefined}
onClose={() => setOccurrenceDialogTarget(null)}
onEdit={openEditForm}
/>
)}
</div>
)
}
interface ConferenceFormModalProps {
conference: ConferenceOut | null
onSaved: () => void
onDeleted: () => void
onClose: () => void
}
/** Модалка создания/редактирования — разметка по образцу `ConferenceOccurrenceDialog` (modal-overlay/modal-panel, закрытие по клику на оверлей, Escape и возврат фокуса). */
function ConferenceFormModal({ conference, onSaved, onDeleted, onClose }: ConferenceFormModalProps) {
useModalDismiss(onClose)
return (
<div className="modal-overlay" role="dialog" aria-modal="true" onClick={onClose}>
<div className="modal-form-wrap" onClick={(e) => e.stopPropagation()}>
<ConferenceFormCard
key={conference?.id ?? 'create'}
conference={conference ?? undefined}
onSaved={onSaved}
onDeleted={onDeleted}
onCancelEdit={onClose}
/>
</div>
</div>
)
}