Files
portal/app/resources/js/components/projects/BulkActionsBar.vue
T
Дмитрий 9ff19f3cc3 @
feat(bot): всеобъемлющая инструкция (36 статей) + 31 узкая экскурсия, которая открывает окна

Живой урок владельца 12.07.2026: «дубли чистятся в 2-х местах, а бот выдумал третье»,
«стыдно такого бота показывать», «видео показываем полное, а я спрашиваю про одно —
надо резать», «надо не только кнопку показывать, а и что за ней».

База знаний: 17 обзорных статей → 36 узких по темам. Портал обойдён целиком
(5 агентов): все экраны, окна, подсказки «(?)», тексты ошибок, внутренние правила.
Новое, чего бот не знал: дубли в ДВУХ местах (Поле и Предложения) + окно «источник
уже есть»; правило 18:00; почему приходит меньше лимита; оплата по счёту и акты;
списания; безопасность и 2FA; отчёты; массовые действия; регистрация.

Экскурсии: 15 → 31, каждая на свою тему (3–7 шагов). Раннер GuidedTour научен
шагу `open` — сам открывает окно/вкладку и подсвечивает то, что ВНУТРИ
(окно дублей, форму подбора, диалог пополнения, панель тарифов, карточку сделки).
99 новых якорей data-tour расставлено по всему порталу (4 агента).

Поиск: веса в tsvector (заголовок A / синонимы B / текст C) — иначе на «чистка
дублей» первой всплывала статья «Списания» из-за мимоходного упоминания «поля».

Тесты: Pest бот 39/39, Vitest 1222/1222. Новые сторожа: у каждой статьи есть
экскурсия; экскурсия не короче 3 и не длиннее 7 шагов; каждая цель экскурсии
реально существует в разметке; экскурсии про действия обязаны открывать окно.
Живая проверка в браузере: экскурсия про дубли сама открыла окно объединения
и перешла на второе место чистки.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@
2026-07-12 12:56:22 +03:00

155 lines
7.0 KiB
Vue

