// web/screens/admin.jsx — Админка фазы 2 (И3): «Тарифы и группы» + «Пользователи». // Работает поверх api.admin.*. Только для админа (роут гейтится в app.jsx). // ── маленькая полоска квоты ── function QBar({ used, limit }) { const unlimited = limit == null || limit < 0; const pct = unlimited ? 0 : Math.min(100, Math.round((used / Math.max(1, limit)) * 100)); const full = !unlimited && used >= limit; return (
{used} {unlimited ? "/ ∞" : "/ " + limit}
{!unlimited && (
)}
); } // ════════════════════════════ Тарифы и группы ════════════════════════════ function AdminPlansScreen() { const [tab, setTab] = React.useState("groups"); return (

Тарифы и группы

Группа прав = что пользователь умеет · тариф = сколько можно
{[["groups", "Группы прав"], ["tariffs", "Тарифы"], ["trial", "Триал"]].map(([id, label]) => ( ))}
{tab === "groups" && } {tab === "tariffs" && } {tab === "trial" && }
); } function GroupsTab() { const [data, reload] = useAsync(() => Promise.all([ api.admin.OptionsCatalog(), api.admin.Groups(), api.admin.Tariffs(), ]).then(([options, groups, tariffs]) => ({ options, groups, tariffs })), []); const [, toast] = useToasts(); const [groups, setGroups] = React.useState(null); const [saving, setSaving] = React.useState(false); const [newGroupName, setNewGroupName] = React.useState(""); React.useEffect(() => { if (data.data) setGroups(JSON.parse(JSON.stringify(data.data.groups))); }, [data.data]); if (data.loading || !groups) return
загружаем…
; if (data.error) return
{String(data.error.message)}
; const { options, tariffs } = data.data; const perm = groups.filter((g) => g.groupType !== "membership"); const membership = groups.filter((g) => g.groupType === "membership"); // разделы каталога const sections = []; const seen = {}; options.forEach((o) => { if (!seen[o.section]) { seen[o.section] = []; sections.push(o.section); } seen[o.section].push(o); }); const toggle = (gid, code) => setGroups((gs) => gs.map((g) => { if (g.id !== gid) return g; const opts = new Set(g.options || []); opts.has(code) ? opts.delete(code) : opts.add(code); return { ...g, options: [...opts] }; })); const setLimit = (gid, field, v) => setGroups((gs) => gs.map((g) => g.id === gid ? { ...g, [field]: v === "" ? null : Math.max(0, parseInt(v, 10) || 0) } : g)); const setBase = (gid, v) => setGroups((gs) => gs.map((g) => g.id === gid ? { ...g, baseTariffId: v ? parseInt(v, 10) : null } : g)); const save = async () => { setSaving(true); try { for (const g of perm) { await api.admin.SaveGroup({ id: g.id, name: g.name, groupType: "permission", blockName: g.blockName, baseTariffId: g.baseTariffId, recognitionRetries: g.recognitionRetries, analysisRetries: g.analysisRetries, options: g.options || [], }); } toast("Группы сохранены", "ok"); reload(); } catch (e) { toast("Ошибка: " + e.message, "error"); } finally { setSaving(false); } }; const addGroup = async () => { const name = newGroupName.trim(); if (!name) return; try { await api.admin.SaveGroup({ id: 0, name, groupType: "permission", options: [] }); setNewGroupName(""); reload(); } catch (e) { toast("Ошибка: " + e.message, "error"); } }; return (
setNewGroupName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") addGroup(); }} placeholder="Новая группа прав…" style={{ ...inp, width: 220 }} />
Строки — права, колонки — группы. Галки складываются: возможность доступна, если включена хотя бы в одной группе пользователя. Два лимита-пула на группу.
{perm.map((g) => ( ))} {sections.map((sec) => ( {seen[sec].map((o) => ( {perm.map((g) => { const on = (g.options || []).includes(o.code); return ( ); })} ))} ))}
Право
{g.name}
{g.memberCount || 0} польз.
{sec}
{o.label} {o.limitKind && лимит · {o.limitKind === "recognition" ? "Р" : "А"}}
{membership.length > 0 && (
Membership-группы (только участники)
{membership.map((g) => ( {g.name} · {g.memberCount || 0} ))}
)}
); } function TariffsTab() { const [data, reload] = useAsync(() => api.admin.Tariffs(), []); const [, toast] = useToasts(); if (data.loading) return
загружаем…
; const tariffs = data.data || []; const save = async (t) => { try { await api.admin.SaveTariff(t); toast("Сохранено", "ok"); reload(); } catch (e) { toast(e.message, "error"); } }; const del = async (id) => { if (!confirm("Удалить тариф?")) return; try { await api.admin.DeleteTariff(id); reload(); } catch (e) { toast(e.message, "error"); } }; return (
Квоты: лимит файлов на диске и распознавание (месяц / пакет). «Лимит/день» — дневной потолок распознаваний для распределения нагрузки: 0 = авто (10% от месячной квоты), -1 = без лимита, число = вручную. Серия, не влезающая в дневной остаток, делится на «(1)» (в работу) и «(2)» (на завтра).
{tariffs.map((t) => )}
ТарифФайловРаспозн.Лимит/деньТипЦена
); } function TariffRow({ t, isNew, onSave, onDel }) { const [v, setV] = React.useState(t); React.useEffect(() => setV(t), [t.id]); const f = (k) => (e) => setV({ ...v, [k]: k === "name" || k === "quotaType" ? e.target.value : parseInt(e.target.value, 10) || 0 }); return (
{v.recognitionDailyLimit === -1 ? "без лимита" : v.recognitionDailyLimit > 0 ? "вручную" : `авто: ${v.recognitionQuota > 0 ? Math.max(1, Math.ceil(v.recognitionQuota / 10)) : 0}/д`}
{!isNew && } ); } function TrialTab() { const [data] = useAsync(() => Promise.all([api.admin.TrialSettings(), api.admin.Tariffs()]).then(([ts, tariffs]) => ({ ts, tariffs })), []); const [, toast] = useToasts(); const [v, setV] = React.useState(null); React.useEffect(() => { if (data.data) setV(data.data.ts); }, [data.data]); if (data.loading || !v) return
загружаем…
; const tariffs = data.data.tariffs; const save = async () => { try { await api.admin.SaveTrialSettings(v); toast("Сохранено", "ok"); } catch (e) { toast(e.message, "error"); } }; return (
Новый пользователь при регистрации получает пробный период. Кончится раньше — по фото или дням — падает на тариф «по окончании».
setV({ ...v, photos: parseInt(e.target.value, 10) || 0 })} /> setV({ ...v, days: parseInt(e.target.value, 10) || 0 })} />
); } // ════════════════════════════ Пользователи ════════════════════════════ function AdminUsersScreen({ onImpersonate }) { const [data, reload] = useAsync(() => Promise.all([api.admin.Users(), api.admin.Groups(), api.admin.Tariffs()]) .then(([users, groups, tariffs]) => ({ users, groups, tariffs })), []); const [, toast] = useToasts(); const [q, setQ] = React.useState(""); const [tariffF, setTariffF] = React.useState(""); const [groupF, setGroupF] = React.useState(""); const [editing, setEditing] = React.useState(null); // юзер для модалки групп if (data.loading) return
загружаем…
; if (data.error) return
{String(data.error.message)}
; const { users, groups, tariffs } = data.data; const filtered = users.filter((u) => { if (q && !(u.login + " " + (u.email || "")).toLowerCase().includes(q.toLowerCase())) return false; if (tariffF && u.tariffLabel !== tariffF) return false; if (groupF && !(u.groupIds || []).includes(parseInt(groupF, 10))) return false; return true; }); const tariffLabels = [...new Set(users.map((u) => u.tariffLabel))]; const block = async (u) => { try { await api.admin.BlockUser(u.id, !u.blocked); reload(); } catch (e) { toast(e.message, "error"); } }; const saveNote = async (u, note) => { try { await api.admin.SetUserNote(u.id, note); toast("Заметка сохранена", "ok"); reload(); } catch (e) { toast(e.message, "error"); } }; const setTariff = async (u, tid) => { try { await api.admin.SetUserTariff(u.id, tid); toast("Тариф изменён", "ok"); reload(); } catch (e) { toast(e.message, "error"); } }; const saveGroups = async (gids) => { try { await api.admin.SetUserGroups(editing.id, gids); toast("Группы обновлены", "ok"); setEditing(null); reload(); } catch (e) { toast(e.message, "error"); } }; return (

Пользователи · {filtered.length}/{users.length}

setQ(e.target.value)} />
Фильтр: {(tariffF || groupF || q) && }
{filtered.map((u) => ( ))}
ПользовательТарифРаспознаваниеФайлыГруппыСерий
{u.login}{u.isAdmin && админ}{u.blocked && заблок.}
{u.email || "—"}
{/* Заметка админа (видна только админу): имя/причина бана. Сохраняется по blur/Enter. */} { const t = e.target.value.trim(); if (t !== (u.note || "")) saveNote(u, t); }} onKeyDown={(e) => { if (e.key === "Enter") e.target.blur(); }} />
{u.onTrial &&
триал: {u.trialDaysLeft}дн · {u.trialFilesLeft ?? "?"}ф
} {u.isAdmin ? Админ : ( )}
{!u.isAdmin &&
{u.onTrial ? "триал" : u.quotaType === "pack" ? "разовый пакет" : "в месяц"}
}
{(u.groupNames || []).map((n, i) => {n})}
{u.shootCount} {!u.isAdmin && <> }
{editing && setEditing(null)} />}
); } // Модалка редактирования групп пользователя — мультивыбор add/remove, сохраняет // весь набор разом (снятая галка = убрать из группы). function GroupsModal({ user, groups, onSave, onClose }) { const [sel, setSel] = React.useState(() => new Set(user.groupIds || [])); const toggle = (gid) => setSel((s) => { const n = new Set(s); n.has(gid) ? n.delete(gid) : n.add(gid); return n; }); return (
e.stopPropagation()} style={{ background: "#fff", borderRadius: 12, padding: 20, width: 360, maxHeight: "80vh", overflow: "auto", boxShadow: "0 10px 40px rgba(0,0,0,.18)" }}>

