feat: добавлена полная поддержка редактирования повторяющихся событий

- Реализована возможность конвертации повторяющегося события в обычное:
  * При снятии галочки 'Повторяющееся событие' rule_id сбрасывается в null
  * Событие становится одиночным, все occurrence исчезают

- Добавлен метод updateRule() в apiClient:
  * Позволяет обновлять существующие правила повторения
  * Изменения применяются ко всем occurrence события
  * Использует PUT запрос к /api/v1/schedule/rules/{id}/

- Исправлено использование repeatMode:
  * Заменен устаревший useCount на repeatMode ('until' | 'count' | 'forever')
  * Корректная обработка end_recurring_period только для режима 'until'

- Добавлена универсальная функция normalizeByweekday():
  * Решена проблема с разными форматами byweekday с backend:
    - Массив: [0, 4] ✓
    - Строка: '0,4' ✓
    - Число: 4 ✓
  * Функция используется во всех компонентах:
    - EventModal: авто-выбор дней, отображение кнопок, сохранение
    - ViewEventDetailsModal: отображение дней недели
  * Исправлены ошибки '.map is not a function' и '.includes is not a function'

- Улучшена логика handleSave() в EventModal:
  * Проверка необходимости сброса rule при снятии галочки
  * Разделение логики создания и обновления правил
  * Нормализация byweekday перед отправкой на backend

Closes: полная поддержка CRUD операций для повторяющихся событий
This commit is contained in:
igor_nadein 2026-02-25 03:02:59 +03:00
parent fda5452a52
commit 3635eb7759
4 changed files with 80 additions and 26 deletions

View File

