fix: исправлена работа повторяющихся событий с django-scheduler

- Исправлен RuleSerializer для корректной конвертации params между форматами:
  * Frontend отправляет dict/JSON: {"byweekday": [0, 4], "interval": 1}
  * Backend сохраняет в формате django-scheduler: "byweekday:0,4;interval:1"
  * Добавлен универсальный парсер _parse_params_input() для всех форматов

- Добавлен UX в стиле Apple Calendar для еженедельных событий:
  * Авто-выбор дня начала при включении WEEKLY
  * Информационный баннер с датой первого события если начальный день не выбран
  * Защита от перезаписи выбора пользователя через useRef

- Рефакторинг и очистка кода:
  * Удалены все отладочные console.log() и print()
  * Улучшены docstrings с примерами
  * Добавлена константа VALID_FREQUENCIES

- Исправлена загрузка событий в режиме "Все события":
  * Убрана блокировка для selectedCalendarId === null
  * События и occurrences загружаются корректно для всех календарей

Closes: повторяющиеся события теперь генерируются только в выбранные дни недели
This commit is contained in:
igor_nadein 2026-02-25 01:55:51 +03:00
parent be70822945
commit d53414aedc
7 changed files with 332 additions and 165 deletions

View File

@ -165,9 +165,16 @@ class CalendarSerializer(serializers.ModelSerializer):
class RuleSerializer(serializers.ModelSerializer):
"""Сериализатор для Rule (rrule RFC 5545)."""
"""
Сериализатор для Rule (rrule RFC 5545).
Преобразует параметры между форматами:
- Frontend: dict {"byweekday": [0, 4], "interval": 1}
- django-scheduler: string "byweekday:0,4;interval:1"
"""
VALID_FREQUENCIES = ['YEARLY', 'MONTHLY', 'WEEKLY', 'DAILY', 'HOURLY', 'MINUTELY', 'SECONDLY']
# Удобные поля для frontend
frequency_display = serializers.CharField(source='get_frequency_display', read_only=True)
class Meta:
@ -175,29 +182,124 @@ class RuleSerializer(serializers.ModelSerializer):
fields = ['id', 'name', 'description', 'frequency', 'frequency_display', 'params']
read_only_fields = ['id', 'frequency_display']
def to_representation(self, instance):
"""Десериализация params из JSON-строки в dict."""
def _dict_to_params_string(self, params_dict):
"""
Преобразует dict в строковый формат django-scheduler.
Args:
params_dict: {"byweekday": [0, 3], "interval": 1}
Returns:
"byweekday:0,3;interval:1"
"""
if not params_dict or not isinstance(params_dict, dict):
return ""
parts = []
for key, value in params_dict.items():
if isinstance(value, (list, tuple)):
value_str = ",".join(str(v) for v in value)
else:
value_str = str(value)
parts.append(f"{key}:{value_str}")
return ";".join(parts)
def _params_string_to_dict(self, params_str):
"""
Преобразует строку django-scheduler в dict.
Args:
params_str: "byweekday:0,3;interval:1"
Returns:
{"byweekday": [0, 3], "interval": 1}
"""
if not params_str:
return {}
result = {}
for param_pair in params_str.split(";"):
if ":" not in param_pair:
continue
key, value_str = param_pair.split(":", 1)
key = key.strip()
# Если значение содержит запятые - это список
if "," in value_str:
try:
result[key] = [int(v.strip()) for v in value_str.split(",")]
except ValueError:
result[key] = [v.strip() for v in value_str.split(",")]
else:
# Пробуем преобразовать в число
try:
result[key] = int(value_str.strip())
except ValueError:
result[key] = value_str.strip()
return result
def _parse_params_input(self, params):
"""
Парсит params из любого формата в dict.
Поддерживает:
- dict (прямо от JSON)
- JSON-string (от некоторых клиентов)
- django-scheduler string (для обратной совместимости)
Args:
params: dict, JSON-string или django-scheduler string
Returns:
dict с параметрами
"""
import json
# Уже dict - возвращаем как есть
if isinstance(params, dict):
return params
# Пробуем распарсить как JSON-строку
if isinstance(params, str):
# Если начинается с { - это JSON
if params.strip().startswith('{'):
try:
return json.loads(params)
except (json.JSONDecodeError, TypeError):
pass
# Иначе пробуем как django-scheduler формат
return self._params_string_to_dict(params)
return {}
def to_representation(self, instance):
"""Преобразует params из БД (django-scheduler format) в dict для frontend."""
data = super().to_representation(instance)
# Парсим params из строки в объект для frontend
if data.get('params'):
try:
if isinstance(data['params'], str):
data['params'] = json.loads(data['params'])
except (json.JSONDecodeError, TypeError):
data['params'] = {}
else:
data['params'] = {}
data['params'] = self._params_string_to_dict(data.get('params', ''))
return data
def to_internal_value(self, data):
"""Преобразует params от frontend (dict/JSON) в django-scheduler format для БД."""
# Парсим params в dict из любого формата
params_dict = self._parse_params_input(data.get('params'))
# Вызываем родительский метод для валидации остальных полей
validated_data = super().to_internal_value(data)
# Конвертируем dict в формат django-scheduler для сохранения в БД
validated_data['params'] = self._dict_to_params_string(params_dict)
return validated_data
def validate_frequency(self, value):
"""Валидация frequency."""
valid_frequencies = ['YEARLY', 'MONTHLY', 'WEEKLY', 'DAILY', 'HOURLY', 'MINUTELY', 'SECONDLY']
if value not in valid_frequencies:
"""Валидация поля frequency."""
if value not in self.VALID_FREQUENCIES:
raise serializers.ValidationError(
f'Invalid frequency. Must be one of: {", ".join(valid_frequencies)}'
f'Invalid frequency. Must be one of: {", ".join(self.VALID_FREQUENCIES)}'
)
return value

