// web/app.jsx — корневой компонент.
// Hash-routing: #/shoots, #/ingest, #/settings.
// При первом запуске сам определяет где мы (Я.Диск подключён или нет).

function App() {
  // Гейт авторизации (фаза 2). Токен в localStorage → проверяем auth.Me.
  const [auth, setAuth] = React.useState({ checked: false, me: null, view: "login", justRegistered: false });

  React.useEffect(() => {
    if (!api.auth.getToken()) { setAuth((a) => ({ ...a, checked: true })); return; }
    api.auth.Me()
      .then((me) => setAuth((a) => ({ ...a, checked: true, me })))
      .catch(() => { api.auth.logout(); setAuth((a) => ({ ...a, checked: true, me: null })); });
  }, []);

  if (!auth.checked) {
    return <div className="muted" style={{ padding: 48 }}>загрузка…</div>;
  }
  if (!auth.me) {
    return auth.view === "register"
      ? <RegisterScreen
          onRegistered={(me) => setAuth({ checked: true, me, view: "login", justRegistered: true })}
          onGoLogin={() => setAuth((a) => ({ ...a, view: "login" }))} />
      : <LoginScreen
          onLogin={(me) => setAuth({ checked: true, me, view: "login", justRegistered: false })}
          onGoRegister={() => setAuth((a) => ({ ...a, view: "register" }))} />;
  }

  const logout = () => { api.auth.logout(); setAuth({ checked: true, me: null, view: "login", justRegistered: false }); };
  return (
    <ToastProvider>
      <AppInner me={auth.me} justRegistered={auth.justRegistered} onLogout={logout} />
    </ToastProvider>
  );
}

