// ============================================================================
// TIME RUST Support Panel — new design wired up to the real backend.
// Single-file React app (loaded via Babel from CDN).
// ============================================================================

const { useState, useEffect, useCallback, useRef, useMemo } = React;

// ─── Tiny helpers ───────────────────────────────────────────────────────────
const h = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));

async function api(url, opts = {}) {
  const { headers: extra, body, ...rest } = opts;
  const isFD = body instanceof FormData;
  const headers = { ...(isFD ? {} : { 'Content-Type': 'application/json' }), ...(extra || {}) };
  const r = await fetch(url, { headers, credentials: 'same-origin', body, ...rest });
  if (r.status === 401) { window.__forceLogin?.(); return { __error: 'unauthorized' }; }
  if (r.status === 403) {
    const d = await r.json().catch(() => ({}));
    if (d.error === 'panel_blocked') { location.href = '/auth/logout?blocked=1'; return { __error: 'blocked' }; }
    window.__forceLogin?.(); return { __error: 'forbidden' };
  }
  if (r.status === 204) return { ok: true };
  return r.json().catch(() => ({}));
}

function padId(id) { return String(id || '').slice(-6).padStart(6, '0'); }
function defAv(uid) {
  try { return `https://cdn.discordapp.com/embed/avatars/${Number(BigInt(uid || '0') >> 22n) % 6}.png`; }
  catch (_) { return 'https://cdn.discordapp.com/embed/avatars/0.png'; }
}
function fmtTime(iso) {
  if (!iso) return '';
  const d = new Date(iso);
  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
function fmtDate(iso) {
  if (!iso) return '—';
  const d = new Date(iso);
  const dd = String(d.getDate()).padStart(2, '0');
  const mm = String(d.getMonth() + 1).padStart(2, '0');
  return `${dd}.${mm}.${d.getFullYear()}, ${fmtTime(iso)}`;
}

const CAT_LABELS = {
  player_report: 'Жалоба на игрока',
  ban_appeal: 'Апелляция бана',
  work: 'Работать у нас',
  other: 'Другое',
};
const CAT_EMOJI = { player_report: '🛡️', ban_appeal: '🔨', work: '💼', other: '❓' };

// ─── Icons (line icons matching the design) ─────────────────────────────────
const Icon = {
  search: (p) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>,
  stats: (p) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M3 3v18h18"/><path d="m7 14 4-4 4 4 6-6"/></svg>,
  ticket: (p) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M3 7v4a2 2 0 0 1 0 4v3a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-3a2 2 0 0 1 0-4V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2Z"/><path d="M12 5v14"/></svg>,
  doc: (p) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>,
  lock: (p) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>,
  filter: (p) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M22 3H2l8 9.5V20l4-2v-5.5z"/></svg>,
  bell: (p) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10 21a2 2 0 0 0 4 0"/></svg>,
  close: (p) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>,
  flame: (p) => <svg viewBox="0 0 24 24" fill="currentColor" {...p}><path d="M12 2c1 3 3 4 3 7a3 3 0 0 1-6 0c0-1 1-2 1-3-3 1-5 4-5 8a7 7 0 0 0 14 0c0-6-7-8-7-12z"/></svg>,
  plus: (p) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" {...p}><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>,
  at: (p) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" {...p}><circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8"/></svg>,
  chevR: (p) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}><polyline points="9 18 15 12 9 6"/></svg>,
  chevL: (p) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}><polyline points="15 18 9 12 15 6"/></svg>,
  send: (p) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" {...p}><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>,
  discord: (p) => <svg viewBox="0 0 24 24" fill="currentColor" {...p}><path d="M19 5a16 16 0 0 0-4-1l-.2.4a14 14 0 0 0-4.5 0L10 4a16 16 0 0 0-4 1c-3 4-3 8-3 12 1.5 1 3 1.7 4.5 2l1-1.3a9 9 0 0 1-1.6-.7l.3-.3c3 1.4 6.6 1.4 9.6 0l.3.3c-.5.3-1 .5-1.6.7l1 1.3c1.5-.3 3-1 4.5-2 0-4 0-8-3-12zM9 14c-.8 0-1.5-.8-1.5-1.7s.7-1.7 1.5-1.7 1.5.8 1.5 1.7S9.8 14 9 14zm6 0c-.8 0-1.5-.8-1.5-1.7s.7-1.7 1.5-1.7 1.5.8 1.5 1.7S15.8 14 15 14z"/></svg>,
  check: (p) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" {...p}><polyline points="20 6 9 17 4 12"/></svg>,
  starF: (p) => <svg viewBox="0 0 24 24" fill="currentColor" {...p}><polygon points="12 2 15 9 22 9.3 16.5 14 18.2 21 12 17.3 5.8 21 7.5 14 2 9.3 9 9"/></svg>,
  starO: (p) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" {...p}><polygon points="12 2 15 9 22 9.3 16.5 14 18.2 21 12 17.3 5.8 21 7.5 14 2 9.3 9 9"/></svg>,
  megaphone: (p) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M3 11v2a2 2 0 0 0 2 2h2l8 5V4L7 9H5a2 2 0 0 0-2 2Z"/><path d="M18 8a5 5 0 0 1 0 8"/></svg>,
  gear: (p) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.9 2.9l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.9.3l-.1.1a2 2 0 1 1-2.9-2.9l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.9l-.1-.1a2 2 0 1 1 2.9-2.9l.1.1a1.7 1.7 0 0 0 1.9.3h0a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.9-.3l.1-.1a2 2 0 1 1 2.9 2.9l-.1.1a1.7 1.7 0 0 0-.3 1.9v0a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"/></svg>,
  bolt: (p) => <svg viewBox="0 0 24 24" fill="currentColor" {...p}><path d="M13 2 4 14h7l-1 8 9-12h-7z"/></svg>,
  trash: (p) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>,
  shield: (p) => <svg viewBox="0 0 24 24" fill="currentColor" {...p}><path d="M12 2 4 5v6c0 5 3.5 9 8 11 4.5-2 8-6 8-11V5z"/></svg>,
  crown: (p) => <svg viewBox="0 0 24 24" fill="currentColor" {...p}><path d="M3 7l4 4 5-7 5 7 4-4-2 12H5z"/></svg>,
  music: (p) => <svg viewBox="0 0 24 24" fill="currentColor" {...p}><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>,
  hand: (p) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M9 11V4a1.5 1.5 0 0 1 3 0v6"/><path d="M12 10V3a1.5 1.5 0 0 1 3 0v8"/><path d="M15 11V5a1.5 1.5 0 0 1 3 0v9"/><path d="M18 12.5a1.5 1.5 0 0 1 3 0V16a6 6 0 0 1-6 6h-1.5a6 6 0 0 1-5.7-4.1L6 13a1.5 1.5 0 0 1 2.7-1.3L9 13"/></svg>,
  ask: (p) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}><circle cx="12" cy="12" r="10"/><path d="M9.5 9a2.5 2.5 0 0 1 5 .5c0 1.5-2.5 2-2.5 3.5"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>,
  key: (p) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}><circle cx="7.5" cy="15.5" r="4.5"/><path d="m10.7 12.3 10.6-10.6"/><path d="m17 4 4 4"/><path d="m14 7 3 3"/></svg>,
  monitor: (p) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>,
  chat: (p) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M21 11.5a8.4 8.4 0 0 1-9 8.5 9 9 0 0 1-4-1L3 21l1.4-4.6A8.5 8.5 0 0 1 12 3a8.4 8.4 0 0 1 9 8.5z"/></svg>,
  globe: (p) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15 15 0 0 1 0 20"/><path d="M12 2a15 15 0 0 0 0 20"/></svg>,
  hourglass: (p) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M6 2h12"/><path d="M6 22h12"/><path d="M6 2c0 5 6 6 6 10s-6 5-6 10"/><path d="M18 2c0 5-6 6-6 10s6 5 6 10"/></svg>,
  hammer: (p) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M14 4l6 6-3 3-6-6z"/><path d="M11 7 3 15l3 3 8-8"/><path d="M9 13l4 4"/></svg>,
  unlock: (p) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/></svg>,
  ban: (p) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}><circle cx="12" cy="12" r="10"/><line x1="4.9" y1="4.9" x2="19.1" y2="19.1"/></svg>,
  gift: (p) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}><rect x="3" y="8" width="18" height="4" rx="1"/><path d="M12 8v13"/><path d="M5 12v9h14v-9"/><path d="M12 8a3 3 0 0 1-3-3 2 2 0 0 1 3-1 2 2 0 0 1 3 1 3 3 0 0 1-3 3z"/></svg>,
  gamepad: (p) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}><line x1="6" y1="11" x2="10" y2="11"/><line x1="8" y1="9" x2="8" y2="13"/><line x1="15" y1="12" x2="15.01" y2="12"/><line x1="18" y1="10" x2="18.01" y2="10"/><path d="M17.32 5H6.68a4 4 0 0 0-3.98 3.59l-1.7 8A4 4 0 0 0 5 21a3 3 0 0 0 2.7-1.7L8 18h8l.3 1.3A3 3 0 0 0 19 21a4 4 0 0 0 3.97-4.41l-1.7-8A4 4 0 0 0 17.32 5z"/></svg>,
  users: (p) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>,
  wrench: (p) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M14.7 6.3a4 4 0 1 1 3 7L18 15l5 5-3 3-5-5-1.8.2a4 4 0 0 1-4.5-4.5L9 13l-6-6 3-3 6 6 .2-1.8z"/></svg>,
  copy: (p) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>,
  paperclip: (p) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M21.4 11.05 12.25 20.2a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>,
};

