Files
portal/app/resources/js/views/admin/AdminPricingTiersView.vue
T
Дмитрий 4bc488e940 fix(admin): AdminPricingTiersView strip ISO-suffix from effective_from caption
Caption "(с 1970-01-01T00:00:00.000000Z)" → "(с 1970-01-01)".
Slice on optional-chain in template; UI smoke verified via Playwright,
Vitest tests/Frontend/AdminPricingTiersView.spec.ts 5/5 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:37:59 +03:00

196 lines
7.2 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-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="editorOpen = true">
Редактировать сетку (с {{ nextMonthStart }})
</v-btn>
<v-dialog v-model="editorOpen" max-width="900">
<v-card>
<v-card-title>Новая сетка (effective_from = {{ nextMonthStart }})</v-card-title>
<v-card-text>
<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>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import axios from 'axios';
/**
* SaaS-admin → Тарифная сетка (Plan 4 Task 9).
*
* Backend: AdminPricingTiersController (GET/POST/DELETE).
* Палитра Forest + JetBrains Mono для tnum-цифр.
*
* defineExpose ниже — для Vitest unit-тестов (`load`/`submit`/`confirmDelete`/
* `editorOpen`/`active`/`scheduled`/`editor`). На прод-сборку это не влияет.
*/
interface Tier {
tier_no: number;
leads_in_tier: number | null;
price_per_lead_kopecks: number;
effective_from: string;
}
interface EditorRow {
tier_no: number;
leads_in_tier: number | null;
price_rub: string;
}
const active = ref<Tier[]>([]);
const scheduled = ref<Record<string, Tier[]>>({});
const editorOpen = ref(false);
const saving = ref(false);
const defaultEditor: EditorRow[] = [
{ 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<EditorRow[]>(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 hasScheduled = computed(() => Object.keys(scheduled.value).length > 0);
async function load(): Promise<void> {
const { data } = await axios.get('/api/admin/pricing-tiers');
active.value = data.data.active;
scheduled.value = data.data.scheduled || {};
}
async function submit(): Promise<void> {
saving.value = true;
try {
await axios.post('/api/admin/pricing-tiers', { tiers: editor.value });
editorOpen.value = false;
await load();
} finally {
saving.value = false;
}
}
async function confirmDelete(effectiveFrom: string): Promise<void> {
if (!window.confirm(`Удалить запланированный набор с ${effectiveFrom}?`)) {
return;
}
await axios.delete(`/api/admin/pricing-tiers/scheduled/${effectiveFrom}`);
await load();
}
onMounted(load);
defineExpose({ load, submit, confirmDelete, editorOpen, active, scheduled, editor });
</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>