function AppInner({ me, justRegistered, onLogout }) {
  const [routeState, _setRoute] = React.useState(() => parseHash());
  const route = routeState.name;
  const routeArg = routeState.arg;
  const [yandex] = useAsync(() => api.yandex.Status(), []);

  // setRoute(name) — простой переход, setRoute(name, arg) — с параметром.
  const setRoute = React.useCallback((name, arg) => {
    const hash = arg != null ? "#/" + name + "/" + arg : "#/" + name;
    window.location.hash = hash;
    _setRoute({ name, arg });
  }, []);

  // Impersonation (И3): админ «Смотреть как {логин}». setImpersonate шлёт
  // заголовок X-Impersonate-User на /rpc/. mode: view (просмотр) / control.
  const [imp, setImp] = React.useState(null);
  const startImpersonate = (user) => { api.admin.setImpersonate(user.id); setImp({ user, mode: "view" }); setRoute("shoots"); };
  const stopImpersonate = () => { api.admin.setImpersonate(0); setImp(null); setRoute("users"); };
  const takeControl = () => setImp((im) => im ? { ...im, mode: "control" } : im);

  // Эффективный профиль/права (И5.2): под impersonation — целевого юзера
  // (auth.Me с заголовком X-Impersonate-User возвращает ЕГО права), иначе сам.
  // По effMe гейтим UI — чтобы «Смотреть как» показывал реальный урезанный вид.
  const [effMe, setEffMe] = React.useState(me);
  React.useEffect(() => {
    let alive = true;
    if (imp) {
      api.auth.Me().then((m) => { if (alive) setEffMe(m); }).catch(() => { if (alive) setEffMe(me); });
    } else {
      setEffMe(me);
    }
    return () => { alive = false; };
  }, [imp, me]);
  const can = (code) => !!(effMe && (effMe.isAdmin || (effMe.rights || []).includes(code)));

  React.useEffect(() => {
    const h = () => _setRoute(parseHash());
    window.addEventListener("hashchange", h);
    return () => window.removeEventListener("hashchange", h);
  }, []);

  // Когда узнали статус Я.Диска первый раз — если не подключён и route не задан явно,
  // перенаправляем на /settings.
  React.useEffect(() => {
    if (!yandex.data) return;
    if (!yandex.data.connected && route === "shoots" && !window.location.hash) {
      setRoute("settings");
    }
  }, [yandex.data]);

  // Уводим на «Мои съёмки» с недоступных роутов (напр. из устаревшего хэша
  // после смены пользователя): админ-роуты — только админу, ingest — только
  // тем, у кого доступ к Я.Диску.
  React.useEffect(() => {
    if (!effMe) return;
    const adminOnly = route === "plans" || route === "users" || route === "settings";
    if (adminOnly && !effMe.isAdmin) { setRoute("shoots"); return; }
    if (route === "ingest" && !(effMe.isAdmin || effMe.ownYandex)) { setRoute("shoots"); return; }
    if (route === "review1" && !can("r1.access")) { setRoute("shoots"); return; }
    if (route === "review2" && !can("r2.access")) { setRoute("shoots"); return; }
    if (route === "releases" && !can("release.access")) { setRoute("shoots"); }
  }, [effMe, route]);

  // Содержимое экрана.
  let screen;
  if (yandex.loading && route === "shoots") {
    screen = <div className="muted" style={{ padding: 48 }}><Spinner /> загружаем…</div>;
  } else if (route === "settings") {
    screen = <SettingsScreen />;
  } else if (route === "ingest") {
    screen = <IngestScreen setRoute={setRoute} />;
  } else if (route === "archive") {
    screen = <ArchiveScreen setRoute={setRoute} />;
  } else if (route === "releases") {
    screen = <ReleasesScreen setRoute={setRoute} />;
  } else if (route === "upload") {
    screen = <UploadScreen setRoute={setRoute} me={effMe} />;
  } else if (route === "plans" && effMe && effMe.isAdmin) {
    screen = <AdminPlansScreen />;
  } else if (route === "users" && effMe && effMe.isAdmin) {
    screen = <AdminUsersScreen onImpersonate={startImpersonate} />;
  } else if (route === "series" && routeArg) {
    screen = <SeriesScreen shootID={parseInt(routeArg, 10)} setRoute={setRoute} />;
  } else if (route === "review1" && routeArg) {
    screen = can("r1.access")
      ? <Review1Screen shootID={parseInt(routeArg, 10)} setRoute={setRoute} isAdmin={!!(me && me.isAdmin)} />
      : <NoAccess what="Ревью 1" setRoute={setRoute} />;
  } else if (route === "review2" && routeArg) {
    screen = can("r2.access")
      ? <Review2Screen shootID={parseInt(routeArg, 10)} setRoute={setRoute} isAdmin={!!(me && me.isAdmin)} />
      : <NoAccess what="Ревью 2" setRoute={setRoute} />;
  } else if (route === "shoots") {
    // Экран «подключите Яндекс» — только для тех, кто с Я.Диском работает
    // (админ / «Свой Яндекс»). Обычный юзер всегда видит свои серии.
    const yAccess = effMe && (effMe.isAdmin || effMe.ownYandex);
    if (yAccess && yandex.data && !yandex.data.connected) {
      screen = <EmptyScreen setRoute={setRoute} />;
    } else {
      screen = <ShootsScreen setRoute={setRoute} me={effMe} />;
    }
  } else {
    screen = <ShootsScreen setRoute={setRoute} me={effMe} />;
  }

  return (
    <div className="app">
      <Topbar route={route} routeArg={routeArg} yandex={yandex.data} setRoute={setRoute} me={effMe} onLogout={onLogout}/>
      <Sidebar route={route} setRoute={setRoute} me={effMe}/>
      <main className="main" key={route + (routeArg || "")}>
        {imp && (
          <div style={{ display: "flex", alignItems: "center", gap: 12, padding: "9px 14px", marginBottom: 12,
            background: imp.mode === "control" ? "#e8731c" : "#f6b352", color: "#fff", borderRadius: 8, fontSize: 13 }}>
            <span>👁 <b>Смотрю как {imp.user.login}</b> · {imp.mode === "control" ? "режим управления — действия применяются к аккаунту пользователя" : "режим просмотра — изменения не сохраняются"}</span>
            <span style={{ flex: 1 }} />
            {imp.mode === "view"
              ? <button onClick={takeControl} style={{ padding: "4px 10px", border: "1px solid rgba(255,255,255,.6)", background: "rgba(255,255,255,.15)", color: "#fff", borderRadius: 6, cursor: "pointer", fontSize: 12 }}>Взять управление</button>
              : <span style={{ fontSize: 12, fontWeight: 600 }}>● Управление активно</span>}
            <button onClick={stopImpersonate} style={{ padding: "4px 10px", border: "1px solid rgba(255,255,255,.6)", background: "transparent", color: "#fff", borderRadius: 6, cursor: "pointer", fontSize: 12 }}>✕ Выйти</button>
          </div>
        )}
        {effMe && effMe.onTrial && (
          <div style={{ display: "flex", alignItems: "center", gap: 10, padding: "9px 14px", marginBottom: 12,
            background: "#fff7e6", border: "1px solid #f0d9a8", borderRadius: 8, fontSize: 13, color: "#7a5b1e" }}>
            <span>⏳ <b>Пробный период:</b> осталось{effMe.trialFilesLeft != null ? " " + effMe.trialFilesLeft + " фото" : ""}{effMe.trialUntil ? " / " + Math.max(0, Math.ceil((new Date(effMe.trialUntil).getTime() - Date.now()) / 86400000)) + " дн." : ""}. Потом аккаунт перейдёт на бесплатный тариф.</span>
          </div>
        )}
        {screen}
      </main>
    </div>
  );
}

