f0a7de7d5d
Обязательно только название фирмы — вывеска. ИНН по желанию: заполнили проверяем контрольную сумму и ловим дубль, оставили пустым — карточка заводится без ИНН. Пустая строка приравнена к «не знаю» и уходит как null. Проверка на дубль по ИНН теперь срабатывает только когда ИНН указан: раньше вторая карточка без ИНН у того же менеджера ложно ловилась бы как «эта фирма уже есть в вашей воронке». Схему БД менять не потребовалось — колонка inn была nullable, уникальный индекс частичный. Тесты: сервер 58/58, экран 9/9. Спека §16 обновлена. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
241 lines
9.7 KiB
Vue
241 lines
9.7 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 (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>
|