362 lines
15 KiB
Python
362 lines
15 KiB
Python
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)
|