6e2ad108de
После правки slepok-чувствительных полей проекта (regions / delivery_days_mask /
daily_limit_target / источник) backend возвращает ProjectResource.applies_from
= N.21:00 МСК (Task 2.11 backend slice, commit dd5954d8). Клиент Лидерры
теперь видит расширенный тост: «Сохранено. Изменения вступят в силу
DD.MM.YYYY в 21:00 МСК.» Когда правка не затронула slepok — обычное
«Сохранено.».
Изменения:
- composables/appliesFromMessage.ts — чистый форматтер (Moscow tz, не локаль клиента).
- ProjectDetailsDrawer / NewProjectDialog / EditProjectDialog — emit('saved', appliesFrom).
- ProjectsView — v-snackbar + onSaved/onDrawerSaved обработчики.
- tests/Frontend/appliesFromMessage.spec.ts — 5 invariant-кейсов.
Plan §Task 2.11 Step 5-6. Spec §4.2.5 UX block. R-15 + R-06..R-08 UX closure.
Vitest worktree-only 944/3sk GREEN, vue-tsc 3 pre-existing errors (вне диффа),
ESLint clean на затронутых файлах.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
431 lines
18 KiB
Vue
431 lines
18 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>
|
||
<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
|
||
: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="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 { 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);
|
||
|
||
// 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 : [];
|
||
if (form.regions.length > 0) {
|
||
// Взаимоисключение: выбор конкретных субъектов снимает «Вся РФ».
|
||
vsyaRf.value = false;
|
||
vsyaRfConfirmed.value = false;
|
||
delete errors.regions;
|
||
}
|
||
}
|
||
|
||
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) {
|
||
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[]> } };
|
||
};
|
||
// Spec C §6.2: лимит превышает баланс — открываем диалог перегрузки.
|
||
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]);
|
||
|
||
// Гейт обязательного региона: нужны либо субъекты, либо подтверждённая «Вся РФ».
|
||
if (form.regions.length === 0 && !vsyaRfConfirmed.value) {
|
||
errors.regions = ['Выберите регион или подтвердите «Вся РФ»'];
|
||
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>
|