// ─── Discord markdown rendering ─────────────────────────────────────────────
function fmtDiscordMd(text) {
  if (!text) return '';
  let s = h(text);
  s = s.replace(/```([\s\S]+?)```/g, (_, c) => `<pre class="md-code">${c.trim()}</pre>`);
  s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
  s = s.replace(/__(.+?)__/g, '<u>$1</u>');
  s = s.replace(/\*([^*\n]+?)\*/g, '<em>$1</em>');
  s = s.replace(/`([^`\n]+?)`/g, '<code class="md-inline">$1</code>');
  s = s.replace(/&lt;@!?(\d+)&gt;/g, '<span class="md-mention">@пользователь</span>');
  s = s.replace(/&lt;#(\d+)&gt;/g, '<span class="md-mention">#канал</span>');
  s = s.replace(/&lt;@&amp;(\d+)&gt;/g, '<span class="md-mention">@роль</span>');
  s = s.replace(/^&gt;\s?(.+)$/gm, '<span class="md-quote">$1</span>');
  s = s.replace(/^#{1,3}\s?(.+)$/gm, '<div class="md-h">$1</div>');
  s = s.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener" class="md-link">$1</a>');
  return s;
}

function colorClassFor(c) {
  if (!c) return 'ec-green';
  if (c === 0x57F287 || c === 5763719) return 'ec-green';
  if (c === 0xFEE75C || c === 16776540) return 'ec-yellow';
  if (c === 0xED4245 || c === 15548997) return 'ec-red';
  if (c === 0x5865F2 || c === 5793266) return 'ec-blue';
  if (c === 0x4CF287) return 'ec-green';
  return 'ec-blue';
}

// ─── Quick replies (defaults — server may override) ─────────────────────────
const DEFAULT_QUICK_REPLY_GROUPS = [
  { id: 'greet', label: 'Приветствие', items: [
    { id: 'qr-hi',     icon: 'hand',      title: 'Приветствие',     text: 'Здравствуйте, {mention}! Начинаю работать по Вашему тикету.' },
    { id: 'qr-more',   icon: 'ask',       title: 'Чем ещё помочь?', text: 'Могу ли я Вам чем-то ещё помочь? {mention}' },
    { id: 'qr-solved', icon: 'check',     title: 'Вопрос решён',    text: 'Рад, что смог помочь! Если появятся ещё вопросы — не стесняйтесь обращаться. Закрываю тикет.' },
    { id: 'qr-ignore', icon: 'hourglass', title: 'Игнор (24ч)',     text: '{mention}, напоминаю о Вашем тикете. Если у Вас остались вопросы — напишите, мы продолжим. Если всё решено — дайте знать, закроем тикет.' },
  ]},
  { id: 'howto', label: 'Инструкции', items: [
    { id: 'qr-steamid', icon: 'key',       title: 'Как узнать SteamID',        text: 'Для получения SteamID64 воспользуйтесь инструкцией: https://wiki.timerust.ru/instrukcii/steamid' },
    { id: 'qr-console', icon: 'monitor',   title: 'Подключение через консоль', text: 'Инструкция по подключению к серверу через консоль: https://wiki.timerust.ru/instrukcii/kak-podklyuchitsya-k-serveru-cherez-connect' },
    { id: 'qr-vip',     icon: 'gift',      title: 'Как получить Free VIP',     text: 'Получить бесплатный VIP можно через нашего Telegram-бота: @timerust_bot' },
    { id: 'qr-vpn',     icon: 'globe',     title: 'Проблема с подключением',   text: 'Воспользуйтесь VPN — Ваш провайдер блокирует подключение к серверу. Если проблема сохранится после включения VPN, напишите нам снова.' },
  ]},
  { id: 'req', label: 'Запросы', items: [
    { id: 'qr-proof', icon: 'doc',       title: 'Запрос доказательств',     text: 'Пожалуйста, предоставьте доказательства (скриншоты, видео или демо-записи), чтобы мы могли рассмотреть вашу заявку.' },
    { id: 'qr-wait',  icon: 'hourglass', title: 'Ожидайте ответа',           text: 'Ваше обращение передано на рассмотрение. Пожалуйста, ожидайте — мы ответим в ближайшее время.' },
    { id: 'qr-tech',  icon: 'wrench',    title: 'Передано тех. специалистам', text: 'Ваше обращение передано техническим специалистам. Они изучат ситуацию и примут необходимые меры в ближайшее время.' },
  ]},
  { id: 'rep', label: 'Жалобы', items: [
    { id: 'qr-rep-ok',   icon: 'shield',  title: 'Жалоба принята',     text: 'Спасибо за жалобу. Игрок будет проверен нашей администрацией в ближайшее время.' },
    { id: 'qr-cheats',   icon: 'gamepad', title: 'Софт/читы',          text: 'Спасибо за обращение. Данный игрок будет проверен на использование стороннего ПО. Если факт нарушения подтвердится, к нему будут применены соответствующие меры.' },
    { id: 'qr-multi',    icon: 'users',   title: 'Мульти-аккаунты',    text: 'Информация о мульти-аккаунтах принята. Мы проверим связанные аккаунты и примем меры в случае подтверждения нарушения.' },
    { id: 'qr-punished', icon: 'check',   title: 'Нарушитель наказан', text: 'По итогам проверки нарушитель получил блокировку. Спасибо за обращение!' },
    { id: 'qr-unblock',  icon: 'unlock',  title: 'Игрок разблокирован', text: 'После рассмотрения вашей апелляции блокировка аккаунта снята. Приносим извинения за доставленные неудобства. Приятной игры!' },
  ]},
  { id: 'deny', label: 'Отказы', items: [
    { id: 'qr-noproof', icon: 'ban',    title: 'Недостаточно доказательств',    text: 'К сожалению, предоставленных доказательств недостаточно для принятия мер. Если у вас появятся дополнительные материалы — создайте новый тикет.' },
    { id: 'qr-banok',   icon: 'hammer', title: 'Бан подтверждён',               text: 'После проверки блокировка вашего аккаунта признана обоснованной. Апелляция отклонена.' },
    { id: 'qr-notech',  icon: 'gear',   title: 'Нет технической возможности',   text: 'К сожалению, у нас нет такой технической возможности. Приносим извинения за неудобства.' },
    { id: 'qr-no-hire', icon: 'shield', title: 'Набор модераторов закрыт',      text: 'В данный момент набор на пост модератора не ведётся. Следите за объявлениями.' },
  ]},
];

// ─── Push notifications setup ───────────────────────────────────────────────
function urlBase64ToUint8Array(b64) {
  const padding = '='.repeat((4 - b64.length % 4) % 4);
  const base64 = (b64 + padding).replace(/-/g, '+').replace(/_/g, '/');
  const raw = atob(base64);
  const out = new Uint8Array(raw.length);
  for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
  return out;
}

async function registerPush() {
  if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
  try {
    const reg = await navigator.serviceWorker.register('/sw.js');
    await navigator.serviceWorker.ready;
    let sub = await reg.pushManager.getSubscription();
    if (!sub) {
      const { key } = await fetch('/api/push/vapid-public-key').then(r => r.json());
      sub = await reg.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: urlBase64ToUint8Array(key),
      });
    }
    await fetch('/api/push/subscribe', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(sub), credentials: 'same-origin',
    });
  } catch (e) { /* push не критичен */ }
}

async function requestPushPermission() {
  if (!('Notification' in window)) return;
  if (Notification.permission === 'default') {
    const r = await Notification.requestPermission();
    if (r === 'granted') registerPush();
  } else if (Notification.permission === 'granted') {
    registerPush();
  }
}

// ─── Notification sound ─────────────────────────────────────────────────────
const notifSound = (typeof Audio !== 'undefined')
  ? new Audio('https://images.timerust.ru/ModerationPanel/new-post-notification-sound.mp3')
  : null;
if (notifSound) notifSound.volume = 0.5;
let lastNotifTime = 0;
function playNotif() {
  if (!notifSound) return;
  const now = Date.now();
  if (now - lastNotifTime < 3000) return;
  lastNotifTime = now;
  try { notifSound.currentTime = 0; notifSound.play().catch(() => {}); } catch (_) {}
}

// ─── Login page ─────────────────────────────────────────────────────────────
function LoginPage({ error }) {
  return (
    <div className="login-page">
      <div className="login-stage" aria-hidden="true" />
      <main className="login-shell">
        <header className="login-top">
          <span className="brand-logo">TIME RUST</span>
          <span className="login-meta">
            <span className="dot" /> Сервисы работают
            <span className="lang">RU</span>
          </span>
        </header>
        <section className="login-center">
          <div className="login-card">
            <div className="login-crest">
              <Icon.discord />
            </div>
            <div className="login-eyebrow">Панель поддержки</div>
            <h1>Вход для модераторов</h1>
            <p className="login-lede">Авторизуйтесь через Discord, чтобы перейти к работе с тикетами.</p>
            <a href="/auth/discord" className="login-btn-discord">
              <Icon.discord />
              <span>Продолжить с Discord</span>
            </a>
            <div className="login-divider">Что произойдёт дальше</div>
            <ul className="login-checklist">
              <li><Icon.check /> <span>Откроется окно Discord для подтверждения входа</span></li>
              <li><Icon.check /> <span>Проверим вашу роль в команде и уровень доступа</span></li>
              <li><Icon.check /> <span>Перенаправим в панель управления тикетами</span></li>
            </ul>
            {error && <div className="login-err">{error}</div>}
            <div className="login-foot">
              Продолжая, вы соглашаетесь с
              <a href="#">Правилами проекта</a> и <a href="#">Политикой обработки данных</a>.
            </div>
          </div>
        </section>
        <footer className="login-bottom">
          <div>© TIME RUST · v2.4.0</div>
          <nav className="login-bottom-links">
            <a href="#">Поддержка</a>
            <a href="#">Статус</a>
            <a href="#">Wiki</a>
          </nav>
        </footer>
      </main>
    </div>
  );
}

// ─── Avatar ─────────────────────────────────────────────────────────────────
function Avatar({ name, src, color, size = '' }) {
  if (src) return <img className={`avatar img ${size}`} src={src} alt={name || ''} onError={(e) => { e.target.style.display = 'none'; }} />;
  const ch = (name || '?').trim().charAt(0).toUpperCase();
  return <span className={`avatar ${size}`} style={{ background: color || '#3a3f55' }}>{ch}</span>;
}

// ─── Sidebar ────────────────────────────────────────────────────────────────
function Sidebar({ active, onNav, currentUser, stats, isAdmin, onCloseDrawer }) {
  const navMain = [
    { id: 'stats',         label: 'Статистика',  icon: Icon.stats },
    { id: 'tickets',       label: 'Тикеты',       icon: Icon.ticket, badge: stats?.unassigned || null },
  ];
  if (isAdmin) {
    navMain.push({ id: 'notifications', label: 'Оповещения', icon: Icon.megaphone });
    navMain.push({ id: 'management',    label: 'Управление', icon: Icon.lock });
  }

  const rating = currentUser?.my_avg_rating || stats?.my_avg_rating;
  const stars = Math.round(rating || 0);

  return (
    <aside className="sidebar">
      <div className="brand">
        <span className="brand-logo">TIME RUST</span>
        {onCloseDrawer && (
          <button className="sidebar-close" onClick={onCloseDrawer} aria-label="Закрыть меню">
            <Icon.close style={{ width: 14, height: 14 }} />
          </button>
        )}
      </div>

      <div className="nav-group">
        <div className="nav-label">Навигация</div>
        {navMain.map((n) => {
          const Ic = n.icon;
          return (
            <button key={n.id} className={`nav-item ${active === n.id ? 'active' : ''}`} onClick={() => onNav(n.id)}>
              <Ic />
              <span>{n.label}</span>
              {n.badge ? <span className="nav-badge">{n.badge}</span> : null}
            </button>
          );
        })}
      </div>

      <div className="rating-card">
        <div className="rating-stars">
          {[1,2,3,4,5].map((i) => (
            <span key={i} className={`rating-star ${i <= stars ? 'on' : 'off'}`}>
              {i <= stars
                ? <Icon.starF style={{ width: 11, height: 11 }} />
                : <Icon.starO style={{ width: 11, height: 11 }} />}
            </span>
          ))}
        </div>
        <div className="rating-line">
          <span className="rating-k">Ваш рейтинг:</span>
          <span className="rating-v">{rating ? rating.toFixed(1) : '—'}/5</span>
          <span className="rating-c">({stats?.my_rated_count || 0})</span>
        </div>
        <div className="rating-line rating-line-sub">
          <span className="rating-k">Закрыто:</span>
          <span className="rating-v">{stats?.my_closed || 0}</span>
          <span className="rating-c">из {stats?.my_total || 0}</span>
        </div>
      </div>

      <div className="sidebar-spacer" />

      <div className="sidebar-user">
        {currentUser?.avatar_url
          ? <img className="sidebar-user-avatar" src={currentUser.avatar_url} alt="" />
          : <div className="sidebar-user-avatar"><span className="su-emoji">👋</span></div>}
        <div className="sidebar-user-meta">
          <div className="name">{currentUser?.global_name || currentUser?.username || 'agent'}</div>
        </div>
        <a className="su-act" href="/auth/logout" title="Выйти из аккаунта">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" style={{ width: 13, height: 13 }}>
            <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
            <polyline points="16 17 21 12 16 7"/>
            <line x1="21" y1="12" x2="9" y2="12"/>
          </svg>
        </a>
      </div>
    </aside>
  );
}

// ─── Mobile topbar ──────────────────────────────────────────────────────────
function MobileTopbar({ section, activeTicket, onMenu, onBack, onInfo }) {
  const titles = { tickets: 'Тикеты', stats: 'Статистика', notifications: 'Оповещения', management: 'Управление' };
  const inChat = section === 'tickets' && !!activeTicket;
  return (
    <header className="mobile-topbar">
      {inChat ? (
        <button className="mt-icon-btn" onClick={onBack} aria-label="Назад">
          <Icon.chevL />
        </button>
      ) : (
        <button className="mt-icon-btn" onClick={onMenu} aria-label="Меню">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
        </button>
      )}
      <div className="mt-title">
        {inChat ? (
          <>
            <span className="mt-pre">#{padId(activeTicket.id)}</span>
            <span className="mt-sub">{activeTicket.category_label || CAT_LABELS[activeTicket.category] || 'Тикет'}</span>
          </>
        ) : (
          <span className="mt-pre">{titles[section] || 'TIME RUST'}</span>
        )}
      </div>
      {inChat ? (
        <button className="mt-icon-btn" onClick={onInfo} aria-label="Информация">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9"/><line x1="12" y1="11" x2="12" y2="17"/><circle cx="12" cy="7.5" r="1" fill="currentColor"/></svg>
        </button>
      ) : (
        <span className="mt-icon-btn mt-spacer" />
      )}
    </header>
  );
}

// ─── Tickets list ───────────────────────────────────────────────────────────
function TicketCard({ ticket, active, currentUserId, onClick }) {
  const sourceLabel = ticket.source || 'discord';
  const isMine = ticket.agent_id === currentUserId;
  const isUnassigned = !ticket.agent_id && ticket.status === 'open';
  const isClosed = ticket.status === 'closed';
  const catLabel = ticket.category_label || CAT_LABELS[ticket.category] || 'Тикет';

  return (
    <div className={`ticket-card ${active ? 'active' : ''}`} onClick={onClick}>
      <div className="tc-head">
        <div className="tc-badges">
          <span className={`badge-src ${sourceLabel}`}>{sourceLabel}</span>
          {ticket.awaiting_reply && !isClosed && (
            <span style={{ width: 6, height: 6, borderRadius: '50%', background: 'var(--red)', marginLeft: 4 }} />
          )}
          {isMine && !isClosed && (
            <span style={{ width: 6, height: 6, borderRadius: '50%', background: 'var(--accent-2)', marginLeft: 4, boxShadow: '0 0 6px var(--accent-2)' }} />
          )}
        </div>
        <div className="tc-author">
          {ticket.agent_id ? (
            <>
              {ticket.agent_avatar
                ? <img className="avatar img" src={ticket.agent_avatar} alt="" />
                : <span className="avatar" style={{ background: '#5a6b86' }}>{(ticket.agent_name || '?')[0].toUpperCase()}</span>}
              <span className="name">{ticket.agent_name || '—'}</span>
            </>
          ) : (
            <span className="name" style={{ color: 'var(--text-3)' }}>не назначен</span>
          )}
        </div>
      </div>

      <div className="tc-title">{catLabel}</div>
      <div className="tc-opened">
        {isClosed ? 'Закрыт ' : 'Открыт '}
        {fmtDate(isClosed ? ticket.closed_at : ticket.created_at)}
      </div>

      <div className="tc-meta-row">
        <div className="tc-meta">
          <div className="lbl">User</div>
          <div className="val">
            <span className="platform-dot" style={{ background: '#5865f2' }}><Icon.discord /></span>
            <span>{ticket.username || '—'}</span>
          </div>
        </div>
        <div className="tc-meta">
          <div className="lbl">User ID</div>
          <div className="val">{ticket.user_id || '—'}</div>
        </div>
      </div>

      <div className="tc-id-row">
        <span># {padId(ticket.id)}</span>
        {ticket.rating ? <span style={{ color: 'var(--orange)' }}>★ {ticket.rating}/5</span> : null}
      </div>
    </div>
  );
}

function TicketsList({ tickets, activeId, onSelect, stats, currentUserId, isAdmin }) {
  const [filter, setFilter] = useState('Ожидают');
  const [query, setQuery] = useState('');

  const isMine = (t) => t.agent_id === currentUserId;
  const isFree = (t) => !t.agent_id && t.status === 'open';
  const isClosed = (t) => t.status === 'closed';
  const isLockedByOther = (t) => t.agent_id && t.agent_id !== currentUserId;

  const filtered = tickets.filter((t) => {
    // Только админ и нынешний агент видят чужие тикеты в работе
    if (!isAdmin && isLockedByOther(t) && !isClosed(t)) return false;
    if (filter === 'Мои' && !isMine(t)) return false;
    if (filter === 'Ожидают' && !isFree(t)) return false;
    if (filter === 'Закрытые' && !isClosed(t)) return false;
    if (query) {
      const q = query.toLowerCase();
      const hay = `${t.category_label || ''} ${t.username || ''} ${t.user_id || ''} ${t.id}`.toLowerCase();
      if (!hay.includes(q)) return false;
    }
    return true;
  });

  const visible = tickets.filter((t) => isAdmin || !isLockedByOther(t) || isClosed(t));
  const counts = {
    total: visible.length,
    open: visible.filter((t) => t.status === 'open').length,
    free: visible.filter(isFree).length,
    mine: visible.filter(isMine).length,
  };

  return (
    <div className="col col-tickets">
      <div className="tickets-header">
        <div className="tickets-h-title">Тикеты</div>

        <div className="stat-quad">
          <div className="sq-card"><div className="sq-num white">{counts.total}</div><div className="sq-lbl">Всего</div></div>
          <div className="sq-card"><div className="sq-num green">{counts.open}</div><div className="sq-lbl">Открыто</div></div>
          <div className="sq-card"><div className="sq-num red">{counts.free}</div><div className="sq-lbl">Свободных</div></div>
          <div className="sq-card"><div className="sq-num yellow">{counts.mine}</div><div className="sq-lbl">Моих</div></div>
        </div>

        <div className="search-row">
          <Icon.search />
          <input
            className="search-input"
            placeholder="Поиск..."
            value={query}
            onChange={(e) => setQuery(e.target.value)} />
        </div>

        <div className="chip-row">
          {['Ожидают', 'Мои', 'Закрытые', 'Все'].map((c) => (
            <button key={c} className={`pill-chip ${filter === c ? 'on' : ''}`} onClick={() => setFilter(c)}>
              {c}
            </button>
          ))}
        </div>
      </div>

      <div className="tickets-scroll">
        {filtered.length === 0 && (
          <div className="tickets-empty">
            <Icon.search style={{ width: 28, height: 28 }} />
            <div className="te-title">Ничего не найдено</div>
            <div className="te-sub">
              {filter === 'Мои' && 'У вас нет тикетов в работе'}
              {filter === 'Ожидают' && 'Нет свободных тикетов'}
              {filter === 'Закрытые' && 'Нет закрытых тикетов'}
              {filter === 'Все' && 'Попробуйте изменить поиск'}
            </div>
          </div>
        )}
        {filtered.map((t) => (
          <TicketCard
            key={t.id}
            ticket={t}
            active={t.id === activeId}
            currentUserId={currentUserId}
            onClick={() => onSelect(t.id)} />
        ))}
      </div>
    </div>
  );
}

// ─── Embed card (Discord embed rendering) ───────────────────────────────────
function EmbedCard({ embed }) {
  const cls = colorClassFor(embed.color);
  return (
    <div className={`embed-card ${cls}`}>
      {embed.title && <div className="ec-title" dangerouslySetInnerHTML={{ __html: fmtDiscordMd(embed.title) }} />}
      {embed.description && <div className="ec-desc" dangerouslySetInnerHTML={{ __html: fmtDiscordMd(embed.description) }} />}
      {embed.fields && embed.fields.length > 0 && (
        <div className="ec-fields">
          {embed.fields.map((f, i) => (
            <div key={i} className={`ec-field ${f.inline ? '' : 'ec-full'}`}>
              <div className="ec-field-name">{f.name}</div>
              <div className="ec-field-val" dangerouslySetInnerHTML={{ __html: fmtDiscordMd(f.value) }} />
            </div>
          ))}
        </div>
      )}
      {embed.footer && <div className="ec-footer">{embed.footer}</div>}
    </div>
  );
}

function V2Card({ v2 }) {
  if (!v2 || !v2.texts || !v2.texts.length) return null;
  const combined = v2.texts.filter((t) => !/^<@!?\d+>$/.test(t)).join('\n');
  if (!combined.trim()) return null;
  const cls = colorClassFor(v2.color);
  return <div className={`embed-card ${cls}`}><div className="ec-desc" dangerouslySetInnerHTML={{ __html: fmtDiscordMd(combined) }} /></div>;
}

// ─── Attachments (images + file links + delete) ─────────────────────────────
function isImage(att) { return att.contentType?.startsWith('image/'); }

function Attachments({ attachments, canDelete, onDelete }) {
  if (!attachments || !attachments.length) return null;
  const imgs = attachments.filter(isImage);
  const files = attachments.filter((a) => !isImage(a));

  return (
    <>
      {imgs.length > 0 && (
        <div className="msg-atts">
          {imgs.map((a, i) => (
            <div key={i} className="msg-att-wrap">
              <a href={a.url} target="_blank" rel="noopener">
                <img className="msg-att-img" src={a.url} alt={a.name} />
              </a>
              {canDelete && (
                <button className="msg-att-del" title="Удалить" onClick={() => onDelete(a.url)}>
                  <Icon.close style={{ width: 11, height: 11 }} />
                </button>
              )}
            </div>
          ))}
        </div>
      )}
      {files.length > 0 && (
        <div className="msg-files">
          {files.map((a, i) => (
            <div key={i} className="msg-file-wrap">
              <a className="msg-file" href={a.url} target="_blank" rel="noopener">
                <Icon.paperclip style={{ width: 11, height: 11 }} />
                <span>{a.name}</span>
              </a>
              {canDelete && (
                <button className="msg-file-del" title="Удалить" onClick={() => onDelete(a.url)}>
                  <Icon.close style={{ width: 11, height: 11 }} />
                </button>
              )}
            </div>
          ))}
        </div>
      )}
    </>
  );
}

// ─── Extract a readable text body from a bot embed/v2 message ───────────────
function botMessageText(m) {
  const parts = [];
  if (m.content) parts.push(m.content);
  if (m.embeds && m.embeds.length) {
    for (const e of m.embeds) {
      if (e.title) parts.push(`**${e.title}**`);
      if (e.description) parts.push(e.description);
      if (e.fields && e.fields.length) {
        for (const f of e.fields) parts.push(`**${f.name}** — ${f.value}`);
      }
    }
  }
  if (m.v2 && m.v2.texts && m.v2.texts.length) {
    m.v2.texts.filter((t) => !/^<@!?\d+>$/.test(t)).forEach((t) => parts.push(t));
  }
  return parts.join('\n\n').trim();
}

// ─── Message bubble ─────────────────────────────────────────────────────────
function Message({ m, ticket, canEdit, canDelete, onEdit, onDelete, onDeleteAtt }) {
  const [editing, setEditing] = useState(false);
  const [draft, setDraft] = useState(m.content || '');
  const taRef = useRef(null);

  useEffect(() => { setDraft(m.content || ''); }, [m.content]);
  useEffect(() => {
    if (editing && taRef.current) {
      taRef.current.focus();
      taRef.current.style.height = 'auto';
      taRef.current.style.height = taRef.current.scrollHeight + 'px';
    }
  }, [editing]);

  // Classify message
  const isWebAgent = !!m.is_web;
  const isBot = !!m.is_bot && !isWebAgent;
  const isWebhook = !!m.is_webhook;
  // Any bot message → compact "system notice" card (extracted text from content/embed/v2)
  const isSystem = isBot;
  const isOut = isWebAgent || isWebhook;
  const isDeleted = !!m.deleted;

  const authorName = isWebAgent ? (m.web_agent_name || m.author_name) : m.author_name;
  const authorAvatar = isWebAgent ? (m.web_agent_avatar || m.author_avatar) : m.author_avatar;

  const commitEdit = () => {
    const next = draft.trim();
    if (!next) { setEditing(false); setDraft(m.content || ''); return; }
    if (next !== m.content) onEdit && onEdit(next);
    setEditing(false);
  };
  const cancelEdit = () => { setEditing(false); setDraft(m.content || ''); };

  if (isSystem) {
    const sysText = botMessageText(m);
    if (!sysText) return null;
    return (
      <div className="msg-system">
        <div className="msg-system-card">
          <span className="msg-system-ic">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ width: 12, height: 12 }}>
              <path d="M17 1l4 4-4 4"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/>
              <path d="M7 23l-4-4 4-4"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>
            </svg>
          </span>
          <div className="msg-system-body">
            <div className="msg-system-title">Системное уведомление · отправлено пользователю</div>
            <div className="msg-system-text" dangerouslySetInnerHTML={{ __html: fmtDiscordMd(sysText) }} />
            <div className="msg-system-meta">
              {authorName && <span>@{authorName}</span>}
              <span>{fmtTime(m.created_at)}</span>
            </div>
          </div>
        </div>
      </div>
    );
  }

  // Deleted message — preserve as red audit-trail card
  if (isDeleted) {
    return (
      <div className={`msg-row ${isOut ? 'out' : ''} is-deleted`}>
        <Avatar name={authorName} src={authorAvatar} color={isOut ? '#9b6acb' : '#5a6b86'} />
        <div className="msg-col">
          <div className="msg-deleted">
            <div className="msg-deleted-head">
              <Icon.trash style={{ width: 11, height: 11 }} />
              <span>Сообщение удалено</span>
            </div>
            {m.content && <div className="msg-deleted-original" dangerouslySetInnerHTML={{ __html: fmtDiscordMd(m.content) }} />}
            <Attachments attachments={m.attachments} canDelete={false} onDelete={() => {}} />
          </div>
          <div className="msg-meta">
            <span className="msg-meta-name">{authorName || '—'}</span>
            <span className="msg-meta-time">{fmtTime(m.created_at)}</span>
            <span className="msg-deleted-tag">в архиве</span>
          </div>
        </div>
      </div>
    );
  }

  const hasActions = canEdit || canDelete;
  return (
    <div className={`msg-row ${isOut ? 'out' : ''} ${hasActions ? 'has-actions' : ''} ${editing ? 'is-editing' : ''}`}>
      <Avatar name={authorName} src={authorAvatar} color={isOut ? '#9b6acb' : '#5a6b86'} />
      <div className="msg-col">
        {editing ? (
          <div className="msg-edit-box">
            <textarea
              ref={taRef}
              className="msg-edit-area"
              value={draft}
              onChange={(e) => {
                setDraft(e.target.value);
                e.target.style.height = 'auto';
                e.target.style.height = e.target.scrollHeight + 'px';
              }}
              onKeyDown={(e) => {
                if (e.key === 'Escape') { e.preventDefault(); cancelEdit(); }
                if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); commitEdit(); }
              }} />
            <div className="msg-edit-actions">
              <span className="msg-edit-hint">Esc — отмена · Ctrl+Enter — сохранить</span>
              <button className="msg-edit-btn ghost" onClick={cancelEdit}>Отмена</button>
              <button className="msg-edit-btn primary" onClick={commitEdit} disabled={!draft.trim() || draft === m.content}>Сохранить</button>
            </div>
          </div>
        ) : (
          <div className="msg-bubble-wrap">
            {(m.content || (m.embeds && m.embeds.length > 0) || (m.v2)) && (
              <div className="msg-bubble">
                {m.content && <div className="msg-content" dangerouslySetInnerHTML={{ __html: fmtDiscordMd(m.content) }} />}
                {m.v2 && <V2Card v2={m.v2} />}
                {m.embeds && m.embeds.map((e, i) => <EmbedCard key={i} embed={e} />)}
              </div>
            )}
            <Attachments
              attachments={m.attachments}
              canDelete={canDelete}
              onDelete={(url) => onDeleteAtt && onDeleteAtt(m.id, url)} />
            {hasActions && (
              <div className="msg-actions">
                {canEdit && (
                  <button className="msg-action" title="Редактировать" onClick={() => setEditing(true)}>
                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ width: 13, height: 13 }}>
                      <path d="M12 20h9"/>
                      <path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
                    </svg>
                  </button>
                )}
                {canDelete && (
                  <button className="msg-action danger" title="Удалить" onClick={() => onDelete && onDelete(m.id)}>
                    <Icon.trash style={{ width: 13, height: 13 }} />
                  </button>
                )}
              </div>
            )}
          </div>
        )}
        <div className="msg-meta">
          <span className="msg-meta-name">{authorName || '—'}</span>
          <span className="msg-meta-time">{fmtTime(m.created_at)}</span>
          {m.edited ? <span className="msg-edit">(изм.)</span> : null}
          {(isWebAgent || isWebhook) && <span className="msg-tag-web">панель</span>}
        </div>
      </div>
    </div>
  );
}

// ─── System info strip (top of chat) ────────────────────────────────────────
function SysInfoCard({ ticket, lockedByOther, currentUserId }) {
  if (!ticket) return null;
  const category = ticket.category_label || CAT_LABELS[ticket.category] || 'Тикет';
  const worker = ticket.agent_name;
  const isClosed = ticket.status === 'closed';
  let status, statusTone;
  if (isClosed) { status = 'Закрыт'; statusTone = 'muted'; }
  else if (lockedByOther) { status = 'В работе у другого'; statusTone = 'warn'; }
  else if (worker) { status = `В работе · @${worker}`; statusTone = 'on'; }
  else { status = 'Ожидает агента'; statusTone = 'muted'; }

  return (
    <div className={`sys-strip ${lockedByOther ? 'locked' : ''}`}>
      <div className="sys-strip-row">
        <div className="sys-strip-left">
          <span className="sys-strip-id">#{padId(ticket.id)}</span>
          <span className="sys-strip-sep" />
          <span className="sys-strip-cat">{category}</span>
          <span className="sys-strip-src src-discord">
            <span className="sys-strip-src-dot" />
            Discord
          </span>
        </div>
        <div className="sys-strip-right">
          <span className={`sys-strip-status sys-strip-status-${statusTone}`}>
            <span className="sys-strip-pulse" />
            {status}
          </span>
        </div>
      </div>
      <div className="sys-strip-meta">
        <span className="sys-strip-meta-item">
          <Icon.users style={{ width: 11, height: 11 }} />
          <span className="sys-strip-meta-k">Пользователь</span>
          <span className="sys-strip-meta-v">@{ticket.username || '—'}</span>
        </span>
        <span className="sys-strip-meta-sep" />
        <span className="sys-strip-meta-item">
          <Icon.doc style={{ width: 11, height: 11 }} />
          <span className="sys-strip-meta-k">Создан</span>
          <span className="sys-strip-meta-v sys-strip-mono">{fmtDate(ticket.created_at)}</span>
        </span>
        {ticket.rating && (
          <>
            <span className="sys-strip-meta-sep" />
            <span className="sys-strip-meta-item">
              <Icon.starF style={{ width: 11, height: 11, color: 'var(--orange)' }} />
              <span className="sys-strip-meta-k">Оценка</span>
              <span className="sys-strip-meta-v">{ticket.rating}/5</span>
            </span>
          </>
        )}
      </div>
      {lockedByOther && (
        <div className="sys-strip-warn">
          <Icon.lock style={{ width: 12, height: 12 }} />
          <span>Просмотр доступен, действия — после передачи от <b>@{worker}</b>.</span>
        </div>
      )}
    </div>
  );
}

// ─── Quick reply popover ────────────────────────────────────────────────────
function QuickReplyPopover({ groups, onPick, onClose }) {
  const [q, setQ] = useState('');
  const ref = useRef(null);

  useEffect(() => {
    const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); };
    const onEsc = (e) => { if (e.key === 'Escape') onClose(); };
    document.addEventListener('mousedown', onDoc);
    document.addEventListener('keydown', onEsc);
    return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onEsc); };
  }, [onClose]);

  const ql = q.trim().toLowerCase();
  const filtered = groups.map((g) => ({
    ...g,
    items: g.items.filter((it) => !ql || it.title.toLowerCase().includes(ql) || it.text.toLowerCase().includes(ql)),
  })).filter((g) => g.items.length > 0);

  return (
    <div className="qr-popover" ref={ref}>
      <div className="qr-pop-search">
        <Icon.search style={{ width: 12, height: 12, color: 'var(--text-3)' }} />
        <input autoFocus placeholder="Поиск шаблона…" value={q} onChange={(e) => setQ(e.target.value)} />
      </div>
      <div className="qr-pop-list">
        {filtered.length === 0 && <div className="qr-pop-empty">Ничего не найдено</div>}
        {filtered.map((g) => (
          <div key={g.id} className="qr-pop-group">
            <div className="qr-pop-group-h">{g.label}</div>
            {g.items.map((it) => {
              const Ic = Icon[it.icon] || Icon.chat;
              return (
                <button key={it.id} className="qr-pop-item" onClick={() => onPick(it)}>
                  <span className="qr-pop-ic"><Ic style={{ width: 13, height: 13 }} /></span>
                  <div className="qr-pop-meta">
                    <div className="qr-pop-title">{it.title}</div>
                    <div className="qr-pop-text">{it.text}</div>
                  </div>
                </button>
              );
            })}
          </div>
        ))}
      </div>
    </div>
  );
}

// ─── Pending file preview row (above input) ─────────────────────────────────
function PendingFiles({ files, onRemove }) {
  if (!files.length) return null;
  return (
    <div className="ra-files">
      {files.map((f, i) => (
        <div key={i} className="ra-file">
          {f.type?.startsWith('image/')
            ? <img className="ra-file-img" src={URL.createObjectURL(f)} alt="" />
            : <Icon.paperclip style={{ width: 12, height: 12 }} />}
          <span>{f.name}</span>
          <button className="ra-file-rm" onClick={() => onRemove(i)} title="Удалить">×</button>
        </div>
      ))}
    </div>
  );
}

// ─── Chat ───────────────────────────────────────────────────────────────────
function Chat({ ticket, messages, currentUser, taken, lockedByOther, isClosed,
                quickReplies, draft, onDraftChange, pendingFiles, onAddFiles, onRemoveFile,
                onSend, sending,
                onEditMessage, onDeleteMessage, onDeleteAttachment }) {
  const scrollRef = useRef(null);
  const inputRef = useRef(null);
  const fileInputRef = useRef(null);
  const [qrOpen, setQrOpen] = useState(false);

  useEffect(() => {
    if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
  }, [ticket?.id, messages?.length]);

  useEffect(() => {
    if (draft && inputRef.current && document.activeElement !== inputRef.current) {
      inputRef.current.focus();
      try {
        const len = inputRef.current.value.length;
        inputRef.current.setSelectionRange(len, len);
      } catch (_) {}
    }
  }, [draft]);

  const onPickFiles = (e) => {
    const list = Array.from(e.target.files || []);
    if (list.length) onAddFiles(list);
    e.target.value = '';
  };

  const canSend = !!(draft.trim() || pendingFiles.length) && !sending && taken && !isClosed;

  // Filter out the very first bot message (ticket intro embed) — user request
  const visibleMessages = useMemo(() => {
    if (!messages || !messages.length) return [];
    const arr = [...messages];
    const firstBotIdx = arr.findIndex((m) => m.is_bot && !m.is_web);
    if (firstBotIdx === 0) arr.shift();
    return arr;
  }, [messages]);

  return (
    <div className="chat-wrap">
      <div className="chat-scroll" ref={scrollRef}>
        <SysInfoCard ticket={ticket} lockedByOther={lockedByOther} currentUserId={currentUser?.id} />
        {visibleMessages.map((m) => (
          <Message
            key={m.id}
            m={m}
            ticket={ticket}
            canEdit={taken && !m.deleted && !!m.is_web && m.web_agent_id === currentUser?.id}
            canDelete={taken && !m.deleted && (!!m.is_web || !!m.is_webhook)}
            onEdit={(text) => onEditMessage(m.id, text)}
            onDelete={onDeleteMessage}
            onDeleteAtt={onDeleteAttachment} />
        ))}
        {messages.length === 0 && (
          <div className="chat-empty">
            <div className="big">TIME RUST</div>
            <div>Нет сообщений</div>
          </div>
        )}
      </div>

      {isClosed ? (
        <div className="chat-take-row">
          <button className="chat-take-btn locked" disabled>
            <Icon.lock style={{ width: 13, height: 13 }} />
            <span>Тикет закрыт — переписка завершена</span>
          </button>
        </div>
      ) : lockedByOther ? (
        <div className="chat-take-row">
          <button className="chat-take-btn locked" disabled>
            <Icon.lock style={{ width: 13, height: 13 }} />
            <span>Тикет ведёт @{ticket.agent_name} — ответ недоступен</span>
          </button>
        </div>
      ) : !taken ? (
        <div className="chat-take-row">
          <button className="chat-take-btn" disabled>
            <Icon.lock style={{ width: 13, height: 13 }} />
            <span>Возьмите тикет в работу, чтобы отвечать</span>
          </button>
        </div>
      ) : (
        <div className="chat-input-wrap">
          {qrOpen && (
            <QuickReplyPopover
              groups={quickReplies}
              onClose={() => setQrOpen(false)}
              onPick={(it) => {
                const mention = ticket.user_id ? `<@${ticket.user_id}>` : `@${ticket.username || 'user'}`;
                const text = it.text.replace(/\{mention\}/g, mention);
                onDraftChange(draft ? draft + '\n' + text : text);
                setQrOpen(false);
                setTimeout(() => inputRef.current?.focus(), 50);
              }} />
          )}
          <PendingFiles files={pendingFiles} onRemove={onRemoveFile} />
          <div className="chat-input">
            <button className={`icon-btn qr-trigger ${qrOpen ? 'active' : ''}`} title="Быстрые ответы" onClick={() => setQrOpen((v) => !v)}>
              <Icon.bolt style={{ width: 14, height: 14 }} />
            </button>
            <button className="icon-btn" title="Тегнуть пользователя" onClick={() => {
              const tag = ticket.user_id ? `<@${ticket.user_id}>` : `@${ticket.username || 'user'}`;
              const next = !draft ? tag + ' '
                : (draft.trimEnd().endsWith(tag) ? draft : (draft.endsWith(' ') ? draft : draft + ' ') + tag + ' ');
              onDraftChange(next);
              setTimeout(() => inputRef.current?.focus(), 50);
            }}>
              <Icon.at style={{ width: 16, height: 16 }} />
            </button>
            <button className="icon-btn" title="Прикрепить" onClick={() => fileInputRef.current?.click()}>
              <Icon.paperclip style={{ width: 15, height: 15 }} />
            </button>
            <input
              ref={fileInputRef}
              type="file"
              multiple
              style={{ display: 'none' }}
              onChange={onPickFiles} />
            <span className="chat-input-sep" />
            <input
              ref={inputRef}
              placeholder="Написать ответ…"
              value={draft}
              onChange={(e) => onDraftChange(e.target.value)}
              onKeyDown={(e) => {
                if (e.key === 'Enter' && !e.shiftKey) {
                  e.preventDefault();
                  if (canSend) onSend();
                }
              }} />
            <button
              className={`icon-btn chat-send ${canSend ? 'active' : ''}`}
              title="Отправить"
              disabled={!canSend}
              onClick={onSend}>
              {sending
                ? <span className="chat-send-spin" />
                : <Icon.send style={{ width: 16, height: 16 }} />}
            </button>
          </div>
        </div>
      )}
    </div>
  );
}

// ─── Modal + ConfirmModal ───────────────────────────────────────────────────
function Modal({ open, onClose, children, width = 460 }) {
  useEffect(() => {
    if (!open) return;
    const onKey = (e) => { if (e.key === 'Escape') onClose?.(); };
    document.addEventListener('keydown', onKey);
    const prev = document.body.style.overflow;
    document.body.style.overflow = 'hidden';
    return () => { document.removeEventListener('keydown', onKey); document.body.style.overflow = prev; };
  }, [open, onClose]);
  if (!open) return null;
  return (
    <div className="modal-backdrop" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose?.(); }}>
      <div className="modal-card" style={{ width }} role="dialog" aria-modal="true">{children}</div>
    </div>
  );
}

function ConfirmModal({ open, onClose, onConfirm, title, description, details,
                       confirmLabel = 'Подтвердить', cancelLabel = 'Отмена', tone = 'danger' }) {
  const toneIcon = {
    danger: <Icon.trash />,
    warn: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 1l4 4-4 4"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><path d="M7 23l-4-4 4-4"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>,
    neutral: <Icon.ask />,
  };
  return (
    <Modal open={open} onClose={onClose}>
      <div className={`confirm-modal tone-${tone}`}>
        <div className="confirm-head">
          <span className={`confirm-icon tone-${tone}`}>{toneIcon[tone] || toneIcon.danger}</span>
          <div className="confirm-title-wrap">
            <div className="confirm-title">{title}</div>
            {description && <div className="confirm-desc">{description}</div>}
          </div>
          <button className="confirm-x" onClick={onClose} aria-label="Закрыть"><Icon.close style={{ width: 12, height: 12 }} /></button>
        </div>
        {details && <div className="confirm-details">{details}</div>}
        <div className="confirm-foot">
          <button className="confirm-btn ghost" onClick={onClose}>{cancelLabel}</button>
          <button className={`confirm-btn primary tone-${tone}`} onClick={onConfirm}>{confirmLabel}</button>
        </div>
      </div>
    </Modal>
  );
}

// ─── Info panel ─────────────────────────────────────────────────────────────
function InfoRow({ k, v, em }) {
  return (
    <div className="info-row">
      <span className="k">{k}</span>
      <span className={`v ${em ? 'v-em' : ''}`}>{v ?? '—'}</span>
    </div>
  );
}

function parseComments(note) {
  if (!note) return [];
  try {
    const parsed = JSON.parse(note);
    if (Array.isArray(parsed)) return parsed;
  } catch (_) {}
  // Legacy single-note string
  return [{ id: 'legacy', author_name: '', author_avatar: '', author_id: '', time: '', text: String(note) }];
}

function fmtCommentTime(iso) {
  if (!iso) return '';
  const d = new Date(iso);
  const today = new Date();
  const sameDay = d.toDateString() === today.toDateString();
  const yesterday = new Date(); yesterday.setDate(today.getDate() - 1);
  const isYesterday = d.toDateString() === yesterday.toDateString();
  const hh = String(d.getHours()).padStart(2, '0');
  const mm = String(d.getMinutes()).padStart(2, '0');
  if (sameDay) return `сегодня · ${hh}:${mm}`;
  if (isYesterday) return `вчера · ${hh}:${mm}`;
  return `${String(d.getDate()).padStart(2,'0')}.${String(d.getMonth()+1).padStart(2,'0')} · ${hh}:${mm}`;
}

function InfoPanel({ ticket, currentUser, taken, lockedByOther, isClosed, agents = [],
                    note, onSaveNote, closing = false,
                    onTake, onTransfer, onClose, onTagPlayer, onCollapse }) {
  const [confirmClose, setConfirmClose] = useState(false);
  const [confirmTransfer, setConfirmTransfer] = useState(false);
  const [comments, setComments] = useState(() => parseComments(note));
  const [draft, setDraft] = useState('');

  useEffect(() => { setComments(parseComments(note)); setDraft(''); }, [ticket?.id, note]);

  const persistComments = (next) => {
    setComments(next);
    onSaveNote(JSON.stringify(next));
  };

  const addComment = () => {
    const text = draft.trim();
    if (!text || lockedByOther) return;
    const entry = {
      id: `c${Date.now()}_${Math.random().toString(36).slice(2,6)}`,
      author_id: currentUser?.id || '',
      author_name: currentUser?.global_name || currentUser?.username || 'агент',
      author_avatar: currentUser?.avatar_url || '',
      time: new Date().toISOString(),
      text,
    };
    persistComments([...comments, entry]);
    setDraft('');
  };

  const removeComment = (id) => {
    persistComments(comments.filter((c) => c.id !== id));
  };

  if (!ticket) return null;
  const ticketNum = `#${padId(ticket.id)}`;
  const catLabel = ticket.category_label || CAT_LABELS[ticket.category] || '—';

  return (
    <aside className="col col-info">
      <div className="info">
        <div className="info-user">
          <Avatar name={ticket.username} src={ticket.user_avatar} color="#5a6b86" size="lg" />
          <div className="name">{ticket.username || '—'}</div>
          <button className="collapse" onClick={onCollapse} title="Свернуть">
            <Icon.chevR style={{ width: 11, height: 11 }} />
            <span>Свернуть</span>
          </button>
        </div>

        <div className="info-block">
          <div className="info-h">Информация</div>
          <InfoRow k="Тикет ID" v={ticketNum} em />
          <InfoRow k="Категория" v={catLabel} />
          <InfoRow k="User ID" v={ticket.user_id} />
          {ticket.form_data?.steamid && <InfoRow k="SteamID64" v={ticket.form_data.steamid} />}
          {ticket.form_data?.age && <InfoRow k="Возраст" v={ticket.form_data.age} />}
          {ticket.form_data?.steam_link && <InfoRow k="Steam профиль" v={ticket.form_data.steam_link} />}
          <InfoRow k="Создан" v={fmtDate(ticket.created_at)} />
          {ticket.closed_at && <InfoRow k="Закрыт" v={fmtDate(ticket.closed_at)} />}
          <InfoRow k="Агент" v={ticket.agent_name || '—'} em />
          {ticket.rating && <InfoRow k="Оценка" v={`${ticket.rating}/5`} em />}
        </div>

        <div className="info-block">
          <div className="info-h">
            Комментарии к тикету
            {comments.length > 0 && <span className="info-h-count">{comments.length}</span>}
          </div>
          {comments.length > 0 && (
            <div className="comments-list">
              {comments.map((c) => (
                <div className="cmt" key={c.id}>
                  {c.author_avatar
                    ? <img className="avatar" src={c.author_avatar} alt="" style={{ borderRadius: '50%', objectFit: 'cover' }} />
                    : <span className="avatar" style={{ background: '#3a3d47' }}>{(c.author_name || '?')[0].toUpperCase()}</span>}
                  <div className="cmt-body">
                    <div className="cmt-head">
                      <span className="cmt-author">@{c.author_name || 'агент'}</span>
                      <span className="cmt-ts">{fmtCommentTime(c.time)}</span>
                      {!lockedByOther && (c.author_id === currentUser?.id || !c.author_id) && (
                        <button className="cmt-del" title="Удалить" onClick={() => removeComment(c.id)}>
                          <Icon.close style={{ width: 11, height: 11 }} />
                        </button>
                      )}
                    </div>
                    <div className="cmt-text">{c.text}</div>
                  </div>
                </div>
              ))}
            </div>
          )}
          <textarea
            className="comment-area"
            placeholder={lockedByOther ? 'Доступно только текущему агенту…' : 'Оставьте комментарий другим агентам…'}
            disabled={lockedByOther}
            value={draft}
            onChange={(e) => setDraft(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
                e.preventDefault();
                addComment();
              }
            }} />
          <div className="comment-foot">
            <button
              className="comment-add-btn"
              disabled={lockedByOther || !draft.trim()}
              onClick={addComment}>
              <Icon.plus style={{ width: 11, height: 11 }} />
              Добавить
            </button>
            <span className="comment-hint">Ctrl + Enter</span>
          </div>
        </div>

        <div className="info-block">
          <div className="info-h">Действия с тикетом</div>
          <div className="action-btn-stack">
            {isClosed ? (
              <button className="action-btn ghost" disabled>
                <Icon.lock /> Тикет закрыт
              </button>
            ) : lockedByOther ? (
              <button className="action-btn ghost" disabled>
                <Icon.lock /> Ведёт @{ticket.agent_name}
              </button>
            ) : !taken ? (
              <>
                <button className="action-btn take" onClick={onTake}>
                  <Icon.flame /> Взять тикет
                </button>
              </>
            ) : (
              <>
                <button className="action-btn transfer" onClick={() => setConfirmTransfer(true)}>
                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
                    <path d="M17 1l4 4-4 4"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/>
                    <path d="M7 23l-4-4 4-4"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>
                  </svg>
                  Передать в очередь
                </button>
                <button className="action-btn tag" onClick={onTagPlayer}>
                  <Icon.at /> Тегнуть игрока
                </button>
                <button className="action-btn close" onClick={() => setConfirmClose(true)} disabled={closing || isClosed}>
                  <Icon.close /> {closing ? 'Закрытие…' : 'Закрыть тикет'}
                </button>
              </>
            )}
          </div>
        </div>
      </div>

      <ConfirmModal
        open={confirmClose}
        onClose={() => setConfirmClose(false)}
        onConfirm={() => { setConfirmClose(false); onClose(); }}
        tone="danger"
        title="Закрыть тикет?"
        description={`Тикет ${ticketNum} будет переведён в архив. Пользователю отправится сообщение и оценка качества.`}
        details={
          <div className="confirm-meta">
            <div className="confirm-meta-row"><span className="confirm-meta-k">Тикет</span><span className="confirm-meta-v mono">{ticketNum}</span></div>
            <div className="confirm-meta-row"><span className="confirm-meta-k">Категория</span><span className="confirm-meta-v">{catLabel}</span></div>
            <div className="confirm-meta-row"><span className="confirm-meta-k">Пользователь</span><span className="confirm-meta-v">@{ticket.username || '—'}</span></div>
            <div className="confirm-meta-row"><span className="confirm-meta-k">Агент</span><span className="confirm-meta-v">@{ticket.agent_name || '—'}</span></div>
          </div>
        }
        confirmLabel="Закрыть тикет" />

      <ConfirmModal
        open={confirmTransfer}
        onClose={() => setConfirmTransfer(false)}
        onConfirm={() => { setConfirmTransfer(false); onTransfer(); }}
        tone="warn"
        title="Передать тикет в общую очередь?"
        description={`Тикет ${ticketNum} станет свободным, и его сможет взять любой агент.`}
        details={
          <div className="confirm-meta">
            <div className="confirm-meta-row"><span className="confirm-meta-k">Тикет</span><span className="confirm-meta-v mono">{ticketNum}</span></div>
            <div className="confirm-meta-row"><span className="confirm-meta-k">Сейчас ведёт</span><span className="confirm-meta-v">@{ticket.agent_name || '—'}</span></div>
            <div className="confirm-meta-row"><span className="confirm-meta-k">После</span><span className="confirm-meta-v free-pill"><span className="free-dot" /> Свободен · в очереди</span></div>
          </div>
        }
        confirmLabel="Передать" />
    </aside>
  );
}

