b1c3efa1e1
Root causes: 1. Default axios без withXSRFToken не отправлял CSRF header → 419 silent fail (catch ловил только 422). 2. PDD regions UI (commits 4f60add..f982046) использовал 32-bit маску, несовместимую с schema's 8-битным CHECK chk_projects_region_mask_range → 500 silent fail. Changes (NewProjectDialog.vue): - Replace default axios import с apiClient + ensureCsrfCookie + extractErrorMessage из api/client.ts (same pattern как NewDealDialog). - await ensureCsrfCookie() перед mutating; apiClient.post/patch. - Remove regions <v-autocomplete> + selectedRegions ref + inverted region_mode watcher (interim A — proper 89-codes реализация в Plan 6). - Add general error banner для non-422 ошибок (419/401/500/network). - form.region_mask=255 + region_mode='include' (schema default = вся РФ). Changes (EditProjectDialog.spec.ts): - Switch mock с default axios на apiClient (cascading from above). Verified: Pest 742/739/3sk/0, Vitest 758/3sk/0, vue-tsc 0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
246 lines
10 KiB
Vue
246 lines
10 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">
|
|
<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">
|
|
<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">
|
|
<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-alert
|
|
v-if="generalError"
|
|
type="error"
|
|
variant="tonal"
|
|
density="compact"
|
|
class="mb-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 type { Project } from '../../stores/projectsStore';
|
|
import DevIndexBadge from '../../components/DevIndexBadge.vue';
|
|
|
|
const props = defineProps<{
|
|
modelValue: boolean;
|
|
mode?: 'create' | 'edit';
|
|
project?: Project | null;
|
|
}>();
|
|
const emit = defineEmits(['update:modelValue', 'saved']);
|
|
|
|
// region_mask=255 = все 8 ФО (schema default, см. db/schema.sql §projects).
|
|
// PDD regions UI отключён до закрытия Plan 6 — конфликт с 8-битной ФО-маской
|
|
// в PhonePrefixService.php (1 phone prefix ↔ 1 ФО, не субъект).
|
|
const form = reactive({
|
|
name: '',
|
|
signal_type: 'site' as 'site' | 'call' | 'sms',
|
|
signal_identifier: '',
|
|
sms_senders: [] as string[],
|
|
sms_keyword: '',
|
|
daily_limit_target: 50,
|
|
region_mask: 255,
|
|
region_mode: 'include' as 'include' | 'exclude',
|
|
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);
|
|
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,
|
|
region_mask: 255,
|
|
region_mode: 'include',
|
|
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>
|
|
.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>
|