4bc488e940
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>
196 lines
7.2 KiB
Vue
196 lines
7.2 KiB
Vue
<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>
|