feat: enhance Telegram import functionality to support delays and cancellations
- Updated telegramCancellationParser to include parsing for delay notices. - Added new test cases for handling delay messages and stop-point cancellations. - Modified telegramImport integration tests to validate new delay handling. - Enhanced documentation to reflect the addition of delay notifications. - Updated TelegramImportPanel component to accommodate new alert types. - Introduced new fixture files for testing delay and cancellation messages.
This commit is contained in:
@@ -63,19 +63,19 @@ export default function TelegramImportPanel({ routes, onChanged }) {
|
||||
const pending = data?.messages.filter(m => m.status === 'review') || [];
|
||||
const rows = showHistory ? data?.messages || [] : pending;
|
||||
return (
|
||||
<section className="card mb-6" aria-label="Отмены из Telegram">
|
||||
<section className="card mb-6" aria-label="Уведомления из Telegram">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-semibold text-gray-900 flex items-center gap-2"><Send className="h-4 w-4 text-primary-600" />Отмены из Telegram</h2>
|
||||
<h2 className="font-semibold text-gray-900 flex items-center gap-2"><Send className="h-4 w-4 text-primary-600" />Уведомления из Telegram</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{status ? configuredCity ? connectionLabels[status.connection] : 'Для выбранного города источник не настроен' : 'Загрузка источника…'}
|
||||
{status && <> · @{status.channel}</>}
|
||||
</p>
|
||||
{status?.connection === 'disabled' && <p className="text-sm text-gray-500 mt-1">Подключите отдельный аккаунт на сервере. Отмены будут поступать автоматически.</p>}
|
||||
{status?.enabled && <p className="text-xs text-gray-500 mt-1">Последняя проверка: {formatTime(status.lastSyncAt)} · {status.autoPublish ? 'Однозначные отмены публикуются автоматически' : 'Публикация после проверки'}</p>}
|
||||
{status?.connection === 'disabled' && <p className="text-sm text-gray-500 mt-1">Подключите отдельный аккаунт на сервере. Сообщения об отменах и опозданиях будут поступать автоматически.</p>}
|
||||
{status?.enabled && <p className="text-xs text-gray-500 mt-1">Последняя проверка: {formatTime(status.lastSyncAt)} · {status.autoPublish ? 'Однозначные сообщения публикуются автоматически' : 'Публикация после проверки'}</p>}
|
||||
{status?.lastError && <p className="text-sm text-amber-700 mt-2">{errorLabels[status.lastError] || 'Не удалось подключиться к Telegram.'}</p>}
|
||||
{status?.retryAt && <p className="text-xs text-gray-500 mt-1">Следующая попытка не раньше: {formatTime(status.retryAt)}</p>}
|
||||
<p className="text-xs text-gray-500 mt-2">Срок действия — до полуночи дня отмены по Минску. АВ/ВОКЗАЛ распознаётся как Автовокзал.</p>
|
||||
<p className="text-xs text-gray-500 mt-2">Срок действия — до полуночи указанного дня по Минску. АВ/ВОКЗАЛ распознаётся как Автовокзал.</p>
|
||||
</div>
|
||||
<button type="button" className="btn-secondary flex items-center justify-center shrink-0" onClick={sync} disabled={busy || !configuredCity || !status?.enabled || status?.missing.length > 0 || status?.syncing}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${busy || status?.syncing ? 'animate-spin' : ''}`} />Проверить канал
|
||||
@@ -92,7 +92,7 @@ export default function TelegramImportPanel({ routes, onChanged }) {
|
||||
{rows.map(row => (
|
||||
<div key={row.id} className="mt-3 rounded-lg border border-gray-200 p-3">
|
||||
<div className="flex flex-wrap justify-between gap-2 text-xs text-gray-500">
|
||||
<span>{formatTime(row.published_at)} · {{ review: 'На проверке', published: 'Опубликовано', dismissed: 'Отклонено', expired: 'День отмены завершён' }[row.status]}</span>
|
||||
<span>{formatTime(row.published_at)} · {{ review: 'На проверке', published: 'Опубликовано', dismissed: 'Отклонено', expired: 'День уведомления завершён' }[row.status]}</span>
|
||||
<a href={row.source_url} target="_blank" rel="noreferrer" className="text-primary-600 inline-flex items-center gap-1">Оригинал<ExternalLink className="h-3 w-3" /></a>
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap break-words text-sm text-gray-700 mt-2">{row.raw_text || 'Вложение без текста — откройте оригинал.'}</p>
|
||||
@@ -105,7 +105,7 @@ export default function TelegramImportPanel({ routes, onChanged }) {
|
||||
</>}
|
||||
</div>
|
||||
))}
|
||||
{!rows.length && <p className="text-sm text-gray-500 mt-3">{showHistory ? 'Сообщений об отменах пока нет.' : 'Нет сообщений, требующих проверки.'}</p>}
|
||||
{!rows.length && <p className="text-sm text-gray-500 mt-3">{showHistory ? 'Сообщений об отменах и опозданиях пока нет.' : 'Нет сообщений, требующих проверки.'}</p>}
|
||||
</div>
|
||||
)}
|
||||
{editing && <TelegramReviewForm key={editing.id} row={editing} routes={routes} busy={busy} onCancel={() => setEditing(null)} onSubmit={values => review(editing, values)} />}
|
||||
@@ -119,40 +119,49 @@ function TelegramReviewForm({ row, routes, busy, onSubmit, onCancel }) {
|
||||
}).format(new Date(row.published_at));
|
||||
const initialNumbers = [...new Set((row.parsed?.selectedRouteNumbers || row.parsed?.groups?.map(g => g.routeNumber) || [])
|
||||
.map(normalizeRouteNumber))];
|
||||
const draftFor = (numbers, date) => {
|
||||
const initialType = row.parsed?.alertType || 'cancellation';
|
||||
const draftFor = (numbers, date, alertType) => {
|
||||
const delay = alertType === 'delay';
|
||||
const possible = delay && row.parsed?.delayPossible;
|
||||
const prefix = delay ? possible ? 'Возможное опоздание' : 'Задержка' : 'Отмена';
|
||||
const description = delay ? possible ? 'возможны опоздания рейсов' : 'с опозданием выполняются рейсы' : 'не выполняются рейсы';
|
||||
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) =>
|
||||
title: `${prefix} отдельных рейсов${numbers.length ? ` ${numbers.map(n => `№${n}`).join(', ')}` : ''}`,
|
||||
message: complete ? `${date.split('-').reverse().join('.')}: ${description}:\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 initialDate = row.parsed?.serviceDate || publicationDate;
|
||||
const [form, setForm] = useState({
|
||||
action: 'publish', service_date: initialDate, route_numbers: initialNumbers,
|
||||
...draftFor(initialNumbers, initialDate)
|
||||
action: 'publish', alert_type: initialType, service_date: initialDate, route_numbers: initialNumbers,
|
||||
...draftFor(initialNumbers, initialDate, initialType)
|
||||
});
|
||||
const [editedText, setEditedText] = useState(false);
|
||||
const change = e => {
|
||||
const { name, value } = e.target;
|
||||
if (name === 'title' || name === 'message') setEditedText(true);
|
||||
setForm(current => ({ ...current, [name]: value,
|
||||
...(name === 'service_date' && !editedText && /^\d{4}-\d{2}-\d{2}$/.test(value) ? draftFor(current.route_numbers, value) : {}) }));
|
||||
...(name === 'service_date' && !editedText && /^\d{4}-\d{2}-\d{2}$/.test(value) ? draftFor(current.route_numbers, value, current.alert_type) : {}),
|
||||
...(name === 'alert_type' && !editedText ? draftFor(current.route_numbers, current.service_date, value) : {}) }));
|
||||
};
|
||||
const changeRoutes = route_numbers => setForm(current => ({ ...current, route_numbers,
|
||||
...(!editedText ? draftFor(route_numbers, current.service_date) : {}) }));
|
||||
...(!editedText ? draftFor(route_numbers, current.service_date, current.alert_type) : {}) }));
|
||||
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="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>
|
||||
<label className="text-sm block">Тип уведомления<select className="input mt-1 w-full" name="alert_type" value={form.alert_type} onChange={change} disabled={busy}>
|
||||
<option value="cancellation">Отмена рейсов</option><option value="delay">Опоздание рейсов</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>
|
||||
<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 || !form.route_numbers.length}>Опубликовать отмену</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user