Files
portal/app/resources/js/views/admin/AdminPricingTiersView.vue
T
Дмитрий 5f209a2fcc fix(ui): косметика UI-аудита — даты дд.мм.гггг, инлайн-валидация, формат денег, aria, тосты, статус-метки, админка
Раунд 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>
2026-06-21 17:08:51 +03:00

311 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>