// NoAccess — заглушка для роутов, недоступных по правам (И5.2).
function NoAccess({ what, setRoute }) {
  return (
    <div className="page" style={{ padding: 48, textAlign: "center" }}>
      <div style={{ fontSize: 15, marginBottom: 8 }}>Нет доступа к «{what}»</div>
      <div className="muted" style={{ fontSize: 13, marginBottom: 16 }}>Эта возможность не включена в твоих правах.</div>
      <button className="btn btn-primary" onClick={() => setRoute("shoots")}>← К моим съёмкам</button>
    </div>
  );
}

// Topbar — узкая верхняя полоса: бренд + хлебные крошки + статусы сервисов.
// Крошки — настоящая навигация: для серии/ревью показывают НАЗВАНИЕ серии
// (а не внутренний номер) + текущий этап, и кликабельны для возврата назад
// (правка 2026-06-07 по запросу Лены).
function Topbar({ route, routeArg, yandex, setRoute, me, onLogout }) {
  const [pwOpen, setPwOpen] = React.useState(false); // модалка «сменить пароль»
  // Для серии/ревью подгружаем shoot ради названия и этапа. Лёгкий поллинг
  // 10 сек — этап меняется медленно, держим крошку свежей.
  const isSeries = (route === "series" || route === "review1" || route === "review2") && routeArg;
  const [shoot, setShoot] = React.useState(null);
  React.useEffect(() => {
    if (!isSeries) { setShoot(null); return; }
    let alive = true;
    const load = () => api.feed.shoots()
      .then((list) => {
        if (!alive) return;
        const s = (list || []).find((x) => String(x.ID) === String(routeArg));
        setShoot(s || null);
      })
      .catch(() => {});
    load();
    const t = setInterval(load, 10000);
    return () => { alive = false; clearInterval(t); };
  }, [route, routeArg]);

  const crumbs = crumbsFor(route, routeArg, shoot);
  return (
    <div className="topbar">
      <div className="brand" style={{ display: "flex", alignItems: "center", gap: 8 }}>
        <div className="brand-mark">P</div>
        <div style={{ fontWeight: 600, fontSize: 14 }}>
          pika<span style={{ color: "var(--ink-3)", fontWeight: 400 }}>srv</span>
        </div>
      </div>
      <div style={{ width: 1, height: 22, background: "var(--line)" }}/>
      <div style={{ display: "flex", alignItems: "center", gap: 6, color: "var(--ink-3)", fontSize: 13 }}>
        {crumbs.map((c, i) => {
          const isLast = i === crumbs.length - 1;
          return (
            <React.Fragment key={i}>
              {i > 0 && <Icon name="chevron" size={11}/>}
              {c.route && !isLast
                ? <button className="crumb-link" onClick={() => setRoute(c.route, c.arg)}>{c.label}</button>
                : <span style={{ color: isLast ? "var(--ink)" : "var(--ink-3)",
                                 fontWeight: isLast ? 500 : 400 }}>{c.label}</span>}
            </React.Fragment>
          );
        })}
      </div>
      <div style={{ flex: 1 }}/>
      <div className="topbar-meta" style={{ display: "flex", gap: 14, fontSize: 12, color: "var(--ink-3)" }}>
        <StatusBadge label="Я.Диск" ok={yandex?.connected}/>
        <StatusBadge label="OpenClaw" ok={false} hint="не настроено"/>
        <StatusBadge label="БД" ok={true}/>
      </div>
      <div className="user-chip" style={{
        display: "flex", alignItems: "center", gap: 8,
        padding: "3px 10px 3px 3px", border: "1px solid var(--line)",
        borderRadius: 99, fontSize: 12.5, color: "var(--ink-2)",
      }}>
        <span style={{
          width: 24, height: 24, borderRadius: 99,
          background: "var(--accent-soft)", color: "var(--accent-2)",
          display: "flex", alignItems: "center", justifyContent: "center",
          fontSize: 11, fontWeight: 600,
        }}>{(me && me.login ? me.login.slice(0, 2) : "АД").toUpperCase()}</span>
        <span>{me ? me.login : "—"}{me && me.isAdmin ? " · админ" : ""}</span>
        <button onClick={() => setPwOpen(true)} title="Сменить пароль" style={{
          background: "none", border: "none", cursor: "pointer", color: "var(--ink-3)",
          marginLeft: 4, padding: 0, fontSize: 12 }}>пароль</button>
        {onLogout && (
          <button onClick={onLogout} title="Выйти" style={{
            background: "none", border: "none", cursor: "pointer", color: "var(--ink-3)",
            marginLeft: 4, padding: 0, fontSize: 12 }}>выйти</button>
        )}
      </div>
      {pwOpen && <ChangePasswordModal onClose={() => setPwOpen(false)} />}
    </div>
  );
}

