Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec805957f1 | |||
| 995aae4571 | |||
| 8a4ee4c69b | |||
| abdb6e20c4 | |||
| 567e950758 | |||
| 8d9af40637 | |||
| 6d6ce28e54 | |||
| dead7e48c5 | |||
| a231fe25a7 | |||
| 5ba484e0ed | |||
| 68cefaec09 | |||
| 62884559e9 | |||
| 53b7e600c6 | |||
| e8e9b31756 | |||
| 1ac83411c6 | |||
| 933b273c71 | |||
| 86cc297ba0 | |||
| a92c1b6f7f | |||
| 25116a8abb | |||
| 21505163b2 | |||
| 54772e1651 | |||
| 12b2947919 | |||
| 769f250af5 | |||
| fc30d4b1d1 | |||
| a9b40d12f9 | |||
| 8341c1758e | |||
| b66a6f6e7b | |||
| b803c6478b | |||
| 6b0550ff89 | |||
| d3764cb8f2 | |||
| a0a219add4 | |||
| 461bcccf4d | |||
| 3957f3bd39 | |||
| 63eb0a6b84 |
@@ -18,7 +18,7 @@ use Illuminate\Support\Facades\Auth;
|
||||
/**
|
||||
* Самозапись клиента (G1/SP1): register → confirm-email → (вход).
|
||||
* Подтверждение почты 6-значным кодом; новый тенант создаётся в статусе
|
||||
* pending_email_confirm, активируется и получает 300 ₽ при подтверждении.
|
||||
* pending_email_confirm, активируется и получает 1000 ₽ при подтверждении.
|
||||
*/
|
||||
class RegistrationController extends Controller
|
||||
{
|
||||
|
||||
@@ -35,7 +35,7 @@ class RegistrationService
|
||||
|
||||
private const MAX_FAILED_ATTEMPTS = 5;
|
||||
|
||||
private const START_BALANCE_RUB = '300.00';
|
||||
private const START_BALANCE_RUB = '1000.00';
|
||||
|
||||
public function __construct(private readonly CaptchaVerifier $captcha) {}
|
||||
|
||||
|
||||
@@ -146,6 +146,12 @@ return [
|
||||
'widget_url_template' => env('JIVO_WIDGET_URL_TEMPLATE', 'https://code.jivo.ru/widget/{id}'),
|
||||
],
|
||||
|
||||
// Яндекс.Метрика портала (lk.liderra.ru). Пусто = Метрика не грузится.
|
||||
// Вебвизор пишет только кабинет клиента (см. resources/js/plugins/metrika.ts + router).
|
||||
'metrika' => [
|
||||
'counter_id' => env('METRIKA_COUNTER_ID'),
|
||||
],
|
||||
|
||||
// Платёжный шлюз ЮKassa. webhook_ip_allowlist — CSV IP/CIDR из env (defense-in-depth
|
||||
// на /api/webhook/payment). Пусто → fail-open (поток не ломается). На проде заполнить
|
||||
// опубликованными ЮKassa подсетями: 185.71.76.0/27,185.71.77.0/27,77.75.153.0/25,
|
||||
|
||||
@@ -70,4 +70,24 @@ onMounted(() => {
|
||||
color: #0f6e56;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Телефон: три колонки не помещаются — название крошилось по слову.
|
||||
Раскладываем: название на всю ширину, ниже строкой «когда списываем» + цена. */
|
||||
@media (max-width: 599.98px) {
|
||||
.ap-row {
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 12px;
|
||||
}
|
||||
.ap-row__name {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
.ap-row__when {
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
.ap-row__price {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -137,6 +137,11 @@ const runwaySub = computed(() =>
|
||||
.chain-cards {
|
||||
flex-direction: column;
|
||||
}
|
||||
/* В столбце плитки — по высоте контента (иначе flex-basis:0 схлопывал их,
|
||||
а overflow:hidden обрезал цифры/кнопки — «Кошелёк/Лиды/Запас» пустели). */
|
||||
.chain-cards > .wallet-card {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.chain-arrow {
|
||||
transform: rotate(90deg);
|
||||
padding: 8px 0;
|
||||
|
||||
@@ -167,4 +167,34 @@ defineExpose({ load, invoices });
|
||||
gap: 4px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Телефон: 4 колонки (дата | номер | сумма | кнопки) не влезают — при кнопках
|
||||
«Счёт»+«Акт» номер крошился, а кнопки уезжали за край. Раскладываем в ярусы:
|
||||
номер+сумма, дата, кнопки во всю ширину. */
|
||||
@media (max-width: 599.98px) {
|
||||
.inv-row {
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-areas:
|
||||
'name amount'
|
||||
'when when'
|
||||
'actions actions';
|
||||
row-gap: 6px;
|
||||
}
|
||||
.inv-when {
|
||||
grid-area: when;
|
||||
}
|
||||
.inv-name {
|
||||
grid-area: name;
|
||||
}
|
||||
.inv-amount {
|
||||
grid-area: amount;
|
||||
text-align: right;
|
||||
align-self: start;
|
||||
}
|
||||
.inv-actions {
|
||||
grid-area: actions;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
*
|
||||
* Billing v2 Spec A §3.4.8.
|
||||
*/
|
||||
import { tierRangeText } from '../../utils/pricingTiers';
|
||||
|
||||
interface TierPreview {
|
||||
tier_no: number;
|
||||
leads_in_tier: number | null;
|
||||
@@ -18,14 +20,7 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
function rangeText(idx: number): string {
|
||||
let start = 1;
|
||||
for (let i = 0; i < idx; i++) {
|
||||
const cap = props.tiers[i].leads_in_tier;
|
||||
if (cap !== null) start += cap;
|
||||
}
|
||||
const cap = props.tiers[idx].leads_in_tier;
|
||||
if (cap === null) return `${start}+`;
|
||||
return `${start}–${start + cap - 1}`;
|
||||
return tierRangeText(props.tiers, idx);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@ import { ref, computed, watch } from 'vue';
|
||||
import { topup, createInvoice } from '../../api/billing';
|
||||
import { extractErrorMessage, extractValidationErrors } from '../../api/client';
|
||||
import { redirectTo } from '../../utils/redirect';
|
||||
import { useLayoutTier } from '../../composables/useLayoutTier';
|
||||
|
||||
// Телефон: диалог на весь экран (плитки сумм/способы оплаты — шире узкого модала).
|
||||
const { isPhone } = useLayoutTier();
|
||||
|
||||
const model = defineModel<boolean>({ required: true });
|
||||
const emit = defineEmits<{ success: [balanceRub: string]; invoiced: [invoiceNumber: string] }>();
|
||||
@@ -95,7 +99,7 @@ defineExpose({ method, amount, submit, canSubmit, errorMsg });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-dialog v-model="model" max-width="460">
|
||||
<v-dialog v-model="model" max-width="460" :fullscreen="isPhone" scrollable>
|
||||
<v-card>
|
||||
<v-card-title class="text-h6">Пополнить баланс</v-card-title>
|
||||
<v-card-text>
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
* (Все / Пополнения / Списания / Возвраты). Данные — GET
|
||||
* /api/billing/transactions (E3). Паттерн self-fetching из ChargesTab.
|
||||
*/
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { getTransactions, type BillingTransaction } from '../../api/billing';
|
||||
import { formatCost, txAmountClass } from '../../composables/billingFormatters';
|
||||
import ResponsiveTable from '../common/ResponsiveTable.vue';
|
||||
|
||||
interface Tab {
|
||||
id: string;
|
||||
@@ -43,6 +44,7 @@ const OP_LABELS: Record<string, string> = {
|
||||
lead_charge: 'Списание за лид',
|
||||
topup: 'Пополнение баланса',
|
||||
migration: 'Конвертация лидов в ₽',
|
||||
autopodbor_charge: 'Списание за автоподбор',
|
||||
};
|
||||
|
||||
function opLabel(tx: BillingTransaction): string {
|
||||
@@ -100,6 +102,15 @@ async function loadOptions(opts: { page: number }): Promise<void> {
|
||||
await load();
|
||||
}
|
||||
|
||||
// Пейджер для карточек (телефон): страниц по 20, как у таблицы.
|
||||
const PER_PAGE = 20;
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PER_PAGE)));
|
||||
|
||||
async function goToPage(p: number): Promise<void> {
|
||||
page.value = p;
|
||||
await load();
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
page.value = 1;
|
||||
await load();
|
||||
@@ -125,30 +136,65 @@ defineExpose({ load, refresh, changeTab, activeTab, total, rows });
|
||||
{{ loadError }}
|
||||
</v-alert>
|
||||
|
||||
<v-data-table-server
|
||||
:headers="headers"
|
||||
:items="rows"
|
||||
:items-length="total"
|
||||
:loading="loading"
|
||||
:items-per-page="20"
|
||||
density="comfortable"
|
||||
@update:options="loadOptions"
|
||||
>
|
||||
<template #[`item.created_at`]="{ item }">
|
||||
<span class="tx-when num">{{ formatWhen(item.created_at) }}</span>
|
||||
<ResponsiveTable :items="rows">
|
||||
<template #table>
|
||||
<v-data-table-server
|
||||
:headers="headers"
|
||||
:items="rows"
|
||||
:items-length="total"
|
||||
:loading="loading"
|
||||
:items-per-page="20"
|
||||
density="comfortable"
|
||||
@update:options="loadOptions"
|
||||
>
|
||||
<template #[`item.created_at`]="{ item }">
|
||||
<span class="tx-when num">{{ formatWhen(item.created_at) }}</span>
|
||||
</template>
|
||||
<template #[`item.description`]="{ item }">
|
||||
<span class="tx-op">{{ opLabel(item) }}</span>
|
||||
</template>
|
||||
<template #[`item.code`]="{ item }">
|
||||
<span class="tx-id">#{{ item.code }}</span>
|
||||
</template>
|
||||
<template #[`item.amount_rub`]="{ item }">
|
||||
<span class="num" :class="txAmountClass(txAmountValue(item))">
|
||||
{{ txAmountText(item) }}
|
||||
</span>
|
||||
</template>
|
||||
</v-data-table-server>
|
||||
</template>
|
||||
<template #[`item.description`]="{ item }">
|
||||
<span class="tx-op">{{ opLabel(item) }}</span>
|
||||
|
||||
<template #card>
|
||||
<div class="tx-cards">
|
||||
<article v-for="tx in rows" :key="tx.code" class="tx-card">
|
||||
<div class="tx-card__top">
|
||||
<span class="tx-card__op">{{ opLabel(tx) }}</span>
|
||||
<span class="num tx-card__amount" :class="txAmountClass(txAmountValue(tx))">{{
|
||||
txAmountText(tx)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="tx-card__meta">
|
||||
<span class="tx-when num">{{ formatWhen(tx.created_at) }}</span>
|
||||
<span class="tx-id">#{{ tx.code }}</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div v-if="rows.length === 0 && !loading" class="tx-empty text-medium-emphasis">
|
||||
Нет транзакций
|
||||
</div>
|
||||
|
||||
<v-pagination
|
||||
v-if="pageCount > 1"
|
||||
:model-value="page"
|
||||
:length="pageCount"
|
||||
:total-visible="5"
|
||||
density="comfortable"
|
||||
class="tx-pager"
|
||||
@update:model-value="goToPage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #[`item.code`]="{ item }">
|
||||
<span class="tx-id">#{{ item.code }}</span>
|
||||
</template>
|
||||
<template #[`item.amount_rub`]="{ item }">
|
||||
<span class="num" :class="txAmountClass(txAmountValue(item))">
|
||||
{{ txAmountText(item) }}
|
||||
</span>
|
||||
</template>
|
||||
</v-data-table-server>
|
||||
</ResponsiveTable>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
@@ -192,4 +238,48 @@ defineExpose({ load, refresh, changeTab, activeTab, total, rows });
|
||||
.tx-amount-neutral {
|
||||
color: #66635c;
|
||||
}
|
||||
|
||||
/* Карточки транзакций (телефон) */
|
||||
.tx-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px 16px 16px;
|
||||
}
|
||||
.tx-card {
|
||||
border: 1px solid rgba(1, 32, 25, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
background: #fff;
|
||||
}
|
||||
.tx-card__top {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.tx-card__op {
|
||||
font-weight: 500;
|
||||
color: #081319;
|
||||
font-size: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
.tx-card__amount {
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tx-card__meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.tx-empty {
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
.tx-pager {
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Обёртка: десктоп (>=1280) — слот #table (обычный v-data-table),
|
||||
* телефон и планшет (<1280) — слот #card (список карточек по каждому item).
|
||||
* Широкие таблицы (Сделки/Списания) не влезают в узкую колонку планшета-портрета
|
||||
* рядом с rail-меню — правые колонки обрезались, поэтому на планшете тоже карточки.
|
||||
* Данные прокидываются через слот-пропсы, чтобы вызывающий сам рисовал карточку.
|
||||
*/
|
||||
import { useLayoutTier } from '../../composables/useLayoutTier';
|
||||
|
||||
defineProps<{ items: unknown[] }>();
|
||||
const { isDesktop } = useLayoutTier();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<template v-if="isDesktop">
|
||||
<slot name="table" :items="items" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<slot name="card" :items="items" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -109,4 +109,16 @@ const avgCostLabel = computed(() => {
|
||||
font-feature-settings: 'tnum';
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Телефон: переключатель периода — на всю ширину, кнопки поровну (без внутренней прокрутки). */
|
||||
@media (max-width: 599.98px) {
|
||||
.page-head :deep(.v-btn-toggle) {
|
||||
width: 100%;
|
||||
}
|
||||
.page-head :deep(.v-btn-toggle) > .v-btn {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
padding-inline: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,9 +6,14 @@
|
||||
* Тело — общий DealDetailBody.vue.
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
import { useDisplay } from 'vuetify';
|
||||
import type { MockDeal } from '../../composables/mockDeals';
|
||||
import DealDetailBody from './DealDetailBody.vue';
|
||||
|
||||
// Телефон: overlay-панель на весь экран (фикс. 480px был шире экрана).
|
||||
const { width, xs } = useDisplay();
|
||||
const drawerWidth = computed(() => (xs.value ? width.value : 480));
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
open: boolean;
|
||||
@@ -43,7 +48,7 @@ function close() {
|
||||
@status-changed="(s: string) => emit('status-changed', s)"
|
||||
/>
|
||||
</aside>
|
||||
<v-navigation-drawer v-else v-model="drawerOpen" location="right" temporary :width="480" class="deal-drawer">
|
||||
<v-navigation-drawer v-else v-model="drawerOpen" location="right" temporary :width="drawerWidth" class="deal-drawer">
|
||||
<DealDetailBody
|
||||
:deal="deal"
|
||||
:tenant-id="tenantId"
|
||||
@@ -69,4 +74,18 @@ function close() {
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
}
|
||||
/* Телефон: панель деталей — полноэкранный оверлей поверх списка
|
||||
(фикс. 400px рядом со списком давал горизонтальный скролл и крошил карточки). */
|
||||
@media (max-width: 599.98px) {
|
||||
.deal-detail-inline {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
width: 100%;
|
||||
max-height: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -35,11 +35,11 @@ function formatRelative(minutes: number): string {
|
||||
<header class="hero pa-5">
|
||||
<div class="hero-eyebrow text-caption text-medium-emphasis">Сделка #{{ deal.id }}</div>
|
||||
<div class="hero-row mt-1">
|
||||
<h2 class="hero-name text-h5">{{ deal.name }}</h2>
|
||||
<h2 class="hero-name text-h5 ym-hide-content">{{ deal.name }}</h2>
|
||||
<v-btn icon="mdi-close" variant="text" size="small" aria-label="Закрыть панель" @click="$emit('close')" />
|
||||
</div>
|
||||
<div class="hero-meta mt-2">
|
||||
<a :href="`tel:${deal.phone.replace(/[^+\d]/g, '')}`" class="phone-link">{{ deal.phone }}</a>
|
||||
<a :href="`tel:${deal.phone.replace(/[^+\d]/g, '')}`" class="phone-link ym-hide-content">{{ deal.phone }}</a>
|
||||
<span class="sep">·</span>
|
||||
<span class="text-caption text-medium-emphasis">
|
||||
<v-icon size="14" class="mr-1">mdi-clock-outline</v-icon>
|
||||
|
||||
@@ -37,7 +37,7 @@ const hasActiveFilter = () => props.filterStatus !== null || props.filterProject
|
||||
density="compact"
|
||||
hide-details
|
||||
clearable
|
||||
class="filters-search"
|
||||
class="filters-search ym-disable-keys"
|
||||
data-testid="filter-search-phone"
|
||||
@update:model-value="(v: string) => $emit('update:searchPhone', v ?? '')"
|
||||
/>
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { MockDeal } from '../../composables/mockDeals';
|
||||
import type { LeadStatus } from '../../composables/leadStatuses';
|
||||
import { stripChannelPrefix } from '../../composables/projectName';
|
||||
import StatusPill from '../ui/StatusPill.vue';
|
||||
import ResponsiveTable from '../common/ResponsiveTable.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -36,6 +37,20 @@ function sourceSignalLabel(deal: MockDeal): string {
|
||||
return t ? (SIGNAL_LABELS[t] ?? '') : '';
|
||||
}
|
||||
|
||||
// Есть ли у сделки источник автоподбора (для карточки телефона: показывать
|
||||
// отдельную строку «Источник» только когда есть что показать).
|
||||
function hasSource(deal: MockDeal): boolean {
|
||||
return Boolean(deal.sourceIdentifier || deal.sourceProjectId);
|
||||
}
|
||||
|
||||
// «Конкурент» — только бренд, без описания: имя конкурента хранится как
|
||||
// «Бренд, описание…», в колонке показываем часть до первой запятой.
|
||||
function competitorShort(name: string | null | undefined): string {
|
||||
if (!name) return '';
|
||||
const i = name.indexOf(',');
|
||||
return (i === -1 ? name : name.slice(0, i)).trim();
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
@@ -51,10 +66,22 @@ function formatDateTime(iso: string | null | undefined): string {
|
||||
function rowProps(deal: MockDeal): Record<string, unknown> {
|
||||
return { class: deal.id === props.activeDealId ? 'deals-row-active' : '' };
|
||||
}
|
||||
|
||||
// Выбор лида на карточке (телефон) — тот же контракт, что чекбоксы таблицы.
|
||||
function isSelected(id: number): boolean {
|
||||
return props.selectedIds.includes(id);
|
||||
}
|
||||
|
||||
function toggleSelect(id: number): void {
|
||||
const next = isSelected(id) ? props.selectedIds.filter((x) => x !== id) : [...props.selectedIds, id];
|
||||
emit('update:selectedIds', next);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card variant="outlined" class="deals-table-card">
|
||||
<ResponsiveTable :items="deals">
|
||||
<template #table>
|
||||
<v-data-table
|
||||
:model-value="selectedIds"
|
||||
:items="deals"
|
||||
@@ -77,16 +104,17 @@ function rowProps(deal: MockDeal): Record<string, unknown> {
|
||||
@click:row="(_e: Event, { item }: { item: MockDeal }) => emit('row-click', item)"
|
||||
>
|
||||
<template #[`item.phone`]="{ item }: { item: MockDeal }">
|
||||
<span class="num ld-mono">{{ item.phone }}</span>
|
||||
<span class="num ld-mono ym-hide-content">{{ item.phone }}</span>
|
||||
</template>
|
||||
|
||||
<template #[`item.competitor`]="{ item }: { item: MockDeal }">
|
||||
<router-link
|
||||
v-if="item.competitorId"
|
||||
class="cell-link"
|
||||
:title="item.competitorName ?? undefined"
|
||||
:to="{ name: 'autopodbor', query: { competitor: String(item.competitorId) } }"
|
||||
@click.stop
|
||||
>{{ item.competitorName }}</router-link
|
||||
>{{ competitorShort(item.competitorName) }}</router-link
|
||||
>
|
||||
<span v-else class="text-medium-emphasis">—</span>
|
||||
</template>
|
||||
@@ -121,9 +149,11 @@ function rowProps(deal: MockDeal): Record<string, unknown> {
|
||||
</template>
|
||||
|
||||
<template #[`item.comment`]="{ item }: { item: MockDeal }">
|
||||
<span class="cell-comment" :class="{ 'text-medium-emphasis': !item.comment }">{{
|
||||
item.comment || '—'
|
||||
}}</span>
|
||||
<span
|
||||
class="cell-comment ym-hide-content"
|
||||
:class="{ 'text-medium-emphasis': !item.comment }"
|
||||
>{{ item.comment || '—' }}</span
|
||||
>
|
||||
</template>
|
||||
|
||||
<template #[`item.receivedAt`]="{ item }: { item: MockDeal }">
|
||||
@@ -142,11 +172,81 @@ function rowProps(deal: MockDeal): Record<string, unknown> {
|
||||
<template #[`item.data-table-select`]="{ isSelected, toggleSelect, internalItem, item }">
|
||||
<v-checkbox-btn
|
||||
:model-value="isSelected(internalItem)"
|
||||
:aria-label="`Выбрать сделку «${(item as MockDeal).phone}»`"
|
||||
:aria-label="`Выбрать сделку #${(item as MockDeal).id}`"
|
||||
@update:model-value="() => toggleSelect(internalItem)"
|
||||
/>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</template>
|
||||
|
||||
<template #card>
|
||||
<div class="deals-cards">
|
||||
<article
|
||||
v-for="deal in deals"
|
||||
:key="deal.id"
|
||||
class="deal-card"
|
||||
:class="{ 'deal-card--active': deal.id === activeDealId }"
|
||||
@click="emit('row-click', deal)"
|
||||
>
|
||||
<div class="deal-card__top">
|
||||
<v-checkbox-btn
|
||||
:model-value="isSelected(deal.id)"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="deal-card__check"
|
||||
:aria-label="`Выбрать сделку #${deal.id}`"
|
||||
@click.stop
|
||||
@update:model-value="() => toggleSelect(deal.id)"
|
||||
/>
|
||||
<span class="deal-card__phone num ld-mono ym-hide-content">{{ deal.phone }}</span>
|
||||
<StatusPill
|
||||
:slug="deal.statusSlug"
|
||||
:label="statusBySlug.get(deal.statusSlug)?.nameRu ?? deal.statusSlug"
|
||||
/>
|
||||
</div>
|
||||
<div class="deal-card__meta">
|
||||
<router-link
|
||||
v-if="deal.competitorId"
|
||||
class="deal-card__competitor"
|
||||
:title="deal.competitorName ?? undefined"
|
||||
:to="{ name: 'autopodbor', query: { competitor: String(deal.competitorId) } }"
|
||||
@click.stop
|
||||
>{{ competitorShort(deal.competitorName) }}</router-link
|
||||
>
|
||||
<span v-else class="deal-card__project">{{ stripChannelPrefix(deal.project) }}</span>
|
||||
<span v-if="!hasSource(deal) && signalLabel(deal.signalType)" class="deal-card__signal">{{
|
||||
signalLabel(deal.signalType)
|
||||
}}</span>
|
||||
<span class="deal-card__dot">·</span>
|
||||
<span :class="{ 'text-medium-emphasis': !deal.city }">{{ deal.city || '—' }}</span>
|
||||
</div>
|
||||
<div v-if="hasSource(deal)" class="deal-card__source-row">
|
||||
<router-link
|
||||
v-if="deal.sourceProjectId"
|
||||
class="deal-card__source-link"
|
||||
:to="{
|
||||
name: 'autopodbor',
|
||||
query: {
|
||||
competitor: deal.competitorId ? String(deal.competitorId) : undefined,
|
||||
project: String(deal.sourceProjectId),
|
||||
},
|
||||
}"
|
||||
@click.stop
|
||||
>{{ deal.sourceIdentifier
|
||||
}}<span v-if="deal.sourceExtraCount"> · +{{ deal.sourceExtraCount }}</span></router-link
|
||||
>
|
||||
<span v-else class="deal-card__source-plain">{{ deal.sourceIdentifier }}</span>
|
||||
<template v-if="sourceSignalLabel(deal)">
|
||||
<span class="deal-card__dot">·</span>
|
||||
<span class="deal-card__signal">{{ sourceSignalLabel(deal) }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="deal.comment" class="deal-card__comment ym-hide-content">{{ deal.comment }}</div>
|
||||
<div class="deal-card__foot num ld-mono-s">{{ formatDateTime(deal.receivedAt) }}</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
</ResponsiveTable>
|
||||
|
||||
<div v-if="deals.length === 0" class="empty-state pa-8 text-center text-medium-emphasis">
|
||||
Нет сделок по выбранным фильтрам
|
||||
@@ -195,4 +295,100 @@ function rowProps(deal: MockDeal): Record<string, unknown> {
|
||||
:deep(.deals-row-active) {
|
||||
background: rgba(15, 110, 86, 0.07);
|
||||
}
|
||||
|
||||
/* Карточки лидов (телефон) */
|
||||
.deals-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
.deal-card {
|
||||
border: 1px solid rgba(1, 32, 25, 0.1);
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: border-color 150ms ease;
|
||||
}
|
||||
.deal-card:active {
|
||||
border-color: rgba(15, 110, 86, 0.5);
|
||||
}
|
||||
.deal-card--active {
|
||||
border-color: var(--liderra-teal);
|
||||
box-shadow: 0 0 0 1px var(--liderra-teal);
|
||||
}
|
||||
.deal-card__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.deal-card__check {
|
||||
flex: 0 0 auto;
|
||||
margin-left: -6px;
|
||||
}
|
||||
.deal-card__phone {
|
||||
font-weight: 700;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.deal-card__meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
color: rgba(1, 32, 25, 0.6);
|
||||
font-size: 13px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.deal-card__project {
|
||||
font-weight: 500;
|
||||
color: #081319;
|
||||
}
|
||||
/* Конкурент — ссылка в автоподбор (как в колонке «Конкурент» на десктопе). */
|
||||
.deal-card__competitor {
|
||||
font-weight: 600;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
text-decoration: none;
|
||||
}
|
||||
.deal-card__competitor:active {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.deal-card__signal {
|
||||
font-size: 11px;
|
||||
color: #6b6356;
|
||||
}
|
||||
/* Источник — отдельная строка под именем: домен/идентификатор + канал. */
|
||||
.deal-card__source-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.deal-card__source-link {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
.deal-card__source-link:active {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.deal-card__source-plain {
|
||||
color: rgba(1, 32, 25, 0.7);
|
||||
}
|
||||
.deal-card__dot {
|
||||
color: rgba(1, 32, 25, 0.3);
|
||||
}
|
||||
.deal-card__comment {
|
||||
font-size: 13px;
|
||||
margin-top: 6px;
|
||||
color: rgba(1, 32, 25, 0.75);
|
||||
}
|
||||
.deal-card__foot {
|
||||
font-size: 12px;
|
||||
color: rgba(1, 32, 25, 0.5);
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -15,6 +15,10 @@ import { extractErrorMessage } from '../../api/client';
|
||||
import { ref, watch } from 'vue';
|
||||
import { LEAD_STATUSES } from '../../composables/leadStatuses';
|
||||
import { type MockDeal, type MockManager } from '../../composables/mockDeals';
|
||||
import { useLayoutTier } from '../../composables/useLayoutTier';
|
||||
|
||||
// Телефон: диалог на весь экран (узкий модал резал поля).
|
||||
const { isPhone } = useLayoutTier();
|
||||
|
||||
/**
|
||||
* Списки проектов и менеджеров грузятся с backend через GET /api/projects,
|
||||
@@ -184,7 +188,7 @@ function close() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-dialog v-model="dialogOpen" :max-width="560" data-testid="new-deal-dialog">
|
||||
<v-dialog v-model="dialogOpen" :max-width="560" :fullscreen="isPhone" scrollable data-testid="new-deal-dialog">
|
||||
<v-card>
|
||||
<v-card-title>Новая сделка</v-card-title>
|
||||
<v-card-text>
|
||||
@@ -216,6 +220,7 @@ function close() {
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
autofocus
|
||||
class="ym-disable-keys"
|
||||
:error-messages="errors.name ? [errors.name] : []"
|
||||
data-testid="field-name"
|
||||
/>
|
||||
@@ -228,6 +233,7 @@ function close() {
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
inputmode="tel"
|
||||
class="ym-disable-keys"
|
||||
:error-messages="errors.phone ? [errors.phone] : []"
|
||||
data-testid="field-phone"
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
*/
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { resolveUnknownStatuses, type StatusMapping, type UnknownStatus } from '../../api/imports';
|
||||
import { useLayoutTier } from '../../composables/useLayoutTier';
|
||||
|
||||
// Телефон: на весь экран (таблица сопоставления статусов широкая).
|
||||
const { isPhone } = useLayoutTier();
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
@@ -62,7 +66,7 @@ defineExpose({ selection, save });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-dialog v-model="dialogOpen" max-width="640">
|
||||
<v-dialog v-model="dialogOpen" max-width="640" :fullscreen="isPhone" scrollable>
|
||||
<v-card>
|
||||
<v-card-title class="text-h6">Маппинг неизвестных статусов</v-card-title>
|
||||
<v-card-text>
|
||||
|
||||
@@ -25,8 +25,8 @@ function formatCost(cost: number): string {
|
||||
density="compact"
|
||||
@click="emit('open', deal.id)"
|
||||
>
|
||||
<div class="card-name">{{ deal.name }}</div>
|
||||
<div class="card-phone text-caption text-medium-emphasis">{{ deal.phone }}</div>
|
||||
<div class="card-name ym-hide-content">{{ deal.name }}</div>
|
||||
<div class="card-phone text-caption text-medium-emphasis ym-hide-content">{{ deal.phone }}</div>
|
||||
<div class="card-meta mt-2">
|
||||
<span class="card-project text-caption">{{ stripChannelPrefix(deal.project) }}</span>
|
||||
<span class="card-cost num">{{ deal.costKopecks != null ? formatCost(deal.costKopecks / 100) : '—' }}</span>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
/** Нижняя панель-вкладки для телефона. 5 главных разделов; остальное — в «Ещё». */
|
||||
import { RouterLink, useRoute } from 'vue-router';
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface Tab { title: string; to: string; }
|
||||
const tabs: Tab[] = [
|
||||
{ title: 'Поле', to: '/autopodbor' },
|
||||
{ title: 'Проекты', to: '/projects' },
|
||||
{ title: 'Сделки', to: '/deals' },
|
||||
{ title: 'Биллинг', to: '/billing' },
|
||||
{ title: 'Канбан', to: '/kanban' },
|
||||
];
|
||||
const route = useRoute();
|
||||
const activeTo = computed(() => tabs.find((t) => route.path === t.to)?.to ?? '');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="ld-bottomnav" aria-label="Основная навигация">
|
||||
<RouterLink
|
||||
v-for="t in tabs"
|
||||
:key="t.to"
|
||||
:to="t.to"
|
||||
data-testid="bottomnav-item"
|
||||
class="ld-bottomnav__item"
|
||||
:class="{ 'ld-bottomnav__item--active': activeTo === t.to }"
|
||||
>
|
||||
<span class="ld-bottomnav__label">{{ t.title }}</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ld-bottomnav {
|
||||
position: fixed;
|
||||
left: 0; right: 0; bottom: 0;
|
||||
z-index: 1010;
|
||||
display: flex;
|
||||
height: 64px;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
background: #ffffff;
|
||||
border-top: 1px solid rgba(1, 32, 25, 0.1);
|
||||
}
|
||||
.ld-bottomnav__item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
min-height: 56px;
|
||||
text-decoration: none;
|
||||
color: rgba(1, 32, 25, 0.55);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ld-bottomnav__item--active { color: var(--liderra-teal); }
|
||||
.ld-bottomnav__label { line-height: 1; }
|
||||
</style>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Выезжающее «Ещё» для телефона: разделы вне 5 нижних вкладок.
|
||||
* Ни один экран не выкидываем — Настройки (реквизиты), Отчёты, Импорт, Помощь, Дашборд
|
||||
* доступны отсюда. Полнота функций — на десктопе; на телефоне вкладка работает, но урезана.
|
||||
*/
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
|
||||
interface Item {
|
||||
title: string;
|
||||
to: string;
|
||||
}
|
||||
const items: Item[] = [
|
||||
{ title: 'Дашборд', to: '/dashboard' },
|
||||
{ title: 'Отчёты', to: '/reports' },
|
||||
{ title: 'Настройки', to: '/settings' },
|
||||
{ title: 'Импорт данных', to: '/import' },
|
||||
{ title: 'Помощь', to: '/help' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-navigation-drawer v-model="open" temporary location="right" width="280">
|
||||
<v-list nav>
|
||||
<v-list-item
|
||||
v-for="i in items"
|
||||
:key="i.to"
|
||||
:to="i.to"
|
||||
:title="i.title"
|
||||
data-testid="more-item"
|
||||
@click="open = false"
|
||||
/>
|
||||
</v-list>
|
||||
</v-navigation-drawer>
|
||||
</template>
|
||||
@@ -30,6 +30,9 @@ interface NavGroup {
|
||||
|
||||
const drawerOpen = defineModel<boolean>('drawerOpen', { default: true });
|
||||
|
||||
// Ярус экрана из AppLayout: на телефоне сайдбар скрыт (навигацию берёт нижняя панель-вкладки).
|
||||
defineProps<{ tier?: 'phone' | 'tablet' | 'desktop' }>();
|
||||
|
||||
const route = useRoute();
|
||||
const auth = useAuthStore();
|
||||
const dealsCount = useDealsCountStore();
|
||||
@@ -81,7 +84,7 @@ defineExpose({ navGroups });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="ld-sidebar" :data-open="drawerOpen">
|
||||
<aside class="ld-sidebar" :data-open="drawerOpen" :data-tier="tier">
|
||||
<div class="ld-sidebar__brand">
|
||||
<span class="ld-sidebar__brand-name">Лидерра<span class="ld-sidebar__brand-dot">.</span></span>
|
||||
</div>
|
||||
@@ -110,6 +113,7 @@ defineExpose({ navGroups });
|
||||
:class="{ 'ld-nav-item--active': route.path === item.to }"
|
||||
:data-tour="`nav-${item.to.replace('/', '')}`"
|
||||
>
|
||||
<span class="ld-nav-item__icon"><v-icon :icon="item.icon" size="20" /></span>
|
||||
<span class="ld-nav-item__title">{{ item.title }}</span>
|
||||
<span v-if="item.badge" class="ld-nav-item__new">{{ item.badge }}</span>
|
||||
<span
|
||||
@@ -140,6 +144,44 @@ defineExpose({ navGroups });
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Телефон: сайдбара нет — навигацию берёт нижняя панель-вкладки (AppBottomNav). */
|
||||
.ld-sidebar[data-tier='phone'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Иконка пункта — по умолчанию скрыта (десктоп остаётся текстовым, как был). */
|
||||
.ld-nav-item__icon {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Планшет: узкая колонка-иконки (rail). Прячем подписи/поиск/бренд-текст, показываем иконки. */
|
||||
.ld-sidebar[data-tier='tablet'] {
|
||||
width: 64px;
|
||||
padding: 20px 8px;
|
||||
}
|
||||
.ld-sidebar[data-tier='tablet'] .ld-sidebar__brand-name,
|
||||
.ld-sidebar[data-tier='tablet'] .ld-cmdk-stub,
|
||||
.ld-sidebar[data-tier='tablet'] .ld-nav-group__eyebrow,
|
||||
.ld-sidebar[data-tier='tablet'] .ld-nav-item__title,
|
||||
.ld-sidebar[data-tier='tablet'] .ld-nav-item__badge,
|
||||
.ld-sidebar[data-tier='tablet'] .ld-nav-item__new {
|
||||
display: none;
|
||||
}
|
||||
.ld-sidebar[data-tier='tablet'] .ld-nav-item__icon {
|
||||
display: inline-flex;
|
||||
}
|
||||
.ld-sidebar[data-tier='tablet'] .ld-nav-item {
|
||||
justify-content: center;
|
||||
padding: 10px 0;
|
||||
gap: 0;
|
||||
}
|
||||
.ld-sidebar[data-tier='tablet'] .ld-sidebar__brand {
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.ld-sidebar__brand {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -89,7 +89,7 @@ async function handleLogout(): Promise<void> {
|
||||
|
||||
<template>
|
||||
<v-app-bar :elevation="0" color="surface" class="app-topbar" :height="56">
|
||||
<v-app-bar-nav-icon class="d-md-none" aria-label="Открыть меню навигации" @click="emit('toggle-drawer')" />
|
||||
<v-app-bar-nav-icon class="d-sm-none" aria-label="Открыть меню навигации" @click="emit('toggle-drawer')" />
|
||||
|
||||
<div class="crumb">
|
||||
<strong>{{ pageTitle }}</strong>
|
||||
@@ -203,9 +203,21 @@ async function handleLogout(): Promise<void> {
|
||||
color: #e8e2d4 !important;
|
||||
}
|
||||
.app-topbar :deep(.v-toolbar__content) {
|
||||
padding-left: 240px;
|
||||
padding-left: 240px; /* десктоп — clear под сайдбар 232px */
|
||||
color: #e8e2d4;
|
||||
}
|
||||
/* Планшет — clear под rail 64px */
|
||||
@media (max-width: 1279.98px) {
|
||||
.app-topbar :deep(.v-toolbar__content) {
|
||||
padding-left: 72px;
|
||||
}
|
||||
}
|
||||
/* Телефон — гамбургер слева, без отступа под сайдбар */
|
||||
@media (max-width: 599.98px) {
|
||||
.app-topbar :deep(.v-toolbar__content) {
|
||||
padding-left: 4px;
|
||||
}
|
||||
}
|
||||
.app-topbar :deep(.v-icon) {
|
||||
color: #b8b0a0;
|
||||
}
|
||||
@@ -226,6 +238,12 @@ async function handleLogout(): Promise<void> {
|
||||
color: #b8b0a0 !important;
|
||||
border-color: rgba(255, 255, 255, 0.12) !important;
|
||||
}
|
||||
/* Телефон: длинный заголовок вплотную к «Поиску» — небольшой зазор слева. */
|
||||
@media (max-width: 599.98px) {
|
||||
.searchbar {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
.search-kbd {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 10px;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<v-dialog v-model="open" max-width="560">
|
||||
<v-dialog v-model="open" max-width="560" :fullscreen="isPhone" scrollable>
|
||||
<v-card>
|
||||
<v-card-title>Дни сбора лидов — для {{ count }} проектов</v-card-title>
|
||||
<v-card-text>
|
||||
<div class="mb-4">
|
||||
<div class="text-caption text-success font-weight-medium mb-2">➕ Добавить дни</div>
|
||||
<div class="d-flex ga-2">
|
||||
<div class="d-flex ga-2 day-row">
|
||||
<v-btn
|
||||
v-for="d in WEEKDAYS"
|
||||
:key="`add-${d.bit}`"
|
||||
@@ -20,7 +20,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-caption text-error font-weight-medium mb-2">➖ Убрать дни</div>
|
||||
<div class="d-flex ga-2">
|
||||
<div class="d-flex ga-2 day-row">
|
||||
<v-btn
|
||||
v-for="d in WEEKDAYS"
|
||||
:key="`remove-${d.bit}`"
|
||||
@@ -48,6 +48,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { WEEKDAYS } from '../../constants/weekdays';
|
||||
import { useLayoutTier } from '../../composables/useLayoutTier';
|
||||
|
||||
// Телефон: на весь экран (ряд кнопок дней недели не влезал в узкий модал).
|
||||
const { isPhone } = useLayoutTier();
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; count: number }>();
|
||||
const emit = defineEmits<{
|
||||
@@ -91,3 +95,18 @@ function apply() {
|
||||
open.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Телефон: 7 кнопок дней (ПН…ВС) в d-flex не влезали — СБ/ВС уезжали за край.
|
||||
Делим ряд поровну между кнопками, чтобы все семь помещались в ширину. */
|
||||
@media (max-width: 599.98px) {
|
||||
.day-row {
|
||||
width: 100%;
|
||||
}
|
||||
.day-row > .v-btn {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
padding-inline: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-dialog v-model="open" max-width="480">
|
||||
<v-dialog v-model="open" max-width="480" :fullscreen="isPhone" scrollable>
|
||||
<v-card>
|
||||
<v-card-title>Лимит лидов — для {{ count }} проектов</v-card-title>
|
||||
<v-card-text>
|
||||
@@ -57,6 +57,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useLayoutTier } from '../../composables/useLayoutTier';
|
||||
|
||||
// Телефон: на весь экран (единообразно с «Дни/Регионы оптом»).
|
||||
const { isPhone } = useLayoutTier();
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; count: number }>();
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-dialog v-model="open" max-width="560">
|
||||
<v-dialog v-model="open" max-width="560" :fullscreen="isPhone" scrollable>
|
||||
<v-card>
|
||||
<v-card-title>Регионы — для {{ count }} проектов</v-card-title>
|
||||
<v-card-text>
|
||||
@@ -76,6 +76,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { REGIONS_ALPHA, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
|
||||
import { useLayoutTier } from '../../composables/useLayoutTier';
|
||||
|
||||
// Телефон: на весь экран (сетка регионов шире узкого модала).
|
||||
const { isPhone } = useLayoutTier();
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; count: number }>();
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { computed, type ComputedRef } from 'vue';
|
||||
import { useDisplay } from 'vuetify';
|
||||
|
||||
export type LayoutTier = 'phone' | 'tablet' | 'desktop';
|
||||
|
||||
/**
|
||||
* Единый источник «яруса» экрана для адаптива портала.
|
||||
* phone < 600px (нижнее меню-вкладки)
|
||||
* tablet 600..1279px (узкая колонка-иконки / rail)
|
||||
* desktop >= 1280px (полный сайдбар 232px, как сейчас)
|
||||
*/
|
||||
export function useLayoutTier(): {
|
||||
tier: ComputedRef<LayoutTier>;
|
||||
isPhone: ComputedRef<boolean>;
|
||||
isTablet: ComputedRef<boolean>;
|
||||
isDesktop: ComputedRef<boolean>;
|
||||
} {
|
||||
const display = useDisplay();
|
||||
const tier = computed<LayoutTier>(() => {
|
||||
const w = display.width.value;
|
||||
if (w < 600) return 'phone';
|
||||
if (w < 1280) return 'tablet';
|
||||
return 'desktop';
|
||||
});
|
||||
return {
|
||||
tier,
|
||||
isPhone: computed(() => tier.value === 'phone'),
|
||||
isTablet: computed(() => tier.value === 'tablet'),
|
||||
isDesktop: computed(() => tier.value === 'desktop'),
|
||||
};
|
||||
}
|
||||
@@ -104,6 +104,7 @@ export const LEGAL_DOCS: Record<'offer' | 'privacy' | 'refund', LegalDoc> = {
|
||||
heading: '5. Хранение и защита',
|
||||
paragraphs: [
|
||||
'Персональные данные хранятся на территории Российской Федерации. Применяются разграничение доступа, шифрование, маскирование данных в выгрузках и журналирование действий.',
|
||||
'Для улучшения сервиса используется Яндекс.Метрика, включая запись пользовательских сессий (Вебвизор). Персональные данные контактов (телефоны, имена) в таких записях маскируются и не передаются. Обработка выполняется на серверах в Российской Федерации.',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -18,6 +18,9 @@ import { usePolling } from '../composables/usePolling';
|
||||
import { POLLING_INTERVAL_MS, POLLING_REMINDERS_INTERVAL_MS } from '../constants/polling';
|
||||
import AppSidebar from '../components/layout/AppSidebar.vue';
|
||||
import AppTopbar from '../components/layout/AppTopbar.vue';
|
||||
import AppBottomNav from '../components/layout/AppBottomNav.vue';
|
||||
import AppMoreDrawer from '../components/layout/AppMoreDrawer.vue';
|
||||
import { useLayoutTier } from '../composables/useLayoutTier';
|
||||
import DevIndexBadge from '../components/DevIndexBadge.vue';
|
||||
import CommandPalette from '../components/layout/CommandPalette.vue';
|
||||
import JivoWidget from '../components/support/JivoWidget.vue';
|
||||
@@ -30,7 +33,10 @@ const notifications = useNotificationsStore();
|
||||
const tenant = useTenantStore();
|
||||
const route = useRoute();
|
||||
|
||||
const { tier } = useLayoutTier();
|
||||
const drawerOpen = ref(true);
|
||||
// «Ещё» на телефоне (разделы вне 5 нижних вкладок)
|
||||
const moreOpen = ref(false);
|
||||
|
||||
// Тот же навигационный pool что в AppSidebar — для crumb-resolution в topbar
|
||||
// (sidebar и topbar — независимые, но navGroups совпадают по контракту).
|
||||
@@ -73,10 +79,13 @@ usePolling(loadBalanceStatus, { intervalMs: POLLING_REMINDERS_INTERVAL_MS, enabl
|
||||
<template>
|
||||
<v-app>
|
||||
<ImpersonationSessionBanner />
|
||||
<AppSidebar v-model:drawer-open="drawerOpen" />
|
||||
<AppTopbar :page-title="currentPageTitle" @toggle-drawer="drawerOpen = !drawerOpen" />
|
||||
<AppSidebar v-model:drawer-open="drawerOpen" :tier="tier" />
|
||||
<AppTopbar
|
||||
:page-title="currentPageTitle"
|
||||
@toggle-drawer="tier === 'phone' ? (moreOpen = !moreOpen) : (drawerOpen = !drawerOpen)"
|
||||
/>
|
||||
|
||||
<v-main class="app-main">
|
||||
<v-main class="app-main" :data-tier="tier">
|
||||
<BalanceFrozenBanner
|
||||
:frozen="tenant.frozen"
|
||||
:deficit-rub="tenant.deficitRub"
|
||||
@@ -92,12 +101,28 @@ usePolling(loadBalanceStatus, { intervalMs: POLLING_REMINDERS_INTERVAL_MS, enabl
|
||||
<CommandPalette />
|
||||
<JivoWidget />
|
||||
<WelcomeTour />
|
||||
|
||||
<!-- Мобильная навигация: нижние вкладки + «Ещё» (только телефон) -->
|
||||
<AppBottomNav v-if="tier === 'phone'" />
|
||||
<AppMoreDrawer v-if="tier === 'phone'" v-model:open="moreOpen" />
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-main {
|
||||
background: #f6f3ec;
|
||||
}
|
||||
/* Отступ под навигацию по ярусу.
|
||||
Десктоп — полный сайдбар 232px; планшет — узкая колонка-иконки (rail) 64px;
|
||||
телефон — сайдбара нет, снизу панель-вкладки (запас под неё padding-bottom). */
|
||||
.app-main[data-tier='desktop'] {
|
||||
padding-left: 232px;
|
||||
}
|
||||
.app-main[data-tier='tablet'] {
|
||||
padding-left: 64px;
|
||||
}
|
||||
.app-main[data-tier='phone'] {
|
||||
padding-left: 0;
|
||||
padding-bottom: 64px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Ленивый загрузчик Яндекс.Метрики для портала.
|
||||
* Грузится ТОЛЬКО из роутера при входе в кабинет (meta.layout==='app') — см. router/index.ts.
|
||||
* id счётчика читается из <meta name="metrika-id"> (проставляется welcome.blade.php по config).
|
||||
* Вебвизор пишет с маскировкой ПДн (классы ym-hide-content в компонентах кабинета).
|
||||
*/
|
||||
declare global {
|
||||
interface Window {
|
||||
ym?: (...args: unknown[]) => void;
|
||||
}
|
||||
}
|
||||
|
||||
let loaded = false;
|
||||
|
||||
function counterId(): number | null {
|
||||
const meta = document.querySelector('meta[name="metrika-id"]');
|
||||
const raw = meta?.getAttribute('content');
|
||||
const id = raw ? Number(raw) : NaN;
|
||||
return Number.isFinite(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
/** Один раз инжектит tag.js и инициализирует Вебвизор. Идемпотентно. No-op без id. */
|
||||
export function ensureLoaded(): void {
|
||||
if (loaded) return;
|
||||
const id = counterId();
|
||||
if (id === null) return;
|
||||
loaded = true;
|
||||
|
||||
// стандартный bootstrap-стаб ym до загрузки tag.js
|
||||
const w = window as Window & { ym?: { (...a: unknown[]): void; a?: unknown[]; l?: number } };
|
||||
w.ym =
|
||||
w.ym ||
|
||||
function (...args: unknown[]) {
|
||||
(w.ym as { a?: unknown[] }).a = (w.ym as { a?: unknown[] }).a || [];
|
||||
(w.ym as { a: unknown[] }).a.push(args);
|
||||
};
|
||||
(w.ym as { l?: number }).l = Number(new Date());
|
||||
|
||||
const s = document.createElement('script');
|
||||
s.async = true;
|
||||
s.src = `https://mc.yandex.ru/metrika/tag.js?id=${id}`;
|
||||
document.head.appendChild(s);
|
||||
|
||||
// defer:true — без авто-первого-хита; первый хит шлёт роутер вручную
|
||||
w.ym(id, 'init', {
|
||||
webvisor: true,
|
||||
clickmap: true,
|
||||
trackLinks: true,
|
||||
accurateTrackBounce: true,
|
||||
defer: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Отправить заход (SPA-переход). No-op без id/ym. */
|
||||
export function hit(url: string): void {
|
||||
const id = counterId();
|
||||
if (id === null || typeof window.ym !== 'function') return;
|
||||
window.ym(id, 'hit', url);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { ensureLoaded, hit } from '../plugins/metrika';
|
||||
|
||||
/**
|
||||
* Vue Router (фаза 2). История — `createWebHistory` (HTML5 history API);
|
||||
@@ -413,3 +414,13 @@ router.beforeEach(async (to) => {
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Метрика/Вебвизор — только в кабинете клиента (layout==='app').
|
||||
// Загружаем лениво при первом входе, затем шлём hit на каждом переходе внутри кабинета.
|
||||
// Вход (auth), публичные (public), админка (admin), портал продаж (salesAuth) — БЕЗ Метрики.
|
||||
router.afterEach((to) => {
|
||||
if (to.meta.layout === 'app') {
|
||||
ensureLoaded();
|
||||
hit(to.fullPath);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Единый источник истины для показа объёмных ступеней тарифа.
|
||||
*
|
||||
* В БД у каждой ступени хранится ШИРИНА (`leads_in_tier`) — сколько лидов
|
||||
* помещается именно в эту ступень, а НЕ «до какого лида она действует».
|
||||
* Живой расчёт списаний (PricingTierResolver / BalanceToLeadsConverter)
|
||||
* накапливает эти ширины: tier 1 = 1..w1, tier 2 = w1+1..w1+w2, и т.д.
|
||||
*
|
||||
* Эта функция переводит ширину в тот накопительный диапазон, который видит
|
||||
* клиент в кабинете. Держим её в ОДНОМ месте, чтобы админка и кабинет никогда
|
||||
* не разошлись в цифрах.
|
||||
*/
|
||||
export interface TierWidth {
|
||||
leads_in_tier: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Диапазон номеров лидов для ступени с индексом `idx` (0-based) в массиве
|
||||
* ступеней, отсортированном по возрастанию tier_no.
|
||||
*
|
||||
* Пример (ширины 250/500/…): idx=0 → «1–250», idx=1 → «251–750».
|
||||
* Последняя ступень (`leads_in_tier === null`) → «19251+».
|
||||
*/
|
||||
export function tierRangeText(tiers: ReadonlyArray<TierWidth>, idx: number): string {
|
||||
let start = 1;
|
||||
for (let i = 0; i < idx; i++) {
|
||||
const cap = tiers[i].leads_in_tier;
|
||||
if (cap !== null) start += cap;
|
||||
}
|
||||
const cap = tiers[idx].leads_in_tier;
|
||||
if (cap === null) return `${start}+`;
|
||||
return `${start}–${start + cap - 1}`;
|
||||
}
|
||||
@@ -32,11 +32,18 @@ import DealDetailDrawer from '../components/deals/DealDetailDrawer.vue';
|
||||
import NewDealDialog from '../components/deals/NewDealDialog.vue';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useLeadStatusesStore } from '../stores/leadStatuses';
|
||||
import { useLayoutTier } from '../composables/useLayoutTier';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const leadStatusesStore = useLeadStatusesStore();
|
||||
const leadStatuses = computed(() => leadStatusesStore.statuses);
|
||||
|
||||
// Телефон: одна колонка + переключатель этапов (drag вбок неудобен на узком экране;
|
||||
// смена этапа сделки — тапом по карточке через drawer).
|
||||
const { isPhone } = useLayoutTier();
|
||||
const activeSlug = ref<string>(LEAD_STATUSES[0]?.slug ?? '');
|
||||
const activeStatus = computed(() => leadStatuses.value.find((s) => s.slug === activeSlug.value) ?? leadStatuses.value[0] ?? null);
|
||||
|
||||
interface DraggableChangeEvent {
|
||||
added?: { element: MockDeal; newIndex: number };
|
||||
removed?: { element: MockDeal; oldIndex: number };
|
||||
@@ -222,7 +229,9 @@ defineExpose({
|
||||
><span class="num">{{ totalDeals }}</span> сделок</span
|
||||
>
|
||||
<span class="sep">·</span>
|
||||
<span class="text-caption text-medium-emphasis"> Перетаскивание между колонками </span>
|
||||
<span class="text-caption text-medium-emphasis">{{
|
||||
isPhone ? 'Выберите этап' : 'Перетаскивание между колонками'
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex ga-2">
|
||||
@@ -244,7 +253,37 @@ defineExpose({
|
||||
Не удалось загрузить сделки. Попробуйте обновить.
|
||||
</v-alert>
|
||||
|
||||
<div class="kanban-board mt-4" tabindex="0" role="region" aria-label="Канбан-доска воронки продаж">
|
||||
<!-- Телефон: переключатель этапов + одна колонка на всю ширину -->
|
||||
<div v-if="isPhone" class="kanban-mobile mt-4">
|
||||
<div class="stage-switcher" role="tablist" aria-label="Этапы воронки">
|
||||
<button
|
||||
v-for="status in leadStatuses"
|
||||
:key="status.slug"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="status.slug === activeSlug"
|
||||
class="stage-chip"
|
||||
:class="{ 'stage-chip--active': status.slug === activeSlug }"
|
||||
:style="{ '--accent': status.colorHex }"
|
||||
@click="activeSlug = status.slug"
|
||||
>
|
||||
<span class="stage-chip__name">{{ status.nameRu }}</span>
|
||||
<span class="stage-chip__count">{{ (dealsByStatus[status.slug] || []).length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<KanbanColumn
|
||||
v-if="activeStatus"
|
||||
:key="activeStatus.slug"
|
||||
:status="activeStatus"
|
||||
:deals="dealsByStatus[activeSlug] || []"
|
||||
@update:deals="(list) => (dealsByStatus[activeSlug] = list)"
|
||||
@change="(e) => onColumnChange(activeSlug as MockDeal['statusSlug'], e)"
|
||||
@open-deal="onOpenDeal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Планшет/десктоп: все колонки с горизонтальной прокруткой -->
|
||||
<div v-else class="kanban-board mt-4" tabindex="0" role="region" aria-label="Канбан-доска воронки продаж">
|
||||
<KanbanColumn
|
||||
v-for="status in leadStatuses"
|
||||
:key="status.slug"
|
||||
@@ -325,6 +364,66 @@ defineExpose({
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Телефон: переключатель этапов (чипсы) + одна колонка на всю ширину */
|
||||
.kanban-mobile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.stage-switcher {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.stage-chip {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #d9d5cd;
|
||||
background: #fff;
|
||||
color: #3a3a33;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
border-color 150ms ease,
|
||||
background 150ms ease;
|
||||
}
|
||||
.stage-chip__count {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-feature-settings: 'tnum';
|
||||
font-size: 12px;
|
||||
color: #66635c;
|
||||
background: #f0ede4;
|
||||
padding: 0 7px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.stage-chip--active {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, #fff);
|
||||
color: #081319;
|
||||
box-shadow: inset 0 -2px 0 var(--accent);
|
||||
}
|
||||
.stage-chip--active .stage-chip__count {
|
||||
background: #fff;
|
||||
}
|
||||
/* Колонка на телефоне — во всю ширину (перебиваем фикс 280px из KanbanColumn) */
|
||||
.kanban-mobile :deep(.kanban-column) {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
max-height: none;
|
||||
}
|
||||
.kanban-mobile :deep(.column-body) {
|
||||
max-height: calc(100vh - 340px);
|
||||
}
|
||||
|
||||
.kanban-board::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<v-container fluid class="projects-view" :class="{ 'has-drawer': singleSelectedProject !== null }">
|
||||
<div class="d-flex justify-space-between align-center mb-4">
|
||||
<div class="d-flex justify-space-between align-center mb-4 ga-3 projects-head">
|
||||
<h1 class="text-h4">Проекты</h1>
|
||||
<v-btn color="primary" prepend-icon="mdi-plus" @click="openCreate">Создать проект</v-btn>
|
||||
</div>
|
||||
@@ -29,7 +29,7 @@
|
||||
</div>
|
||||
</v-alert>
|
||||
|
||||
<div v-if="!isEmptyAccount" class="d-flex flex-wrap ga-3 mb-4">
|
||||
<div v-if="!isEmptyAccount" class="d-flex flex-wrap ga-3 mb-4 filters-bar">
|
||||
<v-select
|
||||
v-model="store.filters.signal_type"
|
||||
:items="typeFilters"
|
||||
@@ -372,6 +372,25 @@ onUnmounted(() => store.stopPolling());
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
/* Телефон: фильтры — во всю ширину в столбик (перебиваем inline max-width),
|
||||
чтобы подписи не обрезались («Р…», «Д…»). */
|
||||
@media (max-width: 599.98px) {
|
||||
.filters-bar > * {
|
||||
max-width: none !important;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
/* Телефон: заголовок «Проекты» и кнопка «Создать проект» жались вплотную —
|
||||
ставим кнопку под заголовок во всю ширину, с воздухом между ними. */
|
||||
.projects-head {
|
||||
flex-direction: column;
|
||||
}
|
||||
/* align-self перебивает utility-класс .align-center (у него !important на
|
||||
align-items) — заголовок и кнопка прижаты к левому краю, как на десктопе. */
|
||||
.projects-head > h1,
|
||||
.projects-head > .v-btn {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
.toolbar-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
</span>
|
||||
</v-card-title>
|
||||
<v-data-table
|
||||
:headers="tierHeaders"
|
||||
:headers="activeHeaders"
|
||||
:items="active"
|
||||
:items-per-page="7"
|
||||
density="comfortable"
|
||||
@@ -33,10 +33,17 @@
|
||||
<span v-if="item.leads_in_tier !== null">{{ item.leads_in_tier }}</span>
|
||||
<span v-else class="text-medium-emphasis">все свыше</span>
|
||||
</template>
|
||||
<template #[`item.client_range`]="{ item }">
|
||||
<span :data-testid="`active-range-${item.tier_no}`">{{ activeRangeText(item) }} лидов</span>
|
||||
</template>
|
||||
<template #[`item.price_rub`]="{ item }">
|
||||
{{ fmtRub(item.price_per_lead_kopecks) }}
|
||||
</template>
|
||||
</v-data-table>
|
||||
<div class="text-caption text-medium-emphasis px-4 pb-3">
|
||||
«Лидов в ступени» — сколько заявок помещается именно в эту ступень (ширина). «Диапазон» —
|
||||
как эти ступени видит клиент в кабинете: с какого по какой лид действует цена.
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<v-card v-if="hasScheduled" class="mb-6" elevation="1">
|
||||
@@ -68,11 +75,16 @@
|
||||
style="max-width: 240px"
|
||||
data-testid="effective-from-input"
|
||||
/>
|
||||
<p class="text-caption text-medium-emphasis mb-2">
|
||||
В поле «Лидов в ступени» вводится <strong>ширина</strong> ступени (сколько заявок в неё
|
||||
помещается). Справа сразу видно, <strong>что увидит клиент</strong> — накопительный диапазон.
|
||||
</p>
|
||||
<table class="editor-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ступень</th>
|
||||
<th>Лидов в ступени</th>
|
||||
<th>Клиент увидит</th>
|
||||
<th>Цена за лид (₽)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -90,6 +102,9 @@
|
||||
/>
|
||||
<span v-else class="text-medium-emphasis">все свыше</span>
|
||||
</td>
|
||||
<td :data-testid="`editor-range-${t.tier_no}`" class="editor-range num">
|
||||
{{ editorRangeText(t) }} лидов
|
||||
</td>
|
||||
<td>
|
||||
<v-text-field
|
||||
v-model="t.price_rub"
|
||||
@@ -148,6 +163,7 @@ import {
|
||||
type PricingTierEditorRow,
|
||||
} from '../../api/admin';
|
||||
import { extractErrorMessage } from '../../api/client';
|
||||
import { tierRangeText } from '../../utils/pricingTiers';
|
||||
|
||||
/**
|
||||
* SaaS-admin → Тарифная сетка (Plan 4 Task 9, Sprint 1 G1 error handling).
|
||||
@@ -198,6 +214,28 @@ const tierHeaders = [
|
||||
{ title: 'Цена за лид', key: 'price_rub', sortable: false },
|
||||
];
|
||||
|
||||
// Активная сетка показывает ещё и накопительный диапазон — ровно то, что видит
|
||||
// клиент в кабинете (устраняет путаницу «в админке одни числа, у клиента другие»).
|
||||
const activeHeaders = [
|
||||
{ title: '№', key: 'tier_no', sortable: false, width: 80 },
|
||||
{ title: 'Лидов в ступени', key: 'leads_in_tier', sortable: false },
|
||||
{ title: 'Диапазон (у клиента)', key: 'client_range', sortable: false },
|
||||
{ title: 'Цена за лид', key: 'price_rub', sortable: false },
|
||||
];
|
||||
|
||||
// Диапазон для строки активной сетки: находим позицию ступени по tier_no
|
||||
// и переводим ширины в накопительный диапазон (общий помощник — как в кабинете).
|
||||
function activeRangeText(item: AdminPricingTier): string {
|
||||
const idx = active.value.findIndex((t) => t.tier_no === item.tier_no);
|
||||
return tierRangeText(active.value, idx);
|
||||
}
|
||||
|
||||
// Живой предпросмотр диапазона в редакторе — пересчитывается при правке ширины.
|
||||
function editorRangeText(row: PricingTierEditorRow): string {
|
||||
const idx = editor.value.findIndex((r) => r.tier_no === row.tier_no);
|
||||
return tierRangeText(editor.value, idx);
|
||||
}
|
||||
|
||||
const nextMonthStart = computed(() => {
|
||||
const d = new Date();
|
||||
d.setDate(1);
|
||||
@@ -307,4 +345,12 @@ defineExpose({
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.num {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-feature-settings: 'tnum';
|
||||
}
|
||||
.editor-range {
|
||||
color: #6b6456;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -74,7 +74,9 @@ onMounted(async () => {
|
||||
await store.loadState();
|
||||
// Deep-link из «Сделок»: ?competitor=<id>[&project=<id>] — открыть экран
|
||||
// конкурента (и, если задан project, — модалку «Настройки проекта»).
|
||||
const q = route.query;
|
||||
// route?. — на случай монтирования без роутера (юнит-тесты): в приложении
|
||||
// роутер всегда есть, поведение не меняется.
|
||||
const q = route?.query ?? {};
|
||||
const compId = Number(Array.isArray(q.competitor) ? q.competitor[0] : q.competitor);
|
||||
if (Number.isInteger(compId) && compId > 0) {
|
||||
ctx.competitorId = compId;
|
||||
|
||||
@@ -633,6 +633,19 @@ defineExpose({ list, selected, toggle, toggleAll, moveToField, moveSelected, ope
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
/* Телефон: 3 кнопки (Добавить/Заменить/Отказаться) не влезали в карточку и
|
||||
заезжали за правый край — раскладываем на всю ширину поровну. */
|
||||
@media (max-width: 599.98px) {
|
||||
.ld-actbtns {
|
||||
width: 100%;
|
||||
}
|
||||
.ld-actbtns .ld-btn {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
}
|
||||
}
|
||||
.ld-alert-inline {
|
||||
font-size: 12.5px;
|
||||
color: #8a1c1c;
|
||||
|
||||
@@ -116,6 +116,36 @@
|
||||
.ld-banner--amber .ld-banner__txt b {
|
||||
color: #8a6a12;
|
||||
}
|
||||
/* Телефон: текст и ссылка не влезают в строку (текст крошился по слову,
|
||||
«Разобрать предложения →» вылезала за край) — раскладываем в столбик. */
|
||||
@media (max-width: 599.98px) {
|
||||
.ld-banner {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.ld-banner__link {
|
||||
white-space: normal;
|
||||
}
|
||||
/* «Выбрать всех» + подсказка — не жать в узкую колонку, подсказка на новую строку. */
|
||||
.ld-selrow {
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 12px;
|
||||
}
|
||||
/* Карточка источника: кнопки (Создать проект/Приостановить/Настройки/В источники)
|
||||
стояли справа фикс. блоком и уезжали за край — переносим их на строку ниже
|
||||
во всю ширину карточки. */
|
||||
.ld-srccard {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ld-srcctl {
|
||||
flex-basis: 100%;
|
||||
max-width: none;
|
||||
justify-content: flex-start;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.ld-tabs {
|
||||
display: flex;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<template>
|
||||
<v-dialog :model-value="modelValue" max-width="720" @update:model-value="$emit('update:modelValue', $event)">
|
||||
<v-dialog
|
||||
:model-value="modelValue"
|
||||
max-width="720"
|
||||
:fullscreen="isPhone"
|
||||
scrollable
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<v-card style="position: relative">
|
||||
<DevIndexBadge
|
||||
:index="mode === 'edit' ? 19 : 18"
|
||||
@@ -276,7 +282,7 @@
|
||||
|
||||
<div class="mt-3">
|
||||
<span class="text-caption">Дни недели приёма</span>
|
||||
<v-btn-toggle v-model="selectedDays" multiple density="comfortable" class="mt-1">
|
||||
<v-btn-toggle v-model="selectedDays" multiple density="comfortable" class="mt-1 weekday-toggle">
|
||||
<v-btn v-for="(day, i) in dayLabels" :key="i" :value="i">{{ day }}</v-btn>
|
||||
</v-btn-toggle>
|
||||
<div class="mt-1">
|
||||
@@ -330,6 +336,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch, computed } from 'vue';
|
||||
import { useLayoutTier } from '../../composables/useLayoutTier';
|
||||
import { apiClient, ensureCsrfCookie, extractErrorMessage } from '../../api/client';
|
||||
import { getRequisites, updateRequisites, type Requisites } from '../../api/requisites';
|
||||
import { firstLeadDate, formatLeadDate } from '../../utils/leadDate';
|
||||
@@ -408,6 +415,9 @@ const emit = defineEmits<{
|
||||
saved: [appliesFrom: string | null];
|
||||
}>();
|
||||
|
||||
// На телефоне диалог — на весь экран (иначе узкий модал режет ряды кнопок/дней).
|
||||
const { isPhone } = useLayoutTier();
|
||||
|
||||
// Plan 6: regions = subject codes (1..89) — backend dual-writes region_mask/region_mode.
|
||||
// Пустой массив = вся РФ.
|
||||
const form = reactive({
|
||||
@@ -723,6 +733,20 @@ defineExpose({
|
||||
line-height: 1.4;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
/* Телефон: 7 кнопок дней недели (ПН…ВС) по 64px не влезали в 390px —
|
||||
СБ и ВС уезжали за правый край и были не видны. Растягиваем группу на всю
|
||||
ширину и делим её поровну между кнопками, чтобы все 7 помещались в строку. */
|
||||
@media (max-width: 599.98px) {
|
||||
.weekday-toggle {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
.weekday-toggle :deep(.v-btn) {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
padding-inline: 0;
|
||||
}
|
||||
}
|
||||
.steps-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -201,7 +201,7 @@ async function runWebhookTest(): Promise<void> {
|
||||
/>
|
||||
</template>
|
||||
</v-text-field>
|
||||
<div class="d-flex ga-2 mt-2">
|
||||
<div class="d-flex ga-2 mt-2 btn-row">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
size="small"
|
||||
@@ -296,7 +296,7 @@ async function runWebhookTest(): Promise<void> {
|
||||
/>
|
||||
</template>
|
||||
</v-text-field>
|
||||
<div class="d-flex ga-2 mt-2">
|
||||
<div class="d-flex ga-2 mt-2 btn-row">
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@@ -359,4 +359,12 @@ async function runWebhookTest(): Promise<void> {
|
||||
font-variation-settings: 'opsz' 18;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
/* Телефон: пары кнопок (Копировать+Перегенерировать, Сохранить+Тестовый webhook)
|
||||
не влезали в строку — длинная вторая кнопка уезжала за край. Разрешаем перенос
|
||||
на вторую строку. */
|
||||
@media (max-width: 599.98px) {
|
||||
.btn-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -262,4 +262,29 @@ const canSave = computed(() => dirty.value && !saving.value && auth.isAuthentica
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Телефон: колонки каналов по 110/130px не влезали — Email уезжал за край,
|
||||
а название события сжималось в узкую колонку и текст крошился по одному слову.
|
||||
Ужимаем колонки-галочки до ширины чекбокса и уменьшаем отступы, чтобы событию
|
||||
осталась почти вся ширина. Заголовки каналов переносим по буквам. */
|
||||
@media (max-width: 599.98px) {
|
||||
.prefs-head,
|
||||
.prefs-row {
|
||||
grid-template-columns: 1fr 46px 46px;
|
||||
}
|
||||
.prefs-cell {
|
||||
padding: 8px 6px;
|
||||
}
|
||||
.ch-col {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
line-height: 1.15;
|
||||
}
|
||||
/* Заголовки каналов — мельче, чтобы «EMAIL» помещался в строку, а не ломался. */
|
||||
.prefs-head .ch-col {
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.02em;
|
||||
padding-inline: 3px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -196,7 +196,7 @@ async function save(): Promise<void> {
|
||||
mandatory="force"
|
||||
density="comfortable"
|
||||
color="primary"
|
||||
class="mt-1 d-block"
|
||||
class="mt-1 d-block subject-type-toggle"
|
||||
data-testid="requisites-subject-type"
|
||||
>
|
||||
<v-btn v-for="t in subjectTypes" :key="t.value" :value="t.value">{{ t.label }}</v-btn>
|
||||
@@ -390,4 +390,27 @@ async function save(): Promise<void> {
|
||||
font-variation-settings: 'opsz' 18;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
|
||||
/* Телефон: три длинных варианта «Тип лица» не влезают в строку —
|
||||
раскладываем сегмент-контрол в столбик во всю ширину. */
|
||||
@media (max-width: 599.98px) {
|
||||
.subject-type-toggle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
}
|
||||
.subject-type-toggle :deep(.v-btn) {
|
||||
width: 100%;
|
||||
border-radius: 0;
|
||||
}
|
||||
.subject-type-toggle :deep(.v-btn:first-child) {
|
||||
border-top-left-radius: 6px;
|
||||
border-top-right-radius: 6px;
|
||||
}
|
||||
.subject-type-toggle :deep(.v-btn:last-child) {
|
||||
border-bottom-left-radius: 6px;
|
||||
border-bottom-right-radius: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ config('app.name', 'Лидерра') }}</title>
|
||||
<meta name="support-email" content="{{ config('services.support.email') }}">
|
||||
@if(config('services.metrika.counter_id'))
|
||||
<meta name="metrika-id" content="{{ config('services.metrika.counter_id') }}">
|
||||
@endif
|
||||
@vite(['resources/css/app.css', 'resources/js/app.ts'])
|
||||
@if(config('services.jivosite.widget_id'))
|
||||
<script src="https://code.jivo.ru/widget/{{ config('services.jivosite.widget_id') }}" async></script>
|
||||
|
||||
@@ -106,7 +106,7 @@ test('register требует accept_offer, accept_pdn, captcha_token', function
|
||||
->assertStatus(422)->assertJsonValidationErrors(['captcha_token']);
|
||||
});
|
||||
|
||||
test('confirm верным кодом активирует тенанта/владельца, баланс 300, выполняет вход', function () {
|
||||
test('confirm верным кодом активирует тенанта/владельца, баланс 1000, выполняет вход', function () {
|
||||
$reg = $this->postJson('/api/auth/register', registerPayload())->assertStatus(201);
|
||||
$code = $reg->json('_dev_plain_code');
|
||||
|
||||
@@ -124,7 +124,7 @@ test('confirm верным кодом активирует тенанта/вла
|
||||
|
||||
$tenant = Tenant::find($user->tenant_id);
|
||||
expect($tenant->status)->toBe('active');
|
||||
expect((float) $tenant->balance_rub)->toBe(300.0);
|
||||
expect((float) $tenant->balance_rub)->toBe(1000.0);
|
||||
});
|
||||
|
||||
test('confirm неверным кодом → 422 + failed_attempts++', function () {
|
||||
|
||||
@@ -143,6 +143,62 @@ describe('AdminPricingTiersView', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminPricingTiersView — клиентский диапазон (устранение путаницы admin↔кабинет)', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(adminApi.getPricingTiers).mockResolvedValue({ active: mockTiers, scheduled: {} });
|
||||
vi.mocked(adminApi.createPricingTiers).mockResolvedValue({ effective_from: '2026-06-01' });
|
||||
});
|
||||
|
||||
it('в активной сетке рядом с шириной показывает накопительный диапазон, как у клиента', async () => {
|
||||
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// ширины 100,200 → ступень 2 у клиента = 101–300 (а не «200»)
|
||||
const cell = wrapper.find('[data-testid="active-range-2"]');
|
||||
expect(cell.exists()).toBe(true);
|
||||
expect(cell.text()).toContain('101–300');
|
||||
});
|
||||
|
||||
it('ступень 7 в активной сетке — открытый диапазон со знаком +', async () => {
|
||||
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// 100+200+400+800+1500+3000 = 6000 → ступень 7 = 6001+
|
||||
expect(wrapper.find('[data-testid="active-range-7"]').text()).toContain('6001+');
|
||||
});
|
||||
|
||||
it('редактор показывает живой клиентский диапазон рядом с полем ширины', async () => {
|
||||
const wrapper = mount(AdminPricingTiersView, {
|
||||
global: {
|
||||
plugins: [vuetify],
|
||||
stubs: { VDialog: { template: '<div><slot /></div>' } },
|
||||
},
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(wrapper.vm as any).editorOpen = true;
|
||||
await wrapper.vm.$nextTick();
|
||||
// defaultEditor: ширины 100,200 → ступень 2 = 101–300
|
||||
const cell = wrapper.find('[data-testid="editor-range-2"]');
|
||||
expect(cell.exists()).toBe(true);
|
||||
expect(cell.text()).toContain('101–300');
|
||||
});
|
||||
|
||||
it('предпросмотр в редакторе пересчитывается при изменении ширины', async () => {
|
||||
const wrapper = mount(AdminPricingTiersView, {
|
||||
global: {
|
||||
plugins: [vuetify],
|
||||
stubs: { VDialog: { template: '<div><slot /></div>' } },
|
||||
},
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
vm.editorOpen = true;
|
||||
vm.editor[0].leads_in_tier = 250; // была 100 → ступень 2 сдвигается на 251–450
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find('[data-testid="editor-range-2"]').text()).toContain('251–450');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminPricingTiersView error handling (Sprint 1 G1)', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||
import AppBottomNav from '../../resources/js/components/layout/AppBottomNav.vue';
|
||||
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/autopodbor', component: { template: '<div/>' } },
|
||||
{ path: '/projects', component: { template: '<div/>' } },
|
||||
{ path: '/deals', component: { template: '<div/>' } },
|
||||
{ path: '/billing', component: { template: '<div/>' } },
|
||||
{ path: '/kanban', component: { template: '<div/>' } },
|
||||
],
|
||||
});
|
||||
|
||||
describe('AppBottomNav', () => {
|
||||
it('рендерит ровно 5 вкладок Поле/Проекты/Сделки/Биллинг/Канбан по порядку', async () => {
|
||||
const vuetify = createVuetify();
|
||||
router.push('/deals');
|
||||
await router.isReady();
|
||||
const wrapper = mount(AppBottomNav, { global: { plugins: [vuetify, router] } });
|
||||
const labels = wrapper.findAll('[data-testid="bottomnav-item"]').map((n) => n.text());
|
||||
expect(labels).toEqual(['Поле', 'Проекты', 'Сделки', 'Биллинг', 'Канбан']);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,10 @@ import { createVuetify } from 'vuetify';
|
||||
import DealsTable from '../../resources/js/components/deals/DealsTable.vue';
|
||||
import type { MockDeal } from '../../resources/js/composables/mockDeals';
|
||||
|
||||
// ResponsiveTable показывает таблицу только на десктопе (>=1280); эти тесты
|
||||
// проверяют табличный слой — фиксируем десктоп-ширину.
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1440 });
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
const sampleDeals: MockDeal[] = [
|
||||
@@ -128,6 +132,29 @@ describe('DealsTable', () => {
|
||||
expect(links[1].props('to')).toMatchObject({ name: 'autopodbor', query: { competitor: '3', project: '5' } });
|
||||
});
|
||||
|
||||
it('«Конкурент» — только бренд до первой запятой, без описания', () => {
|
||||
const deal: MockDeal = {
|
||||
id: 12,
|
||||
name: '79000000002',
|
||||
phone: '79000000002',
|
||||
statusSlug: 'new',
|
||||
project: 'X',
|
||||
manager: { initials: '—', name: '—' },
|
||||
cost: 0,
|
||||
receivedMinutesAgo: 1,
|
||||
competitorId: 4,
|
||||
competitorName: 'AutoMoney, микрокредитная компания',
|
||||
sourceIdentifier: '79135396546',
|
||||
sourceSignalType: 'call',
|
||||
sourceProjectId: 5,
|
||||
sourceIsActive: true,
|
||||
sourceExtraCount: 0,
|
||||
};
|
||||
const w = mountTable([deal]);
|
||||
expect(w.text()).toContain('AutoMoney');
|
||||
expect(w.text()).not.toContain('микрокредитная');
|
||||
});
|
||||
|
||||
it('показывает « · +N» при нескольких источниках проекта', () => {
|
||||
const deal: MockDeal = {
|
||||
id: 11,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import ResponsiveTable from '../../resources/js/components/common/ResponsiveTable.vue';
|
||||
|
||||
function mountAt(width: number) {
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: width });
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
const vuetify = createVuetify();
|
||||
return mount(ResponsiveTable, {
|
||||
global: { plugins: [vuetify] },
|
||||
props: { items: [{ id: 1, phone: '+7 900 000' }] },
|
||||
slots: {
|
||||
table: '<div data-testid="as-table">TABLE</div>',
|
||||
card: '<div data-testid="as-card">CARD</div>',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('ResponsiveTable', () => {
|
||||
it('на телефоне (<600) показывает слот card', () => {
|
||||
const w = mountAt(390);
|
||||
expect(w.find('[data-testid="as-card"]').exists()).toBe(true);
|
||||
expect(w.find('[data-testid="as-table"]').exists()).toBe(false);
|
||||
});
|
||||
it('на планшете-портрете (768) показывает слот card, а не обрезанную таблицу', () => {
|
||||
const w = mountAt(768);
|
||||
expect(w.find('[data-testid="as-card"]').exists()).toBe(true);
|
||||
expect(w.find('[data-testid="as-table"]').exists()).toBe(false);
|
||||
});
|
||||
it('на десктопе (>=1280) показывает слот table', () => {
|
||||
const w = mountAt(1440);
|
||||
expect(w.find('[data-testid="as-table"]').exists()).toBe(true);
|
||||
expect(w.find('[data-testid="as-card"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,10 @@ import type { BillingTransaction, TransactionsPage } from '../../resources/js/ap
|
||||
|
||||
vi.mock('../../resources/js/api/billing');
|
||||
|
||||
// ResponsiveTable показывает таблицу только на десктопе (>=1280); эти тесты
|
||||
// проверяют табличный слой — фиксируем десктоп-ширину.
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1440 });
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
function txn(over: Partial<BillingTransaction> = {}): BillingTransaction {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* Тесты ленивого загрузчика Яндекс.Метрики (resources/js/plugins/metrika.ts).
|
||||
* Загрузчик грузится только из роутера при входе в кабинет (см. router/index.ts).
|
||||
*/
|
||||
describe('metrika loader', () => {
|
||||
beforeEach(() => {
|
||||
document.head.innerHTML = '';
|
||||
document.body.innerHTML = '';
|
||||
delete window.ym;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('без meta-тега ничего не грузит и не падает', async () => {
|
||||
const { ensureLoaded } = await import('../../resources/js/plugins/metrika');
|
||||
ensureLoaded();
|
||||
expect(document.querySelector('script[src*="mc.yandex.ru"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('с meta-тегом инжектит tag.js один раз (идемпотентно)', async () => {
|
||||
const meta = document.createElement('meta');
|
||||
meta.name = 'metrika-id';
|
||||
meta.content = '99999999';
|
||||
document.head.appendChild(meta);
|
||||
|
||||
const { ensureLoaded } = await import('../../resources/js/plugins/metrika');
|
||||
ensureLoaded();
|
||||
ensureLoaded();
|
||||
const scripts = document.querySelectorAll('script[src*="mc.yandex.ru/metrika/tag.js"]');
|
||||
expect(scripts.length).toBe(1);
|
||||
});
|
||||
|
||||
it('hit() вызывает window.ym с id из meta и переданным url', async () => {
|
||||
const meta = document.createElement('meta');
|
||||
meta.name = 'metrika-id';
|
||||
meta.content = '99999999';
|
||||
document.head.appendChild(meta);
|
||||
|
||||
const ym = vi.fn();
|
||||
window.ym = ym;
|
||||
|
||||
const { hit } = await import('../../resources/js/plugins/metrika');
|
||||
hit('/dashboard');
|
||||
expect(ym).toHaveBeenCalledWith(99999999, 'hit', '/dashboard');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { tierRangeText } from '../../resources/js/utils/pricingTiers';
|
||||
|
||||
/**
|
||||
* Единый источник истины для «ширина ступени → накопительный диапазон».
|
||||
* Числа — ровно те, что клиент видит в кабинете (боевая сетка 08.07.2026):
|
||||
* ширины 250/500/1000/2500/5000/10000/∞ → 1–250, 251–750, …, 19251+.
|
||||
*/
|
||||
const tiers = [
|
||||
{ leads_in_tier: 250 },
|
||||
{ leads_in_tier: 500 },
|
||||
{ leads_in_tier: 1000 },
|
||||
{ leads_in_tier: 2500 },
|
||||
{ leads_in_tier: 5000 },
|
||||
{ leads_in_tier: 10000 },
|
||||
{ leads_in_tier: null },
|
||||
];
|
||||
|
||||
describe('tierRangeText', () => {
|
||||
it('первая ступень начинается с 1', () => {
|
||||
expect(tierRangeText(tiers, 0)).toBe('1–250');
|
||||
});
|
||||
|
||||
it('вторая ступень — накопительный диапазон, а не «сама ширина»', () => {
|
||||
expect(tierRangeText(tiers, 1)).toBe('251–750');
|
||||
});
|
||||
|
||||
it('третья ступень продолжает накопление', () => {
|
||||
expect(tierRangeText(tiers, 2)).toBe('751–1750');
|
||||
});
|
||||
|
||||
it('шестая ступень', () => {
|
||||
// 250+500+1000+2500 = 4250 → пятая 4251–9250 → шестая 9251–19250
|
||||
expect(tierRangeText(tiers, 5)).toBe('9251–19250');
|
||||
});
|
||||
|
||||
it('последняя ступень (leads_in_tier=null) — открытый диапазон со знаком +', () => {
|
||||
// 250+500+1000+2500+5000+10000 = 19250 → tier 7 стартует с 19251
|
||||
expect(tierRangeText(tiers, 6)).toBe('19251+');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { defineComponent } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import { useLayoutTier } from '../../resources/js/composables/useLayoutTier';
|
||||
|
||||
function tierAtWidth(width: number): string {
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: width });
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
const vuetify = createVuetify();
|
||||
let captured = '';
|
||||
const Harness = defineComponent({
|
||||
setup() {
|
||||
const { tier } = useLayoutTier();
|
||||
captured = tier.value;
|
||||
return () => null;
|
||||
},
|
||||
});
|
||||
mount(Harness, { global: { plugins: [vuetify] } });
|
||||
return captured;
|
||||
}
|
||||
|
||||
describe('useLayoutTier', () => {
|
||||
it('телефон при ширине < 600', () => { expect(tierAtWidth(390)).toBe('phone'); });
|
||||
it('планшет при 600..1279', () => { expect(tierAtWidth(820)).toBe('tablet'); });
|
||||
it('десктоп при >= 1280', () => { expect(tierAtWidth(1440)).toBe('desktop'); });
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env bash
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Лидерра — ЗАЩИЩЁННЫЙ выкат фронт-сборки портала (public/build).
|
||||
#
|
||||
# ЗАЧЕМ. Портал выкатывается ПОЛНОЙ заменой папки public/build. Если собрать
|
||||
# из УСТАРЕВШЕЙ ветки (где нет чужих свежих фич) и залить — чужая работа
|
||||
# МОЛЧА затирается. Реальный инцидент 08.07.2026: выкат Яндекс.Метрики,
|
||||
# собранный из ветки без колонок «Сделок», снёс колонки «Конкурент/Источник».
|
||||
#
|
||||
# ЧТО ДЕЛАЕТ. Перед подменой сверяет: новая сборка НЕ должна ПОТЕРЯТЬ ни одной
|
||||
# критичной фичи, которая есть в живой сборке. Потеряла — ОТКАЗ, боевой не тронут.
|
||||
# Осознанный откат (когда фичу убираем намеренно) — только с FORCE=1.
|
||||
#
|
||||
# ВАЖНО. Маркеры ищем ТОЛЬКО в чанках, на которые ссылается manifest.json
|
||||
# (активная сборка), а не по всей assets/ — там копятся «осколки» старых
|
||||
# overlay-выкатов, из-за которых наивная проверка промахивалась.
|
||||
#
|
||||
# ИСПОЛЬЗОВАНИЕ (с dev-машины):
|
||||
# tar czf build.tgz -C app/public/build .
|
||||
# scp build.tgz liderra:/tmp/build.tgz
|
||||
# ssh liderra 'sudo bash /var/www/liderra/deploy-build.sh /tmp/build.tgz "ветка/sha/что выкатываем"'
|
||||
# # только проверить, не выкатывая:
|
||||
# ssh liderra 'sudo DRY_RUN=1 bash /var/www/liderra/deploy-build.sh /tmp/build.tgz "проверка"'
|
||||
# # осознанный откат/удаление фичи:
|
||||
# ssh liderra 'sudo FORCE=1 bash /var/www/liderra/deploy-build.sh /tmp/build.tgz "rollback: ..."'
|
||||
#
|
||||
# Откат в любой момент: mv build.bak-guard-<ts> build (см. вывод скрипта).
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
set -euo pipefail
|
||||
|
||||
TGZ="${1:?укажи путь к tar.gz со сборкой (содержимое папки build)}"
|
||||
INFO="${2:-}"
|
||||
FORCE="${FORCE:-}"
|
||||
DRY_RUN="${DRY_RUN:-}"
|
||||
BASE=/var/www/liderra/app/public
|
||||
LIVE="$BASE/build"
|
||||
|
||||
# Критичные маркеры фич: если строка есть в ЖИВОЙ сборке — обязана быть и в новой.
|
||||
# ➕ Добавляй строку сюда, когда выкатываешь новую пользовательскую фичу,
|
||||
# чтобы её тоже нельзя было случайно затереть следующим выкатом.
|
||||
MARKERS=(
|
||||
"Конкурент" # колонки «Сделок»: Конкурент / Источник
|
||||
"mc.yandex" # Яндекс.Метрика (счётчик + Вебвизор)
|
||||
"ym-hide-content" # маскировка ПДн лидов для Метрики (152-ФЗ) — критично
|
||||
"deal-card" # мобильные/планшетные карточки сделок
|
||||
"autopodbor" # автоподбор конкурентов
|
||||
)
|
||||
|
||||
echo "▶ Защищённый выкат сборки портала. Инфо: ${INFO:-—}"
|
||||
[ -f "$TGZ" ] || { echo "❌ Нет файла: $TGZ"; exit 2; }
|
||||
|
||||
STAGING="$BASE/.build-staging.$$"
|
||||
rm -rf "$STAGING"; mkdir -p "$STAGING"
|
||||
tar xzf "$TGZ" -C "$STAGING"
|
||||
[ -f "$STAGING/manifest.json" ] || { echo "❌ В сборке нет manifest.json — это не билд портала."; rm -rf "$STAGING"; exit 2; }
|
||||
|
||||
# Список АКТИВНЫХ чанков (js/css) по manifest — без orphan-осколков прошлых выкатов.
|
||||
active_files() { # $1 = dir сборки → печатает полные пути к активным чанкам
|
||||
local dir="$1"
|
||||
grep -oE 'assets/[A-Za-z0-9_.-]+\.(js|css)' "$dir/manifest.json" | sort -u | sed "s|^|$dir/|"
|
||||
}
|
||||
mapfile -t LIVE_FILES < <(active_files "$LIVE" 2>/dev/null || true)
|
||||
mapfile -t NEW_FILES < <(active_files "$STAGING" 2>/dev/null || true)
|
||||
|
||||
hits() { # $1 = маркер, далее список файлов → сколько файлов содержат маркер
|
||||
local m="$1"; shift
|
||||
[ "$#" -eq 0 ] && { echo 0; return 0; }
|
||||
# grep возвращает 1 при «не найдено»; при pipefail+set -e это рвёт скрипт —
|
||||
# поэтому глушим код через "|| true", а число берём из вывода wc.
|
||||
local c
|
||||
c=$(grep -lF -- "$m" "$@" 2>/dev/null | wc -l) || true
|
||||
echo "${c:-0}"
|
||||
}
|
||||
|
||||
# ── ГЛАВНАЯ ЗАЩИТА: новая сборка не должна терять фичи живой ──────────────────
|
||||
missing=()
|
||||
for m in "${MARKERS[@]}"; do
|
||||
live_n=$(hits "$m" "${LIVE_FILES[@]}")
|
||||
new_n=$(hits "$m" "${NEW_FILES[@]}")
|
||||
if [ "$live_n" -gt 0 ] && [ "$new_n" -eq 0 ]; then
|
||||
missing+=("$m")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "${#missing[@]}" -gt 0 ] && [ "$FORCE" != "1" ]; then
|
||||
echo ""
|
||||
echo "🛑 ОТКАЗ: новая сборка ТЕРЯЕТ фичи, которые есть на боевом:"
|
||||
printf ' • %s\n' "${missing[@]}"
|
||||
echo ""
|
||||
echo " Похоже, собрано из УСТАРЕВШЕЙ ветки (без чужой работы)."
|
||||
echo " Пересобери из ветки-надмножества — где есть ВСЁ живое."
|
||||
echo " Если это ОСОЗНАННЫЙ откат/удаление фичи — повтори с FORCE=1."
|
||||
rm -rf "$STAGING"
|
||||
exit 3
|
||||
fi
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
echo "⚠ FORCE=1: осознанно теряем фичи (${missing[*]}) — по прямому указанию."
|
||||
fi
|
||||
|
||||
if [ "$DRY_RUN" = "1" ]; then
|
||||
echo "✅ DRY_RUN: проверка пройдена, все живые фичи на месте. Выкат НЕ выполнялся."
|
||||
rm -rf "$STAGING"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Подмена с бэкапом и отметкой о выкате ────────────────────────────────────
|
||||
ts="$(date -u +%Y%m%d-%H%M%S)"
|
||||
mv "$LIVE" "$BASE/build.bak-guard-$ts"
|
||||
mv "$STAGING" "$LIVE"
|
||||
appjs=$(grep -oE 'assets/app-[a-zA-Z0-9_-]+\.js' "$LIVE/manifest.json" | head -1)
|
||||
printf '{"deployed_at":"%s","info":"%s","bundle":"%s","guard_ok":true}\n' \
|
||||
"$ts" "${INFO//\"/}" "$appjs" > "$LIVE/DEPLOYED.json"
|
||||
chown -R www-data:www-data "$LIVE"
|
||||
echo "✅ Выкат ок. Бандл: $appjs. Бэкап прежней сборки: build.bak-guard-$ts"
|
||||
Reference in New Issue
Block a user