Files
portal/app/resources/js/components/deals/NewDealDialog.vue
T
Дмитрий 2d7d7d1188 feat(frontend): Sprint 2 Phase B — Vue 3.5 defineModel + Vuetify 3.12 typed slots + lazy-imports + ESLint check
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>
2026-05-09 19:36:02 +03:00

273 lines
11 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.
<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>