// ChangePasswordModal — форма смены собственного пароля (auth.ChangePassword).
function ChangePasswordModal({ onClose }) {
  const [oldP, setOldP] = React.useState("");
  const [newP, setNewP] = React.useState("");
  const [rep, setRep] = React.useState("");
  const [busy, setBusy] = React.useState(false);
  const [, toast] = useToasts();
  const canSave = oldP && newP.length >= 8 && newP === rep && !busy;
  const submit = async () => {
    if (!canSave) return;
    setBusy(true);
    try {
      await api.auth.ChangePassword(oldP, newP);
      toast("Пароль изменён", "info");
      onClose();
    } catch (e) {
      toast("Не удалось: " + (e.message || "ошибка"), "error");
    } finally { setBusy(false); }
  };
  const inp = { width: "100%", padding: "7px 9px", fontSize: 13, marginTop: 4,
    border: "1px solid var(--line-2, #d7dad3)", borderRadius: 6, boxSizing: "border-box" };
  return (
    <div onClick={onClose} style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,.35)",
      display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1000 }}>
      <div onClick={(e) => e.stopPropagation()} style={{ background: "var(--surface, #fff)",
        padding: 22, borderRadius: 12, width: 320, boxShadow: "0 10px 40px rgba(0,0,0,.2)" }}>
        <h3 style={{ margin: "0 0 12px", fontSize: 16 }}>Сменить пароль</h3>
        <label style={{ fontSize: 12, color: "var(--ink-3)" }}>Текущий пароль
          <input type="password" style={inp} value={oldP} autoFocus onChange={(e) => setOldP(e.target.value)} /></label>
        <label style={{ fontSize: 12, color: "var(--ink-3)", display: "block", marginTop: 10 }}>Новый пароль (мин. 8)
          <input type="password" style={inp} value={newP} onChange={(e) => setNewP(e.target.value)} /></label>
        <label style={{ fontSize: 12, color: "var(--ink-3)", display: "block", marginTop: 10 }}>Ещё раз
          <input type="password" style={inp} value={rep} onChange={(e) => setRep(e.target.value)}
                 onKeyDown={(e) => e.key === "Enter" && submit()} /></label>
        {newP && rep && newP !== rep && <div style={{ fontSize: 11, color: "var(--danger,#c0392b)", marginTop: 6 }}>пароли не совпадают</div>}
        <div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 16 }}>
          <button className="btn" onClick={onClose}>Отмена</button>
          <button className="btn btn-accent" onClick={submit} disabled={!canSave}>
            {busy ? "…" : "Сменить"}</button>
        </div>
      </div>
    </div>
  );
}

