2026-05-11 19:26:38 +03:00
< template >
< v-dialog :model-value = "modelValue" max -width = " 720 " @ update : model -value = " $ emit ( ' update : modelValue ' , $ event ) " >
2026-05-12 20:24:33 +03:00
< v-card style = "position: relative" >
2026-05-12 04:45:18 +03:00
< DevIndexBadge
: index = "mode === 'edit' ? 19 : 18"
: label = "mode === 'edit' ? 'EditProjectDialog' : 'NewProjectDialog'"
:dialog-mode = "true"
2026-05-12 20:24:33 +03:00
style = "top: 12px; right: 12px"
2026-05-12 04:45:18 +03:00
/ >
2026-05-11 19:26:38 +03:00
< v-card-title > { { mode === 'edit' ? 'Редактирование проекта' : 'Новый проект' } } < / v-card-title >
< v-card-text >
2026-06-24 16:07:02 +03:00
<!-- Косяк 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 20:59:02 +03:00
< ! - - Баннер -объявление : когда пойдут лиды ( спека 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 >
2026-06-27 13:32:09 +03:00
< 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 >
2026-06-25 13:41:27 +03:00
< 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 >
2026-05-11 19:26:38 +03:00
< 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" >
2026-05-18 15:36:09 +03:00
< div class = "source-hint text-caption text-medium-emphasis mb-2" >
2026-06-25 12:02:52 +03:00
Укажите сайт в вашей нише , по теме которого нужны заявки — приведём вам таких же клиентов
2026-05-18 15:36:09 +03:00
< / div >
2026-05-11 19:26:38 +03:00
< v-text-field
v-model = "form.signal_identifier"
2026-06-25 05:16:11 +03:00
label = "Сайт, с которого нужны заявки"
placeholder = "okna-moskva.ru"
2026-05-12 18:07:20 +03:00
class = "ld-input-quiet"
2026-06-25 18:28:14 +03:00
data -testid = " np -source -identifier "
2026-05-11 19:26:38 +03:00
:error-messages = "errors.signal_identifier"
/ >
< / v-tabs-window-item >
< v-tabs-window-item value = "call" >
2026-05-18 15:36:09 +03:00
< div class = "source-hint text-caption text-medium-emphasis mb-2" >
2026-06-25 12:02:52 +03:00
Укажите телефон в вашей нише , по звонкам на который соберём вам заявки
2026-05-18 15:36:09 +03:00
< / div >
2026-05-11 19:26:38 +03:00
< v-text-field
v-model = "form.signal_identifier"
2026-06-25 05:16:11 +03:00
label = "Телефон, по которому идут клиенты"
2026-05-11 19:26:38 +03:00
placeholder = "79161234567"
2026-06-24 13:04:58 +03:00
hint = "Можно вводить с +7, 8, скобками и пробелами — приведём к виду 79161234567"
persistent -hint
2026-05-12 18:07:20 +03:00
class = "ld-input-quiet"
2026-06-25 18:28:14 +03:00
data -testid = " np -source -identifier "
2026-05-11 19:26:38 +03:00
:error-messages = "errors.signal_identifier"
/ >
< / v-tabs-window-item >
< v-tabs-window-item value = "sms" >
2026-05-18 15:36:09 +03:00
< div class = "source-hint text-caption text-medium-emphasis mb-2" >
Источник — отправитель SMS и ( опционально ) ключевое слово в тексте
< / div >
2026-05-11 19:26:38 +03:00
< 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"
2026-05-12 18:07:20 +03:00
class = "ld-input-quiet"
2026-05-11 19:26:38 +03:00
:error-messages = "errors.sms_keyword"
/ >
< / v-tabs-window-item >
< / v-tabs-window >
2026-06-25 18:28:14 +03:00
<!-- Эпик 3.1 : объявление о вступлении правок количества / региона / дней ( без блокировки ) . -- >
< v-alert
v-if = "showAppliesFromBanner"
type = "info"
variant = "tonal"
density = "comfortable"
class = "mt-3"
data -testid = " np -applies -from -banner "
>
2026-06-25 19:40:04 +03:00
⏳ Ближайший сбор уже идёт по текущим настройкам . Новые количество , регионы
и дни приёма вступят в силу с { { appliesFromDate } } .
2026-06-25 18:28:14 +03:00
< / v-alert >
2026-05-11 19:26:38 +03:00
< v-divider class = "my-4" / >
2026-05-12 20:24:33 +03:00
< v-text-field
v-model = "form.name"
label = "Название проекта"
class = "ld-input-quiet"
:error-messages = "errors.name"
/ >
2026-05-11 19:26:38 +03:00
< v-text-field
v -model .number = " form.daily_limit_target "
label = "Лимит лидов в день"
type = "number"
min = "1"
max = "10000"
2026-05-12 18:07:20 +03:00
class = "ld-input-quiet"
2026-06-25 18:28:14 +03:00
data -testid = " np -limit "
2026-06-25 13:41:27 +03:00
hint = "Сколько заявок в день вы готовы принимать и оплачивать. Можно менять в любой момент."
persistent -hint
2026-05-11 19:26:38 +03:00
:error-messages = "errors.daily_limit_target"
/ >
2026-05-15 05:54:05 +03:00
< v-autocomplete
2026-06-21 12:16:45 +03:00
v -model :search = "regionSearch"
2026-05-20 14:19:09 +03:00
:model-value = "form.regions"
2026-05-15 05:54:05 +03:00
:items = "selectableRegions"
item -title = " name "
item -value = " code "
2026-05-20 14:19:09 +03:00
label = "Регионы"
:disabled = "vsyaRfConfirmed"
2026-05-15 05:54:05 +03:00
multiple
chips
2026-05-23 10:21:10 +03:00
closable -chips
2026-05-15 05:54:05 +03:00
clearable
density = "comfortable"
class = "ld-input-quiet"
data -testid = " regions -autocomplete "
2026-05-20 14:19:09 +03:00
:error-messages = "errors.regions"
@ update :model-value = "onRegionsChange"
2026-05-15 05:54:05 +03:00
>
< 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 >
2026-05-20 14:19:09 +03:00
< 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
2026-06-24 13:31:10 +03:00
v-if=" vsyaRf "
type=" info "
2026-05-20 14:19:09 +03:00
variant=" tonal "
density=" compact "
class=" mt - 2 "
data-testid=" vsya - rf - confirmed "
>
2026-06-24 13:31:10 +03:00
Проект будет получать лиды по всей России — по всем субъектам страны.
</v-alert>
2026-05-20 14:19:09 +03:00
2026-06-19 07:24:27 +03:00
2026-05-14 19:28:33 +03:00
<v-alert
v-if=" generalError "
type=" error "
variant=" tonal "
density=" compact "
2026-05-15 05:54:05 +03:00
class=" mt - 3 "
2026-05-14 19:28:33 +03:00
closable
@click:close=" generalError = null "
>
{{ generalError }}
</v-alert>
2026-05-11 19:26:38 +03:00
<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>
2026-06-24 16:07:02 +03:00
</template>
2026-05-11 19:26:38 +03:00
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant=" text " @click=" close ">Отмена</v-btn>
2026-06-24 16:07:02 +03:00
<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 ">
2026-05-11 19:26:38 +03:00
{{ mode === 'edit' ? 'Сохранить' : 'Создать' }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
2026-05-25 06:23:59 +03:00
<ProjectLimitOverloadDialog
v-model=" overloadOpen "
:payload=" overloadPayload "
@save-blocked=" onOverloadSaveBlocked "
@set-zero=" onOverloadSetZero "
/>
2026-06-25 18:28:14 +03:00
<!-- Эпик 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>
2026-05-11 19:26:38 +03:00
</template>
<script setup lang=" ts " >
2026-06-22 20:59:02 +03:00
import { ref , reactive , watch , computed } from 'vue' ;
2026-05-14 19:28:33 +03:00
import { apiClient , ensureCsrfCookie , extractErrorMessage } from '../../api/client' ;
2026-06-24 16:07:02 +03:00
import { getRequisites , updateRequisites , type Requisites } from '../../api/requisites' ;
2026-06-25 18:28:14 +03:00
import { firstLeadDate , formatLeadDate } from '../../utils/leadDate' ;
2026-05-15 05:54:05 +03:00
import { REGIONS , FEDERAL _DISTRICT _NAMES } from '../../constants/regions' ;
2026-05-12 20:15:26 +03:00
import type { Project } from '../../stores/projectsStore' ;
2026-05-12 04:45:18 +03:00
import DevIndexBadge from '../../components/DevIndexBadge.vue' ;
2026-05-25 06:23:59 +03:00
import ProjectLimitOverloadDialog from '../../components/projects/ProjectLimitOverloadDialog.vue' ;
2026-05-11 19:26:38 +03:00
2026-05-15 05:54:05 +03:00
const selectableRegions = REGIONS . filter ( ( r ) => r . code !== 0 ) ;
2026-06-22 20:59:02 +03:00
// Дата старта лидов для баннера нового проекта (правило слепка 18:00 МСК).
const leadStart = computed ( ( ) => firstLeadDate ( ) ) ;
2026-06-25 18:28:14 +03:00
// Эпик 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 ) ;
2026-06-25 19:40:04 +03:00
// Time-aware дата вступления (правило слепка 18:00 МСК) — объявление актуально по времени суток.
const appliesFromDate = computed ( ( ) => firstLeadDate ( ) ) ;
2026-06-25 18:28:14 +03:00
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 ( ( ) => {
2026-06-25 19:08:47 +03:00
// Эпик 6.3: единый текст правила из API (ProjectRuleMessages). Fallback — локальный.
const fromApi = props . project ? . source _change _message ;
if ( fromApi ) {
return ` Мы уже ведём сбор на завтра. ${ fromApi } Подтвердите смену источника. ` ;
}
2026-06-25 18:28:14 +03:00
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 ( ) ;
}
2026-05-11 19:26:38 +03:00
const props = defineProps < {
modelValue : boolean ;
mode ? : 'create' | 'edit' ;
2026-05-12 20:15:26 +03:00
project ? : Project | null ;
2026-05-11 19:26:38 +03:00
} > ( ) ;
2026-05-28 09:52:42 +03:00
const emit = defineEmits < {
'update:modelValue' : [ value : boolean ] ;
saved : [ appliesFrom : string | null ] ;
} > ( ) ;
2026-05-11 19:26:38 +03:00
2026-05-15 05:54:05 +03:00
// Plan 6: regions = subject codes (1..89) — backend dual-writes region_mask/region_mode.
// Пустой массив = вся РФ.
2026-05-11 19:26:38 +03:00
const form = reactive ( {
name : '' ,
signal _type : 'site' as 'site' | 'call' | 'sms' ,
signal _identifier : '' ,
sms _senders : [ ] as string [ ] ,
sms _keyword : '' ,
daily _limit _target : 50 ,
2026-05-15 05:54:05 +03:00
regions : [ ] as number [ ] ,
2026-05-11 19:26:38 +03:00
delivery _days _mask : 127 ,
} ) ;
const errors = reactive < Record < string , string [ ] > > ( { } ) ;
const saving = ref ( false ) ;
2026-05-14 19:28:33 +03:00
const generalError = ref < string | null > ( null ) ;
2026-05-11 19:26:38 +03:00
2026-06-24 16:07:02 +03:00
// Косяк 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 ;
}
2026-06-19 07:24:27 +03:00
2026-06-24 16:07:02 +03:00
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' ;
}
}
2026-06-19 07:24:27 +03:00
2026-06-24 16:07:02 +03:00
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 ;
}
2026-06-19 07:24:27 +03:00
}
2026-05-25 06:23:59 +03:00
// 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 ) ;
2026-06-24 13:31:10 +03:00
// Plan 4 Task 4 + косяк 03: явная «Вся РФ» одной галочкой (защита от случайной
// «всей РФ» сохранена — нужен осознанный клик), без отдельной кнопки подтверждения.
// vsyaRf — чекбокс выбран; vsyaRfConfirmed — true сразу при установке галочки.
2026-05-20 14:19:09 +03:00
// На бэке regions=[] (Вся РФ) и «забыл» неотличимы → гейт намеренно UI-only.
const vsyaRf = ref ( false ) ;
const vsyaRfConfirmed = ref ( false ) ;
function chooseVsyaRf ( ) : void {
vsyaRf . value = true ;
2026-06-24 13:31:10 +03:00
confirmVsyaRf ( ) ;
2026-05-20 14:19:09 +03:00
}
function confirmVsyaRf ( ) : void {
vsyaRfConfirmed . value = true ;
form . regions = [ ] ; // Вся РФ → пустой массив субъектов
2026-06-24 13:31:10 +03:00
delete errors . regions ; // косяк 03: галочка сразу снимает зависшую ошибку
2026-05-20 14:19:09 +03:00
}
function cancelVsyaRf ( ) : void {
vsyaRf . value = false ;
vsyaRfConfirmed . value = false ;
}
function onRegionsChange ( codes : number [ ] ) : void {
form . regions = Array . isArray ( codes ) ? codes : [ ] ;
2026-06-21 12:16:45 +03:00
// После выбора субъекта очищаем строку поиска (иначе набранное «красно»
// остаётся в поле рядом с чипом).
regionSearch . value = '' ;
2026-05-20 14:19:09 +03:00
if ( form . regions . length > 0 ) {
// Взаимоисключение: выбор конкретных субъектов снимает «Вся РФ».
vsyaRf . value = false ;
vsyaRfConfirmed . value = false ;
delete errors . regions ;
}
}
2026-06-21 12:16:45 +03:00
const regionSearch = ref ( '' ) ;
2026-05-11 19:26:38 +03:00
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 ) => {
2026-05-14 19:28:33 +03:00
if ( open ) generalError . value = null ;
2026-05-20 14:19:09 +03:00
if ( open ) {
delete errors . regions ;
}
2026-05-11 19:26:38 +03:00
if ( open && props . mode === 'edit' && props . project ) {
2026-06-24 16:07:02 +03:00
step . value = 'project' ; // редактирование — всегда форма проекта, без шага реквизитов
2026-05-11 19:26:38 +03:00
Object . assign ( form , props . project ) ;
2026-05-15 05:54:05 +03:00
form . regions = Array . isArray ( props . project . regions ) ? [ ... props . project . regions ] : [ ] ;
2026-05-11 19:26:38 +03:00
const days : number [ ] = [ ] ;
for ( let i = 0 ; i < 7 ; i ++ ) if ( form . delivery _days _mask & ( 1 << i ) ) days . push ( i ) ;
selectedDays . value = days ;
2026-05-20 14:19:09 +03:00
// Существующий проект с пустыми регионами = «Вся РФ» (предзаполняем подтверждённым).
vsyaRf . value = form . regions . length === 0 ;
vsyaRfConfirmed . value = form . regions . length === 0 ;
2026-05-11 19:26:38 +03:00
} else if ( open ) {
Object . assign ( form , {
name : '' ,
signal _type : 'site' ,
signal _identifier : '' ,
sms _senders : [ ] ,
sms _keyword : '' ,
daily _limit _target : 50 ,
2026-05-15 05:54:05 +03:00
regions : [ ] ,
2026-05-11 19:26:38 +03:00
delivery _days _mask : 127 ,
} ) ;
selectedDays . value = [ 0 , 1 , 2 , 3 , 4 , 5 , 6 ] ;
2026-05-20 14:19:09 +03:00
vsyaRf . value = false ;
vsyaRfConfirmed . value = false ;
2026-06-24 16:07:02 +03:00
// Косяк 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 ( ) ;
2026-05-11 19:26:38 +03:00
}
} ,
2026-05-20 14:19:09 +03:00
{ immediate : true } ,
2026-05-11 19:26:38 +03:00
) ;
2026-05-25 06:23:59 +03:00
async function persist ( extra : Record < string , unknown > = { } ) : Promise < void > {
2026-05-20 14:19:09 +03:00
saving . value = true ;
2026-05-11 19:26:38 +03:00
try {
2026-05-14 19:28:33 +03:00
await ensureCsrfCookie ( ) ;
2026-06-23 11:39:14 +03:00
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 ;
}
2026-05-28 09:52:42 +03:00
let appliesFrom : string | null = null ;
2026-05-11 19:26:38 +03:00
if ( props . mode === 'edit' && props . project ) {
2026-05-28 09:52:42 +03:00
const { data } = await apiClient . patch ( ` /api/projects/ ${ props . project . id } ` , body ) ;
// Backend кладёт applies_from только когда правка задела slepok-чувствительные поля.
appliesFrom = data ? . data ? . applies _from ? ? null ;
2026-05-11 19:26:38 +03:00
} else {
2026-05-25 06:23:59 +03:00
await apiClient . post ( '/api/projects' , body ) ;
2026-05-28 09:52:42 +03:00
// Create НЕ генерирует applies_from (новый проект сразу попадает в snapshot).
2026-05-11 19:26:38 +03:00
}
2026-05-25 06:23:59 +03:00
overloadOpen . value = false ;
2026-05-28 09:52:42 +03:00
emit ( 'saved' , appliesFrom ) ;
2026-05-11 19:26:38 +03:00
close ( ) ;
} catch ( e : unknown ) {
2026-05-25 06:23:59 +03:00
const err = e as {
response ? : { status ? : number ; data ? : { error ? : string ; errors ? : Record < string , string [ ] > } } ;
} ;
2026-06-24 16:07:02 +03:00
// G1/SP3b + косяк 04: гейт первого проекта — показываем шаг реквизитов
// прямо в этом окне (запасной путь, обычно шаг 1 показан ещё при открытии).
2026-06-19 07:24:27 +03:00
if ( err . response ? . status === 422 && err . response . data ? . error === 'requisites_required' ) {
2026-06-24 16:07:02 +03:00
step . value = 'requisites' ;
void initCreateStep ( ) ;
2026-06-19 07:24:27 +03:00
}
2026-05-25 06:23:59 +03:00
// Spec C §6.2: лимит превышает баланс — открываем диалог перегрузки.
2026-06-19 07:24:27 +03:00
else if ( err . response ? . status === 409 && err . response . data ? . error === 'balance_insufficient' ) {
2026-05-25 06:23:59 +03:00
overloadPayload . value = err . response . data as OverloadPayloadShape ;
overloadOpen . value = true ;
} else if ( err . response ? . status === 422 && err . response . data ? . errors ) {
2026-05-11 19:26:38 +03:00
Object . assign ( errors , err . response . data . errors ) ;
2026-05-14 19:28:33 +03:00
} else {
generalError . value = extractErrorMessage ( e ) ;
2026-05-11 19:26:38 +03:00
}
} finally {
saving . value = false ;
}
}
2026-05-25 06:23:59 +03:00
async function submit ( ) {
generalError . value = null ;
Object . keys ( errors ) . forEach ( ( k ) => delete errors [ k ] ) ;
2026-06-22 16:43:10 +03:00
// 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 = [
2026-06-25 12:02:52 +03:00
form . signal _type === 'call' ? 'Введите номер в вашей нише' : 'Введите домен сайта в вашей нише' ,
2026-06-22 16:43:10 +03:00
] ;
}
2026-05-25 06:23:59 +03:00
// Гейт обязательного региона: нужны либо субъекты, либо подтверждённая «Вся РФ».
if ( form . regions . length === 0 && ! vsyaRfConfirmed . value ) {
2026-06-22 16:43:10 +03:00
clientErrors . regions = [ 'Выберите регион или подтвердите «Вся РФ»' ] ;
}
if ( Object . keys ( clientErrors ) . length > 0 ) {
Object . assign ( errors , clientErrors ) ;
2026-05-25 06:23:59 +03:00
return ;
}
2026-06-25 18:28:14 +03:00
// Эпик 3.2: смена источника на залоченном проекте — через подтверждение.
if ( sourceLocked . value && sourceDirty . value ) {
sourceConfirmOpen . value = true ;
return ;
}
2026-05-25 06:23:59 +03:00
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 ( ) ;
}
2026-05-11 19:26:38 +03:00
function close ( ) {
emit ( 'update:modelValue' , false ) ;
}
2026-05-20 14:19:09 +03:00
2026-06-24 16:07:02 +03:00
defineExpose ( {
chooseVsyaRf ,
confirmVsyaRf ,
cancelVsyaRf ,
onRegionsChange ,
vsyaRf ,
vsyaRfConfirmed ,
form ,
submit ,
step ,
reqForm ,
saveRequisites ,
} ) ;
2026-05-11 19:26:38 +03:00
< / script >
2026-05-12 18:07:20 +03:00
< style scoped >
2026-05-18 15:36:09 +03:00
. source - hint {
line - height : 1.4 ;
padding : 4 px 2 px ;
}
2026-06-24 16:07:02 +03:00
. steps - head {
display : flex ;
align - items : center ;
gap : 10 px ;
font - size : 13 px ;
}
. steps - head . step - on {
color : var ( -- liderra - teal , # 0 f6e56 ) ;
font - weight : 650 ;
}
. steps - head . step - off {
color : # 6 b6f72 ;
}
. steps - head . step - sep {
flex : 1 ;
height : 1 px ;
background : var ( -- liderra - line , # e6e2d6 ) ;
}
2026-05-12 18:07:20 +03:00
. 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 200 ms 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 >