Add money input mask, form validation, and reminder bell

Form UX (NewLeadView):
- Required fields marked with red asterisk: contact_name, contact_phone,
  email, company_name, industry, city, full scoring section
- Client-side validation for phone format and email format
- Errors shown per-field with red border and message; live updates after
  first submit attempt

Money input:
- New MoneyInput component formats raw digits as "1 000 000 ₸"
- Replaces native number input in NewLeadView and LeadCardView pipeline

Reminders:
- Backend GET /reminders returns leads with next_action_date <= today and
  stage not in (won, lost). Scoped to created_by for managers.
- BellMenu component in header (managers only): shows count badge, opens
  dropdown listing overdue and today's touches; auto-refresh every 60s

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ask 2026-05-19 15:56:23 +05:00
parent f38f61b0f6
commit 633c255758
8 changed files with 457 additions and 63 deletions

View File

@ -0,0 +1,83 @@
import type { FastifyInstance } from 'fastify';
import { query } from '../db.js';
import { requireAuth } from '../auth.js';
import type { DealStage, LeadType, LeadStatus } from '../types.js';
interface ReminderRow {
id: string;
contact_name: string;
company_name: string;
next_action_date: Date | null;
deal_stage: DealStage;
lead_type: LeadType;
status: LeadStatus;
score: number;
created_by: string;
}
function formatDateOnly(d: Date | null): string | null {
if (!d) return null;
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
const day = String(d.getUTCDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
export async function reminderRoutes(app: FastifyInstance) {
app.get('/reminders', async (req) => {
const user = await requireAuth(app, req);
const where: string[] = [
`next_action_date IS NOT NULL`,
`next_action_date <= CURRENT_DATE`,
`deal_stage NOT IN ('won','lost')`,
];
const params: unknown[] = [];
if (user.role !== 'rop') {
params.push(user.sub);
where.push(`created_by = $${params.length}`);
}
const sql = `
SELECT
id, contact_name, company_name,
next_action_date, deal_stage, lead_type, status, score, created_by
FROM leads
WHERE ${where.join(' AND ')}
ORDER BY next_action_date ASC, score DESC
LIMIT 50
`;
const res = await query<ReminderRow>(sql, params);
const today = new Date();
today.setUTCHours(0, 0, 0, 0);
const items = res.rows.map((r) => {
const dateStr = formatDateOnly(r.next_action_date);
const isOverdue = r.next_action_date
? r.next_action_date.getTime() < today.getTime()
: false;
return {
id: r.id,
contact_name: r.contact_name,
company_name: r.company_name,
next_action_date: dateStr,
deal_stage: r.deal_stage,
lead_type: r.lead_type,
status: r.status,
score: r.score,
created_by: r.created_by,
is_overdue: isOverdue,
};
});
return {
count: items.length,
overdue: items.filter((i) => i.is_overdue).length,
today: items.filter((i) => !i.is_overdue).length,
items,
};
});
}

View File

@ -6,6 +6,7 @@ import { authRoutes } from './routes/auth.js';
import { leadRoutes } from './routes/leads.js';
import { healthRoutes } from './routes/health.js';
import { statsRoutes } from './routes/stats.js';
import { reminderRoutes } from './routes/reminders.js';
async function buildApp() {
const app = Fastify({
@ -38,6 +39,7 @@ async function buildApp() {
await app.register(authRoutes);
await app.register(leadRoutes);
await app.register(statsRoutes);
await app.register(reminderRoutes);
return app;
}

View File

@ -1,6 +1,7 @@
<script setup lang="ts">
import { RouterLink, RouterView, useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import BellMenu from '@/components/BellMenu.vue';
const auth = useAuthStore();
const router = useRouter();
@ -42,6 +43,7 @@ function logout() {
</RouterLink>
</div>
<div class="flex items-center gap-4">
<BellMenu v-if="auth.user?.role === 'manager'" />
<span class="text-sm text-gray-500">
{{ auth.user?.username }} ({{ auth.user?.role }})
</span>

View File

@ -0,0 +1,26 @@
import { apiFetch } from './client';
import type { DealStage, LeadStatus, LeadType } from './leads';
export interface ReminderItem {
id: string;
contact_name: string;
company_name: string;
next_action_date: string | null;
deal_stage: DealStage;
lead_type: LeadType;
status: LeadStatus;
score: number;
created_by: string;
is_overdue: boolean;
}
export interface RemindersResponse {
count: number;
overdue: number;
today: number;
items: ReminderItem[];
}
export function getReminders() {
return apiFetch<RemindersResponse>('/reminders');
}

View File

@ -0,0 +1,149 @@
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref } from 'vue';
import { useRouter } from 'vue-router';
import { getReminders, type RemindersResponse } from '@/api/reminders';
const router = useRouter();
const data = ref<RemindersResponse | null>(null);
const open = ref(false);
const loading = ref(false);
const error = ref<string | null>(null);
const root = ref<HTMLElement | null>(null);
let timer: number | null = null;
async function load() {
loading.value = true;
error.value = null;
try {
data.value = await getReminders();
} catch (e) {
error.value = (e as Error).message;
} finally {
loading.value = false;
}
}
function toggle() {
open.value = !open.value;
if (open.value) load();
}
function go(id: string) {
open.value = false;
router.push({ name: 'lead-card', params: { id } });
}
function onDocClick(e: MouseEvent) {
if (!root.value) return;
if (!root.value.contains(e.target as Node)) open.value = false;
}
function formatDate(s: string | null) {
if (!s) return '—';
return new Date(s + 'T00:00:00').toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
});
}
onMounted(() => {
load();
timer = window.setInterval(load, 60000);
document.addEventListener('click', onDocClick);
});
onBeforeUnmount(() => {
if (timer !== null) window.clearInterval(timer);
document.removeEventListener('click', onDocClick);
});
</script>
<template>
<div ref="root" class="relative">
<button
type="button"
class="relative text-gray-600 hover:text-gray-900 p-1"
:title="'Напоминания: ' + (data?.count ?? 0)"
@click.stop="toggle"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0"
/>
</svg>
<span
v-if="data && data.count > 0"
class="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 text-[10px] font-bold rounded-full flex items-center justify-center"
:class="data.overdue > 0 ? 'bg-red-600 text-white' : 'bg-amber-500 text-white'"
>
{{ data.count }}
</span>
</button>
<div
v-if="open"
class="absolute right-0 mt-2 w-80 bg-white border border-gray-200 rounded-xl shadow-lg z-50 overflow-hidden"
>
<div class="px-4 py-3 border-b border-gray-100 flex items-center justify-between">
<div>
<div class="font-semibold text-sm">Напоминания</div>
<div v-if="data" class="text-xs text-gray-500 mt-0.5">
<span v-if="data.overdue > 0" class="text-red-600">
просрочено: {{ data.overdue }}
</span>
<span v-if="data.overdue > 0 && data.today > 0"> · </span>
<span v-if="data.today > 0">сегодня: {{ data.today }}</span>
<span v-if="data.count === 0">всё под контролем 🎉</span>
</div>
</div>
<button
type="button"
class="text-xs text-gray-500 hover:text-gray-900"
@click="load"
>
⟳
</button>
</div>
<div v-if="loading && !data" class="px-4 py-6 text-sm text-gray-500">Загрузка…</div>
<div v-else-if="error" class="px-4 py-6 text-sm text-red-600">{{ error }}</div>
<div
v-else-if="data && data.items.length === 0"
class="px-4 py-6 text-sm text-gray-500 text-center"
>
Нет просроченных касаний.
</div>
<ul v-else-if="data" class="max-h-96 overflow-y-auto">
<li
v-for="r in data.items"
:key="r.id"
class="px-4 py-3 border-t border-gray-100 hover:bg-gray-50 cursor-pointer text-sm"
@click="go(r.id)"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<div class="font-medium truncate">{{ r.contact_name }}</div>
<div class="text-xs text-gray-500 truncate">{{ r.company_name }}</div>
</div>
<div
class="text-xs whitespace-nowrap"
:class="r.is_overdue ? 'text-red-600 font-medium' : 'text-amber-600'"
>
{{ r.is_overdue ? '⚠ ' : '' }}{{ formatDate(r.next_action_date) }}
</div>
</div>
</li>
</ul>
</div>
</div>
</template>

View File

@ -0,0 +1,42 @@
<script setup lang="ts">
import { computed } from 'vue';
const props = defineProps<{
modelValue: string;
placeholder?: string;
id?: string;
}>();
const emit = defineEmits<{
'update:modelValue': [v: string];
}>();
function format(raw: string): string {
if (!raw) return '';
const digits = raw.replace(/\D/g, '');
if (!digits) return '';
return Number(digits).toLocaleString('ru-RU') + ' ₸';
}
const display = computed(() => format(props.modelValue));
function onInput(e: Event) {
const input = e.target as HTMLInputElement;
const digits = input.value.replace(/\D/g, '');
emit('update:modelValue', digits);
input.value = format(digits);
}
</script>
<template>
<input
:id="id"
:value="display"
type="text"
inputmode="numeric"
autocomplete="off"
:placeholder="placeholder ?? '0 ₸'"
class="w-full border border-gray-300 rounded-lg px-3 py-2"
@input="onInput"
/>
</template>

View File

@ -3,6 +3,7 @@ import { onMounted, ref, computed, reactive, watch } from 'vue';
import { useRouter } from 'vue-router';
import ScoreBadge from '@/components/ScoreBadge.vue';
import { useAuthStore } from '@/stores/auth';
import MoneyInput from '@/components/MoneyInput.vue';
import {
deleteLead,
getLead,
@ -258,14 +259,7 @@ onMounted(load);
</div>
<div>
<label class="block text-sm font-medium mb-1">Ожидаемая сумма</label>
<input
v-model="pipelineForm.expected_amount"
type="number"
min="0"
step="0.01"
placeholder="500000"
class="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
<MoneyInput v-model="pipelineForm.expected_amount" placeholder="500 000 ₸" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Следующее касание</label>

View File

@ -2,6 +2,7 @@
import { reactive, ref, computed } from 'vue';
import { useRouter } from 'vue-router';
import QuestionButton from '@/components/QuestionButton.vue';
import MoneyInput from '@/components/MoneyInput.vue';
import {
createLead,
DEAL_STAGES,
@ -108,16 +109,50 @@ const prevContactOpts: { value: PrevContact; label: string }[] = [
{ value: 'well-known', label: 'Знакомы хорошо' },
];
const canSubmit = computed(() => {
if (!form.contact_name.trim() || !form.contact_phone.trim() || !form.company_name.trim()) return false;
if (!form.company_size || !form.budget || !form.urgency || !form.dm_contact) return false;
if (form.lead_type === 'inbound' && !form.source) return false;
if (form.lead_type === 'outbound' && !form.prev_contact) return false;
return true;
const PHONE_RE = /^\+?\d[\d\s\-()]{8,}$/;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function validateFields(): Record<string, string> {
const errs: Record<string, string> = {};
if (!form.contact_name.trim()) errs.contact_name = 'Обязательное поле';
if (!form.contact_phone.trim()) errs.contact_phone = 'Обязательное поле';
else if (!PHONE_RE.test(form.contact_phone.trim()))
errs.contact_phone = 'Неверный формат телефона';
if (!form.email.trim()) errs.email = 'Обязательное поле';
else if (!EMAIL_RE.test(form.email.trim())) errs.email = 'Неверный формат email';
if (!form.company_name.trim()) errs.company_name = 'Обязательное поле';
if (!form.industry.trim()) errs.industry = 'Обязательное поле';
if (!form.city.trim()) errs.city = 'Обязательное поле';
if (!form.company_size) errs.company_size = 'Выберите вариант';
if (!form.budget) errs.budget = 'Выберите вариант';
if (!form.urgency) errs.urgency = 'Выберите вариант';
if (!form.dm_contact) errs.dm_contact = 'Выберите вариант';
if (form.lead_type === 'inbound' && !form.source) errs.source = 'Выберите вариант';
if (form.lead_type === 'outbound' && !form.prev_contact)
errs.prev_contact = 'Выберите вариант';
return errs;
}
const liveErrors = computed(() => validateFields());
const canSubmit = computed(() => Object.keys(liveErrors.value).length === 0);
const attempted = ref(false);
const displayErrors = computed<Record<string, string>>(() => {
if (!attempted.value) return fieldErrors.value;
return { ...liveErrors.value, ...fieldErrors.value };
});
async function submit() {
error.value = null;
attempted.value = true;
const errs = validateFields();
if (Object.keys(errs).length > 0) {
fieldErrors.value = errs;
error.value = 'Заполните обязательные поля';
return;
}
fieldErrors.value = {};
loading.value = true;
try {
@ -173,35 +208,48 @@ async function submit() {
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide">Контакт</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1">Имя контакта</label>
<label class="block text-sm font-medium mb-1">
Имя контакта <span class="text-red-600">*</span>
</label>
<input
v-model="form.contact_name"
type="text"
required
class="w-full border border-gray-300 rounded-lg px-3 py-2"
class="w-full border rounded-lg px-3 py-2"
:class="displayErrors.contact_name ? 'border-red-500' : 'border-gray-300'"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1">Телефон</label>
<input
v-model="form.contact_phone"
type="tel"
required
placeholder="+7 ..."
class="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
<p v-if="fieldErrors.contact_phone" class="mt-1 text-xs text-red-600">
{{ fieldErrors.contact_phone }}
<p v-if="displayErrors.contact_name" class="mt-1 text-xs text-red-600">
{{ displayErrors.contact_name }}
</p>
</div>
<div>
<label class="block text-sm font-medium mb-1">Email</label>
<label class="block text-sm font-medium mb-1">
Телефон <span class="text-red-600">*</span>
</label>
<input
v-model="form.contact_phone"
type="tel"
placeholder="+7 ..."
class="w-full border rounded-lg px-3 py-2"
:class="displayErrors.contact_phone ? 'border-red-500' : 'border-gray-300'"
/>
<p v-if="displayErrors.contact_phone" class="mt-1 text-xs text-red-600">
{{ displayErrors.contact_phone }}
</p>
</div>
<div>
<label class="block text-sm font-medium mb-1">
Email <span class="text-red-600">*</span>
</label>
<input
v-model="form.email"
type="email"
placeholder="ivan@acme.com"
class="w-full border border-gray-300 rounded-lg px-3 py-2"
class="w-full border rounded-lg px-3 py-2"
:class="displayErrors.email ? 'border-red-500' : 'border-gray-300'"
/>
<p v-if="displayErrors.email" class="mt-1 text-xs text-red-600">
{{ displayErrors.email }}
</p>
</div>
<div>
<label class="block text-sm font-medium mb-1">Должность</label>
@ -219,13 +267,18 @@ async function submit() {
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide">Компания</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="md:col-span-2">
<label class="block text-sm font-medium mb-1">Название компании</label>
<label class="block text-sm font-medium mb-1">
Название компании <span class="text-red-600">*</span>
</label>
<input
v-model="form.company_name"
type="text"
required
class="w-full border border-gray-300 rounded-lg px-3 py-2"
class="w-full border rounded-lg px-3 py-2"
:class="displayErrors.company_name ? 'border-red-500' : 'border-gray-300'"
/>
<p v-if="displayErrors.company_name" class="mt-1 text-xs text-red-600">
{{ displayErrors.company_name }}
</p>
</div>
<div>
<label class="block text-sm font-medium mb-1">Сайт</label>
@ -237,45 +290,95 @@ async function submit() {
/>
</div>
<div>
<label class="block text-sm font-medium mb-1">Отрасль</label>
<label class="block text-sm font-medium mb-1">
Отрасль <span class="text-red-600">*</span>
</label>
<input
v-model="form.industry"
type="text"
placeholder="SaaS / Retail / …"
class="w-full border border-gray-300 rounded-lg px-3 py-2"
class="w-full border rounded-lg px-3 py-2"
:class="displayErrors.industry ? 'border-red-500' : 'border-gray-300'"
/>
<p v-if="displayErrors.industry" class="mt-1 text-xs text-red-600">
{{ displayErrors.industry }}
</p>
</div>
<div>
<label class="block text-sm font-medium mb-1">Город</label>
<label class="block text-sm font-medium mb-1">
Город <span class="text-red-600">*</span>
</label>
<input
v-model="form.city"
type="text"
placeholder="Алматы"
class="w-full border border-gray-300 rounded-lg px-3 py-2"
class="w-full border rounded-lg px-3 py-2"
:class="displayErrors.city ? 'border-red-500' : 'border-gray-300'"
/>
<p v-if="displayErrors.city" class="mt-1 text-xs text-red-600">
{{ displayErrors.city }}
</p>
</div>
</div>
</section>
<section class="space-y-4">
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide">Скоринг</h2>
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide">
Скоринг <span class="text-red-600">*</span>
</h2>
<QuestionButton label="Тип лида" v-model="form.lead_type" :options="leadTypeOpts" />
<QuestionButton label="Размер компании" v-model="form.company_size" :options="companySizeOpts" />
<QuestionButton label="Бюджет" v-model="form.budget" :options="budgetOpts" />
<QuestionButton label="Срочность" v-model="form.urgency" :options="urgencyOpts" />
<QuestionButton label="Контакт — ЛПР?" v-model="form.dm_contact" :options="dmContactOpts" />
<QuestionButton
v-if="form.lead_type === 'inbound'"
label="Источник лида"
v-model="form.source"
:options="sourceOpts"
/>
<QuestionButton
v-else
label="Предыдущий контакт"
v-model="form.prev_contact"
:options="prevContactOpts"
/>
<div>
<QuestionButton
label="Размер компании"
v-model="form.company_size"
:options="companySizeOpts"
/>
<p v-if="displayErrors.company_size" class="mt-1 text-xs text-red-600">
{{ displayErrors.company_size }}
</p>
</div>
<div>
<QuestionButton label="Бюджет" v-model="form.budget" :options="budgetOpts" />
<p v-if="displayErrors.budget" class="mt-1 text-xs text-red-600">
{{ displayErrors.budget }}
</p>
</div>
<div>
<QuestionButton label="Срочность" v-model="form.urgency" :options="urgencyOpts" />
<p v-if="displayErrors.urgency" class="mt-1 text-xs text-red-600">
{{ displayErrors.urgency }}
</p>
</div>
<div>
<QuestionButton
label="Контакт — ЛПР?"
v-model="form.dm_contact"
:options="dmContactOpts"
/>
<p v-if="displayErrors.dm_contact" class="mt-1 text-xs text-red-600">
{{ displayErrors.dm_contact }}
</p>
</div>
<div v-if="form.lead_type === 'inbound'">
<QuestionButton
label="Источник лида"
v-model="form.source"
:options="sourceOpts"
/>
<p v-if="displayErrors.source" class="mt-1 text-xs text-red-600">
{{ displayErrors.source }}
</p>
</div>
<div v-else>
<QuestionButton
label="Предыдущий контакт"
v-model="form.prev_contact"
:options="prevContactOpts"
/>
<p v-if="displayErrors.prev_contact" class="mt-1 text-xs text-red-600">
{{ displayErrors.prev_contact }}
</p>
</div>
</section>
<section class="space-y-4">
@ -294,14 +397,7 @@ async function submit() {
</div>
<div>
<label class="block text-sm font-medium mb-1">Ожидаемая сумма</label>
<input
v-model="form.expected_amount"
type="number"
min="0"
step="0.01"
placeholder="500000"
class="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
<MoneyInput v-model="form.expected_amount" placeholder="500 000 ₸" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Следующее касание</label>