Compare commits

..

3 Commits

Author SHA1 Message Date
Telecom
0a403381c5 fix(admin-users): align action buttons in a fixed-width column
The "Сменить пароль" / "Удалить" pair is now wrapped in a
flex justify-end row with fixed widths (144px and 80px). When the
"Удалить" button is hidden for the current user, an empty spacer of
the same width keeps "Сменить пароль" in the same horizontal
position across all rows. Cleans up the ragged column.
2026-05-22 13:26:25 +05:00
Telecom
b1468eeb42 fix(table): center-align the Сумма column
Both header and value in LeadsTable now use text-center so the
column reads as symmetric. Previously the header was overridden to
left by .table-base th and the value was right-aligned, producing a
visible offset on rows with large amounts (e.g. 80M ₸).
2026-05-22 13:22:59 +05:00
Telecom
b18413e6f5 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).
2026-05-22 13:17:33 +05:00
7 changed files with 346 additions and 31 deletions

View File

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

View File

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

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

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

View File

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