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:
parent
f38f61b0f6
commit
633c255758
83
leadscore/backend/src/routes/reminders.ts
Normal file
83
leadscore/backend/src/routes/reminders.ts
Normal 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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import { authRoutes } from './routes/auth.js';
|
|||||||
import { leadRoutes } from './routes/leads.js';
|
import { leadRoutes } from './routes/leads.js';
|
||||||
import { healthRoutes } from './routes/health.js';
|
import { healthRoutes } from './routes/health.js';
|
||||||
import { statsRoutes } from './routes/stats.js';
|
import { statsRoutes } from './routes/stats.js';
|
||||||
|
import { reminderRoutes } from './routes/reminders.js';
|
||||||
|
|
||||||
async function buildApp() {
|
async function buildApp() {
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
@ -38,6 +39,7 @@ async function buildApp() {
|
|||||||
await app.register(authRoutes);
|
await app.register(authRoutes);
|
||||||
await app.register(leadRoutes);
|
await app.register(leadRoutes);
|
||||||
await app.register(statsRoutes);
|
await app.register(statsRoutes);
|
||||||
|
await app.register(reminderRoutes);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterLink, RouterView, useRouter } from 'vue-router';
|
import { RouterLink, RouterView, useRouter } from 'vue-router';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import BellMenu from '@/components/BellMenu.vue';
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -42,6 +43,7 @@ function logout() {
|
|||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
|
<BellMenu v-if="auth.user?.role === 'manager'" />
|
||||||
<span class="text-sm text-gray-500">
|
<span class="text-sm text-gray-500">
|
||||||
{{ auth.user?.username }} ({{ auth.user?.role }})
|
{{ auth.user?.username }} ({{ auth.user?.role }})
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
26
leadscore/frontend/src/api/reminders.ts
Normal file
26
leadscore/frontend/src/api/reminders.ts
Normal 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');
|
||||||
|
}
|
||||||
149
leadscore/frontend/src/components/BellMenu.vue
Normal file
149
leadscore/frontend/src/components/BellMenu.vue
Normal 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>
|
||||||
42
leadscore/frontend/src/components/MoneyInput.vue
Normal file
42
leadscore/frontend/src/components/MoneyInput.vue
Normal 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>
|
||||||
@ -3,6 +3,7 @@ import { onMounted, ref, computed, reactive, watch } from 'vue';
|
|||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import ScoreBadge from '@/components/ScoreBadge.vue';
|
import ScoreBadge from '@/components/ScoreBadge.vue';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import MoneyInput from '@/components/MoneyInput.vue';
|
||||||
import {
|
import {
|
||||||
deleteLead,
|
deleteLead,
|
||||||
getLead,
|
getLead,
|
||||||
@ -258,14 +259,7 @@ onMounted(load);
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1">Ожидаемая сумма</label>
|
<label class="block text-sm font-medium mb-1">Ожидаемая сумма</label>
|
||||||
<input
|
<MoneyInput v-model="pipelineForm.expected_amount" placeholder="500 000 ₸" />
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1">Следующее касание</label>
|
<label class="block text-sm font-medium mb-1">Следующее касание</label>
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
import { reactive, ref, computed } from 'vue';
|
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 {
|
import {
|
||||||
createLead,
|
createLead,
|
||||||
DEAL_STAGES,
|
DEAL_STAGES,
|
||||||
@ -108,16 +109,50 @@ const prevContactOpts: { value: PrevContact; label: string }[] = [
|
|||||||
{ value: 'well-known', label: 'Знакомы хорошо' },
|
{ value: 'well-known', label: 'Знакомы хорошо' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const canSubmit = computed(() => {
|
const PHONE_RE = /^\+?\d[\d\s\-()]{8,}$/;
|
||||||
if (!form.contact_name.trim() || !form.contact_phone.trim() || !form.company_name.trim()) return false;
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
if (!form.company_size || !form.budget || !form.urgency || !form.dm_contact) return false;
|
|
||||||
if (form.lead_type === 'inbound' && !form.source) return false;
|
function validateFields(): Record<string, string> {
|
||||||
if (form.lead_type === 'outbound' && !form.prev_contact) return false;
|
const errs: Record<string, string> = {};
|
||||||
return true;
|
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() {
|
async function submit() {
|
||||||
error.value = null;
|
error.value = null;
|
||||||
|
attempted.value = true;
|
||||||
|
const errs = validateFields();
|
||||||
|
if (Object.keys(errs).length > 0) {
|
||||||
|
fieldErrors.value = errs;
|
||||||
|
error.value = 'Заполните обязательные поля';
|
||||||
|
return;
|
||||||
|
}
|
||||||
fieldErrors.value = {};
|
fieldErrors.value = {};
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
@ -173,35 +208,48 @@ async function submit() {
|
|||||||
<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">Контакт</h2>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<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
|
<input
|
||||||
v-model="form.contact_name"
|
v-model="form.contact_name"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
class="w-full border rounded-lg px-3 py-2"
|
||||||
class="w-full border border-gray-300 rounded-lg px-3 py-2"
|
:class="displayErrors.contact_name ? 'border-red-500' : 'border-gray-300'"
|
||||||
/>
|
/>
|
||||||
</div>
|
<p v-if="displayErrors.contact_name" class="mt-1 text-xs text-red-600">
|
||||||
<div>
|
{{ displayErrors.contact_name }}
|
||||||
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<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
|
<input
|
||||||
v-model="form.email"
|
v-model="form.email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="ivan@acme.com"
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1">Должность</label>
|
<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>
|
<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="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div class="md:col-span-2">
|
<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
|
<input
|
||||||
v-model="form.company_name"
|
v-model="form.company_name"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
class="w-full border rounded-lg px-3 py-2"
|
||||||
class="w-full border border-gray-300 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>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1">Сайт</label>
|
<label class="block text-sm font-medium mb-1">Сайт</label>
|
||||||
@ -237,45 +290,95 @@ async function submit() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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
|
<input
|
||||||
v-model="form.industry"
|
v-model="form.industry"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="SaaS / Retail / …"
|
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>
|
||||||
<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
|
<input
|
||||||
v-model="form.city"
|
v-model="form.city"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Алматы"
|
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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="space-y-4">
|
<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.lead_type" :options="leadTypeOpts" />
|
||||||
<QuestionButton label="Размер компании" v-model="form.company_size" :options="companySizeOpts" />
|
<div>
|
||||||
<QuestionButton label="Бюджет" v-model="form.budget" :options="budgetOpts" />
|
<QuestionButton
|
||||||
<QuestionButton label="Срочность" v-model="form.urgency" :options="urgencyOpts" />
|
label="Размер компании"
|
||||||
<QuestionButton label="Контакт — ЛПР?" v-model="form.dm_contact" :options="dmContactOpts" />
|
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
|
<QuestionButton
|
||||||
v-if="form.lead_type === 'inbound'"
|
|
||||||
label="Источник лида"
|
label="Источник лида"
|
||||||
v-model="form.source"
|
v-model="form.source"
|
||||||
:options="sourceOpts"
|
:options="sourceOpts"
|
||||||
/>
|
/>
|
||||||
|
<p v-if="displayErrors.source" class="mt-1 text-xs text-red-600">
|
||||||
|
{{ displayErrors.source }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
<QuestionButton
|
<QuestionButton
|
||||||
v-else
|
|
||||||
label="Предыдущий контакт"
|
label="Предыдущий контакт"
|
||||||
v-model="form.prev_contact"
|
v-model="form.prev_contact"
|
||||||
:options="prevContactOpts"
|
:options="prevContactOpts"
|
||||||
/>
|
/>
|
||||||
|
<p v-if="displayErrors.prev_contact" class="mt-1 text-xs text-red-600">
|
||||||
|
{{ displayErrors.prev_contact }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
@ -294,14 +397,7 @@ async function submit() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1">Ожидаемая сумма</label>
|
<label class="block text-sm font-medium mb-1">Ожидаемая сумма</label>
|
||||||
<input
|
<MoneyInput v-model="form.expected_amount" placeholder="500 000 ₸" />
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1">Следующее касание</label>
|
<label class="block text-sm font-medium mb-1">Следующее касание</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user