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.
This commit is contained in:
2026-10-05 12:07:09 +03:00
parent e511f74aea
commit c0d40f9b90
18 changed files with 462 additions and 150 deletions

View File

@@ -0,0 +1,36 @@
import { useId } from 'react';
export const normalizeRouteNumber = value => String(value).replace(/[\s№]/g, '').toUpperCase();
export function alertRouteNumbers(alert, routes = []) {
if (Array.isArray(alert?.route_numbers)) return alert.route_numbers;
const number = alert?.route_number || routes.find(r => Number(r.id) === Number(alert?.route_id))?.route_number;
return number ? [normalizeRouteNumber(number)] : [];
}
export default function RouteNumberPicker({ routes, value, onChange, disabled = false, required = false }) {
const id = useId();
const selected = new Set(value.map(normalizeRouteNumber));
const active = new Set(routes.filter(r => r.is_active).map(r => normalizeRouteNumber(r.route_number)));
const numbers = [...new Set([...active, ...selected])].sort((a, b) => a.localeCompare(b, 'ru', { numeric: true }));
const toggle = number => onChange(selected.has(number) ? [...selected].filter(n => n !== number) : [...selected, number]);
return (
<fieldset disabled={disabled} aria-describedby={`${id}-help`}>
<legend className="text-sm font-medium text-gray-700 mb-2">Номера маршрутов{required ? ' *' : ''}</legend>
<div className="flex flex-wrap gap-2 max-h-44 overflow-y-auto p-1">
{numbers.map(number => (
<label key={number} className={`inline-flex items-center gap-2 px-3 py-2 border rounded-lg text-sm cursor-pointer ${selected.has(number) ? 'border-primary-500 bg-primary-50 text-primary-700' : 'border-gray-200 bg-white text-gray-700'}`}>
<input type="checkbox" checked={selected.has(number)} onChange={() => toggle(number)} className="h-4 w-4 rounded text-primary-600 focus:ring-primary-500" />
<span>№{number}{!active.has(number) ? ' (недоступен)' : ''}</span>
</label>
))}
{!numbers.length && <span className="text-sm text-gray-500">Нет действующих маршрутов.</span>}
</div>
<p id={`${id}-help`} className="text-xs text-gray-500 mt-2">
{selected.size ? `Выбрано: ${selected.size}. Уведомление относится ко всем направлениям этих номеров.`
: required ? 'Выберите хотя бы один номер. Можно выбрать несколько.' : 'Без выбранных номеров — общее уведомление для города.'}
</p>
{selected.size > 0 && <button type="button" className="text-xs text-primary-600 mt-2 hover:underline" onClick={() => onChange([])}>Снять выбор</button>}
</fieldset>
);
}

View File

