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:
@@ -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;
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>Дайте 2–5 примеров конкурентов, которых точно знаете. Чем больше — тем точнее.</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('карточку конкурента');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user