Группы · {user.login}

Отметь группы и сохрани. Снятая галка — убрать из группы.
{groups.length === 0 &&
Групп ещё нет — создай в «Тарифы и группы».
} {groups.map((g) => ( ))}
); } // ── общие стили/мелочи ── function Field({ label, children }) { return
{label}
{children}
; } const inp = { padding: "6px 9px", fontSize: 12.5, border: "1px solid var(--line-2,#d7dad3)", borderRadius: 7, background: "#fff" }; const td = { padding: "8px 10px", verticalAlign: "middle" }; const tdh = { padding: "6px 10px" }; const btnPrimary = { padding: "9px 16px", fontSize: 13, fontWeight: 600, border: "none", borderRadius: 8, background: "var(--accent,#3f6f52)", color: "#fff", cursor: "pointer" }; const btnGhost = { padding: "9px 14px", fontSize: 13, border: "1px solid var(--line-2,#d7dad3)", borderRadius: 8, background: "#fff", cursor: "pointer" }; const btnSm = { padding: "3px 8px", fontSize: 11.5, border: "1px solid var(--line-2,#d7dad3)", borderRadius: 6, background: "#fff", cursor: "pointer", marginRight: 4 }; window.AdminPlansScreen = AdminPlansScreen; window.AdminUsersScreen = AdminUsersScreen;