diff --git a/src/renderer/appDefaults.js b/src/renderer/appDefaults.js
new file mode 100644
index 0000000..feab230
--- /dev/null
+++ b/src/renderer/appDefaults.js
@@ -0,0 +1,118 @@
+export const emptySettings = {
+ competitorGroups: [""],
+ ourGroup: "",
+ minIntervalMinutes: 5,
+ maxIntervalMinutes: 10,
+ dailyLimit: 100,
+ historyLimit: 200,
+ accountMaxGroups: 10,
+ accountDailyLimit: 50,
+ floodCooldownMinutes: 1440,
+ queueTtlHours: 24
+};
+
+export const emptyTaskForm = {
+ id: null,
+ name: "",
+ ourGroup: "",
+ minIntervalMinutes: 1,
+ maxIntervalMinutes: 3,
+ dailyLimit: 15,
+ historyLimit: 35,
+ maxInvitesPerCycle: 1,
+ maxCompetitorBots: 1,
+ maxOurBots: 1,
+ randomAccounts: false,
+ multiAccountsPerRun: false,
+ retryOnFail: true,
+ autoJoinCompetitors: true,
+ autoJoinOurGroup: true,
+ separateBotRoles: false,
+ requireSameBotInBoth: true,
+ parseParticipants: false,
+ inviteViaAdmins: false,
+ inviteAdminMasterId: 0,
+ inviteAdminAllowFlood: false,
+ inviteAdminAnonymous: true,
+ separateConfirmRoles: false,
+ maxConfirmBots: 1,
+ useWatcherInviteNoUsername: true,
+ warmupEnabled: true,
+ warmupStartLimit: 3,
+ warmupDailyIncrease: 2,
+ cycleCompetitors: false,
+ competitorCursor: 0,
+ inviteLinkOnFail: false,
+ rolesMode: "manual",
+ stopOnBlocked: true,
+ stopBlockedPercent: 25,
+ notes: "",
+ enabled: true,
+ autoAssignAccounts: true,
+ allowStartWithoutInviteRights: true
+};
+
+export const normalizeIntervals = (form) => {
+ const minValue = Number(form.minIntervalMinutes);
+ const maxValue = Number(form.maxIntervalMinutes);
+ const min = Number.isFinite(minValue) && minValue > 0 ? minValue : 1;
+ let max = Number.isFinite(maxValue) && maxValue > 0 ? maxValue : 1;
+ if (max < min) max = min;
+ return { ...form, minIntervalMinutes: min, maxIntervalMinutes: max };
+};
+
+export const sanitizeTaskForm = (form) => {
+ let normalized = { ...form };
+ normalized = normalizeIntervals(normalized);
+ if (normalized.requireSameBotInBoth) {
+ normalized.separateBotRoles = false;
+ normalized.maxOurBots = normalized.maxCompetitorBots;
+ } else {
+ normalized.separateBotRoles = true;
+ }
+ if (!normalized.separateBotRoles) {
+ normalized.separateConfirmRoles = false;
+ }
+ return normalized;
+};
+
+export const normalizeTask = (row) => ({
+ id: row.id,
+ name: row.name || "",
+ ourGroup: row.our_group || "",
+ minIntervalMinutes: Number(row.min_interval_minutes || 1),
+ maxIntervalMinutes: Number(row.max_interval_minutes || 3),
+ dailyLimit: Number(row.daily_limit || 15),
+ historyLimit: Number(row.history_limit || 35),
+ maxInvitesPerCycle: Number(row.max_invites_per_cycle || 1),
+ maxCompetitorBots: Number(row.max_competitor_bots || 1),
+ maxOurBots: Number(row.max_our_bots || 1),
+ randomAccounts: Boolean(row.random_accounts),
+ multiAccountsPerRun: Boolean(row.multi_accounts_per_run),
+ retryOnFail: Boolean(row.retry_on_fail),
+ autoJoinCompetitors: Boolean(row.auto_join_competitors),
+ autoJoinOurGroup: Boolean(row.auto_join_our_group),
+ separateBotRoles: Boolean(row.separate_bot_roles),
+ requireSameBotInBoth: Boolean(row.require_same_bot_in_both),
+ parseParticipants: Boolean(row.parse_participants),
+ inviteViaAdmins: Boolean(row.invite_via_admins),
+ inviteAdminMasterId: Number(row.invite_admin_master_id || 0),
+ inviteAdminAllowFlood: Boolean(row.invite_admin_allow_flood),
+ inviteAdminAnonymous: row.invite_admin_anonymous == null ? true : Boolean(row.invite_admin_anonymous),
+ separateConfirmRoles: Boolean(row.separate_confirm_roles),
+ maxConfirmBots: Number(row.max_confirm_bots || 1),
+ useWatcherInviteNoUsername: row.use_watcher_invite_no_username == null ? true : Boolean(row.use_watcher_invite_no_username),
+ warmupEnabled: row.warmup_enabled == null ? true : Boolean(row.warmup_enabled),
+ warmupStartLimit: Number(row.warmup_start_limit || 3),
+ warmupDailyIncrease: Number(row.warmup_daily_increase || 2),
+ cycleCompetitors: Boolean(row.cycle_competitors),
+ competitorCursor: Number(row.competitor_cursor || 0),
+ inviteLinkOnFail: Boolean(row.invite_link_on_fail),
+ rolesMode: row.role_mode || "manual",
+ stopOnBlocked: Boolean(row.stop_on_blocked),
+ stopBlockedPercent: Number(row.stop_blocked_percent || 25),
+ notes: row.notes || "",
+ enabled: Boolean(row.enabled),
+ allowStartWithoutInviteRights: row.allow_start_without_invite_rights == null ? true : Boolean(row.allow_start_without_invite_rights),
+ autoAssignAccounts: true
+});
diff --git a/src/renderer/components/AppMain.jsx b/src/renderer/components/AppMain.jsx
new file mode 100644
index 0000000..3177a60
--- /dev/null
+++ b/src/renderer/components/AppMain.jsx
@@ -0,0 +1,76 @@
+import React from "react";
+import QuickActionsBar from "./QuickActionsBar.jsx";
+import NowStatusCard from "./NowStatusCard.jsx";
+import ChecklistCard from "./ChecklistCard.jsx";
+import MainTabs from "./MainTabs.jsx";
+import TaskSettingsTab from "./TaskSettingsTab.jsx";
+import MainTabContent from "./MainTabContent.jsx";
+import TestRunCard from "./TestRunCard.jsx";
+import useTabProps from "../hooks/useTabProps.js";
+
+const AccountsTab = React.lazy(() => import("../tabs/AccountsTab.jsx"));
+const LogsTab = React.lazy(() => import("../tabs/LogsTab.jsx"));
+const QueueTab = React.lazy(() => import("../tabs/QueueTab.jsx"));
+const EventsTab = React.lazy(() => import("../tabs/EventsTab.jsx"));
+const SettingsTab = React.lazy(() => import("../tabs/SettingsTab.jsx"));
+
+export default function AppMain({
+ quickActions,
+ nowStatus,
+ testRun,
+ runTestLive,
+ checklist,
+ tabs,
+ taskSettings,
+ accountsTab,
+ logsTab,
+ queueTab,
+ eventsTab,
+ settingsTab
+}) {
+ const {
+ taskSettingsProps,
+ accountsTabProps,
+ logsTabProps,
+ queueTabProps,
+ eventsTabProps,
+ settingsTabProps
+ } = useTabProps(taskSettings, accountsTab, logsTab, queueTab, eventsTab, settingsTab);
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/renderer/components/AppOverlays.jsx b/src/renderer/components/AppOverlays.jsx
new file mode 100644
index 0000000..9ec6f14
--- /dev/null
+++ b/src/renderer/components/AppOverlays.jsx
@@ -0,0 +1,100 @@
+import React from "react";
+import ImportAccountsModal from "./ImportAccountsModal.jsx";
+import NotificationsModal from "./NotificationsModal.jsx";
+import InfoModal from "./InfoModal.jsx";
+import ToastStack from "./ToastStack.jsx";
+import ConfirmModal from "./ConfirmModal.jsx";
+
+export default function AppOverlays({
+ importModalOpen,
+ setImportModalOpen,
+ manualLoginOpen,
+ setManualLoginOpen,
+ hasSelectedTask,
+ loginForm,
+ setLoginForm,
+ startLogin,
+ completeLogin,
+ loginStatus,
+ tdataForm,
+ setTdataForm,
+ importTdata,
+ tdataLoading,
+ tdataResult,
+ explainTdataError,
+ notificationsOpen,
+ setNotificationsOpen,
+ notificationsModalRef,
+ notifications,
+ filteredNotifications,
+ notificationFilter,
+ setNotificationFilter,
+ setNotifications,
+ infoOpen,
+ setInfoOpen,
+ infoTab,
+ setInfoTab,
+ liveConfirmOpen,
+ setLiveConfirmOpen,
+ liveConfirmContext,
+ onConfirmLiveInvite,
+ onCancelLiveInvite,
+ toasts,
+ dismissToast
+}) {
+ return (
+ <>
+ setImportModalOpen(false)}
+ manualLoginOpen={manualLoginOpen}
+ setManualLoginOpen={setManualLoginOpen}
+ hasSelectedTask={hasSelectedTask}
+ loginForm={loginForm}
+ setLoginForm={setLoginForm}
+ startLogin={startLogin}
+ completeLogin={completeLogin}
+ loginStatus={loginStatus}
+ tdataForm={tdataForm}
+ setTdataForm={setTdataForm}
+ importTdata={importTdata}
+ tdataLoading={tdataLoading}
+ tdataResult={tdataResult}
+ explainTdataError={explainTdataError}
+ />
+ setNotificationsOpen(false)}
+ notificationsModalRef={notificationsModalRef}
+ notifications={notifications}
+ filteredNotifications={filteredNotifications}
+ notificationFilter={notificationFilter}
+ setNotificationFilter={setNotificationFilter}
+ setNotifications={setNotifications}
+ />
+ setInfoOpen(false)}
+ infoTab={infoTab}
+ setInfoTab={setInfoTab}
+ />
+
+
+ >
+ );
+}
diff --git a/src/renderer/components/AppSidebar.jsx b/src/renderer/components/AppSidebar.jsx
new file mode 100644
index 0000000..ce52bf0
--- /dev/null
+++ b/src/renderer/components/AppSidebar.jsx
@@ -0,0 +1,154 @@
+import React from "react";
+import SidebarOverview from "./SidebarOverview.jsx";
+import SidebarAccounts from "./SidebarAccounts.jsx";
+import TasksSidebar from "./TasksSidebar.jsx";
+
+export function AppSidebarOverview({
+ taskSummary,
+ globalStatus,
+ selectedTaskName,
+ competitorGroups,
+ assignedAccountCount,
+ taskStatus,
+ notificationsOpen,
+ setNotificationsOpen,
+ infoOpen,
+ setInfoOpen,
+ bellRef,
+ notificationsCount,
+ onOpenImport
+}) {
+ return (
+ <>
+
+
+ >
+ );
+}
+
+export function AppSidebarTasks({
+ createTask,
+ taskSearch,
+ setTaskSearch,
+ taskFilter,
+ setTaskFilter,
+ taskSort,
+ setTaskSort,
+ filteredTasks,
+ taskStatusMap,
+ selectedTaskId,
+ selectTask,
+ deleteTask,
+ hasSelectedTask,
+ formatCountdown,
+ formatTimestamp,
+ accountById,
+ formatAccountLabel
+}) {
+ return (
+
+ );
+}
+
+export default function AppSidebar({
+ taskSummary,
+ globalStatus,
+ selectedTaskName,
+ competitorGroups,
+ assignedAccountCount,
+ taskStatus,
+ notificationsOpen,
+ setNotificationsOpen,
+ infoOpen,
+ setInfoOpen,
+ bellRef,
+ notificationsCount,
+ onOpenImport,
+ createTask,
+ taskSearch,
+ setTaskSearch,
+ taskFilter,
+ setTaskFilter,
+ taskSort,
+ setTaskSort,
+ filteredTasks,
+ taskStatusMap,
+ selectedTaskId,
+ selectTask,
+ deleteTask,
+ hasSelectedTask,
+ formatCountdown,
+ formatTimestamp,
+ accountById,
+ formatAccountLabel
+}) {
+ return (
+
+ );
+}
diff --git a/src/renderer/components/ChecklistCard.jsx b/src/renderer/components/ChecklistCard.jsx
new file mode 100644
index 0000000..ed4e85d
--- /dev/null
+++ b/src/renderer/components/ChecklistCard.jsx
@@ -0,0 +1,50 @@
+import React from "react";
+
+export default function ChecklistCard({
+ checklistStats,
+ checklistOpen,
+ setChecklistOpen,
+ checklistItems,
+ hasSelectedTask
+}) {
+ return (
+
+
+
+
Чек-лист запуска
+
Готово: {checklistStats.ok}/{checklistStats.total} · Проблемы: {checklistStats.fail}
+
+
setChecklistOpen(!checklistOpen)}>
+ {checklistOpen ? "Свернуть" : "Развернуть"}
+
+
+ {checklistOpen && (
+
+ {checklistItems.map((item) => {
+ const status = item.ok ? "ok" : (item.warn ? "warn" : "fail");
+ const statusLabel = item.ok ? "Готово" : (item.warn ? "Есть проблемы" : "Нужно внимание");
+ return (
+
+
+
{item.label}
+
{item.hint}
+
+
+ {statusLabel}
+
+ {item.actionLabel}
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/src/renderer/components/ConfirmModal.jsx b/src/renderer/components/ConfirmModal.jsx
new file mode 100644
index 0000000..38866d1
--- /dev/null
+++ b/src/renderer/components/ConfirmModal.jsx
@@ -0,0 +1,29 @@
+import React from "react";
+
+export default function ConfirmModal({
+ open,
+ title,
+ message,
+ confirmLabel = "Подтвердить",
+ cancelLabel = "Отмена",
+ onConfirm,
+ onCancel
+}) {
+ if (!open) return null;
+
+ return (
+
+
event.stopPropagation()}>
+
+
{title}
+ Закрыть
+
+
{message}
+
+ {cancelLabel}
+ {confirmLabel}
+
+
+
+ );
+}
diff --git a/src/renderer/components/ImportAccountsModal.jsx b/src/renderer/components/ImportAccountsModal.jsx
new file mode 100644
index 0000000..cb31b83
--- /dev/null
+++ b/src/renderer/components/ImportAccountsModal.jsx
@@ -0,0 +1,146 @@
+import React from "react";
+
+export default function ImportAccountsModal({
+ open,
+ onClose,
+ manualLoginOpen,
+ setManualLoginOpen,
+ hasSelectedTask,
+ loginForm,
+ setLoginForm,
+ startLogin,
+ completeLogin,
+ loginStatus,
+ tdataForm,
+ setTdataForm,
+ importTdata,
+ tdataLoading,
+ tdataResult,
+ explainTdataError
+}) {
+ if (!open) return null;
+
+ return (
+
+
event.stopPropagation()}>
+
+
Импорт аккаунтов
+ Закрыть
+
+
+
+
Добавить аккаунт по коду
+ setManualLoginOpen(!manualLoginOpen)}>
+ {manualLoginOpen ? "Свернуть" : "Развернуть"}
+
+
+ {manualLoginOpen && (
+
+ )}
+
+
+
+
Импорт из tdata
+
+ Можно выбрать сразу несколько папок. Значения по умолчанию — API Telegram Desktop.
+
+
+
+ API ID
+ setTdataForm({ ...tdataForm, apiId: event.target.value })}
+ />
+
+
+ API Hash
+ setTdataForm({ ...tdataForm, apiHash: event.target.value })}
+ />
+
+
+
+ {tdataLoading ? "Импортируем..." : "Импортировать tdata"}
+
+ {tdataLoading &&
Идет импорт, это может занять несколько секунд.
}
+ {tdataResult && (
+
+
Импортировано: {(tdataResult.imported || []).length}
+
Пропущено: {(tdataResult.skipped || []).length}
+
Ошибок: {(tdataResult.failed || []).length}
+ {(tdataResult.failed || []).length > 0 && (
+
+ {tdataResult.failed.map((item, index) => (
+
+
{item.path}
+
{item.error}
+ {explainTdataError(item.error) && (
+
{explainTdataError(item.error)}
+ )}
+
+ ))}
+
+ )}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/renderer/components/InfoModal.jsx b/src/renderer/components/InfoModal.jsx
new file mode 100644
index 0000000..3e6ee55
--- /dev/null
+++ b/src/renderer/components/InfoModal.jsx
@@ -0,0 +1,96 @@
+import React from "react";
+
+export default function InfoModal({ open, onClose, infoTab, setInfoTab }) {
+ if (!open) return null;
+
+ return (
+
+
event.stopPropagation()}>
+
+
Как пользоваться
+ Закрыть
+
+
+ setInfoTab("usage")}
+ >
+ Быстрый старт
+
+ setInfoTab("features")}
+ >
+ Функции
+
+ setInfoTab("strategies")}
+ >
+ Стратегии
+
+ setInfoTab("limits")}
+ >
+ Ограничения Telegram
+
+
+ {infoTab === "usage" && (
+ <>
+
+ Создайте задачу: название, наша группа и группы конкурентов.
+ Импортируйте аккаунты (tdata) и назначьте роли для задачи.
+ Нажмите “Собрать историю”, чтобы добавить авторов из последних сообщений.
+ Нажмите “Запустить”, чтобы отслеживать новые сообщения и приглашать по расписанию.
+ Следите за статусом, логами, событиями и очередью.
+
+
+ “Собрать историю” добавляет в очередь авторов старых сообщений. Без этого учитываются только новые сообщения.
+
+ >
+ )}
+ {infoTab === "features" && (
+
+
Функции и режимы:
+
1) Мониторинг: отслеживает новые сообщения в чатах конкурентов и добавляет авторов в очередь.
+
2) Инвайт по расписанию: приглашает с интервалом и дневным лимитом.
+
3) Инвайт через админов: временно выдает право “Приглашать”, затем снимает.
+
4) Инвайт в чаты с флудом: распределяет инвайт через цепочку выдачи прав.
+
5) Циклический обход конкурентов: переключает мониторинг по списку групп.
+
6) Парсинг участников: пытается получить список участников для закрытых чатов.
+
7) Прогрев лимита: плавно увеличивает дневной лимит по дням.
+
8) Fallback‑лист: собирает проблемные инвайты и предлагает маршруты.
+
+ )}
+ {infoTab === "strategies" && (
+
+
Стратегии инвайта:
+
1) access_hash из сообщения.
+
2) Резолв через участников/источник.
+
3) Инвайт по username (если доступен).
+
4) Инвайт через админов (если включен).
+
5) Отправка инвайт‑ссылки (если включено).
+
После успешного инвайта выполняется проверка фактического вступления.
+
+ )}
+ {infoTab === "limits" && (
+
+
Особенности Telegram и ошибки:
+
1) AUTH_KEY_DUPLICATED: tdata уже используется — выйдите из аккаунта на других устройствах и пересоберите tdata.
+
2) CHAT_ADMIN_REQUIRED: аккаунт должен быть админом с правом “добавлять участников”.
+
3) USER_ID_INVALID: скрытые/анонимные авторы, удаленные аккаунты — инвайт возможен только по username.
+
4) USER_NOT_MUTUAL_CONTACT: ограничения Telegram/приватность пользователя — помогает инвайт‑ссылка или другой аккаунт.
+
5) USER_PRIVACY_RESTRICTED: пользователь запретил инвайты в чаты.
+
6) FLOOD/PEER_FLOOD: снизить лимиты, увеличить интервалы, распределить нагрузку.
+
7) CHANNEL_PRIVATE/INVITE_HASH_INVALID: ссылка недействительна или чат приватный.
+
+ )}
+
+
+ );
+}
diff --git a/src/renderer/components/MainTabContent.jsx b/src/renderer/components/MainTabContent.jsx
new file mode 100644
index 0000000..0740542
--- /dev/null
+++ b/src/renderer/components/MainTabContent.jsx
@@ -0,0 +1,55 @@
+import React, { Suspense } from "react";
+
+export default function MainTabContent({
+ activeTab,
+ TaskSettingsTab,
+ AccountsTab,
+ LogsTab,
+ QueueTab,
+ EventsTab,
+ SettingsTab,
+ taskSettingsProps,
+ accountsTabProps,
+ logsTabProps,
+ queueTabProps,
+ eventsTabProps,
+ settingsTabProps
+}) {
+ return (
+ <>
+ {activeTab === "task" && (
+
+ )}
+
+ {activeTab === "accounts" && (
+ Загрузка...}>
+
+
+ )}
+
+ {activeTab === "logs" && (
+ Загрузка...}>
+
+
+ )}
+
+ {activeTab === "queue" && (
+ Загрузка...}>
+
+
+ )}
+
+ {activeTab === "events" && (
+ Загрузка...}>
+
+
+ )}
+
+ {activeTab === "settings" && (
+ Загрузка...}>
+
+
+ )}
+ >
+ );
+}
diff --git a/src/renderer/components/MainTabs.jsx b/src/renderer/components/MainTabs.jsx
new file mode 100644
index 0000000..37c6ec2
--- /dev/null
+++ b/src/renderer/components/MainTabs.jsx
@@ -0,0 +1,50 @@
+import React from "react";
+
+export default function MainTabs({ activeTab, setActiveTab }) {
+ return (
+
+ setActiveTab("task")}
+ >
+ Задача
+
+ setActiveTab("accounts")}
+ >
+ Аккаунты
+
+ setActiveTab("events")}
+ >
+ Основной поток
+
+ setActiveTab("logs")}
+ >
+ История запусков
+
+ setActiveTab("queue")}
+ >
+ Очередь
+
+ setActiveTab("settings")}
+ >
+ Настройки
+
+
+ );
+}
diff --git a/src/renderer/components/NotificationsModal.jsx b/src/renderer/components/NotificationsModal.jsx
new file mode 100644
index 0000000..bddaadb
--- /dev/null
+++ b/src/renderer/components/NotificationsModal.jsx
@@ -0,0 +1,56 @@
+import React from "react";
+
+export default function NotificationsModal({
+ open,
+ onClose,
+ notificationsModalRef,
+ notifications,
+ filteredNotifications,
+ notificationFilter,
+ setNotificationFilter,
+ setNotifications
+}) {
+ if (!open) return null;
+
+ return (
+
+
event.stopPropagation()}>
+
+
Уведомления
+ setNotifications([])}>
+ Очистить
+
+
+
+ setNotificationFilter("all")}
+ >
+ Все
+
+ setNotificationFilter("error")}
+ >
+ Ошибки
+
+ setNotificationFilter("info")}
+ >
+ Инфо
+
+
+ {filteredNotifications.length === 0 &&
Пока пусто.
}
+ {filteredNotifications.map((item) => (
+
+ {item.text}{item.count > 1 ? ` (x${item.count})` : ""}
+
+ ))}
+
+
+ );
+}
diff --git a/src/renderer/components/NowStatusCard.jsx b/src/renderer/components/NowStatusCard.jsx
new file mode 100644
index 0000000..57150bc
--- /dev/null
+++ b/src/renderer/components/NowStatusCard.jsx
@@ -0,0 +1,95 @@
+import React from "react";
+
+export default function NowStatusCard({
+ nowLine,
+ nowExpanded,
+ setNowExpanded,
+ primaryIssue,
+ openFixTab,
+ monitorLabels,
+ inviteLabels,
+ roleSummary,
+ taskStatus,
+ groupVisibility,
+ lastEvents,
+ formatTimestamp
+}) {
+ return (
+
+
+
{nowLine}
+
setNowExpanded(!nowExpanded)}
+ >
+ {nowExpanded ? "Скрыть детали" : "Подробнее"}
+
+
+ {primaryIssue && (
+
+ Причина: {primaryIssue}
+
+ Исправить
+
+
+ )}
+ {nowExpanded && (
+
+
Мониторит: {monitorLabels.length ? monitorLabels.join(", ") : "—"}
+
Инвайтят: {inviteLabels.length ? inviteLabels.join(", ") : "—"}
+
Схема: мониторинг {roleSummary.monitor.length} · инвайт {roleSummary.invite.length} · подтверждение {roleSummary.confirm.length}
+
Последнее сообщение: {formatTimestamp(taskStatus.monitorInfo ? taskStatus.monitorInfo.lastMessageAt : "")}
+
Источник: {taskStatus.monitorInfo && taskStatus.monitorInfo.lastSource ? taskStatus.monitorInfo.lastSource : "—"}
+ {taskStatus.readiness && (
+
+ Готовность: {taskStatus.readiness.ok ? "Да" : "Нет"}
+ {!taskStatus.readiness.ok && taskStatus.readiness.reasons && (
+
{taskStatus.readiness.reasons.join("\n")}
+ )}
+
+ )}
+ {taskStatus.lastStopReason && (
+
+ Последняя остановка: {taskStatus.lastStopReason}
+ {taskStatus.lastStopAt ? ` (${formatTimestamp(taskStatus.lastStopAt)})` : ""}
+
+ )}
+ {taskStatus.warnings && taskStatus.warnings.length > 0 && (
+
+ {taskStatus.warnings.map((warning, index) => (
+
{warning}
+ ))}
+
+ )}
+ {groupVisibility.length > 0 && groupVisibility.some((item) => item.hidden) && (
+
+ В некоторых группах скрыты участники — инвайт возможен только по username.
+
+ {groupVisibility
+ .filter((item) => item.hidden)
+ .map((item) => (
+
+ {item.title ? `${item.title} (${item.source})` : item.source}
+
+ ))}
+
+
+ )}
+
+ Последние события:
+ {lastEvents.length === 0 &&
—
}
+ {lastEvents.map((event) => {
+ const firstLine = event.message ? String(event.message).split("\n")[0] : "";
+ return (
+
+ {formatTimestamp(event.createdAt)} • {event.eventType}{firstLine ? ` • ${firstLine}` : ""}
+
+ );
+ })}
+
+
+ )}
+
+ );
+}
diff --git a/src/renderer/components/QuickActionsBar.jsx b/src/renderer/components/QuickActionsBar.jsx
new file mode 100644
index 0000000..8a1dda7
--- /dev/null
+++ b/src/renderer/components/QuickActionsBar.jsx
@@ -0,0 +1,142 @@
+import React from "react";
+
+export default function QuickActionsBar({
+ selectedTaskName,
+ autosaveNote,
+ taskStatus,
+ hasSelectedTask,
+ canSaveTask,
+ taskActionLoading,
+ saveTask,
+ parseHistory,
+ joinGroupsForTask,
+ checkAll,
+ startTask,
+ stopTask,
+ moreActionsOpen,
+ setMoreActionsOpen,
+ moreActionsRef,
+ clearQueue,
+ startAllTasks,
+ stopAllTasks,
+ clearDatabase,
+ resetSessions,
+ pauseReason,
+ setActiveTab,
+ tasksLength,
+ runTestSafe
+}) {
+ return (
+
+
+
+
Быстрые действия
+
+ Задача: {selectedTaskName}
+ {autosaveNote && {autosaveNote} }
+
+
+
+ {taskStatus.running ? "Запущено" : "Остановлено"}
+
+
+
+ saveTask("bar")} disabled={!canSaveTask}>Сохранить
+ parseHistory("bar")} disabled={!hasSelectedTask}>Собрать историю
+ joinGroupsForTask("bar")} disabled={!hasSelectedTask}>
+ Добавить ботов в Telegram группы
+
+ checkAll("bar")} disabled={!hasSelectedTask}>Проверить всё
+ Тестовый прогон
+ {taskStatus.running ? (
+ stopTask("bar")} disabled={!hasSelectedTask || taskActionLoading}>
+ [■]
+ {taskActionLoading ? "Остановка..." : "Остановить"}
+
+ ) : (
+ startTask("bar")} disabled={!hasSelectedTask || taskActionLoading}>
+ [>]
+ {taskActionLoading ? "Запуск..." : "Запустить"}
+
+ )}
+
+
+ {
+ event.preventDefault();
+ setMoreActionsOpen((prev) => !prev);
+ }}
+ >
+ Ещё
+
+
+ {
+ clearQueue("bar");
+ setMoreActionsOpen(false);
+ }}
+ disabled={!hasSelectedTask}
+ >
+ Очистить очередь
+
+ {
+ startAllTasks();
+ setMoreActionsOpen(false);
+ }}
+ disabled={!tasksLength}
+ >
+ Запустить все
+
+ {
+ stopAllTasks();
+ setMoreActionsOpen(false);
+ }}
+ disabled={!tasksLength}
+ >
+ Остановить все
+
+ {
+ clearDatabase();
+ setMoreActionsOpen(false);
+ }}
+ >
+ Очистить БД
+
+ {
+ resetSessions();
+ setMoreActionsOpen(false);
+ }}
+ >
+ Сбросить сессии
+
+
+
+ {!taskStatus.running && pauseReason && (
+
+ Пауза: {pauseReason}
+ setActiveTab("accounts")}
+ >
+ Исправить
+
+
+ )}
+
+ );
+}
diff --git a/src/renderer/components/SidebarAccounts.jsx b/src/renderer/components/SidebarAccounts.jsx
new file mode 100644
index 0000000..f12f012
--- /dev/null
+++ b/src/renderer/components/SidebarAccounts.jsx
@@ -0,0 +1,26 @@
+import React from "react";
+
+export default function SidebarAccounts({ onOpenImport }) {
+ return (
+
+
+
+
+
Аккаунты
+
Общий импорт
+
+
{
+ event.preventDefault();
+ onOpenImport();
+ }}
+ >
+ Открыть импорт
+
+
+
+
+ );
+}
diff --git a/src/renderer/components/SidebarOverview.jsx b/src/renderer/components/SidebarOverview.jsx
new file mode 100644
index 0000000..f8e0fc7
--- /dev/null
+++ b/src/renderer/components/SidebarOverview.jsx
@@ -0,0 +1,75 @@
+import React from "react";
+
+export default function SidebarOverview({
+ taskSummary,
+ globalStatus,
+ selectedTaskName,
+ competitorGroups,
+ assignedAccountCount,
+ taskStatus,
+ notificationsOpen,
+ setNotificationsOpen,
+ infoOpen,
+ setInfoOpen,
+ bellRef,
+ notificationsCount
+}) {
+ return (
+
+
+
+
+
Общий обзор
+
+
+
+ setNotificationsOpen((prev) => !prev)}
+ >
+ 🔔
+
+ {notificationsCount > 0 && (
+ {notificationsCount}
+ )}
+
+
setInfoOpen(true)}
+ >
+ ℹ️
+
+
+
+
+
+
+
Всего задач
+
{taskSummary.total}
+
+
+
Сессии
+
+ {globalStatus.connectedSessions}/{globalStatus.totalAccounts}
+
+
+
+
+
+ Задача: {selectedTaskName}
+
+
+ Конкуренты: {competitorGroups.length}
+
+
+ Аккаунты: {assignedAccountCount}
+
+
+ Лимит: {taskStatus.dailyUsed}/{taskStatus.dailyLimit}
+
+
+
+ );
+}
diff --git a/src/renderer/components/TaskSettingsTab.jsx b/src/renderer/components/TaskSettingsTab.jsx
new file mode 100644
index 0000000..950a781
--- /dev/null
+++ b/src/renderer/components/TaskSettingsTab.jsx
@@ -0,0 +1,740 @@
+import React from "react";
+
+export default function TaskSettingsTab({
+ selectedTaskName,
+ taskForm,
+ setTaskForm,
+ activePreset,
+ applyTaskPreset,
+ formatAccountLabel,
+ accountById,
+ competitorText,
+ setCompetitorText,
+ roleMode,
+ applyRoleMode,
+ normalizeIntervals,
+ taskStatus,
+ perAccountInviteSum,
+ hasSelectedTask,
+ inviteAccessStatus,
+ inviteAccessCheckedAt,
+ formatTimestamp,
+ checkInviteAccess,
+ accounts,
+ showNotification,
+ copyToClipboard,
+ sanitizeTaskForm,
+ hasPerAccountInviteLimits,
+ fileImportResult,
+ importInviteFile,
+ fileImportForm,
+ setFileImportForm,
+ criticalErrorAccounts
+}) {
+ return (
+
+
+
+
+
Настройки задачи
+
Для: {selectedTaskName}
+
+
+ Основное
+
+
Пресеты:
+ {taskForm.rolesMode === "auto" ? (
+ <>
+
applyTaskPreset("admin")}
+ >
+ Автораспределение + Инвайт через админа
+
+
applyTaskPreset("no_admin")}
+ >
+ Автораспределение + Без админки
+
+
applyTaskPreset("soft_50")}
+ >
+ Мягкий 50/день (5 инвайтеров)
+
+
applyTaskPreset("soft_25")}
+ >
+ Мягкий 25/день (2 инвайтера)
+
+
applyTaskPreset("soft_50_admin")}
+ >
+ Мягкий 50/день + админы
+
+
applyTaskPreset("soft_25_admin")}
+ >
+ Мягкий 25/день + админы
+
+
+ Мастер-админ: {taskForm.inviteAdminMasterId ? formatAccountLabel(accountById.get(taskForm.inviteAdminMasterId)) : "не выбран"}
+
+ >
+ ) : (
+
В ручном режиме пресеты недоступны.
+ )}
+
+
+
+ Название задачи *
+ setTaskForm({ ...taskForm, name: event.target.value })}
+ placeholder="Например, Таиланд"
+ />
+
+
+ Наша группа *
+ setTaskForm({ ...taskForm, ourGroup: event.target.value })}
+ placeholder="https://t.me/..."
+ />
+
+
+
+ Группы конкурентов *
+
+
+
+ Базовые настройки
+
+ Роли ботов и вступление
+
+
+ setTaskForm({ ...taskForm, autoJoinCompetitors: event.target.checked })}
+ />
+ Автодобавление аккаунтов в группы конкурентов
+
+
+ setTaskForm({ ...taskForm, autoJoinOurGroup: event.target.checked })}
+ />
+ Автодобавление аккаунтов в нашу группу
+
+
+
+
+ applyRoleMode("split")}
+ />
+ Разделить роли (конкуренты и наша группа разными аккаунтами)
+
+
+ applyRoleMode("same")}
+ />
+ Один и тот же бот в конкурентах и нашей группе
+
+
+
+ Режим “один и тот же бот” нужен, когда аккаунт должен быть в конкурентах и в нашей группе для инвайта.
+
+
+
+ Интервалы и лимиты
+
+
+ Мин. интервал (мин) *
+ {
+ const value = event.target.value;
+ setTaskForm({ ...taskForm, minIntervalMinutes: value === "" ? "" : Number(value) });
+ }}
+ onBlur={() => setTaskForm(normalizeIntervals(taskForm))}
+ />
+
+
+ Макс. интервал (мин) *
+ {
+ const value = event.target.value;
+ setTaskForm({ ...taskForm, maxIntervalMinutes: value === "" ? "" : Number(value) });
+ }}
+ onBlur={() => setTaskForm(normalizeIntervals(taskForm))}
+ />
+
+
+ Лимит в день *
+ {
+ const value = event.target.value;
+ setTaskForm({ ...taskForm, dailyLimit: value === "" ? "" : Number(value) });
+ }}
+ onBlur={() => {
+ const value = Number(taskForm.dailyLimit);
+ setTaskForm({ ...taskForm, dailyLimit: Number.isFinite(value) && value > 0 ? value : 1 });
+ }}
+ />
+ Фактический лимит сегодня: {taskStatus.dailyLimit || "—"}
+
+
+ История сообщений (шт) *
+ {
+ const value = event.target.value;
+ setTaskForm({ ...taskForm, historyLimit: value === "" ? "" : Number(value) });
+ }}
+ onBlur={() => {
+ const value = Number(taskForm.historyLimit);
+ setTaskForm({ ...taskForm, historyLimit: Number.isFinite(value) && value > 0 ? value : 1 });
+ }}
+ />
+
+
+
+ Инвайтов за цикл *
+ {
+ const value = event.target.value;
+ setTaskForm({ ...taskForm, maxInvitesPerCycle: value === "" ? "" : Number(value) });
+ }}
+ onBlur={() => {
+ const value = Number(taskForm.maxInvitesPerCycle);
+ setTaskForm({ ...taskForm, maxInvitesPerCycle: Number.isFinite(value) && value > 0 ? value : 1 });
+ }}
+ />
+ Это общий потолок на цикл. Пер‑аккаунтные лимиты распределяют инвайты внутри этого числа.
+
+
+ Итоговая формула: фактический лимит сегодня = min(дневной лимит задачи, разогрев).
+ Внутри цикла инвайты распределяются по аккаунтам согласно их лимитам.
+
+
Фактический лимит сегодня: {taskStatus.dailyLimit || "—"}
+
Сумма лимитов по аккаунтам: {perAccountInviteSum || "—"}
+
+
+ setTaskForm({ ...taskForm, warmupEnabled: event.target.checked })}
+ />
+ Разогрев лимита
+
+ Плавно увеличивает дневной лимит по дням.
+
+ Нужен для “прогрева” новых аккаунтов и снижения риска флуда.
+ График: 1/д ×3, 2/д ×4, 3/д ×5, 4/д ×6, 5/д ×7, 6/д ×8, далее 7/д.
+
+ {taskForm.warmupEnabled && (
+ <>
+
+ Стартовый лимит
+ {
+ const value = event.target.value;
+ setTaskForm({ ...taskForm, warmupStartLimit: value === "" ? "" : Number(value) });
+ }}
+ onBlur={() => {
+ const value = Number(taskForm.warmupStartLimit);
+ setTaskForm({ ...taskForm, warmupStartLimit: Number.isFinite(value) && value > 0 ? value : 1 });
+ }}
+ />
+
+
+ Стартовый лимит применяется в первый день. Далее лимит увеличивается по графику прогрева: итог = Стартовый лимит + (ступень прогрева − 1), но не выше дневного лимита задачи.
+
+
+ Прирост в день
+ {
+ const value = event.target.value;
+ setTaskForm({ ...taskForm, warmupDailyIncrease: value === "" ? "" : Number(value) });
+ }}
+ onBlur={() => {
+ const value = Number(taskForm.warmupDailyIncrease);
+ setTaskForm({ ...taskForm, warmupDailyIncrease: Number.isFinite(value) && value >= 0 ? value : 0 });
+ }}
+ />
+
+ >
+ )}
+
+
+
+
+ Расширенные настройки
+
+ Инвайт через админов
+
+ Шаги: 1) Включить режим 2) Выбрать мастер‑админа 3) Проверить права
+
+
+
+ setTaskForm({ ...taskForm, inviteViaAdmins: event.target.checked })}
+ />
+ Инвайтить через админов
+
+ Временно назначаем пользователя админом с правом “Приглашать”, затем снимаем права.
+
+
+
+ checkInviteAccess("admin_block")}
+ disabled={!hasSelectedTask}
+ >
+ Проверить права
+
+
+ {inviteAccessStatus && inviteAccessStatus.length
+ ? `Права проверены${inviteAccessCheckedAt ? ` (${formatTimestamp(inviteAccessCheckedAt)})` : ""}${
+ inviteAccessStatus.every((item) => item.canInvite) ? " · OK" : " · есть ошибки"
+ }`
+ : "Нет проверки"}
+
+
+ {taskForm.inviteViaAdmins && !taskForm.inviteAdminMasterId && (
+
+ Не выбран мастер‑админ. Инвайт через админов работать не будет.
+
+ )}
+
+ Главный аккаунт
+
+ setTaskForm({ ...taskForm, inviteAdminMasterId: Number(event.target.value) || 0 })}
+ disabled={!taskForm.inviteViaAdmins}
+ >
+ Не выбран
+ {accounts.map((account) => (
+
+ {formatAccountLabel(account)}
+
+ ))}
+
+ {
+ const account = accountById.get(taskForm.inviteAdminMasterId);
+ const username = account && account.username ? `@${account.username}` : "";
+ if (!username) {
+ showNotification("У выбранного аккаунта нет username.", "error");
+ return;
+ }
+ const ok = await copyToClipboard(username);
+ showNotification(ok ? `Скопировано: ${username}` : "Не удалось скопировать.", ok ? "success" : "error");
+ }}
+ >
+ Копировать username
+
+
+
+ Этот аккаунт должен быть админом в целевой группе и уметь выдавать права другим.
+
+
+
+ setTaskForm({ ...taskForm, inviteAdminAnonymous: event.target.checked })}
+ disabled={!taskForm.inviteViaAdmins}
+ />
+ Делать админов анонимными
+
+ Мастер-админ назначает остальных админами с анонимностью и минимальными правами.
+
+
+
+ setTaskForm({ ...taskForm, inviteAdminAllowFlood: event.target.checked })}
+ disabled={!taskForm.inviteViaAdmins}
+ />
+ Инвайтить в чаты с флудом
+
+ Использует выдачу прав между аккаунтами, если Telegram ограничивает инвайтинг.
+
+
+
+
+
+ Распределение ботов
+
+ {roleMode === "same" ? (
+
+ Ботов в обеих группах
+ {
+ const value = event.target.value;
+ if (value === "") {
+ setTaskForm({ ...taskForm, maxCompetitorBots: "", maxOurBots: "" });
+ return;
+ }
+ const nextValue = Number(value);
+ const nextForm = { ...taskForm, maxCompetitorBots: nextValue, maxOurBots: nextValue };
+ setTaskForm(sanitizeTaskForm(nextForm));
+ }}
+ onBlur={() => {
+ const value = Number(taskForm.maxCompetitorBots);
+ const normalized = Number.isFinite(value) && value > 0 ? value : 1;
+ setTaskForm(sanitizeTaskForm({ ...taskForm, maxCompetitorBots: normalized, maxOurBots: normalized }));
+ }}
+ />
+ Одинаковое количество для конкурентов и нашей группы.
+
+ ) : (
+ <>
+
+ Ботов в конкурентах
+ {
+ const value = event.target.value;
+ setTaskForm({
+ ...taskForm,
+ maxCompetitorBots: value === "" ? "" : Number(value)
+ });
+ }}
+ onBlur={() => {
+ const value = Number(taskForm.maxCompetitorBots);
+ setTaskForm({ ...taskForm, maxCompetitorBots: Number.isFinite(value) && value > 0 ? value : 1 });
+ }}
+ />
+ Используется для авто-вступления в группы конкурентов.
+
+
+ Ботов в нашей группе
+ {
+ const value = event.target.value;
+ setTaskForm({ ...taskForm, maxOurBots: value === "" ? "" : Number(value) });
+ }}
+ onBlur={() => {
+ const value = Number(taskForm.maxOurBots);
+ setTaskForm({ ...taskForm, maxOurBots: Number.isFinite(value) && value > 0 ? value : 1 });
+ }}
+ />
+
+ Ограничивает аккаунты, которые будут приглашать.
+
+
+ >
+ )}
+
+
+
+ setTaskForm({ ...taskForm, separateConfirmRoles: event.target.checked })}
+ disabled={!taskForm.separateBotRoles}
+ />
+ Подтверждение отдельными аккаунтами
+
+ Если включено, проверку участия выполняют отдельные аккаунты, не совпадающие с инвайтерами.
+ Ручные чекбоксы ролей в разделе “Аккаунты” имеют приоритет над авто‑распределением.
+
+
+
+ Ботов для подтверждения
+ {
+ const value = event.target.value;
+ setTaskForm({ ...taskForm, maxConfirmBots: value === "" ? "" : Number(value) });
+ }}
+ onBlur={() => {
+ const value = Number(taskForm.maxConfirmBots);
+ const normalized = Number.isFinite(value) && value > 0 ? value : 1;
+ setTaskForm({ ...taskForm, maxConfirmBots: normalized });
+ }}
+ disabled={!taskForm.separateBotRoles || !taskForm.separateConfirmRoles}
+ />
+ Используется при авто-разделении ролей.
+
+
+
+
+
+ Дополнительные настройки
+
+ Экспертные настройки
+ Не трогайте, если не уверены.
+
+ Безопасность
+
+ {!hasPerAccountInviteLimits && (
+
+ setTaskForm({ ...taskForm, randomAccounts: event.target.checked })}
+ />
+ Случайный выбор аккаунтов
+ Инвайты распределяются случайно между доступными аккаунтами.
+
+ )}
+ {!hasPerAccountInviteLimits && (
+
+ setTaskForm({ ...taskForm, multiAccountsPerRun: event.target.checked })}
+ />
+ Несколько аккаунтов за цикл
+ Если выключено — в каждом цикле используется один аккаунт.
+
+ )}
+
+ setTaskForm({ ...taskForm, retryOnFail: event.target.checked })}
+ />
+ Повторять при ошибке
+ Повторяем до 2 раз при неудачном инвайте.
+
+
+ setTaskForm({ ...taskForm, inviteLinkOnFail: event.target.checked })}
+ />
+ Отправлять ссылку при USER_NOT_MUTUAL_CONTACT
+
+ Отправляет пользователю ссылку из поля “Наша группа”.
+
+
+
+ setTaskForm({ ...taskForm, stopOnBlocked: event.target.checked })}
+ />
+ Останавливать при блокировках
+ Останавливает задачу, если % ограниченных аккаунтов выше порога.
+
+
+
+ Очень редко
+
+
+ setTaskForm({ ...taskForm, allowStartWithoutInviteRights: event.target.checked })}
+ />
+ Разрешать запуск без прав инвайта
+ Полезно, если вы выдаёте админов после автодобавления.
+
+
+ setTaskForm({ ...taskForm, useWatcherInviteNoUsername: event.target.checked })}
+ />
+ Инвайт через наблюдателя, если нет username
+
+ Правило 1: сначала резолвим @username в сессии инвайтера — участие в группе конкурентов не требуется.
+
+
+ Правило 2: если username нет — инвайтим наблюдателем, потому что access_hash валиден только в его сессии.
+
+
+
+
+
+
+ Остановить при блоке, %
+ {
+ const value = event.target.value;
+ setTaskForm({ ...taskForm, stopBlockedPercent: value === "" ? "" : Number(value) });
+ }}
+ onBlur={() => {
+ const value = Number(taskForm.stopBlockedPercent);
+ setTaskForm({ ...taskForm, stopBlockedPercent: Number.isFinite(value) && value > 0 ? value : 1 });
+ }}
+ disabled={!taskForm.stopOnBlocked}
+ />
+
+
+
+
+
+ Импорт аудитории необязательно
+ Используйте только если нужен импорт из файла или полный список участников.
+
+
+ Импортировать файл
+
+ {fileImportResult && (
+
+ Импортировано: {fileImportResult.importedCount} · Пропущено: {fileImportResult.skippedCount} · Ошибок: {fileImportResult.failed.length}
+
+ )}
+
+
+ Дополнительные параметры
+
+
+ setTaskForm({ ...taskForm, parseParticipants: event.target.checked })}
+ />
+ Собирать участников чатов конкурентов
+
+ Используется для закрытых участников и полного списка аудитории.
+
+
+
+ setTaskForm({ ...taskForm, cycleCompetitors: event.target.checked })}
+ />
+ Циклически обходить конкурентов
+
+ Мониторинг и сбор будут переключаться по группам по очереди.
+
+
+
+ setFileImportForm({ ...fileImportForm, onlyIds: event.target.checked })}
+ />
+ В файле только ID
+ Если включено — нужен источник (чат), из которого брались ID.
+
+
+ Источник для ID
+ setFileImportForm({ ...fileImportForm, sourceChat: event.target.value })}
+ disabled={!fileImportForm.onlyIds}
+ />
+ Используется для резолва ID при инвайте.
+
+
+
+ {fileImportResult && fileImportResult.failed.length > 0 && (
+
+ {fileImportResult.failed.map((item, index) => (
+
+
{item.path}
+
{item.error}
+
+ ))}
+
+ )}
+
+
+ Ошибки аккаунтов
+ {criticalErrorAccounts.length === 0 && (
+ Ошибок нет.
+ )}
+ {criticalErrorAccounts.length > 0 && (
+
+ {criticalErrorAccounts.map((account) => (
+
+
{formatAccountLabel(account)}
+
{account.last_error || "Ошибка сессии"}
+
+ ))}
+
+ )}
+
+
+ Заметки
+
+
+
+
+
+ );
+}
diff --git a/src/renderer/components/TasksSidebar.jsx b/src/renderer/components/TasksSidebar.jsx
new file mode 100644
index 0000000..71b6eaf
--- /dev/null
+++ b/src/renderer/components/TasksSidebar.jsx
@@ -0,0 +1,147 @@
+import React from "react";
+
+export default function TasksSidebar({
+ createTask,
+ taskSearch,
+ setTaskSearch,
+ taskFilter,
+ setTaskFilter,
+ taskSort,
+ setTaskSort,
+ filteredTasks,
+ taskStatusMap,
+ selectedTaskId,
+ selectTask,
+ deleteTask,
+ hasSelectedTask,
+ formatCountdown,
+ formatTimestamp,
+ accountById,
+ formatAccountLabel
+}) {
+ return (
+
+
+
Задачи
+ Новая задача
+
+
+
+ setTaskSearch(event.target.value)}
+ placeholder="Поиск по названию или ссылке"
+ />
+
+
+ setTaskFilter("all")}
+ >
+ Все
+
+ setTaskFilter("running")}
+ >
+ Запущены
+
+ setTaskFilter("stopped")}
+ >
+ Остановлены
+
+
+
+
+ Сортировка
+ setTaskSort(event.target.value)}>
+ Активные сверху
+ По очереди
+ По лимиту
+ По последнему сообщению
+ По ID
+
+
+
+
+
+ {filteredTasks.length === 0 &&
Совпадений нет.
}
+ {filteredTasks.map((task) => {
+ const status = taskStatusMap[task.id];
+ const statusLabel = status ? (status.running ? "Запущено" : "Остановлено") : "—";
+ const statusClass = status ? (status.running ? "ok" : "off") : "off";
+ const unconfirmedCount = status ? Number(status.unconfirmedCount || 0) : 0;
+ const queueLabel = status ? `Очередь: ${status.queueCount}` : "Очередь: —";
+ const dailyLabel = status ? `Лимит сегодня: ${status.dailyUsed}/${status.dailyLimit}` : "Лимит сегодня: —";
+ const cycleLabel = status && status.running ? `Цикл: ${formatCountdown(status.nextRunAt)}` : "Цикл: —";
+ const lastMessageRaw = status && status.monitorInfo && status.monitorInfo.lastMessageAt
+ ? status.monitorInfo.lastMessageAt
+ : "";
+ const lastMessage = formatTimestamp(lastMessageRaw);
+ const lastSource = status && status.monitorInfo && status.monitorInfo.lastSource
+ ? status.monitorInfo.lastSource
+ : "—";
+ const monitoring = Boolean(status && status.monitorInfo && status.monitorInfo.monitoring);
+ const monitorAccountIds = status && status.monitorInfo && status.monitorInfo.accountIds
+ ? status.monitorInfo.accountIds
+ : (status && status.monitorInfo && status.monitorInfo.accountId ? [status.monitorInfo.accountId] : []);
+ const monitorLabels = monitorAccountIds
+ .map((id) => {
+ const account = accountById.get(id);
+ return account ? formatAccountLabel(account) : String(id);
+ })
+ .filter(Boolean);
+ const monitorLabel = monitorLabels.length
+ ? (monitorLabels.length > 2 ? `${monitorLabels.length} аккаунта` : monitorLabels.join(", "))
+ : "—";
+ const tooltip = [
+ `Статус: ${statusLabel}`,
+ `Очередь: ${status ? status.queueCount : "—"}`,
+ `Лимит сегодня: ${status ? `${status.dailyUsed}/${status.dailyLimit}` : "—"}`,
+ `Мониторинг: ${monitoring ? "активен" : "нет"}`,
+ `Мониторит: ${monitorLabel}`,
+ `Последнее: ${lastMessage}`,
+ `Источник: ${lastSource}`
+ ].join(" | ");
+ return (
+
selectTask(task.id)}
+ title={tooltip}
+ >
+
+
+
{task.name || `Задача #${task.id}`}
+
+
{statusLabel}
+ {unconfirmedCount > 0 && (
+
+ Не подтверждено: {unconfirmedCount}
+
+ )}
+
+
+
+ {queueLabel}
+ {dailyLabel}
+ {cycleLabel}
+
+
+
+ );
+ })}
+
+
+ Удалить задачу
+
+
+ );
+}
diff --git a/src/renderer/components/TestRunCard.jsx b/src/renderer/components/TestRunCard.jsx
new file mode 100644
index 0000000..62dd6a9
--- /dev/null
+++ b/src/renderer/components/TestRunCard.jsx
@@ -0,0 +1,51 @@
+import React from "react";
+
+export default function TestRunCard({ testRun, onRunSafe, onRunLive }) {
+ if (!testRun) return null;
+ const { status, mode, steps, startedAt, finishedAt, summary } = testRun;
+ const statusLabel = status === "running"
+ ? "В процессе"
+ : status === "ok"
+ ? "Готово"
+ : status === "warn"
+ ? "С предупреждениями"
+ : status === "error"
+ ? "Ошибки"
+ : "Не запускался";
+ return (
+
+
+
Тестовый прогон
+
+ Safe
+ Live
+
+
+
+ Статус: {statusLabel} {mode ? ` · режим: ${mode}` : ""}{summary ? ` · ${summary}` : ""}
+
+ {(startedAt || finishedAt) && (
+
+ Старт: {startedAt || "—"} · Завершение: {finishedAt || "—"}
+
+ )}
+
+
+ Шаг
+ Статус
+ Детали
+
+ {steps.length === 0 && (
+
Прогон ещё не запускался.
+ )}
+ {steps.map((step, index) => (
+
+
{step.title}
+
{step.status}
+
{step.details || "—"}
+
+ ))}
+
+
+ );
+}
diff --git a/src/renderer/components/ToastStack.jsx b/src/renderer/components/ToastStack.jsx
new file mode 100644
index 0000000..1c5c88c
--- /dev/null
+++ b/src/renderer/components/ToastStack.jsx
@@ -0,0 +1,18 @@
+import React from "react";
+
+export default function ToastStack({ toasts, onDismiss }) {
+ if (!toasts.length) return null;
+
+ return (
+
+ {toasts.map((toast) => (
+
+ {toast.text}{toast.count > 1 ? ` (x${toast.count})` : ""}
+ onDismiss(toast)}>
+ ✕
+
+
+ ))}
+
+ );
+}
diff --git a/src/renderer/hooks/useAccessChecks.js b/src/renderer/hooks/useAccessChecks.js
new file mode 100644
index 0000000..b65784a
--- /dev/null
+++ b/src/renderer/hooks/useAccessChecks.js
@@ -0,0 +1,51 @@
+export default function useAccessChecks({
+ selectedTaskId,
+ setAccessStatus,
+ setInviteAccessStatus,
+ setInviteAccessCheckedAt,
+ setTaskNotice,
+ showNotification
+}) {
+ const checkAccess = async (source = "editor", silent = false) => {
+ if (!window.api || selectedTaskId == null) {
+ if (!silent) showNotification("Сначала выберите задачу.", "error");
+ return;
+ }
+ if (!silent) showNotification("Проверяем доступ к группам...", "info");
+ try {
+ const result = await window.api.checkAccessByTask(selectedTaskId);
+ if (!result.ok) {
+ if (!silent) showNotification(result.error || "Не удалось проверить доступ", "error");
+ return;
+ }
+ setAccessStatus(result.result || []);
+ if (!silent) setTaskNotice({ text: "Проверка доступа завершена.", tone: "success", source });
+ } catch (error) {
+ if (!silent) showNotification(error.message || String(error), "error");
+ }
+ };
+
+ const checkInviteAccess = async (source = "editor", silent = false) => {
+ if (!window.api || selectedTaskId == null) {
+ if (!silent) showNotification("Сначала выберите задачу.", "error");
+ return;
+ }
+ setInviteAccessStatus([]);
+ setInviteAccessCheckedAt("");
+ if (!silent) showNotification("Проверяем права инвайта...", "info");
+ try {
+ const result = await window.api.checkInviteAccessByTask(selectedTaskId);
+ if (!result.ok) {
+ if (!silent) showNotification(result.error || "Не удалось проверить права", "error");
+ return;
+ }
+ setInviteAccessStatus(result.result || []);
+ setInviteAccessCheckedAt(new Date().toISOString());
+ if (!silent) setTaskNotice({ text: "Проверка прав инвайта завершена.", tone: "success", source });
+ } catch (error) {
+ if (!silent) showNotification(error.message || String(error), "error");
+ }
+ };
+
+ return { checkAccess, checkInviteAccess };
+}
diff --git a/src/renderer/hooks/useAccountComputed.js b/src/renderer/hooks/useAccountComputed.js
new file mode 100644
index 0000000..4dc3f42
--- /dev/null
+++ b/src/renderer/hooks/useAccountComputed.js
@@ -0,0 +1,110 @@
+import { useMemo } from "react";
+
+export default function useAccountComputed({
+ accounts,
+ accountStats,
+ taskAccountRoles,
+ accountAssignments,
+ selectedTaskId,
+ tasks
+}) {
+ const accountById = useMemo(() => {
+ const map = new Map();
+ accounts.forEach((account) => {
+ map.set(account.id, account);
+ });
+ return map;
+ }, [accounts]);
+
+ const accountStatsMap = useMemo(() => {
+ const map = new Map();
+ (accountStats || []).forEach((item) => {
+ map.set(item.id, item);
+ });
+ return map;
+ }, [accountStats]);
+
+ const roleSummary = useMemo(() => {
+ const monitor = [];
+ const invite = [];
+ const confirm = [];
+ Object.entries(taskAccountRoles).forEach(([id, roles]) => {
+ const accountId = Number(id);
+ if (roles.monitor) monitor.push(accountId);
+ if (roles.invite) invite.push(accountId);
+ if (roles.confirm) confirm.push(accountId);
+ });
+ return { monitor, invite, confirm };
+ }, [taskAccountRoles]);
+
+ const roleIntersectionCount = useMemo(() => {
+ let count = 0;
+ Object.values(taskAccountRoles).forEach((roles) => {
+ if (roles.monitor && roles.invite) count += 1;
+ });
+ return count;
+ }, [taskAccountRoles]);
+
+ const assignedAccountCount = useMemo(() => {
+ const ids = new Set([...roleSummary.monitor, ...roleSummary.invite, ...roleSummary.confirm]);
+ return ids.size;
+ }, [roleSummary]);
+
+ const assignedAccountMap = useMemo(() => {
+ const map = new Map();
+ accountAssignments.forEach((row) => {
+ const list = map.get(row.account_id) || [];
+ list.push({
+ taskId: row.task_id,
+ roleMonitor: Boolean(row.role_monitor),
+ roleInvite: Boolean(row.role_invite),
+ roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite),
+ inviteLimit: Number(row.invite_limit || 0)
+ });
+ map.set(row.account_id, list);
+ });
+ return map;
+ }, [accountAssignments]);
+
+ const filterFreeAccounts = tasks.length > 1;
+ const accountBuckets = useMemo(() => {
+ const selected = selectedTaskId;
+ const freeOrSelected = [];
+ const busy = [];
+ const taskNameMap = new Map();
+ tasks.forEach((task) => {
+ taskNameMap.set(task.id, task.name || `Задача #${task.id}`);
+ });
+ accounts.forEach((account) => {
+ const assignedTasks = assignedAccountMap.get(account.id) || [];
+ const assignedToSelected = selected != null && assignedTasks.some((item) => item.taskId === selected);
+ const isFree = assignedTasks.length === 0;
+ if (filterFreeAccounts && !isFree && !assignedToSelected) {
+ busy.push(account);
+ } else {
+ freeOrSelected.push(account);
+ }
+ });
+ return { freeOrSelected, busy, taskNameMap };
+ }, [accounts, assignedAccountMap, selectedTaskId, filterFreeAccounts, tasks]);
+
+ const perAccountInviteSum = useMemo(() => {
+ return Object.values(taskAccountRoles || {}).reduce((sum, roles) => {
+ if (!roles || !roles.invite) return sum;
+ const value = Number(roles.inviteLimit || 0);
+ return value > 0 ? sum + value : sum;
+ }, 0);
+ }, [taskAccountRoles]);
+
+ return {
+ accountById,
+ accountStatsMap,
+ roleSummary,
+ roleIntersectionCount,
+ assignedAccountCount,
+ assignedAccountMap,
+ filterFreeAccounts,
+ accountBuckets,
+ perAccountInviteSum
+ };
+}
diff --git a/src/renderer/hooks/useAccountImport.js b/src/renderer/hooks/useAccountImport.js
new file mode 100644
index 0000000..ded54dd
--- /dev/null
+++ b/src/renderer/hooks/useAccountImport.js
@@ -0,0 +1,165 @@
+import { useState } from "react";
+
+export default function useAccountImport({
+ selectedTaskId,
+ hasSelectedTask,
+ assignAccountsToTask,
+ setAccounts,
+ showNotification,
+ explainTdataError,
+ setTdataNotice
+}) {
+ const [loginForm, setLoginForm] = useState({
+ apiId: "",
+ apiHash: "",
+ phone: "",
+ code: "",
+ password: ""
+ });
+ const [tdataForm, setTdataForm] = useState({
+ apiId: "2040",
+ apiHash: "b18441a1ff607e10a989891a5462e627"
+ });
+ const [loginId, setLoginId] = useState("");
+ const [loginStatus, setLoginStatus] = useState("");
+ const [tdataResult, setTdataResult] = useState(null);
+ const [tdataLoading, setTdataLoading] = useState(false);
+
+ const startLogin = async () => {
+ if (!window.api) {
+ setLoginStatus("Electron API недоступен. Откройте приложение в Electron.");
+ showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
+ return;
+ }
+ if (selectedTaskId == null) {
+ setLoginStatus("Сначала выберите задачу.");
+ showNotification("Сначала выберите задачу.", "error");
+ return;
+ }
+ setLoginStatus("Отправляем код...");
+ showNotification("Отправляем код...", "info");
+ try {
+ const result = await window.api.startLogin({
+ apiId: loginForm.apiId,
+ apiHash: loginForm.apiHash,
+ phone: loginForm.phone
+ });
+ setLoginId(result.loginId);
+ setLoginStatus("Код отправлен. Введите код для входа.");
+ showNotification("Код отправлен. Введите код для входа.", "success");
+ } catch (error) {
+ const message = error.message || String(error);
+ setLoginStatus(message);
+ showNotification(message, "error");
+ }
+ };
+
+ const completeLogin = async () => {
+ if (!window.api) {
+ setLoginStatus("Electron API недоступен. Откройте приложение в Electron.");
+ showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
+ return;
+ }
+ if (selectedTaskId == null) {
+ setLoginStatus("Сначала выберите задачу.");
+ showNotification("Сначала выберите задачу.", "error");
+ return;
+ }
+ setLoginStatus("Завершаем вход...");
+ showNotification("Завершаем вход...", "info");
+ const result = await window.api.completeLogin({
+ loginId,
+ code: loginForm.code,
+ password: loginForm.password
+ });
+
+ if (result.ok) {
+ setLoginStatus("Аккаунт добавлен.");
+ setLoginId("");
+ setLoginForm({ apiId: "", apiHash: "", phone: "", code: "", password: "" });
+ await assignAccountsToTask([result.accountId].filter(Boolean));
+ setAccounts(await window.api.listAccounts());
+ return;
+ }
+
+ if (result.error === "DUPLICATE_ACCOUNT") {
+ setLoginStatus("Аккаунт уже добавлен. Привязан к задаче.");
+ setLoginId("");
+ setLoginForm({ apiId: "", apiHash: "", phone: "", code: "", password: "" });
+ await assignAccountsToTask([result.accountId].filter(Boolean));
+ setAccounts(await window.api.listAccounts());
+ return;
+ }
+
+ if (result.error === "PASSWORD_REQUIRED") {
+ setLoginStatus("Нужен пароль 2FA. Введите пароль.");
+ showNotification("Нужен пароль 2FA. Введите пароль.", "info");
+ return;
+ }
+
+ setLoginStatus(result.error || "Ошибка входа");
+ showNotification(result.error || "Ошибка входа", "error");
+ };
+
+ const importTdata = async () => {
+ if (!window.api) {
+ showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
+ return;
+ }
+ showNotification("Импортируем tdata...", "info");
+ setTdataLoading(true);
+ try {
+ const result = await window.api.importTdata({
+ apiId: tdataForm.apiId,
+ apiHash: tdataForm.apiHash,
+ taskId: selectedTaskId || undefined
+ });
+ if (result && result.canceled) return;
+ if (!result.ok) {
+ const hint = explainTdataError(result.error || "");
+ showNotification(hint ? `${result.error}. ${hint}` : (result.error || "Ошибка импорта tdata"), "error");
+ return;
+ }
+ setTdataResult(result);
+ const importedCount = (result.imported || []).length;
+ const skippedCount = (result.skipped || []).length;
+ const failedCount = (result.failed || []).length;
+ const importedIds = (result.imported || []).map((item) => item.accountId).filter(Boolean);
+ const skippedIds = (result.skipped || []).map((item) => item.accountId).filter(Boolean);
+ if ((importedIds.length || skippedIds.length) && hasSelectedTask) {
+ await assignAccountsToTask([...importedIds, ...skippedIds]);
+ }
+ if (result.authKeyDuplicatedCount) {
+ setTdataNotice({
+ text: `AUTH_KEY_DUPLICATED: ${result.authKeyDuplicatedCount}. Сессии сброшены после импорта.`,
+ tone: "warn"
+ });
+ } else if (importedCount > 0) {
+ setTdataNotice({ text: `Импортировано аккаунтов: ${importedCount}`, tone: "success" });
+ } else if (skippedCount > 0 && failedCount === 0) {
+ setTdataNotice({ text: `Пропущено дубликатов: ${skippedCount}`, tone: "success" });
+ }
+ if (failedCount > 0) {
+ showNotification(`Не удалось импортировать: ${failedCount}`, "error");
+ }
+ setAccounts(await window.api.listAccounts());
+ } catch (error) {
+ showNotification(error.message || String(error), "error");
+ } finally {
+ setTdataLoading(false);
+ }
+ };
+
+ return {
+ loginForm,
+ setLoginForm,
+ tdataForm,
+ setTdataForm,
+ loginStatus,
+ tdataResult,
+ tdataLoading,
+ startLogin,
+ completeLogin,
+ importTdata
+ };
+}
diff --git a/src/renderer/hooks/useAccountManagement.js b/src/renderer/hooks/useAccountManagement.js
new file mode 100644
index 0000000..379a50c
--- /dev/null
+++ b/src/renderer/hooks/useAccountManagement.js
@@ -0,0 +1,243 @@
+export default function useAccountManagement({
+ selectedTaskId,
+ taskAccountRoles,
+ setTaskAccountRoles,
+ selectedAccountIds,
+ setSelectedAccountIds,
+ accounts,
+ accountBuckets,
+ taskForm,
+ hasSelectedTask,
+ loadAccountAssignments,
+ showNotification,
+ setTaskNotice,
+ setAccounts
+}) {
+ const persistAccountRoles = async (next) => {
+ if (!window.api || selectedTaskId == null) return;
+ const rolePayload = Object.entries(next).map(([id, roles]) => ({
+ accountId: Number(id),
+ roleMonitor: Boolean(roles.monitor),
+ roleInvite: Boolean(roles.invite),
+ roleConfirm: Boolean(roles.confirm != null ? roles.confirm : roles.invite),
+ inviteLimit: Number(roles.inviteLimit || 0)
+ }));
+ await window.api.appendTaskAccounts({
+ taskId: selectedTaskId,
+ accountRoles: rolePayload
+ });
+ await window.api.addAccountEvent({
+ accountId: 0,
+ phone: "",
+ action: "roles_changed",
+ details: `задача ${selectedTaskId}: обновлены роли`
+ });
+ await loadAccountAssignments();
+ };
+
+ const updateAccountRole = (accountId, role, value) => {
+ const next = { ...taskAccountRoles };
+ const existing = next[accountId] || { monitor: false, invite: false, confirm: false, inviteLimit: 0 };
+ let inviteLimit = existing.inviteLimit || 0;
+ if (role === "invite" && value && inviteLimit === 0) {
+ inviteLimit = 1;
+ }
+ next[accountId] = { ...existing, [role]: value, inviteLimit };
+ if (!next[accountId].monitor && !next[accountId].invite && !next[accountId].confirm) {
+ delete next[accountId];
+ }
+ const ids = Object.keys(next).map((id) => Number(id));
+ setTaskAccountRoles(next);
+ setSelectedAccountIds(ids);
+ persistAccountRoles(next);
+ };
+
+ const updateAccountInviteLimit = (accountId, value) => {
+ const next = { ...taskAccountRoles };
+ const existing = next[accountId] || { monitor: false, invite: false, confirm: false, inviteLimit: 0 };
+ next[accountId] = { ...existing, inviteLimit: value };
+ const ids = Object.keys(next).map((id) => Number(id));
+ setTaskAccountRoles(next);
+ setSelectedAccountIds(ids);
+ persistAccountRoles(next);
+ };
+
+ const setAccountRolesAll = (accountId, value) => {
+ const next = { ...taskAccountRoles };
+ if (value) {
+ const existing = next[accountId] || { inviteLimit: 0 };
+ next[accountId] = { monitor: true, invite: true, confirm: true, inviteLimit: existing.inviteLimit || 1 };
+ } else {
+ delete next[accountId];
+ }
+ const ids = Object.keys(next).map((id) => Number(id));
+ setTaskAccountRoles(next);
+ setSelectedAccountIds(ids);
+ persistAccountRoles(next);
+ };
+
+ const applyRolePreset = (type) => {
+ if (!hasSelectedTask) return;
+ const availableIds = selectedAccountIds.length
+ ? selectedAccountIds
+ : accountBuckets.freeOrSelected.map((account) => account.id);
+ if (!availableIds.length) {
+ showNotification("Нет доступных аккаунтов для назначения.", "error");
+ return;
+ }
+ const next = {};
+ if (type === "all") {
+ availableIds.forEach((id) => {
+ const existing = taskAccountRoles[id] || {};
+ next[id] = { monitor: true, invite: true, confirm: true, inviteLimit: existing.inviteLimit || 1 };
+ });
+ } else if (type === "one") {
+ const id = availableIds[0];
+ const existing = taskAccountRoles[id] || {};
+ next[id] = { monitor: true, invite: true, confirm: true, inviteLimit: existing.inviteLimit || 1 };
+ } else if (type === "split") {
+ const monitorCount = Math.max(1, Number(taskForm.maxCompetitorBots || 1));
+ const inviteCount = Math.max(1, Number(taskForm.maxOurBots || 1));
+ const monitorIds = availableIds.slice(0, monitorCount);
+ const inviteIds = availableIds.slice(monitorCount, monitorCount + inviteCount);
+ const confirmCount = taskForm.separateConfirmRoles ? Math.max(1, Number(taskForm.maxConfirmBots || 1)) : 0;
+ const confirmIds = taskForm.separateConfirmRoles
+ ? availableIds.slice(monitorCount + inviteCount, monitorCount + inviteCount + confirmCount)
+ : [];
+ monitorIds.forEach((id) => {
+ const existing = taskAccountRoles[id] || {};
+ next[id] = { monitor: true, invite: false, confirm: false, inviteLimit: existing.inviteLimit || 0 };
+ });
+ inviteIds.forEach((id) => {
+ const existing = taskAccountRoles[id] || {};
+ next[id] = { monitor: false, invite: true, confirm: true, inviteLimit: existing.inviteLimit || 1 };
+ });
+ confirmIds.forEach((id) => {
+ const existing = taskAccountRoles[id] || {};
+ next[id] = { monitor: false, invite: false, confirm: true, inviteLimit: existing.inviteLimit || 0 };
+ });
+ if (inviteIds.length < inviteCount) {
+ showNotification("Не хватает аккаунтов для роли инвайта.", "error");
+ }
+ if (taskForm.separateConfirmRoles && confirmIds.length < confirmCount) {
+ showNotification("Не хватает аккаунтов для роли подтверждения.", "error");
+ }
+ }
+ const ids = Object.keys(next).map((id) => Number(id));
+ setTaskAccountRoles(next);
+ setSelectedAccountIds(ids);
+ persistAccountRoles(next);
+ const label = type === "one" ? "Один бот" : type === "split" ? "Разделить роли" : "Все роли";
+ setTaskNotice({ text: `Пресет: ${label}`, tone: "success", source: "accounts" });
+ };
+
+ const assignAccountsToTask = async (accountIds) => {
+ if (!window.api || selectedTaskId == null) return;
+ if (!accountIds.length) return;
+ const nextRoles = { ...taskAccountRoles };
+ accountIds.forEach((accountId) => {
+ if (!nextRoles[accountId]) {
+ nextRoles[accountId] = { monitor: true, invite: true, confirm: true };
+ }
+ });
+ const rolePayload = Object.entries(nextRoles).map(([accountId, roles]) => ({
+ accountId: Number(accountId),
+ roleMonitor: Boolean(roles.monitor),
+ roleInvite: Boolean(roles.invite),
+ roleConfirm: Boolean(roles.confirm != null ? roles.confirm : roles.invite)
+ }));
+ const result = await window.api.appendTaskAccounts({
+ taskId: selectedTaskId,
+ accountRoles: rolePayload
+ });
+ if (result && result.ok) {
+ setTaskAccountRoles(nextRoles);
+ setSelectedAccountIds(Object.keys(nextRoles).map((id) => Number(id)));
+ await loadAccountAssignments();
+ }
+ };
+
+ const moveAccountToTask = async (accountId) => {
+ if (!window.api || selectedTaskId == null) return;
+ await assignAccountsToTask([accountId]);
+ setTaskNotice({ text: "Аккаунт добавлен в задачу.", tone: "success", source: "accounts" });
+ };
+
+ const removeAccountFromTask = async (accountId) => {
+ if (!window.api || selectedTaskId == null) return;
+ const result = await window.api.removeTaskAccount({
+ taskId: selectedTaskId,
+ accountId
+ });
+ if (result && result.ok) {
+ setTaskAccountRoles((prev) => {
+ const next = { ...prev };
+ delete next[accountId];
+ setSelectedAccountIds(Object.keys(next).map((id) => Number(id)));
+ return next;
+ });
+ await loadAccountAssignments();
+ setTaskNotice({ text: "Аккаунт удален из задачи.", tone: "success", source: "accounts" });
+ }
+ };
+
+ const resetCooldown = async (accountId) => {
+ if (!window.api) {
+ showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
+ return;
+ }
+ try {
+ await window.api.resetAccountCooldown(accountId);
+ const updated = await window.api.listAccounts();
+ setAccounts(updated);
+ setTaskNotice({ text: "Аккаунт снова активен.", tone: "success", source: "accounts" });
+ } catch (error) {
+ setTaskNotice({ text: error.message || String(error), tone: "error", source: "accounts" });
+ showNotification(error.message || String(error), "error");
+ }
+ };
+
+ const deleteAccount = async (accountId) => {
+ if (!window.api) {
+ showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
+ return;
+ }
+ try {
+ await window.api.deleteAccount(accountId);
+ setAccounts(await window.api.listAccounts());
+ setTaskNotice({ text: "Аккаунт удален.", tone: "success", source: "accounts" });
+ } catch (error) {
+ setTaskNotice({ text: error.message || String(error), tone: "error", source: "accounts" });
+ showNotification(error.message || String(error), "error");
+ }
+ };
+
+ const refreshIdentity = async () => {
+ if (!window.api) {
+ showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
+ return;
+ }
+ try {
+ await window.api.refreshAccountIdentity();
+ setAccounts(await window.api.listAccounts());
+ setTaskNotice({ text: "ID аккаунтов обновлены.", tone: "success", source: "accounts" });
+ } catch (error) {
+ setTaskNotice({ text: error.message || String(error), tone: "error", source: "accounts" });
+ showNotification(error.message || String(error), "error");
+ }
+ };
+
+ return {
+ persistAccountRoles,
+ resetCooldown,
+ deleteAccount,
+ refreshIdentity,
+ updateAccountRole,
+ updateAccountInviteLimit,
+ setAccountRolesAll,
+ applyRolePreset,
+ assignAccountsToTask,
+ moveAccountToTask,
+ removeAccountFromTask
+ };
+}
diff --git a/src/renderer/hooks/useAppDataState.js b/src/renderer/hooks/useAppDataState.js
new file mode 100644
index 0000000..3ce93e0
--- /dev/null
+++ b/src/renderer/hooks/useAppDataState.js
@@ -0,0 +1,141 @@
+import { useRef, useState } from "react";
+import { emptySettings, emptyTaskForm } from "../appDefaults.js";
+
+export default function useAppDataState() {
+ const [settings, setSettings] = useState(emptySettings);
+ const [accounts, setAccounts] = useState([]);
+ const [accountStats, setAccountStats] = useState([]);
+ const [accountAssignments, setAccountAssignments] = useState([]);
+ const [globalStatus, setGlobalStatus] = useState({ connectedSessions: 0, totalAccounts: 0 });
+ const [logs, setLogs] = useState([]);
+ const [invites, setInvites] = useState([]);
+ const [fallbackList, setFallbackList] = useState([]);
+ const [confirmQueue, setConfirmQueue] = useState([]);
+ const [tasks, setTasks] = useState([]);
+ const [selectedTaskId, setSelectedTaskId] = useState(null);
+ const [taskForm, setTaskForm] = useState(emptyTaskForm);
+ const [competitorText, setCompetitorText] = useState("");
+ const [selectedAccountIds, setSelectedAccountIds] = useState([]);
+ const [taskAccountRoles, setTaskAccountRoles] = useState({});
+ const [activePreset, setActivePreset] = useState("");
+ const presetSignatureRef = useRef("");
+ const [taskStatus, setTaskStatus] = useState({
+ running: false,
+ queueCount: 0,
+ dailyRemaining: 0,
+ dailyUsed: 0,
+ dailyLimit: 0,
+ cycleCompetitors: false,
+ competitorCursor: 0,
+ monitorInfo: { monitoring: false, accountId: 0, accountIds: [], groups: [], lastMessageAt: "", lastSource: "" },
+ nextRunAt: "",
+ nextInviteAccountId: 0,
+ lastInviteAccountId: 0,
+ pendingStats: { total: 0, withUsername: 0, withAccessHash: 0, withoutData: 0 },
+ warnings: [],
+ lastStopReason: "",
+ lastStopAt: ""
+ });
+ const [taskStatusMap, setTaskStatusMap] = useState({});
+ const [membershipStatus, setMembershipStatus] = useState({});
+ const [groupVisibility, setGroupVisibility] = useState([]);
+ const [accessStatus, setAccessStatus] = useState([]);
+ const [inviteAccessStatus, setInviteAccessStatus] = useState([]);
+ const [inviteAccessCheckedAt, setInviteAccessCheckedAt] = useState("");
+ const [accountEvents, setAccountEvents] = useState([]);
+ const [taskAudit, setTaskAudit] = useState([]);
+ const [testRun, setTestRun] = useState({
+ status: "idle",
+ mode: "safe",
+ steps: [],
+ startedAt: "",
+ finishedAt: "",
+ summary: ""
+ });
+ const [queueItems, setQueueItems] = useState([]);
+ const [queueStats, setQueueStats] = useState({ total: 0, withUsername: 0, withAccessHash: 0, withoutData: 0 });
+ const [fileImportForm, setFileImportForm] = useState({
+ onlyIds: false,
+ sourceChat: ""
+ });
+ const [fileImportResult, setFileImportResult] = useState(null);
+ const [taskActionLoading, setTaskActionLoading] = useState(false);
+ const [taskNotice, setTaskNotice] = useState(null);
+ const [autosaveNote, setAutosaveNote] = useState("");
+ const [settingsNotice, setSettingsNotice] = useState(null);
+ const [tdataNotice, setTdataNotice] = useState(null);
+
+ return {
+ settings,
+ setSettings,
+ accounts,
+ setAccounts,
+ accountStats,
+ setAccountStats,
+ accountAssignments,
+ setAccountAssignments,
+ globalStatus,
+ setGlobalStatus,
+ logs,
+ setLogs,
+ invites,
+ setInvites,
+ fallbackList,
+ setFallbackList,
+ confirmQueue,
+ setConfirmQueue,
+ tasks,
+ setTasks,
+ selectedTaskId,
+ setSelectedTaskId,
+ taskForm,
+ setTaskForm,
+ competitorText,
+ setCompetitorText,
+ selectedAccountIds,
+ setSelectedAccountIds,
+ taskAccountRoles,
+ setTaskAccountRoles,
+ activePreset,
+ setActivePreset,
+ presetSignatureRef,
+ taskStatus,
+ setTaskStatus,
+ taskStatusMap,
+ setTaskStatusMap,
+ membershipStatus,
+ setMembershipStatus,
+ groupVisibility,
+ setGroupVisibility,
+ accessStatus,
+ setAccessStatus,
+ inviteAccessStatus,
+ setInviteAccessStatus,
+ inviteAccessCheckedAt,
+ setInviteAccessCheckedAt,
+ accountEvents,
+ setAccountEvents,
+ taskAudit,
+ setTaskAudit,
+ testRun,
+ setTestRun,
+ queueItems,
+ setQueueItems,
+ queueStats,
+ setQueueStats,
+ fileImportForm,
+ setFileImportForm,
+ fileImportResult,
+ setFileImportResult,
+ taskActionLoading,
+ setTaskActionLoading,
+ taskNotice,
+ setTaskNotice,
+ autosaveNote,
+ setAutosaveNote,
+ settingsNotice,
+ setSettingsNotice,
+ tdataNotice,
+ setTdataNotice
+ };
+}
diff --git a/src/renderer/hooks/useAppLoaders.js b/src/renderer/hooks/useAppLoaders.js
new file mode 100644
index 0000000..80913d5
--- /dev/null
+++ b/src/renderer/hooks/useAppLoaders.js
@@ -0,0 +1,88 @@
+import { useCallback } from "react";
+
+export default function useAppLoaders({
+ selectedTaskId,
+ setTasks,
+ setSelectedTaskId,
+ setAccountAssignments,
+ setTaskStatusMap,
+ setSettings,
+ setAccounts,
+ setAccountEvents,
+ setAccountStats,
+ setGlobalStatus
+}) {
+ const loadTasks = useCallback(async () => {
+ const tasksData = await window.api.listTasks();
+ setTasks(tasksData);
+ if (!tasksData.length) {
+ setSelectedTaskId(null);
+ return tasksData;
+ }
+ if (selectedTaskId == null) {
+ setSelectedTaskId(tasksData[0].id);
+ return tasksData;
+ }
+ if (!tasksData.some((task) => task.id === selectedTaskId)) {
+ setSelectedTaskId(tasksData[0].id);
+ }
+ return tasksData;
+ }, [selectedTaskId, setSelectedTaskId, setTasks]);
+
+ const loadAccountAssignments = useCallback(async () => {
+ if (!window.api) return;
+ const assignments = await window.api.listAccountAssignments();
+ setAccountAssignments(assignments || []);
+ }, [setAccountAssignments]);
+
+ const loadTaskStatuses = useCallback(async (tasksData) => {
+ const entries = await Promise.all(
+ (tasksData || []).map(async (task) => {
+ const status = await window.api.taskStatus(task.id);
+ return [task.id, status];
+ })
+ );
+ const map = {};
+ entries.forEach(([id, status]) => {
+ map[id] = status;
+ });
+ setTaskStatusMap(map);
+ }, [setTaskStatusMap]);
+
+ const loadBase = useCallback(async () => {
+ if (!window.api) return;
+ const [settingsData, accountsData, eventsData, statusData] = await Promise.all([
+ window.api.getSettings(),
+ window.api.listAccounts(),
+ window.api.listAccountEvents(200),
+ window.api.getStatus()
+ ]);
+ setSettings(settingsData);
+ setAccounts(accountsData);
+ setAccountEvents(eventsData);
+ setAccountStats(statusData.accountStats || []);
+ setGlobalStatus({
+ connectedSessions: statusData.connectedSessions || 0,
+ totalAccounts: statusData.totalAccounts || 0
+ });
+ const tasksData = await loadTasks();
+ await loadAccountAssignments();
+ await loadTaskStatuses(tasksData);
+ }, [
+ loadAccountAssignments,
+ loadTaskStatuses,
+ loadTasks,
+ setAccountEvents,
+ setAccountStats,
+ setAccounts,
+ setGlobalStatus,
+ setSettings
+ ]);
+
+ return {
+ loadTasks,
+ loadAccountAssignments,
+ loadTaskStatuses,
+ loadBase
+ };
+}
diff --git a/src/renderer/hooks/useAppOrchestration.js b/src/renderer/hooks/useAppOrchestration.js
new file mode 100644
index 0000000..a69cf80
--- /dev/null
+++ b/src/renderer/hooks/useAppOrchestration.js
@@ -0,0 +1,152 @@
+import useTaskLifecycle from "./useTaskLifecycle.js";
+import useTaskStatusSync from "./useTaskStatusSync.js";
+import useNotices from "./useNotices.js";
+import useAutosave from "./useAutosave.js";
+import useAppPolling from "./useAppPolling.js";
+
+export default function useAppOrchestration({
+ activeTab,
+ selectedTaskId,
+ isVisible,
+ setIsVisible,
+ setNow,
+ tasksPollInFlight,
+ accountsPollInFlight,
+ logsPollInFlight,
+ eventsPollInFlight,
+ setTasks,
+ loadTaskStatuses,
+ setTaskStatus,
+ setAccounts,
+ setAccountAssignments,
+ setAccountStats,
+ setGlobalStatus,
+ setLogs,
+ setInvites,
+ setFallbackList,
+ setConfirmQueue,
+ setTaskAudit,
+ setAccountEvents,
+ setQueueItems,
+ setQueueStats,
+ loadBase,
+ loadSelectedTask,
+ setAccessStatus,
+ setInviteAccessStatus,
+ setMembershipStatus,
+ setTaskNotice,
+ setActivePreset,
+ checkAccess,
+ checkInviteAccess,
+ activePreset,
+ taskForm,
+ taskAccountRoles,
+ presetSignatureRef,
+ taskStatus,
+ setTaskStatusMap,
+ checklistStats,
+ setChecklistOpen,
+ hasSelectedTask,
+ roleIntersectionCount,
+ roleSummary,
+ setTaskForm,
+ sanitizeTaskForm,
+ taskNotice,
+ settingsNotice,
+ tdataNotice,
+ setSettingsNotice,
+ setTdataNotice,
+ showNotification,
+ settings,
+ settingsAutosaveReady,
+ taskAutosaveReady,
+ taskAutosaveTimer,
+ competitorText,
+ selectedAccountIds,
+ canSaveTask,
+ saveTask,
+ setSettings
+}) {
+ useAppPolling({
+ activeTab,
+ selectedTaskId,
+ isVisible,
+ setIsVisible,
+ setNow,
+ tasksPollInFlight,
+ accountsPollInFlight,
+ logsPollInFlight,
+ eventsPollInFlight,
+ setTasks,
+ loadTaskStatuses,
+ setTaskStatus,
+ setAccounts,
+ setAccountAssignments,
+ setAccountStats,
+ setGlobalStatus,
+ setLogs,
+ setInvites,
+ setFallbackList,
+ setConfirmQueue,
+ setTaskAudit,
+ setAccountEvents,
+ setQueueItems,
+ setQueueStats
+ });
+
+ useTaskLifecycle({
+ loadBase,
+ selectedTaskId,
+ loadSelectedTask,
+ setAccessStatus,
+ setInviteAccessStatus,
+ setMembershipStatus,
+ setTaskNotice,
+ setActivePreset,
+ checkAccess,
+ checkInviteAccess,
+ activePreset,
+ taskForm,
+ taskAccountRoles,
+ presetSignatureRef
+ });
+
+ useTaskStatusSync({
+ selectedTaskId,
+ taskStatus,
+ setTaskStatusMap,
+ checklistStats,
+ setChecklistOpen,
+ hasSelectedTask,
+ taskForm,
+ roleIntersectionCount,
+ roleSummary,
+ setTaskForm,
+ sanitizeTaskForm
+ });
+
+ useNotices({
+ taskNotice,
+ setTaskNotice,
+ settingsNotice,
+ setSettingsNotice,
+ tdataNotice,
+ setTdataNotice,
+ showNotification
+ });
+
+ useAutosave({
+ settings,
+ settingsAutosaveReady,
+ taskAutosaveReady,
+ taskAutosaveTimer,
+ taskForm,
+ competitorText,
+ taskAccountRoles,
+ selectedAccountIds,
+ canSaveTask,
+ saveTask,
+ setSettings,
+ showNotification
+ });
+}
diff --git a/src/renderer/hooks/useAppOutsideClicks.js b/src/renderer/hooks/useAppOutsideClicks.js
new file mode 100644
index 0000000..dd9132d
--- /dev/null
+++ b/src/renderer/hooks/useAppOutsideClicks.js
@@ -0,0 +1,22 @@
+import useOutsideClick from "./useOutsideClick.js";
+
+export default function useAppOutsideClicks({
+ notificationsOpen,
+ notificationsModalRef,
+ bellRef,
+ setNotificationsOpen,
+ moreActionsOpen,
+ moreActionsRef,
+ setMoreActionsOpen
+}) {
+ useOutsideClick({
+ enabled: notificationsOpen,
+ refs: [notificationsModalRef, bellRef],
+ onOutside: () => setNotificationsOpen(false)
+ });
+ useOutsideClick({
+ enabled: moreActionsOpen,
+ refs: [moreActionsRef],
+ onOutside: () => setMoreActionsOpen(false)
+ });
+}
diff --git a/src/renderer/hooks/useAppPolling.js b/src/renderer/hooks/useAppPolling.js
new file mode 100644
index 0000000..25c5982
--- /dev/null
+++ b/src/renderer/hooks/useAppPolling.js
@@ -0,0 +1,132 @@
+import { useEffect } from "react";
+
+export default function useAppPolling({
+ activeTab,
+ selectedTaskId,
+ isVisible,
+ setIsVisible,
+ setNow,
+ tasksPollInFlight,
+ accountsPollInFlight,
+ logsPollInFlight,
+ eventsPollInFlight,
+ setTasks,
+ loadTaskStatuses,
+ setTaskStatus,
+ setAccounts,
+ setAccountAssignments,
+ setAccountStats,
+ setGlobalStatus,
+ setLogs,
+ setInvites,
+ setFallbackList,
+ setConfirmQueue,
+ setTaskAudit,
+ setAccountEvents,
+ setQueueItems,
+ setQueueStats
+}) {
+ useEffect(() => {
+ if (!window.api) return undefined;
+ const load = async () => {
+ if (!isVisible || tasksPollInFlight.current) return;
+ tasksPollInFlight.current = true;
+ try {
+ const tasksData = await window.api.listTasks();
+ setTasks(tasksData);
+ await loadTaskStatuses(tasksData);
+ if (selectedTaskId != null) {
+ setTaskStatus(await window.api.taskStatus(selectedTaskId));
+ }
+ } finally {
+ tasksPollInFlight.current = false;
+ }
+ };
+ load();
+ const interval = setInterval(async () => {
+ await load();
+ }, 5000);
+ return () => clearInterval(interval);
+ }, [selectedTaskId, isVisible]);
+
+ useEffect(() => {
+ if (!window.api) return undefined;
+ const load = async () => {
+ if (!isVisible || accountsPollInFlight.current) return;
+ accountsPollInFlight.current = true;
+ try {
+ setAccounts(await window.api.listAccounts());
+ setAccountAssignments(await window.api.listAccountAssignments());
+ const statusData = await window.api.getStatus();
+ setAccountStats(statusData.accountStats || []);
+ setGlobalStatus({
+ connectedSessions: statusData.connectedSessions || 0,
+ totalAccounts: statusData.totalAccounts || 0
+ });
+ } finally {
+ accountsPollInFlight.current = false;
+ }
+ };
+ load();
+ const interval = setInterval(load, 15000);
+ return () => clearInterval(interval);
+ }, [isVisible]);
+
+ useEffect(() => {
+ if (!window.api || (activeTab !== "logs" && activeTab !== "queue") || selectedTaskId == null) return undefined;
+ const load = async () => {
+ if (!isVisible || logsPollInFlight.current) return;
+ logsPollInFlight.current = true;
+ try {
+ setLogs(await window.api.listLogs({ limit: 100, taskId: selectedTaskId }));
+ setInvites(await window.api.listInvites({ limit: 200, taskId: selectedTaskId }));
+ setFallbackList(await window.api.listFallback({ limit: 500, taskId: selectedTaskId }));
+ setConfirmQueue(await window.api.listConfirmQueue({ limit: 500, taskId: selectedTaskId }));
+ setTaskAudit(await window.api.listTaskAudit(selectedTaskId));
+ const queueData = await window.api.listQueue({ limit: 200, taskId: selectedTaskId });
+ if (queueData && queueData.items) setQueueItems(queueData.items);
+ if (queueData && queueData.stats) setQueueStats(queueData.stats);
+ } finally {
+ logsPollInFlight.current = false;
+ }
+ };
+ load();
+ const interval = setInterval(load, 5000);
+ return () => clearInterval(interval);
+ }, [activeTab, selectedTaskId, isVisible]);
+
+ useEffect(() => {
+ if (!window.api || activeTab !== "events") return undefined;
+ const load = async () => {
+ if (!isVisible || eventsPollInFlight.current) return;
+ eventsPollInFlight.current = true;
+ try {
+ setAccountEvents(await window.api.listAccountEvents(200));
+ } finally {
+ eventsPollInFlight.current = false;
+ }
+ };
+ load();
+ const interval = setInterval(load, 10000);
+ return () => clearInterval(interval);
+ }, [activeTab, isVisible]);
+
+ useEffect(() => {
+ const timer = setInterval(() => setNow(Date.now()), 1000);
+ return () => clearInterval(timer);
+ }, [setNow]);
+
+ useEffect(() => {
+ const handleVisibility = () => {
+ setIsVisible(!document.hidden);
+ };
+ document.addEventListener("visibilitychange", handleVisibility);
+ window.addEventListener("focus", handleVisibility);
+ window.addEventListener("blur", handleVisibility);
+ return () => {
+ document.removeEventListener("visibilitychange", handleVisibility);
+ window.removeEventListener("focus", handleVisibility);
+ window.removeEventListener("blur", handleVisibility);
+ };
+ }, [setIsVisible]);
+}
diff --git a/src/renderer/hooks/useAppState.js b/src/renderer/hooks/useAppState.js
new file mode 100644
index 0000000..ee10141
--- /dev/null
+++ b/src/renderer/hooks/useAppState.js
@@ -0,0 +1,9 @@
+import useAppDataState from "./useAppDataState.js";
+import useAppUiState from "./useAppUiState.js";
+
+export default function useAppState() {
+ return {
+ ...useAppDataState(),
+ ...useAppUiState()
+ };
+}
diff --git a/src/renderer/hooks/useAppTabGroups.js b/src/renderer/hooks/useAppTabGroups.js
new file mode 100644
index 0000000..4244700
--- /dev/null
+++ b/src/renderer/hooks/useAppTabGroups.js
@@ -0,0 +1,283 @@
+export default function useAppTabGroups({
+ selectedTaskName,
+ taskForm,
+ setTaskForm,
+ activePreset,
+ applyTaskPreset,
+ formatAccountLabel,
+ accountById,
+ competitorText,
+ setCompetitorText,
+ applyRoleMode,
+ normalizeIntervals,
+ taskStatus,
+ perAccountInviteSum,
+ hasSelectedTask,
+ inviteAccessStatus,
+ inviteAccessCheckedAt,
+ formatTimestamp,
+ checkInviteAccess,
+ accounts,
+ showNotification,
+ copyToClipboard,
+ sanitizeTaskForm,
+ hasPerAccountInviteLimits,
+ fileImportResult,
+ importInviteFile,
+ fileImportForm,
+ setFileImportForm,
+ criticalErrorAccounts,
+ accountStatsMap,
+ settings,
+ membershipStatus,
+ assignedAccountMap,
+ accountBuckets,
+ filterFreeAccounts,
+ selectedAccountIds,
+ taskAccountRoles,
+ inviteAdminMasterId,
+ refreshMembership,
+ refreshIdentity,
+ formatAccountStatus,
+ resetCooldown,
+ deleteAccount,
+ updateAccountRole,
+ updateAccountInviteLimit,
+ setAccountRolesAll,
+ applyRolePreset,
+ removeAccountFromTask,
+ moveAccountToTask,
+ logsTab,
+ setLogsTab,
+ exportLogs,
+ clearLogs,
+ exportInvites,
+ exportProblemInvites,
+ exportFallback,
+ updateFallbackStatus,
+ clearFallback,
+ clearInvites,
+ logSearch,
+ setLogSearch,
+ logPage,
+ setLogPage,
+ logPageCount,
+ pagedLogs,
+ inviteSearch,
+ setInviteSearch,
+ invitePage,
+ setInvitePage,
+ invitePageCount,
+ inviteFilter,
+ setInviteFilter,
+ pagedInvites,
+ fallbackSearch,
+ setFallbackSearch,
+ fallbackPage,
+ setFallbackPage,
+ fallbackPageCount,
+ pagedFallback,
+ confirmQueue,
+ confirmSearch,
+ setConfirmSearch,
+ confirmPage,
+ setConfirmPage,
+ confirmPageCount,
+ pagedConfirmQueue,
+ queueItems,
+ queueStats,
+ queueSearch,
+ setQueueSearch,
+ queuePage,
+ setQueuePage,
+ queuePageCount,
+ pagedQueue,
+ clearConfirmQueue,
+ auditSearch,
+ setAuditSearch,
+ auditPage,
+ setAuditPage,
+ auditPageCount,
+ pagedAudit,
+ explainInviteError,
+ expandedInviteId,
+ setExpandedInviteId,
+ inviteStats,
+ invites,
+ selectedTask,
+ accessStatus,
+ roleSummary,
+ mutualContactDiagnostics,
+ accountById,
+ formatAccountLabel,
+ accountEvents,
+ clearAccountEvents,
+ onSettingsChange,
+ saveSettings
+}) {
+ const taskSettings = {
+ selectedTaskName,
+ taskForm,
+ setTaskForm,
+ activePreset,
+ applyTaskPreset,
+ formatAccountLabel,
+ accountById,
+ competitorText,
+ setCompetitorText,
+ roleMode: taskForm.requireSameBotInBoth ? "same" : "split",
+ applyRoleMode,
+ normalizeIntervals,
+ taskStatus,
+ perAccountInviteSum,
+ hasSelectedTask,
+ inviteAccessStatus,
+ inviteAccessCheckedAt,
+ formatTimestamp,
+ checkInviteAccess,
+ accounts,
+ showNotification,
+ copyToClipboard,
+ sanitizeTaskForm,
+ hasPerAccountInviteLimits,
+ fileImportResult,
+ importInviteFile,
+ fileImportForm,
+ setFileImportForm,
+ criticalErrorAccounts
+ };
+
+ const accountsTab = {
+ accounts,
+ accountStatsMap,
+ settings,
+ membershipStatus,
+ assignedAccountMap,
+ accountBuckets,
+ filterFreeAccounts,
+ selectedAccountIds,
+ taskAccountRoles,
+ rolesMode: taskForm.rolesMode,
+ setRolesMode: (mode) => {
+ setTaskForm((prev) => ({ ...prev, rolesMode: mode }));
+ if (mode === "auto") {
+ applyRolePreset("split");
+ }
+ },
+ hasSelectedTask,
+ inviteAdminMasterId,
+ refreshMembership,
+ refreshIdentity,
+ formatAccountStatus,
+ formatAccountLabel,
+ resetCooldown,
+ deleteAccount,
+ updateAccountRole,
+ updateAccountInviteLimit,
+ setAccountRolesAll,
+ applyRolePreset,
+ removeAccountFromTask,
+ moveAccountToTask
+ };
+
+ const logsTabGroup = {
+ logsTab,
+ setLogsTab,
+ hasSelectedTask,
+ exportLogs,
+ clearLogs,
+ exportInvites,
+ exportProblemInvites,
+ exportFallback,
+ updateFallbackStatus,
+ clearFallback,
+ clearInvites,
+ logSearch,
+ setLogSearch,
+ logPage,
+ setLogPage,
+ logPageCount,
+ pagedLogs,
+ inviteSearch,
+ setInviteSearch,
+ invitePage,
+ setInvitePage,
+ invitePageCount,
+ inviteFilter,
+ setInviteFilter,
+ pagedInvites,
+ fallbackSearch,
+ setFallbackSearch,
+ fallbackPage,
+ setFallbackPage,
+ fallbackPageCount,
+ pagedFallback,
+ confirmQueue,
+ confirmSearch,
+ setConfirmSearch,
+ confirmPage,
+ setConfirmPage,
+ confirmPageCount,
+ pagedConfirmQueue,
+ queueItems,
+ queueStats,
+ queueSearch,
+ setQueueSearch,
+ queuePage,
+ setQueuePage,
+ queuePageCount,
+ pagedQueue,
+ clearConfirmQueue,
+ auditSearch,
+ setAuditSearch,
+ auditPage,
+ setAuditPage,
+ auditPageCount,
+ pagedAudit,
+ explainInviteError,
+ expandedInviteId,
+ setExpandedInviteId,
+ inviteStats,
+ invites,
+ selectedTask,
+ accessStatus,
+ inviteAccessStatus,
+ selectedTaskName,
+ roleSummary,
+ mutualContactDiagnostics,
+ accountById,
+ formatAccountLabel
+ };
+ const queueTabGroup = {
+ queueStats,
+ queueSearch,
+ setQueueSearch,
+ queuePage,
+ setQueuePage,
+ queuePageCount,
+ pagedQueue
+ };
+
+ const eventsTab = {
+ accountEvents,
+ formatTimestamp,
+ onClearEvents: clearAccountEvents,
+ accountById,
+ formatAccountLabel
+ };
+
+ const settingsTab = {
+ settings,
+ onSettingsChange,
+ saveSettings
+ };
+
+ return {
+ taskSettings,
+ accountsTab,
+ logsTab: logsTabGroup,
+ queueTab: queueTabGroup,
+ eventsTab,
+ settingsTab
+ };
+}
diff --git a/src/renderer/hooks/useAppTaskDerived.js b/src/renderer/hooks/useAppTaskDerived.js
new file mode 100644
index 0000000..157f729
--- /dev/null
+++ b/src/renderer/hooks/useAppTaskDerived.js
@@ -0,0 +1,32 @@
+import { useMemo } from "react";
+
+export default function useAppTaskDerived({
+ tasks,
+ selectedTaskId,
+ taskForm,
+ competitorText
+}) {
+ const competitorGroups = useMemo(() => {
+ return competitorText
+ .split("\\n")
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0);
+ }, [competitorText]);
+
+ const hasSelectedTask = selectedTaskId != null;
+ const selectedTask = tasks.find((task) => task.id === selectedTaskId) || null;
+ const selectedTaskName = selectedTask ? (selectedTask.name || `Задача #${selectedTask.id}`) : "—";
+ const canSaveTask = Boolean(
+ taskForm.name.trim() &&
+ taskForm.ourGroup.trim() &&
+ competitorGroups.length > 0
+ );
+
+ return {
+ competitorGroups,
+ hasSelectedTask,
+ selectedTask,
+ selectedTaskName,
+ canSaveTask
+ };
+}
diff --git a/src/renderer/hooks/useAppUiState.js b/src/renderer/hooks/useAppUiState.js
new file mode 100644
index 0000000..f7289b0
--- /dev/null
+++ b/src/renderer/hooks/useAppUiState.js
@@ -0,0 +1,83 @@
+import { useRef, useState } from "react";
+
+export default function useAppUiState() {
+ const [notificationsOpen, setNotificationsOpen] = useState(false);
+ const notificationsModalRef = useRef(null);
+ const [importModalOpen, setImportModalOpen] = useState(false);
+ const [nowExpanded, setNowExpanded] = useState(false);
+ const [moreActionsOpen, setMoreActionsOpen] = useState(false);
+ const moreActionsRef = useRef(null);
+ const [checklistOpen, setChecklistOpen] = useState(true);
+ const [manualLoginOpen, setManualLoginOpen] = useState(false);
+ const [taskSearch, setTaskSearch] = useState("");
+ const [taskFilter, setTaskFilter] = useState("all");
+ const [infoOpen, setInfoOpen] = useState(false);
+ const [infoTab, setInfoTab] = useState("usage");
+ const [activeTab, setActiveTab] = useState("events");
+ const [logsTab, setLogsTab] = useState("logs");
+ const [taskSort, setTaskSort] = useState("activity");
+ const [expandedInviteId, setExpandedInviteId] = useState(null);
+ const [liveConfirmOpen, setLiveConfirmOpen] = useState(false);
+ const [liveConfirmContext, setLiveConfirmContext] = useState(null);
+ const [now, setNow] = useState(Date.now());
+ const [isVisible, setIsVisible] = useState(!document.hidden);
+ const bellRef = useRef(null);
+ const settingsAutosaveReady = useRef(false);
+ const taskAutosaveReady = useRef(false);
+ const taskAutosaveTimer = useRef(null);
+ const autosaveNoteTimer = useRef(null);
+ const tasksPollInFlight = useRef(false);
+ const accountsPollInFlight = useRef(false);
+ const logsPollInFlight = useRef(false);
+ const eventsPollInFlight = useRef(false);
+
+ return {
+ notificationsOpen,
+ setNotificationsOpen,
+ notificationsModalRef,
+ importModalOpen,
+ setImportModalOpen,
+ nowExpanded,
+ setNowExpanded,
+ moreActionsOpen,
+ setMoreActionsOpen,
+ moreActionsRef,
+ checklistOpen,
+ setChecklistOpen,
+ manualLoginOpen,
+ setManualLoginOpen,
+ taskSearch,
+ setTaskSearch,
+ taskFilter,
+ setTaskFilter,
+ infoOpen,
+ setInfoOpen,
+ infoTab,
+ setInfoTab,
+ activeTab,
+ setActiveTab,
+ logsTab,
+ setLogsTab,
+ taskSort,
+ setTaskSort,
+ expandedInviteId,
+ setExpandedInviteId,
+ liveConfirmOpen,
+ setLiveConfirmOpen,
+ liveConfirmContext,
+ setLiveConfirmContext,
+ now,
+ setNow,
+ isVisible,
+ setIsVisible,
+ bellRef,
+ settingsAutosaveReady,
+ taskAutosaveReady,
+ taskAutosaveTimer,
+ autosaveNoteTimer,
+ tasksPollInFlight,
+ accountsPollInFlight,
+ logsPollInFlight,
+ eventsPollInFlight
+ };
+}
diff --git a/src/renderer/hooks/useAutosave.js b/src/renderer/hooks/useAutosave.js
new file mode 100644
index 0000000..cf6430c
--- /dev/null
+++ b/src/renderer/hooks/useAutosave.js
@@ -0,0 +1,45 @@
+import { useEffect } from "react";
+
+export default function useAutosave({
+ settings,
+ settingsAutosaveReady,
+ taskAutosaveReady,
+ taskAutosaveTimer,
+ taskForm,
+ competitorText,
+ taskAccountRoles,
+ selectedAccountIds,
+ canSaveTask,
+ saveTask,
+ setSettings,
+ showNotification
+}) {
+ useEffect(() => {
+ if (!settingsAutosaveReady.current) {
+ settingsAutosaveReady.current = true;
+ return;
+ }
+ if (!window.api) return;
+ const timer = setTimeout(async () => {
+ try {
+ const updated = await window.api.saveSettings(settings);
+ setSettings(updated);
+ } catch (error) {
+ showNotification(error.message || String(error), "error");
+ }
+ }, 600);
+ return () => clearTimeout(timer);
+ }, [settings]);
+
+ useEffect(() => {
+ if (!taskAutosaveReady.current) return;
+ if (!canSaveTask) return;
+ if (taskAutosaveTimer.current) {
+ clearTimeout(taskAutosaveTimer.current);
+ }
+ taskAutosaveTimer.current = setTimeout(() => {
+ saveTask("autosave", { silent: true });
+ }, 800);
+ return () => clearTimeout(taskAutosaveTimer.current);
+ }, [taskForm, competitorText, taskAccountRoles, selectedAccountIds, canSaveTask]);
+}
diff --git a/src/renderer/hooks/useCriticalEvents.js b/src/renderer/hooks/useCriticalEvents.js
new file mode 100644
index 0000000..01c42d5
--- /dev/null
+++ b/src/renderer/hooks/useCriticalEvents.js
@@ -0,0 +1,25 @@
+import { useMemo } from "react";
+
+export default function useCriticalEvents({ accountEvents, accounts }) {
+ const criticalEvents = useMemo(() => {
+ const criticalTypes = new Set([
+ "connect_failed",
+ "invite_failed",
+ "invite_user_invalid",
+ "monitor_handler_error",
+ "flood"
+ ]);
+ const now = Date.now();
+ return (accountEvents || []).filter((event) => {
+ const ts = new Date(event.createdAt).getTime();
+ const recent = Number.isFinite(ts) ? (now - ts) < 24 * 60 * 60 * 1000 : true;
+ return criticalTypes.has(event.eventType) && recent;
+ });
+ }, [accountEvents]);
+
+ const criticalErrorAccounts = useMemo(() => {
+ return accounts.filter((account) => account.status && account.status !== "ok");
+ }, [accounts]);
+
+ return { criticalEvents, criticalErrorAccounts };
+}
diff --git a/src/renderer/hooks/useInviteImport.js b/src/renderer/hooks/useInviteImport.js
new file mode 100644
index 0000000..19b85c5
--- /dev/null
+++ b/src/renderer/hooks/useInviteImport.js
@@ -0,0 +1,50 @@
+export default function useInviteImport({
+ fileImportForm,
+ setFileImportForm,
+ setFileImportResult,
+ hasSelectedTask,
+ selectedTaskId,
+ showNotification,
+ setInvites,
+ setFallbackList,
+ loadTaskStatuses
+}) {
+ const importInviteFile = async () => {
+ if (!window.api) {
+ showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
+ return;
+ }
+ if (!hasSelectedTask) {
+ showNotification("Сначала выберите задачу.", "error");
+ return;
+ }
+ if (fileImportForm.onlyIds && !fileImportForm.sourceChat.trim()) {
+ showNotification("Для файла только с ID нужен источник.", "error");
+ return;
+ }
+ try {
+ const result = await window.api.importInviteFile({
+ taskId: selectedTaskId,
+ onlyIds: fileImportForm.onlyIds,
+ sourceChat: fileImportForm.sourceChat
+ });
+ if (result && result.canceled) return;
+ if (!result.ok) {
+ showNotification(result.error || "Ошибка импорта файла", "error");
+ return;
+ }
+ setFileImportResult(result);
+ setFileImportForm({ onlyIds: false, sourceChat: "" });
+ showNotification("Файл импортирован.", "success");
+ const invitesData = await window.api.listInvites(selectedTaskId);
+ setInvites(invitesData);
+ const fallbackData = await window.api.listFallbackInvites(selectedTaskId);
+ setFallbackList(fallbackData);
+ await loadTaskStatuses();
+ } catch (error) {
+ showNotification(error.message || String(error), "error");
+ }
+ };
+
+ return { importInviteFile };
+}
diff --git a/src/renderer/hooks/useLogsView.js b/src/renderer/hooks/useLogsView.js
new file mode 100644
index 0000000..a83ea53
--- /dev/null
+++ b/src/renderer/hooks/useLogsView.js
@@ -0,0 +1,262 @@
+import { useMemo, useState } from "react";
+
+const createPager = (items, page, pageSize) => {
+ const pageCount = Math.max(1, Math.ceil(items.length / pageSize));
+ const start = (page - 1) * pageSize;
+ const end = start + pageSize;
+ return { pageCount, paged: items.slice(start, end) };
+};
+
+export default function useLogsView({
+ logs,
+ invites,
+ fallbackList,
+ taskAudit,
+ confirmQueue,
+ queueItems
+}) {
+ const [logSearch, setLogSearch] = useState("");
+ const [inviteSearch, setInviteSearch] = useState("");
+ const [fallbackSearch, setFallbackSearch] = useState("");
+ const [auditSearch, setAuditSearch] = useState("");
+ const [confirmSearch, setConfirmSearch] = useState("");
+ const [logPage, setLogPage] = useState(1);
+ const [invitePage, setInvitePage] = useState(1);
+ const [fallbackPage, setFallbackPage] = useState(1);
+ const [auditPage, setAuditPage] = useState(1);
+ const [confirmPage, setConfirmPage] = useState(1);
+ const [inviteFilter, setInviteFilter] = useState("all");
+ const [queueSearch, setQueueSearch] = useState("");
+ const [queuePage, setQueuePage] = useState(1);
+
+ const filteredLogs = useMemo(() => {
+ const query = logSearch.trim().toLowerCase();
+ if (!query) return logs;
+ return logs.filter((log) => {
+ const text = [
+ log.startedAt,
+ log.finishedAt,
+ String(log.invitedCount),
+ (log.successIds || []).join(","),
+ (log.errors || []).join("|")
+ ]
+ .join(" ")
+ .toLowerCase();
+ return text.includes(query);
+ });
+ }, [logs, logSearch]);
+
+ const filteredInvites = useMemo(() => {
+ const query = inviteSearch.trim().toLowerCase();
+ return invites.filter((invite) => {
+ if (inviteFilter === "success" && invite.status !== "success") return false;
+ if (inviteFilter === "error" && invite.status !== "failed") return false;
+ if (inviteFilter === "skipped" && invite.status !== "skipped") return false;
+ if (inviteFilter === "unconfirmed" && invite.status !== "unconfirmed") return false;
+ const text = [
+ invite.invitedAt,
+ invite.userId,
+ invite.username,
+ invite.sourceChat,
+ invite.accountPhone,
+ invite.watcherPhone,
+ invite.strategy,
+ invite.strategyMeta,
+ invite.error,
+ invite.skippedReason,
+ invite.confirmError
+ ]
+ .join(" ")
+ .toLowerCase();
+ if (!query) return true;
+ return text.includes(query);
+ });
+ }, [invites, inviteSearch, inviteFilter]);
+
+ const filteredFallback = useMemo(() => {
+ const query = fallbackSearch.trim().toLowerCase();
+ return fallbackList.filter((item) => {
+ const text = [
+ item.userId,
+ item.username,
+ item.reason,
+ item.route,
+ item.sourceChat,
+ item.targetChat,
+ item.status,
+ item.createdAt
+ ]
+ .join(" ")
+ .toLowerCase();
+ if (!query) return true;
+ return text.includes(query);
+ });
+ }, [fallbackList, fallbackSearch]);
+
+ const filteredConfirmQueue = useMemo(() => {
+ const query = confirmSearch.trim().toLowerCase();
+ if (!query) return confirmQueue;
+ return confirmQueue.filter((item) => {
+ const text = [
+ item.user_id,
+ item.username,
+ item.account_id,
+ item.watcher_account_id,
+ item.last_error,
+ item.next_check_at
+ ]
+ .filter(Boolean)
+ .join(" ")
+ .toLowerCase();
+ return text.includes(query);
+ });
+ }, [confirmQueue, confirmSearch]);
+
+ const filteredAudit = useMemo(() => {
+ const query = auditSearch.trim().toLowerCase();
+ if (!query) return taskAudit;
+ return taskAudit.filter((item) => {
+ const text = [item.action, item.details, item.createdAt].join(" ").toLowerCase();
+ return text.includes(query);
+ });
+ }, [taskAudit, auditSearch]);
+ const filteredQueue = useMemo(() => {
+ const query = queueSearch.trim().toLowerCase();
+ if (!query) return queueItems || [];
+ return (queueItems || []).filter((item) => {
+ const text = [
+ item.user_id,
+ item.username,
+ item.source_chat,
+ item.watcher_account_id,
+ item.attempts,
+ item.created_at
+ ]
+ .filter(Boolean)
+ .join(" ")
+ .toLowerCase();
+ return text.includes(query);
+ });
+ }, [queueItems, queueSearch]);
+
+ const inviteStrategyStats = useMemo(() => {
+ let success = 0;
+ let failed = 0;
+ invites.forEach((invite) => {
+ if (!invite.strategyMeta) return;
+ try {
+ const parsed = JSON.parse(invite.strategyMeta);
+ if (!Array.isArray(parsed) || !parsed.length) return;
+ const hasOk = parsed.some((item) => item.ok);
+ if (hasOk) success += 1;
+ else failed += 1;
+ } catch (error) {
+ // ignore parse errors
+ }
+ });
+ return { success, failed };
+ }, [invites]);
+
+ const inviteStats = useMemo(() => {
+ const stats = {
+ total: invites.length,
+ success: 0,
+ failed: 0,
+ skipped: 0,
+ unconfirmed: 0
+ };
+ invites.forEach((invite) => {
+ switch (invite.status) {
+ case "success":
+ stats.success += 1;
+ break;
+ case "failed":
+ stats.failed += 1;
+ break;
+ case "skipped":
+ stats.skipped += 1;
+ break;
+ case "unconfirmed":
+ stats.unconfirmed += 1;
+ break;
+ default:
+ break;
+ }
+ });
+ return stats;
+ }, [invites]);
+
+ const logPageSize = 20;
+ const invitePageSize = 20;
+ const fallbackPageSize = 20;
+ const auditPageSize = 20;
+ const confirmPageSize = 20;
+ const queuePageSize = 20;
+
+ const { pageCount: logPageCount, paged: pagedLogs } = createPager(filteredLogs, logPage, logPageSize);
+ const { pageCount: invitePageCount, paged: pagedInvites } = createPager(filteredInvites, invitePage, invitePageSize);
+ const { pageCount: fallbackPageCount, paged: pagedFallback } = createPager(filteredFallback, fallbackPage, fallbackPageSize);
+ const { pageCount: auditPageCount, paged: pagedAudit } = createPager(filteredAudit, auditPage, auditPageSize);
+ const { pageCount: confirmPageCount, paged: pagedConfirmQueue } = createPager(filteredConfirmQueue, confirmPage, confirmPageSize);
+ const { pageCount: queuePageCount, paged: pagedQueue } = createPager(filteredQueue, queuePage, queuePageSize);
+
+ const mutualContactDiagnostics = useMemo(() => {
+ const items = invites
+ .filter((invite) => invite.error === "USER_NOT_MUTUAL_CONTACT")
+ .slice()
+ .sort((a, b) => (b.invitedAt || "").localeCompare(a.invitedAt || ""));
+ return {
+ count: items.length,
+ recent: items.slice(0, 5)
+ };
+ }, [invites]);
+
+ return {
+ logSearch,
+ setLogSearch,
+ inviteSearch,
+ setInviteSearch,
+ fallbackSearch,
+ setFallbackSearch,
+ auditSearch,
+ setAuditSearch,
+ confirmSearch,
+ setConfirmSearch,
+ logPage,
+ setLogPage,
+ invitePage,
+ setInvitePage,
+ fallbackPage,
+ setFallbackPage,
+ auditPage,
+ setAuditPage,
+ confirmPage,
+ setConfirmPage,
+ queueSearch,
+ setQueueSearch,
+ queuePage,
+ setQueuePage,
+ inviteFilter,
+ setInviteFilter,
+ filteredLogs,
+ filteredInvites,
+ filteredFallback,
+ filteredAudit,
+ filteredConfirmQueue,
+ logPageCount,
+ invitePageCount,
+ fallbackPageCount,
+ auditPageCount,
+ confirmPageCount,
+ queuePageCount,
+ pagedLogs,
+ pagedInvites,
+ pagedFallback,
+ pagedAudit,
+ pagedConfirmQueue,
+ pagedQueue,
+ inviteStrategyStats,
+ inviteStats,
+ mutualContactDiagnostics
+ };
+}
diff --git a/src/renderer/hooks/useMainUiProps.js b/src/renderer/hooks/useMainUiProps.js
new file mode 100644
index 0000000..51e63c9
--- /dev/null
+++ b/src/renderer/hooks/useMainUiProps.js
@@ -0,0 +1,100 @@
+export default function useMainUiProps({
+ selectedTaskName,
+ autosaveNote,
+ taskStatus,
+ hasSelectedTask,
+ canSaveTask,
+ taskActionLoading,
+ saveTask,
+ parseHistory,
+ joinGroupsForTask,
+ checkAll,
+ startTask,
+ stopTask,
+ moreActionsOpen,
+ setMoreActionsOpen,
+ moreActionsRef,
+ clearQueue,
+ startAllTasks,
+ stopAllTasks,
+ clearDatabase,
+ resetSessions,
+ pauseReason,
+ setActiveTab,
+ tasksLength,
+ runTestSafe,
+ nowLine,
+ nowExpanded,
+ setNowExpanded,
+ primaryIssue,
+ openFixTab,
+ monitorLabels,
+ inviteLabels,
+ roleSummary,
+ groupVisibility,
+ lastEvents,
+ formatTimestamp,
+ checklistOpen,
+ setChecklistOpen,
+ checklistStats,
+ checklistItems,
+ activeTab,
+ logsTab,
+ setLogsTab
+}) {
+ const quickActions = {
+ selectedTaskName,
+ autosaveNote,
+ taskStatus,
+ hasSelectedTask,
+ canSaveTask,
+ taskActionLoading,
+ saveTask,
+ parseHistory,
+ joinGroupsForTask,
+ checkAll,
+ startTask,
+ stopTask,
+ moreActionsOpen,
+ setMoreActionsOpen,
+ moreActionsRef,
+ clearQueue,
+ startAllTasks,
+ stopAllTasks,
+ clearDatabase,
+ resetSessions,
+ pauseReason,
+ setActiveTab,
+ tasksLength,
+ runTestSafe
+ };
+ const nowStatus = {
+ nowLine,
+ nowExpanded,
+ setNowExpanded,
+ primaryIssue,
+ openFixTab,
+ monitorLabels,
+ inviteLabels,
+ roleSummary,
+ taskStatus,
+ groupVisibility,
+ lastEvents,
+ formatTimestamp
+ };
+ const checklist = {
+ checklistOpen,
+ setChecklistOpen,
+ checklistStats,
+ checklistItems,
+ setActiveTab
+ };
+ const tabs = {
+ activeTab,
+ setActiveTab,
+ logsTab,
+ setLogsTab
+ };
+
+ return { quickActions, nowStatus, checklist, tabs };
+}
diff --git a/src/renderer/hooks/useNotices.js b/src/renderer/hooks/useNotices.js
new file mode 100644
index 0000000..e22c627
--- /dev/null
+++ b/src/renderer/hooks/useNotices.js
@@ -0,0 +1,29 @@
+import { useEffect } from "react";
+
+export default function useNotices({
+ taskNotice,
+ setTaskNotice,
+ settingsNotice,
+ setSettingsNotice,
+ tdataNotice,
+ setTdataNotice,
+ showNotification
+}) {
+ useEffect(() => {
+ if (!taskNotice) return;
+ showNotification(taskNotice.text, taskNotice.tone || "info");
+ setTaskNotice(null);
+ }, [taskNotice]);
+
+ useEffect(() => {
+ if (!settingsNotice) return;
+ showNotification(settingsNotice.text, settingsNotice.tone || "info");
+ setSettingsNotice(null);
+ }, [settingsNotice]);
+
+ useEffect(() => {
+ if (!tdataNotice) return;
+ showNotification(tdataNotice.text, tdataNotice.tone || "info");
+ setTdataNotice(null);
+ }, [tdataNotice]);
+}
diff --git a/src/renderer/hooks/useNotifications.js b/src/renderer/hooks/useNotifications.js
new file mode 100644
index 0000000..8f7ed81
--- /dev/null
+++ b/src/renderer/hooks/useNotifications.js
@@ -0,0 +1,66 @@
+import { useMemo, useRef, useState } from "react";
+
+export default function useNotifications() {
+ const [toasts, setToasts] = useState([]);
+ const toastTimers = useRef(new Map());
+ const [notifications, setNotifications] = useState([]);
+ const [notificationFilter, setNotificationFilter] = useState("all");
+
+ const showNotification = (text, tone = "info") => {
+ if (!text) return;
+ const key = `${tone}|${text}`;
+ const now = Date.now();
+ setNotifications((prev) => {
+ const existingIndex = prev.findIndex((item) => item.key === key);
+ if (existingIndex >= 0) {
+ const updated = { ...prev[existingIndex], count: (prev[existingIndex].count || 1) + 1, lastAt: now };
+ const next = [updated, ...prev.filter((_, index) => index !== existingIndex)];
+ return next.slice(0, 20);
+ }
+ const entry = { text, tone, id: `${now}-${Math.random().toString(36).slice(2)}`, key, count: 1, lastAt: now };
+ return [entry, ...prev].slice(0, 20);
+ });
+ setToasts((prev) => {
+ const existingIndex = prev.findIndex((item) => item.key === key);
+ if (existingIndex >= 0) {
+ const updated = { ...prev[existingIndex], count: (prev[existingIndex].count || 1) + 1, lastAt: now };
+ const next = [updated, ...prev.filter((_, index) => index !== existingIndex)];
+ return next.slice(0, 4);
+ }
+ const entry = { text, tone, id: `${now}-${Math.random().toString(36).slice(2)}`, key, count: 1, lastAt: now };
+ return [entry, ...prev].slice(0, 4);
+ });
+ if (toastTimers.current.has(key)) {
+ clearTimeout(toastTimers.current.get(key));
+ }
+ const timeoutId = setTimeout(() => {
+ setToasts((prev) => prev.filter((item) => item.key !== key));
+ toastTimers.current.delete(key);
+ }, 6000);
+ toastTimers.current.set(key, timeoutId);
+ };
+
+ const dismissToast = (toast) => {
+ setToasts((prev) => prev.filter((item) => item.id !== toast.id));
+ if (toast.key && toastTimers.current.has(toast.key)) {
+ clearTimeout(toastTimers.current.get(toast.key));
+ toastTimers.current.delete(toast.key);
+ }
+ };
+
+ const filteredNotifications = useMemo(() => {
+ if (notificationFilter === "all") return notifications;
+ return notifications.filter((item) => item.tone === notificationFilter);
+ }, [notifications, notificationFilter]);
+
+ return {
+ toasts,
+ notifications,
+ setNotifications,
+ notificationFilter,
+ setNotificationFilter,
+ filteredNotifications,
+ showNotification,
+ dismissToast
+ };
+}
diff --git a/src/renderer/hooks/useOpenLogsTabListener.js b/src/renderer/hooks/useOpenLogsTabListener.js
new file mode 100644
index 0000000..a153931
--- /dev/null
+++ b/src/renderer/hooks/useOpenLogsTabListener.js
@@ -0,0 +1,9 @@
+import { useEffect } from "react";
+
+export default function useOpenLogsTabListener(setActiveTab) {
+ useEffect(() => {
+ const handleOpenLogs = () => setActiveTab("logs");
+ window.addEventListener("openLogsTab", handleOpenLogs);
+ return () => window.removeEventListener("openLogsTab", handleOpenLogs);
+ }, [setActiveTab]);
+}
diff --git a/src/renderer/hooks/useOutsideClick.js b/src/renderer/hooks/useOutsideClick.js
new file mode 100644
index 0000000..d59c653
--- /dev/null
+++ b/src/renderer/hooks/useOutsideClick.js
@@ -0,0 +1,19 @@
+import { useEffect } from "react";
+
+export default function useOutsideClick({
+ enabled,
+ refs = [],
+ onOutside
+}) {
+ useEffect(() => {
+ if (!enabled) return;
+ const handleClickOutside = (event) => {
+ const isInside = refs.some((ref) => ref && ref.current && ref.current.contains(event.target));
+ if (!isInside) {
+ onOutside();
+ }
+ };
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => document.removeEventListener("mousedown", handleClickOutside);
+ }, [enabled, onOutside, refs]);
+}
diff --git a/src/renderer/hooks/useSettingsActions.js b/src/renderer/hooks/useSettingsActions.js
new file mode 100644
index 0000000..a930792
--- /dev/null
+++ b/src/renderer/hooks/useSettingsActions.js
@@ -0,0 +1,33 @@
+export default function useSettingsActions({
+ settings,
+ setSettings,
+ setSettingsNotice,
+ showNotification
+}) {
+ const onSettingsChange = (field, value) => {
+ setSettings((prev) => ({
+ ...prev,
+ [field]: value
+ }));
+ };
+
+ const saveSettings = async () => {
+ if (!window.api) {
+ setSettingsNotice({ text: "Electron API недоступен. Откройте приложение в Electron.", tone: "error" });
+ showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
+ return;
+ }
+ try {
+ showNotification("Сохраняем настройки...", "info");
+ const updated = await window.api.saveSettings(settings);
+ setSettings(updated);
+ setSettingsNotice({ text: "Настройки сохранены.", tone: "success" });
+ } catch (error) {
+ const message = error.message || String(error);
+ setSettingsNotice({ text: message, tone: "error" });
+ showNotification(message, "error");
+ }
+ };
+
+ return { onSettingsChange, saveSettings };
+}
diff --git a/src/renderer/hooks/useTabProps.js b/src/renderer/hooks/useTabProps.js
new file mode 100644
index 0000000..65e108c
--- /dev/null
+++ b/src/renderer/hooks/useTabProps.js
@@ -0,0 +1,312 @@
+export default function useTabProps(
+ taskSettings,
+ accountsTab,
+ logsTab,
+ queueTab,
+ eventsTab,
+ settingsTab
+) {
+ const {
+ selectedTaskName,
+ taskForm,
+ setTaskForm,
+ activePreset,
+ applyTaskPreset,
+ formatAccountLabel,
+ accountById,
+ competitorText,
+ setCompetitorText,
+ roleMode,
+ applyRoleMode,
+ normalizeIntervals,
+ taskStatus,
+ perAccountInviteSum,
+ hasSelectedTask,
+ inviteAccessStatus,
+ inviteAccessCheckedAt,
+ formatTimestamp,
+ checkInviteAccess,
+ accounts,
+ showNotification,
+ copyToClipboard,
+ sanitizeTaskForm,
+ hasPerAccountInviteLimits,
+ fileImportResult,
+ importInviteFile,
+ fileImportForm,
+ setFileImportForm,
+ criticalErrorAccounts
+ } = taskSettings;
+ const {
+ accountStatsMap,
+ settings,
+ membershipStatus,
+ assignedAccountMap,
+ accountBuckets,
+ filterFreeAccounts,
+ selectedAccountIds,
+ taskAccountRoles,
+ rolesMode,
+ setRolesMode,
+ inviteAdminMasterId,
+ refreshMembership,
+ refreshIdentity,
+ formatAccountStatus,
+ resetCooldown,
+ deleteAccount,
+ updateAccountRole,
+ updateAccountInviteLimit,
+ setAccountRolesAll,
+ applyRolePreset,
+ removeAccountFromTask,
+ moveAccountToTask
+ } = accountsTab;
+ const {
+ logsTab: logsTabName,
+ setLogsTab,
+ exportLogs,
+ clearLogs,
+ exportInvites,
+ exportProblemInvites,
+ exportFallback,
+ updateFallbackStatus,
+ clearFallback,
+ clearInvites,
+ logSearch,
+ setLogSearch,
+ logPage,
+ setLogPage,
+ logPageCount,
+ pagedLogs,
+ inviteSearch,
+ setInviteSearch,
+ invitePage,
+ setInvitePage,
+ invitePageCount,
+ inviteFilter,
+ setInviteFilter,
+ pagedInvites,
+ fallbackSearch,
+ setFallbackSearch,
+ fallbackPage,
+ setFallbackPage,
+ fallbackPageCount,
+ pagedFallback,
+ confirmQueue,
+ confirmSearch,
+ setConfirmSearch,
+ confirmPage,
+ setConfirmPage,
+ confirmPageCount,
+ pagedConfirmQueue,
+ queueItems,
+ queueStats,
+ queueSearch,
+ setQueueSearch,
+ queuePage,
+ setQueuePage,
+ queuePageCount,
+ pagedQueue,
+ clearConfirmQueue,
+ auditSearch,
+ setAuditSearch,
+ auditPage,
+ setAuditPage,
+ auditPageCount,
+ pagedAudit,
+ explainInviteError,
+ expandedInviteId,
+ setExpandedInviteId,
+ inviteStats,
+ invites,
+ selectedTask,
+ accessStatus,
+ roleSummary,
+ mutualContactDiagnostics,
+ accountById,
+ formatAccountLabel
+ } = logsTab;
+ const {
+ queueStats,
+ queueSearch,
+ setQueueSearch,
+ queuePage,
+ setQueuePage,
+ queuePageCount,
+ pagedQueue
+ } = queueTab;
+ const {
+ accountEvents,
+ onClearEvents
+ } = eventsTab;
+ const {
+ onSettingsChange,
+ saveSettings
+ } = settingsTab;
+ const taskSettingsProps = {
+ selectedTaskName,
+ taskForm,
+ setTaskForm,
+ activePreset,
+ applyTaskPreset,
+ formatAccountLabel,
+ accountById,
+ competitorText,
+ setCompetitorText,
+ roleMode,
+ applyRoleMode,
+ normalizeIntervals,
+ taskStatus,
+ perAccountInviteSum,
+ hasSelectedTask,
+ inviteAccessStatus,
+ inviteAccessCheckedAt,
+ formatTimestamp,
+ checkInviteAccess,
+ accounts,
+ showNotification,
+ copyToClipboard,
+ sanitizeTaskForm,
+ hasPerAccountInviteLimits,
+ fileImportResult,
+ importInviteFile,
+ fileImportForm,
+ setFileImportForm,
+ criticalErrorAccounts
+ };
+
+ const accountsTabProps = {
+ accounts,
+ accountStatsMap,
+ settings,
+ membershipStatus,
+ assignedAccountMap,
+ accountBuckets,
+ filterFreeAccounts,
+ selectedAccountIds,
+ taskAccountRoles,
+ rolesMode,
+ setRolesMode,
+ hasSelectedTask,
+ inviteAdminMasterId,
+ refreshMembership,
+ refreshIdentity,
+ formatAccountStatus,
+ formatAccountLabel,
+ resetCooldown,
+ deleteAccount,
+ updateAccountRole,
+ updateAccountInviteLimit,
+ setAccountRolesAll,
+ applyRolePreset,
+ removeAccountFromTask,
+ moveAccountToTask
+ };
+
+ const logsTabProps = {
+ logsTab: logsTabName,
+ setLogsTab,
+ hasSelectedTask,
+ exportLogs,
+ clearLogs,
+ exportInvites,
+ exportProblemInvites,
+ exportFallback,
+ updateFallbackStatus,
+ clearFallback,
+ clearInvites,
+ logSearch,
+ setLogSearch,
+ logPage,
+ setLogPage,
+ logPageCount,
+ pagedLogs,
+ inviteSearch,
+ setInviteSearch,
+ invitePage,
+ setInvitePage,
+ invitePageCount,
+ inviteFilter,
+ setInviteFilter,
+ pagedInvites,
+ fallbackSearch,
+ setFallbackSearch,
+ fallbackPage,
+ setFallbackPage,
+ fallbackPageCount,
+ pagedFallback,
+ confirmQueue,
+ confirmSearch,
+ setConfirmSearch,
+ confirmPage,
+ setConfirmPage,
+ confirmPageCount,
+ pagedConfirmQueue,
+ queueItems,
+ queueStats,
+ queueSearch,
+ setQueueSearch,
+ queuePage,
+ setQueuePage,
+ queuePageCount,
+ pagedQueue,
+ clearConfirmQueue,
+ auditSearch,
+ setAuditSearch,
+ auditPage,
+ setAuditPage,
+ auditPageCount,
+ pagedAudit,
+ formatTimestamp,
+ explainInviteError,
+ expandedInviteId,
+ setExpandedInviteId,
+ inviteStats,
+ invites,
+ selectedTask,
+ taskAccountRoles,
+ accessStatus,
+ inviteAccessStatus,
+ selectedTaskName,
+ roleSummary,
+ mutualContactDiagnostics,
+ accountById,
+ formatAccountLabel
+ };
+ const queueTabProps = {
+ hasSelectedTask,
+ queueStats,
+ queueSearch,
+ setQueueSearch,
+ queuePage,
+ setQueuePage,
+ queuePageCount,
+ pagedQueue,
+ accountById,
+ formatAccountLabel,
+ formatTimestamp
+ };
+
+ const eventsTabProps = {
+ accountEvents,
+ formatTimestamp,
+ onClearEvents,
+ accountById,
+ formatAccountLabel
+ };
+
+ const settingsTabProps = {
+ settings,
+ onSettingsChange,
+ saveSettings
+ };
+
+ return {
+ taskSettingsProps,
+ accountsTabProps,
+ logsTabProps,
+ queueTabProps,
+ eventsTabProps,
+ settingsTabProps
+ };
+}
diff --git a/src/renderer/hooks/useTaskActions.js b/src/renderer/hooks/useTaskActions.js
new file mode 100644
index 0000000..dd9f250
--- /dev/null
+++ b/src/renderer/hooks/useTaskActions.js
@@ -0,0 +1,588 @@
+import { useRef } from "react";
+
+export default function useTaskActions({
+ taskForm,
+ setTaskForm,
+ sanitizeTaskForm,
+ taskAccountRoles,
+ setTaskAccountRoles,
+ selectedAccountIds,
+ setSelectedAccountIds,
+ accounts,
+ selectedTaskId,
+ selectedTaskName,
+ competitorGroups,
+ hasSelectedTask,
+ setTaskNotice,
+ showNotification,
+ setAutosaveNote,
+ autosaveNoteTimer,
+ loadTasks,
+ loadAccountAssignments,
+ loadTaskStatuses,
+ refreshMembership,
+ checkInviteAccess,
+ checkAccess,
+ setLogs,
+ setInvites,
+ setTaskStatus,
+ setSelectedTaskId,
+ resetTaskForm,
+ setCompetitorText,
+ resetSelectedAccountIds,
+ resetTaskAccountRoles,
+ setFallbackList,
+ setConfirmQueue,
+ setAccountEvents,
+ setTaskActionLoading,
+ taskActionLoading,
+ loadBase,
+ createTask,
+ setActiveTab
+}) {
+ const withTimeout = (promise, ms) => (
+ Promise.race([
+ promise,
+ new Promise((_, reject) => setTimeout(() => reject(new Error("TIMEOUT")), ms))
+ ])
+ );
+
+ const saveTask = async (source = "editor", options = {}) => {
+ const silent = Boolean(options.silent);
+ if (!window.api) {
+ if (!silent) {
+ showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
+ }
+ return;
+ }
+ try {
+ if (!silent) {
+ showNotification("Сохраняем задачу...", "info");
+ }
+ const nextForm = sanitizeTaskForm(taskForm);
+ setTaskForm(nextForm);
+ const validateLink = (value) => {
+ const trimmed = String(value || "").trim();
+ if (!trimmed) return false;
+ if (trimmed.startsWith("@")) return true;
+ if (trimmed.startsWith("https://t.me/")) return true;
+ if (trimmed.startsWith("http://t.me/")) return true;
+ return false;
+ };
+ const invalidCompetitors = competitorGroups.filter((link) => !validateLink(link));
+ if (!validateLink(nextForm.ourGroup)) {
+ showNotification("Наша группа должна быть ссылкой t.me или @username.", "error");
+ return;
+ }
+ if (invalidCompetitors.length) {
+ showNotification(`Некорректные ссылки конкурентов: ${invalidCompetitors.join(", ")}`, "error");
+ return;
+ }
+ let accountRolesMap = { ...taskAccountRoles };
+ let accountIds = Object.keys(accountRolesMap).map((id) => Number(id));
+ if (nextForm.requireSameBotInBoth) {
+ const required = Math.max(1, Number(nextForm.maxCompetitorBots || 1));
+ const pool = (selectedAccountIds && selectedAccountIds.length ? selectedAccountIds : accounts.map((account) => account.id))
+ .filter((id) => Number.isFinite(id));
+ const chosen = pool.slice(0, required);
+ accountRolesMap = {};
+ chosen.forEach((accountId) => {
+ const existing = accountRolesMap[accountId] || {};
+ accountRolesMap[accountId] = { monitor: true, invite: true, confirm: true, inviteLimit: existing.inviteLimit || 1 };
+ });
+ accountIds = chosen;
+ setTaskAccountRoles(accountRolesMap);
+ setSelectedAccountIds(chosen);
+ }
+ if (nextForm.autoAssignAccounts && (!accountIds || accountIds.length === 0)) {
+ accountIds = accounts.map((account) => account.id);
+ accountRolesMap = {};
+ accountIds.forEach((accountId) => {
+ const existing = accountRolesMap[accountId] || {};
+ accountRolesMap[accountId] = { monitor: true, invite: true, confirm: true, inviteLimit: existing.inviteLimit || 1 };
+ });
+ setTaskAccountRoles(accountRolesMap);
+ setSelectedAccountIds(accountIds);
+ if (accountIds.length && !silent) {
+ setTaskNotice({ text: `Автоназначены аккаунты: ${accountIds.length}`, tone: "success", source });
+ }
+ }
+ if (!accountIds.length) {
+ if (!silent) {
+ showNotification("Нет аккаунтов для этой задачи.", "error");
+ }
+ return;
+ }
+ const roleEntries = Object.values(accountRolesMap);
+ if (roleEntries.length) {
+ const hasMonitor = roleEntries.some((item) => item.monitor);
+ const hasInvite = roleEntries.some((item) => item.invite);
+ const hasConfirm = roleEntries.some((item) => item.confirm);
+ if (!hasMonitor) {
+ if (!silent) {
+ showNotification("Нужен хотя бы один аккаунт с ролью мониторинга.", "error");
+ }
+ return;
+ }
+ if (!hasInvite) {
+ if (!silent) {
+ showNotification("Нужен хотя бы один аккаунт с ролью инвайта.", "error");
+ }
+ return;
+ }
+ if (nextForm.separateConfirmRoles && !hasConfirm) {
+ if (!silent) {
+ showNotification("Нужен хотя бы один аккаунт с ролью подтверждения.", "error");
+ }
+ return;
+ }
+ } else {
+ const requiredAccounts = nextForm.requireSameBotInBoth
+ ? Math.max(1, Number(nextForm.maxCompetitorBots || 1))
+ : nextForm.separateBotRoles
+ ? Math.max(1, Number(nextForm.maxCompetitorBots || 1))
+ + Math.max(1, Number(nextForm.maxOurBots || 1))
+ + (nextForm.separateConfirmRoles ? Math.max(1, Number(nextForm.maxConfirmBots || 1)) : 0)
+ : 1;
+ if (accountIds.length < requiredAccounts) {
+ if (!silent) {
+ showNotification(`Нужно минимум ${requiredAccounts} аккаунтов для выбранного режима.`, "error");
+ }
+ return;
+ }
+ }
+ const accountRoles = Object.entries(accountRolesMap).map(([id, roles]) => ({
+ accountId: Number(id),
+ roleMonitor: Boolean(roles.monitor),
+ roleInvite: Boolean(roles.invite),
+ roleConfirm: Boolean(roles.confirm != null ? roles.confirm : roles.invite),
+ inviteLimit: Number(roles.inviteLimit || 0)
+ }));
+ const result = await window.api.saveTask({
+ task: nextForm,
+ competitors: competitorGroups,
+ accountIds,
+ accountRoles
+ });
+ if (result.ok) {
+ if (!silent) {
+ setTaskNotice({ text: "Задача сохранена.", tone: "success", source });
+ } else {
+ setAutosaveNote("Автосохранено");
+ if (autosaveNoteTimer.current) {
+ clearTimeout(autosaveNoteTimer.current);
+ }
+ autosaveNoteTimer.current = setTimeout(() => {
+ setAutosaveNote("");
+ }, 1500);
+ }
+ await loadTasks();
+ await loadAccountAssignments();
+ setSelectedTaskId(result.taskId);
+ } else {
+ if (!silent) {
+ showNotification(result.error || "Не удалось сохранить задачу", "error");
+ }
+ }
+ } catch (error) {
+ if (!silent) {
+ showNotification(error.message || String(error), "error");
+ }
+ }
+ };
+
+ const deleteTask = async () => {
+ if (!window.api || selectedTaskId == null) {
+ return;
+ }
+ try {
+ await window.api.deleteTask(selectedTaskId);
+ setTaskNotice({ text: "Задача удалена.", tone: "success", source: "tasks" });
+ const tasksData = await loadTasks();
+ await loadAccountAssignments();
+ if (!tasksData.length) {
+ createTask();
+ setActiveTab("task");
+ }
+ } catch (error) {
+ showNotification(error.message || String(error), "error");
+ }
+ };
+
+ const startTask = async (source = "sidebar") => {
+ if (!window.api || selectedTaskId == null) {
+ showNotification("Сначала выберите задачу.", "error");
+ return;
+ }
+ if (taskActionLoading) return;
+ setTaskActionLoading(true);
+ showNotification("Запуск...", "info");
+ try {
+ const result = await withTimeout(window.api.startTaskById(selectedTaskId), 15000);
+ if (result && result.ok) {
+ setTaskNotice({ text: "Запущено.", tone: "success", source });
+ if (result.warnings && result.warnings.length) {
+ showNotification(`Предупреждения: ${result.warnings.join(" | ")}`, "info");
+ }
+ await refreshMembership("start_task");
+ checkInviteAccess("auto", true);
+ } else {
+ showNotification(result.error || "Не удалось запустить", "error");
+ }
+ } catch (error) {
+ const message = error.message === "TIMEOUT"
+ ? "Запуск не ответил за 15 секунд. Проверьте логи/события и попробуйте снова."
+ : (error.message || String(error));
+ setTaskNotice({ text: message, tone: "error", source });
+ showNotification(message, "error");
+ } finally {
+ setTaskActionLoading(false);
+ }
+ };
+
+ const startAllTasks = async () => {
+ if (!window.api) {
+ showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
+ return;
+ }
+ showNotification("Запускаем все задачи...", "info");
+ try {
+ const result = await window.api.startAllTasks();
+ if (result && result.errors && result.errors.length) {
+ const errorText = result.errors.map((item) => `${item.id}: ${item.error}`).join(" | ");
+ showNotification(`Ошибки запуска: ${errorText}`, "error");
+ }
+ const tasksData = await loadTasks();
+ await loadTaskStatuses(tasksData);
+ } catch (error) {
+ showNotification(error.message || String(error), "error");
+ }
+ };
+
+ const stopTask = async (source = "sidebar") => {
+ if (!window.api || selectedTaskId == null) {
+ showNotification("Сначала выберите задачу.", "error");
+ return;
+ }
+ if (taskActionLoading) return;
+ if (!window.confirm(`Остановить задачу: ${selectedTaskName}?`)) {
+ return;
+ }
+ setTaskActionLoading(true);
+ showNotification("Остановка...", "info");
+ try {
+ await withTimeout(window.api.stopTaskById(selectedTaskId), 15000);
+ setTaskNotice({ text: "Остановлено.", tone: "success", source });
+ } catch (error) {
+ const message = error.message === "TIMEOUT"
+ ? "Остановка не ответила за 15 секунд. Проверьте логи/события и попробуйте снова."
+ : (error.message || String(error));
+ setTaskNotice({ text: message, tone: "error", source });
+ showNotification(message, "error");
+ } finally {
+ setTaskActionLoading(false);
+ }
+ };
+
+ const stopAllTasks = async () => {
+ if (!window.api) {
+ showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
+ return;
+ }
+ if (!window.confirm("Остановить все задачи?")) {
+ return;
+ }
+ showNotification("Останавливаем все задачи...", "info");
+ try {
+ await window.api.stopAllTasks();
+ const tasksData = await loadTasks();
+ await loadTaskStatuses(tasksData);
+ } catch (error) {
+ showNotification(error.message || String(error), "error");
+ }
+ };
+
+ const parseHistory = async (source = "editor") => {
+ if (!window.api || selectedTaskId == null) {
+ showNotification("Сначала выберите задачу.", "error");
+ return;
+ }
+ showNotification("Собираем историю...", "info");
+ try {
+ const result = await window.api.parseHistoryByTask(selectedTaskId);
+ if (result && result.ok) {
+ setTaskNotice({ text: "История добавлена в очередь.", tone: "success", source });
+ if (result.errors && result.errors.length) {
+ showNotification(`Ошибки истории: ${result.errors.join(" | ")}`, "error");
+ }
+ setLogs(await window.api.listLogs({ limit: 100, taskId: selectedTaskId }));
+ setInvites(await window.api.listInvites({ limit: 200, taskId: selectedTaskId }));
+ return;
+ }
+ const message = result.error || "Ошибка при сборе истории";
+ setTaskNotice({ text: message, tone: "error", source });
+ showNotification(message, "error");
+ } catch (error) {
+ const message = error.message || String(error);
+ setTaskNotice({ text: message, tone: "error", source });
+ showNotification(message, "error");
+ }
+ };
+
+ const checkAll = async (source = "bar") => {
+ if (!window.api || selectedTaskId == null) {
+ showNotification("Сначала выберите задачу.", "error");
+ return;
+ }
+ showNotification("Проверяем всё: доступ, права, участие...", "info");
+ await checkAccess(source, true);
+ await checkInviteAccess(source, true);
+ await refreshMembership(source, true);
+ setTaskNotice({ text: "Проверка завершена.", tone: "success", source });
+ };
+
+ const joinGroupsForTask = async (source = "editor") => {
+ if (!window.api || selectedTaskId == null) {
+ showNotification("Сначала выберите задачу.", "error");
+ return;
+ }
+ try {
+ showNotification("Отправляем заявки на вступление...", "info");
+ const result = await window.api.joinGroupsByTask(selectedTaskId);
+ if (!result || !result.ok) {
+ showNotification(result?.error || "Не удалось отправить заявки", "error");
+ return;
+ }
+ setTaskNotice({ text: "Заявки на вступление отправлены.", tone: "success", source });
+ await refreshMembership("join_groups");
+ } catch (error) {
+ showNotification(error.message || String(error), "error");
+ }
+ };
+
+ const clearLogs = async (source = "editor") => {
+ if (!window.api || selectedTaskId == null) {
+ showNotification("Сначала выберите задачу.", "error");
+ return;
+ }
+ try {
+ await window.api.clearLogs(selectedTaskId);
+ setLogs([]);
+ setTaskNotice({ text: "Логи очищены.", tone: "success", source });
+ } catch (error) {
+ showNotification(error.message || String(error), "error");
+ }
+ };
+
+ const clearInvites = async (source = "editor") => {
+ if (!window.api || selectedTaskId == null) {
+ showNotification("Сначала выберите задачу.", "error");
+ return;
+ }
+ try {
+ await window.api.clearInvites(selectedTaskId);
+ setInvites([]);
+ setTaskNotice({ text: "История инвайтов очищена.", tone: "success", source });
+ } catch (error) {
+ showNotification(error.message || String(error), "error");
+ }
+ };
+
+ const clearAccountEvents = async () => {
+ if (!window.api) {
+ showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
+ return;
+ }
+ try {
+ await window.api.clearAccountEvents();
+ setAccountEvents([]);
+ showNotification("События очищены.", "success");
+ } catch (error) {
+ showNotification(error.message || String(error), "error");
+ }
+ };
+
+ const exportLogs = async (source = "editor") => {
+ if (!window.api || selectedTaskId == null) {
+ showNotification("Сначала выберите задачу.", "error");
+ return;
+ }
+ try {
+ const result = await window.api.exportLogs(selectedTaskId);
+ if (result && result.canceled) return;
+ setTaskNotice({ text: `Логи выгружены: ${result.filePath}`, tone: "success", source });
+ } catch (error) {
+ showNotification(error.message || String(error), "error");
+ }
+ };
+
+ const exportInvites = async (source = "editor") => {
+ if (!window.api || selectedTaskId == null) {
+ showNotification("Сначала выберите задачу.", "error");
+ return;
+ }
+ try {
+ const result = await window.api.exportInvites(selectedTaskId);
+ if (result && result.canceled) return;
+ setTaskNotice({ text: `История инвайтов выгружена: ${result.filePath}`, tone: "success", source });
+ } catch (error) {
+ showNotification(error.message || String(error), "error");
+ }
+ };
+
+ const exportProblemInvites = async (source = "editor") => {
+ if (!window.api || selectedTaskId == null) {
+ showNotification("Сначала выберите задачу.", "error");
+ return;
+ }
+ try {
+ const result = await window.api.exportProblemInvites(selectedTaskId);
+ if (result.canceled) return;
+ setTaskNotice({ text: `Проблемные инвайты выгружены: ${result.filePath}`, tone: "success", source });
+ } catch (error) {
+ showNotification(error.message || String(error), "error");
+ }
+ };
+
+ const exportFallback = async (source = "editor") => {
+ if (!window.api || selectedTaskId == null) {
+ showNotification("Сначала выберите задачу.", "error");
+ return;
+ }
+ try {
+ const result = await window.api.exportFallback(selectedTaskId);
+ if (result.canceled) return;
+ setTaskNotice({ text: `Fallback выгружен: ${result.filePath}`, tone: "success", source });
+ } catch (error) {
+ showNotification(error.message || String(error), "error");
+ }
+ };
+
+ const updateFallbackStatus = async (id, status) => {
+ if (!window.api) return;
+ try {
+ await window.api.updateFallback({ id, status });
+ if (selectedTaskId != null) {
+ setFallbackList(await window.api.listFallback({ limit: 500, taskId: selectedTaskId }));
+ }
+ } catch (error) {
+ showNotification(error.message || String(error), "error");
+ }
+ };
+
+ const clearFallback = async (source = "editor") => {
+ if (!window.api || selectedTaskId == null) {
+ showNotification("Сначала выберите задачу.", "error");
+ return;
+ }
+ try {
+ await window.api.clearFallback(selectedTaskId);
+ setFallbackList(await window.api.listFallback({ limit: 500, taskId: selectedTaskId }));
+ setTaskNotice({ text: "Fallback очищен.", tone: "success", source });
+ } catch (error) {
+ showNotification(error.message || String(error), "error");
+ }
+ };
+
+ const clearConfirmQueue = async (source = "editor") => {
+ if (!window.api || selectedTaskId == null) {
+ showNotification("Сначала выберите задачу.", "error");
+ return;
+ }
+ try {
+ await window.api.clearConfirmQueue(selectedTaskId);
+ setConfirmQueue(await window.api.listConfirmQueue({ limit: 500, taskId: selectedTaskId }));
+ setTaskNotice({ text: "Очередь подтверждений очищена.", tone: "success", source });
+ } catch (error) {
+ showNotification(error.message || String(error), "error");
+ }
+ };
+
+ const clearQueue = async (source = "editor") => {
+ if (!window.api || selectedTaskId == null) {
+ showNotification("Сначала выберите задачу.", "error");
+ return;
+ }
+ try {
+ await window.api.clearQueue(selectedTaskId);
+ const data = await window.api.taskStatus(selectedTaskId);
+ setTaskStatus(data);
+ setTaskNotice({ text: "Очередь очищена.", tone: "success", source });
+ } catch (error) {
+ showNotification(error.message || String(error), "error");
+ }
+ };
+
+ const clearDatabase = async () => {
+ if (!window.api) {
+ showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
+ return;
+ }
+ if (!window.confirm("Удалить все данные из базы? Это действие нельзя отменить.")) {
+ return;
+ }
+ try {
+ await window.api.clearDatabase();
+ showNotification("База очищена.", "info");
+ setSelectedTaskId(null);
+ resetTaskForm();
+ setCompetitorText("");
+ resetSelectedAccountIds([]);
+ resetTaskAccountRoles({});
+ setLogs([]);
+ setInvites([]);
+ setTaskStatus({
+ running: false,
+ queueCount: 0,
+ dailyRemaining: 0,
+ dailyUsed: 0,
+ dailyLimit: 0,
+ monitorInfo: { monitoring: false, accountId: 0, accountIds: [], groups: [], lastMessageAt: "", lastSource: "" }
+ });
+ await loadBase();
+ } catch (error) {
+ showNotification(error.message || String(error), "error");
+ }
+ };
+
+ const resetSessions = async () => {
+ if (!window.api) {
+ showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
+ return;
+ }
+ try {
+ await window.api.resetSessions();
+ showNotification("Сессии сброшены.", "info");
+ resetSelectedAccountIds([]);
+ resetTaskAccountRoles({});
+ await loadBase();
+ } catch (error) {
+ showNotification(error.message || String(error), "error");
+ }
+ };
+
+ return {
+ saveTask,
+ deleteTask,
+ startTask,
+ stopTask,
+ startAllTasks,
+ stopAllTasks,
+ parseHistory,
+ checkAll,
+ joinGroupsForTask,
+ clearLogs,
+ clearInvites,
+ clearAccountEvents,
+ exportLogs,
+ exportInvites,
+ exportProblemInvites,
+ exportFallback,
+ updateFallbackStatus,
+ clearFallback,
+ clearConfirmQueue,
+ clearQueue,
+ clearDatabase,
+ resetSessions
+ };
+}
diff --git a/src/renderer/hooks/useTaskFormActions.js b/src/renderer/hooks/useTaskFormActions.js
new file mode 100644
index 0000000..e68d691
--- /dev/null
+++ b/src/renderer/hooks/useTaskFormActions.js
@@ -0,0 +1,24 @@
+import { normalizeIntervals, sanitizeTaskForm } from "../appDefaults.js";
+
+export default function useTaskFormActions({ taskForm, setTaskForm }) {
+ const updateIntervals = (nextMin, nextMax) => {
+ const updated = normalizeIntervals({
+ ...taskForm,
+ minIntervalMinutes: nextMin,
+ maxIntervalMinutes: nextMax
+ });
+ setTaskForm(updated);
+ };
+
+ const applyRoleMode = (mode) => {
+ if (mode === "same") {
+ setTaskForm(sanitizeTaskForm({ ...taskForm, requireSameBotInBoth: true, separateBotRoles: false }));
+ return;
+ }
+ if (mode === "split") {
+ setTaskForm({ ...taskForm, requireSameBotInBoth: false, separateBotRoles: true });
+ }
+ };
+
+ return { updateIntervals, applyRoleMode };
+}
diff --git a/src/renderer/hooks/useTaskLifecycle.js b/src/renderer/hooks/useTaskLifecycle.js
new file mode 100644
index 0000000..d44e784
--- /dev/null
+++ b/src/renderer/hooks/useTaskLifecycle.js
@@ -0,0 +1,44 @@
+import { useEffect } from "react";
+import { buildPresetSignature } from "../utils/presets.js";
+
+export default function useTaskLifecycle({
+ loadBase,
+ selectedTaskId,
+ loadSelectedTask,
+ setAccessStatus,
+ setInviteAccessStatus,
+ setMembershipStatus,
+ setTaskNotice,
+ setActivePreset,
+ checkAccess,
+ checkInviteAccess,
+ activePreset,
+ taskForm,
+ taskAccountRoles,
+ presetSignatureRef
+}) {
+ useEffect(() => {
+ loadBase();
+ }, []);
+
+ useEffect(() => {
+ loadSelectedTask(selectedTaskId);
+ setAccessStatus([]);
+ setInviteAccessStatus([]);
+ setMembershipStatus({});
+ setTaskNotice(null);
+ setActivePreset("");
+ if (selectedTaskId != null) {
+ checkAccess("auto", true);
+ checkInviteAccess("auto", true);
+ }
+ }, [selectedTaskId]);
+
+ useEffect(() => {
+ if (!activePreset) return;
+ const currentSignature = buildPresetSignature(taskForm, taskAccountRoles);
+ if (currentSignature !== presetSignatureRef.current) {
+ setActivePreset("");
+ }
+ }, [taskForm, taskAccountRoles, activePreset]);
+}
diff --git a/src/renderer/hooks/useTaskLoaders.js b/src/renderer/hooks/useTaskLoaders.js
new file mode 100644
index 0000000..7348a3e
--- /dev/null
+++ b/src/renderer/hooks/useTaskLoaders.js
@@ -0,0 +1,108 @@
+import { emptyTaskForm, normalizeTask, sanitizeTaskForm } from "../appDefaults.js";
+
+export default function useTaskLoaders({
+ taskAutosaveReady,
+ setTaskForm,
+ setCompetitorText,
+ setSelectedAccountIds,
+ setTaskAccountRoles,
+ setLogs,
+ setInvites,
+ setFallbackList,
+ setConfirmQueue,
+ setGroupVisibility,
+ setTaskStatus,
+ setMembershipStatus,
+ showNotification,
+ selectedTaskId
+}) {
+ async function loadSelectedTask(taskId) {
+ taskAutosaveReady.current = false;
+ if (!taskId) {
+ setTaskForm(emptyTaskForm);
+ setCompetitorText("");
+ setSelectedAccountIds([]);
+ setTaskAccountRoles({});
+ setLogs([]);
+ setInvites([]);
+ setGroupVisibility([]);
+ setConfirmQueue([]);
+ setTaskStatus({
+ running: false,
+ queueCount: 0,
+ dailyRemaining: 0,
+ dailyUsed: 0,
+ dailyLimit: 0,
+ monitorInfo: { monitoring: false, accountId: 0, accountIds: [], groups: [], lastMessageAt: "", lastSource: "" },
+ nextRunAt: "",
+ nextInviteAccountId: 0,
+ lastInviteAccountId: 0,
+ pendingStats: { total: 0, withUsername: 0, withAccessHash: 0, withoutData: 0 }
+ });
+ taskAutosaveReady.current = true;
+ return;
+ }
+ const details = await window.api.getTask(taskId);
+ if (!details) {
+ taskAutosaveReady.current = true;
+ return;
+ }
+ setTaskForm(sanitizeTaskForm({ ...emptyTaskForm, ...normalizeTask(details.task) }));
+ setCompetitorText((details.competitors || []).join("\n"));
+ const roleMap = {};
+ if (details.accountRoles && details.accountRoles.length) {
+ details.accountRoles.forEach((item) => {
+ const roleConfirm = item.roleConfirm != null ? item.roleConfirm : item.roleInvite;
+ roleMap[item.accountId] = {
+ monitor: Boolean(item.roleMonitor),
+ invite: Boolean(item.roleInvite),
+ confirm: Boolean(roleConfirm),
+ inviteLimit: Number(item.inviteLimit || 0)
+ };
+ });
+ } else {
+ (details.accountIds || []).forEach((accountId) => {
+ roleMap[accountId] = { monitor: true, invite: true, confirm: true, inviteLimit: 1 };
+ });
+ }
+ setTaskAccountRoles(roleMap);
+ setSelectedAccountIds(Object.keys(roleMap).map((id) => Number(id)));
+ setLogs(await window.api.listLogs({ limit: 100, taskId }));
+ setInvites(await window.api.listInvites({ limit: 200, taskId }));
+ setFallbackList(await window.api.listFallback({ limit: 500, taskId }));
+ setConfirmQueue(await window.api.listConfirmQueue({ limit: 500, taskId }));
+ setGroupVisibility([]);
+ setTaskStatus(await window.api.taskStatus(taskId));
+ taskAutosaveReady.current = true;
+ }
+
+ async function refreshMembership(source = "manual", showToast = false) {
+ if (!window.api) {
+ showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
+ return;
+ }
+ try {
+ const payload = selectedTaskId != null
+ ? await window.api.membershipStatusByTask(selectedTaskId)
+ : await window.api.getMembershipStatus();
+ if (payload && payload.ok === false) {
+ showNotification(payload.error || "Не удалось проверить участие.", "error");
+ return;
+ }
+ const map = {};
+ (payload || []).forEach((item) => {
+ if (item && item.accountId != null) {
+ map[item.accountId] = item;
+ }
+ });
+ setMembershipStatus(map);
+ if (showToast) {
+ showNotification("Проверка участия завершена.", "success");
+ }
+ } catch (error) {
+ showNotification(error.message || String(error), "error");
+ }
+ }
+
+ return { loadSelectedTask, refreshMembership };
+}
diff --git a/src/renderer/hooks/useTaskPresets.js b/src/renderer/hooks/useTaskPresets.js
new file mode 100644
index 0000000..6bf6ba9
--- /dev/null
+++ b/src/renderer/hooks/useTaskPresets.js
@@ -0,0 +1,132 @@
+import { buildPresetSignature } from "../utils/presets.js";
+import { sanitizeTaskForm } from "../appDefaults.js";
+
+export default function useTaskPresets({
+ hasSelectedTask,
+ accounts,
+ selectedAccountIds,
+ taskForm,
+ setTaskForm,
+ setTaskAccountRoles,
+ setSelectedAccountIds,
+ persistAccountRoles,
+ showNotification,
+ setTaskNotice,
+ setActivePreset,
+ setActiveTab,
+ selectedTaskId,
+ presetSignatureRef
+}) {
+ const applyTaskPreset = (type) => {
+ if (!hasSelectedTask) {
+ showNotification("Сначала выберите задачу.", "error");
+ return;
+ }
+ if (!accounts.length) {
+ showNotification("Нет доступных аккаунтов.", "error");
+ return;
+ }
+ const masterId = accounts[0].id;
+ const requiredCount = 3;
+ const baseIds = selectedAccountIds.length >= requiredCount
+ ? selectedAccountIds.slice()
+ : accounts.map((account) => account.id);
+ if (baseIds.length < 3) {
+ showNotification("Для раздельных ролей желательно минимум 3 аккаунта (мониторинг/инвайт/подтверждение).", "info");
+ }
+ if (!baseIds.includes(masterId)) {
+ baseIds.unshift(masterId);
+ }
+ const pool = baseIds.filter((id) => id !== masterId);
+ const roleMap = {};
+ const addRole = (id, role) => {
+ if (!id) return;
+ if (!roleMap[id]) roleMap[id] = { monitor: false, invite: false, confirm: false, inviteLimit: 0 };
+ roleMap[id][role] = true;
+ if (role === "invite" && (!roleMap[id].inviteLimit || roleMap[id].inviteLimit === 0)) {
+ roleMap[id].inviteLimit = 1;
+ }
+ };
+ const takeFromPool = (count, used) => {
+ const result = [];
+ for (const id of pool) {
+ if (result.length >= count) break;
+ if (used.has(id)) continue;
+ used.add(id);
+ result.push(id);
+ }
+ return result;
+ };
+ const used = new Set();
+ const isSoft = type === "soft_50" || type === "soft_50_admin" || type === "soft_25" || type === "soft_25_admin";
+ const isSoft25 = type === "soft_25" || type === "soft_25_admin";
+ const monitorCount = 1;
+ const inviteCount = isSoft ? (isSoft25 ? 2 : 5) : 1;
+ const confirmCount = 1;
+ const monitorIds = takeFromPool(monitorCount, used);
+ const confirmIds = takeFromPool(confirmCount, used);
+ const inviteIds = masterId ? [masterId] : takeFromPool(inviteCount, used);
+ if (monitorIds.length < monitorCount) addRole(masterId, "monitor");
+ if (confirmIds.length < confirmCount) addRole(masterId, "confirm");
+ monitorIds.forEach((id) => addRole(id, "monitor"));
+ confirmIds.forEach((id) => addRole(id, "confirm"));
+ inviteIds.forEach((id) => addRole(id, "invite"));
+
+ const nextForm = sanitizeTaskForm({
+ ...taskForm,
+ warmupEnabled: true,
+ historyLimit: 35,
+ separateBotRoles: true,
+ requireSameBotInBoth: false,
+ maxCompetitorBots: 1,
+ maxOurBots: isSoft ? (isSoft25 ? 2 : 5) : 1,
+ separateConfirmRoles: true,
+ maxConfirmBots: isSoft ? 1 : 1,
+ inviteViaAdmins: type === "admin" || type === "soft_50_admin" || type === "soft_25_admin",
+ inviteAdminAnonymous: true,
+ inviteAdminMasterId: masterId,
+ rolesMode: "auto",
+ dailyLimit: isSoft ? (isSoft25 ? 25 : 50) : taskForm.dailyLimit,
+ maxInvitesPerCycle: isSoft ? (isSoft25 ? 2 : 5) : taskForm.maxInvitesPerCycle,
+ minIntervalMinutes: isSoft ? 3 : taskForm.minIntervalMinutes,
+ maxIntervalMinutes: isSoft ? 6 : taskForm.maxIntervalMinutes,
+ retryOnFail: isSoft ? true : taskForm.retryOnFail,
+ stopOnBlocked: isSoft ? true : taskForm.stopOnBlocked,
+ stopBlockedPercent: isSoft ? 25 : taskForm.stopBlockedPercent,
+ randomAccounts: isSoft ? false : taskForm.randomAccounts,
+ multiAccountsPerRun: isSoft ? false : taskForm.multiAccountsPerRun,
+ inviteLinkOnFail: isSoft ? false : taskForm.inviteLinkOnFail,
+ allowStartWithoutInviteRights: isSoft ? false : taskForm.allowStartWithoutInviteRights
+ });
+ const signature = buildPresetSignature(nextForm, roleMap);
+ presetSignatureRef.current = signature;
+ const label = type === "admin"
+ ? "Автораспределение + Инвайт через админа"
+ : type === "no_admin"
+ ? "Автораспределение + Без админки"
+ : type === "soft_50_admin"
+ ? "Мягкий 50/день + Инвайт через админов"
+ : type === "soft_25_admin"
+ ? "Мягкий 25/день + Инвайт через админов"
+ : type === "soft_25"
+ ? "Мягкий 25/день (2 инвайтера)"
+ : "Мягкий режим 50/день (5 инвайтеров)";
+ setTaskForm(nextForm);
+ setTaskAccountRoles(roleMap);
+ setSelectedAccountIds(Object.keys(roleMap).map((id) => Number(id)));
+ persistAccountRoles(roleMap);
+ if (window.api) {
+ window.api.addAccountEvent({
+ accountId: 0,
+ phone: "",
+ action: "preset_applied",
+ details: `задача ${selectedTaskId}: ${label}`
+ });
+ }
+ setTaskNotice({ text: `Пресет: ${label}`, tone: "success", source: "task" });
+ setActivePreset(type);
+ setActiveTab("events");
+ };
+
+ return { applyTaskPreset };
+}
diff --git a/src/renderer/hooks/useTaskSelection.js b/src/renderer/hooks/useTaskSelection.js
new file mode 100644
index 0000000..01bbc96
--- /dev/null
+++ b/src/renderer/hooks/useTaskSelection.js
@@ -0,0 +1,32 @@
+import { emptyTaskForm } from "../appDefaults.js";
+
+export default function useTaskSelection({
+ taskAutosaveReady,
+ selectedTaskId,
+ setSelectedTaskId,
+ setTaskForm,
+ setCompetitorText,
+ setSelectedAccountIds,
+ setTaskAccountRoles,
+ setAccessStatus,
+ setMembershipStatus
+}) {
+ const createTask = () => {
+ taskAutosaveReady.current = false;
+ setSelectedTaskId(null);
+ setTaskForm(emptyTaskForm);
+ setCompetitorText("");
+ setSelectedAccountIds([]);
+ setTaskAccountRoles({});
+ setAccessStatus([]);
+ setMembershipStatus({});
+ taskAutosaveReady.current = true;
+ };
+
+ const selectTask = (taskId) => {
+ if (taskId === selectedTaskId) return;
+ setSelectedTaskId(taskId);
+ };
+
+ return { createTask, selectTask };
+}
diff --git a/src/renderer/hooks/useTaskSelectors.js b/src/renderer/hooks/useTaskSelectors.js
new file mode 100644
index 0000000..47f13b6
--- /dev/null
+++ b/src/renderer/hooks/useTaskSelectors.js
@@ -0,0 +1,74 @@
+import { useMemo } from "react";
+
+export default function useTaskSelectors({
+ tasks,
+ taskStatusMap,
+ deferredTaskSearch,
+ taskFilter,
+ taskSort
+}) {
+ const taskSummary = useMemo(() => {
+ const totals = {
+ total: tasks.length,
+ running: 0,
+ queue: 0,
+ dailyUsed: 0,
+ dailyLimit: 0
+ };
+ tasks.forEach((task) => {
+ const status = taskStatusMap[task.id];
+ if (status && status.running) totals.running += 1;
+ if (status) {
+ totals.queue += Number(status.queueCount || 0);
+ totals.dailyUsed += Number(status.dailyUsed || 0);
+ totals.dailyLimit += Number(status.dailyLimit || 0);
+ }
+ });
+ return totals;
+ }, [tasks, taskStatusMap]);
+
+ const filteredTasks = useMemo(() => {
+ const query = deferredTaskSearch.trim().toLowerCase();
+ const filtered = tasks.filter((task) => {
+ const name = (task.name || "").toLowerCase();
+ const group = (task.our_group || "").toLowerCase();
+ const matchesQuery = !query || name.includes(query) || group.includes(query) || String(task.id).includes(query);
+ if (!matchesQuery) return false;
+ const status = taskStatusMap[task.id];
+ if (taskFilter === "running") return Boolean(status && status.running);
+ if (taskFilter === "stopped") return Boolean(status && !status.running);
+ return true;
+ });
+ const sorted = [...filtered].sort((a, b) => {
+ const statusA = taskStatusMap[a.id];
+ const statusB = taskStatusMap[b.id];
+ if (taskSort === "queue") {
+ return (statusB ? statusB.queueCount : 0) - (statusA ? statusA.queueCount : 0);
+ }
+ if (taskSort === "limit") {
+ return (statusB ? statusB.dailyLimit : 0) - (statusA ? statusA.dailyLimit : 0);
+ }
+ if (taskSort === "lastMessage") {
+ const dateA = statusA && statusA.monitorInfo && statusA.monitorInfo.lastMessageAt
+ ? Date.parse(statusA.monitorInfo.lastMessageAt) || 0
+ : 0;
+ const dateB = statusB && statusB.monitorInfo && statusB.monitorInfo.lastMessageAt
+ ? Date.parse(statusB.monitorInfo.lastMessageAt) || 0
+ : 0;
+ return dateB - dateA;
+ }
+ if (taskSort === "activity") {
+ const aActive = statusA && statusA.running ? 1 : 0;
+ const bActive = statusB && statusB.running ? 1 : 0;
+ if (bActive !== aActive) return bActive - aActive;
+ }
+ if (taskSort === "id") {
+ return b.id - a.id;
+ }
+ return b.id - a.id;
+ });
+ return sorted;
+ }, [tasks, deferredTaskSearch, taskFilter, taskSort, taskStatusMap]);
+
+ return { taskSummary, filteredTasks };
+}
diff --git a/src/renderer/hooks/useTaskStatusSync.js b/src/renderer/hooks/useTaskStatusSync.js
new file mode 100644
index 0000000..4428764
--- /dev/null
+++ b/src/renderer/hooks/useTaskStatusSync.js
@@ -0,0 +1,70 @@
+import { useEffect } from "react";
+
+export default function useTaskStatusSync({
+ selectedTaskId,
+ taskStatus,
+ setTaskStatusMap,
+ checklistStats,
+ setChecklistOpen,
+ hasSelectedTask,
+ taskForm,
+ roleIntersectionCount,
+ roleSummary,
+ setTaskForm,
+ sanitizeTaskForm
+}) {
+ useEffect(() => {
+ if (selectedTaskId == null) return;
+ setTaskStatusMap((prev) => ({
+ ...prev,
+ [selectedTaskId]: taskStatus
+ }));
+ }, [selectedTaskId, taskStatus]);
+
+ useEffect(() => {
+ if (checklistStats.fail > 0) {
+ setChecklistOpen(true);
+ }
+ }, [checklistStats.fail]);
+
+ useEffect(() => {
+ if (!hasSelectedTask) return;
+ if (taskForm.requireSameBotInBoth) {
+ const nextValue = Math.max(1, roleIntersectionCount || 0);
+ if (taskForm.maxCompetitorBots !== nextValue || taskForm.maxOurBots !== nextValue) {
+ setTaskForm((prev) => sanitizeTaskForm({
+ ...prev,
+ maxCompetitorBots: nextValue,
+ maxOurBots: nextValue
+ }));
+ }
+ return;
+ }
+ if (taskForm.separateBotRoles) {
+ const nextCompetitors = Math.max(1, roleSummary.monitor.length || 0);
+ const nextOur = Math.max(1, roleSummary.invite.length || 0);
+ const hasConfirmRoles = roleSummary.confirm.length > 0;
+ const nextConfirm = taskForm.separateConfirmRoles && hasConfirmRoles
+ ? Math.max(1, roleSummary.confirm.length)
+ : taskForm.maxConfirmBots;
+ if (taskForm.maxCompetitorBots !== nextCompetitors || taskForm.maxOurBots !== nextOur || (taskForm.separateConfirmRoles && hasConfirmRoles && taskForm.maxConfirmBots !== nextConfirm)) {
+ setTaskForm((prev) => ({
+ ...prev,
+ maxCompetitorBots: nextCompetitors,
+ maxOurBots: nextOur,
+ maxConfirmBots: nextConfirm
+ }));
+ }
+ }
+ }, [
+ hasSelectedTask,
+ roleIntersectionCount,
+ roleSummary.monitor.length,
+ roleSummary.invite.length,
+ roleSummary.confirm.length,
+ taskForm.requireSameBotInBoth,
+ taskForm.separateBotRoles,
+ taskForm.separateConfirmRoles,
+ taskForm.maxConfirmBots
+ ]);
+}
diff --git a/src/renderer/hooks/useTaskStatusView.js b/src/renderer/hooks/useTaskStatusView.js
new file mode 100644
index 0000000..ecf9038
--- /dev/null
+++ b/src/renderer/hooks/useTaskStatusView.js
@@ -0,0 +1,127 @@
+import { useMemo } from "react";
+
+export default function useTaskStatusView({
+ taskStatus,
+ taskAccountRoles,
+ accountById,
+ formatAccountLabel,
+ setActiveTab,
+ checkInviteAccess,
+ parseHistory,
+ assignedAccountCount,
+ roleSummary,
+ accountEvents,
+ formatCountdownWithNow,
+ inviteAccessStatus
+}) {
+ const monitorAccountIds = taskStatus && taskStatus.monitorInfo && taskStatus.monitorInfo.accountIds
+ ? taskStatus.monitorInfo.accountIds
+ : (taskStatus && taskStatus.monitorInfo && taskStatus.monitorInfo.accountId ? [taskStatus.monitorInfo.accountId] : []);
+ const monitorLabels = monitorAccountIds
+ .map((id) => {
+ const account = accountById.get(id);
+ return account ? formatAccountLabel(account) : String(id);
+ })
+ .filter(Boolean);
+ const inviteAccountIds = Object.entries(taskAccountRoles || {})
+ .filter(([, roles]) => roles && roles.invite && Number(roles.inviteLimit || 0) > 0)
+ .map(([id]) => Number(id));
+ const inviteLabels = inviteAccountIds
+ .map((id) => {
+ const account = accountById.get(id);
+ return account ? formatAccountLabel(account) : String(id);
+ })
+ .filter(Boolean);
+ const nowLine = [
+ `Мониторинг: ${taskStatus.monitorInfo && taskStatus.monitorInfo.monitoring ? "вкл." : "выкл."}`,
+ `Очередь: ${taskStatus.queueCount}`,
+ `Инвайт: ${taskStatus.running ? "активен" : "остановлен"}`,
+ `Следующий цикл: ${taskStatus.running ? formatCountdownWithNow(taskStatus.nextRunAt) : "—"}`
+ ].join(" • ");
+ const primaryIssue = taskStatus.readiness && !taskStatus.readiness.ok && taskStatus.readiness.reasons && taskStatus.readiness.reasons.length
+ ? taskStatus.readiness.reasons[0]
+ : "";
+ const openFixTab = () => {
+ if (!primaryIssue) return;
+ const text = primaryIssue.toLowerCase();
+ if (text.includes("аккаунт") || text.includes("роль") || text.includes("инвайт") || text.includes("монитор")) {
+ setActiveTab("accounts");
+ return;
+ }
+ if (text.includes("групп")) {
+ setActiveTab("task");
+ return;
+ }
+ if (text.includes("прав")) {
+ setActiveTab("accounts");
+ return;
+ }
+ setActiveTab("task");
+ };
+ const inviteAccessChecked = inviteAccessStatus && inviteAccessStatus.length > 0;
+ const inviteAccessOk = inviteAccessChecked && inviteAccessStatus.every((item) => item.canInvite);
+ const inviteAccessWarn = inviteAccessChecked && !inviteAccessOk;
+ const lastEvents = (accountEvents || []).slice(0, 3);
+ const checklistItems = [
+ {
+ id: "accounts",
+ label: "Подключены аккаунты",
+ ok: assignedAccountCount > 0,
+ hint: assignedAccountCount > 0 ? `Назначено: ${assignedAccountCount}` : "Нет назначенных аккаунтов",
+ action: () => setActiveTab("accounts"),
+ actionLabel: "Открыть аккаунты"
+ },
+ {
+ id: "roles",
+ label: "Назначены роли",
+ ok: roleSummary.monitor.length > 0 && inviteAccountIds.length > 0,
+ hint: `Мониторинг: ${roleSummary.monitor.length}, Инвайт: ${inviteAccountIds.length}, Подтверждение: ${roleSummary.confirm.length}`,
+ action: () => setActiveTab("accounts"),
+ actionLabel: "Назначить роли"
+ },
+ {
+ id: "access",
+ label: "Права в цели проверены",
+ ok: inviteAccessOk,
+ warn: inviteAccessWarn,
+ hint: inviteAccessChecked
+ ? (inviteAccessOk ? "Права инвайта подтверждены" : "Есть аккаунты без прав")
+ : "Проверка прав не запускалась",
+ action: () => checkInviteAccess("checklist"),
+ actionLabel: "Проверить права"
+ },
+ {
+ id: "queue",
+ label: "Очередь готова",
+ ok: Number(taskStatus.queueCount || 0) > 0,
+ hint: `В очереди: ${taskStatus.queueCount || 0}`,
+ action: () => parseHistory("checklist"),
+ actionLabel: "Собрать историю"
+ }
+ ];
+ const checklistStats = useMemo(() => {
+ let ok = 0;
+ let warn = 0;
+ let fail = 0;
+ checklistItems.forEach((item) => {
+ if (item.ok) ok += 1;
+ else if (item.warn) warn += 1;
+ else fail += 1;
+ });
+ return { ok, warn, fail, total: checklistItems.length };
+ }, [checklistItems]);
+
+ return {
+ monitorLabels,
+ inviteLabels,
+ nowLine,
+ primaryIssue,
+ openFixTab,
+ checklistItems,
+ checklistStats,
+ inviteAccessChecked,
+ inviteAccessOk,
+ inviteAccessWarn,
+ lastEvents
+ };
+}
diff --git a/src/renderer/hooks/useUiComputed.js b/src/renderer/hooks/useUiComputed.js
new file mode 100644
index 0000000..a753841
--- /dev/null
+++ b/src/renderer/hooks/useUiComputed.js
@@ -0,0 +1,40 @@
+import { useMemo } from "react";
+
+export default function useUiComputed({
+ taskStatus,
+ assignedAccountCount,
+ roleSummary,
+ inviteAccessWarn,
+ taskAccountRoles,
+ taskStatusMap,
+ tasks,
+ formatCountdown,
+ now
+}) {
+ const pauseReason = useMemo(() => {
+ if (!taskStatus || taskStatus.running) return "";
+ if (taskStatus.lastStopReason) return taskStatus.lastStopReason;
+ if (taskStatus.dailyRemaining === 0 && taskStatus.dailyLimit > 0) return "Дневной лимит исчерпан";
+ if (Number(taskStatus.queueCount || 0) === 0) return "Очередь пуста";
+ if (assignedAccountCount === 0) return "Нет назначенных аккаунтов";
+ if (roleSummary.invite.length === 0) return "Нет аккаунтов с ролью инвайта";
+ if (inviteAccessWarn) return "Есть аккаунты без прав на инвайт";
+ return "";
+ }, [taskStatus, assignedAccountCount, roleSummary, inviteAccessWarn]);
+
+ const hasPerAccountInviteLimits = useMemo(() => {
+ return Object.values(taskAccountRoles || {}).some(
+ (roles) => roles && roles.invite && Number(roles.inviteLimit || 0) > 0
+ );
+ }, [taskAccountRoles]);
+
+ const formatCountdownWithNow = useMemo(() => {
+ return (target) => formatCountdown(target, now);
+ }, [formatCountdown, now]);
+
+ return {
+ pauseReason,
+ hasPerAccountInviteLimits,
+ formatCountdownWithNow
+ };
+}
diff --git a/src/renderer/tabs/QueueTab.jsx b/src/renderer/tabs/QueueTab.jsx
new file mode 100644
index 0000000..13e9dbe
--- /dev/null
+++ b/src/renderer/tabs/QueueTab.jsx
@@ -0,0 +1,98 @@
+import React from "react";
+
+export default function QueueTab({
+ hasSelectedTask,
+ queueStats,
+ queueSearch,
+ setQueueSearch,
+ queuePage,
+ setQueuePage,
+ queuePageCount,
+ pagedQueue,
+ accountById,
+ formatAccountLabel,
+ formatTimestamp
+}) {
+ const formatUserWithUsername = (item) => {
+ const id = item.user_id != null ? String(item.user_id) : "—";
+ const username = item.username ? String(item.username).replace(/^@/, "") : "";
+ return username ? `${id} (@${username})` : id;
+ };
+
+ if (!hasSelectedTask) {
+ return (
+
+
+
Очередь
+
+ Выберите задачу, чтобы посмотреть очередь.
+
+ );
+ }
+
+ return (
+
+
+
Очередь
+
+
+
{
+ setQueueSearch(event.target.value);
+ setQueuePage(1);
+ }}
+ placeholder="Поиск по очереди"
+ />
+
+ setQueuePage((prev) => Math.max(1, prev - 1))}
+ disabled={queuePage === 1}
+ >
+ Назад
+
+ {queuePage}/{queuePageCount}
+ setQueuePage((prev) => Math.min(queuePageCount, prev + 1))}
+ disabled={queuePage === queuePageCount}
+ >
+ Вперед
+
+
+
+
+ В очереди: {queueStats?.total ?? 0} · username: {queueStats?.withUsername ?? 0} · access_hash: {queueStats?.withAccessHash ?? 0} · пустые: {queueStats?.withoutData ?? 0}
+
+
+
+ Пользователь
+ Источник
+ Наблюдатель
+ Попытки
+ Добавлен
+
+ {pagedQueue.length === 0 && (
+
Очередь пуста.
+ )}
+ {pagedQueue.map((item) => {
+ const watcher = item.watcher_account_id ? accountById.get(item.watcher_account_id) : null;
+ const watcherLabel = watcher ? formatAccountLabel(watcher) : (item.watcher_account_id || "—");
+ return (
+
+
{formatUserWithUsername(item)}
+
{item.source_chat || "—"}
+
{watcherLabel}
+
{item.attempts ?? 0}
+
{formatTimestamp(item.created_at)}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/renderer/utils/clipboard.js b/src/renderer/utils/clipboard.js
new file mode 100644
index 0000000..b99f077
--- /dev/null
+++ b/src/renderer/utils/clipboard.js
@@ -0,0 +1,25 @@
+export const copyToClipboard = async (text) => {
+ if (!text) return false;
+ try {
+ if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
+ await navigator.clipboard.writeText(text);
+ return true;
+ }
+ } catch (error) {
+ // ignore and fallback
+ }
+ try {
+ const el = document.createElement("textarea");
+ el.value = text;
+ el.setAttribute("readonly", "");
+ el.style.position = "absolute";
+ el.style.left = "-9999px";
+ document.body.appendChild(el);
+ el.select();
+ const ok = document.execCommand("copy");
+ document.body.removeChild(el);
+ return ok;
+ } catch (error) {
+ return false;
+ }
+};
diff --git a/src/renderer/utils/errorHints.js b/src/renderer/utils/errorHints.js
new file mode 100644
index 0000000..b69fd12
--- /dev/null
+++ b/src/renderer/utils/errorHints.js
@@ -0,0 +1,63 @@
+export const explainInviteError = (error) => {
+ if (!error) return "";
+ if (error === "USER_ID_INVALID") {
+ return "Пользователь удален/скрыт; access_hash невалиден для этой сессии; приглашение в канал/чат без валидной сущности.";
+ }
+ if (error === "CHAT_WRITE_FORBIDDEN") {
+ return "Аккаунт не может приглашать: нет прав или он не участник группы.";
+ }
+ if (error === "USER_NOT_MUTUAL_CONTACT") {
+ return "Пользователь не взаимный контакт для добавляющего аккаунта. Обычно это происходит, когда в группе/канале включена опция «добавлять могут только контакты» или у пользователя закрыт приём инвайтов. Решение: использовать аккаунт, который уже в контактах у пользователя, или поменять настройки группы.";
+ }
+ if (error === "USER_PRIVACY_RESTRICTED") {
+ return "Приглашение запрещено пользователем: приватность не позволяет добавлять в группы.";
+ }
+ if (error === "USER_NOT_PARTICIPANT") {
+ return "Аккаунт не состоит в целевой группе или канал приватный.";
+ }
+ if (error === "USER_BANNED_IN_CHANNEL") {
+ return "Пользователь заблокирован в группе или канале назначения.";
+ }
+ if (error === "USER_BOT") {
+ return "Бота нельзя приглашать как обычного пользователя.";
+ }
+ if (error === "USER_KICKED") {
+ return "Пользователь был удален из группы ранее.";
+ }
+ if (error === "CHAT_ADMIN_REQUIRED") {
+ return "Для добавления участников нужны права администратора.";
+ }
+ if (error === "CHAT_MEMBER_ADD_FAILED") {
+ return "Telegram отклонил добавление. Обычно это антиспам-ограничение или недостаток прав.";
+ }
+ if (error === "USER_BLOCKED") {
+ return "Пользователь заблокировал аккаунт, который пытается добавить.";
+ }
+ if (error === "USER_RESTRICTED") {
+ return "Пользователь ограничен Telegram или чатами.";
+ }
+ if (error === "FLOOD" || error === "PEER_FLOOD") {
+ return "Слишком много действий. Снизьте лимит или увеличьте интервалы.";
+ }
+ return "";
+};
+
+export const explainTdataError = (error) => {
+ if (!error) return "";
+ if (error.includes("AUTH_KEY_DUPLICATED")) {
+ return "tdata используется в другом устройстве. Выйдите из аккаунта на других устройствах и пересоберите tdata.";
+ }
+ if (error.includes("SESSION_REVOKED")) {
+ return "Сессия отозвана. Залогиньтесь заново и пересоберите tdata.";
+ }
+ if (error.includes("PHONE_CODE_INVALID")) {
+ return "Неверный код подтверждения.";
+ }
+ if (error.includes("PASSWORD_HASH_INVALID")) {
+ return "Неверный пароль 2FA.";
+ }
+ if (error.includes("API_ID_INVALID")) {
+ return "Неверный API ID/Hash.";
+ }
+ return "";
+};
diff --git a/src/renderer/utils/formatters.js b/src/renderer/utils/formatters.js
new file mode 100644
index 0000000..e13a912
--- /dev/null
+++ b/src/renderer/utils/formatters.js
@@ -0,0 +1,38 @@
+export const formatAccountLabel = (account) => {
+ if (!account) return "—";
+ const base = account.phone || account.user_id || String(account.id);
+ const username = account.username ? `@${account.username}` : "";
+ return username ? `${base} (${username})` : base;
+};
+
+export const formatAccountStatus = (status) => {
+ if (status === "limited") return "В спаме";
+ if (status === "error") return "Ошибка";
+ if (status === "ok") return "ОК";
+ return status || "Неизвестно";
+};
+
+export const formatTimestamp = (value) => {
+ if (!value) return "—";
+ const date = value instanceof Date ? value : new Date(value);
+ if (Number.isNaN(date.getTime())) return "—";
+ return date.toLocaleString("ru-RU");
+};
+
+export const formatCountdown = (target, now) => {
+ if (!target) return "—";
+ const targetTime = new Date(target).getTime();
+ if (!Number.isFinite(targetTime)) return "—";
+ const diff = Math.max(0, Math.floor((targetTime - now) / 1000));
+ const minutes = Math.floor(diff / 60);
+ const seconds = diff % 60;
+ return `${minutes}:${String(seconds).padStart(2, "0")}`;
+};
+
+export const formatTargetType = (value) => {
+ if (!value) return "";
+ if (value === "channel") return "канал";
+ if (value === "megagroup") return "супергруппа";
+ if (value === "group") return "группа";
+ return value;
+};
diff --git a/src/renderer/utils/presets.js b/src/renderer/utils/presets.js
new file mode 100644
index 0000000..4f502d3
--- /dev/null
+++ b/src/renderer/utils/presets.js
@@ -0,0 +1,27 @@
+export const buildPresetSignature = (form, roles) => {
+ const roleEntries = Object.entries(roles || {})
+ .map(([id, value]) => ({
+ id: Number(id),
+ monitor: Boolean(value && value.monitor),
+ invite: Boolean(value && value.invite),
+ confirm: Boolean(value && value.confirm)
+ }))
+ .sort((a, b) => a.id - b.id);
+ const snapshot = {
+ form: {
+ warmupEnabled: Boolean(form.warmupEnabled),
+ historyLimit: Number(form.historyLimit || 0),
+ separateBotRoles: Boolean(form.separateBotRoles),
+ requireSameBotInBoth: Boolean(form.requireSameBotInBoth),
+ maxCompetitorBots: Number(form.maxCompetitorBots || 0),
+ maxOurBots: Number(form.maxOurBots || 0),
+ separateConfirmRoles: Boolean(form.separateConfirmRoles),
+ maxConfirmBots: Number(form.maxConfirmBots || 0),
+ inviteViaAdmins: Boolean(form.inviteViaAdmins),
+ inviteAdminAnonymous: Boolean(form.inviteAdminAnonymous),
+ inviteAdminMasterId: Number(form.inviteAdminMasterId || 0)
+ },
+ roles: roleEntries
+ };
+ return JSON.stringify(snapshot);
+};