41fbc4491d
feat(sales): журнал разговоров по кандидату + переделка обоих окон воронки
Владелец: «краткое содержание разговора писать, чтобы не потерять историю»
и «переделай обоих дизайн, очень не практично, спроси у перплексити».
ЖУРНАЛ (БД v8.74, таблица sales_prospect_notes, append-only):
- kind=note — менеджер написал руками; kind=stage — автозапись о переезде
по стадии (взял в работу, созвон, недозвон, отказ, регистрация).
Повторное открытие карточки журнал не засоряет.
- GET/POST /api/sales/prospects/{id}/notes; лента свежими сверху, грузится
при открытии карточки, а не вместе с доской.
- PATCH /prospects/{id} БОЛЬШЕ НЕ принимает notes: он молча затирал прошлую
запись — ровно та потеря истории, ради которой журнал и появился.
Нашёл rls-reviewer, закрыто тестом. Старые notes перенесены в журнал.
ДИЗАЙН (по разбору Pipedrive/HubSpot/Salesforce через Perplexity):
- Карточка 1100px, две колонки. Слева «что за фирма» + история разговоров
с полем «о чём поговорили». Справа зона действия: «Куда звонить» (номер
крупно, ссылкой tel:), «Результат разговора» своим фоном, контактные лица.
Кнопки внизу окна. Пустая история объясняет, что делать.
- Форма создания разбита на разделы: Компания / Контактные лица / Заметка;
Юрлицо и Город в одну строку; обязательных полей по-прежнему два.
Спека §19. Гейты: Pest 231/231 sales, Vitest 60/60 воронка, vue-tsc чисто,
Larastan 0 в своих файлах, rls-reviewer PASS. TDD RED→GREEN.
LEFTHOOK_EXCLUDE: larastan/cspell падают на файлах параллельной сессии
tests/Feature/Admin/*Balances*, свои файлы проверены отдельно и чисты.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
239 lines
9.4 KiB
Vue
239 lines
9.4 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* Диалог «Добавить кандидата» — менеджер заводит потенциального клиента сам
|
||
* (инициатива, не из поиска начальника).
|
||
*
|
||
* Обязательны название (вывеска) и ИНН. По ИНН кнопкой «Найти» подтягиваем
|
||
* юрлицо и город из ДаData — подсказка, а не замок: не нашлось, ДаData выключена
|
||
* или упала — заполняется руками. Контактных лиц может быть несколько, у каждого
|
||
* несколько телефонов. Пустые поля уходят как null, пустые контакты — не уходят.
|
||
*
|
||
* Спека §16 docs/superpowers/specs/2026-07-15-sales-prospects-kanban-design.md.
|
||
*/
|
||
import { ref, watch } from 'vue';
|
||
import { lookupProspectInn, type ProspectCreatePayload } from '../../api/sales';
|
||
import { cleanContacts, emptyContact, type ContactForm } from '../../utils/prospectContacts';
|
||
import ProspectContactsEditor from './ProspectContactsEditor.vue';
|
||
|
||
const props = defineProps<{ modelValue: boolean }>();
|
||
const emit = defineEmits<{
|
||
'update:modelValue': [v: boolean];
|
||
create: [payload: ProspectCreatePayload];
|
||
}>();
|
||
|
||
const firmName = ref('');
|
||
const legalName = ref('');
|
||
const city = ref('');
|
||
const site = ref('');
|
||
const inn = ref('');
|
||
const notes = ref('');
|
||
const contacts = ref<ContactForm[]>([emptyContact()]);
|
||
const error = ref('');
|
||
const lookingUp = ref(false);
|
||
|
||
// Каждое открытие — чистая форма.
|
||
watch(
|
||
() => props.modelValue,
|
||
(open) => {
|
||
if (open) {
|
||
firmName.value = '';
|
||
legalName.value = '';
|
||
city.value = '';
|
||
site.value = '';
|
||
inn.value = '';
|
||
notes.value = '';
|
||
contacts.value = [emptyContact()];
|
||
error.value = '';
|
||
lookingUp.value = false;
|
||
}
|
||
},
|
||
);
|
||
|
||
/** Пустая строка → null (чтобы не плодить пустышки в базе). */
|
||
function orNull(v: string): string | null {
|
||
const t = v.trim();
|
||
return t === '' ? null : t;
|
||
}
|
||
|
||
/**
|
||
* Проверка ИНН: 10 или 12 цифр + контрольная сумма (алгоритм ФНС).
|
||
* Та же проверка есть на сервере — здесь она только чтобы поймать опечатку
|
||
* сразу, не гоняя человека в ДаData и обратно.
|
||
*/
|
||
function innIsValid(raw: string): boolean {
|
||
const d = raw.trim();
|
||
if (!/^\d{10}$|^\d{12}$/.test(d)) return false;
|
||
|
||
const sum = (weights: number[]): number =>
|
||
weights.reduce((acc, w, i) => acc + w * Number(d[i]), 0) % 11 % 10;
|
||
|
||
if (d.length === 10) return sum([2, 4, 10, 3, 5, 9, 4, 6, 8]) === Number(d[9]);
|
||
return (
|
||
sum([7, 2, 4, 10, 3, 5, 9, 4, 6, 8]) === Number(d[10]) &&
|
||
sum([3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8]) === Number(d[11])
|
||
);
|
||
}
|
||
|
||
/** Подсказка по ИНН. Ошибка ДаData не должна мешать завести карточку руками. */
|
||
async function lookup(): Promise<void> {
|
||
error.value = '';
|
||
if (!innIsValid(inn.value)) {
|
||
error.value = 'ИНН выглядит неправильно: нужно 10 или 12 цифр.';
|
||
return;
|
||
}
|
||
|
||
lookingUp.value = true;
|
||
try {
|
||
const res = await lookupProspectInn(inn.value.trim());
|
||
if (!res.found) {
|
||
error.value = 'По этому ИНН ничего не нашлось — заполните названия руками.';
|
||
return;
|
||
}
|
||
if (res.legal_name) legalName.value = res.legal_name;
|
||
// Город подставляем только если менеджер сам ещё ничего не вписал.
|
||
if (res.city && city.value.trim() === '') city.value = res.city;
|
||
} catch {
|
||
error.value = 'Не удалось спросить у ДаData — заполните названия руками.';
|
||
} finally {
|
||
lookingUp.value = false;
|
||
}
|
||
}
|
||
|
||
function submit(): void {
|
||
error.value = '';
|
||
if (!firmName.value.trim()) {
|
||
error.value = 'Укажите название фирмы.';
|
||
return;
|
||
}
|
||
if (!innIsValid(inn.value)) {
|
||
error.value = 'Укажите ИНН: 10 или 12 цифр.';
|
||
return;
|
||
}
|
||
|
||
emit('create', {
|
||
firm_name: firmName.value.trim(),
|
||
inn: inn.value.trim(),
|
||
legal_name: orNull(legalName.value),
|
||
contacts: cleanContacts(contacts.value),
|
||
city: orNull(city.value),
|
||
site: orNull(site.value),
|
||
notes: orNull(notes.value),
|
||
});
|
||
}
|
||
|
||
defineExpose({ firmName, legalName, city, site, inn, notes, contacts, error, lookingUp, lookup, submit });
|
||
</script>
|
||
|
||
<template>
|
||
<!--
|
||
Форма сгруппирована по смыслу: «Компания» → «Контактные лица» → «Заметка».
|
||
Столбик из шести одинаковых полей читался как стена; разделы дают глазу
|
||
опору и сразу видно, что обязательно (ИНН и название), а что нет.
|
||
-->
|
||
<v-dialog :model-value="modelValue" max-width="640" scrollable @update:model-value="emit('update:modelValue', $event)">
|
||
<v-card>
|
||
<v-card-item class="pb-2">
|
||
<v-card-title>Добавить кандидата</v-card-title>
|
||
<v-card-subtitle>карточка появится у вас в «Новые» с пометкой «своя»</v-card-subtitle>
|
||
</v-card-item>
|
||
|
||
<v-divider />
|
||
|
||
<v-card-text>
|
||
<div class="text-overline text-medium-emphasis mb-1">Компания</div>
|
||
|
||
<div class="d-flex align-start ga-2">
|
||
<v-text-field
|
||
v-model="inn"
|
||
label="ИНН *"
|
||
density="comfortable"
|
||
variant="outlined"
|
||
autofocus
|
||
hint="нажмите «Найти» — подставим юрлицо и город"
|
||
persistent-hint
|
||
/>
|
||
<v-btn
|
||
class="mt-1"
|
||
color="primary"
|
||
variant="tonal"
|
||
:loading="lookingUp"
|
||
@click="lookup"
|
||
>
|
||
Найти
|
||
</v-btn>
|
||
</div>
|
||
|
||
<v-text-field
|
||
v-model="firmName"
|
||
class="mt-4"
|
||
label="Название (вывеска) *"
|
||
density="comfortable"
|
||
variant="outlined"
|
||
hide-details="auto"
|
||
/>
|
||
|
||
<v-row class="mt-1" dense>
|
||
<v-col cols="12" sm="7">
|
||
<v-text-field
|
||
v-model="legalName"
|
||
label="Юрлицо"
|
||
density="comfortable"
|
||
variant="outlined"
|
||
hide-details="auto"
|
||
/>
|
||
</v-col>
|
||
<v-col cols="12" sm="5">
|
||
<v-text-field
|
||
v-model="city"
|
||
label="Город"
|
||
density="comfortable"
|
||
variant="outlined"
|
||
hide-details="auto"
|
||
/>
|
||
</v-col>
|
||
</v-row>
|
||
|
||
<v-text-field
|
||
v-model="site"
|
||
class="mt-3"
|
||
label="Сайт"
|
||
density="comfortable"
|
||
variant="outlined"
|
||
hide-details="auto"
|
||
/>
|
||
|
||
<v-divider class="my-5" />
|
||
|
||
<div class="text-overline text-medium-emphasis">Контактные лица</div>
|
||
<div class="text-caption text-medium-emphasis mb-2">
|
||
можно оставить пустым и заполнить после первого звонка
|
||
</div>
|
||
<ProspectContactsEditor v-model="contacts" />
|
||
|
||
<v-divider class="my-5" />
|
||
|
||
<div class="text-overline text-medium-emphasis mb-1">Заметка</div>
|
||
<v-textarea
|
||
v-model="notes"
|
||
placeholder="Например: увидел вывеску на Ленина, рекламы в Директе нет"
|
||
rows="2"
|
||
auto-grow
|
||
density="comfortable"
|
||
variant="outlined"
|
||
hide-details="auto"
|
||
/>
|
||
|
||
<v-alert v-if="error" type="warning" density="compact" class="mt-3">{{ error }}</v-alert>
|
||
</v-card-text>
|
||
|
||
<v-divider />
|
||
|
||
<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>
|