feat(ux): compact money, phone mask, datalists, audit ilike filter

- LeadsTable money: large numbers now collapse to "80M ₸" / "1.5M ₸"
  / "500K ₸". whitespace-nowrap + title with the exact amount so the
  cell no longer overflows into neighbouring columns.
- PhoneInput.vue: input mask that formats to "+7 (XXX) XXX-XX-XX"
  as you type; normalises leading 8 -> 7; accepts paste; emits the
  formatted string back to v-model. NewLeadView swapped to use it.
- INDUSTRIES dataset (~110 RU/SNG B2B verticals) wired as a
  <datalist> on the "Отрасль" field — suggestion list, free text
  still allowed.
- KZ_CITIES dataset (республиканские + областные + крупные
  районные центры) wired as <datalist> on "Город" — same UX:
  combobox-style, manager can type anything if their city is missing.
- /audit filters: entity_id and actor switched from exact match to
  ILIKE '%...%' so a UUID prefix or username substring matches.
  entity stays exact (enum).
This commit is contained in:
Telecom 2026-05-22 13:17:33 +05:00
parent af53bc77d5
commit b18413e6f5
6 changed files with 326 additions and 14 deletions

View File

@ -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 ')}` : '';

View File

@ -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 }) + ' ₸';
}
@ -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-right 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">

View 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>

View 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
'Алга',
'Аральск',
'Балыкши',
'Денисовка',
'Дербисек',
'Жалагаш',
'Жанаарка',
'Кокпекты',
'Кордай',
'Косшы',
'Лениногорск',
'Маканчи',
'Махамбет',
'Мангистау',
'Нуркент',
'Отеген батыр',
'Сайрам',
'Сарыкемер',
'Талапкер',
'Тургень',
'Узынагаш',
'Уральск (Орал)',
'Форт-Шевченко',
'Чу',
'Шиели',
'Шортанды',
'Шу',
'Янги-Курган',
'Кушмурун',
];

View 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-агентства',
'Безопасность / ЧОП',
'Клининг',
'Авто / Дилеры',
'Автосервис / СТО',
'Автозапчасти',
'Грузовая техника / Спецтехника',
'Авиация / Производство ВС',
'Космос / Аэрокосмическая отрасль',
'Оборонная промышленность',
'Прочее',
];

View File

@ -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>