View File

@ -386,7 +386,7 @@ class ScheduleEventViewSet(viewsets.ModelViewSet):
"""Получить материализованные вхождения в диапазоне.
Query params:
- calendar: ID календаря (обязательно)
- calendar: ID календаря (необязательно, если не указан - все доступные календари)
- start: начало диапазона (ISO 8601)
- end: конец диапазона (ISO 8601)
@ -396,20 +396,12 @@ class ScheduleEventViewSet(viewsets.ModelViewSet):
start_str = request.query_params.get('start')
end_str = request.query_params.get('end')
if not all([calendar_id, start_str, end_str]):
if not all([start_str, end_str]):
return Response(
{'detail': 'Требуются параметры: calendar, start, end'},
{'detail': 'Требуются параметры: start, end'},
status=status.HTTP_400_BAD_REQUEST
)
try:
calendar = Calendar.objects.get(id=calendar_id)
except Calendar.DoesNotExist:
return Response(
{'detail': 'Календарь не найден'},
status=status.HTTP_404_NOT_FOUND
)
start_dt = parse_datetime(start_str)
end_dt = parse_datetime(end_str)
@ -419,25 +411,57 @@ class ScheduleEventViewSet(viewsets.ModelViewSet):
status=status.HTTP_400_BAD_REQUEST
)
# Используем schedule.periods.Period для получения вхождений
events = Event.objects.filter(calendar=calendar)
period = Period(events, start_dt, end_dt)
# Если указан конкретный календарь - фильтруем по нему
if calendar_id:
try:
calendar = Calendar.objects.get(id=calendar_id)
calendars = [calendar]
except Calendar.DoesNotExist:
return Response(
{'detail': 'Календарь не найден'},
status=status.HTTP_404_NOT_FOUND
)
else:
# Если календарь не указан - берем все доступные пользователю календари
from django.contrib.auth import get_user_model
User = get_user_model()
user = request.user
if user.is_staff or user.is_superuser:
calendars = Calendar.objects.all()
else:
ct = ContentType.objects.get_for_model(User)
calendar_ids = CalendarRelation.objects.filter(
content_type=ct,
object_id=user.id
).values_list('calendar_id', flat=True)
calendars = Calendar.objects.filter(id__in=calendar_ids)
# Результат
occurrences = []
for occurrence in period.get_occurrences():
occurrences.append({
'id': occurrence.event.id,
'title': occurrence.title or occurrence.event.title,
'description': occurrence.event.description,
'start': occurrence.start.isoformat(),
'end': occurrence.end.isoformat(),
'color_event': occurrence.event.color_event,
'event_id': occurrence.event.id,
'is_recurring': occurrence.event.rule_id is not None,
'rule': occurrence.event.rule_id,
'end_recurring_period': occurrence.event.end_recurring_period.isoformat() if occurrence.event.end_recurring_period else None,
})
# Обрабатываем каждый календарь
for calendar in calendars:
events = Event.objects.filter(calendar=calendar)
period = Period(events, start_dt, end_dt)
for occurrence in period.get_occurrences():
# Создаем уникальный ID для каждого вхождения (комбинация event_id и start)
unique_id = f"{occurrence.event.id}_{occurrence.start.isoformat()}"
occurrences.append({
'id': unique_id, # Уникальный ID для каждого вхождения
'title': occurrence.title or occurrence.event.title,
'description': occurrence.event.description,
'start': occurrence.start.isoformat(),
'end': occurrence.end.isoformat(),
'calendar': calendar.id, # Добавляем ID календаря
'color_event': occurrence.event.color_event,
'event_id': occurrence.event.id, # ID оригинального события
'is_recurring': occurrence.event.rule_id is not None,
'rule': occurrence.event.rule_id,
'end_recurring_period': occurrence.event.end_recurring_period.isoformat() if occurrence.event.end_recurring_period else None,
})
return Response(occurrences)

View File

@ -326,11 +326,6 @@ function CalendarCard({
// Загрузка событий выбранного календаря
useEffect(() => {
if (selectedCalendarId === null) {
setEvents([]);
return;
}
let cancelled = false;
const start = new Date(monthDate.getFullYear(), monthDate.getMonth(), 1);
@ -347,32 +342,18 @@ function CalendarCard({
setLoading(true);
setError(null);
// Если календарь не выбран (null) - загружаем все события пользователя
if (selectedCalendarId === null) {
const myEventsResult = await apiClient.getMyEvents({
start: formatDateKey(fetchStart),
end: formatDateKey(fetchEnd),
});
if (!cancelled) {
const eventsList = Array.isArray(myEventsResult) ? myEventsResult : (myEventsResult?.results || []);
setEvents(eventsList);
setSidebarEvents(eventsList); // Передаем события наружу
}
return;
}
// Загружаем обычные события и occurrences параллельно для конкретного календаря
// Загружаем обычные события и occurrences параллельно
// Если selectedCalendarId === null, загружаем все доступные события со всех календарей
const [eventsResult, occurrencesResult] = await Promise.all([
apiClient.getCalendarEvents({
start: formatDateKey(fetchStart),
end: formatDateKey(fetchEnd),
calendar: selectedCalendarId,
calendar: selectedCalendarId || undefined, // undefined означает все календари
}),
apiClient.getOccurrences({
start: formatDateKey(fetchStart),
end: formatDateKey(fetchEnd),
calendar: selectedCalendarId,
calendar: selectedCalendarId || undefined,
}),
]);
@ -383,7 +364,7 @@ function CalendarCard({
// Occurrences (развернутые повторяющиеся события)
const occurrencesList = Array.isArray(occurrencesResult) ? occurrencesResult : (occurrencesResult?.results || []);
// Фильтруем только повторяющиеся события (избегаем дубликатов)
// Фильтруем только повторяющиеся события (обычные события уже в regularEvents)
const recurringOccurrences = occurrencesList.filter((occ: any) => occ.is_recurring);
// Объединяем оба типа событий
@ -430,10 +411,7 @@ function CalendarCard({
// Обработчики событий
const handleDayClick = (date: Date) => {
if (!selectedCalendarId) {
alert("Сначала выберите календарь");
return;
}
// Больше не блокируем при selectedCalendarId === null (разрешаем просмотр "Все события")
setSelectedDate(date);
const startDate = new Date(date);
startDate.setHours(10, 0, 0, 0);

View File

@ -157,36 +157,16 @@ export function BigCalendar() {
const startStr = start.toISOString().split("T")[0];
const endStr = end.toISOString().split("T")[0];
// Если календарь не выбран (null) - загружаем все события пользователя
if (!selectedCalendarId) {
const myEventsResult = await apiClient.getMyEvents({
start: startStr,
end: endStr,
});
const eventsList = Array.isArray(myEventsResult) ? myEventsResult : (myEventsResult?.results || []);
const allEvents = eventsList.map((evt: any) => ({
...evt,
start: new Date(evt.start),
end: new Date(evt.end),
allDay: false,
title: evt.rule ? `⟲ ${evt.title}` : evt.title,
}));
setEvents(allEvents);
setLoading(false);
return;
}
// Загружаем обычные события и occurrences параллельно для выбранного календаря
// Загружаем обычные события и occurrences параллельно
// Если selectedCalendarId === null, загружаем все доступные события со всех календарей
const [eventsResult, occurrencesResult] = await Promise.all([
apiClient.getCalendarEvents({
calendar: selectedCalendarId,
calendar: selectedCalendarId || undefined,
start: startStr,
end: endStr,
}),
apiClient.getOccurrences({
calendar: selectedCalendarId,
calendar: selectedCalendarId || undefined,
start: startStr,
end: endStr,
}),
@ -210,18 +190,20 @@ export function BigCalendar() {
// Обработка occurrences (повторяющихся событий)
const occurrencesList = Array.isArray(occurrencesResult) ? occurrencesResult : (occurrencesResult?.results || []);
const occurrenceEvents = occurrencesList
.filter((occ: any) => occ && occ.is_recurring) // Только повторяющиеся (избегаем дубликатов)
.filter((occ: any) => occ && occ.is_recurring) // Только повторяющиеся (обычные события уже в regularEvents)
.map((occ: any) => ({
id: occ.id,
title: `⟲ ${occ.title}`, // Добавляем индикатор повторения
title: occ.title.startsWith('⟲') ? occ.title : `⟲ ${occ.title}`, // Добавляем индикатор повторения
description: occ.description,
start: new Date(occ.start),
end: new Date(occ.end),
allDay: false, // Явно указываем что событие с временем
calendar: selectedCalendarId, // Календарь уже известен из запроса
calendar: occ.calendar, // Используем calendar из occurrence (важно для "Все события")
color_event: occ.color_event || '#3498db',
event_id: occ.event_id,
rule: occ.rule,
isOccurrence: true, // Помечаем как occurrence для корректного отображения
}));

View File

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { X, Trash2 } from "lucide-react";
import { apiClient } from "@/lib/api";
@ -31,6 +31,43 @@ export function EventModal({
const [loadingEmployees, setLoadingEmployees] = useState(false);
const [addingParticipants, setAddingParticipants] = useState(false);
const [pendingParticipantIds, setPendingParticipantIds] = useState<number[]>([]);
// Ref для отслеживания, был ли уже выполнен авто-выбор дня (чтобы не перезаписывать выбор пользователя)
const autoWeekdaySetRef = useRef(false);
// Сброс флага при открытии/закрытии модала
useEffect(() => {
if (!isOpen) {
autoWeekdaySetRef.current = false;
}
}, [isOpen]);
// Автоматический выбор дня начала при включении WEEKLY (как в Apple Calendar)
useEffect(() => {
// Если уже устанавливали автоматически - не трогаем выбор пользователя
if (autoWeekdaySetRef.current) {
return;
}
if (editingEvent?.frequency === 'WEEKLY' && editingEvent?.start) {
const startDate = new Date(editingEvent.start);
const startDay = startDate.getDay();
// Конвертируем: JS (0=Вс, 1=Пн...) -> наш формат (0=Пн, 6=Вс)
const dayIndex = startDay === 0 ? 6 : startDay - 1;
// Если дни недели еще не выбраны - добавляем день начала
if (!editingEvent.byweekday || editingEvent.byweekday.length === 0) {
setEditingEvent((prev: any) => ({
...prev,
byweekday: [dayIndex]
}));
autoWeekdaySetRef.current = true;
} else {
// Дни уже выбраны (например, при редактировании события)
autoWeekdaySetRef.current = true;
}
}
}, [editingEvent?.frequency, editingEvent?.start]);
useEffect(() => {
if (isOpen && event) {
@ -76,6 +113,45 @@ export function EventModal({
return `${year}-${month}-${day}T${hours}:${minutes}`;
};
// Вычисляет дату первого вхождения еженедельного события
const getFirstOccurrenceDate = (): string | null => {
if (editingEvent?.frequency !== 'WEEKLY' || !editingEvent?.start || !editingEvent?.byweekday || editingEvent.byweekday.length === 0) {
return null;
}
const startDate = new Date(editingEvent.start);
const startDay = startDate.getDay();
const startDayIndex = startDay === 0 ? 6 : startDay - 1;
// Если день начала выбран - первое вхождение это сам день начала
if (editingEvent.byweekday.includes(startDayIndex)) {
return null; // Не показываем подсказку
}
// Ищем ближайший выбранный день недели
const sortedDays = [...editingEvent.byweekday].sort((a, b) => a - b);
let daysToAdd = 0;
// Ищем день после startDayIndex
const nextDay = sortedDays.find(d => d > startDayIndex);
if (nextDay !== undefined) {
daysToAdd = nextDay - startDayIndex;
} else {
// Если все дни до текущего - берем первый день следующей недели
daysToAdd = 7 - startDayIndex + sortedDays[0];
}
const firstOccurrence = new Date(startDate);
firstOccurrence.setDate(firstOccurrence.getDate() + daysToAdd);
return firstOccurrence.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric',
weekday: 'short'
});
};
const loadEventParticipants = async (eventId: number) => {
setEventParticipantsLoading(true);
try {
@ -489,6 +565,19 @@ export function EventModal({
);
})}
</div>
{(() => {
const firstOccurrence = getFirstOccurrenceDate();
return firstOccurrence ? (
<div className="mt-2 flex items-start gap-2 rounded-lg bg-blue-50 px-3 py-2 border border-blue-200">
<svg className="h-4 w-4 text-blue-600 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-xs text-blue-800">
Первое событие: <span className="font-medium">{firstOccurrence}</span>
</p>
</div>
) : null;
})()}
</div>
)}

View File

@ -43,12 +43,11 @@ export function ViewDayEventsModal({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="w-full max-w-2xl rounded-2xl bg-white shadow-xl max-h-[85vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl max-h-[90vh] overflow-y-auto">
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">События на {capitalizedDate}</h3>
<p className="text-sm text-gray-500 mt-0.5">
<p className="text-xs text-gray-500 mt-1">
{sortedEvents.length === 0
? "Нет событий"
: `${sortedEvents.length} ${sortedEvents.length === 1 ? "событие" : "событий"}`}
@ -56,19 +55,18 @@ export function ViewDayEventsModal({
</div>
<button
onClick={onClose}
className="rounded-full p-1 hover:bg-gray-100 transition"
className="rounded-full p-1 hover:bg-gray-100"
>
<X size={20} className="text-gray-600" />
</button>
</div>
{/* Events List */}
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="space-y-4">
{sortedEvents.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Calendar size={48} className="text-gray-300 mb-3" />
<div className="flex flex-col items-center justify-center py-8 text-center">
<Calendar size={40} className="text-gray-300 mb-2" />
<p className="text-gray-500 text-sm mb-1">Нет событий в этот день</p>
<p className="text-gray-400 text-xs">Создайте новое событие, нажав кнопку ниже</p>
<p className="text-gray-400 text-xs">Создайте новое событие</p>
</div>
) : (
<div className="space-y-2">
@ -76,31 +74,31 @@ export function ViewDayEventsModal({
<button
key={event.id}
onClick={() => onEventClick(event)}
className="w-full text-left rounded-lg border border-gray-200 bg-white p-4 transition hover:border-sky-300 hover:bg-sky-50/50 hover:shadow-sm"
className="w-full text-left rounded-lg border border-gray-200 bg-white p-3 transition hover:border-sky-300 hover:bg-sky-50"
>
<div className="flex items-start gap-3">
<div className="flex items-start gap-2">
{/* Color Indicator */}
<div
className="mt-1 h-10 w-1 rounded-full flex-shrink-0"
className="mt-1 h-8 w-1 rounded-full flex-shrink-0"
style={{ backgroundColor: event.color_event || "#3498db" }}
/>
<div className="flex-1 min-w-0">
{/* Title with recurring indicator */}
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium text-gray-900 truncate">
<div className="flex items-center gap-1.5 mb-1">
<h4 className="text-sm font-medium text-gray-900 truncate">
{event.title}
</h4>
{event.rule && (
<span className="text-xs bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded flex-shrink-0">
🔁 Повторяется
🔁
</span>
)}
</div>
{/* Time */}
<div className="flex items-center gap-1.5 text-sm text-gray-600">
<Clock size={14} />
<div className="flex items-center gap-1 text-xs text-gray-600">
<Clock size={12} />
<span>
{formatTime(event.start)} - {formatTime(event.end)}
</span>
@ -108,7 +106,7 @@ export function ViewDayEventsModal({
{/* Description preview */}
{event.description && (
<p className="text-xs text-gray-500 mt-2 line-clamp-2">
<p className="text-xs text-gray-500 mt-1.5 line-clamp-2">
{event.description}
</p>
)}
@ -118,17 +116,16 @@ export function ViewDayEventsModal({
))}
</div>
)}
</div>
{/* Footer */}
<div className="border-t border-gray-200 px-6 py-4">
<button
onClick={onCreateEvent}
className="w-full flex items-center justify-center gap-2 rounded-lg bg-sky-500 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-sky-600"
>
<Plus size={16} />
Создать событие на этот день
</button>
<div className="mt-4">
<button
onClick={onCreateEvent}
className="w-full flex items-center justify-center gap-2 rounded-lg bg-sky-500 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-sky-600"
>
<Plus size={16} />
Создать событие
</button>
</div>
</div>
</div>
</div>

View File

@ -66,23 +66,22 @@ export function ViewEventDetailsModal({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="w-full max-w-2xl rounded-2xl bg-white shadow-xl max-h-[85vh] flex flex-col">
{/* Header */}
<div className="flex items-start justify-between border-b border-gray-200 px-6 py-4">
<div className="flex-1 min-w-0 pr-4">
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl max-h-[90vh] overflow-y-auto">
<div className="mb-4 flex items-start justify-between">
<div className="flex-1 min-w-0 pr-3">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-lg font-semibold text-gray-900 truncate">
<h3 className="text-lg font-semibold text-gray-900">
{event.title}
</h3>
{event.rule && (
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded flex-shrink-0">
🔁 Повторяется
<span className="text-xs bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded flex-shrink-0">
🔁
</span>
)}
</div>
<div className="flex items-center gap-1.5 text-sm text-gray-500">
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<div
className="h-3 w-3 rounded-full flex-shrink-0"
className="h-2.5 w-2.5 rounded-full flex-shrink-0"
style={{ backgroundColor: event.color_event || "#3498db" }}
/>
<span className="truncate">Календарь #{event.calendar}</span>
@ -90,23 +89,22 @@ export function ViewEventDetailsModal({
</div>
<button
onClick={onClose}
className="rounded-full p-1 hover:bg-gray-100 transition flex-shrink-0"
className="rounded-full p-1 hover:bg-gray-100 flex-shrink-0"
>
<X size={20} className="text-gray-600" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
<div className="space-y-4">
{/* Time */}
<div className="flex items-start gap-3">
<Clock size={20} className="text-gray-400 mt-0.5 flex-shrink-0" />
<div className="flex items-start gap-2.5">
<Clock size={18} className="text-gray-400 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-gray-700">Время</p>
<p className="text-sm text-gray-600 mt-1">
<p className="text-sm font-medium text-gray-700 mb-1">Время</p>
<p className="text-xs text-gray-600">
<span className="font-medium">Начало:</span> {capitalizedStart}
</p>
<p className="text-sm text-gray-600 mt-0.5">
<p className="text-xs text-gray-600 mt-0.5">
<span className="font-medium">Конец:</span> {capitalizedEnd}
</p>
</div>
@ -114,11 +112,11 @@ export function ViewEventDetailsModal({
{/* Recurring Info */}
{event.rule && event.end_recurring_period && (
<div className="flex items-start gap-3">
<Calendar size={20} className="text-gray-400 mt-0.5 flex-shrink-0" />
<div className="flex items-start gap-2.5">
<Calendar size={18} className="text-gray-400 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-gray-700">Повторение</p>
<p className="text-sm text-gray-600 mt-1">
<p className="text-sm font-medium text-gray-700 mb-1">Повторение</p>
<p className="text-xs text-gray-600">
До {formatDate(event.end_recurring_period)}
</p>
</div>
@ -127,11 +125,11 @@ export function ViewEventDetailsModal({
{/* Description */}
{event.description && (
<div className="flex items-start gap-3">
<FileText size={20} className="text-gray-400 mt-0.5 flex-shrink-0" />
<div className="flex items-start gap-2.5">
<FileText size={18} className="text-gray-400 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-gray-700 mb-1">Описание</p>
<p className="text-sm text-gray-600 whitespace-pre-wrap">
<p className="text-xs text-gray-600 whitespace-pre-wrap">
{event.description}
</p>
</div>
@ -140,22 +138,22 @@ export function ViewEventDetailsModal({
{/* Participants */}
{showParticipants && (
<div className="flex items-start gap-3">
<Users size={20} className="text-gray-400 mt-0.5 flex-shrink-0" />
<div className="flex items-start gap-2.5">
<Users size={18} className="text-gray-400 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-gray-700 mb-2">
<p className="text-sm font-medium text-gray-700 mb-1.5">
Участники {participants.length > 0 && `(${participants.length})`}
</p>
{loadingParticipants ? (
<p className="text-xs text-gray-500">Загрузка...</p>
) : participants.length > 0 ? (
<div className="space-y-2">
<div className="space-y-1.5">
{participants.map((participant: any) => (
<div
key={participant.id}
className="flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-2"
className="flex items-center gap-2 rounded-lg bg-gray-50 px-2.5 py-2"
>
<div className="h-7 w-7 rounded-full bg-sky-100 flex items-center justify-center flex-shrink-0">
<div className="h-6 w-6 rounded-full bg-sky-100 flex items-center justify-center flex-shrink-0">
<span className="text-xs font-medium text-sky-700">
{participant.user_name?.[0] || "?"}
</span>
@ -177,11 +175,8 @@ export function ViewEventDetailsModal({
</div>
</div>
)}
</div>
{/* Footer */}
<div className="border-t border-gray-200 px-6 py-4">
<div className="flex gap-2">
<div className="mt-4 flex gap-2">
<button
onClick={onEdit}
className="flex-1 flex items-center justify-center gap-2 rounded-lg bg-sky-500 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-sky-600"