Add Telegram bot notifications for managers in settings

In Settings → Notifications, new «Telegram-уведомления» section:
- Bot token input (from @BotFather)
- Step-by-step instructions: manager sends /start to bot, gets Chat ID, admin adds it
- Copyable bot invite link
- Event checkboxes: new booking, cancellation, check-in, check-out, channel error, payment
- Manager list with avatar initials, name, Chat ID, delete button
- Add manager form: name + Chat ID input
- Test message button (shown when at least one manager is added)
- Full section only visible after token is entered

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 22:32:36 +03:00
parent 42f15b6c5b
commit de5b56c577

View File

@@ -1,5 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { Save, Building2, Bell, Shield, Globe, CreditCard, BedDouble, Mail, MessageSquare, Users, Plus, X as XIcon } from 'lucide-react' import { Save, Building2, Bell, Shield, Globe, CreditCard, BedDouble, Mail, MessageSquare, Users, Plus, X as XIcon, Send, CheckCircle2, Copy } from 'lucide-react'
import { MOCK_HOTELS } from '../data/mockData' import { MOCK_HOTELS } from '../data/mockData'
import { cn, PLAN_COLORS, PLAN_LABELS } from '../lib/utils' import { cn, PLAN_COLORS, PLAN_LABELS } from '../lib/utils'
import { Badge } from '../components/ui/Badge' import { Badge } from '../components/ui/Badge'
@@ -80,6 +80,42 @@ export function SettingsPage() {
const [smsApiKey, setSmsApiKey] = useState('') const [smsApiKey, setSmsApiKey] = useState('')
const [smsSender, setSmsSender] = useState('') const [smsSender, setSmsSender] = useState('')
// Telegram settings
const [tgBotToken, setTgBotToken] = useState('')
const [tgManagers, setTgManagers] = useState<{ id: string; name: string; chatId: string }[]>([])
const [newTgName, setNewTgName] = useState('')
const [newTgChatId, setNewTgChatId] = useState('')
const [tgEvents, setTgEvents] = useState({
newBooking: true,
cancellation: true,
checkIn: true,
checkOut: false,
channelError: true,
payment: false,
})
const [tgTestSent, setTgTestSent] = useState(false)
const [copiedToken, setCopiedToken] = useState(false)
const tgBotConfigured = tgBotToken.length > 10
const addTgManager = () => {
if (!newTgName.trim() || !newTgChatId.trim()) return
setTgManagers(prev => [...prev, { id: `tg-${Date.now()}`, name: newTgName.trim(), chatId: newTgChatId.trim() }])
setNewTgName('')
setNewTgChatId('')
}
const sendTgTest = () => {
setTgTestSent(true)
setTimeout(() => setTgTestSent(false), 3000)
}
const copyBotLink = () => {
navigator.clipboard.writeText('https://t.me/HotelSyncBot?start=connect')
setCopiedToken(true)
setTimeout(() => setCopiedToken(false), 2000)
}
const handleSave = () => { const handleSave = () => {
setSaved(true) setSaved(true)
setTimeout(() => setSaved(false), 2000) setTimeout(() => setSaved(false), 2000)
@@ -436,6 +472,150 @@ export function SettingsPage() {
</div> </div>
</div> </div>
{/* Telegram */}
<div className="pt-2 border-t border-slate-100 dark:border-slate-700">
<div className="flex items-center gap-2 mb-1">
<Send size={15} className="text-sky-500" />
<h3 className="text-sm font-semibold text-slate-800 dark:text-slate-200">Telegram-уведомления для менеджеров</h3>
</div>
<p className="text-xs text-slate-400 mb-3">
Менеджеры получают уведомления о бронированиях прямо в Telegram. Каждый менеджер должен написать боту команду <code className="bg-slate-100 dark:bg-slate-700 px-1 rounded">/start</code> и сообщить свой Chat ID.
</p>
{/* How to connect — step instructions */}
<div className="rounded-xl bg-sky-50 dark:bg-sky-900/20 border border-sky-200 dark:border-sky-800 p-3.5 mb-3 space-y-2">
<p className="text-xs font-semibold text-sky-700 dark:text-sky-300">Как подключить менеджера:</p>
<ol className="text-xs text-sky-700 dark:text-sky-300 space-y-1 list-decimal list-inside">
<li>Менеджер открывает Telegram и переходит в бота</li>
<li>Пишет <code className="bg-sky-100 dark:bg-sky-900 px-1 rounded">/start</code> бот ответит его Chat ID</li>
<li>Менеджер сообщает Chat ID администратору тот добавляет его ниже</li>
</ol>
<button
onClick={copyBotLink}
className="flex items-center gap-1.5 text-xs font-medium text-sky-600 dark:text-sky-400 hover:underline"
>
{copiedToken ? <CheckCircle2 size={13} className="text-emerald-500" /> : <Copy size={13} />}
{copiedToken ? 'Скопировано!' : 'Скопировать ссылку на бота'}
</button>
</div>
{/* Bot token */}
<div className="mb-3">
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">
Токен бота
<span className="ml-1 text-slate-400 font-normal">(получить у @BotFather в Telegram)</span>
</label>
<input
type="password"
className="input text-sm font-mono"
placeholder="123456789:AAExx..."
value={tgBotToken}
onChange={e => setTgBotToken(e.target.value)}
/>
</div>
{/* Events to notify */}
{tgBotConfigured && (
<>
<div className="mb-3">
<p className="text-xs font-medium text-slate-600 dark:text-slate-400 mb-2">Уведомлять о событиях:</p>
<div className="grid grid-cols-2 gap-1.5">
{([
{ key: 'newBooking' as const, label: 'Новое бронирование' },
{ key: 'cancellation' as const, label: 'Отмена брони' },
{ key: 'checkIn' as const, label: 'Заезд гостя' },
{ key: 'checkOut' as const, label: 'Выезд гостя' },
{ key: 'channelError' as const, label: 'Ошибка канала' },
{ key: 'payment' as const, label: 'Платёж / оплата' },
]).map(ev => (
<label key={ev.key} className="flex items-center gap-2 text-xs cursor-pointer">
<input
type="checkbox"
className="accent-brand-600"
checked={tgEvents[ev.key]}
onChange={() => setTgEvents(p => ({ ...p, [ev.key]: !p[ev.key] }))}
/>
<span className="text-slate-700 dark:text-slate-300">{ev.label}</span>
</label>
))}
</div>
</div>
{/* Manager list */}
<div className="mb-3">
<p className="text-xs font-medium text-slate-600 dark:text-slate-400 mb-2">
Менеджеры получатели уведомлений
{tgManagers.length > 0 && <span className="ml-1 text-slate-400">({tgManagers.length})</span>}
</p>
<div className="space-y-1.5 mb-2">
{tgManagers.length === 0 && (
<p className="text-xs text-slate-400 py-2 text-center rounded-lg bg-slate-50 dark:bg-slate-700/30">
Менеджеры ещё не добавлены
</p>
)}
{tgManagers.map(m => (
<div key={m.id} className="flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-700/40 text-sm">
<div className="w-7 h-7 rounded-full bg-sky-100 dark:bg-sky-900/50 flex items-center justify-center shrink-0">
<span className="text-xs font-bold text-sky-600 dark:text-sky-400">{m.name[0]?.toUpperCase()}</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">{m.name}</p>
<p className="text-xs text-slate-500 dark:text-slate-400 font-mono">Chat ID: {m.chatId}</p>
</div>
<button
onClick={() => setTgManagers(prev => prev.filter(x => x.id !== m.id))}
className="p-1 rounded text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
<XIcon size={13} />
</button>
</div>
))}
</div>
{/* Add manager */}
<div className="flex gap-2">
<input
type="text"
className="input text-sm flex-1"
placeholder="Имя менеджера"
value={newTgName}
onChange={e => setNewTgName(e.target.value)}
/>
<input
type="text"
className="input text-sm w-32 font-mono"
placeholder="Chat ID"
value={newTgChatId}
onChange={e => setNewTgChatId(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') addTgManager() }}
/>
<button onClick={addTgManager} className="btn-secondary px-3">
<Plus size={14} />
</button>
</div>
</div>
{/* Test button */}
{tgManagers.length > 0 && (
<button
onClick={sendTgTest}
className={cn(
'flex items-center gap-1.5 text-sm font-medium px-3 py-2 rounded-lg border transition-colors',
tgTestSent
? 'bg-emerald-50 dark:bg-emerald-900/20 border-emerald-300 dark:border-emerald-700 text-emerald-700 dark:text-emerald-400'
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-300 hover:border-sky-400 hover:text-sky-600',
)}
>
{tgTestSent
? <><CheckCircle2 size={14} /> Тестовое сообщение отправлено!</>
: <><Send size={14} /> Отправить тестовое сообщение</>
}
</button>
)}
</>
)}
</div>
{/* SMS */} {/* SMS */}
<div className="pt-2 border-t border-slate-100 dark:border-slate-700"> <div className="pt-2 border-t border-slate-100 dark:border-slate-700">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">