Files
portal/app/resources/js/components/projects/ProjectDetailsDrawer.vue
T
Дмитрий 9ff19f3cc3 @
feat(bot): всеобъемлющая инструкция (36 статей) + 31 узкая экскурсия, которая открывает окна

Живой урок владельца 12.07.2026: «дубли чистятся в 2-х местах, а бот выдумал третье»,
«стыдно такого бота показывать», «видео показываем полное, а я спрашиваю про одно —
надо резать», «надо не только кнопку показывать, а и что за ней».

База знаний: 17 обзорных статей → 36 узких по темам. Портал обойдён целиком
(5 агентов): все экраны, окна, подсказки «(?)», тексты ошибок, внутренние правила.
Новое, чего бот не знал: дубли в ДВУХ местах (Поле и Предложения) + окно «источник
уже есть»; правило 18:00; почему приходит меньше лимита; оплата по счёту и акты;
списания; безопасность и 2FA; отчёты; массовые действия; регистрация.

Экскурсии: 15 → 31, каждая на свою тему (3–7 шагов). Раннер GuidedTour научен
шагу `open` — сам открывает окно/вкладку и подсвечивает то, что ВНУТРИ
(окно дублей, форму подбора, диалог пополнения, панель тарифов, карточку сделки).
99 новых якорей data-tour расставлено по всему порталу (4 агента).

Поиск: веса в tsvector (заголовок A / синонимы B / текст C) — иначе на «чистка
дублей» первой всплывала статья «Списания» из-за мимоходного упоминания «поля».

Тесты: Pest бот 39/39, Vitest 1222/1222. Новые сторожа: у каждой статьи есть
экскурсия; экскурсия не короче 3 и не длиннее 7 шагов; каждая цель экскурсии
реально существует в разметке; экскурсии про действия обязаны открывать окно.
Живая проверка в браузере: экскурсия про дубли сама открыла окно объединения
и перешла на второе место чистки.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@
2026-07-12 12:56:22 +03:00

