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:
parent
be70822945
commit
d53414aedc
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 для корректного отображения
|
||||
}));
|
||||
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user