- 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.
673 lines
26 KiB
JavaScript
673 lines
26 KiB
JavaScript
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>
|
||
);
|
||
}
|