605 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onBeforeUnmount, watch } from 'vue';
import axios from 'axios';
import type { Project, BalancePayload } from '../../stores/projectsStore';
import { useProjectsStore } from '../../stores/projectsStore';
import { REGIONS_ALPHA, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
import { formatLeadDate, firstLeadDate } from '../../utils/leadDate';
const props = defineProps<{ project: Project | null }>();
const emit = defineEmits<{ close: []; saved: [appliesFrom: string | null]; 'balance-insufficient': [balance: BalancePayload] }>();
interface FormState {
name: string;
daily_limit_target: number;
regions: number[];
delivery_days_mask: number;
sms_senders: string[];
sms_keyword: string;
signal_identifier: string;
}
const form = reactive<FormState>({
name: '',
daily_limit_target: 50,
regions: [],
delivery_days_mask: 127,
sms_senders: [],
sms_keyword: '',
signal_identifier: '',
});
const selectableRegions = REGIONS_ALPHA;
function reseedFromProject(p: Project | null): void {
if (!p) return;
form.name = p.name;
form.daily_limit_target = p.daily_limit_target;
form.regions = Array.isArray(p.regions) ? [...p.regions] : [];
form.delivery_days_mask = p.delivery_days_mask ?? 127;
form.sms_senders = p.sms_senders ?? [];
form.sms_keyword = p.sms_keyword ?? '';
form.signal_identifier = p.signal_identifier ?? '';
}
reseedFromProject(props.project);
watch(
() => props.project?.id,
() => {
reseedFromProject(props.project);
},
);
const saving = ref(false);
const errors = reactive<Record<string, string[]>>({});
const store = useProjectsStore();
// Эпик 3 (редизайн 2026-06-25): вместо жёсткого замка — редактируемые поля
// + объявление о вступлении + подтверждение смены источника. Раздача доводит
// хвост по старому источнику через слепок, поэтому смена безопасна.
const sourceLocked = computed(() => props.project?.source_locked === true);
// Информационный баннер: пока летит сбор на завтра, правки количества/региона/дней
// вступят в силу не сразу. Показываем, когда проект залочен И эти поля изменены.
const slepokFieldsDirty = computed(() => {
const p = props.project;
if (!p) return false;
const regionsChanged = JSON.stringify([...form.regions].sort()) !== JSON.stringify([...(p.regions ?? [])].sort());
return (
form.daily_limit_target !== p.daily_limit_target ||
form.delivery_days_mask !== (p.delivery_days_mask ?? 127) ||
regionsChanged
);
});
const showAppliesFromBanner = computed(() => sourceLocked.value && slepokFieldsDirty.value);
// Time-aware дата вступления правок по правилу слепка 18:00 МСК (до 18:00 → завтра,
// после → послезавтра). Объявление всегда показывает АКТУАЛЬНУЮ дату.
const appliesFromDate = computed(() => firstLeadDate());
// Изменён ли источник относительно исходного состояния проекта.
const sourceDirty = computed(() => {
const p = props.project;
if (!p) return false;
if (p.signal_type === 'sms') {
const sendersChanged = JSON.stringify(form.sms_senders) !== JSON.stringify(p.sms_senders ?? []);
return sendersChanged || form.sms_keyword !== (p.sms_keyword ?? '');
}
return form.signal_identifier !== (p.signal_identifier ?? '');
});
// Диалог подтверждения смены источника на залоченном проекте.
const sourceConfirmOpen = ref(false);
const sourceConfirmText = computed(() => {
// Эпик 6.3: единый текст правила из API (ProjectRuleMessages). Fallback — локальный.
const fromApi = props.project?.source_change_message;
if (fromApi) {
return `Мы уже ведём сбор на завтра. ${fromApi} Подтвердите смену источника.`;
}
const d = formatLeadDate(props.project?.source_unlock_at);
return (
'Мы уже ведём сбор на завтра. Лиды по старому источнику придут завтра в любом случае. ' +
(d ? `Послезавтра (после ${d}) — уже по новому. ` : '') +
'Подтвердите смену источника.'
);
});
async function onPause(): Promise<void> {
if (!props.project) return;
const result = await store.toggleActive(props.project);
if (result?.deferred) {
// Task 14: 409 balance_insufficient при попытке возобновить — не закрываем drawer,
// сообщаем родителю показать диалог пополнения баланса.
emit('balance-insufficient', result.balance);
return;
}
// #4: после паузы/возобновления панель и галочка должны исчезнуть (как у Save и Delete).
emit('close');
}
async function onDelete(): Promise<void> {
if (!props.project) return;
const ok = window.confirm(
'Удалить проект? Действие необратимо. Если по проекту есть сделки или поставщик уже заказал лиды — удаление будет заблокировано.',
);
if (!ok) return;
Object.keys(errors).forEach((k) => delete errors[k]);
try {
await store.del(props.project.id);
emit('close');
} catch (e: unknown) {
const err = e as { response?: { status?: number; data?: { errors?: Record<string, string[]> } } };
if (err.response?.status === 422 && err.response.data?.errors) {
Object.assign(errors, err.response.data.errors);
}
// НЕ закрываем drawer — клиент видит ошибку и может поставить проект на паузу.
}
}
async function onSave(): Promise<void> {
if (!props.project) return;
// Эпик 3.2: смена источника на залоченном проекте — через подтверждение.
if (sourceLocked.value && sourceDirty.value) {
sourceConfirmOpen.value = true;
return;
}
await doSave();
}
function cancelSourceChange(): void {
sourceConfirmOpen.value = false;
}
async function confirmSourceChange(): Promise<void> {
sourceConfirmOpen.value = false;
await doSave();
}
async function doSave(): Promise<void> {
if (!props.project) return;
saving.value = true;
Object.keys(errors).forEach((k) => delete errors[k]);
try {
const payload: Record<string, unknown> = {
name: form.name,
daily_limit_target: form.daily_limit_target,
regions: form.regions,
delivery_days_mask: form.delivery_days_mask,
};
// 18.05.2026 ux: редактирование источника проекта.
if (props.project.signal_type === 'site' || props.project.signal_type === 'call') {
payload.signal_identifier = form.signal_identifier;
}
if (props.project.signal_type === 'sms') {
payload.sms_senders = form.sms_senders;
payload.sms_keyword = form.sms_keyword;
}
const { data } = await axios.patch(`/api/projects/${props.project.id}`, payload);
// Backend кладёт applies_from когда правка задела slepok-чувствительные поля
// (см. ProjectService::updateAndExposeAppliesFrom / Task 2.8).
const appliesFrom: string | null = data?.data?.applies_from ?? null;
emit('saved', appliesFrom);
} catch (e: unknown) {
const err = e as {
response?: {
status?: number;
data?: {
errors?: Record<string, string[]>;
error?: string;
current_capacity_leads?: number;
would_be_required_leads?: number;
};
};
};
if (err.response?.status === 422 && err.response.data?.errors) {
Object.assign(errors, err.response.data.errors);
} else if (err.response?.status === 409 && err.response.data?.error === 'balance_insufficient') {
// O (балансовый блок): раньше панель молчала при 409 — клиент не понимал,
// почему правка лимита не сохранилась. Показываем причину под полем лимита.
const d = err.response.data;
errors.daily_limit_target = [
`Лимит превышает баланс: хватает на ${d.current_capacity_leads ?? 0} лид(ов), запрошено ${d.would_be_required_leads ?? form.daily_limit_target}. Пополните баланс, чтобы увеличить лимит.`,
];
}
} finally {
saving.value = false;
}
}
function onKey(e: KeyboardEvent): void {
if (e.key === 'Escape' && props.project) emit('close');
}
onMounted(() => document.addEventListener('keydown', onKey));
onBeforeUnmount(() => document.removeEventListener('keydown', onKey));
const activeDays = computed<boolean[]>(() => {
const mask = form.delivery_days_mask;
return Array.from({ length: 7 }, (_, i) => (mask & (1 << i)) !== 0);
});
function toggleDay(i: number): void {
form.delivery_days_mask ^= 1 << i;
}
const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
</script>
<template>
<aside class="project-details-drawer" :class="{ open: project !== null }" data-tour="project-drawer">
<div v-if="project" class="pdd-content">
<header class="pdd-head">
<div class="pdd-head-left">
<div class="pdd-title">{{ project.name }}</div>
<v-chip
v-if="project.preflight_blocked_at"
color="error"
size="x-small"
variant="tonal"
class="ml-2"
data-testid="drawer-balance-blocked-badge"
>
Не запущен не хватает баланса
</v-chip>
</div>
<button class="pdd-close" data-testid="pdd-close" aria-label="Закрыть" @click="$emit('close')">
<v-icon size="20" icon="mdi-close" />
</button>
</header>
<div class="pdd-body">
<!-- Общая ошибка уровня проекта (например, supplier-snapshot guard или has-deals на delete). -->
<div v-if="errors.project" class="pdd-error pdd-error-banner" data-testid="pdd-error-project">
{{ errors.project[0] }}
</div>
<label class="pdd-field">
<span class="pdd-label">Название</span>
<input v-model="form.name" data-testid="pdd-name" class="pdd-input" />
<div v-if="errors.name" class="pdd-error" data-testid="pdd-error-name">{{ errors.name[0] }}</div>
</label>
<!-- 18.05.2026 ux: редактирование источника проекта (site/call/sms) -->
<label v-if="project?.signal_type === 'site'" class="pdd-field">
<span class="pdd-label">Сайт, с которого нужны заявки</span>
<input
v-model="form.signal_identifier"
data-testid="pdd-signal-identifier"
class="pdd-input"
placeholder="okna-moskva.ru"
/>
<div v-if="errors.signal_identifier" class="pdd-error" data-testid="pdd-error-signal">
{{ errors.signal_identifier[0] }}
</div>
</label>
<label v-else-if="project?.signal_type === 'call'" class="pdd-field">
<span class="pdd-label">Телефон, по которому идут клиенты</span>
<input
v-model="form.signal_identifier"
data-testid="pdd-signal-identifier"
class="pdd-input"
placeholder="79161234567"
/>
<div class="pdd-hint">Можно с +7, 8, скобками и пробелами приведём к виду 79161234567</div>
<div v-if="errors.signal_identifier" class="pdd-error" data-testid="pdd-error-signal">
{{ errors.signal_identifier[0] }}
</div>
</label>
<div v-else-if="project?.signal_type === 'sms'" class="pdd-field">
<span class="pdd-label">Источник отправители SMS</span>
<v-combobox
v-model="form.sms_senders"
data-testid="pdd-sms-senders"
multiple
chips
clearable
density="comfortable"
hide-details
placeholder="MTS, BEELINE …"
/>
<div v-if="errors.sms_senders" class="pdd-error">{{ errors.sms_senders[0] }}</div>
<span class="pdd-label mt-2">Ключевое слово (опционально)</span>
<input
v-model="form.sms_keyword"
data-testid="pdd-sms-keyword"
class="pdd-input"
placeholder="КРЕДИТ"
/>
<div v-if="errors.sms_keyword" class="pdd-error">{{ errors.sms_keyword[0] }}</div>
</div>
<!-- Эпик 3.1: объявление о вступлении правок количества/региона/дней (без блокировки). -->
<div
v-if="showAppliesFromBanner"
class="pdd-lock-hint"
data-testid="pdd-applies-from-banner"
>
Ближайший сбор уже идёт по текущим настройкам. Новые количество, регионы
и дни приёма вступят в силу с {{ appliesFromDate }}.
</div>
<label class="pdd-field">
<span class="pdd-label">Лимит лидов в день</span>
<input
v-model.number="form.daily_limit_target"
type="number"
min="1"
max="10000"
data-testid="pdd-limit"
data-tour="project-drawer-limit"
class="pdd-input"
/>
<div v-if="errors.daily_limit_target" class="pdd-error">{{ errors.daily_limit_target[0] }}</div>
</label>
<div class="pdd-field">
<span class="pdd-label">Регионы (пусто = вся РФ)</span>
<v-autocomplete
v-model="form.regions"
:items="selectableRegions"
item-title="name"
item-value="code"
multiple
chips
closable-chips
clearable
density="comfortable"
hide-details
data-testid="pdd-regions"
>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps">
<template #subtitle>
{{ FEDERAL_DISTRICT_NAMES[item.raw.federalDistrict] || '' }}
</template>
</v-list-item>
</template>
</v-autocomplete>
</div>
<div class="pdd-field">
<span class="pdd-label">Дни недели приёма</span>
<div class="pdd-days">
<button
v-for="(label, i) in dayLabels"
:key="i"
type="button"
:data-testid="`pdd-day-${i}`"
:class="['pdd-day', { active: activeDays[i] }]"
@click="toggleDay(i)"
>
{{ label }}
</button>
</div>
</div>
</div>
<footer class="pdd-foot">
<div class="pdd-foot-left">
<button class="pdd-btn pdd-btn-warning" data-testid="pdd-pause" data-tour="project-drawer-pause" @click="onPause">
<v-icon size="18" :icon="project.is_active ? 'mdi-pause' : 'mdi-play'" />
{{ project.is_active ? 'Приостановить' : 'Возобновить' }}
</button>
<button class="pdd-btn pdd-btn-error" data-testid="pdd-delete" @click="onDelete">
<v-icon size="18" icon="mdi-delete" /> Удалить
</button>
</div>
<div class="pdd-foot-right">
<button class="pdd-btn pdd-btn-text" data-testid="pdd-cancel" @click="$emit('close')">
Отмена
</button>
<button class="pdd-btn pdd-btn-primary" data-testid="pdd-save" :disabled="saving" @click="onSave">
Сохранить
</button>
</div>
</footer>
<!-- Эпик 3.2: подтверждение смены источника на залоченном проекте. -->
<div
v-if="sourceConfirmOpen"
class="pdd-confirm-overlay"
data-testid="pdd-source-change-confirm"
>
<div class="pdd-confirm-box">
<div class="pdd-confirm-title">Сменить источник?</div>
<div class="pdd-confirm-text">{{ sourceConfirmText }}</div>
<div class="pdd-confirm-actions">
<button
class="pdd-btn pdd-btn-text"
data-testid="pdd-source-confirm-no"
@click="cancelSourceChange"
>
Отмена
</button>
<button
class="pdd-btn pdd-btn-primary"
data-testid="pdd-source-confirm-yes"
@click="confirmSourceChange"
>
Сменить источник
</button>
</div>
</div>
</div>
</div>
</aside>
</template>
<style scoped>
.project-details-drawer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 480px;
background: var(--liderra-surface, #ffffff);
border-left: 1px solid var(--liderra-line, #e6e2d6);
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.06);
transform: translateX(100%);
transition: transform 240ms cubic-bezier(0.16, 1, 0.3, 1);
display: flex;
flex-direction: column;
z-index: 5;
}
.project-details-drawer.open {
transform: translateX(0);
}
.pdd-content {
display: flex;
flex-direction: column;
height: 100%;
}
.pdd-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--liderra-line, #e6e2d6);
}
.pdd-head-left {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
min-width: 0;
}
.pdd-title {
font-weight: 600;
font-size: 16px;
}
.pdd-close {
background: none;
border: 0;
cursor: pointer;
font-size: 18px;
padding: 4px;
}
.pdd-body {
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 14px;
flex: 1;
overflow-y: auto;
}
.pdd-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.pdd-label {
font-size: 12px;
color: #6b6f72;
}
.pdd-input {
padding: 8px 10px;
border: 1px solid var(--liderra-line, #e6e2d6);
border-radius: 6px;
font: inherit;
}
.pdd-days {
display: flex;
gap: 4px;
}
.pdd-day {
padding: 6px 10px;
border: 1px solid var(--liderra-line, #e6e2d6);
background: #ffffff;
border-radius: 4px;
cursor: pointer;
font: inherit;
}
.pdd-day.active {
background: #0f6e56;
color: #ffffff;
border-color: #0f6e56;
}
.pdd-foot {
display: flex;
justify-content: space-between;
padding: 12px 20px;
border-top: 1px solid var(--liderra-line, #e6e2d6);
}
.pdd-foot-left,
.pdd-foot-right {
display: flex;
gap: 8px;
}
.pdd-btn {
padding: 6px 14px;
border: 0;
border-radius: 6px;
cursor: pointer;
font: inherit;
}
.pdd-btn-text {
background: transparent;
color: #081319;
}
.pdd-btn-primary {
background: #0f6e56;
color: #ffffff;
}
.pdd-btn-warning {
background: #f59e0b;
color: #ffffff;
}
.pdd-btn-error {
background: #dc2626;
color: #ffffff;
}
.pdd-error {
color: #dc2626;
font-size: 12px;
margin-top: 4px;
}
.pdd-hint {
color: #6b6f72;
font-size: 12px;
margin-top: 4px;
}
.pdd-lock-hint {
margin-top: 6px;
font-size: 12.5px;
line-height: 1.45;
color: #23433a;
background: #f0f6f3;
border: 1px solid #cfe4db;
border-left: 3px solid #0f6e56;
border-radius: 6px;
padding: 9px 11px;
}
.pdd-confirm-overlay {
position: absolute;
inset: 0;
background: rgba(1, 32, 25, 0.45);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
z-index: 10;
}
.pdd-confirm-box {
background: #ffffff;
border-radius: 10px;
padding: 20px;
max-width: 380px;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.18);
}
.pdd-confirm-title {
font-weight: 600;
font-size: 15px;
margin-bottom: 8px;
}
.pdd-confirm-text {
font-size: 13px;
line-height: 1.5;
color: #23433a;
margin-bottom: 16px;
}
.pdd-confirm-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>