feat: dedup on lead create + @mention notifications in notes
Dedup (P0):
- Backend findDuplicate() matches by lower(email) and digits-only
contact_phone (normalises leading 8 -> 7 for KZ/RU). New
GET /leads/check returns the existing lead summary; POST /leads
short-circuits with 409 DUPLICATE {existing: {...}} unless the
request body includes force: true.
- ApiRequestError now carries the full server error payload so the
client can read the existing-lead info from a 409.
- NewLeadView debounces a check on email/phone blur and shows an
amber banner with the existing lead (link to the card, matched_on
field, current owner, deal stage). A confirm checkbox unlocks the
submit button and propagates force=true. 409 raised by submit is
caught and re-renders the banner.
@mention (P1):
- noteRoutes parses @username tokens from the note body. For each
match that exists in app_users (and isn't the author), it inserts
a lightweight other-kind task assigned to the mentioned user with
due_date=today and a title pointing back at the source. Recipient
picks it up in /tasks/upcoming -> bell. POST returns the new note
+ mentioned: string[].
- NotesPanel toasts "Уведомил: @user1, @user2" on save and renders
@mentions inline as a sky-tinted pill (no v-html — safe segments).
This commit is contained in:
parent
7e4c212db8
commit
8990edc281
@ -32,6 +32,7 @@ interface LeadBody {
|
|||||||
expected_amount?: unknown;
|
expected_amount?: unknown;
|
||||||
next_action_date?: unknown;
|
next_action_date?: unknown;
|
||||||
comment?: unknown;
|
comment?: unknown;
|
||||||
|
force?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireString(name: string, v: unknown): string {
|
function requireString(name: string, v: unknown): string {
|
||||||
@ -156,6 +157,79 @@ async function latestBreakdown(leadId: string): Promise<Record<string, number>>
|
|||||||
return rows[0]?.breakdown ?? {};
|
return rows[0]?.breakdown ?? {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DuplicateMatch {
|
||||||
|
id: string;
|
||||||
|
contact_name: string;
|
||||||
|
company_name: string;
|
||||||
|
created_by: string;
|
||||||
|
deal_stage: DealStage;
|
||||||
|
matched_on: 'email' | 'phone';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalises a phone string to digits-only, mapping a leading 8 to 7
|
||||||
|
// (KZ/RU convention). Empty input → empty string.
|
||||||
|
function normalisePhone(raw: string): string {
|
||||||
|
const digits = raw.replace(/\D/g, '');
|
||||||
|
if (!digits) return '';
|
||||||
|
return digits[0] === '8' ? '7' + digits.slice(1) : digits;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Looks for an existing lead that shares the given email or phone. Email
|
||||||
|
// match is case-insensitive on the local form; phone match strips
|
||||||
|
// non-digits on both sides. excludeId is honoured so PATCH self-edits
|
||||||
|
// don't flag themselves.
|
||||||
|
async function findDuplicate(
|
||||||
|
email: string,
|
||||||
|
phone: string,
|
||||||
|
excludeId?: string,
|
||||||
|
): Promise<DuplicateMatch | null> {
|
||||||
|
const trimmedEmail = email.trim().toLowerCase();
|
||||||
|
const normPhone = normalisePhone(phone);
|
||||||
|
|
||||||
|
const conds: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
if (trimmedEmail) {
|
||||||
|
params.push(trimmedEmail);
|
||||||
|
conds.push(`LOWER(email) = $${params.length}`);
|
||||||
|
}
|
||||||
|
if (normPhone) {
|
||||||
|
params.push(normPhone);
|
||||||
|
conds.push(`REGEXP_REPLACE(contact_phone, '[^0-9]', '', 'g') = $${params.length}`);
|
||||||
|
}
|
||||||
|
if (conds.length === 0) return null;
|
||||||
|
|
||||||
|
let whereSql = `(${conds.join(' OR ')})`;
|
||||||
|
if (excludeId) {
|
||||||
|
params.push(excludeId);
|
||||||
|
whereSql += ` AND id <> $${params.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await query<DuplicateMatch & {
|
||||||
|
email: string;
|
||||||
|
contact_phone: string;
|
||||||
|
}>(
|
||||||
|
`SELECT id, contact_name, company_name, created_by, deal_stage,
|
||||||
|
email, contact_phone
|
||||||
|
FROM leads
|
||||||
|
WHERE ${whereSql}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) return null;
|
||||||
|
const emailMatch =
|
||||||
|
trimmedEmail && row.email && row.email.trim().toLowerCase() === trimmedEmail;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
contact_name: row.contact_name,
|
||||||
|
company_name: row.company_name,
|
||||||
|
created_by: row.created_by,
|
||||||
|
deal_stage: row.deal_stage,
|
||||||
|
matched_on: emailMatch ? 'email' : 'phone',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function checkLeadAccess(
|
async function checkLeadAccess(
|
||||||
leadId: string,
|
leadId: string,
|
||||||
user: { sub: string; role: string },
|
user: { sub: string; role: string },
|
||||||
@ -407,8 +481,21 @@ export async function leadRoutes(app: FastifyInstance) {
|
|||||||
.send('' + lines.join('\n'));
|
.send('' + lines.join('\n'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// DUPLICATE CHECK (no insert)
|
||||||
|
app.get<{ Querystring: { email?: string; phone?: string } }>(
|
||||||
|
'/leads/check',
|
||||||
|
async (req) => {
|
||||||
|
const email = (req.query.email ?? '').trim();
|
||||||
|
const phone = (req.query.phone ?? '').trim();
|
||||||
|
if (!email && !phone) return { duplicate: false };
|
||||||
|
const match = await findDuplicate(email, phone);
|
||||||
|
if (!match) return { duplicate: false };
|
||||||
|
return { duplicate: true, existing: match };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// CREATE
|
// CREATE
|
||||||
app.post<{ Body: LeadBody }>('/leads', async (req) => {
|
app.post<{ Body: LeadBody }>('/leads', async (req, reply) => {
|
||||||
const user = req.user as { sub: string };
|
const user = req.user as { sub: string };
|
||||||
const body = req.body ?? {};
|
const body = req.body ?? {};
|
||||||
|
|
||||||
@ -417,6 +504,20 @@ export async function leadRoutes(app: FastifyInstance) {
|
|||||||
const company_name = requireString('company_name', body.company_name);
|
const company_name = requireString('company_name', body.company_name);
|
||||||
|
|
||||||
const email = optionalString(body.email);
|
const email = optionalString(body.email);
|
||||||
|
const force = body.force === true;
|
||||||
|
|
||||||
|
if (!force) {
|
||||||
|
const dup = await findDuplicate(email, contact_phone);
|
||||||
|
if (dup) {
|
||||||
|
return reply.code(409).send({
|
||||||
|
error: {
|
||||||
|
code: 'DUPLICATE',
|
||||||
|
message: 'Лид с такими контактами уже существует',
|
||||||
|
existing: dup,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
const position = optionalString(body.position);
|
const position = optionalString(body.position);
|
||||||
const website = optionalString(body.website);
|
const website = optionalString(body.website);
|
||||||
const industry = optionalString(body.industry);
|
const industry = optionalString(body.industry);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { query } from '../db.js';
|
import { query } from '../db.js';
|
||||||
import { requireAuth } from '../auth.js';
|
import { listAllUsers, requireAuth } from '../auth.js';
|
||||||
import { AppError } from '../errors.js';
|
import { AppError } from '../errors.js';
|
||||||
|
|
||||||
interface NoteRow {
|
interface NoteRow {
|
||||||
@ -11,6 +11,54 @@ interface NoteRow {
|
|||||||
created_at: Date;
|
created_at: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pulls @username tokens out of free-form note text. Usernames are
|
||||||
|
// 3-32 chars of letters/digits/._- (matches what admin allows).
|
||||||
|
function extractMentions(text: string): string[] {
|
||||||
|
const re = /@([A-Za-z0-9._-]{2,32})/g;
|
||||||
|
const out = new Set<string>();
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(text)) !== null) out.add(m[1]);
|
||||||
|
return Array.from(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort: for each valid mentioned user (existing in app_users,
|
||||||
|
// excluding the author), drop a lightweight "other" task that surfaces
|
||||||
|
// in their bell-menu via /tasks/upcoming. Title carries the author so
|
||||||
|
// the recipient knows who pinged them; the lead context routes them
|
||||||
|
// back to the source thread.
|
||||||
|
async function notifyMentions(
|
||||||
|
leadId: string,
|
||||||
|
author: string,
|
||||||
|
body: string,
|
||||||
|
contactName: string,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const mentions = extractMentions(body);
|
||||||
|
if (mentions.length === 0) return [];
|
||||||
|
const known = new Set(listAllUsers().map((u) => u.username));
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const notified: string[] = [];
|
||||||
|
for (const username of mentions) {
|
||||||
|
if (username === author) continue;
|
||||||
|
if (!known.has(username)) continue;
|
||||||
|
try {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO lead_tasks (lead_id, kind, title, due_date, assigned_to)
|
||||||
|
VALUES ($1, 'other', $2, $3, $4)`,
|
||||||
|
[
|
||||||
|
leadId,
|
||||||
|
`💬 @${author} упомянул в заметке (${contactName})`,
|
||||||
|
today,
|
||||||
|
username,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
notified.push(username);
|
||||||
|
} catch {
|
||||||
|
/* swallow — mention notification is best-effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return notified;
|
||||||
|
}
|
||||||
|
|
||||||
async function checkLeadAccess(
|
async function checkLeadAccess(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
leadId: string,
|
leadId: string,
|
||||||
@ -56,7 +104,21 @@ export async function noteRoutes(app: FastifyInstance) {
|
|||||||
RETURNING id, lead_id, body, author, created_at`,
|
RETURNING id, lead_id, body, author, created_at`,
|
||||||
[req.params.id, body, user.sub],
|
[req.params.id, body, user.sub],
|
||||||
);
|
);
|
||||||
return rows[0];
|
const note = rows[0];
|
||||||
|
|
||||||
|
// Look up the contact name to embed in the mention notification.
|
||||||
|
const { rows: leadRows } = await query<{ contact_name: string }>(
|
||||||
|
`SELECT contact_name FROM leads WHERE id = $1`,
|
||||||
|
[req.params.id],
|
||||||
|
);
|
||||||
|
const contactName = leadRows[0]?.contact_name ?? 'лид';
|
||||||
|
const mentioned = await notifyMentions(
|
||||||
|
req.params.id,
|
||||||
|
user.sub,
|
||||||
|
body,
|
||||||
|
contactName,
|
||||||
|
);
|
||||||
|
return { ...note, mentioned };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -4,18 +4,21 @@ export interface ApiError {
|
|||||||
code: string;
|
code: string;
|
||||||
message: string;
|
message: string;
|
||||||
fields?: Record<string, string>;
|
fields?: Record<string, string>;
|
||||||
|
[extra: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ApiRequestError extends Error {
|
export class ApiRequestError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
code: string;
|
code: string;
|
||||||
fields?: Record<string, string>;
|
fields?: Record<string, string>;
|
||||||
|
payload: ApiError;
|
||||||
|
|
||||||
constructor(status: number, err: ApiError) {
|
constructor(status: number, err: ApiError) {
|
||||||
super(err.message);
|
super(err.message);
|
||||||
this.status = status;
|
this.status = status;
|
||||||
this.code = err.code;
|
this.code = err.code;
|
||||||
this.fields = err.fields;
|
this.fields = err.fields;
|
||||||
|
this.payload = err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -180,7 +180,25 @@ export function listManagers() {
|
|||||||
return apiFetch<{ data: string[] }>('/managers');
|
return apiFetch<{ data: string[] }>('/managers');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createLead(body: CreateLeadBody) {
|
export interface DuplicateMatch {
|
||||||
|
id: string;
|
||||||
|
contact_name: string;
|
||||||
|
company_name: string;
|
||||||
|
created_by: string;
|
||||||
|
deal_stage: DealStage;
|
||||||
|
matched_on: 'email' | 'phone';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkDuplicate(args: { email?: string; phone?: string }) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (args.email) params.set('email', args.email);
|
||||||
|
if (args.phone) params.set('phone', args.phone);
|
||||||
|
return apiFetch<{ duplicate: boolean; existing?: DuplicateMatch }>(
|
||||||
|
`/leads/check?${params.toString()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLead(body: CreateLeadBody & { force?: boolean }) {
|
||||||
return apiFetch<Lead>('/leads', {
|
return apiFetch<Lead>('/leads', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
|
|||||||
@ -8,12 +8,16 @@ export interface Note {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreatedNote extends Note {
|
||||||
|
mentioned?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export function listNotes(leadId: string) {
|
export function listNotes(leadId: string) {
|
||||||
return apiFetch<{ data: Note[] }>(`/leads/${leadId}/notes`);
|
return apiFetch<{ data: Note[] }>(`/leads/${leadId}/notes`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createNote(leadId: string, body: string) {
|
export function createNote(leadId: string, body: string) {
|
||||||
return apiFetch<Note>(`/leads/${leadId}/notes`, {
|
return apiFetch<CreatedNote>(`/leads/${leadId}/notes`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ body }),
|
body: JSON.stringify({ body }),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -49,6 +49,11 @@ async function add() {
|
|||||||
const created = await createNote(props.leadId, text);
|
const created = await createNote(props.leadId, text);
|
||||||
notes.value.unshift(created);
|
notes.value.unshift(created);
|
||||||
draft.value = '';
|
draft.value = '';
|
||||||
|
if (created.mentioned && created.mentioned.length > 0) {
|
||||||
|
toast.success(
|
||||||
|
'Уведомил: ' + created.mentioned.map((u) => '@' + u).join(', '),
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error((e as Error).message);
|
toast.error((e as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
@ -56,6 +61,25 @@ async function add() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BodySegment {
|
||||||
|
text: string;
|
||||||
|
mention: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSegments(body: string): BodySegment[] {
|
||||||
|
const re = /@([A-Za-z0-9._-]{2,32})/g;
|
||||||
|
const out: BodySegment[] = [];
|
||||||
|
let last = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(body)) !== null) {
|
||||||
|
if (m.index > last) out.push({ text: body.slice(last, m.index), mention: false });
|
||||||
|
out.push({ text: m[0], mention: true });
|
||||||
|
last = m.index + m[0].length;
|
||||||
|
}
|
||||||
|
if (last < body.length) out.push({ text: body.slice(last), mention: false });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
async function remove(id: string) {
|
async function remove(id: string) {
|
||||||
if (!confirm('Удалить заметку?')) return;
|
if (!confirm('Удалить заметку?')) return;
|
||||||
try {
|
try {
|
||||||
@ -142,7 +166,15 @@ onMounted(load);
|
|||||||
удалить
|
удалить
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm mt-1 whitespace-pre-wrap">{{ n.body }}</p>
|
<p class="text-sm mt-1 whitespace-pre-wrap">
|
||||||
|
<template v-for="(seg, i) in renderSegments(n.body)" :key="i">
|
||||||
|
<span
|
||||||
|
v-if="seg.mention"
|
||||||
|
class="text-sky-700 font-medium bg-sky-50 px-1 rounded"
|
||||||
|
>{{ seg.text }}</span>
|
||||||
|
<template v-else>{{ seg.text }}</template>
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import PhoneInput from '@/components/PhoneInput.vue';
|
|||||||
import { INDUSTRIES } from '@/data/industries';
|
import { INDUSTRIES } from '@/data/industries';
|
||||||
import { KZ_CITIES } from '@/data/cities-kz';
|
import { KZ_CITIES } from '@/data/cities-kz';
|
||||||
import {
|
import {
|
||||||
|
checkDuplicate,
|
||||||
createLead,
|
createLead,
|
||||||
DEAL_STAGES,
|
DEAL_STAGES,
|
||||||
DEAL_STAGE_LABELS,
|
DEAL_STAGE_LABELS,
|
||||||
@ -15,6 +16,7 @@ import {
|
|||||||
type CreateLeadBody,
|
type CreateLeadBody,
|
||||||
type DealStage,
|
type DealStage,
|
||||||
type DmContact,
|
type DmContact,
|
||||||
|
type DuplicateMatch,
|
||||||
type LeadType,
|
type LeadType,
|
||||||
type PrevContact,
|
type PrevContact,
|
||||||
type Source,
|
type Source,
|
||||||
@ -78,6 +80,30 @@ const form = reactive<FormState>({
|
|||||||
|
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const fieldErrors = ref<Record<string, string>>({});
|
const fieldErrors = ref<Record<string, string>>({});
|
||||||
|
const duplicate = ref<DuplicateMatch | null>(null);
|
||||||
|
const forceCreate = ref(false);
|
||||||
|
let dupTimer: number | null = null;
|
||||||
|
|
||||||
|
async function runDupCheck() {
|
||||||
|
const email = form.email.trim();
|
||||||
|
const phone = form.contact_phone.trim();
|
||||||
|
if (!email && !phone) {
|
||||||
|
duplicate.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await checkDuplicate({ email, phone });
|
||||||
|
duplicate.value = r.duplicate && r.existing ? r.existing : null;
|
||||||
|
if (!duplicate.value) forceCreate.value = false;
|
||||||
|
} catch {
|
||||||
|
/* silent — banner just won't show */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleDupCheck() {
|
||||||
|
if (dupTimer !== null) window.clearTimeout(dupTimer);
|
||||||
|
dupTimer = window.setTimeout(runDupCheck, 300);
|
||||||
|
}
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@ -185,7 +211,11 @@ function validateFields(): Record<string, string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const liveErrors = computed(() => validateFields());
|
const liveErrors = computed(() => validateFields());
|
||||||
const canSubmit = computed(() => Object.keys(liveErrors.value).length === 0);
|
const canSubmit = computed(
|
||||||
|
() =>
|
||||||
|
Object.keys(liveErrors.value).length === 0 &&
|
||||||
|
!(duplicate.value && !forceCreate.value),
|
||||||
|
);
|
||||||
const attempted = ref(false);
|
const attempted = ref(false);
|
||||||
|
|
||||||
const displayErrors = computed<Record<string, string>>(() => {
|
const displayErrors = computed<Record<string, string>>(() => {
|
||||||
@ -271,8 +301,19 @@ async function submit() {
|
|||||||
if (form.lead_type === 'inbound') body.source = form.source!;
|
if (form.lead_type === 'inbound') body.source = form.source!;
|
||||||
else body.prev_contact = form.prev_contact!;
|
else body.prev_contact = form.prev_contact!;
|
||||||
|
|
||||||
await createLead(body);
|
try {
|
||||||
|
await createLead({ ...body, force: forceCreate.value || undefined });
|
||||||
router.push({ name: 'leads' });
|
router.push({ name: 'leads' });
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiRequestError && e.code === 'DUPLICATE') {
|
||||||
|
const existing = (e.payload?.existing ?? null) as DuplicateMatch | null;
|
||||||
|
if (existing) duplicate.value = existing;
|
||||||
|
error.value =
|
||||||
|
'Дубликат: лид с такими контактами уже есть. Отметьте "Создать всё равно" и повторите.';
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApiRequestError) {
|
if (e instanceof ApiRequestError) {
|
||||||
error.value = e.message;
|
error.value = e.message;
|
||||||
@ -325,6 +366,37 @@ async function submit() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="duplicate"
|
||||||
|
class="rounded-2xl border border-amber-200 bg-amber-50/70 px-4 py-3 flex items-start gap-3"
|
||||||
|
>
|
||||||
|
<span class="text-xl leading-none mt-0.5">⚠️</span>
|
||||||
|
<div class="flex-1 text-sm">
|
||||||
|
<p class="font-medium text-amber-900">
|
||||||
|
Похоже на дубликат:
|
||||||
|
<router-link
|
||||||
|
:to="{ name: 'lead-card', params: { id: duplicate.id } }"
|
||||||
|
class="underline text-amber-900 hover:text-amber-700"
|
||||||
|
>
|
||||||
|
{{ duplicate.contact_name }} · {{ duplicate.company_name }}
|
||||||
|
</router-link>
|
||||||
|
</p>
|
||||||
|
<p class="text-amber-800 text-xs mt-1">
|
||||||
|
Найдено по {{ duplicate.matched_on === 'email' ? 'email' : 'телефону' }} ·
|
||||||
|
ответственный: <span class="font-medium">{{ duplicate.created_by }}</span> ·
|
||||||
|
этап: {{ DEAL_STAGE_LABELS[duplicate.deal_stage] }}
|
||||||
|
</p>
|
||||||
|
<label class="mt-2 inline-flex items-center gap-2 text-xs text-amber-900 cursor-pointer">
|
||||||
|
<input
|
||||||
|
v-model="forceCreate"
|
||||||
|
type="checkbox"
|
||||||
|
class="w-3.5 h-3.5 rounded border-amber-300"
|
||||||
|
/>
|
||||||
|
Всё равно создать новый лид
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form class="card-pad space-y-8" @submit.prevent="submit">
|
<form class="card-pad space-y-8" @submit.prevent="submit">
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-3">Контакт</h2>
|
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-3">Контакт</h2>
|
||||||
@ -350,6 +422,7 @@ async function submit() {
|
|||||||
<PhoneInput
|
<PhoneInput
|
||||||
v-model="form.contact_phone"
|
v-model="form.contact_phone"
|
||||||
:error="!!displayErrors.contact_phone"
|
:error="!!displayErrors.contact_phone"
|
||||||
|
@blur="scheduleDupCheck"
|
||||||
/>
|
/>
|
||||||
<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 }}
|
||||||
@ -372,6 +445,7 @@ async function submit() {
|
|||||||
placeholder="ivan@acme.com"
|
placeholder="ivan@acme.com"
|
||||||
class="input-base"
|
class="input-base"
|
||||||
:class="displayErrors.email ? 'border-red-500' : 'border-gray-300'"
|
:class="displayErrors.email ? 'border-red-500' : 'border-gray-300'"
|
||||||
|
@blur="scheduleDupCheck"
|
||||||
/>
|
/>
|
||||||
<p v-if="displayErrors.email" class="mt-1 text-xs text-red-600">
|
<p v-if="displayErrors.email" class="mt-1 text-xs text-red-600">
|
||||||
{{ displayErrors.email }}
|
{{ displayErrors.email }}
|
||||||
|
|||||||
Reference in New Issue
Block a user