function StatusBadge({ label, ok, hint }) {
  return (
    <span style={{ display: "inline-flex", alignItems: "center", gap: 5 }} title={hint || ""}>
      <span style={{
        width: 7, height: 7, borderRadius: 99,
        background: ok ? "var(--ok, #82b39a)" : "var(--warn, #d28a52)",
      }}/>
      {label}
    </span>
  );
}

// crumbsFor возвращает массив крошек { label, route?, arg? }. Крошка с route —
// кликабельная (переход). Последняя всегда «текущая» (не кликается). Для серии
// показываем название + текущий этап вместо внутреннего номера.
function crumbsFor(route, arg, shoot) {
  const title = (shoot && shoot.Title) ? shoot.Title : `Серия ${arg || ""}`;
  if (route === "shoots")   return [{ label: "Мои съёмки" }];
  if (route === "ingest")   return [{ label: "Мои съёмки", route: "shoots" }, { label: "Обработать новые" }];
  if (route === "archive")  return [{ label: "Архив" }];
  if (route === "releases") return [{ label: "Релизы" }];
  if (route === "settings") return [{ label: "Настройки" }];
  if (route === "upload")   return [{ label: "Загрузить" }];
  if (route === "plans")    return [{ label: "Тарифы и группы" }];
  if (route === "users")    return [{ label: "Пользователи" }];
  if (route === "series") {
    const crumbs = [{ label: "Мои съёмки", route: "shoots" }, { label: title }];
    if (shoot) {
      const info = stageInfo(shoot.Stage, shoot);
      const stageLabel = (STAGES[info.idx] && STAGES[info.idx].label) || shoot.Stage;
      crumbs.push({ label: stageLabel });
    }
    return crumbs;
  }
  if (route === "review1")  return [{ label: "Мои съёмки", route: "shoots" }, { label: title, route: "series", arg }, { label: "Ревью 1" }];
  if (route === "review2")  return [{ label: "Мои съёмки", route: "shoots" }, { label: title, route: "series", arg }, { label: "Ревью 2" }];
  return [];
}

