feat(sales): диалог карточки прогноза — результат разговора + валидация
Этап 1 Task 10. Селектор результата, поля по стадии, отказ скрыт при user, обязательные причина/время. Тесты через vm (v-dialog телепортит контент). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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: 'дорого' });
|
||||
});
|
||||
});
|
||||
@@ -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-сессий.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user