32006a2bda
UX-request 18.05.2026 (п.8): - Сайт: «Источник — домен сайта-«донора», с которого приходят лиды» - Звонок: «Источник — телефонный номер «донора», на который звонят клиенты» - СМС: «Источник — отправитель SMS и (опционально) ключевое слово в тексте» Подпись text-caption text-medium-emphasis, выше существующего label поля. Один и тот же NewProjectDialog используется и для create, и для edit. NewProjectDialog.spec.ts 5/2sk/0 — без регрессий. Build 1.96s. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
284 lines
12 KiB
Vue
284 lines
12 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
|
||
v-model="form.regions"
|
||
:items="selectableRegions"
|
||
item-title="name"
|
||
item-value="code"
|
||
label="Регионы (пусто = вся РФ)"
|
||
multiple
|
||
chips
|
||
clearable
|
||
density="comfortable"
|
||
class="ld-input-quiet"
|
||
data-testid="regions-autocomplete"
|
||
@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-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>
|
||
</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';
|
||
|
||
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);
|
||
|
||
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 && 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;
|
||
} 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];
|
||
}
|
||
},
|
||
);
|
||
|
||
async function submit() {
|
||
saving.value = true;
|
||
generalError.value = null;
|
||
Object.keys(errors).forEach((k) => delete errors[k]);
|
||
try {
|
||
await ensureCsrfCookie();
|
||
if (props.mode === 'edit' && props.project) {
|
||
await apiClient.patch(`/api/projects/${props.project.id}`, { ...form });
|
||
} else {
|
||
await apiClient.post('/api/projects', { ...form });
|
||
}
|
||
emit('saved');
|
||
close();
|
||
} 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(errors, err.response.data.errors);
|
||
} else {
|
||
generalError.value = extractErrorMessage(e);
|
||
}
|
||
} finally {
|
||
saving.value = false;
|
||
}
|
||
}
|
||
|
||
function close() {
|
||
emit('update:modelValue', false);
|
||
}
|
||
</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>
|