5f209a2fcc
Раунд 2 минор-фиксы (Playwright-аудит): - RuDateField (новый): даты дд.мм.гггг через ru date-picker вместо нативного <input type=date> (показывал мм/дд/гггг на en-локали) — Отчёты + Сделки. - BalanceCapacityIndicator: разделитель тысяч «1 000 ₽», эмодзи→mdi. - dealsApiMapper/DealDetailBody: статус-смена в активности русскими метками (было «viewed → new» сырыми слагами). - ProfileTab: инлайн-валидация Имя/Фамилия (под полем, как в Реквизитах). - RequisitesTab: проверка формата телефона на клиенте. - ApiTab: eye-toggle с aria-label (показать/скрыть ключ и секрет). - DashboardView: «3 / 0» → скрываем «/ N» и «лимит тарифа» при лимите 0. - KanbanView: тост-подтверждение при смене статуса (+ цветной фейл-тост). - NotificationsTab: убран жаргон «users.notification_preferences в БД». - Админка: TenantsTable «ИНН не указан» вместо пустого «ИНН »; PricingTiers epoch-дата «1970»→«начала» + ru-формат цены; Incidents empty-state «Инцидентов нет»; SupplierIntegration/PdSubjectRequests — window.confirm/alert → v-dialog/snackbar. Верификация: type-check, build, Playwright (даты дд.мм.гггг подтверждены). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
311 lines
12 KiB
Vue
311 lines
12 KiB
Vue
<template>
|
||
<div class="admin-pricing-tiers-view">
|
||
<h1 class="text-h4 mb-6">Тарифная сетка</h1>
|
||
|
||
<v-alert
|
||
v-if="errorMessage"
|
||
type="error"
|
||
variant="tonal"
|
||
class="mb-4"
|
||
density="compact"
|
||
data-testid="pricing-error-alert"
|
||
closable
|
||
@click:close="errorMessage = null"
|
||
>
|
||
{{ errorMessage }}
|
||
</v-alert>
|
||
|
||
<v-card class="mb-6" elevation="1">
|
||
<v-card-title>
|
||
Текущая активная сетка
|
||
<span v-if="active.length" class="text-caption text-medium-emphasis ml-2">
|
||
(с {{ fmtEffective(active[0]?.effective_from) }})
|
||
</span>
|
||
</v-card-title>
|
||
<v-data-table
|
||
:headers="tierHeaders"
|
||
:items="active"
|
||
:items-per-page="7"
|
||
density="comfortable"
|
||
class="numeric-tnum"
|
||
>
|
||
<template #[`item.leads_in_tier`]="{ item }">
|
||
<span v-if="item.leads_in_tier !== null">{{ item.leads_in_tier }}</span>
|
||
<span v-else class="text-medium-emphasis">все свыше</span>
|
||
</template>
|
||
<template #[`item.price_rub`]="{ item }">
|
||
{{ fmtRub(item.price_per_lead_kopecks) }}
|
||
</template>
|
||
</v-data-table>
|
||
</v-card>
|
||
|
||
<v-card v-if="hasScheduled" class="mb-6" elevation="1">
|
||
<v-card-title>Запланированные изменения</v-card-title>
|
||
<v-card-text>
|
||
<div v-for="(group, date) in scheduled" :key="date" class="mb-4">
|
||
<strong>С {{ date }}:</strong>
|
||
<v-data-table :headers="tierHeaders" :items="group" density="compact" class="mt-2" />
|
||
<v-btn color="error" variant="text" @click="confirmDelete(date)">Отменить</v-btn>
|
||
</div>
|
||
</v-card-text>
|
||
</v-card>
|
||
|
||
<v-btn color="primary" prepend-icon="mdi-pencil" data-testid="open-editor-btn" @click="openEditor">
|
||
Редактировать сетку (с {{ nextMonthStart }})
|
||
</v-btn>
|
||
|
||
<v-dialog v-model="editorOpen" max-width="900">
|
||
<v-card>
|
||
<v-card-title>Новая сетка (effective_from = {{ effectiveFrom }})</v-card-title>
|
||
<v-card-text>
|
||
<v-text-field
|
||
v-model="effectiveFrom"
|
||
type="date"
|
||
label="Дата вступления в силу"
|
||
:min="minEffectiveFrom"
|
||
density="compact"
|
||
class="mb-3"
|
||
style="max-width: 240px"
|
||
data-testid="effective-from-input"
|
||
/>
|
||
<table class="editor-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Ступень</th>
|
||
<th>Лидов в ступени</th>
|
||
<th>Цена за лид (₽)</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="t in editor" :key="t.tier_no">
|
||
<td>{{ t.tier_no }}</td>
|
||
<td>
|
||
<v-text-field
|
||
v-if="t.tier_no !== 7"
|
||
v-model.number="t.leads_in_tier"
|
||
type="number"
|
||
min="1"
|
||
density="compact"
|
||
hide-details
|
||
/>
|
||
<span v-else class="text-medium-emphasis">все свыше</span>
|
||
</td>
|
||
<td>
|
||
<v-text-field
|
||
v-model="t.price_rub"
|
||
type="number"
|
||
step="0.01"
|
||
min="0"
|
||
density="compact"
|
||
hide-details
|
||
/>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</v-card-text>
|
||
<v-card-actions>
|
||
<v-spacer />
|
||
<v-btn @click="editorOpen = false">Отмена</v-btn>
|
||
<v-btn color="primary" :loading="saving" @click="submit">Сохранить</v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-dialog>
|
||
|
||
<v-dialog v-model="deleteDialogOpen" max-width="440">
|
||
<v-card>
|
||
<v-card-title>Удалить запланированный набор?</v-card-title>
|
||
<v-card-text>
|
||
Запланированная сетка с <strong>{{ deleteTarget }}</strong> будет удалена. Действие необратимо.
|
||
</v-card-text>
|
||
<v-card-actions>
|
||
<v-spacer />
|
||
<v-btn @click="deleteDialogOpen = false">Отмена</v-btn>
|
||
<v-btn color="error" data-testid="confirm-delete-btn" @click="performDelete">Удалить</v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-dialog>
|
||
|
||
<v-snackbar
|
||
v-model="successToastOpen"
|
||
:timeout="4000"
|
||
color="success"
|
||
location="bottom right"
|
||
data-testid="pricing-success-toast"
|
||
>
|
||
{{ successMessage }}
|
||
</v-snackbar>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted } from 'vue';
|
||
import {
|
||
getPricingTiers,
|
||
createPricingTiers,
|
||
deleteScheduledPricingTier,
|
||
type AdminPricingTier,
|
||
type PricingTierEditorRow,
|
||
} from '../../api/admin';
|
||
import { extractErrorMessage } from '../../api/client';
|
||
|
||
/**
|
||
* SaaS-admin → Тарифная сетка (Plan 4 Task 9, Sprint 1 G1 error handling).
|
||
*
|
||
* Backend: AdminPricingTiersController (GET/POST/DELETE).
|
||
* Палитра Forest + JetBrains Mono для tnum-цифр.
|
||
*
|
||
* defineExpose ниже — для Vitest unit-тестов.
|
||
*/
|
||
|
||
const active = ref<AdminPricingTier[]>([]);
|
||
const scheduled = ref<Record<string, AdminPricingTier[]>>({});
|
||
|
||
// UI-аудит: epoch-дата (1970) выглядит сломанной → «начала»; ru-формат даты и цены.
|
||
function fmtEffective(iso?: string | null): string {
|
||
if (!iso || iso.startsWith('1970')) return 'начала';
|
||
return new Date(iso).toLocaleDateString('ru-RU');
|
||
}
|
||
function fmtRub(kopecks: number): string {
|
||
return new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 }).format(kopecks / 100) + ' ₽';
|
||
}
|
||
const editorOpen = ref(false);
|
||
const saving = ref(false);
|
||
|
||
// Sprint 1 G1: error/success state для UI feedback.
|
||
const errorMessage = ref<string | null>(null);
|
||
const successMessage = ref<string | null>(null);
|
||
const successToastOpen = ref(false);
|
||
|
||
// G10: диалог подтверждения удаления (замена window.confirm).
|
||
const deleteDialogOpen = ref(false);
|
||
const deleteTarget = ref<string | null>(null);
|
||
|
||
const defaultEditor: PricingTierEditorRow[] = [
|
||
{ tier_no: 1, leads_in_tier: 100, price_rub: '500.00' },
|
||
{ tier_no: 2, leads_in_tier: 200, price_rub: '450.00' },
|
||
{ tier_no: 3, leads_in_tier: 400, price_rub: '400.00' },
|
||
{ tier_no: 4, leads_in_tier: 800, price_rub: '350.00' },
|
||
{ tier_no: 5, leads_in_tier: 1500, price_rub: '300.00' },
|
||
{ tier_no: 6, leads_in_tier: 3000, price_rub: '270.00' },
|
||
{ tier_no: 7, leads_in_tier: null, price_rub: '250.00' },
|
||
];
|
||
const editor = ref<PricingTierEditorRow[]>(JSON.parse(JSON.stringify(defaultEditor)));
|
||
|
||
const tierHeaders = [
|
||
{ title: '№', key: 'tier_no', sortable: false, width: 80 },
|
||
{ title: 'Лидов в ступени', key: 'leads_in_tier', sortable: false },
|
||
{ title: 'Цена за лид', key: 'price_rub', sortable: false },
|
||
];
|
||
|
||
const nextMonthStart = computed(() => {
|
||
const d = new Date();
|
||
d.setDate(1);
|
||
d.setMonth(d.getMonth() + 1);
|
||
return d.toISOString().slice(0, 10);
|
||
});
|
||
|
||
const effectiveFrom = ref<string>(nextMonthStart.value);
|
||
|
||
const minEffectiveFrom = computed(() => {
|
||
// Минимум — сегодня (владелец может сменить тариф текущей датой; backend
|
||
// валидирует after_or_equal:today). Прошлое по-прежнему недоступно.
|
||
return new Date().toISOString().slice(0, 10);
|
||
});
|
||
|
||
const hasScheduled = computed(() => Object.keys(scheduled.value).length > 0);
|
||
|
||
async function load(): Promise<void> {
|
||
try {
|
||
const data = await getPricingTiers();
|
||
active.value = data.active;
|
||
scheduled.value = data.scheduled;
|
||
} catch (err) {
|
||
errorMessage.value = extractErrorMessage(err, 'Не удалось загрузить тарифную сетку.');
|
||
}
|
||
}
|
||
|
||
async function submit(): Promise<void> {
|
||
saving.value = true;
|
||
errorMessage.value = null;
|
||
successMessage.value = null;
|
||
try {
|
||
await createPricingTiers(editor.value, effectiveFrom.value);
|
||
editorOpen.value = false;
|
||
successMessage.value = `Сохранено: новая сетка вступит в силу с ${effectiveFrom.value}.`;
|
||
successToastOpen.value = true;
|
||
await load();
|
||
} catch (err) {
|
||
errorMessage.value = extractErrorMessage(err, 'Не удалось сохранить тарифную сетку.');
|
||
// Диалог остаётся открытым — пользователь может исправить и повторить.
|
||
} finally {
|
||
saving.value = false;
|
||
}
|
||
}
|
||
|
||
function openEditor(): void {
|
||
effectiveFrom.value = nextMonthStart.value;
|
||
editorOpen.value = true;
|
||
}
|
||
|
||
function confirmDelete(effectiveFromDate: string): void {
|
||
deleteTarget.value = effectiveFromDate;
|
||
deleteDialogOpen.value = true;
|
||
}
|
||
|
||
async function performDelete(): Promise<void> {
|
||
const effectiveFromDate = deleteTarget.value;
|
||
if (effectiveFromDate === null) return;
|
||
deleteDialogOpen.value = false;
|
||
errorMessage.value = null;
|
||
successMessage.value = null;
|
||
try {
|
||
await deleteScheduledPricingTier(effectiveFromDate);
|
||
successMessage.value = `Удалено: запланированный набор с ${effectiveFromDate}.`;
|
||
successToastOpen.value = true;
|
||
await load();
|
||
} catch (err) {
|
||
errorMessage.value = extractErrorMessage(err, 'Не удалось удалить запланированный набор.');
|
||
} finally {
|
||
deleteTarget.value = null;
|
||
}
|
||
}
|
||
|
||
onMounted(load);
|
||
|
||
defineExpose({
|
||
load,
|
||
submit,
|
||
openEditor,
|
||
confirmDelete,
|
||
performDelete,
|
||
editorOpen,
|
||
active,
|
||
scheduled,
|
||
editor,
|
||
errorMessage,
|
||
successMessage,
|
||
successToastOpen,
|
||
saving,
|
||
effectiveFrom,
|
||
deleteDialogOpen,
|
||
deleteTarget,
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.numeric-tnum :deep(td) {
|
||
font-feature-settings: 'tnum';
|
||
font-family: 'JetBrains Mono', monospace;
|
||
}
|
||
.editor-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
}
|
||
.editor-table th,
|
||
.editor-table td {
|
||
padding: 8px 12px;
|
||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||
}
|
||
</style>
|