feat(конкурентное поле): доводка фронта до прототипа — F1/F2/F3 + чистка M2

Сверка прототипа с реализацией показала расхождения — закрыты по TDD (dev, фронт):

- F1: экран «Предложения» (FieldProposalsScreen) переписан под вид «Поля» —
  карточки-плитки field-shared, тип+«предложение», крупная похожесть, Сайт +
  Справочник 2ГИС·Яндекс, править/удалять в карточке, массовый перенос; кнопка
  «Собрать конкурентов» открывает единое окно сбора 300 ₽ вместо старого autoform.
- F2: новый дружелюбный админ-экран AdminAutopodborPricingView (правка цен
  доп.услуг через PUT /api/admin/system-settings/{key} с обоснованием для аудита,
  сетка лидов для справки) + маршрут /admin/autopodbor-pricing + пункт меню.
- F3: колонка «когда списывается» в панели доп.услуг биллинга.
- M2: удалён мёртвый экран FieldManualCompetitorScreen (+ спека) — на него не
  было переходов; ручное добавление живёт окном на «Поле».

Тесты автоподбор+админ 43/43 зелёные, продакшен-вёрстка eslint-чистая, vite build .
НЕ на проде. M1 (18:00/21:00 МСК) — не баг, реальный инвариант продукта, не трогал.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-30 04:57:58 +03:00
parent 3561028dd2
commit 793b20a39c
10 changed files with 674 additions and 538 deletions
@@ -29,6 +29,7 @@ onMounted(() => {
<div class="font-weight-medium">Сбор конкурентов</div>
<div class="text-caption text-medium-emphasis">Подбор похожих конкурентов по вашим примерам и региону</div>
</div>
<div class="ap-row__when text-caption text-medium-emphasis">при успешном подборе</div>
<div class="ap-row__price num">{{ searchPrice }} </div>
</div>
<v-divider class="my-2" />
@@ -37,6 +38,7 @@ onMounted(() => {
<div class="font-weight-medium">Сбор источников</div>
<div class="text-caption text-medium-emphasis">Все источники одного конкурента (сайты и телефоны) для проектов</div>
</div>
<div class="ap-row__when text-caption text-medium-emphasis">при успешном изучении</div>
<div class="ap-row__price num">{{ studyPrice }} </div>
</div>
</v-card-text>
@@ -52,6 +54,13 @@ onMounted(() => {
}
.ap-row__name {
min-width: 0;
flex: 1;
}
.ap-row__when {
flex-shrink: 0;
white-space: nowrap;
text-align: right;
min-width: 140px;
}
.ap-row__price {
font-family: 'JetBrains Mono', ui-monospace, monospace;
+1
View File
@@ -30,6 +30,7 @@ const navItems: NavItem[] = [
{ title: 'Лиды', icon: 'mdi-target', to: '/admin/leads' },
{ title: 'Биллинг', icon: 'mdi-credit-card-outline', to: '/admin/billing' },
{ title: 'Тарифная сетка', icon: 'mdi-tag-arrow-right', to: '/admin/pricing-tiers' },
{ title: 'Тарифы «Конкурентного поля»', icon: 'mdi-bullseye-arrow', to: '/admin/autopodbor-pricing' },
{ title: 'Цены поставщиков', icon: 'mdi-currency-rub', to: '/admin/supplier-prices' },
{ title: 'Инциденты', icon: 'mdi-alert-outline', to: '/admin/incidents' },
{ title: 'Impersonation', icon: 'mdi-account-switch', to: '/admin/impersonation' },
+12
View File
@@ -270,6 +270,18 @@ const routes: RouteRecordRaw[] = [
devLabel: 'Admin Pricing Tiers',
},
},
{
path: '/admin/autopodbor-pricing',
name: 'admin-autopodbor-pricing',
component: () => import('../views/admin/AdminAutopodborPricingView.vue'),
meta: {
layout: 'admin',
title: 'Тарифы «Конкурентного поля»',
requiresAuth: true,
devIndex: 28,
devLabel: 'Admin Autopodbor Pricing',
},
},
{
path: '/admin/supplier-prices',
name: 'admin-supplier-prices',
@@ -0,0 +1,237 @@
<template>
<div class="admin-autopodbor-pricing-view">
<h1 class="text-h4 mb-1">Тарифы и услуги «Конкурентного поля»</h1>
<p class="text-body-2 text-medium-emphasis mb-6">Управление ценами. Изменения применяются ко всем клиентам.</p>
<v-alert
v-if="errorMessage"
type="error"
variant="tonal"
density="compact"
class="mb-4"
closable
data-testid="ap-pricing-error"
@click:close="errorMessage = null"
>
{{ errorMessage }}
</v-alert>
<v-card class="mb-6" elevation="1" max-width="640">
<v-card-title class="text-subtitle-1 font-weight-bold">Дополнительные услуги</v-card-title>
<v-card-subtitle class="pb-2">Цена за успешный результат. Списывается только при успехе, пустой результат бесплатно.</v-card-subtitle>
<v-card-text>
<v-text-field
v-model="searchPrice"
type="number"
min="0"
step="1"
label="Сбор конкурентов — ₽ за подбор"
hint="Списывается при успешном подборе конкурентов."
persistent-hint
density="comfortable"
class="mb-3"
data-testid="ap-search-price"
/>
<v-text-field
v-model="studyPrice"
type="number"
min="0"
step="1"
label="Сбор источников — ₽ за изучение конкурента"
hint="Списывается, если нашли сайты/телефоны конкурента."
persistent-hint
density="comfortable"
class="mb-3"
data-testid="ap-study-price"
/>
<v-textarea
v-model="reason"
label="Причина изменения (для журнала аудита) — минимум 30 символов"
:error="reason.length > 0 && !reasonValid"
rows="2"
auto-grow
density="comfortable"
data-testid="ap-reason"
/>
<div class="d-flex justify-end">
<v-btn
color="primary"
:loading="saving"
:disabled="!hasChanges"
data-testid="ap-save-btn"
@click="save"
>
Сохранить тарифы
</v-btn>
</div>
</v-card-text>
</v-card>
<h2 class="text-subtitle-1 font-weight-bold mb-1">Тариф на лиды</h2>
<p class="text-body-2 text-medium-emphasis mb-2">
Сетка цен за лиды по объёму здесь для справки (настраивается отдельно в «Тарифной сетке»).
</p>
<v-card elevation="1" max-width="640">
<table class="lead-tiers-table">
<thead>
<tr>
<th>Лидов в ступени</th>
<th class="r">Цена за лид</th>
</tr>
</thead>
<tbody>
<tr v-for="t in tiers" :key="t.tier_no">
<td>
<span v-if="t.leads_in_tier !== null">{{ t.leads_in_tier }}</span>
<span v-else class="text-medium-emphasis">все свыше</span>
</td>
<td class="r num">{{ fmtRub(t.price_per_lead_kopecks) }}</td>
</tr>
</tbody>
</table>
</v-card>
<v-snackbar
v-model="successToastOpen"
:timeout="4000"
color="success"
location="bottom right"
data-testid="ap-pricing-success"
>
{{ successMessage }}
</v-snackbar>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { listSystemSettings, updateSystemSetting, getPricingTiers, type AdminPricingTier } from '../../api/admin';
import { extractErrorMessage } from '../../api/client';
/**
* SaaS-admin → дружелюбный экран тарифов доп.услуг «Конкурентного поля».
* Правит две цены в system_settings (autopodbor_price_search_rub / _study_rub)
* через PUT /api/admin/system-settings/{key} (audit-log, reason ≥30). Сетка лидов —
* справочно (read-only). Прообраз — прототип renderAdmin.
*/
const SEARCH_KEY = 'autopodbor_price_search_rub';
const STUDY_KEY = 'autopodbor_price_study_rub';
const DEFAULT_REASON = 'Изменение тарифов доп.услуг «Конкурентное поле» администратором.';
const searchPrice = ref('');
const studyPrice = ref('');
const origSearch = ref('');
const origStudy = ref('');
const reason = ref(DEFAULT_REASON);
const tiers = ref<AdminPricingTier[]>([]);
const saving = ref(false);
const errorMessage = ref<string | null>(null);
const successMessage = ref<string | null>(null);
const successToastOpen = ref(false);
const reasonValid = computed(() => reason.value.trim().length >= 30);
const hasChanges = computed(
() => searchPrice.value !== origSearch.value || studyPrice.value !== origStudy.value,
);
function fmtRub(kopecks: number): string {
return new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 }).format(kopecks / 100) + ' ₽';
}
async function load(): Promise<void> {
errorMessage.value = null;
try {
const settings = await listSystemSettings();
const s = settings.find((x) => x.key === SEARCH_KEY);
const t = settings.find((x) => x.key === STUDY_KEY);
searchPrice.value = origSearch.value = s?.value ?? '';
studyPrice.value = origStudy.value = t?.value ?? '';
} catch (err) {
errorMessage.value = extractErrorMessage(err, 'Не удалось загрузить тарифы.');
}
try {
const data = await getPricingTiers();
tiers.value = data.active;
} catch {
// Сетка лидов — справочная; её отсутствие не блокирует правку цен.
}
}
async function save(): Promise<void> {
errorMessage.value = null;
successMessage.value = null;
if (!hasChanges.value) {
errorMessage.value = 'Вы не изменили ни одной цены.';
return;
}
if (!reasonValid.value) {
errorMessage.value = 'Укажите причину изменения — минимум 30 символов (для журнала аудита).';
return;
}
saving.value = true;
try {
const reasonText = reason.value.trim();
if (searchPrice.value !== origSearch.value) {
await updateSystemSetting(SEARCH_KEY, { value: String(searchPrice.value), reason: reasonText, admin_user_id: 1 });
}
if (studyPrice.value !== origStudy.value) {
await updateSystemSetting(STUDY_KEY, { value: String(studyPrice.value), reason: reasonText, admin_user_id: 1 });
}
successMessage.value = 'Тарифы сохранены. Изменения применяются ко всем клиентам.';
successToastOpen.value = true;
await load();
} catch (err) {
errorMessage.value = extractErrorMessage(err, 'Не удалось сохранить тарифы.');
} finally {
saving.value = false;
}
}
onMounted(load);
defineExpose({
load,
save,
searchPrice,
studyPrice,
reason,
tiers,
saving,
errorMessage,
successMessage,
successToastOpen,
reasonValid,
hasChanges,
});
</script>
<style scoped>
.lead-tiers-table {
width: 100%;
border-collapse: collapse;
}
.lead-tiers-table th,
.lead-tiers-table td {
padding: 9px 16px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
text-align: left;
font-size: 14px;
}
.lead-tiers-table th {
font-size: 11.5px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #55606b;
}
.lead-tiers-table .r {
text-align: right;
}
.lead-tiers-table .num {
font-family: 'JetBrains Mono', monospace;
font-feature-settings: 'tnum';
font-weight: 600;
color: #0f6e56;
}
</style>
@@ -4,7 +4,6 @@ import { useAutopodborStore } from '../../stores/autopodborStore';
import FieldWorkspaceScreen from './screens/FieldWorkspaceScreen.vue';
import FieldCompetitorScreen from './screens/FieldCompetitorScreen.vue';
import FieldProposalsScreen from './screens/FieldProposalsScreen.vue';
import FieldManualCompetitorScreen from './screens/FieldManualCompetitorScreen.vue';
import EntryScreen from './screens/EntryScreen.vue';
import AutoFormScreen from './screens/AutoFormScreen.vue';
import ManualFormScreen from './screens/ManualFormScreen.vue';
@@ -19,7 +18,6 @@ type ScreenName =
| 'field'
| 'fieldcompetitor'
| 'field-proposals'
| 'field-manual-competitor'
| 'entry'
| 'autoform'
| 'manualform'
@@ -56,7 +54,6 @@ const screens: Partial<Record<ScreenName, any>> = {
field: FieldWorkspaceScreen,
fieldcompetitor: FieldCompetitorScreen,
'field-proposals': FieldProposalsScreen,
'field-manual-competitor': FieldManualCompetitorScreen,
entry: EntryScreen,
autoform: AutoFormScreen,
manualform: ManualFormScreen,
@@ -1,211 +0,0 @@
<script setup lang="ts">
import { inject, reactive, ref } from 'vue';
import { useAutopodborStore } from '../../../stores/autopodborStore';
interface AutopodborNav {
go: (s: string) => void;
ctx: { competitorId: number | null };
screen: { value: string };
}
const nav = inject('autopodborNav') as AutopodborNav;
const store = useAutopodborStore();
const form = reactive({
name: '',
site_url: '',
directory: '',
description: '',
is_federal: false,
});
const busy = ref(false);
const error = ref('');
async function submit() {
if (!form.name.trim()) {
error.value = 'Укажите название конкурента.';
return;
}
busy.value = true;
error.value = '';
try {
const created = await store.addFieldCompetitor({
name: form.name.trim(),
site_url: form.site_url.trim() || undefined,
directory: form.directory.trim() || undefined,
description: form.description.trim() || undefined,
is_federal: form.is_federal,
});
// открыть карточку нового конкурента
nav.ctx.competitorId = created.id;
nav.go('fieldcompetitor');
} catch {
error.value = 'Не удалось добавить конкурента. Попробуйте ещё раз.';
} finally {
busy.value = false;
}
}
defineExpose({ form, submit });
</script>
<template>
<div class="ld-mc">
<button class="ld-mc__back" @click="nav.go('field')"> К полю</button>
<h1 class="ld-mc__title">Добавить конкурента вручную</h1>
<p class="ld-mc__sub">Конкурент сразу попадёт в поле. Источники по нему соберёте отдельно на его карточке.</p>
<div class="ld-mc__card">
<label class="ld-lbl">Название <span class="ld-req">*</span></label>
<input v-model="form.name" class="ld-input" placeholder="напр. Ромашка" />
<label class="ld-lbl">Сайт</label>
<input v-model="form.site_url" class="ld-input" placeholder="primer.ru (без http://)" />
<label class="ld-lbl">Справочник (2ГИС / Яндекс.Карты)</label>
<input v-model="form.directory" class="ld-input" placeholder="полная ссылка на карточку" />
<label class="ld-lbl">Описание</label>
<textarea v-model="form.description" class="ld-input ld-textarea" rows="3" placeholder="Чем занимается, для кого, чем похож на вас"></textarea>
<label class="ld-check">
<input v-model="form.is_federal" type="checkbox" />
Федеральный игрок
</label>
<p v-if="error" class="ld-mc__error">{{ error }}</p>
<div class="ld-mc__actions">
<button class="ld-btn-ghost" :disabled="busy" @click="nav.go('field')">Отмена</button>
<button class="ld-btn-primary" :disabled="busy" @click="submit">Добавить в поле</button>
</div>
</div>
</div>
</template>
<style scoped>
.ld-mc {
padding: 22px 0 60px;
max-width: 560px;
}
.ld-mc__back {
background: none;
border: none;
color: #7a7468;
font-size: 13px;
cursor: pointer;
padding: 0;
margin-bottom: 14px;
}
.ld-mc__title {
font-size: 22px;
font-weight: 700;
color: #012019;
margin: 0 0 6px;
}
.ld-mc__sub {
font-size: 13.5px;
color: #7a7468;
margin: 0 0 18px;
line-height: 1.45;
}
.ld-mc__card {
background: #fff;
border: 1px solid #e8e2d4;
border-radius: 10px;
padding: 20px 22px;
}
.ld-lbl {
display: block;
font-size: 12.5px;
font-weight: 600;
color: #4a4540;
margin: 14px 0 5px;
}
.ld-lbl:first-child {
margin-top: 0;
}
.ld-req {
color: #0f6e56;
}
.ld-input {
width: 100%;
border: 1px solid #d9d2c4;
border-radius: 7px;
padding: 9px 11px;
font-size: 13.5px;
color: #012019;
font-family: inherit;
}
.ld-input:focus {
outline: none;
border-color: var(--liderra-teal, #0f6e56);
}
.ld-textarea {
resize: vertical;
}
.ld-check {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #4a4540;
margin-top: 14px;
cursor: pointer;
}
.ld-check input {
width: 16px;
height: 16px;
}
.ld-mc__error {
font-size: 13px;
color: #a11;
background: #fdecea;
border-radius: 6px;
padding: 8px 10px;
margin: 14px 0 0;
}
.ld-mc__actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
}
.ld-btn-primary {
display: inline-flex;
align-items: center;
background: var(--liderra-teal, #0f6e56);
color: #fff;
border: none;
border-radius: 7px;
padding: 9px 18px;
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
}
.ld-btn-primary:hover {
background: #0b5a45;
}
.ld-btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
.ld-btn-ghost {
display: inline-flex;
align-items: center;
background: transparent;
color: var(--liderra-teal, #0f6e56);
border: 1.5px solid var(--liderra-teal, #0f6e56);
border-radius: 7px;
padding: 9px 18px;
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
}
.ld-btn-ghost:hover {
background: rgba(15, 110, 86, 0.06);
}
.ld-btn-ghost:disabled {
opacity: 0.5;
cursor: default;
}
</style>
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, inject, onMounted, ref } from 'vue';
import { computed, inject, onMounted, reactive, ref } from 'vue';
import { useAutopodborStore } from '../../../stores/autopodborStore';
import type { CompetitorDto } from '../../../api/autopodbor';
import { REGIONS } from '../../../constants/regions';
interface AutopodborNav {
go: (s: string) => void;
@@ -13,12 +14,30 @@ const store = useAutopodborStore();
const selected = ref<number[]>([]);
const busy = ref(false);
const toast = ref('');
const regions = REGIONS.filter((r) => r.code > 0);
const prices = computed(() => store.prices);
const list = computed<CompetitorDto[]>(() =>
[...store.proposals].sort((a, b) => (b.relevance_pct ?? 0) - (a.relevance_pct ?? 0)),
[...store.proposals].sort((a, b) => (b.relevance_pct ?? -1) - (a.relevance_pct ?? -1)),
);
const allSelected = computed(() => list.value.length > 0 && selected.value.length === list.value.length);
function flash(m: string) {
toast.value = m;
setTimeout(() => (toast.value = ''), 2400);
}
function dirLabel(c: CompetitorDto): string {
const has2gis = (c.directory_urls ?? []).some((u) => u.includes('2gis'));
const hasYa = (c.directory_urls ?? []).some((u) => u.includes('yandex'));
const parts = [];
if (has2gis) parts.push('2ГИС');
if (hasYa) parts.push('Яндекс.Карты');
return parts.join(' · ');
}
function toggle(id: number) {
const i = selected.value.indexOf(id);
if (i === -1) selected.value.push(id);
@@ -27,6 +46,9 @@ function toggle(id: number) {
function toggleAll() {
selected.value = allSelected.value ? [] : list.value.map((c) => c.id);
}
function clearSel() {
selected.value = [];
}
async function reload() {
busy.value = true;
@@ -43,316 +65,302 @@ async function moveToField(c: CompetitorDto) {
try {
await store.moveCompetitorToBox(c.id, 'field');
await reload();
flash(`«${c.name}» перенесён в поле ✓`);
} finally {
busy.value = false;
}
}
async function moveSelected() {
if (busy.value) return;
busy.value = true;
try {
for (const id of [...selected.value]) {
const ids = [...selected.value];
for (const id of ids) {
await store.moveCompetitorToBox(id, 'field');
}
await reload();
selected.value = [];
flash(`${ids.length} перенесено в поле ✓`);
} finally {
busy.value = false;
}
}
function openCompetitor(c: CompetitorDto) {
nav.ctx.competitorId = c.id;
nav.go('fieldcompetitor');
// ——— Окно «Собрать конкурентов для меня» (шаг 1, 300 ₽) — единое с «Полем» ———
const collect = reactive({
open: false,
running: false,
niche: '',
regionCode: regions[0]?.code ?? null,
selfSite: '',
includeFederal: true,
examples: [
{ site: '', dir: '' },
{ site: '', dir: '' },
] as Array<{ site: string; dir: string }>,
});
function openCollect() {
Object.assign(collect, {
open: true, running: false, niche: '', selfSite: '', includeFederal: true,
examples: [{ site: '', dir: '' }, { site: '', dir: '' }],
});
}
function addExample() {
collect.examples.push({ site: '', dir: '' });
}
async function runCollect() {
const examples = collect.examples.flatMap((e) => [e.site.trim(), e.dir.trim()]).filter(Boolean);
if (!collect.niche.trim() || examples.length < 1 || !collect.regionCode) {
flash('Заполните направление, регион и хотя бы один пример');
return;
}
collect.running = true;
try {
const run = await store.search({
region_code: collect.regionCode,
examples,
about_self: [collect.niche.trim(), collect.selfSite.trim()].filter(Boolean),
include_federal: collect.includeFederal,
});
await store.pollRun(run.id);
await reload();
collect.open = false;
flash('Готово: добавлены предложения');
} catch {
collect.running = false;
flash('Не удалось запустить подбор. Проверьте баланс.');
}
}
// ——— Окно «Править карточку конкурента» ———
const editComp = reactive({
open: false, id: null as number | null, name: '', type: 'loc', pct: '', site: '', gis: '', ya: '', desc: '',
});
function openEdit(c: CompetitorDto) {
const dirs = c.directory_urls ?? [];
Object.assign(editComp, {
open: true, id: c.id, name: c.name, type: c.is_federal ? 'fed' : 'loc',
pct: c.relevance_pct === null ? '' : String(c.relevance_pct),
site: c.site_url ?? '', desc: c.description ?? '',
gis: dirs.find((u) => u.includes('2gis')) ?? '',
ya: dirs.find((u) => u.includes('yandex')) ?? '',
});
}
async function saveEdit() {
if (editComp.id === null) return;
busy.value = true;
try {
const dirs = [editComp.gis.trim(), editComp.ya.trim()].filter(Boolean);
await store.editCompetitor(editComp.id, {
name: editComp.name.trim(),
is_federal: editComp.type === 'fed',
relevance_pct: editComp.pct === '' ? null : Math.max(0, Math.min(100, parseInt(editComp.pct) || 0)),
site_url: editComp.site.trim() || null,
description: editComp.desc.trim() || null,
directory_urls: dirs,
});
editComp.open = false;
await reload();
flash('Карточка сохранена ✓');
} finally {
busy.value = false;
}
}
// ——— Удаление предложения ———
const del = reactive({ open: false, id: null as number | null, name: '' });
function openDelete(c: CompetitorDto) {
Object.assign(del, { open: true, id: c.id, name: c.name });
}
async function confirmDelete() {
if (del.id === null) return;
busy.value = true;
try {
await store.removeCompetitor(del.id);
del.open = false;
await reload();
flash('Удалено');
} finally {
busy.value = false;
}
}
onMounted(() => {
void reload();
});
defineExpose({ list, selected, toggle, toggleAll, moveToField, moveSelected });
defineExpose({ list, selected, toggle, toggleAll, moveToField, moveSelected, openCollect, openEdit });
</script>
<template>
<div class="ld-prop">
<header class="ld-prop__head">
<h1 class="ld-prop__title">Конкурентное поле</h1>
<button class="ld-btn-primary" :disabled="busy" @click="nav.go('autoform')"> Собрать конкурентов</button>
</header>
<div class="ld-field">
<h1 class="ld-field__title">Конкурентное поле</h1>
<p class="ld-field__sub">
Найдено в предложениях: <b>{{ list.length }}</b>. Это черновик разберите и перенесите нужных в поле.
</p>
<div class="ld-prop__tabs">
<div class="ld-field__acts">
<button class="ld-btn primary" :disabled="busy" @click="openCollect"> Собрать конкурентов для меня</button>
<button class="ld-btn ghost" :disabled="busy" @click="nav.go('field')"> В поле</button>
</div>
<div class="ld-tabs">
<button class="ld-tab" @click="nav.go('field')">В поле</button>
<button class="ld-tab ld-tab--active">Предложения</button>
<button class="ld-tab ld-tab--on">Предложения <span class="ld-tab__c">{{ list.length }}</span></button>
</div>
<div v-if="list.length === 0" class="ld-prop__empty">
<p class="ld-prop__empty-title">Предложений пока нет</p>
<p class="ld-prop__empty-sub">Соберите конкурентов найденные появятся здесь, и вы перенесёте нужных в поле.</p>
</div>
<template v-else>
<label class="ld-prop__selall">
<input type="checkbox" :checked="allSelected" @change="toggleAll" />
Выбрать все
<div v-if="list.length" class="ld-selrow">
<label class="ld-selall">
<input type="checkbox" :checked="allSelected" @change="toggleAll" /> Выбрать все предложения
</label>
<span v-if="selected.length" class="ld-selcnt">Выбрано: <b>{{ selected.length }}</b></span>
<span v-else class="ld-selcnt ld-selcnt--mut">Отметьте 2 и более появится перенос в поле.</span>
</div>
<div class="ld-prop__grid">
<article
v-for="c in list"
:key="c.id"
class="ld-comp"
:class="{ 'ld-comp--picked': selected.includes(c.id) }"
>
<label class="ld-comp__check">
<input type="checkbox" :checked="selected.includes(c.id)" @change="toggle(c.id)" />
</label>
<div class="ld-comp__body" @click="openCompetitor(c)">
<div class="ld-comp__top">
<span class="ld-comp__name">{{ c.name }}</span>
<span v-if="c.is_federal" class="ld-comp__tag">федеральный</span>
<span v-if="c.origin === 'manual'" class="ld-comp__tag ld-comp__tag--manual">добавлен вручную</span>
<span v-if="c.relevance_pct !== null" class="ld-comp__rel">{{ c.relevance_pct }}%</span>
<div v-if="list.length === 0" class="ld-empty">
<p class="ld-empty__t">Предложений пока нет</p>
<p class="ld-empty__s">Соберите конкурентов найденные появятся здесь, и вы перенесёте нужных в поле.</p>
</div>
<div v-else class="ld-grid">
<article v-for="c in list" :key="c.id" class="ld-card ld-card--sug" :class="{ 'ld-card--picked': selected.includes(c.id) }">
<div class="ld-card__top">
<div>
<div class="ld-card__nm">
<input type="checkbox" class="ld-pick" :checked="selected.includes(c.id)" @change="toggle(c.id)" />
{{ c.name }}
</div>
<div class="ld-bdgs">
<span class="ld-bdg" :class="c.is_federal ? 'ld-bdg--fed' : 'ld-bdg--loc'">{{ c.is_federal ? 'федеральный' : 'региональный' }}</span>
<span v-if="c.origin === 'manual'" class="ld-bdg ld-bdg--man">добавлен вручную</span>
<span class="ld-bdg ld-bdg--sug">предложение</span>
</div>
<p v-if="c.description" class="ld-comp__desc">{{ c.description }}</p>
<p v-if="c.site_url" class="ld-comp__site">{{ c.site_url }}</p>
</div>
<div class="ld-comp__act">
<button class="ld-btn-mini ld-btn-mini--primary" :disabled="busy" @click="moveToField(c)">В поле</button>
<div class="ld-pctwrap">
<div v-if="c.relevance_pct !== null" class="ld-pct" :class="c.relevance_pct >= 85 ? '' : c.relevance_pct >= 65 ? 'ld-pct--mid' : 'ld-pct--lo'">
{{ c.relevance_pct }}<span class="ld-pct__u">%</span>
</div>
<div v-if="c.relevance_pct !== null" class="ld-pl">похожесть</div>
<div v-else class="ld-pct ld-pct--man"></div>
</div>
</article>
</div>
</template>
</div>
<div class="ld-desc">{{ c.description || '—' }}</div>
<div class="ld-mrow"><span class="ld-lbl">Сайт:</span> <a v-if="c.site_url">{{ c.site_url }}</a><span v-else class="ld-na">не указан</span></div>
<div class="ld-mrow"><span class="ld-lbl">Справочник:</span> <span v-if="dirLabel(c)">{{ dirLabel(c) }}</span><span v-else class="ld-na">не указан</span></div>
<div class="ld-cfoot">
<span>
<span class="ld-link" @click="openEdit(c)"> Изменить</span>
<span class="ld-link ld-link--del" @click="openDelete(c)"> Удалить</span>
</span>
<button class="ld-btn primary sm" :disabled="busy" @click="moveToField(c)">В поле </button>
</div>
</article>
</div>
<Transition name="ld-bar">
<div v-if="selected.length >= 2" class="ld-bulkbar">
<span class="ld-bulkbar__count">Выбрано конкурентов: {{ selected.length }}</span>
<button class="ld-btn-primary ld-btn-primary--bar" :disabled="busy" @click="moveSelected">
Перенести выбранных в поле
</button>
<span>Выбрано конкурентов: <b>{{ selected.length }}</b> это черновик, сохраняется</span>
<div class="ld-bulkbar__acts">
<button class="ld-btn gray sm" :disabled="busy" @click="clearSel">Снять выбор</button>
<button class="ld-btn primary sm" :disabled="busy" @click="moveSelected">Перенести выбранных в поле </button>
</div>
</div>
</Transition>
<!-- ====== Окно: Собрать конкурентов ====== -->
<div v-if="collect.open" class="ld-ovl" @click.self="collect.open = false">
<div class="ld-modal">
<template v-if="!collect.running">
<h3 class="ld-modal__h">Собрать конкурентов для меня</h3>
<p class="ld-modal__m">Лидерра соберёт список конкурентов. Он ляжет в «Предложения» вы сами выберете, кого взять в поле.</p>
<div class="ld-rules">
<h4>Как заполнить, чтобы результат был точным</h4>
<ul>
<li>Опишите простыми словами, чем вы занимаетесь от этого зависит, кого мы найдём.</li>
<li>Дайте 25 примеров конкурентов, которых точно знаете. Чем больше тем точнее.</li>
<li>У каждого примера укажите сайт <b>или</b> ссылку на 2ГИС/Яндекс.Карты.</li>
<li>Укажите свой сайт чтобы не предлагать вас самих.</li>
</ul>
</div>
<div class="ld-fld"><label>Чем вы занимаетесь (направление поиска) <span class="ld-req">*</span></label>
<textarea v-model="collect.niche" rows="2" class="ld-in" placeholder="Коротко — что вы делаете и для кого"></textarea></div>
<div class="ld-fld"><label>Регион поиска <span class="ld-req">*</span></label>
<select v-model="collect.regionCode" class="ld-in"><option v-for="r in regions" :key="r.code" :value="r.code">{{ r.name }}</option></select></div>
<div class="ld-fld"><label>Ваш сайт</label><input v-model="collect.selfSite" class="ld-in" placeholder="primer.ru" /></div>
<div class="ld-fld"><label>Примеры ваших конкурентов <span class="ld-req">*</span> минимум 2</label>
<div v-for="(ex, i) in collect.examples" :key="i" class="ld-ex">
<input v-model="ex.site" class="ld-in" placeholder="сайт: primer.ru" />
<input v-model="ex.dir" class="ld-in" placeholder="или ссылка на 2ГИС/Яндекс" />
</div>
<span class="ld-link" @click="addExample">+ добавить ещё пример</span></div>
<div class="ld-fld"><label>Включать федеральных конкурентов</label>
<select v-model="collect.includeFederal" class="ld-in"><option :value="true">Да</option><option :value="false">Нет</option></select></div>
<div class="ld-price">💳 Сбор конкурентов <b>{{ prices.search }} </b> за подбор. Деньги спишутся <b>только если что-то найдём</b>; пустой результат бесплатно.</div>
<div class="ld-modal__foot">
<button class="ld-btn gray sm" @click="collect.open = false">Отмена</button>
<button class="ld-btn primary sm" @click="runCollect">Запустить подбор (платно)</button>
</div>
</template>
<template v-else>
<h3 class="ld-modal__h">Идёт подбор <span class="ld-spin"></span></h3>
<p class="ld-modal__m">Можно закрыть вкладку мы сохраним результат. Деньги спишутся только при успехе.</p>
</template>
</div>
</div>
<!-- ====== Окно: Править карточку ====== -->
<div v-if="editComp.open" class="ld-ovl" @click.self="editComp.open = false">
<div class="ld-modal">
<h3 class="ld-modal__h">Изменить карточку конкурента</h3>
<p class="ld-modal__m">Можно поправить перед переносом в поле.</p>
<div class="ld-fld"><label>Название <span class="ld-req">*</span></label><input v-model="editComp.name" class="ld-in" /></div>
<div class="ld-fld"><label>Тип</label><select v-model="editComp.type" class="ld-in"><option value="loc">региональный</option><option value="fed">федеральный</option></select></div>
<div class="ld-fld"><label>Похожесть на вас, %</label><input v-model="editComp.pct" type="number" min="0" max="100" class="ld-in" /><div class="ld-hint">Портал сортирует по похожести: 100% сверху.</div></div>
<div class="ld-fld"><label>Чем занимается (описание)</label><textarea v-model="editComp.desc" rows="2" class="ld-in"></textarea></div>
<div class="ld-fld"><label>Сайт</label><input v-model="editComp.site" class="ld-in" placeholder="primer.ru" /></div>
<div class="ld-fld"><label>Ссылка на карточку в 2ГИС</label><input v-model="editComp.gis" class="ld-in" placeholder="https://2gis.ru/..." /></div>
<div class="ld-fld"><label>Ссылка на карточку в Яндекс.Картах</label><input v-model="editComp.ya" class="ld-in" placeholder="https://yandex.ru/maps/..." /></div>
<div class="ld-hint">Укажите хотя бы одно: сайт или справочник иначе мы не сможем найти источники конкурента.</div>
<div class="ld-modal__foot">
<button class="ld-btn gray sm" @click="editComp.open = false">Отмена</button>
<button class="ld-btn primary sm" :disabled="busy" @click="saveEdit">Сохранить</button>
</div>
</div>
</div>
<!-- ====== Окно: Удалить предложение ====== -->
<div v-if="del.open" class="ld-ovl" @click.self="del.open = false">
<div class="ld-modal">
<h3 class="ld-modal__h">Удалить конкурента?</h3>
<p class="ld-modal__m">«{{ del.name }}» будет удалён из предложений (если это лишнее или ошибочное предложение).</p>
<div class="ld-modal__foot">
<button class="ld-btn gray sm" @click="del.open = false">Отмена</button>
<button class="ld-btn danger sm" :disabled="busy" @click="confirmDelete">Удалить</button>
</div>
</div>
</div>
<Transition name="ld-toast">
<div v-if="toast" class="ld-toast">{{ toast }}</div>
</Transition>
</div>
</template>
<style scoped>
.ld-prop {
padding: 28px 0 96px;
@import './field-shared.css';
.ld-field {
padding: 24px 0 90px;
max-width: 1080px;
}
.ld-prop__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
.ld-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 13px;
}
.ld-prop__title {
font-size: 24px;
font-weight: 700;
color: #012019;
margin: 0;
}
.ld-prop__tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid #e8e2d4;
margin-bottom: 18px;
}
.ld-tab {
background: transparent;
border: none;
border-bottom: 2px solid transparent;
padding: 9px 14px;
font-size: 14px;
font-weight: 600;
color: #7a7468;
cursor: pointer;
}
.ld-tab--active {
color: var(--liderra-teal, #0f6e56);
border-bottom-color: var(--liderra-teal, #0f6e56);
}
.ld-prop__empty {
background: #fff;
border: 1px dashed #e0d9c8;
border-radius: 10px;
padding: 40px 24px;
text-align: center;
}
.ld-prop__empty-title {
font-size: 16px;
font-weight: 700;
color: #012019;
margin: 0 0 6px;
}
.ld-prop__empty-sub {
font-size: 13.5px;
color: #7a7468;
margin: 0;
}
.ld-prop__selall {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #4a4540;
margin-bottom: 12px;
cursor: pointer;
}
.ld-prop__selall input {
flex-shrink: 0;
width: 17px;
height: 17px;
}
.ld-prop__grid {
display: flex;
flex-direction: column;
gap: 10px;
}
.ld-comp {
display: flex;
gap: 12px;
align-items: flex-start;
background: #fff;
border: 1px solid #e8e2d4;
border-radius: 10px;
padding: 14px 16px;
}
.ld-comp--picked {
border-color: var(--liderra-teal, #0f6e56);
box-shadow: 0 0 0 1px var(--liderra-teal, #0f6e56);
}
.ld-comp__check {
padding-top: 2px;
}
.ld-comp__check input {
flex-shrink: 0;
width: 17px;
height: 17px;
}
.ld-comp__body {
flex: 1;
cursor: pointer;
min-width: 0;
}
.ld-comp__top {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.ld-comp__name {
font-size: 15px;
font-weight: 700;
color: #012019;
}
.ld-comp__tag {
font-size: 11px;
font-weight: 500;
color: #7a7468;
background: var(--liderra-ivory, #f6f3ec);
border-radius: 4px;
padding: 1px 7px;
}
.ld-comp__tag--manual {
color: #0f6e56;
background: rgba(15, 110, 86, 0.08);
}
.ld-comp__rel {
margin-left: auto;
font-size: 13px;
font-weight: 700;
color: var(--liderra-teal, #0f6e56);
font-feature-settings: 'tnum';
}
.ld-comp__desc {
font-size: 13px;
color: #4a4540;
margin: 6px 0 4px;
line-height: 1.45;
}
.ld-comp__site {
font-size: 12.5px;
color: #7a7468;
margin: 0;
}
.ld-comp__act {
display: flex;
align-items: flex-start;
}
.ld-btn-mini {
background: #fff;
border: 1px solid #d9d2c4;
border-radius: 6px;
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
color: #4a4540;
cursor: pointer;
white-space: nowrap;
}
.ld-btn-mini--primary {
color: #fff;
background: var(--liderra-teal, #0f6e56);
border-color: var(--liderra-teal, #0f6e56);
}
.ld-btn-mini--primary:hover {
background: #0b5a45;
}
.ld-btn-mini:disabled {
opacity: 0.5;
cursor: default;
}
.ld-bulkbar {
position: fixed;
left: 50%;
bottom: 22px;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 22px;
background: #012019;
color: #fff;
border-radius: 12px;
padding: 12px 18px;
box-shadow: 0 10px 32px rgba(1, 32, 25, 0.28);
z-index: 40;
}
.ld-bulkbar__count {
font-size: 13.5px;
font-weight: 600;
}
.ld-bar-enter-active,
.ld-bar-leave-active {
transition:
opacity 180ms ease,
transform 180ms ease;
}
.ld-bar-enter-from,
.ld-bar-leave-to {
opacity: 0;
transform: translate(-50%, 12px);
}
.ld-btn-primary {
display: inline-flex;
align-items: center;
background: var(--liderra-teal, #0f6e56);
color: #fff;
border: none;
border-radius: 7px;
padding: 9px 16px;
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
transition: background 180ms ease;
}
.ld-btn-primary:hover {
background: #0b5a45;
}
.ld-btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
.ld-btn-primary--bar {
padding: 8px 14px;
@media (max-width: 860px) {
.ld-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,81 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
vi.mock('../../resources/js/api/admin');
import AdminAutopodborPricingView from '../../resources/js/views/admin/AdminAutopodborPricingView.vue';
import { listSystemSettings, updateSystemSetting, getPricingTiers } from '../../resources/js/api/admin';
const vuetify = createVuetify();
function settings(search = '300', study = '50') {
return [
{ key: 'autopodbor_price_search_rub', value: search, type: 'decimal', description: null, updated_at: '', updated_by: null },
{ key: 'autopodbor_price_study_rub', value: study, type: 'decimal', description: null, updated_at: '', updated_by: null },
{ key: 'other_key', value: '1', type: 'int', description: null, updated_at: '', updated_by: null },
];
}
function tiers() {
return {
active: [{ tier_no: 1, leads_in_tier: 100, price_per_lead_kopecks: 50000, effective_from: '2026-06-01' }],
scheduled: {},
};
}
function mountV() {
return mount(AdminAutopodborPricingView, { global: { plugins: [vuetify] } });
}
describe('AdminAutopodborPricingView', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(listSystemSettings).mockResolvedValue(settings() as any);
vi.mocked(getPricingTiers).mockResolvedValue(tiers() as any);
vi.mocked(updateSystemSetting).mockResolvedValue({} as any);
});
it('грузит текущие тарифы доп.услуг из system-settings', async () => {
const w = mountV();
await new Promise((r) => setTimeout(r, 0));
expect(listSystemSettings).toHaveBeenCalled();
expect((w.vm as any).searchPrice).toBe('300');
expect((w.vm as any).studyPrice).toBe('50');
});
it('показывает сетку лидов для справки', async () => {
const w = mountV();
await new Promise((r) => setTimeout(r, 0));
expect(w.text()).toContain('Тариф на лиды');
expect(w.text()).toContain('500'); // 50000 коп = 500 ₽
});
it('сохранение изменённой цены зовёт updateSystemSetting с value и reason', async () => {
const w = mountV();
await new Promise((r) => setTimeout(r, 0));
(w.vm as any).searchPrice = '350';
await (w.vm as any).save();
expect(updateSystemSetting).toHaveBeenCalledWith(
'autopodbor_price_search_rub',
expect.objectContaining({ value: '350' }),
);
expect(updateSystemSetting).not.toHaveBeenCalledWith('autopodbor_price_study_rub', expect.anything());
});
it('причина короче 30 символов блокирует сохранение', async () => {
const w = mountV();
await new Promise((r) => setTimeout(r, 0));
(w.vm as any).searchPrice = '350';
(w.vm as any).reason = 'мало';
await (w.vm as any).save();
expect(updateSystemSetting).not.toHaveBeenCalled();
expect((w.vm as any).errorMessage).toContain('30');
});
it('без изменений не зовёт сохранение', async () => {
const w = mountV();
await new Promise((r) => setTimeout(r, 0));
await (w.vm as any).save();
expect(updateSystemSetting).not.toHaveBeenCalled();
expect((w.vm as any).errorMessage).toBeTruthy();
});
});
@@ -1,51 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
import { reactive, ref } from 'vue';
vi.mock('../../resources/js/api/autopodbor');
import FieldManualCompetitorScreen from '../../resources/js/views/autopodbor/screens/FieldManualCompetitorScreen.vue';
import { useAutopodborStore } from '../../resources/js/stores/autopodborStore';
const vuetify = createVuetify();
function makeNav() {
return { go: vi.fn(), ctx: reactive({ competitorId: null }), screen: ref('field-manual-competitor') };
}
function mountM(nav: any) {
return mount(FieldManualCompetitorScreen, { global: { plugins: [vuetify], provide: { autopodborNav: nav } } });
}
describe('FieldManualCompetitorScreen', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
});
it('пустое имя показывает ошибку и не отправляет', async () => {
const store = useAutopodborStore();
const addSpy = vi.spyOn(store, 'addFieldCompetitor');
const w = mountM(makeNav());
const btn = w.findAll('button').find((b) => b.text() === 'Добавить в поле');
await btn!.trigger('click');
await new Promise((r) => setTimeout(r, 0));
expect(addSpy).not.toHaveBeenCalled();
expect(w.text()).toContain('Укажите название');
});
it('заполнение и отправка добавляют конкурента и открывают его карточку', async () => {
const store = useAutopodborStore();
vi.spyOn(store, 'addFieldCompetitor').mockResolvedValue({ id: 42, name: 'Ромашка' } as any);
const nav = makeNav();
const w = mountM(nav);
await w.find('input').setValue('Ромашка');
const btn = w.findAll('button').find((b) => b.text() === 'Добавить в поле');
await btn!.trigger('click');
await new Promise((r) => setTimeout(r, 0));
expect(store.addFieldCompetitor).toHaveBeenCalled();
expect(nav.ctx.competitorId).toBe(42);
expect(nav.go).toHaveBeenCalledWith('fieldcompetitor');
});
});
@@ -18,13 +18,13 @@ function comp(over: Partial<any> = {}) {
return {
id: 1,
name: 'Окна',
description: null,
description: 'Окна ПВХ под ключ',
is_federal: false,
relevance_pct: 80,
origin: 'auto',
box: 'proposal',
site_url: 'okna.ru',
directory_urls: [],
directory_urls: ['https://2gis.ru/firm/1', 'https://yandex.ru/maps/1'],
studied_at: null,
study_run_id: null,
search_run_id: 5,
@@ -42,18 +42,23 @@ describe('FieldProposalsScreen', () => {
vi.clearAllMocks();
});
it('грузит предложения и показывает их', async () => {
it('грузит предложения и показывает карточку-плитку с похожестью и Справочником', async () => {
const store = useAutopodborStore();
vi.spyOn(store, 'loadProposals').mockImplementation(async () => {
store.proposals = [comp({ id: 1, name: 'Окна Комфорт' })] as any;
store.proposals = [comp({ id: 1, name: 'Окна Комфорт', relevance_pct: 80 })] as any;
});
const w = mountP(makeNav());
await new Promise((r) => setTimeout(r, 0));
expect(store.loadProposals).toHaveBeenCalled();
expect(w.find('.ld-card').exists()).toBe(true);
expect(w.text()).toContain('Окна Комфорт');
expect(w.text()).toContain('80');
expect(w.text()).toContain('Справочник');
expect(w.text()).toContain('2ГИС');
expect(w.text()).toContain('Яндекс.Карты');
});
it('«В поле» по конкуренту зовёт moveCompetitorToBox(field)', async () => {
it('«В поле» по конкуренту зовёт moveCompetitorToBox(field)', async () => {
const store = useAutopodborStore();
vi.spyOn(store, 'loadProposals').mockImplementation(async () => {
store.proposals = [comp({ id: 7 })] as any;
@@ -61,7 +66,7 @@ describe('FieldProposalsScreen', () => {
const moveSpy = vi.spyOn(store, 'moveCompetitorToBox').mockResolvedValue();
const w = mountP(makeNav());
await new Promise((r) => setTimeout(r, 0));
const btn = w.find('.ld-comp__act button');
const btn = w.find('.ld-cfoot button');
await btn.trigger('click');
await new Promise((r) => setTimeout(r, 0));
expect(moveSpy).toHaveBeenCalledWith(7, 'field');
@@ -76,4 +81,52 @@ describe('FieldProposalsScreen', () => {
await new Promise((r) => setTimeout(r, 0));
expect(w.text()).toContain('Предложений пока нет');
});
it('«Собрать конкурентов» открывает окно сбора с ценой 300 ₽ (не уходит на старую форму)', async () => {
const store = useAutopodborStore();
vi.spyOn(store, 'loadProposals').mockResolvedValue();
store.prices = { search: '300', study: '50' };
const nav = makeNav();
const w = mountP(nav);
await new Promise((r) => setTimeout(r, 0));
const btn = w.findAll('button').find((b) => b.text().includes('Собрать конкурентов'));
await btn!.trigger('click');
expect(w.find('.ld-ovl').exists()).toBe(true);
expect(w.text()).toContain('Сбор конкурентов');
expect(w.text()).toContain('300 ₽');
expect(nav.go).not.toHaveBeenCalledWith('autoform');
});
it('массово переносит выбранных в поле при ≥2', async () => {
const store = useAutopodborStore();
vi.spyOn(store, 'loadProposals').mockImplementation(async () => {
store.proposals = [comp({ id: 1 }), comp({ id: 2 })] as any;
});
const moveSpy = vi.spyOn(store, 'moveCompetitorToBox').mockResolvedValue();
const w = mountP(makeNav());
await new Promise((r) => setTimeout(r, 0));
const boxes = w.findAll('.ld-pick');
await boxes[0].trigger('change');
await boxes[1].trigger('change');
expect(w.find('.ld-bulkbar').exists()).toBe(true);
const btn = w.findAll('.ld-bulkbar button').find((b) => b.text().includes('Перенести'));
await btn!.trigger('click');
await new Promise((r) => setTimeout(r, 0));
expect(moveSpy).toHaveBeenCalledWith(1, 'field');
expect(moveSpy).toHaveBeenCalledWith(2, 'field');
});
it('«Изменить» открывает окно правки карточки конкурента', async () => {
const store = useAutopodborStore();
vi.spyOn(store, 'loadProposals').mockImplementation(async () => {
store.proposals = [comp({ id: 1, name: 'Окна Комфорт' })] as any;
});
const w = mountP(makeNav());
await new Promise((r) => setTimeout(r, 0));
const link = w.findAll('.ld-link').find((b) => b.text().includes('Изменить'));
await link!.trigger('click');
await new Promise((r) => setTimeout(r, 0));
expect(w.find('.ld-ovl').exists()).toBe(true);
expect(w.text()).toContain('карточку конкурента');
});
});