@ -4,6 +4,17 @@ import { useState, useEffect, useRef } from "react";
import { X, Trash2 } from "lucide-react";
import { apiClient } from "@/lib/api";
// Нормализация byweekday в массив чисел
function normalizeByweekday(byweekday: any): number[] {
if (!byweekday) return [];
if (Array.isArray(byweekday)) return byweekday;
if (typeof byweekday === 'string') {
return byweekday.split(',').map(d => parseInt(d.trim(), 10)).filter(n => !isNaN(n));
}
if (typeof byweekday === 'number') return [byweekday];
return [];
}
interface EventModalProps {
isOpen: boolean;
onClose: () => void;
@ -56,7 +67,8 @@ export function EventModal({
const dayIndex = startDay === 0 ? 6 : startDay - 1;
// Если дни недели еще не выбраны - добавляем день начала
if (!editingEvent.byweekday || editingEvent.byweekday.length === 0) {
const normalizedByweekday = normalizeByweekday(editingEvent.byweekday);
if (normalizedByweekday.length === 0) {
setEditingEvent((prev: any) => ({
...prev,
byweekday: [dayIndex]
@ -123,7 +135,8 @@ export function EventModal({
// Вычисляет дату первого вхождения еженедельного события
const getFirstOccurrenceDate = (): string | null => {
if (editingEvent?.frequency !== 'WEEKLY' || !editingEvent?.start || !editingEvent?.byweekday || editingEvent.byweekday.length === 0) {
const normalizedByweekday = normalizeByweekday(editingEvent?.byweekday);
if (editingEvent?.frequency !== 'WEEKLY' || !editingEvent?.start || normalizedByweekday.length === 0) {
return null;
}
@ -132,12 +145,12 @@ export function EventModal({
const startDayIndex = startDay === 0 ? 6 : startDay - 1;
// Если день начала выбран - первое вхождение это сам день начала
if (editingEvent.byweekday.includes(startDayIndex)) {
if (normalizedByweekday.includes(startDayIndex)) {
return null; // Не показываем подсказку
}
// Ищем ближайший выбранный день недели
const sortedDays = [...editingEvent.byweekday].sort((a, b) => a - b);
const sortedDays = [...normalizedByweekday].sort((a, b) => a - b);
let daysToAdd = 0;
// Ищем день после startDayIndex
@ -192,7 +205,7 @@ export function EventModal({
isRecurring: true,
frequency: rule.frequency,
interval: rule.params?.interval || 1,
byweekday: rule.params?.byweekday || [],
byweekday: normalizeByweekday(rule.params?.byweekday),
count: rule.params?.count || undefined,
repeatMode,
};
@ -265,29 +278,43 @@ export function EventModal({
setSaving(true);
let ruleId = editingEvent.rule || null;
// Если это повторяющееся событие и у него нет Rule - создаем правило
if (editingEvent.isRecurring && !ruleId) {
// Если убрали галочку "Повторяющееся событие" - сбрасываем rule
if (!editingEvent.isRecurring && editingEvent.id && editingEvent.rule) {
ruleId = null;
}
// Если это повторяющееся событие - создаем или обновляем правило
if (editingEvent.isRecurring) {
const params: any = {
interval: editingEvent.interval || 1,
};
// Добавляем byweekday для еженедельных событий
if (editingEvent.frequency === 'WEEKLY' && editingEvent.byweekday && editingEvent.byweekday.length > 0) {
params.byweekday = editingEvent.byweekday;
const normalizedByweekday = normalizeByweekday(editingEvent.byweekday);
if (editingEvent.frequency === 'WEEKLY' && normalizedByweekday.length > 0) {
params.byweekday = normalizedByweekday;
}
// Добавляем count если выбран этот вариант
if (editingEvent.useCount && editingEvent.count) {
// Добавляем count если выбран режим "Количество раз"
if (editingEvent.repeatMode === 'count' && editingEvent.count) {
params.count = editingEvent.count;
}
const rule = await apiClient.createRule({
const ruleData = {
name: `Rule for ${editingEvent.title}`,
description: `Recurring rule for ${editingEvent.title}`,
frequency: editingEvent.frequency || 'WEEKLY',
params,
});
ruleId = rule.id;
};
if (ruleId) {
// Обновляем существующее правило
await apiClient.updateRule(ruleId, ruleData);
} else {
// Создаем новое правило
const rule = await apiClient.createRule(ruleData);
ruleId = rule.id;
}
}
const eventData: any = {
@ -304,8 +331,8 @@ export function EventModal({
rule: ruleId,
};
// Добавляем end_recurring_period только для повторяющихся событий (если не используется count)
if (editingEvent.isRecurring && !editingEvent.useCount && editingEvent.end_recurring_period) {
// Добавляем end_recurring_period только для повторяющихся событий с режимом 'until'
if (editingEvent.isRecurring && editingEvent.repeatMode === 'until' && editingEvent.end_recurring_period) {
const endDate = new Date(editingEvent.end_recurring_period);
endDate.setHours(23, 59, 59, 999);
eventData.end_recurring_period = endDate.toISOString();
@ -532,14 +559,14 @@ export function EventModal({
{ value: 5, label: 'СБ' },
{ value: 6, label: 'ВС' },
].map((day) => {
const byweekday = editingEvent.byweekday || [];
const byweekday = normalizeByweekday(editingEvent.byweekday);
const isSelected = byweekday.includes(day.value);
return (
<button
key={day.value}
type="button"
onClick={() => {
const current = editingEvent.byweekday || [];
const current = normalizeByweekday(editingEvent.byweekday);
const updated = isSelected
? current.filter((d: number) => d !== day.value)
: [...current, day.value].sort();

View File

@ -30,12 +30,12 @@ export function ViewDayEventsModal({
const eventStart = new Date(event.start);
const eventEnd = new Date(event.end);
const selectedDay = new Date(date);
// Сбрасываем время для корректного сравнения дат
eventStart.setHours(0, 0, 0, 0);
eventEnd.setHours(0, 0, 0, 0);
selectedDay.setHours(0, 0, 0, 0);
// Проверяем, попадает ли selectedDay в диапазон [eventStart, eventEnd]
return selectedDay >= eventStart && selectedDay <= eventEnd;
});
@ -100,7 +100,7 @@ export function ViewDayEventsModal({
</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>

View File

@ -37,6 +37,17 @@ const WEEKDAY_LABELS: Record<number, string> = {
6: "ВС",
};
// Нормализация byweekday в массив чисел
function normalizeByweekday(byweekday: any): number[] {
if (!byweekday) return [];
if (Array.isArray(byweekday)) return byweekday;
if (typeof byweekday === 'string') {
return byweekday.split(',').map(d => parseInt(d.trim(), 10)).filter(n => !isNaN(n));
}
if (typeof byweekday === 'number') return [byweekday];
return [];
}
export function ViewEventDetailsModal({
isOpen,
onClose,
@ -97,7 +108,7 @@ export function ViewEventDetailsModal({
</h3>
{event.rule && (
<span className="text-xs bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded flex-shrink-0">
🔁
⟲
</span>
)}
</div>
@ -146,7 +157,7 @@ export function ViewEventDetailsModal({
{event.rule_data.params?.byweekday && (
<div>
Дни недели: <span className="font-medium">
{event.rule_data.params.byweekday.map((day: number) => WEEKDAY_LABELS[day]).join(", ")}
{normalizeByweekday(event.rule_data.params.byweekday).map((day: number) => WEEKDAY_LABELS[day]).join(", ")}
</span>
</div>
)}
@ -161,9 +172,7 @@ export function ViewEventDetailsModal({
</div>
)}
</div>
<div className="text-xs text-blue-600 mt-2">
💡 Редактирование параметров повторения пока не поддерживается
</div>
</div>
)}

View File

@ -530,6 +530,24 @@ class ApiClient {
});
}
async updateRule(ruleId: number, data: {
name: string;
description?: string;
frequency: string;
params?: any;
}): Promise<any> {
// Сериализуем params в JSON-строку для django-scheduler
const payload = {
...data,
params: data.params ? JSON.stringify(data.params) : '{}',
};
return this.request(`/api/v1/schedule/rules/${ruleId}/`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
async getRules(): Promise<any> {
return this.request('/api/v1/schedule/rules/');
}