Files
portal/app/resources/js/components/deals/NewDealDialog.vue
T

291 lines
12 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 { type MockDeal, type MockManager } from '../../composables/mockDeals';
/**
* Списки проектов и менеджеров грузятся с backend через GET /api/projects,
* /api/managers при открытии диалога (если передан tenantId). На fail —
* списки пустые + degradation-alert (lookupsFailed), создание блокируется
* до повторной успешной загрузки.
*/
const projectOptions = ref<string[]>([]);
const managerOptions = ref<MockManager[]>([]);
// 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 {
// Audit C6: фиксируем провал — UI покажет degradation-alert.
lookupsFailed.value = true;
}
}
// 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);
// Audit C6: loadLookups упал → показываем degradation-alert (списки пусты).
const lookupsFailed = 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;
lookupsFailed.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;
}
}
defineExpose({ lookupsFailed, projectOptions, managerOptions });
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-alert
v-if="lookupsFailed"
type="warning"
variant="tonal"
density="compact"
class="mb-3"
data-testid="lookups-error-alert"
>
Не удалось загрузить списки проектов и менеджеров попробуйте позже.
</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>