This commit is contained in:
tikeev.k
2026-06-01 17:38:44 +03:00
commit 4548a57f83
14 changed files with 2989 additions and 0 deletions

361
bot/keyboards.py Normal file
View File

@@ -0,0 +1,361 @@
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, KeyboardButton, ReplyKeyboardMarkup
from bot.db import is_overdue
from bot.taxonomy import SECTIONS, SUBTOPICS, TOPICS
STATUS_LABELS = {
"new": "🆕 Новая",
"in_progress": "🛠 В работе",
"waiting": "⏳ Ожидание ответа",
"deferred": "💤 Отложена",
"closed": "✅ Закрыта",
"rejected": "🚫 Отклонена",
}
PRIORITY_LABELS = {
"low": "🟢 Низкий",
"medium": "🟡 Средний",
"high": "🟠 Высокий",
"critical": "🔴 Критичный",
}
PRIORITY_EMOJI = {
"low": "🟢",
"medium": "🟡",
"high": "🟠",
"critical": "🔴",
}
def status_label(status: str) -> str:
return STATUS_LABELS.get(status, status)
def priority_label(priority: str) -> str:
return PRIORITY_LABELS.get(priority, priority)
def priority_emoji(priority: str) -> str:
return PRIORITY_EMOJI.get(priority, "⚪️")
def main_menu() -> ReplyKeyboardMarkup:
return ReplyKeyboardMarkup(
keyboard=[
[KeyboardButton(text="Создать заявку")],
[KeyboardButton(text="Мои заявки"), KeyboardButton(text="FAQ")],
],
resize_keyboard=True,
)
def sections() -> InlineKeyboardMarkup:
"""Корневое меню: 11 разделов согласно схеме."""
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text=label, callback_data=f"sec:{code}")]
for code, label in SECTIONS
]
)
def topics(section_code: str) -> InlineKeyboardMarkup:
"""Подтемы выбранного раздела + кнопка «Назад к разделам»."""
rows: list[list[InlineKeyboardButton]] = [
[InlineKeyboardButton(text=label, callback_data=f"tp:{section_code}:{code}")]
for code, label in TOPICS.get(section_code, [])
]
rows.append([InlineKeyboardButton(text="⬅️ К разделам", callback_data="sec:back")])
return InlineKeyboardMarkup(inline_keyboard=rows)
def subtopics(section_code: str, topic_code: str) -> InlineKeyboardMarkup:
"""Уточнение для подтемы, у которой есть 3-й уровень (например, тип CRM)."""
rows: list[list[InlineKeyboardButton]] = [
[InlineKeyboardButton(text=label, callback_data=f"sb:{section_code}:{topic_code}:{code}")]
for code, label in SUBTOPICS.get((section_code, topic_code), [])
]
rows.append([InlineKeyboardButton(text="⬅️ К темам", callback_data=f"sec:{section_code}")])
return InlineKeyboardMarkup(inline_keyboard=rows)
def instruction_result() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="Не помогло, создать заявку", callback_data="instruction:create")],
[InlineKeyboardButton(text="Отменить создание", callback_data="ticket:cancel")],
]
)
def cancel_creation() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="Отменить создание", callback_data="ticket:cancel")]
]
)
def attachment_actions(count: int = 0) -> InlineKeyboardMarkup:
"""Кнопки на шаге прикрепления фото: завершить (с любым числом фото) или отменить."""
done_text = f"✅ Готово ({count})" if count else "✅ Готово (без фото)"
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text=done_text, callback_data="att:done")],
[InlineKeyboardButton(text="Отменить создание", callback_data="ticket:cancel")],
]
)
def duplicate_options(existing_id: int) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text=f" Дополнить заявку #{existing_id}", callback_data=f"dup:append:{existing_id}")],
[InlineKeyboardButton(text="🆕 Создать новую заявку", callback_data="dup:new")],
[InlineKeyboardButton(text="Отменить создание", callback_data="ticket:cancel")],
]
)
def user_tickets(tickets: list, status_filter: str = "active") -> InlineKeyboardMarkup:
archive_label = "Архив закрытых" if status_filter == "active" else "Активные заявки"
archive_filter = "closed" if status_filter == "active" else "active"
rows = [
[
InlineKeyboardButton(
text=f"#{ticket['id']} · {status_label(ticket['status'])} · {ticket['created_at']}",
callback_data=f"user_ticket:{ticket['id']}:{status_filter}",
)
]
for ticket in tickets
]
rows.append([InlineKeyboardButton(text=archive_label, callback_data=f"user_tickets:{archive_filter}")])
return InlineKeyboardMarkup(inline_keyboard=rows)
def back_to_user_tickets(status_filter: str = "active") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="Назад к моим заявкам", callback_data=f"user_tickets:{status_filter}")]
]
)
def user_ticket_actions(ticket_id: int, status: str, status_filter: str = "active") -> InlineKeyboardMarkup:
rows: list[list[InlineKeyboardButton]] = []
if status not in ("closed", "rejected"):
rows.append(
[InlineKeyboardButton(text="💬 Добавить комментарий", callback_data=f"user_comment:{ticket_id}:{status_filter}")]
)
rows.append(
[InlineKeyboardButton(text="💬 Чат с поддержкой", callback_data=f"user_chat:{ticket_id}")]
)
rows.append(
[InlineKeyboardButton(text="✅ Вопрос решён, закрыть", callback_data=f"user_close:{ticket_id}:{status_filter}")]
)
rows.append([InlineKeyboardButton(text="Назад к моим заявкам", callback_data=f"user_tickets:{status_filter}")])
return InlineKeyboardMarkup(inline_keyboard=rows)
def user_comment_cancel(ticket_id: int, status_filter: str = "active") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="Отмена", callback_data=f"user_comment_cancel:{ticket_id}:{status_filter}")]
]
)
def user_close_confirm(ticket_id: int, status_filter: str = "active") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="✅ Да, закрыть заявку", callback_data=f"user_close_yes:{ticket_id}:{status_filter}")],
[InlineKeyboardButton(text="⬅️ Нет, оставить", callback_data=f"user_ticket:{ticket_id}:{status_filter}")],
]
)
def user_reply_kb(ticket_id: int) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="💬 Ответить", callback_data=f"user_chat:{ticket_id}")]
]
)
def user_chat_controls() -> ReplyKeyboardMarkup:
return ReplyKeyboardMarkup(
keyboard=[[KeyboardButton(text="❌ Завершить чат")]],
resize_keyboard=True,
)
def admin_reply_kb(ticket_id: int) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="💬 Ответить", callback_data=f"admin:chat:{ticket_id}:open")]
]
)
# ----------------------------------------------------------------- admin side
FILTER_TITLES = {
"open": "Открытые заявки",
"new": "Новые заявки",
"in_progress": "Заявки в работе",
"waiting": "Ожидают ответа",
"deferred": "Отложенные заявки",
"overdue": "Просроченные по SLA",
"mine": "Мои заявки",
"closed": "Архив (закрытые/отклонённые)",
}
def filter_title(list_filter: str) -> str:
return FILTER_TITLES.get(list_filter, "Заявки")
def admin_dashboard(stats: dict[str, int]) -> InlineKeyboardMarkup:
def btn(text: str, key: str) -> InlineKeyboardButton:
return InlineKeyboardButton(text=text, callback_data=f"adm:list:{key}")
return InlineKeyboardMarkup(
inline_keyboard=[
[btn(f"📋 Все открытые · {stats['open']}", "open")],
[btn(f"🆕 Новые · {stats['new']}", "new"), btn(f"🛠 В работе · {stats['in_progress']}", "in_progress")],
[btn(f"⏳ Ожидание · {stats['waiting']}", "waiting"), btn(f"💤 Отложены · {stats['deferred']}", "deferred")],
[btn(f"🔴 Просрочены · {stats['overdue']}", "overdue"), btn("🙋 Мои", "mine")],
[
InlineKeyboardButton(text="🔎 Поиск по №", callback_data="adm:find"),
InlineKeyboardButton(text=f"🗄 Архив · {stats['closed']}", callback_data="adm:list:closed"),
],
]
)
def open_tickets(tickets: list, list_filter: str, sla_minutes: int) -> InlineKeyboardMarkup:
rows = []
for ticket in tickets:
mark = " 🔴" if is_overdue(ticket, sla_minutes) else ""
rows.append(
[
InlineKeyboardButton(
text=f"{priority_emoji(ticket['priority'])} #{ticket['id']} · {ticket['topic']} · {status_label(ticket['status'])}{mark}",
callback_data=f"admin_ticket:{ticket['id']}:{list_filter}",
)
]
)
rows.append([InlineKeyboardButton(text="⬅️ Дашборд", callback_data="adm:dash")])
return InlineKeyboardMarkup(inline_keyboard=rows)
def priority_menu(ticket_id: int, list_filter: str) -> InlineKeyboardMarkup:
rows = [
[InlineKeyboardButton(text=label, callback_data=f"setprio:{ticket_id}:{level}")]
for level, label in PRIORITY_LABELS.items()
]
rows.append([InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_ticket:{ticket_id}:{list_filter}")])
return InlineKeyboardMarkup(inline_keyboard=rows)
def assignee_menu(ticket_id: int, admins: list[tuple[int, str]], list_filter: str) -> InlineKeyboardMarkup:
rows = [
[InlineKeyboardButton(text=f"👤 {name}", callback_data=f"assignto:{ticket_id}:{admin_id}")]
for admin_id, name in admins
]
rows.append([InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_ticket:{ticket_id}:{list_filter}")])
return InlineKeyboardMarkup(inline_keyboard=rows)
def back_to_ticket(ticket_id: int, list_filter: str) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="⬅️ К заявке", callback_data=f"admin_ticket:{ticket_id}:{list_filter}")]
]
)
# Быстрые причины отложения заявки («быстрые ответы»).
DEFER_REASONS = [
"Ожидаем запчасти / поставку",
"Согласуем время визита",
"Ждём ответ от поставщика / вендора",
"Ожидаем информацию от заявителя",
"Передано смежному отделу",
]
def defer_reasons_menu(ticket_id: int, list_filter: str) -> InlineKeyboardMarkup:
rows = [
[InlineKeyboardButton(text=reason, callback_data=f"defrsn:{ticket_id}:{index}:{list_filter}")]
for index, reason in enumerate(DEFER_REASONS)
]
rows.append([InlineKeyboardButton(text="✏️ Своя причина", callback_data=f"defrsn:{ticket_id}:custom:{list_filter}")])
rows.append([InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_ticket:{ticket_id}:{list_filter}")])
return InlineKeyboardMarkup(inline_keyboard=rows)
def chat_session_kb(ticket_id: int, list_filter: str) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="❌ Закрыть чат", callback_data=f"admin:chatend:{ticket_id}:{list_filter}")]
]
)
def admin_cancel(ticket_id: int, list_filter: str) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="Отмена", callback_data=f"adm:back:{ticket_id}:{list_filter}")]
]
)
def ticket_actions(ticket, viewer_id: int, list_filter: str = "open", with_back: bool = False) -> InlineKeyboardMarkup:
ticket_id = ticket["id"]
status = ticket["status"]
is_holder = ticket["assignee_id"] == viewer_id
def cb(action: str) -> str:
return f"admin:{action}:{ticket_id}:{list_filter}"
rows: list[list[InlineKeyboardButton]] = []
if status in ("closed", "rejected"):
rows.append([InlineKeyboardButton(text="♻️ Переоткрыть", callback_data=cb("reopen"))])
else:
if status == "new":
rows.append([InlineKeyboardButton(text="▶️ Взять в работу", callback_data=cb("assign"))])
elif status in ("waiting", "deferred"):
rows.append([InlineKeyboardButton(text="▶️ Вернуть в работу", callback_data=cb("assign"))])
elif status == "in_progress" and not is_holder:
rows.append([InlineKeyboardButton(text="✋ Перехватить", callback_data=cb("assign"))])
action_row: list[InlineKeyboardButton] = []
if status != "new" and is_holder:
action_row.append(InlineKeyboardButton(text="✅ Закрыть", callback_data=cb("close")))
if status == "in_progress":
action_row.append(InlineKeyboardButton(text="В ожидание", callback_data=cb("wait")))
if status in ("new", "in_progress"):
action_row.append(InlineKeyboardButton(text="💤 Отложить", callback_data=cb("defer")))
for i in range(0, len(action_row), 2):
rows.append(action_row[i : i + 2])
rows.append(
[
InlineKeyboardButton(text="🎚 Приоритет", callback_data=cb("prio")),
InlineKeyboardButton(text="👥 Назначить", callback_data=cb("assignto")),
]
)
rows.append(
[
InlineKeyboardButton(text="📝 Заметка", callback_data=cb("note")),
InlineKeyboardButton(text="💬 Чат", callback_data=cb("chat")),
]
)
rows.append([InlineKeyboardButton(text="🚫 Отклонить", callback_data=cb("reject"))])
rows.append([InlineKeyboardButton(text="🕓 История", callback_data=cb("history"))])
if with_back:
rows.append([InlineKeyboardButton(text="⬅️ К списку", callback_data=f"adm:list:{list_filter}")])
return InlineKeyboardMarkup(inline_keyboard=rows)