Files
portal/app/resources/js/views/admin/AdminAutopodborPricingView.vue
T
Дмитрий 1b3683c6b1 fix(конкурентное поле): 6 находок теста «тупого клиента» — ошибки, регион, дедуп, миграции
- адресные сообщения в окнах сбора/изучения (маппер autopodborErrorMessage)
- регион по умолчанию = пустой плейсхолдер «выберите регион»
- кнопка «Собрать источники» у изучённого конкурента → «Источники собраны»
- сквозной дедуп предложений между прогонами (без двойного списания, ретрай цел)
- убран захардкоженный admin_user_id с фронта (id ставит бэкенд)
- идемпотентный гард в 3 миграции автоподбора (migrate:fresh снова зелёный)
- заглушка Агента: +тип 8-800 (tollfree) для полноты эмуляции

Тесты: Pest автоподбор 82/82, Vitest 62/62, vite build зелёный.

эскейп: фиксируй (авторизовано владельцем)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 06:42:33 +03:00

239 lines
8.9 KiB
Vue

<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();
// admin_user_id НЕ шлём с клиента — бэкенд проставляет id админа из сессии (audit-log).
if (searchPrice.value !== origSearch.value) {
await updateSystemSetting(SEARCH_KEY, { value: String(searchPrice.value), reason: reasonText });
}
if (studyPrice.value !== origStudy.value) {
await updateSystemSetting(STUDY_KEY, { value: String(studyPrice.value), reason: reasonText });
}
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>