Files
ridebus-admin/frontend/src/components/TelegramStopAliases.jsx
Zakhar Timoshenko 666a0b268f feat: implement stop alias management for Telegram import
- Added TelegramStopAliasService for handling stop aliases in the database.
- Created API endpoints for managing stop aliases (list, create, update, delete).
- Introduced TelegramStopAliases component in the frontend for user interaction with stop aliases.
- Updated TelegramImportService to utilize stop aliases for message processing.
- Enhanced telegramCancellationParser to resolve stop names using aliases.
- Added migration script to create telegram_stop_aliases table and seed initial data.
- Updated documentation to include details about stop alias functionality.
2026-10-03 01:31:11 +03:00

95 lines
7.3 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 { useEffect, useState } from 'react';
import { BookOpen, Pencil, Trash2, X } from 'lucide-react';
import { stopsApi, telegramImportApi } from '../services/api';
import { useCity } from '../contexts/CityContext';
const emptyForm = { alias: '', stop_id: '' };
export default function TelegramStopAliases({ onChanged }) {
const { activeCity } = useCity();
const [open, setOpen] = useState(false);
const [aliases, setAliases] = useState([]);
const [stops, setStops] = useState([]);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(emptyForm);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
useEffect(() => {
setEditingId(null); setForm(emptyForm); setNotice(''); setError('');
setAliases([]); setStops([]);
if (!open) return;
let current = true;
setLoading(true);
Promise.all([telegramImportApi.getStopAliases(), stopsApi.getAll()])
.then(([dictionary, catalog]) => { if (current) { setAliases(dictionary.data); setStops(catalog.data); } })
.catch(err => { if (current) setError(err.response?.data?.error || 'Не удалось загрузить словарь остановок.'); })
.finally(() => { if (current) setLoading(false); });
return () => { current = false; };
}, [open, activeCity?.id]);
const refresh = async () => { setAliases((await telegramImportApi.getStopAliases()).data); onChanged(); };
const save = async event => {
event.preventDefault(); setBusy(true); setError(''); setNotice('');
try {
if (editingId === null) await telegramImportApi.createStopAlias(form);
else await telegramImportApi.updateStopAlias(editingId, form);
setEditingId(null); setForm(emptyForm); await refresh();
setNotice('Соответствие сохранено. При следующем чтении канала сообщения на проверке за последние три дня будут разобраны заново.');
} catch (err) { setError(err.response?.data?.error || 'Не удалось сохранить соответствие.'); }
finally { setBusy(false); }
};
const remove = async id => {
setBusy(true); setError(''); setNotice('');
try {
await telegramImportApi.deleteStopAlias(id);
if (editingId === id) { setEditingId(null); setForm(emptyForm); }
await refresh(); setNotice('Соответствие удалено.');
} catch (err) { setError(err.response?.data?.error || 'Не удалось удалить соответствие.'); }
finally { setBusy(false); }
};
return <div className="mt-4 border-t pt-3">
<button type="button" className="text-sm text-primary-600 inline-flex items-center gap-2 hover:underline" aria-expanded={open} onClick={() => setOpen(!open)} disabled={busy}>
<BookOpen className="h-4 w-4" />{open ? 'Скрыть словарь остановок' : 'Словарь названий остановок'}
</button>
{open && <div className="mt-3 space-y-3">
<p className="text-sm text-gray-600">Свяжите название из канала с остановкой {activeCity?.name ? `города ${activeCity.name}` : 'выбранного города'}. Например: ЗОДЧЕГО → Зодчего Иоанна. Регистр, пробелы и знаки препинания не учитываются.</p>
{error && <p role="alert" className="text-sm text-red-700">{error}</p>}
{notice && <p role="status" className="text-sm text-green-700">{notice}</p>}
{loading ? <p className="text-sm text-gray-500">Загрузка словаря…</p> : <>
<form onSubmit={save} className="rounded-lg bg-gray-50 p-3 space-y-3">
<div className="flex justify-between items-center"><h3 className="text-sm font-medium">{editingId === null ? 'Новое соответствие' : 'Изменить соответствие'}</h3>
{editingId !== null && <button type="button" aria-label="Отменить изменение соответствия" onClick={() => { setEditingId(null); setForm(emptyForm); }} disabled={busy}><X className="h-4 w-4" /></button>}
</div>
<div className="grid sm:grid-cols-2 gap-3">
<label className="text-sm">Название в канале<input className="input mt-1 w-full" placeholder="ЗОДЧЕГО" value={form.alias} maxLength={200} onChange={e => setForm({ ...form, alias: e.target.value })} required disabled={busy} /></label>
<label className="text-sm">Остановка в админке<select className="input mt-1 w-full" value={form.stop_id} onChange={e => setForm({ ...form, stop_id: e.target.value })} required disabled={busy}>
<option value="">Выберите остановку</option>
{form.stop_id && !stops.some(stop => Number(stop.id) === Number(form.stop_id)) && <option value={form.stop_id} disabled>Остановка отключена — выберите другую</option>}
{stops.map(stop => <option key={stop.id} value={stop.id}>{stop.name}{stop.address ? ` · ${stop.address}` : ''} · #{stop.id}</option>)}
</select></label>
</div>
<button type="submit" className="btn-primary text-sm" disabled={busy || !stops.length}>{editingId === null ? 'Добавить соответствие' : 'Сохранить соответствие'}</button>
</form>
<div className="space-y-2">
{aliases.map(alias => <div key={alias.id} className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-gray-200 p-3">
<div className="min-w-0 break-words text-sm"><strong>{alias.alias}</strong> <span className="text-gray-400">→</span> {alias.stop_name}
{!alias.stop_active && <span className="text-xs text-amber-700 ml-2">Остановка отключена</span>}
<p className="text-xs text-gray-500">{alias.stop_address ? `${alias.stop_address} · ` : ''}Остановка #{alias.stop_id}</p>
</div>
<div className="flex gap-3 shrink-0">
<button type="button" className="text-primary-600 text-sm inline-flex items-center gap-1" disabled={busy} onClick={() => { setEditingId(alias.id); setForm({ alias: alias.alias, stop_id: String(alias.stop_id) }); setNotice(''); setError(''); }}><Pencil className="h-3 w-3" />Изменить</button>
<button type="button" className="text-red-600 text-sm inline-flex items-center gap-1" disabled={busy} onClick={() => remove(alias.id)}><Trash2 className="h-3 w-3" />Удалить</button>
</div>
</div>)}
{!aliases.length && <p className="text-sm text-gray-500">Добавьте первое соответствие. Для нескольких вариантов одного названия можно создать отдельные записи.</p>}
</div>
<p className="text-xs text-gray-500">Изменения применяются к новым сообщениям и сообщениям на проверке. Опубликованные уведомления сохраняют свой текст.</p>
</>}
</div>}
</div>;
}