function Sidebar({ route, setRoute, me }) {
  // Грузим серии для счётчиков и очереди + активные per-photo операции
  // (reanalyse/rerecognize). Polling 2.5 сек — было 5, Лена жаловалась что
  // после «Переделать» долго не появляется в работе (правка 2026-06-05).
  // shoots отдельно опрашиваем реже (10 сек) — стадии меняются медленно.
  const [active, setActive] = React.useState([]);
  const [archived, setArchived] = React.useState([]);
  const [jobs, setJobs] = React.useState([]);
  const refreshJobs = React.useCallback(() => {
    if (typeof api.jobs === "function") {
      api.jobs().then((r) => setJobs(Array.isArray(r) ? r : [])).catch(() => {});
    }
  }, []);
  const refreshShoots = React.useCallback(() => {
    api.feed.shoots("false").then((r) => setActive(Array.isArray(r) ? r : [])).catch(() => {});
    api.feed.shoots("true").then((r) => setArchived(Array.isArray(r) ? r : [])).catch(() => {});
  }, []);
  React.useEffect(() => {
    refreshJobs();
    refreshShoots();
    const tj = setInterval(refreshJobs, 2500);
    const ts = setInterval(refreshShoots, 10000);
    return () => { clearInterval(tj); clearInterval(ts); };
  }, [refreshJobs, refreshShoots]);

  const newCount = active.filter((s) => s.Stage === "new").length;
  // Серии в активной фазе модели — по Stage + фактическому прогрессу.
  // Правка 2026-06-05: если все фотки уже recognized/analyzed/generated, но
  // shoot.Stage ещё не переключён (нет автоматического перехода) — не считаем
  // running, иначе spinner торчит вечно (жалоба Лены на серию 260517 8/8).
  const seriesActive = active.filter((s) => {
    const total = s.Photos || 0;
    switch (s.Stage) {
      case "recognizing": return (s.Recognized || 0) < total;
      case "analyze":     return (s.Analyzed || 0) < total;
      case "generate":    return (s.Generated || 0) < total;
      case "plan":        return true; // plan быстрый, прогресса не считаем
      default:            return false;
    }
  });

  // Человеко-читаемая подпись текущей серийной операции.
  const seriesLabel = (s) => {
    const t = s.Photos || 0;
    // Ждёт воркера в очереди анализа (стадия уже plan — мы помечаем её при
    // постановке, чтобы пережить рестарт, — но воркер ещё не взял): показываем
    // «в очереди», а не «план вариативности» (правка 2026-06-24).
    if (s.AnalyseQueued && !s.AnalyseRunning) return "в очереди на анализ…";
    // Аналогично для генерации текстов (стадия уже generate, воркер ещё не взял).
    if (s.GenerateQueued && !s.GenerateRunning) return "в очереди на тексты…";
    switch (s.Stage) {
      case "recognizing": {
        // Если в работе немного фоток (per-photo «Перераспознать», в т.ч. 2-3
        // подряд) — показываем их имена, а не счётчик N/M серии. Для большого
        // батча (полная серия) остаётся прогресс N/M (правка 2026-06-07).
        const strip = (f) => (f || "").replace(/\.[a-z0-9]+$/i, "");
        const inflight = (s.RecognizingFiles || []).slice().sort();
        if (inflight.length >= 1 && inflight.length <= 3) {
          return inflight.map(strip).join(", ") + " — распознаётся";
        }
        return `${s.Recognized || 0}/${t} — распознавание серии`;
      }
      case "plan":        return "план вариативности";
      case "analyze":     return `${s.Analyzed || 0}/${t} — анализ серии`;
      case "generate":    return `${s.Generated || 0}/${t} — тексты серии`;
      default:            return s.Stage;
    }
  };

  // Объединяем per-photo jobs с серийными активностями. Ключ — shootID
  // (одна серия может одновременно иметь и серийную операцию, и per-photo).
  const byShoot = new Map();
  for (const s of seriesActive) {
    byShoot.set(s.ID, { shoot: s, jobs: [] });
  }
  for (const j of jobs) {
    let entry = byShoot.get(j.shootId);
    if (!entry) {
      // Серия не в active-stage, но per-photo операция идёт. Найдём имя.
      const s = active.find((x) => x.ID === j.shootId);
      if (s) {
        entry = { shoot: s, jobs: [] };
        byShoot.set(j.shootId, entry);
      } else {
        continue; // серия не нашлась — пропускаем
      }
    }
    entry.jobs.push(j);
  }
  // Серии, ждущие воркера в очереди анализа (Submit прошёл, очередь ещё не
  // взяла), и та, что воркер крутит, пока стадия не переключилась на plan/
  // analyze. В seriesActive они не попадают — добавим по флагам с бэка, иначе
  // слева в «В работе» их не видно, хотя справа на карточке статус есть
  // (жалоба Лены 2026-06-23).
  for (const s of active) {
    if ((s.AnalyseQueued || s.AnalyseRunning || s.GenerateQueued || s.GenerateRunning) && !byShoot.has(s.ID)) {
      byShoot.set(s.ID, { shoot: s, jobs: [] });
    }
  }
  const queueGroups = [...byShoot.values()];

  // «Обработать новые» (ingest из Я.Диска) — только админ или члены группы
  // «Свой Яндекс»; остальные работают только через «Загрузить».
  const yandexAccess = me && (me.isAdmin || me.ownYandex);
  const items = [
    { id: "shoots",  label: "Мои съёмки",      icon: "folder",  count: active.length },
    { id: "upload",  label: "Загрузить",       icon: "image" },
    ...(yandexAccess ? [{ id: "ingest", label: "Обработать новые", icon: "inbox", count: newCount, badge: newCount > 0 }] : []),
    { id: "archive", label: "Архив",           icon: "archive", count: archived.length },
    ...((me && (me.isAdmin || (me.rights || []).includes("release.access"))) ? [{ id: "releases", label: "Релизы", icon: "image" }] : []),
  ];

  return (
    <aside className="sidebar">
      <div className="nav-group">
        <div className="nav-label">Конвейер</div>
        {items.map((it) => (
          <button key={it.id}
                  className={"nav-item" + (route === it.id ? " active" : "")}
                  onClick={() => setRoute(it.id)}>
            <span className="nav-left">
              <Icon name={it.icon} size={14} className="nav-icon"/>
              <span>{it.label}</span>
            </span>
            <span className="nav-count">{it.count}</span>
          </button>
        ))}
      </div>

      {me && me.isAdmin && (
        <div className="nav-group">
          <div className="nav-label">Система</div>
          <button className={"nav-item" + (route === "settings" ? " active" : "")}
                  onClick={() => setRoute("settings")}>
            <span className="nav-left">
              <Icon name="settings" size={14} className="nav-icon"/>
              <span>Настройки</span>
            </span>
          </button>
        </div>
      )}

      {me && me.isAdmin && (
        <div className="nav-group">
          <div className="nav-label">Администрирование</div>
          {[["plans", "Тарифы и группы", "layers"], ["users", "Пользователи", "folder"]].map(([id, label, icon]) => (
            <button key={id} className={"nav-item" + (route === id ? " active" : "")} onClick={() => setRoute(id)}>
              <span className="nav-left">
                <Icon name={icon} size={14} className="nav-icon"/>
                <span>{label}</span>
              </span>
            </button>
          ))}
        </div>
      )}

      {queueGroups.length > 0 && (
        <div className="queue-indicator">
          <div className="qi-head" title="Опрашиваем сервер раз в 5 секунд">
            <Spinner/> В работе · {queueGroups.length}
          </div>
          {queueGroups.map(({ shoot: s, jobs: js }) => (
            <div key={s.ID} className="qi-group" style={{ marginBottom: 6 }}>
              <button onClick={() => setRoute("series", s.ID)}
                      title={`Открыть серию ${s.Title || s.ID}`}
                      style={{
                        background: "transparent", border: 0, padding: 0,
                        textAlign: "left", cursor: "pointer", color: "inherit",
                        fontSize: "inherit", fontFamily: "inherit",
                        width: "100%", fontWeight: 500,
                      }}>
                {s.Title || `Серия ${s.ID}`}
              </button>
              {/* Распознавание. Различаем по ТИПУ запуска (флаг RecognizeFull с
                  бэка), а не по числу кадров:
                    • запуск ВСЕЙ серии → прогресс N/M одной строкой;
                    • поштучный / частичный ретрай (per-photo, «все с ошибкой»,
                      «Заново») → имена файлов с их статусом; если их слишком
                      много — короткая сводка (правка 2026-06-08). */}
              {s.Stage === "recognizing" && (() => {
                const inflight = s.InFlight || [];
                if (inflight.length === 0) return null;
                const subStyle = { paddingLeft: 12, fontSize: 11, color: "var(--ink-3)", marginTop: 1 };
                if (s.RecognizeFull) {
                  return (
                    <div className="qi-sub" style={subStyle}>
                      ↳ {(s.Recognized || 0)}/{(s.Photos || 0)} — распознавание серии
                    </div>
                  );
                }
                const strip = (f) => (f || "").replace(/\.[a-z0-9]+$/i, "");
                const sorted = inflight.slice().sort((a, b) => {
                  if (a.Stage !== b.Stage) return a.Stage === "recognizing" ? -1 : 1;
                  return (a.Filename || "").localeCompare(b.Filename || "", undefined, { numeric: true });
                });
                if (sorted.length > 6) {
                  return (
                    <div className="qi-sub" style={subStyle}>
                      ↳ перераспознаётся {sorted.length} кадров с ошибкой
                    </div>
                  );
                }
                return sorted.map((f, i) => (
                  <div key={i} className="qi-sub" style={subStyle}>
                    ↳ {strip(f.Filename)} — {f.Stage === "recognizing" ? "распознаётся" : "в очереди"}
                  </div>
                ));
              })()}
              {/* План / анализ / тексты — одна строка-сводка. */}
              {["plan", "analyze", "generate"].includes(s.Stage) && (
                <div className="qi-sub" style={{
                  paddingLeft: 12, fontSize: 11, color: "var(--ink-3)",
                  marginTop: 1,
                }}>
                  ↳ {seriesLabel(s)}
                </div>
              )}
              {/* Ждёт/идёт анализ, но стадия ещё дозапусковая (не plan/analyze/
                  generate) — серия в очереди анализа. */}
              {!["recognizing", "plan", "analyze", "generate"].includes(s.Stage)
                && (s.AnalyseQueued || s.AnalyseRunning) && (
                <div className="qi-sub" style={{
                  paddingLeft: 12, fontSize: 11, color: "var(--ink-3)",
                  marginTop: 1,
                }}>
                  ↳ {s.AnalyseRunning ? "анализ серии…" : "в очереди на анализ…"}
                </div>
              )}
              {/* Per-photo операции (reanalyse, rerecognize) для этой серии. */}
              {js.map((j) => (
                <div key={j.photoId} className="qi-sub" style={{
                  paddingLeft: 12, fontSize: 11, color: "var(--ink-3)",
                  marginTop: 1,
                }}>
                  ↳ {j.filename ? j.filename.replace(/\.[a-z]+$/i, "") : "фото"} — {j.op}
                </div>
              ))}
            </div>
          ))}
        </div>
      )}

      <div style={{ flex: 1 }}/>
    </aside>
  );
}

// Простой парсер hash: #/route или #/route/arg → { name, arg }.
function parseHash() {
  const h = window.location.hash.replace(/^#\/?/, "");
  if (!h) return { name: "shoots", arg: null };
  const [name, arg] = h.split("/");
  if (["ingest", "settings", "shoots", "archive", "releases", "series", "review1", "review2", "upload", "plans", "users"].includes(name)) {
    return { name, arg: arg || null };
  }
  return { name: "shoots", arg: null };
}

window.App = App;