<template>
<v-card class="bulk-actions-bar" elevation="6" data-tour="projects-bulk-bar">
<DevIndexBadge :index="20" label="BulkActionsBar" :dialog-mode="true" style="top: 4px; right: 4px" />
<v-card-text class="d-flex align-center ga-3 flex-wrap">
<strong>Выбрано: {{ store.selectedIds.size }}</strong>
<v-divider vertical />
<v-btn color="primary" variant="outlined" prepend-icon="mdi-earth" data-testid="bulk-regions" @click="regionsOpen = true">
Регионы
</v-btn>
<v-btn color="primary" variant="outlined" prepend-icon="mdi-calendar" data-testid="bulk-days" @click="daysOpen = true">
Дни сбора
</v-btn>
<v-btn color="primary" variant="outlined" prepend-icon="mdi-target" data-testid="bulk-limit" data-tour="projects-bulk-limit" @click="limitOpen = true">
Лимит лидов
</v-btn>
<v-divider vertical />
<v-btn color="warning" prepend-icon="mdi-pause" data-testid="bulk-pause" data-tour="projects-bulk-pause" @click="confirmAndRun('pause')">
Приостановить
</v-btn>
<v-btn color="success" prepend-icon="mdi-play" data-testid="bulk-resume" @click="confirmAndRun('resume')">
Возобновить
</v-btn>
<v-divider vertical />
<v-btn color="error" prepend-icon="mdi-delete" data-testid="bulk-delete" @click="confirmAndRun('delete')">
Удалить
</v-btn>
<v-spacer />
<v-btn variant="text" data-testid="bulk-clear" @click="store.clearSelection">Снять выбор</v-btn>
</v-card-text>
<RegionsBulkDialog
v-model="regionsOpen"
:count="store.selectedIds.size"
@apply="(p) => runBulk({ action: 'update_regions', ...p })"
/>
<DaysBulkDialog
v-model="daysOpen"
:count="store.selectedIds.size"
@apply="(p) => runBulk({ action: 'update_days', ...p })"
/>
<LimitBulkDialog
v-model="limitOpen"
:count="store.selectedIds.size"
@apply="(p) => runBulk({ action: 'update_limit', ...p })"
/>
<v-snackbar
v-model="skipToastOpen"
:timeout="6000"
color="warning"
location="bottom right"
data-testid="bulk-skip-toast"
>
{{ skipToastText }}
<template #actions>
<v-btn variant="text" @click="skipToastOpen = false">Закрыть</v-btn>
</template>
</v-snackbar>
</v-card>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useProjectsStore } from '../../stores/projectsStore';
import DevIndexBadge from '../DevIndexBadge.vue';
import RegionsBulkDialog from './RegionsBulkDialog.vue';
import DaysBulkDialog from './DaysBulkDialog.vue';
import LimitBulkDialog from './LimitBulkDialog.vue';
const store = useProjectsStore();
const regionsOpen = ref(false);
const daysOpen = ref(false);
const limitOpen = ref(false);
// Sprint 1 C5: window.alert → v-snackbar (non-blocking, accessible, не breaks браузерный automation).
const skipToastOpen = ref(false);
const skipToastText = ref('');
const messages: Record<string, string> = {
pause: 'Приостановить выбранные проекты?',
resume: 'Возобновить выбранные проекты?',
delete: 'Удалить выбранные проекты? Действие необратимо. Проекты со сделками будут пропущены.',
};
async function confirmAndRun(action: 'pause' | 'resume' | 'delete') {
if (!window.confirm(messages[action])) return;
await runBulk({ action });
}
async function runBulk(payload: Parameters<typeof store.bulkUpdate>[0]) {
const result = await store.bulkUpdate(payload);
if (result.skipped.length > 0) {
const supplierLocked = result.skipped.filter((s) => s.reason === 'supplier_snapshot_locked').length;
const withDeals = result.skipped.filter((s) => s.reason === 'has_deals').length;
const balanceInsufficient = result.skipped.filter((s) => s.reason === 'balance_insufficient').length;
const belowDelivered = result.skipped.filter((s) => s.reason === 'below_delivered_today').length;
// Для действия «Возобновить» с балансовым блоком — специальный тост со сводкой запуска.
if (payload.action === 'resume' && balanceInsufficient > 0) {
const parts: string[] = [`Запущено ${result.updated}, отложено ${balanceInsufficient} — не хватает баланса`];
// Остальные причины добавляем следом, если есть.
if (supplierLocked > 0) parts.push(`${supplierLocked} — мы уже начали сбор лидов на завтра`);
if (withDeals > 0) parts.push(`${withDeals} — по проекту есть сделки`);
if (belowDelivered > 0) parts.push(`${belowDelivered} — лимит ниже уже доставленных сегодня лидов`);
skipToastText.value = parts.join('; ') + '.';
skipToastOpen.value = true;
return;
}
const groups: string[] = [];
if (supplierLocked > 0) {
groups.push(
`${supplierLocked} — мы уже начали сбор лидов на завтра (поставьте проект на паузу, удалить можно будет послезавтра)`,
);
}
if (withDeals > 0) {
groups.push(`${withDeals} — по проекту есть сделки`);
}
if (balanceInsufficient > 0) {
groups.push(`${balanceInsufficient} — не хватает баланса на новый лимит`);
}
if (belowDelivered > 0) {
groups.push(`${belowDelivered} — лимит ниже уже доставленных сегодня лидов`);
}
// Fallback на старый текст, если reason неизвестный (защита от регрессии при добавлении новых причин).
if (groups.length === 0) {
groups.push(`${result.skipped.length} (конфликт с уже доставленными лидами)`);
}
skipToastText.value = `Применено: ${result.updated}. Пропущено: ${groups.join('; ')}.`;
skipToastOpen.value = true;
}
}
defineExpose({ regionsOpen, daysOpen, limitOpen, skipToastOpen, skipToastText, runBulk });
</script>
<style scoped>
.bulk-actions-bar {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
max-width: calc(100vw - 48px);
}
</style>