2026-05-11 19:38:59 +03:00
|
|
|
<template>
|
2026-05-11 19:41:53 +03:00
|
|
|
<v-card class="bulk-actions-bar" elevation="6">
|
2026-05-12 20:24:33 +03:00
|
|
|
<DevIndexBadge :index="20" label="BulkActionsBar" :dialog-mode="true" style="top: 4px; right: 4px" />
|
2026-06-21 12:44:19 +03:00
|
|
|
<v-card-text class="d-flex align-center ga-3 flex-wrap">
|
2026-05-11 19:41:53 +03:00
|
|
|
<strong>Выбрано: {{ store.selectedIds.size }}</strong>
|
2026-05-12 15:23:29 +03:00
|
|
|
|
2026-05-11 19:41:53 +03:00
|
|
|
<v-divider vertical />
|
2026-05-12 15:23:29 +03:00
|
|
|
|
2026-06-21 12:52:58 +03:00
|
|
|
<v-btn color="primary" variant="outlined" prepend-icon="mdi-earth" data-testid="bulk-regions" @click="regionsOpen = true">
|
|
|
|
|
Регионы…
|
2026-05-12 15:23:29 +03:00
|
|
|
</v-btn>
|
2026-06-21 12:52:58 +03:00
|
|
|
<v-btn color="primary" variant="outlined" prepend-icon="mdi-calendar" data-testid="bulk-days" @click="daysOpen = true">
|
|
|
|
|
Дни сбора…
|
2026-05-12 15:23:29 +03:00
|
|
|
</v-btn>
|
2026-06-21 12:52:58 +03:00
|
|
|
<v-btn color="primary" variant="outlined" prepend-icon="mdi-target" data-testid="bulk-limit" @click="limitOpen = true">
|
|
|
|
|
Лимит лидов…
|
2026-05-12 15:23:29 +03:00
|
|
|
</v-btn>
|
|
|
|
|
|
|
|
|
|
<v-divider vertical />
|
|
|
|
|
|
2026-05-11 19:41:53 +03:00
|
|
|
<v-btn color="warning" prepend-icon="mdi-pause" data-testid="bulk-pause" @click="confirmAndRun('pause')">
|
|
|
|
|
Приостановить
|
|
|
|
|
</v-btn>
|
|
|
|
|
<v-btn color="success" prepend-icon="mdi-play" data-testid="bulk-resume" @click="confirmAndRun('resume')">
|
|
|
|
|
Возобновить
|
|
|
|
|
</v-btn>
|
2026-05-12 15:23:29 +03:00
|
|
|
|
|
|
|
|
<v-divider vertical />
|
|
|
|
|
|
2026-06-17 05:17:12 +03:00
|
|
|
<v-btn color="error" prepend-icon="mdi-delete" data-testid="bulk-delete" @click="confirmAndRun('delete')">
|
2026-05-21 08:37:26 +03:00
|
|
|
Удалить
|
2026-05-11 19:41:53 +03:00
|
|
|
</v-btn>
|
2026-05-12 15:23:29 +03:00
|
|
|
|
2026-05-11 19:41:53 +03:00
|
|
|
<v-spacer />
|
|
|
|
|
<v-btn variant="text" data-testid="bulk-clear" @click="store.clearSelection">Снять выбор</v-btn>
|
|
|
|
|
</v-card-text>
|
2026-05-12 15:23:29 +03:00
|
|
|
|
|
|
|
|
<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 })"
|
|
|
|
|
/>
|
2026-05-15 09:02:47 +03:00
|
|
|
|
|
|
|
|
<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>
|
2026-05-11 19:41:53 +03:00
|
|
|
</v-card>
|
2026-05-11 19:38:59 +03:00
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-05-12 15:23:29 +03:00
|
|
|
import { ref } from 'vue';
|
2026-05-11 19:41:53 +03:00
|
|
|
import { useProjectsStore } from '../../stores/projectsStore';
|
2026-05-12 04:45:18 +03:00
|
|
|
import DevIndexBadge from '../DevIndexBadge.vue';
|
2026-05-12 15:23:29 +03:00
|
|
|
import RegionsBulkDialog from './RegionsBulkDialog.vue';
|
|
|
|
|
import DaysBulkDialog from './DaysBulkDialog.vue';
|
|
|
|
|
import LimitBulkDialog from './LimitBulkDialog.vue';
|
2026-05-11 19:41:53 +03:00
|
|
|
|
|
|
|
|
const store = useProjectsStore();
|
|
|
|
|
|
2026-05-12 15:23:29 +03:00
|
|
|
const regionsOpen = ref(false);
|
|
|
|
|
const daysOpen = ref(false);
|
|
|
|
|
const limitOpen = ref(false);
|
|
|
|
|
|
2026-05-15 09:02:47 +03:00
|
|
|
// Sprint 1 C5: window.alert → v-snackbar (non-blocking, accessible, не breaks браузерный automation).
|
|
|
|
|
const skipToastOpen = ref(false);
|
|
|
|
|
const skipToastText = ref('');
|
|
|
|
|
|
2026-05-11 19:41:53 +03:00
|
|
|
const messages: Record<string, string> = {
|
|
|
|
|
pause: 'Приостановить выбранные проекты?',
|
|
|
|
|
resume: 'Возобновить выбранные проекты?',
|
2026-05-21 08:37:26 +03:00
|
|
|
delete: 'Удалить выбранные проекты? Действие необратимо. Проекты со сделками будут пропущены.',
|
2026-05-11 19:41:53 +03:00
|
|
|
};
|
|
|
|
|
|
2026-05-21 08:37:26 +03:00
|
|
|
async function confirmAndRun(action: 'pause' | 'resume' | 'delete') {
|
2026-05-11 19:41:53 +03:00
|
|
|
if (!window.confirm(messages[action])) return;
|
2026-05-12 15:23:29 +03:00
|
|
|
await runBulk({ action });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function runBulk(payload: Parameters<typeof store.bulkUpdate>[0]) {
|
|
|
|
|
const result = await store.bulkUpdate(payload);
|
|
|
|
|
if (result.skipped.length > 0) {
|
2026-05-26 12:33:18 +03:00
|
|
|
const supplierLocked = result.skipped.filter((s) => s.reason === 'supplier_snapshot_locked').length;
|
|
|
|
|
const withDeals = result.skipped.filter((s) => s.reason === 'has_deals').length;
|
2026-06-23 09:06:46 +03:00
|
|
|
const balanceInsufficient = result.skipped.filter((s) => s.reason === 'balance_insufficient').length;
|
|
|
|
|
const belowDelivered = result.skipped.filter((s) => s.reason === 'below_delivered_today').length;
|
2026-05-26 12:33:18 +03:00
|
|
|
const groups: string[] = [];
|
|
|
|
|
if (supplierLocked > 0) {
|
|
|
|
|
groups.push(
|
|
|
|
|
`${supplierLocked} — мы уже начали сбор лидов на завтра (поставьте проект на паузу, удалить можно будет послезавтра)`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (withDeals > 0) {
|
|
|
|
|
groups.push(`${withDeals} — по проекту есть сделки`);
|
|
|
|
|
}
|
2026-06-23 09:06:46 +03:00
|
|
|
if (balanceInsufficient > 0) {
|
|
|
|
|
groups.push(`${balanceInsufficient} — не хватает баланса на новый лимит`);
|
|
|
|
|
}
|
|
|
|
|
if (belowDelivered > 0) {
|
|
|
|
|
groups.push(`${belowDelivered} — лимит ниже уже доставленных сегодня лидов`);
|
|
|
|
|
}
|
2026-05-26 12:33:18 +03:00
|
|
|
// Fallback на старый текст, если reason неизвестный (защита от регрессии при добавлении новых причин).
|
|
|
|
|
if (groups.length === 0) {
|
|
|
|
|
groups.push(`${result.skipped.length} (конфликт с уже доставленными лидами)`);
|
|
|
|
|
}
|
|
|
|
|
skipToastText.value = `Применено: ${result.updated}. Пропущено: ${groups.join('; ')}.`;
|
2026-05-15 09:02:47 +03:00
|
|
|
skipToastOpen.value = true;
|
2026-05-12 15:23:29 +03:00
|
|
|
}
|
2026-05-11 19:41:53 +03:00
|
|
|
}
|
2026-05-12 15:23:29 +03:00
|
|
|
|
2026-05-15 09:02:47 +03:00
|
|
|
defineExpose({ regionsOpen, daysOpen, limitOpen, skipToastOpen, skipToastText, runBulk });
|
2026-05-11 19:38:59 +03:00
|
|
|
</script>
|
2026-05-11 19:41:53 +03:00
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
.bulk-actions-bar {
|
|
|
|
|
position: fixed;
|
|
|
|
|
bottom: 24px;
|
|
|
|
|
left: 50%;
|
|
|
|
|
transform: translateX(-50%);
|
|
|
|
|
z-index: 10;
|
2026-05-12 15:23:29 +03:00
|
|
|
max-width: calc(100vw - 48px);
|
2026-05-11 19:41:53 +03:00
|
|
|
}
|
|
|
|
|
</style>
|