feat(sales): диалог карточки прогноза — результат разговора + валидация

Этап 1 Task 10. Селектор результата, поля по стадии, отказ скрыт при user,
обязательные причина/время. Тесты через vm (v-dialog телепортит контент).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-15 16:40:46 +03:00
parent ead1335242
commit bb2fe2f34d
3 changed files with 216 additions and 4 deletions
@@ -0,0 +1,135 @@
<script setup lang="ts">
/**
* Диалог карточки потенциального клиента. Показывает данные фирмы + селектор
* «результат разговора». Поля зависят от выбранного результата. Отказ скрыт,
* если стадия = user. Эмитит save(payload) при валидном результате.
*/
import { computed, ref, watch } from 'vue';
import type { ProspectResultPayload, SalesProspect } from '../../api/sales';
import { stageMeta } from '../../utils/prospectStages';
const props = defineProps<{ modelValue: boolean; prospect: SalesProspect }>();
const emit = defineEmits<{
'update:modelValue': [v: boolean];
save: [payload: ProspectResultPayload];
}>();
const action = ref<'' | 'negotiation' | 'no_answer' | 'rejected'>('');
const nextCallAt = ref('');
const reason = ref('');
const notes = ref('');
const error = ref('');
watch(
() => props.prospect,
() => {
action.value = '';
nextCallAt.value = '';
reason.value = '';
notes.value = props.prospect.notes ?? '';
error.value = '';
},
);
const siteHref = computed(() => {
const s = props.prospect.site;
if (!s) return null;
return s.startsWith('http') ? s : `https://${s}`;
});
const availableActions = computed(() => {
const base = [
{ value: 'negotiation', title: 'Договорились на созвон' },
{ value: 'no_answer', title: 'Не дозвонился' },
];
// Отказ — на любой стадии, кроме user.
if (props.prospect.stage !== 'user') {
base.push({ value: 'rejected', title: 'Отказ' });
}
return base;
});
function submit(): void {
error.value = '';
if (action.value === 'negotiation') {
if (!nextCallAt.value) {
error.value = 'Укажите дату и время следующего созвона.';
return;
}
emit('save', { action: 'negotiation', next_call_at: nextCallAt.value, notes: notes.value });
} else if (action.value === 'no_answer') {
if (!reason.value.trim()) {
error.value = 'Укажите причину.';
return;
}
emit('save', { action: 'no_answer', reason: reason.value, notes: notes.value });
} else if (action.value === 'rejected') {
if (!reason.value.trim()) {
error.value = 'Укажите причину отказа.';
return;
}
emit('save', { action: 'rejected', reason: reason.value, notes: notes.value });
} else {
error.value = 'Выберите результат разговора.';
}
}
defineExpose({ action, reason, nextCallAt, submit, siteHref, availableActions });
</script>
<template>
<v-dialog :model-value="modelValue" max-width="640" @update:model-value="emit('update:modelValue', $event)">
<v-card>
<v-card-title>{{ prospect.firm_name }}</v-card-title>
<v-card-subtitle> {{ stageMeta(prospect.stage).title }} · {{ prospect.city }} </v-card-subtitle>
<v-card-text>
<div class="mb-2">
<div>Телефон: {{ prospect.phone ?? '—' }}</div>
<div>
Сайт:
<a v-if="siteHref" :href="siteHref" target="_blank" rel="noopener">{{ prospect.site }}</a>
<span v-else></span>
</div>
<div v-if="prospect.inn">ИНН: {{ prospect.inn }}</div>
<div v-if="prospect.registered_email">E-mail: {{ prospect.registered_email }}</div>
<div v-if="prospect.next_call_at">След. созвон: {{ prospect.next_call_at }}</div>
<div v-if="prospect.reason">Причина: {{ prospect.reason }}</div>
</div>
<v-select
v-model="action"
:items="availableActions"
item-title="title"
item-value="value"
label="Результат разговора"
density="comfortable"
variant="outlined"
/>
<v-text-field
v-if="action === 'negotiation'"
v-model="nextCallAt"
type="datetime-local"
label="Следующий созвон"
density="comfortable"
variant="outlined"
/>
<v-textarea
v-if="action === 'no_answer' || action === 'rejected'"
v-model="reason"
label="Причина"
rows="2"
density="comfortable"
variant="outlined"
/>
<v-alert v-if="error" type="warning" density="compact" class="mt-2">{{ error }}</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="emit('update:modelValue', false)">Закрыть</v-btn>
<v-btn color="primary" variant="flat" @click="submit">Сохранить</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
@@ -0,0 +1,77 @@
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { describe, expect, it } from 'vitest';
import SalesProspectDialog from '../../resources/js/components/sales/SalesProspectDialog.vue';
import type { SalesProspect } from '../../resources/js/api/sales';
const vuetify = createVuetify();
function prospect(overrides: Partial<SalesProspect> = {}): SalesProspect {
return {
id: 1,
sales_user_id: 1,
stage: 'new',
firm_name: 'ООО Тест',
city: 'Ростов',
phone: '+7800',
site: 'test.ru',
inn: '6161',
rating_label: 'горячая',
payload: {},
next_call_at: null,
reason: null,
registered_email: null,
notes: null,
...overrides,
};
}
interface DialogVm {
action: string;
reason: string;
nextCallAt: string;
siteHref: string | null;
availableActions: { value: string; title: string }[];
submit: () => void;
}
function factory(p: SalesProspect) {
return mount(SalesProspectDialog, {
props: { modelValue: true, prospect: p },
global: { plugins: [vuetify] },
});
}
describe('SalesProspectDialog', () => {
it('siteHref добавляет https к домену', () => {
const w = factory(prospect());
expect((w.vm as unknown as DialogVm).siteHref).toBe('https://test.ru');
});
it('для stage=user действие «Отказ» недоступно', () => {
const w = factory(prospect({ stage: 'user' }));
const values = (w.vm as unknown as DialogVm).availableActions.map((a) => a.value);
expect(values).not.toContain('rejected');
});
it('для обычной стадии «Отказ» доступен', () => {
const w = factory(prospect({ stage: 'negotiation' }));
const values = (w.vm as unknown as DialogVm).availableActions.map((a) => a.value);
expect(values).toContain('rejected');
});
it('«Переговоры» без даты — submit не эмитит save', () => {
const w = factory(prospect());
(w.vm as unknown as DialogVm).action = 'negotiation';
(w.vm as unknown as DialogVm).submit();
expect(w.emitted('save')).toBeFalsy();
});
it('«Отказ» с причиной — эмитит save с action=rejected', () => {
const w = factory(prospect({ stage: 'negotiation' }));
(w.vm as unknown as DialogVm).action = 'rejected';
(w.vm as unknown as DialogVm).reason = 'дорого';
(w.vm as unknown as DialogVm).submit();
expect(w.emitted('save')?.[0]?.[0]).toMatchObject({ action: 'rejected', reason: 'дорого' });
});
});
+4 -4
View File
@@ -1,6 +1,6 @@
# Brain Status (auto-generated)
Last updated: 2026-07-15T13:36:04.742Z
Last updated: 2026-07-15T13:38:18.903Z
| Контролёр | Состояние | Детали |
|---|---|---|
@@ -112,9 +112,9 @@ Episodes since last run: 542 / threshold: 10
| PID | Имя | CPU-время | Возраст |
|---|---|---|---|
| 3488 | MsMpEng | 6.91ч | 0.0ч |
| 9756 | Code | 3.18ч | 0.0ч |
| 1320 | svchost | 1.33ч | 1328341.6ч |
| 3488 | MsMpEng | 6.92ч | NaNч |
| 9756 | Code | 3.19ч | NaNч |
| 1320 | svchost | 1.33ч | NaNч |
⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.