// Settings screen const { useState: useStateT, useEffect: useEffectT, useMemo: useMemoT } = React; // Все контентные настройки (промпт, БЗ, автоматизация, шаблоны, рассылка) // пер-сервисные — запросы всегда несут service_id открытого ВПН-а. function svcQuery(service, extra = "") { const q = service ? `service_id=${service.id}` : ""; const all = [q, extra].filter(Boolean).join("&"); return all ? "?" + all : ""; } // Плашка «какой ВПН сейчас правим» — чтобы админ не отредактировал промпт или // не отправил рассылку не тому сервису. function ServiceBanner({ service, hint }) { if (!service) return null; return (
Сервис: {service.name} {service.slug} {hint && {hint}}
); } function SettingsScreen({ operators: ops, setOperators, showToast, currentOperator, services = [], serviceId = null, onServicesChanged, mobileChrome = null }) { const isAdmin = currentOperator?.role === "admin"; const defaultSection = isAdmin ? "operators" : "profile"; const [section, setSection] = useStateT(defaultSection); const [modalOpen, setModalOpen] = useStateT(false); const [editingOp, setEditingOp] = useStateT(null); const [confirmDelete, setConfirmDelete] = useStateT(null); // Телефон: null — список разделов, иначе открыт конкретный раздел. const [mobileSection, setMobileSection] = useStateT(null); // Настройки всегда правятся у конкретного ВПН-а: промпт, база знаний, // автоматизация и рассылка у каждого свои. const svc = services.find((s) => s.id === serviceId) || null; // hint — подпись под названием в мобильном списке; scope делит список на // «настройки этого ВПН-а» и «общие для всех». const allSections = [ { id: "operators", label: "Операторы", icon: "operators", adminOnly: true, scope: "common", hint: "команда, роли, доступ к ВПН" }, { id: "services", label: "Сервисы", icon: "server", adminOnly: true, scope: "common", hint: "подключить ВПН: API, токен, business_id" }, { id: "profile", label: "Профиль", icon: "user", adminOnly: false, scope: "common", hint: "имя, пароль, уведомления" }, { id: "ai", label: "ИИ-настройки", icon: "sparkles", adminOnly: true, scope: "service", hint: "промпт, модель, автоответ" }, { id: "kb", label: "База знаний", icon: "book", adminOnly: false, scope: "service", hint: "статьи для ответов ИИ" }, { id: "automation", label: "Автоматизация", icon: "zap", adminOnly: true, scope: "service", hint: "эскалация, оценки, лимиты" }, // advanced: URL и токен теперь спрашивают прямо в форме сервиса, поэтому // сюда заходят редко — только чтобы выбрать другой источник или погасить // отдельные кнопки. В меню уезжает вниз, под разделитель. { id: "customer", label: "Источник данных", icon: "user", adminOnly: true, scope: "service", advanced: true, hint: "нужно, только если это не Support API бота" }, { id: "sounds", label: "Звуки", icon: "bellRing", adminOnly: true, scope: "common", hint: "новое сообщение, вызов оператора" }, { id: "broadcast", label: "Рассылка", icon: "megaphone", adminOnly: true, scope: "service", hint: "сообщение клиентам сервиса" }, { id: "templates", label: "Шаблоны", icon: "template", adminOnly: false, scope: "service", hint: "быстрые ответы по «/»" }, { id: "folders", label: "Папки", icon: "grid", adminOnly: false, scope: "service", hint: "свои разделы в списке тикетов" }, ]; const sections = allSections.filter(s => !s.adminOnly || isAdmin); async function saveOperator(data) { try { if (editingOp) { const updated = await window.apiFetch("PUT", `/api/operators/${editingOp.id}`, data); setOperators((arr) => arr.map((o) => (o.id === editingOp.id ? { ...o, ...updated } : o))); showToast("Оператор обновлён"); } else { const created = await window.apiFetch("POST", "/api/operators", data); setOperators((arr) => [...arr, { ...created, closed: 0, avgTime: "—" }]); showToast("Оператор добавлен"); } } catch (e) { showToast("Ошибка сохранения"); } setModalOpen(false); } async function deleteOperator(op) { try { await window.apiFetch("DELETE", `/api/operators/${op.id}`); setOperators((arr) => arr.filter((o) => o.id !== op.id)); showToast("Оператор удалён"); } catch (e) { showToast("Ошибка удаления"); } setConfirmDelete(null); } const sectionBody = ( <> {section === "operators" && { setEditingOp(null); setModalOpen(true); }} onEdit={(op) => { setEditingOp(op); setModalOpen(true); }} onDelete={(op) => setConfirmDelete(op)} />} {section === "services" && } {section === "profile" && } {section === "ai" && } {section === "kb" && } {section === "automation" && } {section === "customer" && } {section === "sounds" && } {section === "broadcast" && } {section === "templates" && } {section === "folders" && } ); const modals = ( <> {modalOpen && setModalOpen(false)} onSave={saveOperator} />} {confirmDelete && ( setConfirmDelete(null)}>
Удалить оператора?
«{confirmDelete.name}» больше не сможет отвечать.
)} ); // ── Телефон: боковое меню превращается в список с провалами ────────────── if (mobileChrome) { const chrome = mobileChrome; const current = sections.find((x) => x.id === section); if (mobileSection) { return (
setMobileSection(null)} />
{sectionBody}
{modals}
); } const groups = [ { title: "Этот сервис", items: sections.filter((x) => x.scope === "service" && !x.advanced) }, { title: "Общее", items: sections.filter((x) => x.scope === "common" && !x.advanced) }, { title: "Дополнительно", items: sections.filter((x) => x.advanced) }, ].filter((g) => g.items.length); return (
} />
{svc && (
{svc.emoji || svc.name[0]}
{svc.name}
{svc.qdrantCollection}
)} {groups.map((g) => (
{g.title}
{g.items.map((x) => ( ))}
))}
{modals}
); } return (
{sectionBody}
{modals}
); } function OperatorsSection({ operators, services = [], setOperators, showToast, onAdd, onEdit, onDelete }) { const [saving, setSaving] = useStateT(null); // Флаг = доступ оператора к ВПН-сервису. Снятие возвращает его тикеты в // этом сервисе в очередь, установка сразу подключает к раздаче. async function toggleService(op, serviceId) { const current = op.serviceIds || []; const next = current.includes(serviceId) ? current.filter((id) => id !== serviceId) : [...current, serviceId]; setSaving(`${op.id}:${serviceId}`); try { await window.apiFetch("PUT", `/api/operators/${op.id}/services`, { service_ids: next }); setOperators((arr) => arr.map((o) => (o.id === op.id ? { ...o, serviceIds: next } : o))); } catch { showToast && showToast("Ошибка изменения доступа"); } finally { setSaving(null); } } return (

Операторы

{operators.length} операторов · {operators.filter((o) => o.online).length} онлайн
{/* Телефон: шесть колонок не помещаются — те же данные карточками */}
{operators.length === 0 && (
Нет операторов
)} {operators.map((op) => (
{op.name}
{op.tg}{op.tgId ? ` · ID ${op.tgId}` : ""}
{op.role === "admin" ? "Администратор" : "Агент"}
Доступ к ВПН
{op.role === "admin" ? ( все сервисы ) : services.length === 0 ? ( — ) : (
{services.map((sv) => { const on = (op.serviceIds || []).includes(sv.id); const busy = saving === `${op.id}:${sv.id}`; return ( ); })}
)}
))}
{/* overflow-x-auto, а не hidden: на планшете таблица шире колонки, и скрытые колонки иначе не достать. */}
{operators.map((op) => ( ))} {operators.length === 0 && }
Имя Telegram Роль Доступ к ВПН Статус Действия
{op.name}
{op.tg}
{op.tgId &&
ID {op.tgId}
} {!op.tgId &&
ID не задан
}
{op.role === "admin" ? "Администратор" : "Агент"} {op.role === "admin" ? ( все сервисы ) : services.length === 0 ? ( — ) : (
{services.map((s) => { const on = (op.serviceIds || []).includes(s.id); const busy = saving === `${op.id}:${s.id}`; return ( ); })}
)}
Нет операторов
); } // ── Секция «Клиенты»: откуда панель берёт профиль клиента ──────────────────── // Список источников приходит из реестра app.customer — свой провайдер // появляется здесь сам, достаточно положить файл в app/providers/. function CustomerSection({ showToast, service }) { const [cfg, setCfg] = useStateT(null); const [configText, setConfigText] = useStateT("{}"); const [saving, setSaving] = useStateT(false); const [err, setErr] = useStateT(null); useEffectT(() => { setCfg(null); setErr(null); window.apiFetch("GET", "/api/settings/customer" + svcQuery(service)) .then((d) => { setCfg(d); setConfigText(JSON.stringify(d.config || {}, null, 2)); }) .catch(() => setCfg(null)); }, [service?.id]); async function save(next) { setSaving(true); try { await window.apiFetch("PUT", "/api/settings/customer" + svcQuery(service), { provider: next.provider, config: next.config, cacheTtl: next.cacheTtl, }); showToast("Источник данных о клиентах сохранён"); setErr(null); } catch (e) { showToast("Ошибка сохранения"); } setSaving(false); } function pickProvider(name) { const next = { ...cfg, provider: name }; setCfg(next); save(next); } function saveConfig() { let parsed; try { parsed = JSON.parse(configText || "{}"); } catch (e) { setErr("Это не похоже на JSON: " + e.message); return; } const next = { ...cfg, config: parsed }; setCfg(next); save(next); } if (!cfg) return
Загрузка...
; const current = (cfg.available || []).find((p) => p.name === cfg.provider); const supported = new Set(current?.actions || []); const disabled = new Set((cfg.config || {}).disable || []); return (

Источник данных о клиентах

Адрес и токен Support API спрашивают прямо в форме сервиса — сюда заходят, только чтобы выбрать другой источник или погасить лишние кнопки
Источник данных · {cfg.serviceName}
{/* Остальные источники (http, mock, remnawave и свои) в проде не используются — показываем только bot_api, плюс то, что уже реально выбрано у этого сервиса, чтобы не спрятать активный выбор молча, если он вдруг не bot_api. */} {(cfg.available || []) .filter((p) => p.name === "bot_api" || p.name === cfg.provider) .map((p) => { const on = cfg.provider === p.name; return ( ); })}
{current &&
{current.description}
} {current?.isMock && (
Сейчас показываются выдуманные данные. Чтобы подключить настоящую API, выберите bot_api и заполните адрес и токен Support API в форме сервиса («Настройки → Сервисы»).
)}
Конфигурация источника