// Reusable UI components const { useState, useEffect, useRef, useMemo } = React; // Generic avatar circle with initials or real photo function Avatar({ initials, color, size = 36, ring = false, photoUrl = null }) { const [imgError, setImgError] = useState(false); const ringCls = ring ? "ring-2 ring-[#13131a]" : ""; if (photoUrl && !imgError) { return ( {initials} setImgError(true)} /> ); } return (
{initials}
); } function StatusBadge({ status }) { const map = { ai: { label: "ИИ", cls: "bg-[#4F8EF7]/15 text-[#7BA8F9] border-[#4F8EF7]/30" }, queue: { label: "Очередь", cls: "bg-[#f97316]/15 text-[#f97316] border-[#f97316]/30" }, in_progress: { label: "В работе", cls: "bg-[#eab308]/15 text-[#eab308] border-[#eab308]/30" }, waiting: { label: "Ожидание", cls: "bg-[#A855F7]/15 text-[#c084fc] border-[#A855F7]/30" }, closed: { label: "Закрыт", cls: "bg-zinc-500/15 text-zinc-400 border-zinc-600/40" }, // legacy rows that predate the ai/queue/waiting model new: { label: "Новый", cls: "bg-[#4F8EF7]/15 text-[#7BA8F9] border-[#4F8EF7]/30" }, }; const cfg = map[status] || map.queue; return ( {cfg.label} ); } // Waiting-reason label: why the ticket sits in «Ожидание» // operator_replied → blue «ждём ответ» (ball is on the client's side) // manual → red «клиент ждёт ответ» (operator paused it, client is owed an answer) function WaitingLabel({ reason }) { if (!reason) return null; const cfg = reason === "manual" ? { label: "клиент ждёт ответ", cls: "bg-[#ef4444]/15 text-[#ef4444] border-[#ef4444]/30" } : { label: "ждём ответ", cls: "bg-[#4F8EF7]/15 text-[#7BA8F9] border-[#4F8EF7]/30" }; return ( {cfg.label} ); } // Accumulated time the ticket has spent «В работе» (SLA); ticks only while // slaStartedAt is set (i.e. the ticket is in_progress right now). function fmtSla(totalSeconds) { const s = Math.max(0, Math.floor(totalSeconds)); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sec = s % 60; const mm = String(m).padStart(2, "0"); const ss = String(sec).padStart(2, "0"); return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`; } function SlaTimer({ slaSeconds, slaStartedAt, className = "" }) { const running = !!slaStartedAt; // Each running timer ticks itself — idle cards never re-render. const [, setTick] = useState(0); useEffect(() => { if (!running) return; const t = setInterval(() => setTick((v) => v + 1), 1000); return () => clearInterval(t); }, [running]); const base = slaSeconds || 0; const extra = running ? Math.max(0, (Date.now() - Date.parse(slaStartedAt)) / 1000) : 0; const total = base + extra; if (!running && total === 0) return null; const cls = running ? "text-[#eab308]" : "text-zinc-500"; return ( {fmtSla(total)} ); } // «16:12 - 25.02.2026» — формат, в котором операторы читают время последнего // визита коллеги. Он же используется в ленте действий клиента. function fmtDateTime(iso) { if (!iso) return ""; const d = new Date(iso); if (isNaN(d)) return ""; const p = (n) => String(n).padStart(2, "0"); return `${p(d.getHours())}:${p(d.getMinutes())} - ` + `${p(d.getDate())}.${p(d.getMonth() + 1)}.${d.getFullYear()}`; } // Присутствие оператора. Раньше здесь стояло «Офлайн», из которого не понять, // ждать человека или забирать тикет; теперь видно, когда он был в сети. function PresenceLabel({ online, paused, lastSeen, className = "" }) { const state = online ? (paused ? { dot: "bg-[#eab308]", text: "text-[#eab308]", label: "На паузе" } : { dot: "bg-[#22c55e]", text: "text-[#22c55e]", label: "Онлайн" }) : { dot: "bg-zinc-600", text: "text-[#6b7280]", label: lastSeen ? `был в сети ${fmtDateTime(lastSeen)}` : "не заходил" }; return ( {state.label} ); } function PlanBadge({ plan }) { const map = { Pro: "bg-gradient-to-r from-[#A855F7] to-[#4F8EF7] text-white", Basic: "bg-[#1f2a44] text-[#7BA8F9] border border-[#4F8EF7]/30", Trial: "bg-[#3d3320] text-[#eab308] border border-[#eab308]/30", }; return ( {plan} ); } function SubStatus({ status }) { const map = { active: { label: "Активна", color: "text-[#22c55e]", dot: "bg-[#22c55e]" }, expiring: { label: "Истекает", color: "text-[#eab308]", dot: "bg-[#eab308]" }, blocked: { label: "Заблокирована", color: "text-[#ef4444]", dot: "bg-[#ef4444]" }, }; const cfg = map[status] || map.active; return ( {cfg.label} ); } // Icons (inline SVG, stroke-based) function Icon({ name, className = "w-4 h-4", strokeWidth = 1.75 }) { const paths = { search: <>, bell: <>, menu: <>, send: <>, paperclip: <>, chevronDown: <>, chevronRight: <>, x: <>, check: <>, plus: <>, edit: <>, trash: <>, chat: <>, chart: <>, settings: <>, bellRing: <>, image: <>, sparkles: <>, user: <>, refresh: <>, key: <>, plus2: <>, arrowLeft: <>, arrowRight: <>, calendar: <>, operators: <>, book: <>, clock: <>, server: <>, zap: <>, megaphone: <>, template: <>, link: <>, grid: <>, info: <>, dots: <>, pause: <>, logout: <>, lock: <>, handRaise: <>, }; return ( {paths[name]} ); } // ── Переключатель ВПН-сервисов ─────────────────────────────────────────────── // Ряд пилюль под шапкой: слева «Все сервисы» с суммарным счётчиком, дальше по // пилюле на каждый ВПН — точка фирменного цвета, название и счётчик активных // обращений («новые + непрочитанные»). Нулевой счётчик не рисуется. // currentServiceId === null означает режим «Все сервисы». function ServicePill({ service, count, active, onClick, allMode = false }) { return ( ); } function ServiceSwitcher({ services, currentServiceId, onSelect, activeService, aiPromptPreview }) { // Один сервис — переключать нечего, ряд не занимает место. if (!services || services.length < 2) return null; const total = services.reduce((sum, s) => sum + (s.activeCount || 0), 0); return (
onSelect(null)} />
{services.map((s) => ( onSelect(s.id)} /> ))}
{activeService ? ( <> {aiPromptPreview && } ) : ( )}
); } function ContextChip({ label, value, dot, mono, strong, accent }) { return ( {dot && } {label}: {value} ); } // Точка цвета сервиса — метка в списке диалогов в режиме «Все сервисы». function ServiceDot({ color, name }) { if (!color) return null; return ( ); } // ── Мобильная адаптация ────────────────────────────────────────────────────── // Раскладка ветвится по трём брейкпоинтам. Одна кодовая база: карточки, пузыри // и панели те же, меняется только их размещение. const VIEWPORT_MOBILE_MAX = 699.98; // телефон const VIEWPORT_TABLET_MAX = 1099.98; // планшет и узкое окно на десктопе function readViewport() { if (typeof window === "undefined") return "desktop"; const w = window.innerWidth; return w <= VIEWPORT_MOBILE_MAX ? "mobile" : w <= VIEWPORT_TABLET_MAX ? "tablet" : "desktop"; } // matchMedia вместо ручного resize: событие приходит только при переходе через // границу, а не на каждый пиксель перетаскивания окна. function useViewport() { const [mode, setMode] = useState(readViewport); useEffect(() => { const mq = [ window.matchMedia(`(max-width: ${VIEWPORT_MOBILE_MAX}px)`), window.matchMedia(`(max-width: ${VIEWPORT_TABLET_MAX}px)`), ]; const onChange = () => setMode(readViewport()); mq.forEach((m) => m.addEventListener("change", onChange)); onChange(); return () => mq.forEach((m) => m.removeEventListener("change", onChange)); }, []); return { mode, isMobile: mode === "mobile", isTablet: mode === "tablet", isDesktop: mode === "desktop", // Карточка клиента и меню действий уезжают в шторку и на планшете тоже. isCompact: mode !== "desktop", }; } // Читаемый цвет поверх фирменного цвета сервиса. function contrastOn(hex) { if (!hex || hex[0] !== "#" || hex.length < 7) return "#fff"; const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); const b = parseInt(hex.slice(5, 7), 16); return (r * 299 + g * 587 + b * 114) / 1000 > 150 ? "#0d0d12" : "#fff"; } // Подложка модального окна. Закрывает окно только если на ней прошли ОБА // события мыши — и нажатие, и отпускание. Прежний вариант ловил `click`, а он // всплывает до общего предка: выделение текста в поле, законченное за краем // формы, читалось как клик по подложке, и окно закрывалось с набранным. // Escape закрывает всегда — раньше это умела только шторка. function ModalOverlay({ onClose, zIndex = 50, className = "", children }) { const downOnBackdrop = useRef(false); useEffect(() => { if (!onClose) return; const onKey = (e) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); return (
{ downOnBackdrop.current = e.target === e.currentTarget; }} onMouseUp={(e) => { if (downOnBackdrop.current && e.target === e.currentTarget && onClose) onClose(); downOnBackdrop.current = false; }} className={"fixed inset-0 backdrop-blur-sm flex items-center justify-center " + (className || "bg-black/60 p-4")} > {children}
); } // Шторка снизу: затемнение, «грабер», закрытие по свайпу вниз, тапу вне и Esc. // Общая для карточки клиента, выбора сервиса и меню действий над тикетом. function BottomSheet({ open, onClose, title, subtitle, children, maxHeight = "88%" }) { const [mounted, setMounted] = useState(open); const [shown, setShown] = useState(false); const [dragY, setDragY] = useState(0); const dragging = useRef(false); const startY = useRef(0); useEffect(() => { if (open) { setMounted(true); setDragY(0); const t = requestAnimationFrame(() => setShown(true)); return () => cancelAnimationFrame(t); } setShown(false); // Размонтируем после анимации ухода, иначе шторка исчезает рывком. const t = setTimeout(() => setMounted(false), 280); return () => clearTimeout(t); }, [open]); useEffect(() => { if (!open) return; const onKey = (e) => { if (e.key === "Escape") onClose && onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [open, onClose]); if (!mounted) return null; const onTouchStart = (e) => { dragging.current = true; startY.current = e.touches[0].clientY; }; const onTouchMove = (e) => { if (!dragging.current) return; setDragY(Math.max(0, e.touches[0].clientY - startY.current)); }; const onTouchEnd = () => { dragging.current = false; // Больше трети «грабера пути» — считаем жест закрытием. if (dragY > 90) onClose && onClose(); setDragY(0); }; const transform = shown ? (dragY ? `translateY(${dragY}px)` : "none") : "translateY(101%)"; return ( <>
{(title || subtitle) && (

{title}

{subtitle && {subtitle}}
)}
{children}
); } // Лента сервисов на мобильном: плитка 44×44 со счётчиком, подпись под ней, // первая плитка «Все» пунктиром. Данные те же, что у ServiceSwitcher. function ServiceTile({ service, count, active, onClick, allMode = false }) { const color = service?.color || "#4F8EF7"; const letter = service ? (service.emoji || (service.name || "?").trim()[0].toUpperCase()) : ""; return ( ); } function ServiceRail({ services, currentServiceId, onSelect }) { if (!services || services.length < 2) return null; const total = services.reduce((sum, s) => sum + (s.activeCount || 0), 0); return (
onSelect(null)} /> {services.map((s) => ( onSelect(s.id)} /> ))}
); } // Шапка мобильного экрана: слева либо логотип, либо «назад». // leading — что показать слева, когда кнопка «назад» не нужна (например, // аватар клиента в двухколоночной раскладке планшета). function MobileAppBar({ title, subtitle, onBack, right, leading }) { return (
{onBack ? ( ) : leading ? leading : (
Х
)}
{title}
{subtitle &&
{subtitle}
}
{right}
); } // Круглая кнопка шапки с необязательным бейджем. function AppBarButton({ icon, label, badge, onClick, tone = "muted" }) { return ( ); } // Нижняя навигация. «Статистика» — только у админа, как и в TopBar. function MobileNav({ screen, setScreen, badge = 0, isAdmin = false }) { const items = [ { id: "dialogs", label: "Диалоги", icon: "chat" }, { id: "health", label: "Состояние", icon: "server" }, { id: "stats", label: "Статистика", icon: "chart" }, { id: "settings", label: "Настройки", icon: "settings" }, ].filter((i) => i.id !== "stats" || isAdmin); return ( ); } function Toast({ msg, type = "ok" }) { if (!msg) return null; const dot = type === "warn" ? "bg-[#f59e0b]" : "bg-[#22c55e]"; return (
{msg}
); } Object.assign(window, { Avatar, StatusBadge, WaitingLabel, SlaTimer, fmtSla, PlanBadge, SubStatus, Icon, Toast, ModalOverlay, fmtDateTime, PresenceLabel, ServiceSwitcher, ServicePill, ContextChip, ServiceDot, useViewport, contrastOn, BottomSheet, ServiceRail, ServiceTile, MobileAppBar, AppBarButton, MobileNav });