@@ -3,6 +3,7 @@ import { ExternalLink, RefreshCw, Send, X } from 'lucide-react';
import { telegramImportApi } from '../services/api';
import { useCity } from '../contexts/CityContext';
import TelegramStopAliases from './TelegramStopAliases';
import RouteNumberPicker, { normalizeRouteNumber } from './RouteNumberPicker';
const connectionLabels = {
disabled: 'Импорт выключен', configuration_required: 'Нужна настройка аккаунта',
@@ -116,38 +117,42 @@ function TelegramReviewForm({ row, routes, busy, onSubmit, onCancel }) {
const publicationDate = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Minsk', year: 'numeric', month: '2-digit', day: '2-digit'
}).format(new Date(row.published_at));
const initialRouteId = row.parsed?.plans?.length === 1 ? row.parsed.plans[0].routeId : '';
const draftFor = routeId => {
const route = routes.find(r => Number(r.id) === Number(routeId));
return row.drafts?.find(d => d.routeId !== null && Number(d.routeId) === Number(routeId))
|| row.drafts?.find(d => d.routeId === null && (!route || String(d.routeNumber).toLowerCase() === String(route.route_number).toLowerCase()));
const initialNumbers = [...new Set((row.parsed?.selectedRouteNumbers || row.parsed?.groups?.map(g => g.routeNumber) || [])
.map(normalizeRouteNumber))];
const draftFor = (numbers, date) => {
const drafts = numbers.map(number => row.drafts?.find(d => normalizeRouteNumber(d.routeNumber) === number));
const complete = drafts.length && drafts.every(Boolean);
return {
title: `Отмена отдельных рейсов${numbers.length ? ` ${numbers.map(n => `№${n}`).join(', ')}` : ''}`,
message: complete ? `${date.split('-').reverse().join('.')}: не выполняются рейсы:\n${drafts.map((d, i) =>
`${drafts.length > 1 ? `Маршрут №${numbers[i]}:\n` : ''}${d.message.replace(/^[^\n]*\n/, '')}`).join('\n\n')}` : row.raw_text.slice(0, 5000)
};
};
const initialDraft = draftFor(initialRouteId);
const initialDate = row.parsed?.serviceDate || publicationDate;
const [form, setForm] = useState({
action: 'publish', service_date: row.parsed?.serviceDate || publicationDate, route_id: initialRouteId,
title: initialDraft?.title || 'Отмена отдельных рейсов', message: initialDraft?.message || row.raw_text.slice(0, 5000)
action: 'publish', service_date: initialDate, route_numbers: initialNumbers,
...draftFor(initialNumbers, initialDate)
});
const [editedText, setEditedText] = useState(false);
const change = e => {
const { name, value } = e.target;
if (name === 'title' || name === 'message') setEditedText(true);
const draft = name === 'route_id' && !editedText ? draftFor(value) : null;
const message = name === 'service_date' && !editedText && /^\d{4}-\d{2}-\d{2}$/.test(value)
? form.message.replace(/^\d{2}\.\d{2}\.\d{4}:/, `${value.split('-').reverse().join('.')}:`) : form.message;
const draftMessage = draft?.message.replace(/^\d{2}\.\d{2}\.\d{4}:/, `${form.service_date.split('-').reverse().join('.')}:`);
setForm({ ...form, message, [name]: value, ...(draft ? { title: draft.title, message: draftMessage } : {}) });
setForm(current => ({ ...current, [name]: value,
...(name === 'service_date' && !editedText && /^\d{4}-\d{2}-\d{2}$/.test(value) ? draftFor(current.route_numbers, value) : {}) }));
};
const changeRoutes = route_numbers => setForm(current => ({ ...current, route_numbers,
...(!editedText ? draftFor(route_numbers, current.service_date) : {}) }));
return (
<form className="mt-4 rounded-lg bg-gray-50 p-4 space-y-3" onSubmit={e => { e.preventDefault(); onSubmit(form); }}>
<div className="flex items-center justify-between"><h3 className="font-medium">Проверка отмены</h3><button type="button" onClick={onCancel} aria-label="Закрыть проверку" disabled={busy}><X className="h-5 w-5" /></button></div>
<div className="grid sm:grid-cols-2 gap-3">
<label className="text-sm">Маршрут<select className="input mt-1 w-full" name="route_id" value={form.route_id} onChange={change} required><option value="">Выберите направление</option>{routes.filter(r => r.is_active).map(r => <option key={r.id} value={r.id}>№{r.route_number} · {r.name}</option>)}</select></label>
<RouteNumberPicker routes={routes} value={form.route_numbers} onChange={changeRoutes} disabled={busy} required />
<div>
<label className="text-sm">День отмены<input className="input mt-1 w-full" type="date" name="service_date" value={form.service_date} onChange={change} required /></label>
</div>
<label className="text-sm block">Заголовок<input className="input mt-1 w-full" name="title" value={form.title} maxLength={200} onChange={change} required /></label>
<label className="text-sm block">Текст уведомления<textarea className="input mt-1 w-full" rows={5} name="message" value={form.message} maxLength={5000} onChange={change} required /></label>
<p className="text-xs text-gray-500">Уточните остановки и времена. Уведомление будет действовать до 00:00 следующего дня по Минску. Ссылка на оригинал добавится автоматически.</p>
<div className="flex flex-wrap gap-2"><button className="btn-primary" disabled={busy}>Опубликовать отмену</button><button type="button" className="btn-secondary" disabled={busy} onClick={onCancel}>Отмена</button></div>
<div className="flex flex-wrap gap-2"><button className="btn-primary" disabled={busy || !form.route_numbers.length}>Опубликовать отмену</button><button type="button" className="btn-secondary" disabled={busy} onClick={onCancel}>Отмена</button></div>
</form>
);
}

View File

@@ -4,6 +4,7 @@ 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() {
@@ -93,7 +94,7 @@ export default function AlertsPage() {
loadData();
} catch (error) {
console.error('Error saving alert:', error);
alert('Ошибка сохранения уведомления');
throw new Error(error.response?.data?.error || 'Ошибка сохранения уведомления');
}
};
@@ -382,17 +383,10 @@ function AlertCard({ alert, index, now, onEdit, onDelete }) {
<AlertTypeBadge type={alert.alert_type} />
<span className={`text-xs px-2 py-0.5 rounded-full ${statusLabel.className}`}>{statusLabel.label}</span>
</div>
{alert.route_name && (
<div className="flex items-center gap-2 mb-2">
<span
className="px-2 py-0.5 rounded text-xs font-bold text-white"
style={{ backgroundColor: alert.color || '#6B7280' }}
>
{alert.route_number}
</span>
<span className="text-sm text-gray-600">{alert.route_name}</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>
@@ -466,9 +460,10 @@ function toMinskDate(value) {
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_id: alert?.route_id || '',
route_numbers: alertRouteNumbers(alert, routes),
alert_type: alert?.alert_type || 'info',
title: alert?.title || '',
message: alert?.message || '',
@@ -482,14 +477,16 @@ function AlertForm({ alert, routes, templates, onSubmit, onCancel }) {
const data = {
...formData,
route_id: formData.route_id || null,
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);
}
@@ -540,25 +537,9 @@ function AlertForm({ alert, routes, templates, onSubmit, onCancel }) {
<p className="mt-2 text-xs text-gray-500">Шаблон заполнит тип, заголовок и текст — всё можно отредактировать перед сохранением.</p>
</div>
{/* Route */}
<div>
<label className="label">
Маршрут <span className="text-gray-500 text-xs">(необязательно)</span>
</label>
<select
name="route_id"
value={formData.route_id}
onChange={handleChange}
className="input"
>
<option value="">Общее уведомление</option>
{routes.map(route => (
<option key={route.id} value={route.id}>
{route.route_number} — {route.name}
</option>
))}
</select>
</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>