Files
portal/app/resources/js/views/admin/AdminPricingTiersView.vue
T

297 lines
11 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">
(с {{ active[0]?.effective_from?.slice(0, 10) }})
</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 }">
{{ (item.price_per_lead_kopecks / 100).toFixed(2) }}
</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[]>>({});
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(() => {
const d = new Date();
d.setDate(d.getDate() + 1);
return d.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>