Corp/frontend/src/components/EventModal.tsx
igor_nadein 3635eb7759 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 операций для повторяющихся событий
2026-02-25 03:02:59 +03:00

854 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
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;
event: any | null;
onSave: () => void;
showParticipants?: boolean;
}
export function EventModal({
isOpen,
onClose,
event,
onSave,
showParticipants = false,
}: EventModalProps) {
const [editingEvent, setEditingEvent] = useState<any>(null);
const [eventParticipants, setEventParticipants] = useState<any[]>([]);
const [eventParticipantsLoading, setEventParticipantsLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [ruleDetails, setRuleDetails] = useState<any>(null);
const [showAddParticipants, setShowAddParticipants] = useState(false);
const [availableEmployees, setAvailableEmployees] = useState<any[]>([]);
const [selectedEmployeeIds, setSelectedEmployeeIds] = useState<number[]>([]);
const [searchQuery, setSearchQuery] = useState('');
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;
// Если дни недели еще не выбраны - добавляем день начала
const normalizedByweekday = normalizeByweekday(editingEvent.byweekday);
if (normalizedByweekday.length === 0) {
setEditingEvent((prev: any) => ({
...prev,
byweekday: [dayIndex]
}));
autoWeekdaySetRef.current = true;
} else {
// Дни уже выбраны (например, при редактировании события)
autoWeekdaySetRef.current = true;
}
}
}, [editingEvent?.frequency, editingEvent?.start]);
useEffect(() => {
if (isOpen && event) {
// Инициализируем все boolean поля дефолтными значениями
// Определяем repeatMode: 'until' | 'count' | 'forever'
let repeatMode = 'until';
if (event.count) {
repeatMode = 'count';
} else if (!event.end_recurring_period && event.isRecurring) {
repeatMode = 'forever';
}
setEditingEvent({
...event,
isRecurring: event.isRecurring ?? false,
repeatMode,
});
// Загружаем участников для существующего события
if (event.id && showParticipants) {
loadEventParticipants(event.id);
} else {
setEventParticipants([]);
}
// Загружаем правило для повторяющегося события
if (event.id && event.rule) {
loadRule(event.rule);
} else {
setRuleDetails(null);
}
} else if (!isOpen) {
// Сбрасываем временные состояния при закрытии модала
setShowAddParticipants(false);
setSelectedEmployeeIds([]);
setPendingParticipantIds([]);
setSearchQuery('');
setAvailableEmployees([]);
setRuleDetails(null);
}
}, [isOpen, event, showParticipants]);
// Конвертирует Date в формат datetime-local (локальное время)
const toLocalDateTimeString = (date: Date | string): string => {
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const hours = String(d.getHours()).padStart(2, '0');
const minutes = String(d.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
};
// Вычисляет дату первого вхождения еженедельного события
const getFirstOccurrenceDate = (): string | null => {
const normalizedByweekday = normalizeByweekday(editingEvent?.byweekday);
if (editingEvent?.frequency !== 'WEEKLY' || !editingEvent?.start || normalizedByweekday.length === 0) {
return null;
}
const startDate = new Date(editingEvent.start);
const startDay = startDate.getDay();
const startDayIndex = startDay === 0 ? 6 : startDay - 1;
// Если день начала выбран - первое вхождение это сам день начала
if (normalizedByweekday.includes(startDayIndex)) {
return null; // Не показываем подсказку
}
// Ищем ближайший выбранный день недели
const sortedDays = [...normalizedByweekday].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 {
const data = await apiClient.getEventParticipants(eventId);
setEventParticipants(data.results || data || []);
} catch (error) {
console.error('Failed to load event participants:', error);
} finally {
setEventParticipantsLoading(false);
}
};
const loadRule = async (ruleId: number) => {
try {
const rule = await apiClient.getRule(ruleId);
setRuleDetails(rule);
// Заполняем поля редактирования параметрами из загруженного правила
setEditingEvent((prev: any) => {
// Определяем repeatMode на основе параметров правила
let repeatMode: 'until' | 'count' | 'forever' = 'until';
if (rule.params?.count) {
repeatMode = 'count';
} else if (!prev.end_recurring_period) {
repeatMode = 'forever';
}
return {
...prev,
isRecurring: true,
frequency: rule.frequency,
interval: rule.params?.interval || 1,
byweekday: normalizeByweekday(rule.params?.byweekday),
count: rule.params?.count || undefined,
repeatMode,
};
});
} catch (error) {
console.error('Failed to load rule:', error);
}
};
const loadAvailableEmployees = async () => {
setLoadingEmployees(true);
try {
const data = await apiClient.getEmployees({ limit: 200 });
setAvailableEmployees(data.results || []);
} catch (error) {
console.error('Failed to load employees:', error);
} finally {
setLoadingEmployees(false);
}
};
const handleRemoveParticipant = async (relationId: number) => {
if (!confirm('Удалить участника из события?')) return;
try {
await apiClient.removeEventParticipant(relationId);
await loadEventParticipants(editingEvent.id);
} catch (error) {
console.error('Failed to remove participant:', error);
alert('Не удалось удалить участника');
}
};
const handleAddParticipants = async () => {
if (selectedEmployeeIds.length === 0) return;
// Если событие еще не создано - добавляем в pending список
if (!editingEvent.id) {
setPendingParticipantIds([...pendingParticipantIds, ...selectedEmployeeIds]);
setSelectedEmployeeIds([]);
setShowAddParticipants(false);
setSearchQuery('');
return;
}
// Если событие уже существует - добавляем сразу
setAddingParticipants(true);
try {
await Promise.all(
selectedEmployeeIds.map(userId =>
apiClient.addEventParticipant(editingEvent.id, userId, 'attendee')
)
);
await loadEventParticipants(editingEvent.id);
setSelectedEmployeeIds([]);
setShowAddParticipants(false);
setSearchQuery('');
} catch (error) {
console.error('Failed to add participants:', error);
alert('Не удалось добавить участников');
} finally {
setAddingParticipants(false);
}
};
const handleSave = async () => {
if (!editingEvent || !editingEvent.title?.trim()) return;
try {
setSaving(true);
let ruleId = editingEvent.rule || null;
// Если убрали галочку "Повторяющееся событие" - сбрасываем rule
if (!editingEvent.isRecurring && editingEvent.id && editingEvent.rule) {
ruleId = null;
}
// Если это повторяющееся событие - создаем или обновляем правило
if (editingEvent.isRecurring) {
const params: any = {
interval: editingEvent.interval || 1,
};
// Добавляем byweekday для еженедельных событий
const normalizedByweekday = normalizeByweekday(editingEvent.byweekday);
if (editingEvent.frequency === 'WEEKLY' && normalizedByweekday.length > 0) {
params.byweekday = normalizedByweekday;
}
// Добавляем count если выбран режим "Количество раз"
if (editingEvent.repeatMode === 'count' && editingEvent.count) {
params.count = editingEvent.count;
}
const ruleData = {
name: `Rule for ${editingEvent.title}`,
description: `Recurring rule for ${editingEvent.title}`,
frequency: editingEvent.frequency || 'WEEKLY',
params,
};
if (ruleId) {
// Обновляем существующее правило
await apiClient.updateRule(ruleId, ruleData);
} else {
// Создаем новое правило
const rule = await apiClient.createRule(ruleData);
ruleId = rule.id;
}
}
const eventData: any = {
title: editingEvent.title,
description: editingEvent.description,
start: typeof editingEvent.start === 'string'
? editingEvent.start
: editingEvent.start?.toISOString?.() || new Date(editingEvent.start).toISOString(),
end: typeof editingEvent.end === 'string'
? editingEvent.end
: editingEvent.end?.toISOString?.() || new Date(editingEvent.end).toISOString(),
calendar: editingEvent.calendar,
color_event: editingEvent.color_event || '#3498db',
rule: ruleId,
};
// Добавляем 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();
}
if (editingEvent.id) {
await apiClient.updateEvent(editingEvent.id, eventData);
} else {
const savedEvent = await apiClient.createEvent(eventData);
// Если есть ожидающие участники - добавляем их
if (pendingParticipantIds.length > 0 && savedEvent.id) {
try {
await Promise.all(
pendingParticipantIds.map(userId =>
apiClient.addEventParticipant(savedEvent.id, userId, 'attendee')
)
);
} catch (error) {
console.error('Failed to add participants to new event:', error);
}
}
}
setPendingParticipantIds([]);
onSave();
onClose();
} catch (err: any) {
console.error('❌ Ошибка сохранения события:', err);
alert('Не удалось сохранить событие:\n' + (err.message || 'Неизвестная ошибка'));
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!editingEvent?.id || !confirm(`Удалить событие "${editingEvent.title}"?`)) return;
try {
setSaving(true);
await apiClient.deleteEvent(editingEvent.id);
onSave();
onClose();
} catch (err) {
console.error('Ошибка удаления события:', err);
alert('Не удалось удалить событие');
} finally {
setSaving(false);
}
};
if (!isOpen || !editingEvent) return null;
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm p-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">
<h3 className="text-lg font-semibold text-gray-900">
{editingEvent.id ? "Редактировать событие" : "Создать событие"}
</h3>
<button
onClick={onClose}
disabled={saving}
className="rounded-full p-1 hover:bg-gray-100 disabled:opacity-50"
>
<X size={20} className="text-gray-600" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
Название
</label>
<input
type="text"
value={editingEvent.title || ''}
onChange={(e) => setEditingEvent({ ...editingEvent, title: e.target.value })}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
placeholder="Название события"
autoFocus
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
Описание
</label>
<textarea
value={editingEvent.description || ''}
onChange={(e) => setEditingEvent({ ...editingEvent, description: e.target.value })}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
placeholder="Описание (необязательно)"
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Начало</label>
<input
type="datetime-local"
value={
editingEvent.start
? toLocalDateTimeString(editingEvent.start)
: ''
}
onChange={(e) => setEditingEvent({
...editingEvent,
start: new Date(e.target.value).toISOString()
})}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Конец</label>
<input
type="datetime-local"
value={
editingEvent.end
? toLocalDateTimeString(editingEvent.end)
: ''
}
onChange={(e) => setEditingEvent({
...editingEvent,
end: new Date(e.target.value).toISOString()
})}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
/>
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
Цвет
</label>
<div className="flex items-center gap-2">
<input
type="color"
value={editingEvent.color_event || '#3498db'}
onChange={(e) => setEditingEvent({ ...editingEvent, color_event: e.target.value })}
className="h-10 w-20 rounded-lg border border-gray-300 cursor-pointer"
/>
<span className="text-xs text-gray-500">{editingEvent.color_event || '#3498db'}</span>
</div>
</div>
{/* Повторяющееся событие */}
<div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={editingEvent.isRecurring || false}
onChange={(e) => setEditingEvent({
...editingEvent,
isRecurring: e.target.checked,
frequency: e.target.checked ? 'WEEKLY' : undefined,
interval: e.target.checked ? 1 : undefined,
repeatMode: e.target.checked ? 'until' : undefined,
end_recurring_period: e.target.checked && !editingEvent.end_recurring_period
? new Date(new Date().setMonth(new Date().getMonth() + 3)).toISOString().slice(0, 10)
: editingEvent.end_recurring_period
})}
className="h-4 w-4 rounded border-gray-300 text-sky-500 focus:ring-2 focus:ring-sky-100"
/>
<span className="text-sm font-medium text-gray-700">Повторяющееся событие</span>
</label>
{editingEvent.isRecurring && (
<div className="space-y-3 rounded-lg bg-gray-50 p-3">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
Частота повторения
</label>
<select
value={editingEvent.frequency || 'WEEKLY'}
onChange={(e) => setEditingEvent({ ...editingEvent, frequency: e.target.value })}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
>
<option value="DAILY">Ежедневно</option>
<option value="WEEKLY">Еженедельно</option>
<option value="MONTHLY">Ежемесячно</option>
<option value="YEARLY">Ежегодно</option>
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
Интервал
</label>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">Каждые</span>
<input
type="number"
min="1"
max="365"
value={editingEvent.interval || 1}
onChange={(e) => setEditingEvent({ ...editingEvent, interval: parseInt(e.target.value) })}
className="w-20 rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
/>
<span className="text-sm text-gray-600">
{editingEvent.frequency === 'DAILY' && 'дней'}
{editingEvent.frequency === 'WEEKLY' && (editingEvent.interval === 1 ? 'неделю' : 'недели')}
{editingEvent.frequency === 'MONTHLY' && (editingEvent.interval === 1 ? 'месяц' : 'месяца')}
{editingEvent.frequency === 'YEARLY' && (editingEvent.interval === 1 ? 'год' : 'года')}
</span>
</div>
</div>
{editingEvent.frequency === 'WEEKLY' && (
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Дни недели
</label>
<div className="grid grid-cols-7 gap-1">
{[
{ value: 0, label: 'ПН' },
{ value: 1, label: 'ВТ' },
{ value: 2, label: 'СР' },
{ value: 3, label: 'ЧТ' },
{ value: 4, label: 'ПТ' },
{ value: 5, label: 'СБ' },
{ value: 6, label: 'ВС' },
].map((day) => {
const byweekday = normalizeByweekday(editingEvent.byweekday);
const isSelected = byweekday.includes(day.value);
return (
<button
key={day.value}
type="button"
onClick={() => {
const current = normalizeByweekday(editingEvent.byweekday);
const updated = isSelected
? current.filter((d: number) => d !== day.value)
: [...current, day.value].sort();
setEditingEvent({ ...editingEvent, byweekday: updated });
}}
className={`rounded-lg px-2 py-1.5 text-xs font-medium transition ${
isSelected
? 'bg-sky-500 text-white'
: 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
>
{day.label}
</button>
);
})}
</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>
)}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Окончание повторения
</label>
<div className="flex items-center gap-2 mb-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
checked={editingEvent.repeatMode === 'until'}
onChange={() => setEditingEvent({ ...editingEvent, repeatMode: 'until', count: undefined })}
className="h-4 w-4 text-sky-500 focus:ring-sky-500"
/>
<span className="text-sm text-gray-700">До даты</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
checked={editingEvent.repeatMode === 'count'}
onChange={() => setEditingEvent({ ...editingEvent, repeatMode: 'count', end_recurring_period: undefined })}
className="h-4 w-4 text-sky-500 focus:ring-sky-500"
/>
<span className="text-sm text-gray-700">Количество раз</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
checked={editingEvent.repeatMode === 'forever'}
onChange={() => setEditingEvent({ ...editingEvent, repeatMode: 'forever', count: undefined, end_recurring_period: undefined })}
className="h-4 w-4 text-sky-500 focus:ring-sky-500"
/>
<span className="text-sm text-gray-700">Бесконечно</span>
</label>
</div>
{editingEvent.repeatMode === 'count' ? (
<input
type="number"
min="1"
max="999"
value={editingEvent.count || 10}
onChange={(e) => setEditingEvent({ ...editingEvent, count: parseInt(e.target.value) })}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
placeholder="Количество повторений"
/>
) : editingEvent.repeatMode === 'until' ? (
<input
type="date"
value={editingEvent.end_recurring_period || ''}
onChange={(e) => setEditingEvent({ ...editingEvent, end_recurring_period: e.target.value })}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
min={new Date().toISOString().slice(0, 10)}
/>
) : (
<p className="text-xs text-gray-500 italic">
Событие будет повторяться бесконечно
</p>
)}
</div>
</div>
)}
</div>
{/* Event Participants */}
{showParticipants && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-gray-700">
Участники события
{!eventParticipantsLoading && eventParticipants.length > 0 && (
<span className="ml-1 text-gray-500">({eventParticipants.length})</span>
)}
</label>
<button
type="button"
onClick={() => {
setShowAddParticipants(!showAddParticipants);
if (!showAddParticipants && availableEmployees.length === 0) {
loadAvailableEmployees();
}
}}
className="text-xs text-sky-600 hover:text-sky-700 font-medium"
>
{showAddParticipants ? 'Отмена' : '+ Добавить участников'}
</button>
</div>
{/* Pending участники для нового события */}
{!editingEvent.id && pendingParticipantIds.length > 0 && (
<div className="space-y-2 mb-3">
<p className="text-xs text-blue-600 font-medium">Будут добавлены после создания:</p>
{pendingParticipantIds.map(userId => {
const emp = availableEmployees.find(e => e.id === userId);
return emp ? (
<div
key={userId}
className="flex items-center justify-between rounded-lg bg-blue-50 px-3 py-2"
>
<div className="flex items-center gap-2">
<div className="h-6 w-6 rounded-full bg-blue-100 flex items-center justify-center">
<span className="text-xs font-medium text-blue-700">
{emp.first_name?.[0] || '?'}
</span>
</div>
<div className="text-xs">
<div className="font-medium text-gray-900">{emp.first_name} {emp.last_name}</div>
<div className="text-gray-500">attendee</div>
</div>
</div>
<button
type="button"
onClick={() => setPendingParticipantIds(pendingParticipantIds.filter(id => id !== userId))}
className="text-red-600 hover:text-red-700 p-1 rounded hover:bg-red-50"
title="Удалить"
>
<X size={14} />
</button>
</div>
) : null;
})}
</div>
)}
{eventParticipantsLoading ? (
<p className="text-xs text-gray-500 py-2">Загрузка...</p>
) : editingEvent.id && eventParticipants.length > 0 ? (
<div className="space-y-2 max-h-32 overflow-y-auto">
{eventParticipants.map((participant: any) => (
<div
key={participant.id}
className="flex items-center justify-between rounded-lg bg-gray-50 px-3 py-2"
>
<div className="flex items-center gap-2">
<div className="h-6 w-6 rounded-full bg-sky-100 flex items-center justify-center">
<span className="text-xs font-medium text-sky-700">
{participant.user_name?.[0] || '?'}
</span>
</div>
<div className="text-xs">
<div className="font-medium text-gray-900">{participant.user_name}</div>
<div className="text-gray-500">{participant.distinction || 'attendee'}</div>
</div>
</div>
<button
type="button"
onClick={() => handleRemoveParticipant(participant.id)}
className="text-red-600 hover:text-red-700 p-1 rounded hover:bg-red-50"
title="Удалить участника"
>
<X size={14} />
</button>
</div>
))}
</div>
) : editingEvent.id ? (
<p className="text-xs text-gray-500 py-2">Нет участников</p>
) : !pendingParticipantIds.length ? (
<p className="text-xs text-gray-500 py-2">Добавьте участников после создания или выберите их до сохранения</p>
) : null}
{/* Форма добавления участников */}
{showAddParticipants && (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3 space-y-3">
<input
type="text"
placeholder="Поиск сотрудников..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
/>
{loadingEmployees ? (
<p className="text-xs text-gray-500 py-2">Загрузка сотрудников...</p>
) : (
<>
<div className="max-h-48 overflow-y-auto space-y-1">
{availableEmployees
.filter(emp => {
const fullName = `${emp.first_name} ${emp.last_name} ${emp.email || ''}`.toLowerCase();
const matchesSearch = fullName.includes(searchQuery.toLowerCase());
const notParticipant = !eventParticipants.find(p => p.object_id === emp.id);
const notPending = !pendingParticipantIds.includes(emp.id);
return matchesSearch && notParticipant && notPending;
})
.map(emp => (
<label
key={emp.id}
className="flex items-center gap-2 p-2 rounded hover:bg-white cursor-pointer"
>
<input
type="checkbox"
checked={selectedEmployeeIds.includes(emp.id)}
onChange={(e) => {
if (e.target.checked) {
setSelectedEmployeeIds([...selectedEmployeeIds, emp.id]);
} else {
setSelectedEmployeeIds(selectedEmployeeIds.filter(id => id !== emp.id));
}
}}
className="h-4 w-4 rounded border-gray-300 text-sky-500 focus:ring-2 focus:ring-sky-100"
/>
<div className="text-xs">
<div className="font-medium text-gray-900">
{emp.first_name} {emp.last_name}
</div>
{emp.email && <div className="text-gray-500">{emp.email}</div>}
</div>
</label>
))}
</div>
{selectedEmployeeIds.length > 0 && (
<button
type="button"
onClick={handleAddParticipants}
disabled={addingParticipants}
className="w-full rounded-lg bg-sky-500 px-4 py-2 text-sm font-medium text-white transition hover:bg-sky-600 disabled:opacity-50"
>
{addingParticipants ? 'Добавление...' : `Добавить выбранных (${selectedEmployeeIds.length})`}
</button>
)}
</>
)}
</div>
)}
</div>
)}
<div className="flex gap-2">
<button
onClick={handleSave}
disabled={!editingEvent.title?.trim() || saving}
className="flex-1 rounded-lg bg-sky-500 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-sky-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? 'Сохранение...' : (editingEvent.id ? 'Сохранить' : 'Создать')}
</button>
{editingEvent.id && (
<button
onClick={handleDelete}
disabled={saving}
className="rounded-lg border border-red-200 bg-red-50 px-4 py-2.5 text-sm font-medium text-red-600 transition hover:bg-red-100 disabled:opacity-50"
title="Удалить событие"
>
<Trash2 size={16} />
</button>
)}
</div>
</div>
</div>
</div>
);
}