Files
portal/app/resources/js/views/projects/NewProjectDialog.vue
T
Дмитрий e1601e7862 feat(billing-v2-c): UI префлайт Task 1.10 — баннер заморозки, индикатор ёмкости, диалог перегрузки
Spec C §3.6/§6.2. Бэкенд: GET /api/billing/balance-status (frozen + capacity + required + дефицит ₽/leads), Pest 6. Фронт: BalanceFrozenBanner (в AppLayout, глобально), BalanceCapacityIndicator (в BillingView под балансом), ProjectLimitOverloadDialog (409-перехват в NewProjectDialog: save-blocked/set-zero), tenantStore + api getBalanceStatus. Vitest +18.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 20:39:21 +03:00

424 lines
18 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
: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', 'saved']);
// 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 };
if (props.mode === 'edit' && props.project) {
await apiClient.patch(`/api/projects/${props.project.id}`, body);
} else {
await apiClient.post('/api/projects', body);
}
overloadOpen.value = false;
emit('saved');
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>