Files
portal/app/resources/js/components/sales/SalesProspectCreateDialog.vue
T
Дмитрий 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>
@
2026-07-18 13:04:02 +03:00

239 lines
9.4 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">
/**
* Диалог «Добавить кандидата» — менеджер заводит потенциального клиента сам
* (инициатива, не из поиска начальника).
*
* Обязательны название (вывеска) и ИНН. По ИНН кнопкой «Найти» подтягиваем
* юрлицо и город из Да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>