// ─── Stats: helpers and components ──────────────────────────────────────────
const MONTHS_GEN = ['января','февраля','марта','апреля','мая','июня','июля','августа','сентября','октября','ноября','декабря'];
const MONTHS_NOM = ['январь','февраль','март','апрель','май','июнь','июль','август','сентябрь','октябрь','ноябрь','декабрь'];
const WEEKDAYS_FULL = ['воскресенье','понедельник','вторник','среда','четверг','пятница','суббота'];
const WEEKDAYS_SHORT = ['пн','вт','ср','чт','пт','сб','вс'];
const pad2 = (n) => (n < 10 ? '0' + n : '' + n);

function plural(n, forms) {
  const a = Math.abs(n) % 100;
  const b = a % 10;
  if (a > 10 && a < 20) return forms[2];
  if (b > 1 && b < 5) return forms[1];
  if (b === 1) return forms[0];
  return forms[2];
}
function weekdayMon(d) { return (d.getDay() + 6) % 7; }
function parseInputDate(s) {
  if (!s) return null;
  const [y, m, d] = s.split('-').map(Number);
  if (!y || !m || !d) return null;
  return new Date(y, m - 1, d);
}
function formatInputDate(d) {
  if (!d) return '';
  return `${pad2(d.getDate())} . ${pad2(d.getMonth() + 1)} . ${d.getFullYear()}`;
}
function formatChartTime(date, range) {
  const d = date.getDate();
  const m = MONTHS_GEN[date.getMonth()];
  const wd = WEEKDAYS_FULL[date.getDay()];
  if (range === '1D') return `${wd}, ${d} ${m}, ${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
  if (range === '7D') return `${wd}, ${d} ${m}, ${pad2(date.getHours())}:00`;
  if (range === '30D') return `${d} ${m}, ${wd}`;
  return `${d} ${m} ${date.getFullYear()}`;
}

function smoothPath(pts, tension = 0.4) {
  if (pts.length < 2) return '';
  let d = `M${pts[0][0]},${pts[0][1]}`;
  for (let i = 0; i < pts.length - 1; i++) {
    const p0 = pts[i - 1] || pts[i];
    const p1 = pts[i];
    const p2 = pts[i + 1];
    const p3 = pts[i + 2] || p2;
    const cp1x = p1[0] + (p2[0] - p0[0]) * tension * 0.5;
    const cp1y = p1[1] + (p2[1] - p0[1]) * tension * 0.5;
    const cp2x = p2[0] - (p3[0] - p1[0]) * tension * 0.5;
    const cp2y = p2[1] - (p3[1] - p1[1]) * tension * 0.5;
    d += ` C${cp1x.toFixed(1)},${cp1y.toFixed(1)} ${cp2x.toFixed(1)},${cp2y.toFixed(1)} ${p2[0]},${p2[1]}`;
  }
  return d;
}

const RANGE_MS = {
  '1D': 24 * 60 * 60 * 1000,
  '7D': 7 * 24 * 60 * 60 * 1000,
  '30D': 30 * 24 * 60 * 60 * 1000,
  'ALL': 365 * 24 * 60 * 60 * 1000,
};
const RANGE_BUCKETS = { '1D': 24, '7D': 56, '30D': 60, 'ALL': 90 };

function bucketTickets(tickets, range, endDate, moderator) {
  const N = RANGE_BUCKETS[range];
  const span = RANGE_MS[range];
  const end = endDate ? endDate.getTime() : Date.now();
  const start = end - span;
  const arr = new Array(N).fill(0);
  for (const t of tickets || []) {
    if (!t.created_at) continue;
    if (moderator && t.agent_id !== moderator) continue;
    const ts = new Date(t.created_at).getTime();
    if (ts < start || ts > end) continue;
    const idx = Math.min(N - 1, Math.floor(((ts - start) / span) * N));
    arr[idx]++;
  }
  return arr;
}
function dateForIdx(range, i, N, endDate) {
  const span = RANGE_MS[range];
  const end = endDate ? endDate.getTime() : Date.now();
  return new Date(end - (1 - i / Math.max(1, N - 1)) * span);
}

function useClickOutside(ref, onOut) {
  useEffect(() => {
    const h = (e) => { if (ref.current && !ref.current.contains(e.target)) onOut(); };
    document.addEventListener('mousedown', h);
    return () => document.removeEventListener('mousedown', h);
  }, [onOut]);
}

function MiniSpark({ data, active = false }) {
  const w = 200, h = 28, pad = 2;
  const N = Math.max(4, data.length);
  const buckets = [];
  // resample to 16 points
  const target = 16;
  for (let i = 0; i < target; i++) {
    const a = Math.floor((i / target) * N);
    const b = Math.max(a + 1, Math.floor(((i + 1) / target) * N));
    let s = 0;
    for (let j = a; j < b && j < N; j++) s += data[j] || 0;
    buckets.push(s);
  }
  const max = Math.max(1, ...buckets);
  const pts = buckets.map((v, i) => {
    const x = pad + (i / (buckets.length - 1)) * (w - pad * 2);
    const y = h - pad - (v / max) * (h - pad * 2);
    return [x, y];
  });
  const stroke = active ? '#4ed47a' : 'rgba(255,255,255,0.18)';
  return (
    <svg className="mini-spark" viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none">
      {active && (
        <path d={smoothPath(pts) + ` L${pts[pts.length - 1][0]},${h} L${pts[0][0]},${h} Z`} fill="#4ed47a" opacity="0.08" />
      )}
      <path d={smoothPath(pts)} fill="none" stroke={stroke} strokeWidth="1.5" strokeLinecap="round" />
    </svg>
  );
}

function PeriodCard({ label, value, active, onClick, data }) {
  return (
    <button type="button" className={`period-card ${active ? 'on' : ''}`} onClick={onClick}>
      <div className="period-lbl">{label}</div>
      <div className="period-val">{value}</div>
      <MiniSpark active={active} data={data} />
    </button>
  );
}

function StatsSelect({ label, value, placeholder, options, onChange }) {
  const [open, setOpen] = useState(false);
  const ref = useRef(null);
  useClickOutside(ref, () => setOpen(false));
  const selected = options.find((o) => o.value === value);
  return (
    <div className={`period-card input-card ${open ? 'is-open' : ''}`} ref={ref}>
      <div className="period-lbl">{label}</div>
      <button type="button" className="period-input pi-trigger" onClick={() => setOpen((o) => !o)}>
        <span className={selected ? 'pi-value' : 'pi-placeholder'}>{selected ? selected.label : placeholder}</span>
        <Icon.chevR className={`pi-chev ${open ? 'is-open' : ''}`} style={{ width: 11, height: 11 }} />
      </button>
      {open && (
        <div className="pi-pop pi-pop-select">
          <button type="button" className={`pi-opt ${!value ? 'on' : ''}`} onClick={() => { onChange(''); setOpen(false); }}>
            {placeholder}
          </button>
          {options.map((o) => (
            <button key={o.value} type="button" className={`pi-opt ${value === o.value ? 'on' : ''}`}
              onClick={() => { onChange(o.value); setOpen(false); }}>
              {o.label}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

function StatsDatePicker({ label, value, onChange }) {
  const [open, setOpen] = useState(false);
  const initial = parseInputDate(value) || new Date();
  const [view, setView] = useState(new Date(initial.getFullYear(), initial.getMonth(), 1));
  const ref = useRef(null);
  useClickOutside(ref, () => setOpen(false));
  const first = new Date(view.getFullYear(), view.getMonth(), 1);
  const startOffset = weekdayMon(first);
  const startGrid = new Date(first);
  startGrid.setDate(first.getDate() - startOffset);
  const cells = [];
  for (let i = 0; i < 42; i++) {
    const d = new Date(startGrid); d.setDate(startGrid.getDate() + i); cells.push(d);
  }
  const isSameDay = (a, b) => a && b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
  const selected = parseInputDate(value);
  const shiftMonth = (delta) => { const v = new Date(view); v.setMonth(view.getMonth() + delta); setView(v); };
  const pickDay = (d) => { onChange(`${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`); setOpen(false); };
  return (
    <div className={`period-card input-card ${open ? 'is-open' : ''}`} ref={ref}>
      <div className="period-lbl">{label}</div>
      <button type="button" className="period-input pi-trigger" onClick={() => setOpen((o) => !o)}>
        <span className={selected ? 'pi-value' : 'pi-placeholder'}>
          {selected ? formatInputDate(selected) : 'дд . мм . гггг'}
        </span>
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" className="pi-chev" style={{ width: 14, height: 14, opacity: 0.6 }}>
          <rect x="3" y="5" width="18" height="16" rx="2" /><line x1="3" y1="10" x2="21" y2="10" />
          <line x1="8" y1="3" x2="8" y2="7" /><line x1="16" y1="3" x2="16" y2="7" />
        </svg>
      </button>
      {open && (
        <div className="pi-pop pi-pop-cal" onMouseDown={(e) => e.stopPropagation()}>
          <div className="cal-head">
            <button type="button" className="cal-nav" onClick={() => shiftMonth(-1)}><Icon.chevL style={{ width: 14, height: 14 }} /></button>
            <span className="cal-title">{MONTHS_NOM[view.getMonth()]} {view.getFullYear()} г.</span>
            <button type="button" className="cal-nav" onClick={() => shiftMonth(1)}><Icon.chevR style={{ width: 14, height: 14 }} /></button>
          </div>
          <div className="cal-wk">
            {WEEKDAYS_SHORT.map((w, i) => <div key={w} className={`cal-wk-d ${i >= 5 ? 'wknd' : ''}`}>{w}</div>)}
          </div>
          <div className="cal-grid">
            {cells.map((d, i) => {
              const otherMonth = d.getMonth() !== view.getMonth();
              const isWknd = weekdayMon(d) >= 5;
              const isSel = isSameDay(d, selected);
              return (
                <button key={i} type="button"
                  className={`cal-d ${otherMonth ? 'om' : ''} ${isWknd ? 'wknd' : ''} ${isSel ? 'sel' : ''}`}
                  onClick={() => pickDay(d)}>{d.getDate()}</button>
              );
            })}
          </div>
          <div className="cal-foot">
            <button type="button" className="cal-clear" onClick={() => { onChange(''); setOpen(false); }}>Очистить</button>
          </div>
        </div>
      )}
    </div>
  );
}

function BigChart({ tickets, range, moderator, date }) {
  const endDate = parseInputDate(date) || new Date();
  const data = useMemo(() => bucketTickets(tickets, range, endDate, moderator), [tickets, range, moderator, date]);
  const N = data.length;
  const peakVal = Math.max(1, ...data);
  const peak = Math.max(0, ...data);

  const w = 1400, h = 260;
  const padTop = 18;
  const innerH = h - padTop;
  const pts = data.map((d, i) => {
    const x = (i / Math.max(1, data.length - 1)) * w;
    const y = padTop + innerH - (d / peakVal) * innerH;
    return [x, y];
  });
  const path = smoothPath(pts, 0.35);
  const fill = pts.length ? path + ` L${pts[pts.length - 1][0]},${h} L${pts[0][0]},${h} Z` : '';
  const defaultIdx = Math.round((N - 1) * 0.9);
  const [hoverIdx, setHoverIdx] = useState(null);
  const showHover = hoverIdx !== null;
  const activeIdx = showHover ? hoverIdx : defaultIdx;
  const cross = pts[activeIdx] || [0, h];
  const value = Math.max(0, Math.round(data[activeIdx] || 0));
  const timeLabel = formatChartTime(dateForIdx(range, activeIdx, N, endDate), range);
  const valueLabel = `${value} ${plural(value, ['тикет', 'тикета', 'тикетов'])}`;
  const ttLeftPct = (cross[0] / w) * 100;
  const ttSide = ttLeftPct > 80 ? 'left' : 'right';
  const svgRef = useRef(null);
  const onMove = (e) => {
    const rect = svgRef.current.getBoundingClientRect();
    if (rect.width === 0) return;
    const tFrac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
    setHoverIdx(Math.round(tFrac * (N - 1)));
  };
  return (
    <div className="bc" onMouseLeave={() => setHoverIdx(null)}>
      <svg ref={svgRef} className="bc-svg" viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" onMouseMove={onMove}>
        <defs>
          <linearGradient id="bc-fill" x1="0" x2="0" y1="0" y2="1">
            <stop offset="0%" stopColor="#4ed47a" stopOpacity="0.55" />
            <stop offset="55%" stopColor="#4ed47a" stopOpacity="0.18" />
            <stop offset="100%" stopColor="#4ed47a" stopOpacity="0.02" />
          </linearGradient>
        </defs>
        {fill && <path d={fill} fill="url(#bc-fill)" />}
        {path && <path d={path} fill="none" stroke="#5fdc8a" strokeWidth="1.6" strokeLinejoin="round" strokeLinecap="round" />}
        {showHover && cross && (
          <>
            <line x1={cross[0]} x2={cross[0]} y1={0} y2={h} stroke="#4ed47a" strokeOpacity="0.7" strokeDasharray="3 4" strokeWidth="1" />
            <circle cx={cross[0]} cy={cross[1]} r="3.5" fill="#5fdc8a" stroke="#0d0d11" strokeWidth="1.5" />
          </>
        )}
      </svg>
      {showHover && (
        <>
          <div className={`bc-tt bc-tt-${ttSide}`}
            style={{ left: `${ttLeftPct}%`, top: `calc(${(cross[1] / h) * 100}% - 14px)` }}>
            <div className="bc-tt-val"><span className="bc-tt-num">{value}</span><span className="bc-tt-lbl">{valueLabel.split(' ')[1]}</span></div>
          </div>
          <div className="bc-time" style={{ left: `${ttLeftPct}%` }}>{timeLabel}</div>
        </>
      )}
      <div className="bc-tabs">
        <span className="bc-peak"><span className="bc-peak-num">{peak}</span> пик</span>
      </div>
    </div>
  );
}

function fmtDurationHM(ms) {
  if (!ms || !isFinite(ms) || ms < 0) return '—';
  const m = Math.round(ms / 60000);
  if (m < 60) return `${m}м`;
  const h = Math.floor(m / 60);
  const mm = m % 60;
  if (h < 24) return `${h}ч ${mm}м`;
  const d = Math.floor(h / 24);
  return `${d}д ${h % 24}ч`;
}

// Estimate avg agent response time: time from ticket.created_at → ticket.first_reply_at (if available),
// else fallback to time-to-close for closed tickets.
function avgResponseTime(tickets, agentId) {
  let total = 0, count = 0;
  for (const t of tickets || []) {
    if (agentId && t.agent_id !== agentId) continue;
    const from = t.created_at ? new Date(t.created_at).getTime() : 0;
    const to = t.first_reply_at ? new Date(t.first_reply_at).getTime()
             : (t.taken_at ? new Date(t.taken_at).getTime()
             : (t.closed_at ? new Date(t.closed_at).getTime() : 0));
    if (!from || !to || to < from) continue;
    total += (to - from); count++;
  }
  return count ? total / count : 0;
}

function StaffRowCard({ s, idx }) {
  const rc = [
    { bg: '#f0b945', fg: '#1a1a21' }, { bg: '#6c6f7a', fg: '#e7e8ec' },
    { bg: '#c67a3c', fg: '#1a1a21' }, { bg: '#3a3d47', fg: '#c0c2cb' },
  ][idx] || { bg: '#3a3d47', fg: '#c0c2cb' };
  const avg = Number(s.avg_rating || 0);
  const fullStars = Math.round(avg);
  const role = s.role || (s.is_admin ? 'Администратор' : 'Саппорт');
  const name = s.name || s.global_name || s.username || '—';
  return (
    <div className="staff-row-card">
      <div className="staff-rank-chip" style={{ background: rc.bg, color: rc.fg }}>#{idx + 1}</div>
      {s.avatar_url
        ? <img className="staff-avatar img" src={s.avatar_url} alt="" />
        : <div className="staff-avatar" style={{ background: '#4a4d57' }}>{name.trim().charAt(0).toUpperCase()}</div>}
      <div className="staff-meta-col">
        <div className="staff-name">{name}</div>
        <div className="staff-role">{role}</div>
        <div className="staff-stars-row">
          {[1, 2, 3, 4, 5].map((i) => (
            <Icon.starF key={i} style={{ width: 10, height: 10, color: i <= fullStars ? '#f0b945' : '#2c2f38' }} />
          ))}
        </div>
        <div className="staff-counts">
          {avg.toFixed(1)} / 5.0 · {s.rated_count || 0} оценок · {s.closed || 0} закрыто
        </div>
      </div>
    </div>
  );
}

const CAT_ICON_TONE = {
  player_report: { icon: '✓', bg: 'rgba(74,188,117,0.18)' },
  ban_appeal:    { icon: '⛖', bg: 'rgba(155,108,255,0.18)' },
  work:          { icon: '☰', bg: 'rgba(232,161,74,0.18)' },
  other:         { icon: '?', bg: 'rgba(225,91,91,0.18)' },
};

function StatsView({ stats, currentUser, tickets = [], agents = [], categories = [] }) {
  const [detailed, setDetailed] = useState(null);
  const [range, setRange] = useState('30D');
  const [date, setDate] = useState('');
  const [moderator, setModerator] = useState('');

  useEffect(() => {
    api('/api/stats/detailed').then((d) => { if (!d.__error) setDetailed(d); });
  }, []);

  // Period sparkline data (independent of moderator/date)
  const sparkAll = useMemo(() => bucketTickets(tickets, 'ALL', new Date(), ''), [tickets]);
  const spark30  = useMemo(() => bucketTickets(tickets, '30D', new Date(), ''), [tickets]);
  const spark7   = useMemo(() => bucketTickets(tickets, '7D',  new Date(), ''), [tickets]);
  const spark1   = useMemo(() => bucketTickets(tickets, '1D',  new Date(), ''), [tickets]);

  // Total counts per period from real tickets
  const countInRange = (rng) => {
    const span = RANGE_MS[rng];
    const start = Date.now() - span;
    return (tickets || []).reduce((acc, t) => {
      if (!t.created_at) return acc;
      const ts = new Date(t.created_at).getTime();
      return ts >= start ? acc + 1 : acc;
    }, 0);
  };
  const cnt = {
    ALL: countInRange('ALL'),
    '30D': countInRange('30D'),
    '7D': countInRange('7D'),
    '1D': countInRange('1D'),
  };

  // Personal stats
  const myId = currentUser?.id;
  const myTickets = (tickets || []).filter((t) => t.agent_id === myId);
  const myAvg = stats?.my_avg_rating ? Number(stats.my_avg_rating) : 0;
  const myResp = avgResponseTime(tickets, myId);

  // Category counts (from tickets data)
  const catCounts = useMemo(() => {
    const m = {};
    for (const t of tickets || []) {
      const key = t.category || 'other';
      if (!m[key]) m[key] = { total: 0, closed: 0, open: 0, label: t.category_label || CAT_LABELS[key] || key };
      m[key].total++;
      if (t.status === 'closed') m[key].closed++; else m[key].open++;
    }
    return Object.entries(m)
      .map(([key, v]) => ({ key, ...v }))
      .sort((a, b) => b.total - a.total);
  }, [tickets]);

  // Staff list
  const staffAll = useMemo(() => {
    if (detailed?.staff && Array.isArray(detailed.staff)) return detailed.staff;
    // Fallback: derive from agents + tickets if backend doesn't return detailed
    return (agents || []).map((a) => {
      const tk = (tickets || []).filter((t) => t.agent_id === a.id);
      const closed = tk.filter((t) => t.status === 'closed').length;
      const rated = tk.filter((t) => t.rating);
      const avg = rated.length ? rated.reduce((s, t) => s + Number(t.rating || 0), 0) / rated.length : 0;
      return { id: a.id, name: a.global_name || a.username, role: a.is_admin ? 'Администратор' : 'Саппорт',
               avatar_url: a.avatar_url, avg_rating: avg, rated_count: rated.length, closed };
    }).sort((a, b) => (b.avg_rating - a.avg_rating) || (b.closed - a.closed));
  }, [detailed, agents, tickets]);
  const staff = moderator ? staffAll.filter((s) => s.id === moderator) : staffAll;

  const modOptions = staffAll.map((s) => ({ value: s.id, label: s.name || s.global_name || s.username || '—' }));

  return (
    <div className="stats">
      <h2>Личная статистика</h2>
      <div className="personal-grid">
        <div className="personal-card">
          <div className="personal-val">{myTickets.length}</div>
          <div className="personal-lbl">Обработано тикетов</div>
        </div>
        <div className="personal-card">
          <div className="personal-val">{myAvg ? myAvg.toFixed(1) : '—'}</div>
          <div className="personal-lbl">Рейтинг</div>
        </div>
        <div className="personal-card">
          <div className="personal-val">{fmtDurationHM(myResp)}</div>
          <div className="personal-lbl">Среднее время ответа</div>
        </div>
      </div>

      <h2 style={{ marginTop: 24 }}>Глобальная статистика</h2>
      <div className="period-grid">
        <PeriodCard label="ВСЕ ВРЕМЯ" value={String(cnt.ALL)} active={range === 'ALL'} onClick={() => setRange('ALL')} data={sparkAll} />
        <PeriodCard label="30 ДНЕЙ"   value={String(cnt['30D'])} active={range === '30D'} onClick={() => setRange('30D')} data={spark30} />
        <PeriodCard label="7 ДНЕЙ"    value={String(cnt['7D'])}  active={range === '7D'}  onClick={() => setRange('7D')}  data={spark7} />
        <PeriodCard label="1 ДЕНЬ"    value={String(cnt['1D'])}  active={range === '1D'}  onClick={() => setRange('1D')}  data={spark1} />
        <StatsDatePicker label="ВЫБОР ПО ДАТЕ" value={date} onChange={setDate} />
        <StatsSelect label="МОДЕРАТОР" placeholder="Все" value={moderator} onChange={setModerator} options={modOptions} />
      </div>

      <div className="big-chart-wrap">
        <BigChart tickets={tickets} range={range} moderator={moderator} date={date} />
      </div>

      <h2 style={{ marginTop: 28 }}>Рейтинг сотрудников</h2>
      <div className="staff-row-grid">
        {staff.map((s, i) => <StaffRowCard key={s.id || i} s={s} idx={i} />)}
        {!staff.length && <div style={{ color: 'var(--text-3)' }}>Нет данных</div>}
      </div>

      <h2 style={{ marginTop: 28 }}>Категории</h2>
      <div className="cat-table">
        <div className="cat-row cat-head">
          <div className="cat-col">КАТЕГОРИЯ</div>
          <div className="cat-col">ВСЕГО</div>
          <div className="cat-col">ЗАКРЫТО</div>
          <div className="cat-col">ОТКРЫТО</div>
        </div>
        {catCounts.map((c) => {
          const tone = CAT_ICON_TONE[c.key] || CAT_ICON_TONE.other;
          return (
            <div className="cat-row" key={c.key}>
              <div className="cat-col cat-name">
                <span className="cat-icon" style={{ background: tone.bg }}>{tone.icon}</span>
                <span>{c.label}</span>
              </div>
              <div className="cat-col cat-num">{c.total}</div>
              <div className="cat-col cat-num">{c.closed}</div>
              <div className={`cat-col cat-num cat-open ${c.open === 0 ? 'zero' : 'nz'}`}>{c.open}</div>
            </div>
          );
        })}
        {!catCounts.length && <div className="cat-row" style={{ color: 'var(--text-3)' }}>Нет данных</div>}
      </div>
    </div>
  );
}

// ─── Announcements (Notifications) view ─────────────────────────────────────
function NotificationsView({ pushToast }) {
  const [tpls, setTpls] = useState([]);
  const [confirmId, setConfirmId] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    api('/api/admin/announcements').then((d) => {
      if (!d.__error) setTpls(Array.isArray(d) ? d : []);
      setLoading(false);
    });
  }, []);

  const updateField = (id, key, val) =>
    setTpls((arr) => arr.map((t) => t.id === id ? { ...t, [key]: val } : t));

  const send = async (t) => {
    setConfirmId(null);
    const r = await api(`/api/admin/announcements/${t.id}/send`, {
      method: 'POST',
      body: JSON.stringify({ text: t.text, webhook_url: t.webhook_url }),
    });
    if (r.__error) return;
    if (r.error) {
      pushToast({ type: 'error', title: 'Ошибка', message: r.error });
    } else {
      pushToast({ type: 'success', title: 'Объявление отправлено', message: `«${t.title}» — доставлено в Discord.` });
    }
  };

  const toneToColor = {
    blue:   { bg: 'rgba(74,188,117,.14)',  fg: '#87d4a3', border: 'rgba(74,188,117,.32)' },
    yellow: { bg: 'rgba(232,191,74,.14)',  fg: '#f0c04a', border: 'rgba(232,191,74,.34)' },
    violet: { bg: 'rgba(138,115,232,.16)', fg: '#a895f5', border: 'rgba(138,115,232,.34)' },
    orange: { bg: 'rgba(232,161,74,.14)',  fg: '#e8a14a', border: 'rgba(232,161,74,.32)' },
  };

  if (loading) return <div className="stats"><h2>Оповещения</h2><div style={{ color: 'var(--text-3)' }}>Загрузка…</div></div>;

  return (
    <div className="adm-page">
      <header className="adm-h">
        <div className="adm-h-title">
          <span className="adm-h-ic adm-h-ic-megaphone"><Icon.megaphone style={{ width: 13, height: 13 }} /></span>
          <h1>Оповещения</h1>
        </div>
        <p className="adm-h-sub">Шаблоны объявлений, доставляемые в Discord через Webhook. Текст использует Discord-разметку.</p>
      </header>

      <div className="annc-grid">
        {tpls.map((t) => {
          const tone = toneToColor[t.color === 0x57F287 ? 'blue' : t.color === 0xFEE75C ? 'yellow' : t.color === 0xE879F9 ? 'violet' : 'orange'] || toneToColor.blue;
          return (
            <article className="annc-card" key={t.id}>
              <div className="annc-card-head">
                <span className="annc-ic" style={{ background: tone.bg, color: tone.fg, borderColor: tone.border, fontSize: 18, lineHeight: 1 }}>
                  {t.emoji || '📢'}
                </span>
                <div className="annc-card-titles">
                  <div className="annc-title">{t.title}</div>
                  <div className="annc-sub">
                    {t.lastSent ? <>Отправлено: <span className="annc-sub-mono">{t.lastSent.date}</span></> : 'Ещё не отправлялось'}
                  </div>
                </div>
              </div>

              <div className="annc-field">
                <label className="annc-label">
                  <span>Webhook URL</span>
                  <span className="annc-badge annc-badge-admin">только админ</span>
                </label>
                <div className="annc-url">
                  <input
                    className="annc-textarea"
                    style={{ minHeight: 0, height: 32, padding: '6px 10px', fontSize: 11, fontFamily: 'JetBrains Mono, monospace' }}
                    value={t.webhook_url || ''}
                    onChange={(e) => updateField(t.id, 'webhook_url', e.target.value)} />
                </div>
              </div>

              <div className="annc-field">
                <label className="annc-label">
                  <span>Текст объявления</span>
                  <span className="annc-char-count">{(t.text || '').length} симв.</span>
                </label>
                <textarea
                  className="annc-textarea"
                  value={t.text || ''}
                  spellCheck={false}
                  onChange={(e) => updateField(t.id, 'text', e.target.value)} />
              </div>

              <div className="annc-actions">
                {confirmId === t.id ? (
                  <div className="annc-confirm">
                    <span className="annc-confirm-text">Отправить?</span>
                    <button className="annc-mini" onClick={() => setConfirmId(null)}>Отмена</button>
                    <button className="annc-mini primary" onClick={() => send(t)}>Подтвердить</button>
                  </div>
                ) : (
                  <button
                    className="annc-btn primary"
                    style={{ color: tone.fg, borderColor: tone.border, background: tone.bg }}
                    onClick={() => setConfirmId(t.id)}>
                    <Icon.send style={{ width: 12, height: 12 }} />
                    <span>Отправить</span>
                  </button>
                )}
              </div>
            </article>
          );
        })}
      </div>
    </div>
  );
}

// ─── Management view ────────────────────────────────────────────────────────
function ToggleSwitch({ on, onChange, disabled }) {
  return (
    <button className={`toggle-sw ${on ? 'on' : ''} ${disabled ? 'is-disabled' : ''}`} onClick={() => !disabled && onChange(!on)} disabled={disabled} aria-pressed={on}>
      <span className="toggle-knob" />
    </button>
  );
}

function ManagementView({ pushToast, allCategories }) {
  const [agents, setAgents] = useState([]);
  const [loading, setLoading] = useState(true);

  const reload = async () => {
    const d = await api('/api/admin/agents');
    if (!d.__error) setAgents(Array.isArray(d) ? d : []);
    setLoading(false);
  };

  useEffect(() => { reload(); }, []);

  const updateAgent = async (agent, patch) => {
    const next = { ...agent, ...patch };
    setAgents((arr) => arr.map((a) => a.id === agent.id ? next : a));
    await api(`/api/admin/agents/${agent.id}`, {
      method: 'PATCH',
      body: JSON.stringify(patch),
    });
    pushToast({ type: 'success', title: 'Изменения сохранены', message: `@${agent.global_name || agent.username}`, duration: 2400 });
  };

  if (loading) return <div className="stats"><h2>Управление</h2><div style={{ color: 'var(--text-3)' }}>Загрузка…</div></div>;

  return (
    <div className="adm-page">
      <header className="adm-h">
        <div className="adm-h-title">
          <span className="adm-h-ic adm-h-ic-gear"><Icon.gear style={{ width: 13, height: 13 }} /></span>
          <h1>Управление агентами</h1>
        </div>
        <p className="adm-h-sub">Настройка доступа к панели и категориям тикетов для каждого агента.</p>
      </header>

      <section className="adm-section">
        <div className="mgmt-agents">
          {agents.map((a) => (
            <div key={a.id} className="mgmt-agent">
              <div className="mgmt-agent-head">
                {a.avatar_url
                  ? <img className="mgmt-agent-avatar img" src={a.avatar_url} alt="" />
                  : <span className="mgmt-agent-avatar" style={{ background: '#5a6b86' }}>{(a.global_name || '?')[0].toUpperCase()}</span>}
                <div className="mgmt-agent-meta">
                  <div className="mgmt-agent-name-row">
                    <span className="mgmt-agent-name">{a.global_name || a.username}</span>
                    {a.is_admin && <span className="mgmt-agent-badge admin">АДМИН</span>}
                    {!a.is_admin && <span className="mgmt-agent-badge">Саппорт</span>}
                  </div>
                  <div className="mgmt-agent-id">{a.id}</div>
                </div>
              </div>

              <div className="mgmt-agent-section">
                <div className="mgmt-agent-section-h">Категории тикетов</div>
                <div className="mgmt-cat-grid">
                  {(allCategories || []).map((cat) => {
                    const on = (a.categories || []).includes(cat.value);
                    return (
                      <button
                        key={cat.value}
                        className={`cat-chip ${on ? 'on' : ''} ${a.is_admin ? 'is-disabled' : ''}`}
                        disabled={a.is_admin}
                        onClick={() => {
                          const next = on
                            ? (a.categories || []).filter((c) => c !== cat.value)
                            : [...(a.categories || []), cat.value];
                          updateAgent(a, { categories: next });
                        }}>
                        <span className={`cat-chip-check ${on ? 'on' : ''}`}>
                          {on && <Icon.check style={{ width: 9, height: 9 }} />}
                        </span>
                        <span className="cat-chip-lbl">{cat.label}</span>
                      </button>
                    );
                  })}
                </div>
              </div>

              <div className="mgmt-perms">
                <div className="mgmt-perm-row">
                  <div className="mgmt-perm-left">
                    <span className="mgmt-perm-ic"><Icon.key style={{ width: 11, height: 11 }} /></span>
                    <span className="mgmt-perm-lbl">Доступ к панели</span>
                    <span className={`mgmt-perm-state ${a.panel_access !== false ? 'ok' : 'off'}`}>
                      · {a.panel_access !== false ? 'Разрешён' : 'Заблокирован'}
                    </span>
                  </div>
                  <ToggleSwitch
                    on={a.panel_access !== false}
                    onChange={(v) => updateAgent(a, { panel_access: v })}
                    disabled={a.is_admin} />
                </div>
                <div className="mgmt-perm-row">
                  <div className="mgmt-perm-left">
                    <span className="mgmt-perm-ic"><Icon.megaphone style={{ width: 11, height: 11 }} /></span>
                    <span className="mgmt-perm-lbl">Доступ к оповещениям</span>
                    <span className={`mgmt-perm-state ${a.announce_access ? 'ok' : 'off'}`}>
                      · {a.announce_access ? 'Разрешён' : 'Заблокирован'}
                    </span>
                  </div>
                  <ToggleSwitch
                    on={!!a.announce_access}
                    onChange={(v) => updateAgent(a, { announce_access: v })}
                    disabled={a.is_admin} />
                </div>
              </div>
            </div>
          ))}
        </div>
      </section>
    </div>
  );
}

// ─── Toast system ───────────────────────────────────────────────────────────
function ToastIcon({ type }) {
  if (type === 'success') return <Icon.check />;
  if (type === 'error') return <Icon.close />;
  if (type === 'new-ticket') return <Icon.ticket />;
  if (type === 'new-message') return <Icon.chat />;
  return <Icon.ask />;
}
function highlight(text) {
  if (typeof text !== 'string') return text;
  const RE = /(@[\wЀ-ӿ.\-]+|#\d{3,}|\b\d{4,}\b|свободны?|закрыт[аыоэ]*|архив\w*|передан[оаыэ]*|взят[оаыэ]*|сброшен[оаыэ]*|успешн[оаыэ]*|выполнен[оаыэ]*|отправлен[оаыэ]*)/gi;
  const parts = []; let last = 0; let m; let key = 0;
  while ((m = RE.exec(text)) !== null) {
    if (m.index > last) parts.push(text.slice(last, m.index));
    const token = m[0];
    const isMono = /^[@#]/.test(token) || /^\d+$/.test(token);
    parts.push(<span key={key++} className={'g' + (isMono ? ' mono' : '')}>{token}</span>);
    last = m.index + token.length;
  }
  if (last < text.length) parts.push(text.slice(last));
  return parts;
}
function ToastStack({ toasts = [], onDismiss }) {
  return (
    <div className="toast-stack" aria-live="polite">
      {toasts.map((t) => (
        <div key={t.id} className={`toast toast-${t.type || 'info'}`}>
          <span className={`toast-ic toast-ic-${t.type || 'info'}`}><ToastIcon type={t.type} /></span>
          <div className="toast-body">
            {t.title && <div className="toast-title">{highlight(t.title)}</div>}
            {t.message && <div className="toast-msg">{highlight(t.message)}</div>}
            {t.quote && (
              <div className="toast-quote">
                <span className="tq-bar" />
                <span className="tq-text">«{t.quote}»</span>
              </div>
            )}
            {t.actions && t.actions.length > 0 && (
              <div className="toast-actions">
                {t.actions.map((a, i) => (
                  <button key={i} className={`toast-action ${a.primary ? 'primary' : ''}`} onClick={() => { a.onClick?.(); onDismiss(t.id); }}>
                    {a.label}
                  </button>
                ))}
              </div>
            )}
          </div>
          <button className="toast-x" onClick={() => onDismiss(t.id)} aria-label="Закрыть"><Icon.close style={{ width: 11, height: 11 }} /></button>
          <span className="toast-progress" style={{ animationDuration: (t.duration || 3800) + 'ms' }} />
        </div>
      ))}
    </div>
  );
}

// ─── Main App ───────────────────────────────────────────────────────────────
function App() {
  const [bootState, setBootState] = useState('loading'); // 'loading' | 'login' | 'app'
  const [currentUser, setCurrentUser] = useState(null);
  const [tickets, setTickets] = useState([]);
  const [stats, setStats] = useState({});
  const [agents, setAgents] = useState([]);
  const [categories, setCategories] = useState([]);
  const [quickReplies, setQuickReplies] = useState(DEFAULT_QUICK_REPLY_GROUPS);

  const [section, setSection] = useState('tickets');
  const [activeTicketId, setActiveTicketId] = useState(null);
  const [activeTicket, setActiveTicket] = useState(null);
  // Messages always carry the ticket id they were fetched for. Any stale fetch
  // result for a previously-selected ticket is ignored at render-time, which
  // prevents cross-ticket leaks into the "deleted" tracker.
  const [msgState, setMsgState] = useState({ ticketId: null, messages: [] });
  const messages = msgState.ticketId === activeTicketId ? msgState.messages : [];
  const setMessagesFor = useCallback((tid, msgs) => {
    setMsgState({ ticketId: tid, messages: msgs });
  }, []);
  const updateMessages = useCallback((fn) => {
    setMsgState((s) => ({ ...s, messages: fn(s.messages) }));
  }, []);
  const [drafts, setDrafts] = useState({});
  const [pendingFilesMap, setPendingFilesMap] = useState({});
  const [sending, setSending] = useState(false);
  const [closing, setClosing] = useState(false);
  // Preserve messages deleted from the panel locally (in case backend hard-deletes).
  // Stored per-ticket: { [ticketId]: { [msgId]: { ...messageData, deleted: true, _ticketId } } }.
  // Legacy entries without _ticketId are dropped on load (they were polluted by a previous bug).
  const [deletedMsgs, setDeletedMsgs] = useState(() => {
    try {
      const raw = JSON.parse(localStorage.getItem('panel.deletedMsgs') || '{}') || {};
      const cleaned = {};
      for (const tid in raw) {
        const inner = raw[tid] || {};
        const okInner = {};
        for (const mid in inner) {
          const v = inner[mid];
          if (v && String(v._ticketId) === String(tid)) okInner[mid] = v;
        }
        if (Object.keys(okInner).length) cleaned[tid] = okInner;
      }
      // Write back so legacy garbage is removed from storage immediately
      try { localStorage.setItem('panel.deletedMsgs', JSON.stringify(cleaned)); } catch (_) {}
      return cleaned;
    } catch (_) { return {}; }
  });
  const persistDeletedMsgs = useCallback((next) => {
    setDeletedMsgs(next);
    try { localStorage.setItem('panel.deletedMsgs', JSON.stringify(next)); } catch (_) {}
  }, []);

  const [toasts, setToasts] = useState([]);
  const toastIdRef = useRef(1);

  const [mobileMenuOpen, setMobileMenuOpen] = useState(false);

  // Toast helpers
  const pushToast = useCallback((opts) => {
    const id = toastIdRef.current++;
    setToasts((arr) => [...arr, { id, type: 'success', duration: 3800, ...opts }]);
    if ((opts.duration ?? 3800) > 0) {
      setTimeout(() => setToasts((arr) => arr.filter((x) => x.id !== id)), opts.duration ?? 3800);
    }
  }, []);
  const dismissToast = (id) => setToasts((arr) => arr.filter((x) => x.id !== id));

  // Force-login hook for api()
  useEffect(() => {
    window.__forceLogin = () => {
      setCurrentUser(null);
      setBootState('login');
    };
    return () => { delete window.__forceLogin; };
  }, []);

  // Initial boot
  useEffect(() => {
    (async () => {
      const me = await api('/api/me');
      if (me && !me.__error && me.id) {
        setCurrentUser(me);
        setBootState('app');
        // Bootstrap data
        const [tk, st, ag, cats, qr] = await Promise.all([
          api('/api/tickets'),
          api('/api/stats'),
          api('/api/agents'),
          api('/api/admin/categories').catch(() => []),
          api('/api/quick-replies').catch(() => []),
        ]);
        if (Array.isArray(tk)) setTickets(tk);
        if (st && !st.__error) setStats(st);
        if (Array.isArray(ag)) setAgents(ag);
        if (Array.isArray(cats)) setCategories(cats);
        if (Array.isArray(qr) && qr.length) {
          // Group by cat
          const map = {};
          for (const it of qr) {
            const key = it.cat || 'Другое';
            if (!map[key]) map[key] = { id: key, label: key, items: [] };
            map[key].items.push({
              id: it.id || `qr-${Math.random().toString(36).slice(2,8)}`,
              icon: it.icon_key || 'chat',
              title: it.label,
              text: it.text,
            });
          }
          const groups = Object.values(map);
          if (groups.length) setQuickReplies(groups);
        }
        // Push permission (ask once)
        setTimeout(() => requestPushPermission(), 1500);
      } else {
        setBootState('login');
      }
    })();
  }, []);

  // SSE subscription
  useEffect(() => {
    if (bootState !== 'app' || !currentUser) return;
    let es;
    let lastActivity = Date.now();
    let reconnectTimer;

    const connect = () => {
      es = new EventSource('/api/events');
      lastActivity = Date.now();

      es.addEventListener('message', async (e) => {
        lastActivity = Date.now();
        const d = JSON.parse(e.data || '{}');
        const tid = d.ticket_id;
        playNotif();
        // Refresh ticket list + open ticket if needed
        const tk = await api('/api/tickets');
        if (Array.isArray(tk)) setTickets(tk);
        if (tid && activeTicketIdRef.current === tid) {
          const det = await api(`/api/tickets/${tid}`);
          if (activeTicketIdRef.current !== tid) return; // user switched away during await
          if (det.ticket) { setActiveTicket(det.ticket); setMessagesFor(tid, det.messages || []); }
        }
      });

      es.addEventListener('message_self', async () => {
        lastActivity = Date.now();
        const tid = activeTicketIdRef.current;
        if (tid) {
          const det = await api(`/api/tickets/${tid}`);
          if (activeTicketIdRef.current !== tid) return;
          if (det.ticket) { setActiveTicket(det.ticket); setMessagesFor(tid, det.messages || []); }
        }
      });

      es.addEventListener('message_edit', async () => {
        lastActivity = Date.now();
        const tid = activeTicketIdRef.current;
        if (tid) {
          const det = await api(`/api/tickets/${tid}`);
          if (activeTicketIdRef.current !== tid) return;
          if (det.ticket) { setActiveTicket(det.ticket); setMessagesFor(tid, det.messages || []); }
        }
      });

      es.addEventListener('ticket_new', async () => {
        lastActivity = Date.now();
        playNotif();
        const tk = await api('/api/tickets');
        if (Array.isArray(tk)) setTickets(tk);
        const st = await api('/api/stats');
        if (st && !st.__error) setStats(st);
        pushToast({
          type: 'new-ticket',
          title: 'Новый тикет',
          message: 'Открыт новый тикет в поддержке',
          duration: 5500,
          actions: [{ label: 'Открыть', primary: true, onClick: () => setSection('tickets') }],
        });
      });

      es.addEventListener('ticket_update', async (e) => {
        lastActivity = Date.now();
        const d = e.data ? JSON.parse(e.data) : {};
        const tk = await api('/api/tickets');
        if (Array.isArray(tk)) setTickets(tk);
        const st = await api('/api/stats');
        if (st && !st.__error) setStats(st);
        if (d.id && activeTicketIdRef.current === d.id) {
          const det = await api(`/api/tickets/${d.id}`);
          if (activeTicketIdRef.current !== d.id) return;
          if (det.ticket) { setActiveTicket(det.ticket); setMessagesFor(d.id, det.messages || []); }
          else { setActiveTicketId(null); setActiveTicket(null); setMessagesFor(null, []); }
        }
      });

      es.addEventListener('force_logout', () => {
        pushToast({ type: 'error', title: 'Доступ отозван', message: 'Ваша роль агента была отозвана.', duration: 6000 });
        setTimeout(() => { location.href = '/auth/logout'; }, 1500);
      });

      es.addEventListener('force_refresh', async () => {
        lastActivity = Date.now();
        const me = await api('/api/me');
        if (me && !me.__error) setCurrentUser(me);
        const tk = await api('/api/tickets');
        if (Array.isArray(tk)) setTickets(tk);
      });

      es.onerror = () => {
        try { es.close(); } catch (_) {}
        clearTimeout(reconnectTimer);
        reconnectTimer = setTimeout(connect, 3000);
      };
    };
    connect();

    // Reconnect if tab regains focus after long inactivity
    const onVis = async () => {
      if (document.visibilityState === 'visible') {
        if (Date.now() - lastActivity > 30000) {
          try { es?.close(); } catch (_) {}
          connect();
        }
        const tk = await api('/api/tickets');
        if (Array.isArray(tk)) setTickets(tk);
        const tid = activeTicketIdRef.current;
        if (tid) {
          const det = await api(`/api/tickets/${tid}`);
          if (activeTicketIdRef.current !== tid) return;
          if (det.ticket) { setActiveTicket(det.ticket); setMessagesFor(tid, det.messages || []); }
        }
      }
    };
    document.addEventListener('visibilitychange', onVis);

    return () => {
      try { es?.close(); } catch (_) {}
      clearTimeout(reconnectTimer);
      document.removeEventListener('visibilitychange', onVis);
    };
  }, [bootState, currentUser]);

  // Keep ref of activeTicketId so SSE handlers see latest value
  const activeTicketIdRef = useRef(null);
  useEffect(() => { activeTicketIdRef.current = activeTicketId; }, [activeTicketId]);

  // Load ticket detail when selected
  useEffect(() => {
    if (!activeTicketId) { setActiveTicket(null); setMessagesFor(null, []); return; }
    let cancelled = false;
    const tid = activeTicketId;
    (async () => {
      const d = await api(`/api/tickets/${tid}`);
      if (cancelled || d.__error) return;
      if (activeTicketIdRef.current !== tid) return;
      setActiveTicket(d.ticket || null);
      setMessagesFor(tid, d.messages || []);
    })();
    return () => { cancelled = true; };
  }, [activeTicketId]);

  // Derived state
  const taken = activeTicket?.agent_id === currentUser?.id;
  const lockedByOther = !!activeTicket?.agent_id && activeTicket.agent_id !== currentUser?.id;
  const isClosed = activeTicket?.status === 'closed';
  const draft = drafts[activeTicketId] || '';

  // Track messages we've seen for the active ticket so we can detect when one
  // disappears between refreshes (Discord-side delete or hard delete by backend)
  // and keep showing it in red. Only runs when messages actually belong to the
  // current ticket (msgState.ticketId === activeTicketId), and bot/system rows
  // are never tracked — those aren't really "agent" or "user" messages.
  const seenMsgsRef = useRef({}); // ticketId → { msgId: messageData }
  useEffect(() => {
    if (!activeTicketId) return;
    if (msgState.ticketId !== activeTicketId) return; // stale messages for another ticket
    const tk = String(activeTicketId);
    const seen = seenMsgsRef.current[tk] || {};
    const currentIds = new Set(messages.map((m) => m.id));
    const newlyMissing = [];
    for (const id in seen) {
      if (!currentIds.has(id) && !(deletedMsgs[tk]?.[id])) {
        newlyMissing.push(seen[id]);
      }
    }
    if (newlyMissing.length) {
      const next = { ...deletedMsgs };
      if (!next[tk]) next[tk] = {};
      for (const m of newlyMissing) {
        next[tk][m.id] = { ...m, deleted: true, _ticketId: tk };
      }
      persistDeletedMsgs(next);
    }
    // Refresh seen set with currently-present non-bot non-deleted messages
    const fresh = {};
    for (const m of messages) {
      if (m.deleted) continue;
      if (m.is_bot && !m.is_web) continue; // skip bot/system messages
      fresh[m.id] = m;
    }
    seenMsgsRef.current[tk] = fresh;
  }, [messages, activeTicketId, msgState.ticketId]);

  // Merge locally-preserved deleted messages so they remain visible in red even
  // if the backend hard-deleted them after our DELETE call. Filters entries by
  // _ticketId match as belt-and-suspenders against any future leakage.
  const mergedMessages = useMemo(() => {
    if (!activeTicketId) return messages;
    const tk = String(activeTicketId);
    const local = deletedMsgs[tk] || {};
    const localIds = Object.keys(local).filter((id) => String(local[id]?._ticketId) === tk);
    if (!localIds.length) return messages;
    const byId = Object.fromEntries(messages.map((m) => [m.id, m]));
    const result = messages.map((m) => local[m.id] ? { ...m, deleted: true } : m);
    for (const id of localIds) {
      if (!byId[id]) result.push(local[id]);
    }
    result.sort((a, b) => new Date(a.created_at || 0) - new Date(b.created_at || 0));
    return result;
  }, [messages, deletedMsgs, activeTicketId]);
  const pendingFiles = pendingFilesMap[activeTicketId] || [];

  const setDraftFor = (id, val) => setDrafts((p) => ({ ...p, [id]: val }));
  const addPendingFiles = (files) => setPendingFilesMap((p) => ({ ...p, [activeTicketId]: [...(p[activeTicketId] || []), ...files].slice(0, 5) }));
  const removePendingFile = (idx) => setPendingFilesMap((p) => ({ ...p, [activeTicketId]: (p[activeTicketId] || []).filter((_, i) => i !== idx) }));

  // Actions
  const onTake = async () => {
    const tid = activeTicketId;
    const r = await api(`/api/tickets/${tid}/accept`, { method: 'POST' });
    if (r.__error) return;
    if (r.error) {
      pushToast({ type: 'error', title: 'Не удалось взять тикет', message: r.error });
      return;
    }
    pushToast({ type: 'success', title: 'Тикет взят в работу', message: `#${padId(tid)} — теперь работает @${currentUser.global_name || currentUser.username}` });
    // Auto-send greeting message with user mention
    const userId = activeTicket?.user_id;
    const mention = userId ? `<@${userId}>` : (activeTicket?.username ? `@${activeTicket.username}` : '');
    if (mention) {
      const fd = new FormData();
      fd.append('content', `Здравствуйте, ${mention}! Начинаю работать по Вашему тикету.`);
      api(`/api/tickets/${tid}/reply`, { method: 'POST', body: fd });
    }
  };

  const onTransfer = async () => {
    const r = await api(`/api/tickets/${activeTicketId}/transfer`, { method: 'POST' });
    if (r.__error) return;
    if (r.error) { pushToast({ type: 'error', title: 'Передача отклонена', message: r.error }); return; }
    pushToast({ type: 'success', title: 'Тикет передан в очередь', message: `#${padId(activeTicketId)} — теперь свободен.` });
  };

  const onCloseTicket = async () => {
    if (closing) return;
    if (activeTicket?.status === 'closed') {
      pushToast({ type: 'info', title: 'Тикет уже закрыт', duration: 2500 });
      return;
    }
    setClosing(true);
    const r = await api(`/api/tickets/${activeTicketId}/close`, { method: 'POST' });
    setClosing(false);
    if (r.__error) return;
    if (r.error) { pushToast({ type: 'error', title: 'Не удалось закрыть', message: r.error }); return; }
    // Optimistically mark as closed to prevent re-click before SSE refresh arrives
    setActiveTicket((t) => t ? { ...t, status: 'closed', closed_at: new Date().toISOString() } : t);
    pushToast({ type: 'success', title: 'Тикет закрыт', message: `#${padId(activeTicketId)} переведён в архив.` });
  };

  const onTagPlayer = () => {
    if (!taken) return;
    const tag = activeTicket.user_id ? `<@${activeTicket.user_id}>` : `@${activeTicket.username || 'user'}`;
    setDraftFor(activeTicketId, (draft && draft.trimEnd().endsWith(tag)) ? draft : (draft ? (draft.endsWith(' ') ? draft : draft + ' ') + tag + ' ' : tag + ' '));
    pushToast({ type: 'info', title: 'Игрок тегнут', message: 'Тег вставлен в поле ответа.', duration: 2200 });
  };

  const onSaveNote = async (val) => {
    await api(`/api/tickets/${activeTicketId}/note`, {
      method: 'POST',
      body: JSON.stringify({ note: val }),
    });
  };

  const onSend = async () => {
    if (sending) return;
    const text = (drafts[activeTicketId] || '').trim();
    const files = pendingFilesMap[activeTicketId] || [];
    if (!text && !files.length) return;
    setSending(true);
    const fd = new FormData();
    fd.append('content', text);
    files.forEach((f) => fd.append('files', f));
    const r = await api(`/api/tickets/${activeTicketId}/reply`, { method: 'POST', body: fd });
    setSending(false);
    if (r.__error) return;
    if (r.error) {
      pushToast({ type: 'error', title: 'Не удалось отправить', message: r.error });
      return;
    }
    setDraftFor(activeTicketId, '');
    setPendingFilesMap((p) => ({ ...p, [activeTicketId]: [] }));
  };

  const onEditMessage = async (msgId, content) => {
    const r = await api(`/api/tickets/${activeTicketId}/messages/${msgId}`, {
      method: 'PATCH',
      body: JSON.stringify({ content }),
    });
    if (r.__error) return;
    if (r.error) { pushToast({ type: 'error', title: 'Ошибка', message: r.error }); return; }
    pushToast({ type: 'info', title: 'Сообщение обновлено', duration: 2000 });
  };

  const onDeleteMessage = async (msgId) => {
    if (!confirm('Удалить сообщение?')) return;
    // Remember the original message locally so we can keep showing it in red
    // even if the backend hard-deletes the row.
    const target = messages.find((m) => m.id === msgId);
    if (target) {
      const tk = activeTicketId;
      persistDeletedMsgs({
        ...deletedMsgs,
        [tk]: {
          ...(deletedMsgs[tk] || {}),
          [msgId]: { ...target, deleted: true, _ticketId: String(tk) },
        },
      });
      updateMessages((arr) => arr.map((m) => m.id === msgId ? { ...m, deleted: true } : m));
    }
    const r = await api(`/api/tickets/${activeTicketId}/messages/${msgId}`, { method: 'DELETE' });
    if (r.__error) return;
    if (r.error) { pushToast({ type: 'error', title: 'Ошибка', message: r.error }); return; }
    pushToast({ type: 'info', title: 'Сообщение удалено', duration: 2000 });
  };

  const onDeleteAttachment = async (msgId, url) => {
    if (!confirm('Удалить вложение?')) return;
    const r = await api(`/api/tickets/${activeTicketId}/messages/${msgId}/attachments`, {
      method: 'DELETE',
      body: JSON.stringify({ url }),
    });
    if (r.__error) return;
    if (r.error) { pushToast({ type: 'error', title: 'Ошибка', message: r.error }); return; }
  };

  // ────────── Render ──────────
  if (bootState === 'loading') {
    return (
      <div className="app-boot">
        <div className="boot-spinner" />
        <div>Загрузка панели…</div>
      </div>
    );
  }
  if (bootState === 'login') {
    return <LoginPage />;
  }

  let main = null;
  if (section === 'tickets') {
    main = (
      <div className="main" style={activeTicket ? undefined : { gridTemplateColumns: '420px 1fr' }}>
        <TicketsList
          tickets={tickets}
          activeId={activeTicketId}
          onSelect={setActiveTicketId}
          stats={stats}
          currentUserId={currentUser?.id}
          isAdmin={!!currentUser?.is_admin} />
        {activeTicket ? (
          <>
            <div className="col col-chat">
              <Chat
                ticket={activeTicket}
                messages={mergedMessages}
                currentUser={currentUser}
                taken={taken}
                lockedByOther={lockedByOther}
                isClosed={isClosed}
                quickReplies={quickReplies}
                draft={draft}
                onDraftChange={(v) => setDraftFor(activeTicketId, v)}
                pendingFiles={pendingFiles}
                onAddFiles={addPendingFiles}
                onRemoveFile={removePendingFile}
                onSend={onSend}
                sending={sending}
                onEditMessage={onEditMessage}
                onDeleteMessage={onDeleteMessage}
                onDeleteAttachment={onDeleteAttachment} />
            </div>
            <InfoPanel
              ticket={activeTicket}
              currentUser={currentUser}
              taken={taken}
              lockedByOther={lockedByOther}
              isClosed={isClosed}
              agents={agents}
              note={activeTicket?.note}
              onSaveNote={onSaveNote}
              closing={closing}
              onTake={onTake}
              onTransfer={onTransfer}
              onClose={onCloseTicket}
              onTagPlayer={onTagPlayer}
              onCollapse={() => setActiveTicketId(null)} />
          </>
        ) : (
          <div className="col col-chat empty-chat">
            <div className="empty-chat-inner">
              <div className="empty-chat-icon">
                <Icon.chat style={{ width: 34, height: 34 }} />
              </div>
              <div className="empty-chat-title">Тикет не выбран</div>
              <div className="empty-chat-sub">Выберите тикет из списка слева, чтобы открыть переписку и инструменты модерации.</div>
            </div>
          </div>
        )}
      </div>
    );
  } else if (section === 'stats') {
    main = <div className="main" style={{ gridTemplateColumns: '1fr' }}><StatsView stats={stats} currentUser={currentUser} tickets={tickets} agents={agents} categories={categories} /></div>;
  } else if (section === 'notifications') {
    main = <div className="main" style={{ gridTemplateColumns: '1fr' }}><NotificationsView pushToast={pushToast} /></div>;
  } else if (section === 'management') {
    main = <div className="main" style={{ gridTemplateColumns: '1fr' }}><ManagementView pushToast={pushToast} allCategories={categories} /></div>;
  }

  const mobileSection = (section === 'tickets' && activeTicket) ? 'chat' : 'list';

  return (
    <div
      className="app"
      data-mobile-section={mobileSection}
      data-mobile-menu-open={mobileMenuOpen ? 'true' : 'false'}>
      <MobileTopbar
        section={section}
        activeTicket={section === 'tickets' ? activeTicket : null}
        onMenu={() => setMobileMenuOpen(true)}
        onBack={() => setActiveTicketId(null)}
        onInfo={() => {}} />
      <Sidebar
        active={section}
        onNav={(id) => { setSection(id); setMobileMenuOpen(false); }}
        currentUser={currentUser}
        stats={stats}
        isAdmin={!!currentUser?.is_admin}
        onCloseDrawer={() => setMobileMenuOpen(false)} />
      <div className="mobile-backdrop" onClick={() => setMobileMenuOpen(false)} aria-hidden="true" />
      {main}
      <ToastStack toasts={toasts} onDismiss={dismissToast} />
    </div>
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(<App />);
