Files
portal/app/resources/js/views/projects/NewProjectDialog.vue
T
Дмитрий 3630e71620
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
feat(projects): клиентская валидация формы нового проекта (F-NEWPROJECT-1) + синк статусов находок по проду
- NewProjectDialog.submit(): обязательны название и источник (домен/номер/отправители по типу сигнала); ошибка показывается сразу, без обращения к серверу. +2 TDD-теста, два существующих теста обновлены под новый гейт. Полный фронт-сьют — без новых падений; проверено глазами в браузере на локалке.
- docs: пометки находок приёмки/UI/impersonation/ADR-018 приведены к фактическому прод-статусу (M-1, M-2 капча Yandex, FN-RESET/2/3/ENC, F-CSV, apiv1-rate, N-4, F-T1, F-P1 — на проде; failed_jobs очищены 494191->0; tenant 24 удалён soft-delete).
- решение владельца 22.06: admin-area доделки (saas-admin SSO, two-person approval, role-guard супер-админа, supplier fallback, обезличенный admin_user_id) сняты как отдельные задачи — доделать единым пакетом вместе с подключением Yandex SSO после ООО.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:43:10 +03:00

492 lines
21 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>
<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="Формат: 11 цифр, начинаются с 7"
: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 && !vsyaRfConfirmed"
type="warning"
variant="tonal"
density="compact"
class="mt-2"
data-testid="vsya-rf-warning"
>
Вы выбрали всю Россию — проект будет получать лиды по всем регионам (всем субъектам РФ).
Подтвердите, что это намеренно.
<div class="mt-2">
<v-btn
size="small"
color="warning"
variant="flat"
data-testid="confirm-vsya-rf"
@click="confirmVsyaRf"
>
Подтверждаю «Вся РФ»
</v-btn>
<v-btn size="small" variant="text" class="ml-2" @click="cancelVsyaRf"> Отмена </v-btn>
</div>
</v-alert>
<v-chip
v-else-if="vsyaRfConfirmed"
color="success"
size="small"
class="mt-2"
data-testid="vsya-rf-confirmed"
>
Вся РФ — подтверждено
</v-chip>
<v-alert
v-if="requisitesRequired"
type="warning"
variant="tonal"
density="compact"
class="mt-3"
data-testid="requisites-gate-alert"
>
Сначала заполните реквизиты компании — без них нельзя создать первый проект.
<div class="mt-2">
<v-btn
size="small"
color="primary"
variant="flat"
data-testid="requisites-gate-btn"
@click="goToRequisites"
>
Заполнить реквизиты
</v-btn>
</div>
</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>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="close">Отмена</v-btn>
<v-btn 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 } from 'vue';
import { useRouter } from 'vue-router';
import { apiClient, ensureCsrfCookie, extractErrorMessage } from '../../api/client';
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);
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);
const router = useRouter();
// G1/SP3b: гейт первого проекта — бэкенд 422 {error:'requisites_required'}.
const requisitesRequired = ref(false);
function goToRequisites(): void {
close();
router.push({ path: '/settings', query: { tab: 'requisites' } });
}
// 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: обязательный выбор региона + явная «Вся РФ» с подтверждением.
// vsyaRf — чекбокс выбран; vsyaRfConfirmed — подтверждён через предупреждение.
// На бэке regions=[] (Вся РФ) и «забыл» неотличимы → гейт намеренно UI-only.
const vsyaRf = ref(false);
const vsyaRfConfirmed = ref(false);
function chooseVsyaRf(): void {
vsyaRf.value = true;
vsyaRfConfirmed.value = false;
}
function confirmVsyaRf(): void {
vsyaRfConfirmed.value = true;
form.regions = []; // Вся РФ → пустой массив субъектов
delete errors.regions;
}
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) requisitesRequired.value = false;
if (open) {
delete errors.regions;
}
if (open && props.mode === 'edit' && props.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;
}
},
{ immediate: true },
);
async function persist(extra: Record<string, unknown> = {}): Promise<void> {
saving.value = true;
try {
await ensureCsrfCookie();
const body = { ...form, ...extra };
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: гейт первого проекта — ведём клиента к форме реквизитов.
if (err.response?.status === 422 && err.response.data?.error === 'requisites_required') {
requisitesRequired.value = true;
}
// 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 });
</script>
<style scoped>
.source-hint {
line-height: 1.4;
padding: 4px 2px;
}
.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>