2d7d7d1188
Sprint 2 Phase B (modernization). Закрытие audit O-stack-04/05/07 + O-perf-06:
- O-stack-04: Vue 3.5 defineModel() в 3 диалогах (NewDealDialog,
ImpersonationDialog, ReminderDialog) — boilerplate −5 строк/файл.
+ useTemplateRef() в TwoFactorView (input v-for refs).
- O-stack-05: Vuetify 3.12 типизированные слоты VDataTable
(DealsView + AdminTenantsView) — inline-аннотации `{ item }: { item: T }`
на 6+7 scoped-slot bindings; vue-tsc проверяет доступ к полям статически.
- O-stack-07: ESLint flat-config verified — header-comment добавлен
в eslint.config.js. Legacy .eslintrc.json не используется.
- O-perf-06: defineAsyncComponent() для тяжёлых диалогов в 3 местах:
DealsView (DealDetailDrawer + NewDealDialog), DealDetailDrawer
(ReminderDialog), RemindersView (ReminderDialog). KanbanView оставлен
sync — async-загрузка приводила к EnvironmentTeardownError в jsdom
(KanbanView.spec.ts), see in-file comment. Сборка показывает chunk'и
ImpersonationDialog (7.61 kB), DealDetailDrawer (11.12 kB), NewDealDialog
и ReminderDialog как отдельные lazy-bundles.
vue-tsc: 0 errors. ESLint: 0. Vitest: 416/416 PASS. Build: success.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
273 lines
11 KiB
Vue
273 lines
11 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* Диалог создания новой сделки. Используется в DealsView и KanbanView.
|
||
*
|
||
* Submit-flow:
|
||
* 1. Validate (≥10 digits phone, required fields).
|
||
* 2. Если `tenantId` передан — POST /api/deals → backend возвращает deal с
|
||
* реальным id из БД. На fail (network / 500) — fallback на local-only
|
||
* (sync вид сохраняется, при reload потеряется — отображаем warning).
|
||
* 3. Если `tenantId` не передан — чистый local-mode (legacy).
|
||
* 4. emit('created', deal) → parent пушит в свой reactive-state.
|
||
*/
|
||
import * as dealsApi from '../../api/deals';
|
||
import { extractErrorMessage } from '../../api/client';
|
||
import { ref, watch } from 'vue';
|
||
import { LEAD_STATUSES } from '../../composables/leadStatuses';
|
||
import { MOCK_MANAGERS, MOCK_PROJECTS, type MockDeal, type MockManager } from '../../composables/mockDeals';
|
||
|
||
/**
|
||
* Управление source для проектов и менеджеров. Если tenantId передан, загружаем
|
||
* с backend через GET /api/projects, /api/managers. На fail (network) —
|
||
* fallback на MOCK_PROJECTS/MOCK_MANAGERS (UI всё равно работоспособен).
|
||
*/
|
||
const projectOptions = ref<string[]>([...MOCK_PROJECTS]);
|
||
const managerOptions = ref<MockManager[]>([...MOCK_MANAGERS]);
|
||
// Map name → backend-id, нужен только когда manager_id отправляется на backend.
|
||
const managerIdByName = ref<Map<string, number>>(new Map());
|
||
|
||
async function loadLookups(tenantId: number) {
|
||
try {
|
||
const [projects, managers] = await Promise.all([
|
||
dealsApi.listProjects(tenantId),
|
||
dealsApi.listManagers(tenantId),
|
||
]);
|
||
if (projects.length > 0) {
|
||
projectOptions.value = projects.map((p) => p.name);
|
||
}
|
||
if (managers.length > 0) {
|
||
managerOptions.value = managers.map((m) => ({ initials: m.initials, name: m.name }));
|
||
const map = new Map<string, number>();
|
||
managers.forEach((m) => map.set(m.name, m.id));
|
||
managerIdByName.value = map;
|
||
}
|
||
} catch {
|
||
// Молчаливый fallback на mock — UI пользователь всё равно увидит.
|
||
}
|
||
}
|
||
|
||
// Vue 3.5 defineModel() — заменяет props.modelValue + emit('update:modelValue').
|
||
// Sprint 2 Phase B / O-stack-04. Снижение boilerplate: 5 строк → 1.
|
||
const dialogOpen = defineModel<boolean>({ required: true });
|
||
|
||
const props = defineProps<{
|
||
/** Опциональный preset статуса (KanbanView передаёт slug колонки куда дропают). */
|
||
presetStatus?: MockDeal['statusSlug'];
|
||
/**
|
||
* Если передан — submit делает POST /api/deals. Если нет — чистый local-mode
|
||
* (для тестов и legacy). На prod — всегда передаётся из родителя через
|
||
* `auth.user.tenant_id`.
|
||
*/
|
||
tenantId?: number;
|
||
}>();
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'created', deal: MockDeal): void;
|
||
}>();
|
||
|
||
const name = ref('');
|
||
const phone = ref('');
|
||
const project = ref<string | null>(null);
|
||
const manager = ref<MockManager | null>(null);
|
||
const cost = ref<number>(0);
|
||
const statusSlug = ref<MockDeal['statusSlug']>('new');
|
||
|
||
const errors = ref<Record<string, string>>({});
|
||
const submitError = ref<string | null>(null);
|
||
const busy = ref(false);
|
||
|
||
// Регенерируем ID на каждое создание для local-mode. На API — backend SERIAL.
|
||
function nextId(): number {
|
||
return Math.floor(Date.now() / 1000) + Math.floor(Math.random() * 1000);
|
||
}
|
||
|
||
function reset() {
|
||
name.value = '';
|
||
phone.value = '';
|
||
project.value = null;
|
||
manager.value = null;
|
||
cost.value = 0;
|
||
statusSlug.value = props.presetStatus ?? 'new';
|
||
errors.value = {};
|
||
submitError.value = null;
|
||
busy.value = false;
|
||
}
|
||
|
||
watch(
|
||
dialogOpen,
|
||
(open) => {
|
||
if (open) {
|
||
reset();
|
||
// Подгружаем lookups при открытии (дешевле грузить on-demand чем
|
||
// на mount — диалог не открывается часто).
|
||
if (props.tenantId) {
|
||
loadLookups(props.tenantId);
|
||
}
|
||
}
|
||
},
|
||
{ immediate: true },
|
||
);
|
||
|
||
function validate(): boolean {
|
||
const e: Record<string, string> = {};
|
||
if (!name.value.trim()) e.name = 'Имя обязательно.';
|
||
if (!phone.value.trim()) e.phone = 'Телефон обязателен.';
|
||
else if (phone.value.replace(/\D/g, '').length < 10) e.phone = 'Минимум 10 цифр.';
|
||
if (!project.value) e.project = 'Выберите проект.';
|
||
if (!manager.value) e.manager = 'Выберите менеджера.';
|
||
if (cost.value < 0) e.cost = 'Стоимость не может быть отрицательной.';
|
||
errors.value = e;
|
||
return Object.keys(e).length === 0;
|
||
}
|
||
|
||
async function submit() {
|
||
if (!validate()) return;
|
||
submitError.value = null;
|
||
busy.value = true;
|
||
try {
|
||
let dealId = nextId();
|
||
// Backend create если tenantId передан. На fail — local-only fallback
|
||
// (UI не блокируется при network-down, но warning показываем).
|
||
if (props.tenantId) {
|
||
try {
|
||
// Manager mapping name → id (если backend lookup сработал).
|
||
const managerId = manager.value ? managerIdByName.value.get(manager.value.name) : undefined;
|
||
const created = await dealsApi.createDeal({
|
||
tenant_id: props.tenantId,
|
||
project_name: project.value!,
|
||
phone: phone.value.trim(),
|
||
contact_name: name.value.trim(),
|
||
status: statusSlug.value,
|
||
manager_id: managerId,
|
||
});
|
||
dealId = created.id;
|
||
} catch (err) {
|
||
submitError.value = extractErrorMessage(
|
||
err,
|
||
'Не удалось сохранить на сервере. Сделка добавлена локально.',
|
||
);
|
||
// Не делаем return — продолжаем с local-id для UI consistency.
|
||
}
|
||
}
|
||
|
||
const deal: MockDeal = {
|
||
id: dealId,
|
||
name: name.value.trim(),
|
||
phone: phone.value.trim(),
|
||
statusSlug: statusSlug.value,
|
||
project: project.value!,
|
||
manager: { ...manager.value! },
|
||
cost: cost.value,
|
||
receivedMinutesAgo: 0,
|
||
};
|
||
emit('created', deal);
|
||
// На submitError — оставляем dialog открытым с warning, иначе закрываем.
|
||
if (submitError.value === null) {
|
||
dialogOpen.value = false;
|
||
}
|
||
} finally {
|
||
busy.value = false;
|
||
}
|
||
}
|
||
|
||
function close() {
|
||
dialogOpen.value = false;
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<v-dialog v-model="dialogOpen" :max-width="560" data-testid="new-deal-dialog">
|
||
<v-card>
|
||
<v-card-title>Новая сделка</v-card-title>
|
||
<v-card-text>
|
||
<v-alert
|
||
v-if="submitError"
|
||
type="warning"
|
||
variant="tonal"
|
||
density="compact"
|
||
class="mb-3"
|
||
data-testid="submit-error-alert"
|
||
>
|
||
{{ submitError }}
|
||
</v-alert>
|
||
<v-row dense>
|
||
<v-col cols="12" md="6">
|
||
<v-text-field
|
||
v-model="name"
|
||
label="Имя клиента"
|
||
variant="outlined"
|
||
density="comfortable"
|
||
autofocus
|
||
:error-messages="errors.name ? [errors.name] : []"
|
||
data-testid="field-name"
|
||
/>
|
||
</v-col>
|
||
<v-col cols="12" md="6">
|
||
<v-text-field
|
||
v-model="phone"
|
||
label="Телефон"
|
||
placeholder="+7 (___) ___-__-__"
|
||
variant="outlined"
|
||
density="comfortable"
|
||
inputmode="tel"
|
||
:error-messages="errors.phone ? [errors.phone] : []"
|
||
data-testid="field-phone"
|
||
/>
|
||
</v-col>
|
||
<v-col cols="12" md="6">
|
||
<v-select
|
||
v-model="project"
|
||
:items="projectOptions"
|
||
label="Проект"
|
||
variant="outlined"
|
||
density="comfortable"
|
||
:error-messages="errors.project ? [errors.project] : []"
|
||
data-testid="field-project"
|
||
/>
|
||
</v-col>
|
||
<v-col cols="12" md="6">
|
||
<v-select
|
||
v-model="manager"
|
||
:items="managerOptions"
|
||
item-title="name"
|
||
return-object
|
||
label="Менеджер"
|
||
variant="outlined"
|
||
density="comfortable"
|
||
:error-messages="errors.manager ? [errors.manager] : []"
|
||
data-testid="field-manager"
|
||
/>
|
||
</v-col>
|
||
<v-col cols="12" md="6">
|
||
<v-text-field
|
||
v-model.number="cost"
|
||
label="Стоимость лида, ₽"
|
||
variant="outlined"
|
||
density="comfortable"
|
||
type="number"
|
||
min="0"
|
||
:error-messages="errors.cost ? [errors.cost] : []"
|
||
data-testid="field-cost"
|
||
/>
|
||
</v-col>
|
||
<v-col cols="12" md="6">
|
||
<v-select
|
||
v-model="statusSlug"
|
||
:items="LEAD_STATUSES.map((s) => ({ value: s.slug, title: s.nameRu }))"
|
||
label="Статус"
|
||
variant="outlined"
|
||
density="comfortable"
|
||
data-testid="field-status"
|
||
/>
|
||
</v-col>
|
||
</v-row>
|
||
</v-card-text>
|
||
<v-card-actions>
|
||
<v-spacer />
|
||
<v-btn variant="text" :disabled="busy" data-testid="cancel-btn" @click="close">Отмена</v-btn>
|
||
<v-btn color="primary" :loading="busy" data-testid="submit-btn" @click="submit">Создать</v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-dialog>
|
||
</template>
|