Files
portal/app/resources/js/components/sales/SalesProspectCreateDialog.vue
T
Дмитрий f0a7de7d5d fix(портал продаж): ИНН при заведении кандидата больше не обязателен
Обязательно только название фирмы — вывеска. ИНН по желанию: заполнили
проверяем контрольную сумму и ловим дубль, оставили пустым — карточка
заводится без ИНН. Пустая строка приравнена к «не знаю» и уходит как null.

Проверка на дубль по ИНН теперь срабатывает только когда ИНН указан:
раньше вторая карточка без ИНН у того же менеджера ложно ловилась бы как
«эта фирма уже есть в вашей воронке». Схему БД менять не потребовалось —
колонка inn была nullable, уникальный индекс частичный.

Тесты: сервер 58/58, экран 9/9. Спека §16 обновлена.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 09:51:52 +03:00

241 lines
9.7 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 (inn.value.trim() !== '' && !innIsValid(inn.value)) {
error.value = 'ИНН выглядит неправильно: нужно 10 или 12 цифр. Можно оставить пустым.';
return;
}
emit('create', {
firm_name: firmName.value.trim(),
inn: orNull(inn.value),
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>