Files
ridebus-admin/frontend/src/pages/AlertsPage.jsx
Zakhar Timoshenko c0d40f9b90 feat: add support for multiple route numbers in alert notifications
- Updated FCM alert notifications documentation to include handling of multiple route numbers.
- Enhanced Telegram cancellation import logic to support multiple route numbers, ensuring correct notifications are sent for all directions.
- Modified frontend components to allow selection of multiple route numbers for alerts, improving user experience.
- Implemented backend migration to add `route_numbers` column to alerts and adjusted related database constraints.
- Introduced new utility functions for normalizing and validating route numbers in alert processing.
2026-10-05 12:07:09 +03:00

673 lines
26 KiB
JavaScript
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.
import { useState, useEffect } from 'react';
import { Plus, Edit, Trash2, AlertCircle, AlertTriangle, Info, Clock, Filter, RefreshCw, Send, Calendar } from 'lucide-react';
import { alertsApi, routesApi } from '../services/api';
import { AlertTypeBadge } from '../components/AlertComponents';
import { PageLoader } from '../components/AnimatedComponents';
import TelegramImportPanel from '../components/TelegramImportPanel';
import RouteNumberPicker, { alertRouteNumbers } from '../components/RouteNumberPicker';
import { useAuth } from '../contexts/AuthContext';
export default function AlertsPage() {
const { user } = useAuth();
const [alerts, setAlerts] = useState([]);
const [routes, setRoutes] = useState([]);
const [templates, setTemplates] = useState([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingAlert, setEditingAlert] = useState(null);
const [filterType, setFilterType] = useState('all');
const [filterStatus, setFilterStatus] = useState('all');
const [refreshing, setRefreshing] = useState(false);
const [now, setNow] = useState(() => new Date());
useEffect(() => {
let timer;
const updateClock = () => {
clearTimeout(timer);
const current = new Date();
setNow(current);
const boundaries = alerts.flatMap(item => [item.start_time, item.end_time])
.filter(Boolean).map(value => new Date(value).getTime()).filter(value => value > current.getTime());
if (boundaries.length) timer = setTimeout(updateClock, Math.min(Math.min(...boundaries) - current.getTime() + 5, 2_147_483_647));
};
updateClock();
document.addEventListener('visibilitychange', updateClock);
return () => { clearTimeout(timer); document.removeEventListener('visibilitychange', updateClock); };
}, [alerts]);
useEffect(() => { loadData(); }, []);
const loadData = async () => {
try {
const [alertsRes, routesRes, templatesRes] = await Promise.all([
alertsApi.getAll(),
routesApi.getAll(),
alertsApi.getTemplates()
]);
setAlerts(alertsRes.data);
setRoutes(routesRes.data);
setTemplates(templatesRes.data);
} catch (error) {
console.error('Error loading data:', error);
} finally {
setLoading(false);
setRefreshing(false);
}
};
const handleRefresh = async () => {
setRefreshing(true);
await loadData();
};
const handleCreate = () => {
setEditingAlert(null);
setShowForm(true);
};
const handleEdit = (alert) => {
setEditingAlert(alert);
setShowForm(true);
};
const handleDelete = async (id) => {
if (!confirm('Удалить это уведомление?')) return;
try {
await alertsApi.delete(id);
loadData();
} catch (error) {
console.error('Error deleting alert:', error);
alert('Ошибка удаления уведомления');
}
};
const handleFormSubmit = async (data, idempotencyKey) => {
try {
if (editingAlert) {
await alertsApi.update(editingAlert.id, data);
} else {
await alertsApi.create(data, idempotencyKey);
}
setShowForm(false);
loadData();
} catch (error) {
console.error('Error saving alert:', error);
throw new Error(error.response?.data?.error || 'Ошибка сохранения уведомления');
}
};
const handleCleanup = async () => {
if (!confirm('Деактивировать все истекшие уведомления?')) return;
try {
const result = await alertsApi.cleanup();
alert(`Деактивировано: ${result.data.deactivated_count}`);
loadData();
} catch (error) {
console.error('Error cleaning up alerts:', error);
alert('Ошибка очистки');
}
};
if (loading) {
return <PageLoader />;
}
const statusCounts = alerts.reduce((counts, alert) => {
counts[getAlertStatus(alert, now)] += 1;
return counts;
}, { active: 0, scheduled: 0, draft: 0, expired: 0 });
const filteredAlerts = alerts.filter(alert =>
(filterType === 'all' || alert.alert_type === filterType)
&& (filterStatus === 'all' || getAlertStatus(alert, now) === filterStatus)
);
return (
<div>
{/* Header */}
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
<div>
<h1 className="page-title">Уведомления</h1>
<p className="text-sm text-gray-500 mt-1">
Всего: {alerts.length} • В эфире: {statusCounts.active} • Запланировано: {statusCounts.scheduled}
</p>
</div>
<div className="flex gap-2">
<button
onClick={handleRefresh}
className={`btn-secondary flex items-center ${refreshing ? 'opacity-70' : ''}`}
disabled={refreshing}
>
<RefreshCw className={`h-4 w-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
Обновить
</button>
<button onClick={handleCreate} className="btn-primary hidden sm:flex items-center">
<Plus className="h-5 w-5 mr-2" />
Создать
</button>
</div>
</div>
{showForm && (
<AlertForm
alert={editingAlert}
routes={routes}
templates={templates}
onSubmit={handleFormSubmit}
onCancel={() => setShowForm(false)}
/>
)}
{/* Info Banner */}
<div className="card mb-6 bg-blue-50 border-blue-200">
<div className="flex items-start gap-3">
<Info className="h-5 w-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div>
<h3 className="font-semibold text-blue-900 mb-1">О системе уведомлений</h3>
<p className="text-sm text-blue-800">
Используйте шаблон, затем уточните текст и время. Будущее уведомление появится у пассажиров и получит push-рассылку в момент начала.
</p>
</div>
</div>
</div>
{/* Filters */}
{user?.role === 'admin' && <TelegramImportPanel routes={routes} onChanged={loadData} />}
<div className="card mb-6 space-y-4">
<div>
<h3 className="text-sm font-medium text-gray-700 mb-2">Тип уведомления</h3>
<div className="flex flex-wrap gap-2">
<FilterButton
active={filterType === 'all'}
onClick={() => setFilterType('all')}
icon={Filter}
>
Все
</FilterButton>
<FilterButton
active={filterType === 'delay'}
onClick={() => setFilterType('delay')}
icon={Clock}
color="amber"
>
Задержки
</FilterButton>
<FilterButton
active={filterType === 'cancellation'}
onClick={() => setFilterType('cancellation')}
icon={AlertTriangle}
color="red"
>
Отмены
</FilterButton>
<FilterButton
active={filterType === 'detour'}
onClick={() => setFilterType('detour')}
icon={AlertCircle}
color="blue"
>
Изменения
</FilterButton>
<FilterButton
active={filterType === 'info'}
onClick={() => setFilterType('info')}
icon={Info}
color="gray"
>
Информация
</FilterButton>
</div>
</div>
<div>
<h3 className="text-sm font-medium text-gray-700 mb-2">Статус</h3>
<div className="flex flex-wrap gap-2">
<FilterButton
active={filterStatus === 'all'}
onClick={() => setFilterStatus('all')}
>
Все
</FilterButton>
<FilterButton
active={filterStatus === 'active'}
onClick={() => setFilterStatus('active')}
color="green"
>
В эфире ({statusCounts.active})
</FilterButton>
<FilterButton
active={filterStatus === 'scheduled'}
onClick={() => setFilterStatus('scheduled')}
color="blue"
>
Запланировано ({statusCounts.scheduled})
</FilterButton>
<FilterButton
active={filterStatus === 'draft'}
onClick={() => setFilterStatus('draft')}
color="gray"
>
Черновики ({statusCounts.draft})
</FilterButton>
<FilterButton
active={filterStatus === 'expired'}
onClick={() => setFilterStatus('expired')}
color="red"
>
Завершены ({statusCounts.expired})
</FilterButton>
</div>
</div>
<div className="flex items-center justify-between pt-4 border-t">
<p className="text-sm text-gray-600">
Найдено: <span className="font-medium">{filteredAlerts.length}</span>
</p>
<button onClick={handleCleanup} className="text-sm text-primary-600 hover:underline">
Очистить истёкшие
</button>
</div>
</div>
{/* Alerts List */}
{filteredAlerts.length > 0 ? (
<div className="space-y-4">
{filteredAlerts.map((alert, index) => (
<AlertCard
key={alert.id}
alert={alert}
index={index}
now={now}
onEdit={() => handleEdit(alert)}
onDelete={() => handleDelete(alert.id)}
/>
))}
</div>
) : (
<div className="empty-state">
<AlertCircle className="empty-state-icon" />
<p className="empty-state-text">
{filterType !== 'all' || filterStatus !== 'all'
? 'Уведомления не найдены. Измените фильтры.'
: 'Нет уведомлений. Создайте первое!'}
</p>
{filterType === 'all' && filterStatus === 'all' && (
<button onClick={handleCreate} className="btn-primary">
<Plus className="h-5 w-5 mr-2 inline" />
Создать уведомление
</button>
)}
</div>
)}
{/* Mobile FAB */}
<button
onClick={handleCreate}
className="fab sm:hidden"
title="Создать уведомление"
>
<Plus className="h-6 w-6" />
</button>
</div>
);
}
function getAlertStatus(alert, now = new Date()) {
if (!alert.is_active) return 'draft';
if (alert.end_time && new Date(alert.end_time) <= now) return 'expired';
if (alert.start_time && new Date(alert.start_time) > now) return 'scheduled';
return 'active';
}
function FilterButton({ active, onClick, icon: Icon, color = 'primary', children }) {
const colors = {
primary: active ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200',
amber: active ? 'bg-amber-600 text-white' : 'bg-amber-50 text-amber-700 hover:bg-amber-100',
red: active ? 'bg-red-600 text-white' : 'bg-red-50 text-red-700 hover:bg-red-100',
blue: active ? 'bg-blue-600 text-white' : 'bg-blue-50 text-blue-700 hover:bg-blue-100',
gray: active ? 'bg-gray-600 text-white' : 'bg-gray-50 text-gray-700 hover:bg-gray-100',
green: active ? 'bg-green-600 text-white' : 'bg-green-50 text-green-700 hover:bg-green-100',
};
return (
<button
onClick={onClick}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 flex items-center gap-2 ${colors[color]}`}
>
{Icon && <Icon className="h-4 w-4" />}
{children}
</button>
);
}
function AlertCard({ alert, index, now, onEdit, onDelete }) {
const alertIcons = {
delay: { icon: Clock, color: 'text-amber-600' },
cancellation: { icon: AlertTriangle, color: 'text-red-600' },
detour: { icon: AlertCircle, color: 'text-blue-600' },
info: { icon: Info, color: 'text-gray-600' }
};
const config = alertIcons[alert.alert_type] || alertIcons.info;
const Icon = config.icon;
const status = getAlertStatus(alert, now);
const statusLabels = {
active: { label: 'В эфире', className: 'bg-green-100 text-green-700' },
scheduled: { label: 'Запланировано', className: 'bg-blue-100 text-blue-600' },
draft: { label: 'Черновик', className: 'bg-gray-100 text-gray-600' },
expired: { label: 'Завершено', className: 'bg-red-100 text-red-600' }
};
const statusLabel = statusLabels[status];
return (
<div
className={`card list-item-animated group ${!alert.is_active ? 'opacity-60' : ''}`}
style={{ animationDelay: `${index * 30}ms` }}
>
<div className="flex items-start gap-4">
{/* Icon */}
<div className={`p-3 rounded-lg bg-gray-50 flex-shrink-0`}>
<Icon className={`h-6 w-6 ${config.color}`} />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-3 mb-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1">
<h3 className="font-bold text-gray-900">{alert.title}</h3>
<AlertTypeBadge type={alert.alert_type} />
<span className={`text-xs px-2 py-0.5 rounded-full ${statusLabel.className}`}>{statusLabel.label}</span>
</div>
<div className="flex flex-wrap items-center gap-2 mb-2">
{alertRouteNumbers(alert).map(number => <span key={number} className="px-2 py-0.5 rounded text-xs font-bold bg-primary-100 text-primary-700">№{number}</span>)}
<span className="text-xs text-gray-500">{alertRouteNumbers(alert).length ? 'Все направления' : 'Общее уведомление'}</span>
</div>
</div>
</div>
<p className="text-sm text-gray-700 mb-3 line-clamp-2">{alert.message}</p>
<div className="flex items-center justify-between">
<div className="text-xs text-gray-500">
{alert.start_time && (
<div>
Начало: {new Date(alert.start_time).toLocaleString('ru-RU', { timeZone: MINSK_TIME_ZONE })}
</div>
)}
{alert.end_time && (
<div>
Конец: {new Date(alert.end_time).toLocaleString('ru-RU', { timeZone: MINSK_TIME_ZONE })}
</div>
)}
{status === 'scheduled' && <div className="mt-1 text-blue-700">Push будет отправлен в момент начала</div>}
{alert.notification_sent_at && <div className="mt-1 text-green-700">Push отправлен: {new Date(alert.notification_sent_at).toLocaleString('ru-RU', { timeZone: MINSK_TIME_ZONE })}</div>}
</div>
<div className="flex gap-2">
<button onClick={onEdit} className="btn-secondary text-sm flex items-center">
<Edit className="h-4 w-4 mr-1" />
Изменить
</button>
<button onClick={onDelete} className="btn-danger p-2">
<Trash2 className="h-5 w-5" />
</button>
</div>
</div>
</div>
</div>
</div>
);
}
function createAlertIdempotencyKey() {
if (globalThis.crypto?.randomUUID) {
return globalThis.crypto.randomUUID();
}
return `alert-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
const MINSK_TIME_ZONE = 'Europe/Minsk';
const MINSK_OFFSET = '+03:00';
function toDateTimeInput(value = new Date()) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: MINSK_TIME_ZONE,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hourCycle: 'h23'
}).formatToParts(date).reduce((result, part) => ({ ...result, [part.type]: part.value }), {});
return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}`;
}
// Store an explicit UTC instant; the operator enters and sees Minsk time.
function toApiDateTime(value) {
if (!value) return null;
const date = new Date(`${value}:00${MINSK_OFFSET}`);
return Number.isNaN(date.getTime()) ? value : date.toISOString();
}
function toMinskDate(value) {
return new Date(`${value}:00${MINSK_OFFSET}`);
}
function AlertForm({ alert, routes, templates, onSubmit, onCancel }) {
const [idempotencyKey] = useState(createAlertIdempotencyKey);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [selectedTemplateId, setSelectedTemplateId] = useState(null);
const [formData, setFormData] = useState({
route_numbers: alertRouteNumbers(alert, routes),
alert_type: alert?.alert_type || 'info',
title: alert?.title || '',
message: alert?.message || '',
start_time: alert?.start_time ? toDateTimeInput(alert.start_time) : toDateTimeInput(),
end_time: alert?.end_time ? toDateTimeInput(alert.end_time) : '',
is_active: alert?.is_active !== false
});
const handleSubmit = async (e) => {
e.preventDefault();
const data = {
...formData,
start_time: toApiDateTime(formData.start_time),
end_time: toApiDateTime(formData.end_time)
};
setSubmitting(true);
try {
setError('');
await onSubmit(data, alert ? null : idempotencyKey);
} catch (err) {
setError(err.message);
} finally {
setSubmitting(false);
}
};
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
};
const applyTemplate = (template) => {
setSelectedTemplateId(template.id);
setFormData((current) => ({
...current,
alert_type: template.alert_type,
title: template.title,
message: template.message
}));
};
const startDate = toMinskDate(formData.start_time);
const isScheduled = formData.is_active && !Number.isNaN(startDate.getTime()) && startDate > new Date();
const saveLabel = alert ? 'Сохранить' : !formData.is_active ? 'Сохранить черновик' : isScheduled ? 'Запланировать' : 'Опубликовать';
return (
<div className="fixed inset-0 bg-black/50 flex items-end sm:items-center justify-center z-50">
<div className="bg-white w-full sm:max-w-2xl sm:mx-4 sm:rounded-lg rounded-t-2xl max-h-[90vh] overflow-hidden flex flex-col animate-[slideUp_0.3s_ease-out] sm:animate-[scaleIn_0.2s_ease-out]">
<div className="p-4 sm:p-6 border-b border-gray-200">
<h2 className="text-lg sm:text-xl font-semibold">
{alert ? 'Редактировать' : 'Создать'} уведомление
</h2>
</div>
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-hidden">
<div className="p-4 sm:p-6 overflow-y-auto flex-1 space-y-4">
<div>
<label className="label">Шаблон</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{templates.map(template => <button type="button" key={template.id} onClick={() => applyTemplate(template)}
className={`rounded-lg border p-3 text-left transition-colors ${selectedTemplateId === template.id ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:bg-gray-50'}`}>
<span className="block text-sm font-medium text-gray-900">{template.label}</span>
<span className="mt-1 block text-xs leading-5 text-gray-500">{template.description}</span>
</button>)}
</div>
<p className="mt-2 text-xs text-gray-500">Шаблон заполнит тип, заголовок и текст — всё можно отредактировать перед сохранением.</p>
</div>
<RouteNumberPicker routes={routes} value={formData.route_numbers} disabled={submitting}
onChange={route_numbers => setFormData(current => ({ ...current, route_numbers }))} />
{error && <p role="alert" className="text-sm text-red-700">{error}</p>}
{/* Alert Type */}
<div>
<label className="label">Тип уведомления *</label>
<select
name="alert_type"
value={formData.alert_type}
onChange={handleChange}
className="input"
required
>
<option value="info">Информация</option>
<option value="delay">Задержка</option>
<option value="cancellation">Отмена рейса</option>
<option value="detour">Изменение маршрута</option>
</select>
</div>
{/* Title */}
<div>
<label className="label">Заголовок *</label>
<input
type="text"
name="title"
value={formData.title}
onChange={handleChange}
className="input"
required
maxLength={200}
placeholder="Краткое описание проблемы"
/>
</div>
{/* Message */}
<div>
<label className="label">Сообщение *</label>
<textarea
name="message"
value={formData.message}
onChange={handleChange}
className="input"
required
rows="4"
placeholder="Подробная информация для пассажиров"
/>
</div>
{/* Time Range */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="label">Время начала *</label>
<input
type="datetime-local"
name="start_time"
value={formData.start_time}
onChange={handleChange}
className="input"
required
/>
</div>
<div>
<label className="label">
Время окончания <span className="text-gray-500 text-xs">(необязательно)</span>
</label>
<input
type="datetime-local"
name="end_time"
value={formData.end_time}
onChange={handleChange}
className="input"
min={formData.start_time}
/>
</div>
</div>
<div className="rounded-lg bg-blue-50 p-3 text-sm text-blue-900">
<div className="flex items-center gap-2 font-medium"><Calendar className="h-4 w-4" />Планирование публикации</div>
<p className="mt-1 text-xs leading-5 text-blue-800">
{!formData.is_active ? 'Это черновик: пассажиры и push-рассылка его не увидят.' : isScheduled
? `Публикация и push-рассылка начнутся ${startDate.toLocaleString('ru-RU')}.`
: 'Уведомление будет опубликовано сразу после сохранения.'}
</p>
<div className="mt-2 flex flex-wrap gap-2">
<button type="button" className="text-xs font-medium underline" onClick={() => setFormData(current => ({ ...current, start_time: toDateTimeInput() }))}>Начать сейчас</button>
<button type="button" className="text-xs font-medium underline" onClick={() => setFormData(current => ({ ...current, start_time: toDateTimeInput(new Date(Date.now() + 60 * 60 * 1000)) }))}>Через час</button>
</div>
</div>
{/* Active Checkbox */}
<div>
<label className="flex items-center p-3 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100 transition-colors">
<input
type="checkbox"
name="is_active"
checked={formData.is_active}
onChange={handleChange}
className="h-5 w-5 text-primary-600 border-gray-300 rounded mr-3"
/>
<div>
<span className="text-gray-900 font-medium">Опубликовать уведомление</span>
<p className="text-xs text-gray-500 mt-0.5">
Снимите отметку, чтобы сохранить черновик без публикации и рассылки
</p>
</div>
</label>
</div>
</div>
<div className="p-4 sm:p-6 border-t border-gray-200 flex flex-col-reverse sm:flex-row sm:justify-end gap-2 sm:gap-3 safe-bottom">
<button
type="button"
onClick={onCancel}
className="btn-secondary w-full sm:w-auto"
disabled={submitting}
>
Отмена
</button>
<button
type="submit"
className="btn-primary w-full sm:w-auto"
disabled={submitting}
>
{submitting ? 'Сохранение…' : <><Send className="mr-2 inline h-4 w-4" />{saveLabel}</>}
</button>
</div>
</form>
</div>
</div>
);
}