Files
mkd/components/office/MeetingsAndRooms.tsx
2026-02-04 00:17:04 +05:00

1424 lines
64 KiB
TypeScript
Executable File
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
import React, { useState, useEffect } from 'react';
import { authFetch } from '../../services/apiClient';
import { Calendar, Clock, Users, MapPin, Plus, CheckCircle2, AlertCircle, X, Edit, Save, Play, Square, UserPlus, Calendar as CalendarIcon, FileText, MessageSquare, Building2 } from 'lucide-react';
import { Meeting, MeetingRoom, MeetingBooking } from '../../types';
import { CURRENT_USER_MOCK } from '../../constants';
export const MeetingsAndRooms: React.FC = () => {
const [meetings, setMeetings] = useState<Meeting[]>([]);
const [rooms, setRooms] = useState<MeetingRoom[]>([]);
const [bookings, setBookings] = useState<MeetingBooking[]>([]);
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showCreateRoomModal, setShowCreateRoomModal] = useState(false);
const [editingRoom, setEditingRoom] = useState<MeetingRoom | null>(null);
const [showRescheduleModal, setShowRescheduleModal] = useState(false);
const [selectedMeeting, setSelectedMeeting] = useState<Meeting | null>(null);
const [showEditModal, setShowEditModal] = useState(false);
const [rescheduleData, setRescheduleData] = useState({
date: '',
startTime: '',
endTime: ''
});
const [roomFormData, setRoomFormData] = useState({
name: '',
capacity: 10,
location: '',
description: '',
equipment: [] as string[],
isActive: true
});
const [showMeetingCard, setShowMeetingCard] = useState(false);
const [meetingCardData, setMeetingCardData] = useState({
notes: '',
conclusions: '',
questions: '',
theses: ''
});
const [employees, setEmployees] = useState<Array<{ id: string; name: string }>>([]);
const [formData, setFormData] = useState({
title: '',
description: '',
roomId: '',
startTime: '',
endTime: '',
participantIds: [] as string[],
agenda: ''
});
const [editData, setEditData] = useState({
notes: '',
conclusions: ''
});
useEffect(() => {
fetchMeetings();
fetchRooms();
fetchBookings();
}, [selectedDate]);
useEffect(() => {
const fetchEmployees = async () => {
try {
const res = await authFetch('/api/employees/list');
if (res.ok) {
const data = await res.json();
setEmployees(Array.isArray(data) ? data : []);
}
} catch (e) {
console.error('Ошибка загрузки списка сотрудников:', e);
}
};
fetchEmployees();
}, []);
const fetchMeetings = async () => {
try {
const response = await authFetch(`/api/office/meetings?startDate=${selectedDate}&endDate=${selectedDate}`);
if (response.ok) {
const data = await response.json();
// Нормализуем данные из API
const normalizedData = data.map((meeting: any) => ({
id: meeting.id,
title: meeting.title || '',
description: meeting.description,
organizer: meeting.organizer || '',
startTime: meeting.start_time || meeting.startTime,
endTime: meeting.end_time || meeting.endTime,
roomId: meeting.room_id || meeting.roomId,
room: meeting.room_name ? {
id: meeting.room_id,
name: meeting.room_name,
capacity: meeting.room_capacity
} : meeting.room,
participants: Array.isArray(meeting.participants) ? meeting.participants : (typeof meeting.participants === 'string' ? JSON.parse(meeting.participants || '[]') : []),
agenda: meeting.agenda,
notes: meeting.notes || null,
conclusions: meeting.conclusions || null,
status: meeting.status || 'scheduled',
reminderSent: meeting.reminder_sent || meeting.reminderSent || false,
reminderTime: meeting.reminder_time || meeting.reminderTime,
attachments: Array.isArray(meeting.attachments) ? meeting.attachments : (typeof meeting.attachments === 'string' ? JSON.parse(meeting.attachments || '[]') : []),
createdAt: meeting.created_at || meeting.createdAt,
updatedAt: meeting.updated_at || meeting.updatedAt
}));
setMeetings(normalizedData);
}
} catch (error) {
console.error('Ошибка загрузки совещаний:', error);
}
};
const fetchRooms = async () => {
try {
const response = await authFetch('/api/office/meeting-rooms?all=true');
if (response.ok) {
const data = await response.json();
// Нормализуем данные из API
const normalizedData = data.map((room: any) => ({
id: room.id,
name: room.name || '',
capacity: room.capacity || 0,
location: room.location,
equipment: Array.isArray(room.equipment) ? room.equipment : (typeof room.equipment === 'string' ? JSON.parse(room.equipment || '[]') : []),
description: room.description,
isActive: room.is_active !== undefined ? room.is_active : (room.isActive !== undefined ? room.isActive : true),
createdAt: room.created_at || room.createdAt,
updatedAt: room.updated_at || room.updatedAt
}));
setRooms(normalizedData);
}
} catch (error) {
console.error('Ошибка загрузки переговорных:', error);
}
};
const handleCreateMeeting = async () => {
try {
if (!formData.title || !formData.title.trim()) {
alert('Пожалуйста, укажите название совещания');
return;
}
if (!formData.startTime) {
alert('Пожалуйста, укажите время начала');
return;
}
if (!formData.endTime) {
alert('Пожалуйста, укажите время окончания');
return;
}
const startDateTime = new Date(`${selectedDate}T${formData.startTime}`);
const endDateTime = new Date(`${selectedDate}T${formData.endTime}`);
if (endDateTime <= startDateTime) {
alert('Время окончания должно быть позже времени начала');
return;
}
const participantsArray = formData.participantIds
.map(id => employees.find(e => e.id === id)?.name)
.filter((n): n is string => !!n);
const response = await authFetch('/api/office/meetings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: formData.title,
description: formData.description || null,
organizer: CURRENT_USER_MOCK.name,
startTime: startDateTime.toISOString(),
endTime: endDateTime.toISOString(),
roomId: formData.roomId ? parseInt(formData.roomId) : null,
participants: participantsArray,
agenda: formData.agenda || null
})
});
if (response.ok) {
const newMeeting = await response.json();
// Нормализуем данные
const normalizedMeeting = {
id: newMeeting.id,
title: newMeeting.title || formData.title,
description: newMeeting.description || formData.description,
organizer: newMeeting.organizer || CURRENT_USER_MOCK.name,
startTime: newMeeting.start_time || startDateTime.toISOString(),
endTime: newMeeting.end_time || endDateTime.toISOString(),
roomId: newMeeting.room_id || (formData.roomId ? parseInt(formData.roomId) : null),
room: rooms.find(r => r.id === (formData.roomId ? parseInt(formData.roomId) : null)),
participants: participantsArray,
agenda: newMeeting.agenda || formData.agenda,
notes: newMeeting.notes,
conclusions: newMeeting.conclusions,
status: newMeeting.status || 'scheduled',
reminderSent: false,
attachments: [],
createdAt: newMeeting.created_at,
updatedAt: newMeeting.updated_at
};
// Устанавливаем выбранную дату на дату созданного совещания, чтобы оно отобразилось
const meetingDate = new Date(normalizedMeeting.startTime).toISOString().split('T')[0];
setSelectedDate(meetingDate);
// Обновляем список (useEffect автоматически вызовет fetchMeetings при изменении selectedDate)
setShowCreateModal(false);
setFormData({
title: '',
description: '',
roomId: '',
startTime: '',
endTime: '',
participantIds: [],
agenda: ''
});
} else {
const errorData = await response.json().catch(() => ({ error: 'Неизвестная ошибка' }));
alert(`Ошибка создания совещания: ${errorData.error || 'Неизвестная ошибка'}`);
}
} catch (error: any) {
console.error('Ошибка создания совещания:', error);
alert(`Ошибка создания совещания: ${error.message || 'Неизвестная ошибка'}`);
}
};
const handleOpenEditModal = (meeting: Meeting) => {
setSelectedMeeting(meeting);
setEditData({
notes: meeting.notes || '',
conclusions: meeting.conclusions || ''
});
setShowEditModal(true);
};
const handleSaveMeeting = async () => {
if (!selectedMeeting) return;
try {
const response = await authFetch(`/api/office/meetings/${selectedMeeting.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
notes: editData.notes,
conclusions: editData.conclusions
})
});
if (response.ok) {
const updatedMeeting = await response.json();
// Обновляем в списке
setMeetings(meetings.map(m =>
m.id === selectedMeeting.id
? { ...m, notes: updatedMeeting.notes || editData.notes, conclusions: updatedMeeting.conclusions || editData.conclusions }
: m
));
setShowEditModal(false);
setSelectedMeeting(null);
fetchMeetings(); // Обновляем список
} else {
const errorData = await response.json().catch(() => ({ error: 'Неизвестная ошибка' }));
alert(`Ошибка сохранения: ${errorData.error || 'Неизвестная ошибка'}`);
}
} catch (error: any) {
console.error('Ошибка сохранения совещания:', error);
alert(`Ошибка сохранения: ${error.message || 'Неизвестная ошибка'}`);
}
};
const handleStartMeeting = async () => {
if (!selectedMeeting) return;
try {
const response = await authFetch(`/api/office/meetings/${selectedMeeting.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
status: 'in_progress'
})
});
if (response.ok) {
fetchMeetings();
if (selectedMeeting) {
setSelectedMeeting({ ...selectedMeeting, status: 'in_progress' });
}
alert('Совещание начато');
} else {
const errorData = await response.json().catch(() => ({ error: 'Неизвестная ошибка' }));
alert(`Ошибка: ${errorData.error || 'Неизвестная ошибка'}`);
}
} catch (error: any) {
console.error('Ошибка начала совещания:', error);
alert(`Ошибка: ${error.message || 'Неизвестная ошибка'}`);
}
};
const handleEndMeeting = async () => {
if (!selectedMeeting) return;
try {
const response = await authFetch(`/api/office/meetings/${selectedMeeting.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
status: 'completed',
notes: meetingCardData.notes,
conclusions: meetingCardData.conclusions
})
});
if (response.ok) {
fetchMeetings();
if (selectedMeeting) {
setSelectedMeeting({ ...selectedMeeting, status: 'completed' });
}
alert('Совещание завершено');
} else {
const errorData = await response.json().catch(() => ({ error: 'Неизвестная ошибка' }));
alert(`Ошибка: ${errorData.error || 'Неизвестная ошибка'}`);
}
} catch (error: any) {
console.error('Ошибка завершения совещания:', error);
alert(`Ошибка: ${error.message || 'Неизвестная ошибка'}`);
}
};
const handleSaveMeetingCard = async () => {
if (!selectedMeeting) return;
try {
const response = await authFetch(`/api/office/meetings/${selectedMeeting.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
notes: meetingCardData.notes,
conclusions: meetingCardData.conclusions
})
});
if (response.ok) {
fetchMeetings();
alert('Данные сохранены');
} else {
const errorData = await response.json().catch(() => ({ error: 'Неизвестная ошибка' }));
alert(`Ошибка сохранения: ${errorData.error || 'Неизвестная ошибка'}`);
}
} catch (error: any) {
console.error('Ошибка сохранения:', error);
alert(`Ошибка: ${error.message || 'Неизвестная ошибка'}`);
}
};
const handleRescheduleMeeting = async () => {
if (!selectedMeeting) return;
if (!rescheduleData.date || !rescheduleData.startTime || !rescheduleData.endTime) {
alert('Заполните все поля для переноса');
return;
}
try {
const newStartDateTime = new Date(`${rescheduleData.date}T${rescheduleData.startTime}`);
const newEndDateTime = new Date(`${rescheduleData.date}T${rescheduleData.endTime}`);
if (newEndDateTime <= newStartDateTime) {
alert('Время окончания должно быть позже времени начала');
return;
}
const response = await authFetch(`/api/office/meetings/${selectedMeeting.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
startTime: newStartDateTime.toISOString(),
endTime: newEndDateTime.toISOString()
})
});
if (response.ok) {
fetchMeetings();
setSelectedDate(rescheduleData.date);
setShowRescheduleModal(false);
setShowMeetingCard(false);
alert('Совещание перенесено');
} else {
const errorData = await response.json().catch(() => ({ error: 'Неизвестная ошибка' }));
alert(`Ошибка: ${errorData.error || 'Неизвестная ошибка'}`);
}
} catch (error: any) {
console.error('Ошибка переноса совещания:', error);
alert(`Ошибка: ${error.message || 'Неизвестная ошибка'}`);
}
};
const handleOpenEditRoom = (room: MeetingRoom) => {
setEditingRoom(room);
setRoomFormData({
name: room.name,
capacity: room.capacity,
location: room.location || '',
description: room.description || '',
equipment: Array.isArray(room.equipment) ? [...room.equipment] : [],
isActive: room.isActive !== false
});
setShowCreateRoomModal(true);
};
const handleCreateRoom = async () => {
try {
if (!roomFormData.name || !roomFormData.name.trim()) {
alert('Пожалуйста, укажите название переговорной');
return;
}
if (editingRoom) {
const response = await authFetch(`/api/office/meeting-rooms/${editingRoom.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: roomFormData.name,
capacity: roomFormData.capacity,
location: roomFormData.location || null,
description: roomFormData.description || null,
equipment: roomFormData.equipment,
isActive: roomFormData.isActive !== false
})
});
if (response.ok) {
fetchRooms();
setShowCreateRoomModal(false);
setEditingRoom(null);
setRoomFormData({ name: '', capacity: 10, location: '', description: '', equipment: [], isActive: true });
alert('Переговорная обновлена');
} else {
const errorData = await response.json().catch(() => ({ error: 'Неизвестная ошибка' }));
alert(`Ошибка сохранения: ${errorData.error || 'Неизвестная ошибка'}`);
}
return;
}
const response = await authFetch('/api/office/meeting-rooms', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: roomFormData.name,
capacity: roomFormData.capacity,
location: roomFormData.location || null,
description: roomFormData.description || null,
equipment: roomFormData.equipment
})
});
if (response.ok) {
fetchRooms();
setShowCreateRoomModal(false);
setRoomFormData({
name: '',
capacity: 10,
location: '',
description: '',
equipment: [],
isActive: true
});
alert('Переговорная комната успешно создана');
} else {
const errorData = await response.json().catch(() => ({ error: 'Неизвестная ошибка' }));
alert(`Ошибка создания переговорной: ${errorData.error || 'Неизвестная ошибка'}`);
}
} catch (error: any) {
console.error('Ошибка создания переговорной:', error);
alert(`Ошибка создания переговорной: ${error.message || 'Неизвестная ошибка'}`);
}
};
const handleAddParticipants = async (newParticipants: string[]) => {
if (!selectedMeeting) return;
try {
const currentParticipants = selectedMeeting.participants || [];
const updatedParticipants = [...new Set([...currentParticipants, ...newParticipants])];
const response = await authFetch(`/api/office/meetings/${selectedMeeting.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
participants: updatedParticipants
})
});
if (response.ok) {
fetchMeetings();
if (selectedMeeting) {
setSelectedMeeting({ ...selectedMeeting, participants: updatedParticipants });
}
alert('Участники добавлены');
} else {
const errorData = await response.json().catch(() => ({ error: 'Неизвестная ошибка' }));
alert(`Ошибка: ${errorData.error || 'Неизвестная ошибка'}`);
}
} catch (error: any) {
console.error('Ошибка добавления участников:', error);
alert(`Ошибка: ${error.message || 'Неизвестная ошибка'}`);
}
};
const fetchBookings = async () => {
try {
const response = await authFetch(`/api/office/meeting-bookings?startDate=${selectedDate}&endDate=${selectedDate}`);
if (response.ok) {
const data = await response.json();
const normalized = (Array.isArray(data) ? data : []).map((b: any) => ({
id: b.id,
roomId: b.room_id ?? b.roomId,
meetingId: b.meeting_id ?? b.meetingId,
bookedBy: b.booked_by ?? b.bookedBy,
startTime: b.start_time ?? b.startTime,
endTime: b.end_time ?? b.endTime,
purpose: b.purpose,
status: b.status ?? 'active',
room: b.room_name ? { id: b.room_id, name: b.room_name, capacity: 0, isActive: true, createdAt: '', updatedAt: '' } : b.room,
meeting: b.meeting_title ? { id: 0, title: b.meeting_title, organizer: '', startTime: b.start_time, endTime: b.end_time, participants: [], status: 'scheduled', reminderSent: false, createdAt: '', updatedAt: '' } : b.meeting,
createdAt: b.created_at ?? b.createdAt,
updatedAt: b.updated_at ?? b.updatedAt
}));
setBookings(normalized);
}
} catch (error) {
console.error('Ошибка загрузки бронирований:', error);
}
};
return (
<div className="space-y-6 animate-fade-in">
<div className="flex justify-between items-center">
<h3 className="text-lg font-bold text-slate-800">Совещания и переговорные</h3>
<button
onClick={() => {
setShowCreateModal(true);
// Устанавливаем время по умолчанию
const now = new Date();
const defaultStart = new Date(now.getTime() + 60 * 60 * 1000); // Через час
const defaultEnd = new Date(defaultStart.getTime() + 60 * 60 * 1000); // Через 2 часа
setFormData(prev => ({
...prev,
startTime: defaultStart.toTimeString().slice(0, 5),
endTime: defaultEnd.toTimeString().slice(0, 5)
}));
}}
className="bg-primary-600 text-white px-4 py-2 rounded-lg text-sm font-bold hover:bg-primary-700 transition-colors flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Новое совещание
</button>
</div>
{/* Выбор даты */}
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-4">
<div className="flex items-center gap-4">
<label className="text-sm font-medium text-slate-700">Дата:</label>
<input
type="date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
className="px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Совещания */}
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6">
<h4 className="font-bold text-slate-800 mb-4 flex items-center gap-2">
<Calendar className="w-5 h-5" />
Совещания
</h4>
{meetings.length > 0 ? (
<div className="space-y-3">
{meetings.map((meeting) => (
<div
key={meeting.id}
className="p-4 bg-slate-50 rounded-lg border border-slate-200 cursor-pointer hover:border-primary-300 transition-colors"
onClick={() => {
setSelectedMeeting(meeting);
setMeetingCardData({
notes: meeting.notes || '',
conclusions: meeting.conclusions || '',
questions: '',
theses: ''
});
setShowMeetingCard(true);
}}
>
<div className="flex justify-between items-start mb-2">
<h5 className="font-bold text-slate-800">{meeting.title}</h5>
{(meeting.notes || meeting.conclusions) && (
<span className="text-xs text-emerald-600 font-bold flex items-center gap-1">
<Edit className="w-3 h-3" />
Есть записи
</span>
)}
</div>
<div className="space-y-1 text-sm text-slate-600">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4" />
{new Date(meeting.startTime).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })} - {new Date(meeting.endTime).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
</div>
{meeting.room && (
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4" />
{meeting.room.name}
</div>
)}
<div className="flex items-center gap-2">
<Users className="w-4 h-4" />
{(meeting.participants || []).length} участников
</div>
<div className="flex gap-2 mt-2">
<button
onClick={(e) => {
e.stopPropagation();
setSelectedMeeting(meeting);
// Инициализируем данные карточки из совещания
setMeetingCardData({
notes: meeting.notes || '',
conclusions: meeting.conclusions || '',
questions: '', // Пока нет поля в БД, можно добавить позже
theses: '' // Пока нет поля в БД, можно добавить позже
});
setShowMeetingCard(true);
}}
className="px-3 py-1.5 bg-blue-50 text-blue-600 rounded-lg text-xs font-bold hover:bg-blue-100 transition-colors flex items-center gap-1"
title="Открыть карточку совещания"
>
<CheckCircle2 className="w-3 h-3" />
Карточка
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleOpenEditModal(meeting);
}}
className="px-3 py-1.5 bg-emerald-50 text-emerald-600 rounded-lg text-xs font-bold hover:bg-emerald-100 transition-colors flex items-center gap-1"
title="Редактировать заметки и заключения"
>
<Edit className="w-3 h-3" />
Заметки
</button>
</div>
</div>
</div>
))}
</div>
) : (
<p className="text-slate-500 text-sm">Нет совещаний на выбранную дату</p>
)}
</div>
{/* Переговорные */}
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6">
<div className="flex justify-between items-center mb-4">
<h4 className="font-bold text-slate-800">Переговорные комнаты</h4>
<button
onClick={() => {
setEditingRoom(null);
setRoomFormData({
name: '',
capacity: 10,
location: '',
description: '',
equipment: [],
isActive: true
});
setShowCreateRoomModal(true);
}}
className="px-3 py-1.5 bg-primary-600 text-white rounded-lg text-xs font-bold hover:bg-primary-700 transition-colors flex items-center gap-1"
>
<Plus className="w-3 h-3" />
Создать
</button>
</div>
{rooms.length > 0 ? (
<div className="space-y-3">
{rooms.map((room) => {
const roomBookings = bookings.filter(b => b.roomId === room.id);
const activeBookings = roomBookings
.filter(b => b.status === 'active')
.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
const isFree = room.isActive !== false && activeBookings.length === 0;
return (
<div
key={room.id}
className={`p-4 rounded-lg border ${room.isActive === false ? 'bg-slate-100 border-slate-200' : 'bg-slate-50 border-slate-200'}`}
>
<div className="flex items-start justify-between gap-2 mb-2">
<div className="min-w-0">
<h5 className="font-bold text-slate-800">{room.name}</h5>
{room.isActive === false && (
<span className="text-xs text-amber-600 font-medium">Неактивна</span>
)}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{room.isActive !== false && (
<button
onClick={(e) => { e.stopPropagation(); handleOpenEditRoom(room); }}
className="p-1.5 text-slate-500 hover:text-primary-600 hover:bg-primary-50 rounded-lg transition-colors"
title="Редактировать переговорную"
>
<Edit className="w-4 h-4" />
</button>
)}
<span className={`px-2 py-1 rounded text-xs ${
room.isActive === false
? 'bg-slate-200 text-slate-600'
: isFree
? 'bg-emerald-100 text-emerald-700'
: 'bg-amber-100 text-amber-700'
}`}>
{room.isActive === false ? 'Неактивна' : isFree ? 'Свободна' : 'Занята'}
</span>
</div>
</div>
<p className="text-sm text-slate-600 mb-2">
Вместимость: {room.capacity} чел.
{room.location && ` · ${room.location}`}
</p>
{room.isActive !== false && (
<div className="mt-2">
{activeBookings.length > 0 ? (
<div className="space-y-1.5">
<p className="text-xs font-medium text-slate-500">Занято:</p>
{activeBookings.map((booking) => (
<div key={booking.id} className="text-xs text-slate-600 flex items-center gap-2">
<Clock className="w-3.5 h-3.5 flex-shrink-0" />
{new Date(booking.startTime).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}{new Date(booking.endTime).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
{(booking.purpose || booking.meeting?.title) && (
<span className="text-slate-500">
· {booking.meeting?.title ? `Совещание: ${booking.meeting.title}` : booking.purpose}
</span>
)}
</div>
))}
</div>
) : (
<p className="text-xs text-emerald-600">На выбранную дату свободна весь день</p>
)}
</div>
)}
{room.isActive === false && (
<button
onClick={(e) => { e.stopPropagation(); handleOpenEditRoom(room); }}
className="mt-2 text-xs text-primary-600 hover:underline"
>
Редактировать
</button>
)}
</div>
);
})}
</div>
) : (
<p className="text-slate-500 text-sm">Нет переговорных комнат</p>
)}
</div>
</div>
{/* Meeting Card Modal */}
{showMeetingCard && selectedMeeting && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl p-6 max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<div>
<h3 className="text-lg font-bold text-slate-800">{selectedMeeting.title}</h3>
<p className="text-sm text-slate-500 mt-1">
{new Date(selectedMeeting.startTime).toLocaleString('ru-RU')} - {new Date(selectedMeeting.endTime).toLocaleString('ru-RU')}
</p>
</div>
<button
onClick={() => {
setShowMeetingCard(false);
setSelectedMeeting(null);
}}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Статус и действия */}
<div className="mb-6 p-4 bg-slate-50 rounded-lg">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<span className={`px-3 py-1 rounded-lg text-xs font-bold ${
selectedMeeting.status === 'in_progress' ? 'bg-emerald-100 text-emerald-700' :
selectedMeeting.status === 'completed' ? 'bg-blue-100 text-blue-700' :
selectedMeeting.status === 'canceled' ? 'bg-red-100 text-red-700' :
'bg-slate-100 text-slate-700'
}`}>
{selectedMeeting.status === 'in_progress' ? 'Идет' :
selectedMeeting.status === 'completed' ? 'Завершено' :
selectedMeeting.status === 'canceled' ? 'Отменено' :
'Запланировано'}
</span>
{selectedMeeting.room && (
<span className="text-sm text-slate-600 flex items-center gap-1">
<MapPin className="w-4 h-4" />
{selectedMeeting.room.name}
</span>
)}
</div>
<div className="flex gap-2">
{selectedMeeting.status === 'scheduled' && (
<button
onClick={handleStartMeeting}
className="px-4 py-2 bg-emerald-600 text-white rounded-lg text-sm font-bold hover:bg-emerald-700 transition-colors flex items-center gap-2"
>
<Play className="w-4 h-4" />
Начать совещание
</button>
)}
{selectedMeeting.status === 'in_progress' && (
<button
onClick={handleEndMeeting}
className="px-4 py-2 bg-red-600 text-white rounded-lg text-sm font-bold hover:bg-red-700 transition-colors flex items-center gap-2"
>
<Square className="w-4 h-4" />
Завершить совещание
</button>
)}
</div>
</div>
{/* Информация о совещании */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-slate-600">Организатор:</span>
<span className="ml-2 font-medium">{selectedMeeting.organizer}</span>
</div>
<div>
<span className="text-slate-600">Участников:</span>
<span className="ml-2 font-medium">{(selectedMeeting.participants || []).length}</span>
</div>
{selectedMeeting.agenda && (
<div className="col-span-2">
<span className="text-slate-600">Повестка:</span>
<p className="mt-1 text-slate-800">{selectedMeeting.agenda}</p>
</div>
)}
{selectedMeeting.description && (
<div className="col-span-2">
<span className="text-slate-600">Описание:</span>
<p className="mt-1 text-slate-800">{selectedMeeting.description}</p>
</div>
)}
</div>
</div>
{/* Участники */}
<div className="mb-6">
<div className="flex justify-between items-center mb-2">
<h4 className="font-bold text-slate-800 flex items-center gap-2">
<Users className="w-4 h-4" />
Участники
</h4>
<button
onClick={() => {
const newParticipant = prompt('Введите имя участника:');
if (newParticipant && newParticipant.trim()) {
handleAddParticipants([newParticipant.trim()]);
}
}}
className="px-3 py-1.5 bg-primary-50 text-primary-600 rounded-lg text-xs font-bold hover:bg-primary-100 transition-colors flex items-center gap-1"
>
<UserPlus className="w-3 h-3" />
Добавить
</button>
</div>
<div className="flex flex-wrap gap-2">
{(selectedMeeting.participants || []).map((participant, index) => (
<span key={index} className="px-3 py-1 bg-slate-100 text-slate-700 rounded-lg text-sm">
{participant}
</span>
))}
{(!selectedMeeting.participants || selectedMeeting.participants.length === 0) && (
<span className="text-sm text-slate-400">Нет участников</span>
)}
</div>
</div>
{/* Тезисы */}
<div className="mb-6">
<h4 className="font-bold text-slate-800 mb-2 flex items-center gap-2">
<FileText className="w-4 h-4" />
Тезисы
</h4>
<textarea
value={meetingCardData.theses}
onChange={(e) => setMeetingCardData({ ...meetingCardData, theses: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
rows={4}
placeholder="Введите тезисы совещания..."
/>
</div>
{/* Вопросы */}
<div className="mb-6">
<h4 className="font-bold text-slate-800 mb-2 flex items-center gap-2">
<MessageSquare className="w-4 h-4" />
Вопросы
</h4>
<textarea
value={meetingCardData.questions}
onChange={(e) => setMeetingCardData({ ...meetingCardData, questions: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
rows={4}
placeholder="Введите вопросы для обсуждения..."
/>
</div>
{/* Заметки */}
<div className="mb-6">
<h4 className="font-bold text-slate-800 mb-2">Заметки / Протокол</h4>
<textarea
value={meetingCardData.notes}
onChange={(e) => setMeetingCardData({ ...meetingCardData, notes: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
rows={6}
placeholder="Введите заметки или протокол совещания..."
/>
</div>
{/* Заключения */}
<div className="mb-6">
<h4 className="font-bold text-slate-800 mb-2">Заключения</h4>
<textarea
value={meetingCardData.conclusions}
onChange={(e) => setMeetingCardData({ ...meetingCardData, conclusions: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
rows={6}
placeholder="Введите заключения совещания, принятые решения, задачи..."
/>
</div>
{/* Кнопки действий */}
<div className="flex gap-3 pt-4 border-t border-slate-200">
<button
onClick={() => {
setRescheduleData({
date: new Date(selectedMeeting.startTime).toISOString().split('T')[0],
startTime: new Date(selectedMeeting.startTime).toTimeString().slice(0, 5),
endTime: new Date(selectedMeeting.endTime).toTimeString().slice(0, 5)
});
setShowRescheduleModal(true);
}}
className="px-4 py-2 bg-amber-50 text-amber-600 rounded-lg text-sm font-bold hover:bg-amber-100 transition-colors flex items-center gap-2"
>
<CalendarIcon className="w-4 h-4" />
Перенести
</button>
<button
onClick={handleSaveMeetingCard}
className="flex-1 bg-primary-600 text-white px-4 py-2 rounded-lg font-bold text-sm hover:bg-primary-700 transition-colors flex items-center justify-center gap-2"
>
<Save className="w-4 h-4" />
Сохранить
</button>
<button
onClick={() => {
setShowMeetingCard(false);
setSelectedMeeting(null);
}}
className="px-4 py-2 bg-slate-100 text-slate-600 rounded-lg font-bold text-sm hover:bg-slate-200 transition-colors"
>
Закрыть
</button>
</div>
</div>
</div>
)}
{/* Edit Meeting Modal (Notes & Conclusions) */}
{showEditModal && selectedMeeting && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<div>
<h3 className="text-lg font-bold text-slate-800">Заметки и заключения</h3>
<p className="text-sm text-slate-500 mt-1">{selectedMeeting.title}</p>
</div>
<button
onClick={() => {
setShowEditModal(false);
setSelectedMeeting(null);
}}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Заметки / Протокол</label>
<textarea
value={editData.notes}
onChange={(e) => setEditData({ ...editData, notes: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
rows={8}
placeholder="Введите заметки или протокол совещания..."
/>
<p className="text-xs text-slate-400 mt-1">Заметки во время совещания, протокол</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Заключения</label>
<textarea
value={editData.conclusions}
onChange={(e) => setEditData({ ...editData, conclusions: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
rows={6}
placeholder="Введите заключения совещания, принятые решения, задачи..."
/>
<p className="text-xs text-slate-400 mt-1">Заключения, решения, задачи по итогам совещания</p>
</div>
<div className="flex gap-3 pt-4">
<button
onClick={handleSaveMeeting}
className="flex-1 bg-primary-600 text-white px-4 py-2 rounded-lg font-bold text-sm hover:bg-primary-700 transition-colors flex items-center justify-center gap-2"
>
<Save className="w-4 h-4" />
Сохранить
</button>
<button
onClick={() => {
setShowEditModal(false);
setSelectedMeeting(null);
}}
className="px-4 py-2 bg-slate-100 text-slate-600 rounded-lg font-bold text-sm hover:bg-slate-200 transition-colors"
>
Отмена
</button>
</div>
</div>
</div>
</div>
)}
{/* Create Meeting Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl p-6 max-w-md w-full max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-bold text-slate-800">Создать совещание</h3>
<button
onClick={() => setShowCreateModal(false)}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Название *</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
placeholder="Введите название совещания"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Дата</label>
<input
type="date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Время начала *</label>
<input
type="time"
value={formData.startTime}
onChange={(e) => setFormData({ ...formData, startTime: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Время окончания *</label>
<input
type="time"
value={formData.endTime}
onChange={(e) => setFormData({ ...formData, endTime: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Переговорная</label>
<select
value={formData.roomId}
onChange={(e) => setFormData({ ...formData, roomId: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
>
<option value="">Без переговорной</option>
{rooms.filter((r) => r.isActive !== false).map((room) => (
<option key={room.id} value={room.id.toString()}>
{room.name} (вместимость: {room.capacity})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Участники</label>
<div className="border border-slate-300 rounded-lg p-2 max-h-40 overflow-y-auto bg-slate-50/50">
{employees.length === 0 ? (
<p className="text-xs text-slate-500 py-2">Список сотрудников пуст</p>
) : (
<div className="space-y-1">
{employees.map((emp) => (
<label key={emp.id} className="flex items-center gap-2 py-1 px-2 rounded hover:bg-white cursor-pointer">
<input
type="checkbox"
checked={formData.participantIds.includes(emp.id)}
onChange={(e) => {
if (e.target.checked) {
setFormData({ ...formData, participantIds: [...formData.participantIds, emp.id] });
} else {
setFormData({ ...formData, participantIds: formData.participantIds.filter(id => id !== emp.id) });
}
}}
className="w-4 h-4 text-primary-600 border-slate-300 rounded"
/>
<span className="text-sm text-slate-700">{emp.name}</span>
</label>
))}
</div>
)}
</div>
{formData.participantIds.length > 0 && (
<p className="text-xs text-slate-600 mt-1">Выбрано: {formData.participantIds.length}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Описание</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
rows={3}
placeholder="Описание совещания..."
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Повестка дня</label>
<textarea
value={formData.agenda}
onChange={(e) => setFormData({ ...formData, agenda: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
rows={3}
placeholder="Повестка дня..."
/>
</div>
<div className="flex gap-3 pt-4">
<button
onClick={handleCreateMeeting}
disabled={!formData.title || !formData.startTime || !formData.endTime}
className="flex-1 bg-primary-600 text-white px-4 py-2 rounded-lg font-bold text-sm hover:bg-primary-700 transition-colors disabled:bg-slate-300 disabled:cursor-not-allowed"
>
Создать
</button>
<button
onClick={() => setShowCreateModal(false)}
className="px-4 py-2 bg-slate-100 text-slate-600 rounded-lg font-bold text-sm hover:bg-slate-200 transition-colors"
>
Отмена
</button>
</div>
</div>
</div>
</div>
)}
{/* Reschedule Meeting Modal */}
{showRescheduleModal && selectedMeeting && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl p-6 max-w-md w-full">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-bold text-slate-800">Перенести совещание</h3>
<button
onClick={() => {
setShowRescheduleModal(false);
}}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Название совещания</label>
<input
type="text"
value={selectedMeeting.title}
disabled
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm bg-slate-50"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Новая дата *</label>
<input
type="date"
value={rescheduleData.date}
onChange={(e) => setRescheduleData({ ...rescheduleData, date: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Время начала *</label>
<input
type="time"
value={rescheduleData.startTime}
onChange={(e) => setRescheduleData({ ...rescheduleData, startTime: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Время окончания *</label>
<input
type="time"
value={rescheduleData.endTime}
onChange={(e) => setRescheduleData({ ...rescheduleData, endTime: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
required
/>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 text-xs text-blue-800">
<p className="font-bold mb-1"> Текущее время:</p>
<p>
{new Date(selectedMeeting.startTime).toLocaleString('ru-RU')} - {new Date(selectedMeeting.endTime).toLocaleString('ru-RU')}
</p>
</div>
<div className="flex gap-3 pt-4">
<button
onClick={handleRescheduleMeeting}
disabled={!rescheduleData.date || !rescheduleData.startTime || !rescheduleData.endTime}
className="flex-1 bg-amber-600 text-white px-4 py-2 rounded-lg font-bold text-sm hover:bg-amber-700 transition-colors disabled:bg-slate-300 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
<CalendarIcon className="w-4 h-4" />
Перенести
</button>
<button
onClick={() => {
setShowRescheduleModal(false);
}}
className="px-4 py-2 bg-slate-100 text-slate-600 rounded-lg font-bold text-sm hover:bg-slate-200 transition-colors"
>
Отмена
</button>
</div>
</div>
</div>
</div>
)}
{/* Create / Edit Room Modal */}
{showCreateRoomModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl p-6 max-w-md w-full max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-bold text-slate-800">
{editingRoom ? 'Редактировать переговорную' : 'Создать переговорную'}
</h3>
<button
onClick={() => {
setShowCreateRoomModal(false);
setEditingRoom(null);
}}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Название *</label>
<input
type="text"
value={roomFormData.name}
onChange={(e) => setRoomFormData({ ...roomFormData, name: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="Например: Переговорная №1"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Вместимость *</label>
<input
type="number"
value={roomFormData.capacity}
onChange={(e) => setRoomFormData({ ...roomFormData, capacity: parseInt(e.target.value) || 10 })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
min="1"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Местоположение</label>
<input
type="text"
value={roomFormData.location}
onChange={(e) => setRoomFormData({ ...roomFormData, location: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="Например: 3 этаж"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Оборудование</label>
<div className="space-y-2">
{['projector', 'whiteboard', 'video_conference', 'screen', 'microphone'].map((eq) => (
<label key={eq} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={roomFormData.equipment.includes(eq)}
onChange={(e) => {
if (e.target.checked) {
setRoomFormData({ ...roomFormData, equipment: [...roomFormData.equipment, eq] });
} else {
setRoomFormData({ ...roomFormData, equipment: roomFormData.equipment.filter(item => item !== eq) });
}
}}
className="w-4 h-4 text-primary-600 border-slate-300 rounded focus:ring-primary-500"
/>
<span className="text-sm text-slate-700">
{eq === 'projector' ? 'Проектор' :
eq === 'whiteboard' ? 'Доска' :
eq === 'video_conference' ? 'Видеоконференция' :
eq === 'screen' ? 'Экран' :
eq === 'microphone' ? 'Микрофон' : eq}
</span>
</label>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Описание</label>
<textarea
value={roomFormData.description}
onChange={(e) => setRoomFormData({ ...roomFormData, description: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
rows={3}
placeholder="Дополнительная информация о переговорной..."
/>
</div>
{editingRoom && (
<div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={roomFormData.isActive}
onChange={(e) => setRoomFormData({ ...roomFormData, isActive: e.target.checked })}
className="w-4 h-4 text-primary-600 border-slate-300 rounded focus:ring-primary-500"
/>
<span className="text-sm font-medium text-slate-700">Активна (доступна для бронирования)</span>
</label>
<p className="text-xs text-slate-500 mt-1">Неактивные комнаты не отображаются в списке при создании совещания</p>
</div>
)}
<div className="flex gap-3 pt-4">
<button
onClick={handleCreateRoom}
disabled={!roomFormData.name || !roomFormData.name.trim() || !roomFormData.capacity || roomFormData.capacity <= 0}
className="flex-1 bg-primary-600 text-white px-4 py-2 rounded-lg font-bold text-sm hover:bg-primary-700 transition-colors disabled:bg-slate-300 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
<Building2 className="w-4 h-4" />
{editingRoom ? 'Сохранить' : 'Создать'}
</button>
<button
onClick={() => {
setShowCreateRoomModal(false);
setEditingRoom(null);
}}
className="px-4 py-2 bg-slate-100 text-slate-600 rounded-lg font-bold text-sm hover:bg-slate-200 transition-colors"
>
Отмена
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
};