1b3683c6b1
- адресные сообщения в окнах сбора/изучения (маппер 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>
239 lines
8.9 KiB
Vue
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>
|