Compare commits
3 Commits
af53bc77d5
...
0a403381c5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a403381c5 | ||
|
|
b1468eeb42 | ||
|
|
b18413e6f5 |
@ -21,17 +21,19 @@ export async function auditRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
const where: string[] = [];
|
const where: string[] = [];
|
||||||
const params: unknown[] = [];
|
const params: unknown[] = [];
|
||||||
if (req.query.entity) {
|
if (req.query.entity && req.query.entity.trim()) {
|
||||||
params.push(req.query.entity);
|
params.push(req.query.entity.trim());
|
||||||
where.push(`entity = $${params.length}`);
|
where.push(`entity = $${params.length}`);
|
||||||
}
|
}
|
||||||
if (req.query.entity_id) {
|
if (req.query.entity_id && req.query.entity_id.trim()) {
|
||||||
params.push(req.query.entity_id);
|
// Prefix-substring match so users can paste any chunk of a UUID
|
||||||
where.push(`entity_id = $${params.length}`);
|
// (or a username substring) and still find rows.
|
||||||
|
params.push('%' + req.query.entity_id.trim() + '%');
|
||||||
|
where.push(`entity_id ILIKE $${params.length}`);
|
||||||
}
|
}
|
||||||
if (req.query.actor) {
|
if (req.query.actor && req.query.actor.trim()) {
|
||||||
params.push(req.query.actor);
|
params.push('%' + req.query.actor.trim() + '%');
|
||||||
where.push(`actor = $${params.length}`);
|
where.push(`actor ILIKE $${params.length}`);
|
||||||
}
|
}
|
||||||
const limit = Math.min(Math.max(Number(req.query.limit ?? 100), 1), 500);
|
const limit = Math.min(Math.max(Number(req.query.limit ?? 100), 1), 500);
|
||||||
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
||||||
|
|||||||
@ -71,6 +71,11 @@ function formatDate(s: string | null) {
|
|||||||
|
|
||||||
function formatMoney(v: number | null) {
|
function formatMoney(v: number | null) {
|
||||||
if (v === null) return '—';
|
if (v === null) return '—';
|
||||||
|
if (v >= 1_000_000) {
|
||||||
|
const m = v / 1_000_000;
|
||||||
|
return (m >= 10 ? Math.round(m) : Number(m.toFixed(1)).toString()) + 'M ₸';
|
||||||
|
}
|
||||||
|
if (v >= 10_000) return Math.round(v / 1_000) + 'K ₸';
|
||||||
return v.toLocaleString('ru-RU', { maximumFractionDigits: 0 }) + ' ₸';
|
return v.toLocaleString('ru-RU', { maximumFractionDigits: 0 }) + ' ₸';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -215,7 +220,7 @@ function exportCsv() {
|
|||||||
<th>Контакт</th>
|
<th>Контакт</th>
|
||||||
<th>Компания</th>
|
<th>Компания</th>
|
||||||
<th>Этап</th>
|
<th>Этап</th>
|
||||||
<th class="text-right">Сумма</th>
|
<th class="!text-center">Сумма</th>
|
||||||
<th>Касание</th>
|
<th>Касание</th>
|
||||||
<th>Тип</th>
|
<th>Тип</th>
|
||||||
<th>Автор</th>
|
<th>Автор</th>
|
||||||
@ -245,7 +250,10 @@ function exportCsv() {
|
|||||||
{{ DEAL_STAGE_LABELS[lead.deal_stage] }}
|
{{ DEAL_STAGE_LABELS[lead.deal_stage] }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-right tabular-nums text-gray-700">
|
<td
|
||||||
|
class="px-4 py-3 text-center tabular-nums text-gray-700 whitespace-nowrap"
|
||||||
|
:title="lead.expected_amount !== null ? lead.expected_amount.toLocaleString('ru-RU') + ' ₸' : ''"
|
||||||
|
>
|
||||||
{{ formatMoney(lead.expected_amount) }}
|
{{ formatMoney(lead.expected_amount) }}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-gray-600 tabular-nums">
|
<td class="px-4 py-3 text-gray-600 tabular-nums">
|
||||||
|
|||||||
66
leadscore/frontend/src/components/PhoneInput.vue
Normal file
66
leadscore/frontend/src/components/PhoneInput.vue
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string;
|
||||||
|
placeholder?: string;
|
||||||
|
error?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{ (e: 'update:modelValue', v: string): void }>();
|
||||||
|
|
||||||
|
// Format raw digits as +7 (XXX) XXX-XX-XX. Accept any country prefix:
|
||||||
|
// if first digit is 8, convert to 7. Limit to 11 digits for KZ/RU.
|
||||||
|
function formatPhone(raw: string): string {
|
||||||
|
let digits = raw.replace(/\D/g, '');
|
||||||
|
if (digits.length === 0) return '';
|
||||||
|
// Normalize leading 8 to 7 (KZ/RU pattern)
|
||||||
|
if (digits[0] === '8') digits = '7' + digits.slice(1);
|
||||||
|
// Ensure leading 7 if user just typed local part starting with 9/7
|
||||||
|
if (digits[0] !== '7') digits = '7' + digits;
|
||||||
|
digits = digits.slice(0, 11);
|
||||||
|
|
||||||
|
const cc = digits[0];
|
||||||
|
const rest = digits.slice(1);
|
||||||
|
let out = '+' + cc;
|
||||||
|
if (rest.length === 0) return out;
|
||||||
|
out += ' (' + rest.slice(0, 3);
|
||||||
|
if (rest.length < 3) return out;
|
||||||
|
out += ')';
|
||||||
|
if (rest.length > 3) out += ' ' + rest.slice(3, 6);
|
||||||
|
if (rest.length > 6) out += '-' + rest.slice(6, 8);
|
||||||
|
if (rest.length > 8) out += '-' + rest.slice(8, 10);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const display = computed(() => formatPhone(props.modelValue));
|
||||||
|
|
||||||
|
function onInput(e: Event) {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
const formatted = formatPhone(target.value);
|
||||||
|
// Keep cursor near the end after formatting (simplest UX, no jumping).
|
||||||
|
emit('update:modelValue', formatted);
|
||||||
|
// Force the input value to formatted, in case Vue's reactive update lags.
|
||||||
|
if (target.value !== formatted) target.value = formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPaste(e: ClipboardEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const text = e.clipboardData?.getData('text') ?? '';
|
||||||
|
emit('update:modelValue', formatPhone(text));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<input
|
||||||
|
:value="display"
|
||||||
|
type="tel"
|
||||||
|
inputmode="tel"
|
||||||
|
autocomplete="tel"
|
||||||
|
:placeholder="placeholder ?? '+7 (___) ___-__-__'"
|
||||||
|
class="input-base"
|
||||||
|
:class="error ? 'border-red-500' : 'border-gray-300'"
|
||||||
|
@input="onInput"
|
||||||
|
@paste="onPaste"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
114
leadscore/frontend/src/data/cities-kz.ts
Normal file
114
leadscore/frontend/src/data/cities-kz.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
// Cities of Kazakhstan: республиканского значения + областные центры +
|
||||||
|
// районные центры и города областного подчинения. Suggestion list only —
|
||||||
|
// any free-form value still allowed via datalist.
|
||||||
|
export const KZ_CITIES: string[] = [
|
||||||
|
// Города республиканского значения
|
||||||
|
'Алматы',
|
||||||
|
'Астана',
|
||||||
|
'Шымкент',
|
||||||
|
// Областные центры
|
||||||
|
'Актау',
|
||||||
|
'Актобе',
|
||||||
|
'Атырау',
|
||||||
|
'Жезказган',
|
||||||
|
'Караганда',
|
||||||
|
'Кокшетау',
|
||||||
|
'Костанай',
|
||||||
|
'Кызылорда',
|
||||||
|
'Павлодар',
|
||||||
|
'Петропавловск',
|
||||||
|
'Семей',
|
||||||
|
'Талдыкорган',
|
||||||
|
'Тараз',
|
||||||
|
'Туркестан',
|
||||||
|
'Уральск',
|
||||||
|
'Усть-Каменогорск',
|
||||||
|
'Конаев',
|
||||||
|
// Города областного значения и районные центры
|
||||||
|
'Аксай',
|
||||||
|
'Аксу',
|
||||||
|
'Алтай',
|
||||||
|
'Аркалык',
|
||||||
|
'Арыс',
|
||||||
|
'Балхаш',
|
||||||
|
'Байконур',
|
||||||
|
'Булаево',
|
||||||
|
'Державинск',
|
||||||
|
'Жанаозен',
|
||||||
|
'Жанатас',
|
||||||
|
'Жетысай',
|
||||||
|
'Житикара',
|
||||||
|
'Зайсан',
|
||||||
|
'Каражал',
|
||||||
|
'Кандыагаш',
|
||||||
|
'Капшагай',
|
||||||
|
'Каратау',
|
||||||
|
'Каркаралинск',
|
||||||
|
'Каскелен',
|
||||||
|
'Кентау',
|
||||||
|
'Кульсары',
|
||||||
|
'Курчатов',
|
||||||
|
'Лисаковск',
|
||||||
|
'Макинск',
|
||||||
|
'Мамлютка',
|
||||||
|
'Мерке',
|
||||||
|
'Приозёрск',
|
||||||
|
'Риддер',
|
||||||
|
'Рудный',
|
||||||
|
'Сарань',
|
||||||
|
'Сарканд',
|
||||||
|
'Сарыагаш',
|
||||||
|
'Сатпаев',
|
||||||
|
'Серебрянск',
|
||||||
|
'Степногорск',
|
||||||
|
'Степняк',
|
||||||
|
'Талгар',
|
||||||
|
'Тайынша',
|
||||||
|
'Темир',
|
||||||
|
'Темиртау',
|
||||||
|
'Текели',
|
||||||
|
'Уштобе',
|
||||||
|
'Хромтау',
|
||||||
|
'Шалкар',
|
||||||
|
'Шар',
|
||||||
|
'Шардара',
|
||||||
|
'Шахтинск',
|
||||||
|
'Шемонаиха',
|
||||||
|
'Щучинск',
|
||||||
|
'Эмба',
|
||||||
|
'Экибастуз',
|
||||||
|
'Ерейментау',
|
||||||
|
'Есик',
|
||||||
|
'Есиль',
|
||||||
|
'Жаркент',
|
||||||
|
// Прочие частые в B2B
|
||||||
|
'Алга',
|
||||||
|
'Аральск',
|
||||||
|
'Балыкши',
|
||||||
|
'Денисовка',
|
||||||
|
'Дербисек',
|
||||||
|
'Жалагаш',
|
||||||
|
'Жанаарка',
|
||||||
|
'Кокпекты',
|
||||||
|
'Кордай',
|
||||||
|
'Косшы',
|
||||||
|
'Лениногорск',
|
||||||
|
'Маканчи',
|
||||||
|
'Махамбет',
|
||||||
|
'Мангистау',
|
||||||
|
'Нуркент',
|
||||||
|
'Отеген батыр',
|
||||||
|
'Сайрам',
|
||||||
|
'Сарыкемер',
|
||||||
|
'Талапкер',
|
||||||
|
'Тургень',
|
||||||
|
'Узынагаш',
|
||||||
|
'Уральск (Орал)',
|
||||||
|
'Форт-Шевченко',
|
||||||
|
'Чу',
|
||||||
|
'Шиели',
|
||||||
|
'Шортанды',
|
||||||
|
'Шу',
|
||||||
|
'Янги-Курган',
|
||||||
|
'Кушмурун',
|
||||||
|
];
|
||||||
114
leadscore/frontend/src/data/industries.ts
Normal file
114
leadscore/frontend/src/data/industries.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
// Top ~100 industries for B2B lead intake. Suggestion list only — users
|
||||||
|
// can still type a free-form value (datalist allows it).
|
||||||
|
export const INDUSTRIES: string[] = [
|
||||||
|
'IT / Разработка ПО',
|
||||||
|
'SaaS',
|
||||||
|
'IT-консалтинг',
|
||||||
|
'Системная интеграция',
|
||||||
|
'Кибербезопасность',
|
||||||
|
'Облачные сервисы',
|
||||||
|
'Хостинг / Дата-центры',
|
||||||
|
'Big Data / Аналитика',
|
||||||
|
'AI / Machine Learning',
|
||||||
|
'Финтех',
|
||||||
|
'Банки',
|
||||||
|
'Микрофинансовые организации',
|
||||||
|
'Страхование',
|
||||||
|
'Инвестиции / Брокеры',
|
||||||
|
'Бухгалтерия / Аудит',
|
||||||
|
'Юриспруденция',
|
||||||
|
'HR / Рекрутинг',
|
||||||
|
'Консалтинг',
|
||||||
|
'Маркетинг / Реклама',
|
||||||
|
'PR / Коммуникации',
|
||||||
|
'Digital-агентства',
|
||||||
|
'SEO / SMM',
|
||||||
|
'Колл-центры',
|
||||||
|
'BPO / Аутсорсинг',
|
||||||
|
'Электронная коммерция',
|
||||||
|
'Маркетплейсы',
|
||||||
|
'Розничная торговля',
|
||||||
|
'Оптовая торговля',
|
||||||
|
'FMCG',
|
||||||
|
'Продукты питания / Напитки',
|
||||||
|
'Алкогольная продукция',
|
||||||
|
'HoReCa / Рестораны',
|
||||||
|
'Гостиницы / Отели',
|
||||||
|
'Туризм / Турагентства',
|
||||||
|
'Авиаперевозки',
|
||||||
|
'Железнодорожные перевозки',
|
||||||
|
'Автоперевозки / Логистика',
|
||||||
|
'Курьерская доставка',
|
||||||
|
'Складская логистика',
|
||||||
|
'Морские перевозки',
|
||||||
|
'Таможня / ВЭД',
|
||||||
|
'Производство (общее)',
|
||||||
|
'Машиностроение',
|
||||||
|
'Металлургия',
|
||||||
|
'Металлообработка',
|
||||||
|
'Химическая промышленность',
|
||||||
|
'Нефтепереработка',
|
||||||
|
'Нефть и Газ',
|
||||||
|
'Горнодобывающая отрасль',
|
||||||
|
'Угольная промышленность',
|
||||||
|
'Энергетика',
|
||||||
|
'Возобновляемая энергетика',
|
||||||
|
'ЖКХ / Коммунальные услуги',
|
||||||
|
'Водоснабжение / Утилиты',
|
||||||
|
'Строительство',
|
||||||
|
'Жилое строительство',
|
||||||
|
'Промышленное строительство',
|
||||||
|
'Архитектура / Проектирование',
|
||||||
|
'Девелопмент / Недвижимость',
|
||||||
|
'Аренда коммерческой недвижимости',
|
||||||
|
'Недвижимость (агентства)',
|
||||||
|
'Деревообработка',
|
||||||
|
'Производство мебели',
|
||||||
|
'Лёгкая промышленность',
|
||||||
|
'Текстиль / Одежда',
|
||||||
|
'Обувь / Аксессуары',
|
||||||
|
'Косметика / Парфюмерия',
|
||||||
|
'Бытовая химия',
|
||||||
|
'Электроника',
|
||||||
|
'Бытовая техника',
|
||||||
|
'Полупроводники',
|
||||||
|
'Телекоммуникации',
|
||||||
|
'Сотовая связь',
|
||||||
|
'Интернет-провайдеры',
|
||||||
|
'Медиа / СМИ',
|
||||||
|
'Издательства',
|
||||||
|
'Кино / Видеопродакшен',
|
||||||
|
'Музыкальная индустрия',
|
||||||
|
'Игровая индустрия / GameDev',
|
||||||
|
'Образование (школы)',
|
||||||
|
'Высшее образование / Вузы',
|
||||||
|
'EdTech / Онлайн-образование',
|
||||||
|
'Курсы / Тренинги',
|
||||||
|
'Здравоохранение',
|
||||||
|
'Частные клиники',
|
||||||
|
'Стоматология',
|
||||||
|
'Фарма / Производство ЛС',
|
||||||
|
'Аптечные сети',
|
||||||
|
'Медицинское оборудование',
|
||||||
|
'Биотех',
|
||||||
|
'Сельское хозяйство',
|
||||||
|
'Животноводство',
|
||||||
|
'Растениеводство',
|
||||||
|
'Агротехнологии (AgriTech)',
|
||||||
|
'Рыболовство / Аквакультура',
|
||||||
|
'Госсектор / Госорганы',
|
||||||
|
'НКО / НГО',
|
||||||
|
'Религиозные организации',
|
||||||
|
'Спорт / Фитнес',
|
||||||
|
'Развлечения / Event-агентства',
|
||||||
|
'Безопасность / ЧОП',
|
||||||
|
'Клининг',
|
||||||
|
'Авто / Дилеры',
|
||||||
|
'Автосервис / СТО',
|
||||||
|
'Автозапчасти',
|
||||||
|
'Грузовая техника / Спецтехника',
|
||||||
|
'Авиация / Производство ВС',
|
||||||
|
'Космос / Аэрокосмическая отрасль',
|
||||||
|
'Оборонная промышленность',
|
||||||
|
'Прочее',
|
||||||
|
];
|
||||||
@ -179,10 +179,11 @@ onMounted(load);
|
|||||||
<option value="rop">rop</option>
|
<option value="rop">rop</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-right">
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex justify-end items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-secondary !py-1 text-xs mr-2"
|
class="btn-secondary !py-1 text-xs w-36"
|
||||||
@click="resetPassword(u)"
|
@click="resetPassword(u)"
|
||||||
>
|
>
|
||||||
Сменить пароль
|
Сменить пароль
|
||||||
@ -190,11 +191,13 @@ onMounted(load);
|
|||||||
<button
|
<button
|
||||||
v-if="u.username !== auth.user?.username"
|
v-if="u.username !== auth.user?.username"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-danger !py-1 text-xs"
|
class="btn-danger !py-1 text-xs w-20"
|
||||||
@click="remove(u)"
|
@click="remove(u)"
|
||||||
>
|
>
|
||||||
Удалить
|
Удалить
|
||||||
</button>
|
</button>
|
||||||
|
<span v-else class="w-20" aria-hidden="true"></span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@ -3,6 +3,9 @@ import { reactive, ref, computed } from 'vue';
|
|||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import QuestionButton from '@/components/QuestionButton.vue';
|
import QuestionButton from '@/components/QuestionButton.vue';
|
||||||
import MoneyInput from '@/components/MoneyInput.vue';
|
import MoneyInput from '@/components/MoneyInput.vue';
|
||||||
|
import PhoneInput from '@/components/PhoneInput.vue';
|
||||||
|
import { INDUSTRIES } from '@/data/industries';
|
||||||
|
import { KZ_CITIES } from '@/data/cities-kz';
|
||||||
import {
|
import {
|
||||||
createLead,
|
createLead,
|
||||||
DEAL_STAGES,
|
DEAL_STAGES,
|
||||||
@ -344,12 +347,9 @@ async function submit() {
|
|||||||
<label class="block text-sm font-medium mb-1">
|
<label class="block text-sm font-medium mb-1">
|
||||||
Телефон <span class="text-red-600">*</span>
|
Телефон <span class="text-red-600">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<PhoneInput
|
||||||
v-model="form.contact_phone"
|
v-model="form.contact_phone"
|
||||||
type="tel"
|
:error="!!displayErrors.contact_phone"
|
||||||
placeholder="+7 ..."
|
|
||||||
class="input-base"
|
|
||||||
:class="displayErrors.contact_phone ? 'border-red-500' : 'border-gray-300'"
|
|
||||||
/>
|
/>
|
||||||
<p v-if="displayErrors.contact_phone" class="mt-1 text-xs text-red-600">
|
<p v-if="displayErrors.contact_phone" class="mt-1 text-xs text-red-600">
|
||||||
{{ displayErrors.contact_phone }}
|
{{ displayErrors.contact_phone }}
|
||||||
@ -422,10 +422,14 @@ async function submit() {
|
|||||||
<input
|
<input
|
||||||
v-model="form.industry"
|
v-model="form.industry"
|
||||||
type="text"
|
type="text"
|
||||||
|
list="industries-list"
|
||||||
placeholder="SaaS / Retail / …"
|
placeholder="SaaS / Retail / …"
|
||||||
class="input-base"
|
class="input-base"
|
||||||
:class="displayErrors.industry ? 'border-red-500' : 'border-gray-300'"
|
:class="displayErrors.industry ? 'border-red-500' : 'border-gray-300'"
|
||||||
/>
|
/>
|
||||||
|
<datalist id="industries-list">
|
||||||
|
<option v-for="ind in INDUSTRIES" :key="ind" :value="ind" />
|
||||||
|
</datalist>
|
||||||
<p v-if="displayErrors.industry" class="mt-1 text-xs text-red-600">
|
<p v-if="displayErrors.industry" class="mt-1 text-xs text-red-600">
|
||||||
{{ displayErrors.industry }}
|
{{ displayErrors.industry }}
|
||||||
</p>
|
</p>
|
||||||
@ -437,10 +441,14 @@ async function submit() {
|
|||||||
<input
|
<input
|
||||||
v-model="form.city"
|
v-model="form.city"
|
||||||
type="text"
|
type="text"
|
||||||
|
list="cities-list"
|
||||||
placeholder="Алматы"
|
placeholder="Алматы"
|
||||||
class="input-base"
|
class="input-base"
|
||||||
:class="displayErrors.city ? 'border-red-500' : 'border-gray-300'"
|
:class="displayErrors.city ? 'border-red-500' : 'border-gray-300'"
|
||||||
/>
|
/>
|
||||||
|
<datalist id="cities-list">
|
||||||
|
<option v-for="c in KZ_CITIES" :key="c" :value="c" />
|
||||||
|
</datalist>
|
||||||
<p v-if="displayErrors.city" class="mt-1 text-xs text-red-600">
|
<p v-if="displayErrors.city" class="mt-1 text-xs text-red-600">
|
||||||
{{ displayErrors.city }}
|
{{ displayErrors.city }}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user