Files
portal/app/resources/js/components/projects/ProjectDetailsDrawer.vue
T
Дмитрий f7963bcfb3 fix/projects: нормализация телефона-источника + понятная ошибка и подсказка
Косяк 02: поле телефона проекта типа call отвергало +7.., 8.., пробелы и скобки.
prepareForValidation в StoreProjectRequest и UpdateProjectRequest приводит номер
через PhoneNormalizer к канону 7XXXXXXXXXX без ведущего плюса, чтобы раздача
LeadRouter матчила без плюса. Финальная regex оставлена страховкой.
Кастомные messages по signal_type: ошибка с примером формата, без имени Источник.
Фронт: постоянная подсказка под полем в NewProjectDialog и ProjectDetailsDrawer.
TDD: ProjectPhoneNormalizationTest 8 кейсов, GREEN. Проверено глазами на 8000.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:04:58 +03:00

466 lines
17 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 } from '../../stores/projectsStore';
import { useProjectsStore } from '../../stores/projectsStore';
import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
import { repositionMenuAfterOpen } from '../../utils/menuRepositionFix';
import { formatLeadDate } from '../../utils/leadDate';
const props = defineProps<{ project: Project | null }>();
const emit = defineEmits<{ close: []; saved: [appliesFrom: string | null] }>();
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.filter((r) => r.code !== 0);
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();
// Блокировка смены источника (спека 2026-06-22-project-source-edit-lock-ux).
const sourceLocked = computed(() => props.project?.source_locked === true);
const sourceLockHint = computed(() => {
const d = formatLeadDate(props.project?.source_unlock_at);
if (props.project?.source_unlock_projected) {
return (
'Чтобы изменить источник, поставьте проект на паузу. Лидерра уже собирает по нему лиды.' +
(d ? ` Поставите паузу сейчас — изменить сможете ${d} после 21:00.` : '')
);
}
return d
? `Изменить источник можно будет ${d} после 21:00. Лидерра ещё получает лиды по старому источнику.`
: 'Источник пока изменить нельзя.';
});
async function onPause(): Promise<void> {
if (!props.project) return;
await store.toggleActive(props.project);
// #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;
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 }">
<div v-if="project" class="pdd-content">
<header class="pdd-head">
<div class="pdd-title">{{ project.name }}</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-konkurent.ru"
:disabled="sourceLocked"
/>
<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"
:disabled="sourceLocked"
/>
<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 …"
:disabled="sourceLocked"
/>
<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="КРЕДИТ"
:disabled="sourceLocked"
/>
<div v-if="errors.sms_keyword" class="pdd-error">{{ errors.sms_keyword[0] }}</div>
</div>
<!-- Подсказка блокировки источника (спека 2026-06-22-project-source-edit-lock-ux). -->
<div v-if="sourceLocked" class="pdd-lock-hint" data-testid="pdd-source-lock-hint">
🔒 {{ sourceLockHint }}
</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"
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"
@update:menu="repositionMenuAfterOpen"
>
<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" @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>
</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-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;
}
</style>