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 params: unknown[] = [];
|
||||
if (req.query.entity) {
|
||||
params.push(req.query.entity);
|
||||
if (req.query.entity && req.query.entity.trim()) {
|
||||
params.push(req.query.entity.trim());
|
||||
where.push(`entity = $${params.length}`);
|
||||
}
|
||||
if (req.query.entity_id) {
|
||||
params.push(req.query.entity_id);
|
||||
where.push(`entity_id = $${params.length}`);
|
||||
if (req.query.entity_id && req.query.entity_id.trim()) {
|
||||
// Prefix-substring match so users can paste any chunk of a UUID
|
||||
// (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) {
|
||||
params.push(req.query.actor);
|
||||
where.push(`actor = $${params.length}`);
|
||||
if (req.query.actor && req.query.actor.trim()) {
|
||||
params.push('%' + req.query.actor.trim() + '%');
|
||||
where.push(`actor ILIKE $${params.length}`);
|
||||
}
|
||||
const limit = Math.min(Math.max(Number(req.query.limit ?? 100), 1), 500);
|
||||
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
||||
|
||||
@ -71,6 +71,11 @@ function formatDate(s: string | null) {
|
||||
|
||||
function formatMoney(v: number | null) {
|
||||
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 }) + ' ₸';
|
||||
}
|
||||
|
||||
@ -215,7 +220,7 @@ function exportCsv() {
|
||||
<th>Контакт</th>
|
||||
<th>Компания</th>
|
||||
<th>Этап</th>
|
||||
<th class="text-right">Сумма</th>
|
||||
<th class="!text-center">Сумма</th>
|
||||
<th>Касание</th>
|
||||
<th>Тип</th>
|
||||
<th>Автор</th>
|
||||
@ -245,7 +250,10 @@ function exportCsv() {
|
||||
{{ DEAL_STAGE_LABELS[lead.deal_stage] }}
|
||||
</span>
|
||||
</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) }}
|
||||
</td>
|
||||
<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>
|
||||
</select>
|
||||
</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
|
||||
type="button"
|
||||
class="btn-secondary !py-1 text-xs mr-2"
|
||||
class="btn-secondary !py-1 text-xs w-36"
|
||||
@click="resetPassword(u)"
|
||||
>
|
||||
Сменить пароль
|
||||
@ -190,11 +191,13 @@ onMounted(load);
|
||||
<button
|
||||
v-if="u.username !== auth.user?.username"
|
||||
type="button"
|
||||
class="btn-danger !py-1 text-xs"
|
||||
class="btn-danger !py-1 text-xs w-20"
|
||||
@click="remove(u)"
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
<span v-else class="w-20" aria-hidden="true"></span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@ -3,6 +3,9 @@ import { reactive, ref, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import QuestionButton from '@/components/QuestionButton.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 {
|
||||
createLead,
|
||||
DEAL_STAGES,
|
||||
@ -344,12 +347,9 @@ async function submit() {
|
||||
<label class="block text-sm font-medium mb-1">
|
||||
Телефон <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
<PhoneInput
|
||||
v-model="form.contact_phone"
|
||||
type="tel"
|
||||
placeholder="+7 ..."
|
||||
class="input-base"
|
||||
:class="displayErrors.contact_phone ? 'border-red-500' : 'border-gray-300'"
|
||||
:error="!!displayErrors.contact_phone"
|
||||
/>
|
||||
<p v-if="displayErrors.contact_phone" class="mt-1 text-xs text-red-600">
|
||||
{{ displayErrors.contact_phone }}
|
||||
@ -422,10 +422,14 @@ async function submit() {
|
||||
<input
|
||||
v-model="form.industry"
|
||||
type="text"
|
||||
list="industries-list"
|
||||
placeholder="SaaS / Retail / …"
|
||||
class="input-base"
|
||||
: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">
|
||||
{{ displayErrors.industry }}
|
||||
</p>
|
||||
@ -437,10 +441,14 @@ async function submit() {
|
||||
<input
|
||||
v-model="form.city"
|
||||
type="text"
|
||||
list="cities-list"
|
||||
placeholder="Алматы"
|
||||
class="input-base"
|
||||
: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">
|
||||
{{ displayErrors.city }}
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user