fa7361364d
Над «Откуда собирать заявки» добавлена строка-подсказка с tooltip: лимит распределяется по поставщикам равномерно; даже если не выбирается полностью — просто увеличить лимит, и сделок придёт больше. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
771 lines
35 KiB
Vue
771 lines
35 KiB
Vue
<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>
|
||
|
||
<div class="d-flex align-center mb-3 text-body-2 text-medium-emphasis" data-testid="np-boost-hint">
|
||
<span>Как увеличить количество сделок</span>
|
||
<v-tooltip
|
||
text="Ваш лимит распределяется на нескольких поставщиков равномерно. Даже если лимит не выбирается полностью, просто увеличьте лимит — и сделок придёт больше."
|
||
location="top"
|
||
max-width="280"
|
||
>
|
||
<template #activator="{ props: tip }">
|
||
<v-icon
|
||
v-bind="tip"
|
||
size="14"
|
||
class="src-hint ml-1"
|
||
icon="mdi-help-circle-outline"
|
||
aria-label="Как увеличить количество сделок"
|
||
tabindex="0"
|
||
/>
|
||
</template>
|
||
</v-tooltip>
|
||
</div>
|
||
|
||
<div class="d-flex align-center mb-1 text-body-2 text-medium-emphasis">
|
||
<span>Откуда собирать заявки</span>
|
||
<v-tooltip
|
||
text="Сайт — заявки с сайтов вашей ниши. Звонок — звонки на номер. СМС — заявки по СМС. Выберите подходящий источник."
|
||
location="top"
|
||
max-width="280"
|
||
>
|
||
<template #activator="{ props: tip }">
|
||
<v-icon
|
||
v-bind="tip"
|
||
size="14"
|
||
class="src-hint ml-1"
|
||
icon="mdi-help-circle-outline"
|
||
aria-label="Чем отличаются источники"
|
||
tabindex="0"
|
||
/>
|
||
</template>
|
||
</v-tooltip>
|
||
</div>
|
||
<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-moskva.ru"
|
||
class="ld-input-quiet"
|
||
data-testid="np-source-identifier"
|
||
: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
|
||
class="ld-input-quiet"
|
||
data-testid="np-source-identifier"
|
||
: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>
|
||
|
||
<!-- Эпик 3.1: объявление о вступлении правок количества/региона/дней (без блокировки). -->
|
||
<v-alert
|
||
v-if="showAppliesFromBanner"
|
||
type="info"
|
||
variant="tonal"
|
||
density="comfortable"
|
||
class="mt-3"
|
||
data-testid="np-applies-from-banner"
|
||
>
|
||
⏳ Ближайший сбор уже идёт по текущим настройкам. Новые количество, регионы
|
||
и дни приёма вступят в силу с {{ appliesFromDate }}.
|
||
</v-alert>
|
||
|
||
<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"
|
||
data-testid="np-limit"
|
||
hint="Сколько заявок в день вы готовы принимать и оплачивать. Можно менять в любой момент."
|
||
persistent-hint
|
||
: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"
|
||
>
|
||
<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"
|
||
/>
|
||
|
||
<!-- Эпик 3.2: подтверждение смены источника на залоченном проекте. -->
|
||
<v-dialog v-model="sourceConfirmOpen" max-width="430">
|
||
<v-card data-testid="np-source-change-confirm">
|
||
<v-card-title>Сменить источник?</v-card-title>
|
||
<v-card-text>{{ sourceConfirmText }}</v-card-text>
|
||
<v-card-actions>
|
||
<v-spacer />
|
||
<v-btn variant="text" data-testid="np-source-confirm-no" @click="cancelSourceChange">Отмена</v-btn>
|
||
<v-btn color="primary" data-testid="np-source-confirm-yes" @click="confirmSourceChange">
|
||
Сменить источник
|
||
</v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-dialog>
|
||
</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, formatLeadDate } from '../../utils/leadDate';
|
||
import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
|
||
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());
|
||
|
||
// Эпик 3 (редизайн 2026-06-25): в edit-режиме источник редактируем (не readonly).
|
||
// Замок заменён объявлением о вступлении + подтверждением смены источника —
|
||
// раздача доводит хвост по старому источнику через слепок.
|
||
const sourceLocked = computed(() => props.mode === 'edit' && 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 МСК) — объявление актуально по времени суток.
|
||
const appliesFromDate = computed(() => firstLeadDate());
|
||
|
||
const sourceDirty = computed(() => {
|
||
const p = props.project;
|
||
if (!p) return false;
|
||
if (form.signal_type === 'sms') {
|
||
return (
|
||
JSON.stringify(form.sms_senders) !== JSON.stringify(p.sms_senders ?? []) ||
|
||
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}) — уже по новому. ` : '') +
|
||
'Подтвердите смену источника.'
|
||
);
|
||
});
|
||
|
||
function cancelSourceChange(): void {
|
||
sourceConfirmOpen.value = false;
|
||
}
|
||
|
||
async function confirmSourceChange(): Promise<void> {
|
||
sourceConfirmOpen.value = false;
|
||
await persist();
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
// Эпик 3.2: смена источника на залоченном проекте — через подтверждение.
|
||
if (sourceLocked.value && sourceDirty.value) {
|
||
sourceConfirmOpen.value = true;
|
||
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>
|