Files
portal/app/resources/js/views/projects/NewProjectDialog.vue
T
Дмитрий 394c97e83e fix/projects: косяк 04 — реквизиты первым шагом визарда создания (вариант C)
Новый клиент без реквизитов заполняет короткие реквизиты прямо в окне
создания проекта (шаг 1: тип лица, имя, телефон, ИНН для юр/ИП), затем
переходит к форме проекта (шаг 2) — без ухода на /settings и без потери
черновика. Решение по шагу — по light-complete реквизитов (fail-open при
ошибке чтения; бэкенд-гейт 422 остаётся запасным путём, тоже инлайн).
Убран старый алерт с уходом на /settings. Vitest 20 passed, оба пути
проверены глазами на 8000 (есть реквизиты сразу проект; нет шаг 1 затем 2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 16:07:02 +03:00

642 lines
28 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<v-dialog :model-value="modelValue" max-width="720" @update:model-value="$emit('update:modelValue', $event)">
<v-card style="position: relative">
<DevIndexBadge
:index="mode === 'edit' ? 19 : 18"
:label="mode === 'edit' ? 'EditProjectDialog' : 'NewProjectDialog'"
:dialog-mode="true"
style="top: 12px; right: 12px"
/>
<v-card-title>{{ mode === 'edit' ? 'Редактирование проекта' : 'Новый проект' }}</v-card-title>
<v-card-text>
<!-- Косяк 04 / вариант C: для нового клиента без реквизитов шаг 1
(короткие реквизиты) прямо в визарде, без ухода на /settings. -->
<div v-if="step === 'requisites'" data-testid="req-step">
<div class="steps-head mb-3">
<span class="step-on">1. Реквизиты</span>
<span class="step-sep"></span>
<span class="step-off">2. Проект</span>
</div>
<v-alert type="info" variant="tonal" density="comfortable" class="mb-4">
Сначала короткие реквизиты компании без них нельзя создать первый проект. Это пара минут.
</v-alert>
<v-select
v-model="reqForm.subject_type"
:items="subjectTypeItems"
item-title="title"
item-value="value"
label="Тип лица"
density="comfortable"
class="ld-input-quiet"
data-testid="req-subject-type"
:error-messages="reqErrors.subject_type"
/>
<v-text-field
v-model="reqForm.contact_name"
label="Контактное имя"
density="comfortable"
class="ld-input-quiet"
data-testid="req-contact-name"
:error-messages="reqErrors.contact_name"
/>
<v-text-field
v-model="reqForm.contact_phone"
label="Контактный телефон"
hint="Примем в любом формате — приведём сами"
persistent-hint
density="comfortable"
class="ld-input-quiet"
data-testid="req-contact-phone"
:error-messages="reqErrors.contact_phone"
/>
<v-text-field
v-if="reqForm.subject_type === 'legal_entity' || reqForm.subject_type === 'sole_proprietor'"
v-model="reqForm.inn"
label="ИНН"
density="comfortable"
class="ld-input-quiet mt-2"
data-testid="req-inn"
:error-messages="reqErrors.inn"
/>
<v-alert
v-if="reqGeneralError"
type="error"
variant="tonal"
density="compact"
class="mt-2"
>
{{ reqGeneralError }}
</v-alert>
</div>
<template v-else>
<!-- Баннер-объявление: когда пойдут лиды (спека 2026-06-22-project-source-edit-lock-ux). -->
<v-alert
v-if="mode !== 'edit'"
type="info"
variant="tonal"
density="comfortable"
class="mb-4"
data-testid="np-lead-banner"
>
📣 Лидерра поставит проект в сбор сразу после создания. Первые лиды пойдут с {{ leadStart }}.
</v-alert>
<v-tabs v-model="form.signal_type" :disabled="mode === 'edit'" color="primary">
<v-tab value="site"><v-icon start>mdi-web</v-icon>Сайт</v-tab>
<v-tab value="call"><v-icon start>mdi-phone</v-icon>Звонок</v-tab>
<v-tab value="sms"><v-icon start>mdi-message-text</v-icon>СМС</v-tab>
</v-tabs>
<v-tabs-window v-model="form.signal_type" class="mt-4">
<v-tabs-window-item value="site">
<div class="source-hint text-caption text-medium-emphasis mb-2">
Источник домен сайта-«донора», с которого приходят лиды
</div>
<v-text-field
v-model="form.signal_identifier"
label="Домен конкурента"
placeholder="okna-konkurent.ru"
:readonly="mode === 'edit'"
class="ld-input-quiet"
:error-messages="errors.signal_identifier"
/>
</v-tabs-window-item>
<v-tabs-window-item value="call">
<div class="source-hint text-caption text-medium-emphasis mb-2">
Источник телефонный номер «донора», на который звонят клиенты
</div>
<v-text-field
v-model="form.signal_identifier"
label="Номер конкурента"
placeholder="79161234567"
hint="Можно вводить с +7, 8, скобками и пробелами — приведём к виду 79161234567"
persistent-hint
:readonly="mode === 'edit'"
class="ld-input-quiet"
:error-messages="errors.signal_identifier"
/>
</v-tabs-window-item>
<v-tabs-window-item value="sms">
<div class="source-hint text-caption text-medium-emphasis mb-2">
Источник отправитель SMS и (опционально) ключевое слово в тексте
</div>
<v-combobox
v-model="form.sms_senders"
label="Отправители (до 11 символов каждый)"
multiple
chips
clearable
:error-messages="errors.sms_senders"
/>
<v-text-field
v-model="form.sms_keyword"
label="Ключевое слово (опционально)"
hint="Если пусто — проект подключится только к B3"
class="ld-input-quiet"
:error-messages="errors.sms_keyword"
/>
</v-tabs-window-item>
</v-tabs-window>
<v-divider class="my-4" />
<v-text-field
v-model="form.name"
label="Название проекта"
class="ld-input-quiet"
:error-messages="errors.name"
/>
<v-text-field
v-model.number="form.daily_limit_target"
label="Лимит лидов в день"
type="number"
min="1"
max="10000"
class="ld-input-quiet"
:error-messages="errors.daily_limit_target"
/>
<v-autocomplete
v-model:search="regionSearch"
:model-value="form.regions"
:items="selectableRegions"
item-title="name"
item-value="code"
label="Регионы"
:disabled="vsyaRfConfirmed"
multiple
chips
closable-chips
clearable
density="comfortable"
class="ld-input-quiet"
data-testid="regions-autocomplete"
:error-messages="errors.regions"
@update:model-value="onRegionsChange"
@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>
<v-checkbox
:model-value="vsyaRf"
label="Вся РФ (все регионы)"
density="comfortable"
hide-details
data-testid="vsya-rf-checkbox"
@update:model-value="(v: boolean | null) => (v ? chooseVsyaRf() : cancelVsyaRf())"
/>
<v-alert
v-if="vsyaRf"
type="info"
variant="tonal"
density="compact"
class="mt-2"
data-testid="vsya-rf-confirmed"
>
Проект будет получать лиды по всей России — по всем субъектам страны.
</v-alert>
<v-alert
v-if="generalError"
type="error"
variant="tonal"
density="compact"
class="mt-3"
closable
@click:close="generalError = null"
>
{{ generalError }}
</v-alert>
<div class="mt-3">
<span class="text-caption">Дни недели приёма</span>
<v-btn-toggle v-model="selectedDays" multiple density="comfortable" class="mt-1">
<v-btn v-for="(day, i) in dayLabels" :key="i" :value="i">{{ day }}</v-btn>
</v-btn-toggle>
<div class="mt-1">
<v-btn size="small" variant="text" @click="setWorkdays('weekdays')">Будни</v-btn>
<v-btn size="small" variant="text" @click="setWorkdays('all')">Все дни</v-btn>
</div>
</div>
</template>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="close">Отмена</v-btn>
<v-btn
v-if="step === 'requisites'"
color="primary"
:loading="reqSaving"
data-testid="req-next-btn"
@click="saveRequisites"
>
Далее: к проекту
</v-btn>
<v-btn v-else color="primary" :loading="saving" data-testid="submit-btn" @click="submit">
{{ mode === 'edit' ? 'Сохранить' : 'Создать' }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<ProjectLimitOverloadDialog
v-model="overloadOpen"
:payload="overloadPayload"
@save-blocked="onOverloadSaveBlocked"
@set-zero="onOverloadSetZero"
/>
</template>
<script setup lang="ts">
import { ref, reactive, watch, computed } from 'vue';
import { apiClient, ensureCsrfCookie, extractErrorMessage } from '../../api/client';
import { getRequisites, updateRequisites, type Requisites } from '../../api/requisites';
import { firstLeadDate } from '../../utils/leadDate';
import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
import { repositionMenuAfterOpen } from '../../utils/menuRepositionFix';
import type { Project } from '../../stores/projectsStore';
import DevIndexBadge from '../../components/DevIndexBadge.vue';
import ProjectLimitOverloadDialog from '../../components/projects/ProjectLimitOverloadDialog.vue';
const selectableRegions = REGIONS.filter((r) => r.code !== 0);
// Дата старта лидов для баннера нового проекта (правило слепка 18:00 МСК).
const leadStart = computed(() => firstLeadDate());
const props = defineProps<{
modelValue: boolean;
mode?: 'create' | 'edit';
project?: Project | null;
}>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
saved: [appliesFrom: string | null];
}>();
// Plan 6: regions = subject codes (1..89) — backend dual-writes region_mask/region_mode.
// Пустой массив = вся РФ.
const form = reactive({
name: '',
signal_type: 'site' as 'site' | 'call' | 'sms',
signal_identifier: '',
sms_senders: [] as string[],
sms_keyword: '',
daily_limit_target: 50,
regions: [] as number[],
delivery_days_mask: 127,
});
const errors = reactive<Record<string, string[]>>({});
const saving = ref(false);
const generalError = ref<string | null>(null);
// Косяк 04 / вариант C: визард создания. step='requisites' — новый клиент без
// реквизитов заполняет их прямо здесь (шаг 1), затем step='project' (шаг 2).
// Реквизиты неполны ⟺ проектов ещё нет (гейт G1/SP2 не пускал без них), поэтому
// решение по шагу принимаем по light-complete, без отдельного счётчика проектов.
const step = ref<'requisites' | 'project'>('project');
const reqForm = reactive({ subject_type: '', contact_name: '', contact_phone: '', inn: '' });
const reqErrors = reactive<Record<string, string[]>>({});
const reqSaving = ref(false);
const reqGeneralError = ref<string | null>(null);
const subjectTypeItems = [
{ value: 'individual', title: 'Физлицо' },
{ value: 'sole_proprietor', title: 'ИП' },
{ value: 'legal_entity', title: 'Юрлицо' },
];
// Зеркало RequisitesService::isLightComplete — тип лица + имя + телефон (+ ИНН для юр/ИП).
function reqLightComplete(r: Requisites | null): boolean {
if (!r || !r.subject_type || !r.contact_name?.trim() || !r.contact_phone?.trim()) {
return false;
}
if ((r.subject_type === 'legal_entity' || r.subject_type === 'sole_proprietor') && !r.inn?.trim()) {
return false;
}
return true;
}
async function initCreateStep(): Promise<void> {
step.value = 'project';
try {
const r = await getRequisites();
if (!reqLightComplete(r)) {
step.value = 'requisites';
if (r) {
reqForm.subject_type = r.subject_type ?? '';
reqForm.contact_name = r.contact_name ?? '';
reqForm.contact_phone = r.contact_phone ?? '';
reqForm.inn = r.inn ?? '';
}
}
} catch {
// fail-open: при ошибке чтения не блокируем — бэкенд-гейт защитит на submit (422 → шаг 1).
step.value = 'project';
}
}
async function saveRequisites(): Promise<void> {
Object.keys(reqErrors).forEach((k) => delete reqErrors[k]);
reqGeneralError.value = null;
reqSaving.value = true;
try {
await updateRequisites({
subject_type: reqForm.subject_type as Requisites['subject_type'],
contact_name: reqForm.contact_name,
contact_phone: reqForm.contact_phone,
inn: reqForm.inn || null,
});
step.value = 'project';
} 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(reqErrors, err.response.data.errors);
} else {
reqGeneralError.value = extractErrorMessage(e);
}
} finally {
reqSaving.value = false;
}
}
// Spec C §6.2: префлайт баланса — диалог перегрузки лимита по 409.
interface OverloadPayloadShape {
current_balance_rub: string;
current_capacity_leads: number;
would_be_required_leads: number;
deficit_leads: number;
}
const overloadOpen = ref(false);
const overloadPayload = ref<OverloadPayloadShape | null>(null);
// Plan 4 Task 4 + косяк 03: явная «Вся РФ» одной галочкой (защита от случайной
// «всей РФ» сохранена — нужен осознанный клик), без отдельной кнопки подтверждения.
// vsyaRf — чекбокс выбран; vsyaRfConfirmed — true сразу при установке галочки.
// На бэке regions=[] (Вся РФ) и «забыл» неотличимы → гейт намеренно UI-only.
const vsyaRf = ref(false);
const vsyaRfConfirmed = ref(false);
function chooseVsyaRf(): void {
vsyaRf.value = true;
confirmVsyaRf();
}
function confirmVsyaRf(): void {
vsyaRfConfirmed.value = true;
form.regions = []; // Вся РФ → пустой массив субъектов
delete errors.regions; // косяк 03: галочка сразу снимает зависшую ошибку
}
function cancelVsyaRf(): void {
vsyaRf.value = false;
vsyaRfConfirmed.value = false;
}
function onRegionsChange(codes: number[]): void {
form.regions = Array.isArray(codes) ? codes : [];
// После выбора субъекта очищаем строку поиска (иначе набранное «красно»
// остаётся в поле рядом с чипом).
regionSearch.value = '';
if (form.regions.length > 0) {
// Взаимоисключение: выбор конкретных субъектов снимает «Вся РФ».
vsyaRf.value = false;
vsyaRfConfirmed.value = false;
delete errors.regions;
}
}
const regionSearch = ref('');
const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
const selectedDays = ref<number[]>([0, 1, 2, 3, 4, 5, 6]);
watch(selectedDays, (days) => {
form.delivery_days_mask = days.reduce((acc, d) => acc | (1 << d), 0);
});
function setWorkdays(preset: 'weekdays' | 'all') {
if (preset === 'weekdays') selectedDays.value = [0, 1, 2, 3, 4];
else selectedDays.value = [0, 1, 2, 3, 4, 5, 6];
}
watch(
() => props.modelValue,
(open) => {
if (open) generalError.value = null;
if (open) {
delete errors.regions;
}
if (open && props.mode === 'edit' && props.project) {
step.value = 'project'; // редактирование — всегда форма проекта, без шага реквизитов
Object.assign(form, props.project);
form.regions = Array.isArray(props.project.regions) ? [...props.project.regions] : [];
const days: number[] = [];
for (let i = 0; i < 7; i++) if (form.delivery_days_mask & (1 << i)) days.push(i);
selectedDays.value = days;
// Существующий проект с пустыми регионами = «Вся РФ» (предзаполняем подтверждённым).
vsyaRf.value = form.regions.length === 0;
vsyaRfConfirmed.value = form.regions.length === 0;
} else if (open) {
Object.assign(form, {
name: '',
signal_type: 'site',
signal_identifier: '',
sms_senders: [],
sms_keyword: '',
daily_limit_target: 50,
regions: [],
delivery_days_mask: 127,
});
selectedDays.value = [0, 1, 2, 3, 4, 5, 6];
vsyaRf.value = false;
vsyaRfConfirmed.value = false;
// Косяк 04 / вариант C: решаем, нужен ли шаг реквизитов (новый клиент без них).
Object.assign(reqForm, { subject_type: '', contact_name: '', contact_phone: '', inn: '' });
Object.keys(reqErrors).forEach((k) => delete reqErrors[k]);
reqGeneralError.value = null;
void initCreateStep();
}
},
{ immediate: true },
);
async function persist(extra: Record<string, unknown> = {}): Promise<void> {
saving.value = true;
try {
await ensureCsrfCookie();
const body: Record<string, unknown> = { ...form, ...extra };
// M (балансовый блок findings): для site/call не отправляем sms-поля. Форма
// держит sms_senders=null/[] и sms_keyword, а UpdateProjectRequest валидирует
// sms_senders как array|min:1 → null давал молчаливый 422 (поле sms на форме
// site/call не отрисовано, ошибка не видна). Заодно не триггерим зря snapshot-guard.
if (form.signal_type !== 'sms') {
delete body.sms_senders;
delete body.sms_keyword;
}
let appliesFrom: string | null = null;
if (props.mode === 'edit' && props.project) {
const { data } = await apiClient.patch(`/api/projects/${props.project.id}`, body);
// Backend кладёт applies_from только когда правка задела slepok-чувствительные поля.
appliesFrom = data?.data?.applies_from ?? null;
} else {
await apiClient.post('/api/projects', body);
// Create НЕ генерирует applies_from (новый проект сразу попадает в snapshot).
}
overloadOpen.value = false;
emit('saved', appliesFrom);
close();
} catch (e: unknown) {
const err = e as {
response?: { status?: number; data?: { error?: string; errors?: Record<string, string[]> } };
};
// G1/SP3b + косяк 04: гейт первого проекта — показываем шаг реквизитов
// прямо в этом окне (запасной путь, обычно шаг 1 показан ещё при открытии).
if (err.response?.status === 422 && err.response.data?.error === 'requisites_required') {
step.value = 'requisites';
void initCreateStep();
}
// Spec C §6.2: лимит превышает баланс — открываем диалог перегрузки.
else if (err.response?.status === 409 && err.response.data?.error === 'balance_insufficient') {
overloadPayload.value = err.response.data as OverloadPayloadShape;
overloadOpen.value = true;
} else if (err.response?.status === 422 && err.response.data?.errors) {
Object.assign(errors, err.response.data.errors);
} else {
generalError.value = extractErrorMessage(e);
}
} finally {
saving.value = false;
}
}
async function submit() {
generalError.value = null;
Object.keys(errors).forEach((k) => delete errors[k]);
// F-NEWPROJECT-1: клиентская валидация обязательных полей — показываем ошибку
// сразу, не дожидаясь ответа сервера (сервер всё равно валидирует повторно).
const clientErrors: Record<string, string[]> = {};
if (!form.name.trim()) {
clientErrors.name = ['Введите название проекта'];
}
if (form.signal_type === 'sms') {
if (form.sms_senders.length === 0) {
clientErrors.sms_senders = ['Укажите хотя бы одного отправителя'];
}
} else if (!form.signal_identifier.trim()) {
clientErrors.signal_identifier = [
form.signal_type === 'call' ? 'Введите номер конкурента' : 'Введите домен конкурента',
];
}
// Гейт обязательного региона: нужны либо субъекты, либо подтверждённая «Вся РФ».
if (form.regions.length === 0 && !vsyaRfConfirmed.value) {
clientErrors.regions = ['Выберите регион или подтвердите «Вся РФ»'];
}
if (Object.keys(clientErrors).length > 0) {
Object.assign(errors, clientErrors);
return;
}
await persist();
}
// Spec C §6.2 — исходы диалога перегрузки лимита.
async function onOverloadSaveBlocked(): Promise<void> {
await persist({ force_save_blocked: true });
}
async function onOverloadSetZero(): Promise<void> {
form.daily_limit_target = 0;
overloadOpen.value = false;
await persist();
}
function close() {
emit('update:modelValue', false);
}
defineExpose({
chooseVsyaRf,
confirmVsyaRf,
cancelVsyaRf,
onRegionsChange,
vsyaRf,
vsyaRfConfirmed,
form,
submit,
step,
reqForm,
saveRequisites,
});
</script>
<style scoped>
.source-hint {
line-height: 1.4;
padding: 4px 2px;
}
.steps-head {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
}
.steps-head .step-on {
color: var(--liderra-teal, #0f6e56);
font-weight: 650;
}
.steps-head .step-off {
color: #6b6f72;
}
.steps-head .step-sep {
flex: 1;
height: 1px;
background: var(--liderra-line, #e6e2d6);
}
.ld-input-quiet :deep(.v-field) {
border-radius: var(--radius-8);
}
.ld-input-quiet :deep(.v-field__outline__start),
.ld-input-quiet :deep(.v-field__outline__end),
.ld-input-quiet :deep(.v-field__outline__notch::before),
.ld-input-quiet :deep(.v-field__outline__notch::after) {
border-color: var(--liderra-line);
opacity: 1;
transition: border-color 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
.ld-input-quiet :deep(.v-field:hover .v-field__outline__start),
.ld-input-quiet :deep(.v-field:hover .v-field__outline__end),
.ld-input-quiet :deep(.v-field:hover .v-field__outline__notch::before),
.ld-input-quiet :deep(.v-field:hover .v-field__outline__notch::after) {
border-color: var(--liderra-line-strong);
opacity: 1;
}
.ld-input-quiet :deep(.v-field--focused .v-field__outline__start),
.ld-input-quiet :deep(.v-field--focused .v-field__outline__end),
.ld-input-quiet :deep(.v-field--focused .v-field__outline__notch::before),
.ld-input-quiet :deep(.v-field--focused .v-field__outline__notch::after) {
border-color: var(--liderra-teal);
opacity: 1;
}
.ld-input-quiet :deep(.v-field--error:not(.v-field--disabled) .v-field__outline__start),
.ld-input-quiet :deep(.v-field--error:not(.v-field--disabled) .v-field__outline__end),
.ld-input-quiet :deep(.v-field--error:not(.v-field--disabled) .v-field__outline__notch::before),
.ld-input-quiet :deep(.v-field--error:not(.v-field--disabled) .v-field__outline__notch::after) {
border-color: currentColor;
opacity: 1;
}
</style>