Compare commits
No commits in common. "762b2e45258c58b162ea4c8ec8a9f469edb9a201" and "7e4c212db8f7101b811af0f1ba15a2ea392b4c7e" have entirely different histories.
762b2e4525
...
7e4c212db8
@ -3,7 +3,6 @@ import {
|
|||||||
createUser,
|
createUser,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
listAllUsers,
|
listAllUsers,
|
||||||
listManagers,
|
|
||||||
requireAuth,
|
requireAuth,
|
||||||
requireRole,
|
requireRole,
|
||||||
updateUserPassword,
|
updateUserPassword,
|
||||||
@ -13,18 +12,7 @@ import { audit } from '../audit.js';
|
|||||||
import { AppError } from '../errors.js';
|
import { AppError } from '../errors.js';
|
||||||
import { query } from '../db.js';
|
import { query } from '../db.js';
|
||||||
import { calculateScore, SCORING_FORMULA_VERSION } from '../scoring.js';
|
import { calculateScore, SCORING_FORMULA_VERSION } from '../scoring.js';
|
||||||
import type {
|
import type { LeadRow, ScoringInput } from '../types.js';
|
||||||
Budget,
|
|
||||||
CompanySize,
|
|
||||||
DealStage,
|
|
||||||
DmContact,
|
|
||||||
LeadRow,
|
|
||||||
LeadType,
|
|
||||||
PrevContact,
|
|
||||||
ScoringInput,
|
|
||||||
Source,
|
|
||||||
Urgency,
|
|
||||||
} from '../types.js';
|
|
||||||
|
|
||||||
function parseRole(v: unknown): 'manager' | 'rop' {
|
function parseRole(v: unknown): 'manager' | 'rop' {
|
||||||
if (v === 'manager' || v === 'rop') return v;
|
if (v === 'manager' || v === 'rop') return v;
|
||||||
@ -136,235 +124,4 @@ export async function adminRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
return { total: rows.length, updated, formula_version: SCORING_FORMULA_VERSION };
|
return { total: rows.length, updated, formula_version: SCORING_FORMULA_VERSION };
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Bulk CSV-driven import ---------------------------------------------
|
|
||||||
|
|
||||||
const COMPANY_SIZE_VALUES: CompanySize[] = ['1-10', '11-50', '51-200', '200+'];
|
|
||||||
const BUDGET_VALUES: Budget[] = ['no', 'maybe', 'confirmed'];
|
|
||||||
const URGENCY_VALUES: Urgency[] = ['none', '6m-plus', '3-6m', '1-3m', 'under-1m'];
|
|
||||||
const DM_VALUES: DmContact[] = ['unknown', 'user', 'influencer', 'champion', 'economic-buyer'];
|
|
||||||
const SOURCE_VALUES: Source[] = ['cold', 'ad', 'content', 'referral', 'website', 'demo-request'];
|
|
||||||
const PREV_VALUES: PrevContact[] = ['none', 'once', 'well-known'];
|
|
||||||
const STAGE_VALUES: DealStage[] = ['new', 'contacted', 'proposal', 'won', 'lost'];
|
|
||||||
|
|
||||||
function pickEnum<T extends string>(
|
|
||||||
raw: unknown,
|
|
||||||
allowed: readonly T[],
|
|
||||||
fallback: T,
|
|
||||||
): T {
|
|
||||||
if (typeof raw !== 'string') return fallback;
|
|
||||||
const v = raw.trim().toLowerCase();
|
|
||||||
if (!v) return fallback;
|
|
||||||
return (allowed as readonly string[]).includes(v) ? (v as T) : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalisePhone(raw: string): string {
|
|
||||||
const digits = raw.replace(/\D/g, '');
|
|
||||||
if (!digits) return '';
|
|
||||||
return digits[0] === '8' ? '7' + digits.slice(1) : digits;
|
|
||||||
}
|
|
||||||
|
|
||||||
function str(v: unknown, max = 500): string {
|
|
||||||
if (typeof v !== 'string') return '';
|
|
||||||
return v.trim().slice(0, max);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ImportRow {
|
|
||||||
contact_name?: unknown;
|
|
||||||
contact_phone?: unknown;
|
|
||||||
email?: unknown;
|
|
||||||
position?: unknown;
|
|
||||||
company_name?: unknown;
|
|
||||||
website?: unknown;
|
|
||||||
industry?: unknown;
|
|
||||||
city?: unknown;
|
|
||||||
lead_type?: unknown;
|
|
||||||
company_size?: unknown;
|
|
||||||
budget?: unknown;
|
|
||||||
urgency?: unknown;
|
|
||||||
dm_contact?: unknown;
|
|
||||||
source?: unknown;
|
|
||||||
prev_contact?: unknown;
|
|
||||||
deal_stage?: unknown;
|
|
||||||
expected_amount?: unknown;
|
|
||||||
comment?: unknown;
|
|
||||||
assigned_to?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
app.post<{ Body: { rows?: unknown } }>(
|
|
||||||
'/admin/leads/import',
|
|
||||||
async (req) => {
|
|
||||||
const user = await requireAuth(app, req);
|
|
||||||
requireRole(user, 'rop');
|
|
||||||
|
|
||||||
if (!Array.isArray(req.body?.rows)) {
|
|
||||||
throw AppError.validation('rows must be an array');
|
|
||||||
}
|
|
||||||
const rows = (req.body!.rows as ImportRow[]).slice(0, 5000);
|
|
||||||
const managers = new Set(listManagers());
|
|
||||||
|
|
||||||
const errors: { row: number; reason: string }[] = [];
|
|
||||||
let imported = 0;
|
|
||||||
let skippedDuplicate = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < rows.length; i++) {
|
|
||||||
const r = rows[i];
|
|
||||||
try {
|
|
||||||
const contact_name = str(r.contact_name, 200);
|
|
||||||
const contact_phone = str(r.contact_phone, 50);
|
|
||||||
const company_name = str(r.company_name, 200);
|
|
||||||
const email = str(r.email, 200);
|
|
||||||
|
|
||||||
if (!contact_name) {
|
|
||||||
errors.push({ row: i + 1, reason: 'contact_name пуст' });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!contact_phone) {
|
|
||||||
errors.push({ row: i + 1, reason: 'contact_phone пуст' });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!company_name) {
|
|
||||||
errors.push({ row: i + 1, reason: 'company_name пуст' });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dedup by email or normalised phone against existing rows.
|
|
||||||
const normPhone = normalisePhone(contact_phone);
|
|
||||||
const lowerEmail = email.toLowerCase();
|
|
||||||
const dupParams: unknown[] = [];
|
|
||||||
const dupConds: string[] = [];
|
|
||||||
if (lowerEmail) {
|
|
||||||
dupParams.push(lowerEmail);
|
|
||||||
dupConds.push(`LOWER(email) = $${dupParams.length}`);
|
|
||||||
}
|
|
||||||
if (normPhone) {
|
|
||||||
dupParams.push(normPhone);
|
|
||||||
dupConds.push(
|
|
||||||
`REGEXP_REPLACE(contact_phone, '[^0-9]', '', 'g') = $${dupParams.length}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (dupConds.length > 0) {
|
|
||||||
const dup = await query<{ id: string }>(
|
|
||||||
`SELECT id FROM leads WHERE ${dupConds.join(' OR ')} LIMIT 1`,
|
|
||||||
dupParams,
|
|
||||||
);
|
|
||||||
if (dup.rows.length > 0) {
|
|
||||||
skippedDuplicate++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const lead_type = pickEnum<LeadType>(
|
|
||||||
r.lead_type,
|
|
||||||
['inbound', 'outbound'],
|
|
||||||
'inbound',
|
|
||||||
);
|
|
||||||
const company_size = pickEnum<CompanySize>(
|
|
||||||
r.company_size,
|
|
||||||
COMPANY_SIZE_VALUES,
|
|
||||||
'1-10',
|
|
||||||
);
|
|
||||||
const budget = pickEnum<Budget>(r.budget, BUDGET_VALUES, 'no');
|
|
||||||
const urgency = pickEnum<Urgency>(r.urgency, URGENCY_VALUES, 'none');
|
|
||||||
const dm_contact = pickEnum<DmContact>(r.dm_contact, DM_VALUES, 'unknown');
|
|
||||||
const source =
|
|
||||||
lead_type === 'inbound'
|
|
||||||
? pickEnum<Source>(r.source, SOURCE_VALUES, 'cold')
|
|
||||||
: null;
|
|
||||||
const prev_contact =
|
|
||||||
lead_type === 'outbound'
|
|
||||||
? pickEnum<PrevContact>(r.prev_contact, PREV_VALUES, 'none')
|
|
||||||
: null;
|
|
||||||
const deal_stage = pickEnum<DealStage>(r.deal_stage, STAGE_VALUES, 'new');
|
|
||||||
|
|
||||||
const expectedRaw = r.expected_amount;
|
|
||||||
let expected_amount: number | null = null;
|
|
||||||
if (expectedRaw !== undefined && expectedRaw !== null && expectedRaw !== '') {
|
|
||||||
const n =
|
|
||||||
typeof expectedRaw === 'number'
|
|
||||||
? expectedRaw
|
|
||||||
: Number(String(expectedRaw).replace(/[^\d.-]/g, ''));
|
|
||||||
if (Number.isFinite(n) && n >= 0) {
|
|
||||||
expected_amount = Math.round(n * 100) / 100;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestedOwner = str(r.assigned_to, 64);
|
|
||||||
const owner =
|
|
||||||
requestedOwner && managers.has(requestedOwner) ? requestedOwner : user.sub;
|
|
||||||
|
|
||||||
const input: ScoringInput = {
|
|
||||||
lead_type,
|
|
||||||
company_size,
|
|
||||||
budget,
|
|
||||||
urgency,
|
|
||||||
dm_contact,
|
|
||||||
source: source ?? undefined,
|
|
||||||
prev_contact: prev_contact ?? undefined,
|
|
||||||
email: email || null,
|
|
||||||
};
|
|
||||||
const { total, breakdown, status } = calculateScore(input);
|
|
||||||
|
|
||||||
const ins = await query<{ id: string }>(
|
|
||||||
`INSERT INTO leads
|
|
||||||
(contact_name, contact_phone, email, position, company_name, website, industry, city,
|
|
||||||
lead_type, company_size, budget, urgency, dm_contact, source, prev_contact,
|
|
||||||
score, status, deal_stage, expected_amount, comment, created_by)
|
|
||||||
VALUES
|
|
||||||
($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)
|
|
||||||
RETURNING id`,
|
|
||||||
[
|
|
||||||
contact_name,
|
|
||||||
contact_phone,
|
|
||||||
email,
|
|
||||||
str(r.position, 200),
|
|
||||||
company_name,
|
|
||||||
str(r.website, 300),
|
|
||||||
str(r.industry, 200),
|
|
||||||
str(r.city, 200),
|
|
||||||
lead_type,
|
|
||||||
company_size,
|
|
||||||
budget,
|
|
||||||
urgency,
|
|
||||||
dm_contact,
|
|
||||||
source,
|
|
||||||
prev_contact,
|
|
||||||
total,
|
|
||||||
status,
|
|
||||||
deal_stage,
|
|
||||||
expected_amount,
|
|
||||||
str(r.comment, 4000),
|
|
||||||
owner,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
await query(
|
|
||||||
`INSERT INTO score_history (lead_id, total, breakdown, changed_by, formula_version)
|
|
||||||
VALUES ($1, $2, $3, $4, $5)`,
|
|
||||||
[ins.rows[0].id, total, breakdown, user.sub, SCORING_FORMULA_VERSION],
|
|
||||||
);
|
|
||||||
imported++;
|
|
||||||
} catch (e) {
|
|
||||||
errors.push({
|
|
||||||
row: i + 1,
|
|
||||||
reason: (e as Error).message ?? 'unknown error',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await audit(user.sub, 'system', 'leads', 'create', {
|
|
||||||
kind: 'csv_import',
|
|
||||||
total: rows.length,
|
|
||||||
imported,
|
|
||||||
skipped_duplicate: skippedDuplicate,
|
|
||||||
errors: errors.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
total: rows.length,
|
|
||||||
imported,
|
|
||||||
skipped_duplicate: skippedDuplicate,
|
|
||||||
errors,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,7 +32,6 @@ 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 {
|
||||||
@ -157,79 +156,6 @@ 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 },
|
||||||
@ -481,21 +407,8 @@ 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, reply) => {
|
app.post<{ Body: LeadBody }>('/leads', async (req) => {
|
||||||
const user = req.user as { sub: string };
|
const user = req.user as { sub: string };
|
||||||
const body = req.body ?? {};
|
const body = req.body ?? {};
|
||||||
|
|
||||||
@ -504,20 +417,6 @@ 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 { listAllUsers, requireAuth } from '../auth.js';
|
import { requireAuth } from '../auth.js';
|
||||||
import { AppError } from '../errors.js';
|
import { AppError } from '../errors.js';
|
||||||
|
|
||||||
interface NoteRow {
|
interface NoteRow {
|
||||||
@ -11,54 +11,6 @@ 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,
|
||||||
@ -104,21 +56,7 @@ 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],
|
||||||
);
|
);
|
||||||
const note = rows[0];
|
return 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 };
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -64,13 +64,6 @@ function userInitials(name: string | undefined): string {
|
|||||||
>
|
>
|
||||||
Аудит
|
Аудит
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink
|
|
||||||
v-if="auth.isRop"
|
|
||||||
:to="{ name: 'admin-import' }"
|
|
||||||
class="nav-link"
|
|
||||||
>
|
|
||||||
Импорт
|
|
||||||
</RouterLink>
|
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
|
|||||||
@ -36,39 +36,3 @@ export function recalcAllLeads() {
|
|||||||
{ method: 'POST' },
|
{ method: 'POST' },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImportRow {
|
|
||||||
contact_name?: string;
|
|
||||||
contact_phone?: string;
|
|
||||||
email?: string;
|
|
||||||
position?: string;
|
|
||||||
company_name?: string;
|
|
||||||
website?: string;
|
|
||||||
industry?: string;
|
|
||||||
city?: string;
|
|
||||||
lead_type?: string;
|
|
||||||
company_size?: string;
|
|
||||||
budget?: string;
|
|
||||||
urgency?: string;
|
|
||||||
dm_contact?: string;
|
|
||||||
source?: string;
|
|
||||||
prev_contact?: string;
|
|
||||||
deal_stage?: string;
|
|
||||||
expected_amount?: string | number;
|
|
||||||
comment?: string;
|
|
||||||
assigned_to?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImportResult {
|
|
||||||
total: number;
|
|
||||||
imported: number;
|
|
||||||
skipped_duplicate: number;
|
|
||||||
errors: { row: number; reason: string }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function importLeads(rows: ImportRow[]) {
|
|
||||||
return apiFetch<ImportResult>('/admin/leads/import', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ rows }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -4,21 +4,18 @@ 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,25 +180,7 @@ export function listManagers() {
|
|||||||
return apiFetch<{ data: string[] }>('/managers');
|
return apiFetch<{ data: string[] }>('/managers');
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DuplicateMatch {
|
export function createLead(body: CreateLeadBody) {
|
||||||
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,16 +8,12 @@ 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<CreatedNote>(`/leads/${leadId}/notes`, {
|
return apiFetch<Note>(`/leads/${leadId}/notes`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ body }),
|
body: JSON.stringify({ body }),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -49,11 +49,6 @@ 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 {
|
||||||
@ -61,25 +56,6 @@ 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 {
|
||||||
@ -166,15 +142,7 @@ onMounted(load);
|
|||||||
удалить
|
удалить
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm mt-1 whitespace-pre-wrap">
|
<p class="text-sm mt-1 whitespace-pre-wrap">{{ n.body }}</p>
|
||||||
<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>
|
||||||
|
|||||||
@ -51,12 +51,6 @@ const router = createRouter({
|
|||||||
component: () => import('@/views/AuditView.vue'),
|
component: () => import('@/views/AuditView.vue'),
|
||||||
meta: { ropOnly: true },
|
meta: { ropOnly: true },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/admin/import',
|
|
||||||
name: 'admin-import',
|
|
||||||
component: () => import('@/views/AdminImportView.vue'),
|
|
||||||
meta: { ropOnly: true },
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,477 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, reactive, ref } from 'vue';
|
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import { importLeads, type ImportResult, type ImportRow } from '@/api/admin';
|
|
||||||
import { useToast } from '@/composables/useToast';
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const toast = useToast();
|
|
||||||
|
|
||||||
// Target fields the importer understands. Only contact_name + phone +
|
|
||||||
// company_name are required; everything else gets sensible defaults if
|
|
||||||
// the column is missing.
|
|
||||||
interface FieldDef {
|
|
||||||
key: keyof ImportRow;
|
|
||||||
label: string;
|
|
||||||
required: boolean;
|
|
||||||
hint?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FIELDS: FieldDef[] = [
|
|
||||||
{ key: 'contact_name', label: 'Имя контакта', required: true },
|
|
||||||
{ key: 'contact_phone', label: 'Телефон', required: true },
|
|
||||||
{ key: 'company_name', label: 'Компания', required: true },
|
|
||||||
{ key: 'email', label: 'Email', required: false },
|
|
||||||
{ key: 'position', label: 'Должность', required: false },
|
|
||||||
{ key: 'website', label: 'Сайт', required: false },
|
|
||||||
{ key: 'industry', label: 'Отрасль', required: false },
|
|
||||||
{ key: 'city', label: 'Город', required: false },
|
|
||||||
{
|
|
||||||
key: 'lead_type',
|
|
||||||
label: 'Тип лида',
|
|
||||||
required: false,
|
|
||||||
hint: 'inbound / outbound (по умолчанию inbound)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'company_size',
|
|
||||||
label: 'Размер компании',
|
|
||||||
required: false,
|
|
||||||
hint: '1-10 / 11-50 / 51-200 / 200+',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'budget',
|
|
||||||
label: 'Бюджет',
|
|
||||||
required: false,
|
|
||||||
hint: 'no / maybe / confirmed',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'urgency',
|
|
||||||
label: 'Срочность',
|
|
||||||
required: false,
|
|
||||||
hint: 'none / 6m-plus / 3-6m / 1-3m / under-1m',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'dm_contact',
|
|
||||||
label: 'Контакт (MEDDIC)',
|
|
||||||
required: false,
|
|
||||||
hint: 'unknown / user / influencer / champion / economic-buyer',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'source',
|
|
||||||
label: 'Источник (inbound)',
|
|
||||||
required: false,
|
|
||||||
hint: 'cold / ad / content / referral / website / demo-request',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'prev_contact',
|
|
||||||
label: 'Пред. контакт (outbound)',
|
|
||||||
required: false,
|
|
||||||
hint: 'none / once / well-known',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'deal_stage',
|
|
||||||
label: 'Этап воронки',
|
|
||||||
required: false,
|
|
||||||
hint: 'new / contacted / proposal / won / lost',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'expected_amount',
|
|
||||||
label: 'Сумма сделки',
|
|
||||||
required: false,
|
|
||||||
hint: 'числом',
|
|
||||||
},
|
|
||||||
{ key: 'comment', label: 'Комментарий', required: false },
|
|
||||||
{
|
|
||||||
key: 'assigned_to',
|
|
||||||
label: 'Ответственный (username)',
|
|
||||||
required: false,
|
|
||||||
hint: 'если пусто — себе',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function initialMapping(): Record<string, number | null> {
|
|
||||||
return Object.fromEntries(FIELDS.map((f) => [f.key, null])) as Record<
|
|
||||||
string,
|
|
||||||
number | null
|
|
||||||
>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileName = ref<string | null>(null);
|
|
||||||
const rawHeaders = ref<string[]>([]);
|
|
||||||
const rawRows = ref<string[][]>([]);
|
|
||||||
const mapping: Record<string, number | null> = reactive(initialMapping());
|
|
||||||
const importing = ref(false);
|
|
||||||
const result = ref<ImportResult | null>(null);
|
|
||||||
|
|
||||||
const allRequiredMapped = computed(() =>
|
|
||||||
FIELDS.filter((f) => f.required).every(
|
|
||||||
(f) => mapping[f.key as string] !== null && mapping[f.key as string] !== undefined,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Minimal CSV parser. Handles quoted cells with embedded commas /
|
|
||||||
// newlines / escaped double quotes. Strips a leading BOM. Auto-detects
|
|
||||||
// "," vs ";" by counting first-line occurrences.
|
|
||||||
function parseCsv(text: string): string[][] {
|
|
||||||
let src = text.replace(/^/, '');
|
|
||||||
const firstLine = src.split(/\r?\n/, 1)[0] ?? '';
|
|
||||||
const delim = (firstLine.match(/;/g)?.length ?? 0) >
|
|
||||||
(firstLine.match(/,/g)?.length ?? 0)
|
|
||||||
? ';'
|
|
||||||
: ',';
|
|
||||||
|
|
||||||
const rows: string[][] = [];
|
|
||||||
let cur: string[] = [];
|
|
||||||
let cell = '';
|
|
||||||
let inQuotes = false;
|
|
||||||
|
|
||||||
for (let i = 0; i < src.length; i++) {
|
|
||||||
const c = src[i];
|
|
||||||
if (inQuotes) {
|
|
||||||
if (c === '"') {
|
|
||||||
if (src[i + 1] === '"') {
|
|
||||||
cell += '"';
|
|
||||||
i++;
|
|
||||||
} else {
|
|
||||||
inQuotes = false;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
cell += c;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c === '"') {
|
|
||||||
inQuotes = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c === delim) {
|
|
||||||
cur.push(cell);
|
|
||||||
cell = '';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c === '\n' || c === '\r') {
|
|
||||||
if (c === '\r' && src[i + 1] === '\n') i++;
|
|
||||||
cur.push(cell);
|
|
||||||
cell = '';
|
|
||||||
rows.push(cur);
|
|
||||||
cur = [];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
cell += c;
|
|
||||||
}
|
|
||||||
if (cell.length > 0 || cur.length > 0) {
|
|
||||||
cur.push(cell);
|
|
||||||
rows.push(cur);
|
|
||||||
}
|
|
||||||
// Drop trailing empty row(s)
|
|
||||||
while (rows.length > 0 && rows[rows.length - 1].every((c) => c.trim() === '')) {
|
|
||||||
rows.pop();
|
|
||||||
}
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to guess which CSV column maps to which target field by header name.
|
|
||||||
function autoMap(headers: string[]) {
|
|
||||||
const norm = (s: string) =>
|
|
||||||
s.trim().toLowerCase().replace(/[\s_-]+/g, '');
|
|
||||||
|
|
||||||
const HINTS: Record<keyof ImportRow, string[]> = {
|
|
||||||
contact_name: ['contactname', 'name', 'имя', 'имяконтакта', 'fio', 'фио'],
|
|
||||||
contact_phone: ['phone', 'tel', 'mobile', 'телефон', 'тел'],
|
|
||||||
email: ['email', 'mail', 'почта', 'емейл'],
|
|
||||||
position: ['position', 'title', 'должность', 'позиция'],
|
|
||||||
company_name: ['company', 'companyname', 'компания', 'organisation', 'organization', 'org'],
|
|
||||||
website: ['website', 'site', 'url', 'сайт'],
|
|
||||||
industry: ['industry', 'отрасль', 'индустрия', 'sphere'],
|
|
||||||
city: ['city', 'город'],
|
|
||||||
lead_type: ['leadtype', 'type', 'тип', 'типлида'],
|
|
||||||
company_size: ['companysize', 'size', 'размер'],
|
|
||||||
budget: ['budget', 'бюджет'],
|
|
||||||
urgency: ['urgency', 'срочность', 'deadline'],
|
|
||||||
dm_contact: ['dmcontact', 'dm', 'authority', 'лпр'],
|
|
||||||
source: ['source', 'источник'],
|
|
||||||
prev_contact: ['prevcontact', 'history', 'предконтакт', 'previouscontact'],
|
|
||||||
deal_stage: ['dealstage', 'stage', 'этап', 'воронка'],
|
|
||||||
expected_amount: ['expectedamount', 'amount', 'value', 'сумма', 'deal', 'price'],
|
|
||||||
comment: ['comment', 'note', 'комментарий', 'notes'],
|
|
||||||
assigned_to: ['assignedto', 'owner', 'manager', 'ответственный', 'менеджер'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const used = new Set<number>();
|
|
||||||
for (const f of FIELDS) {
|
|
||||||
const hints = HINTS[f.key];
|
|
||||||
if (!hints) continue;
|
|
||||||
let foundIndex = -1;
|
|
||||||
for (let i = 0; i < headers.length; i++) {
|
|
||||||
if (used.has(i)) continue;
|
|
||||||
if (hints.includes(norm(headers[i]))) {
|
|
||||||
foundIndex = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (foundIndex < 0) {
|
|
||||||
for (let i = 0; i < headers.length; i++) {
|
|
||||||
if (used.has(i)) continue;
|
|
||||||
const h = norm(headers[i]);
|
|
||||||
if (hints.some((hint) => h.includes(hint) || hint.includes(h))) {
|
|
||||||
foundIndex = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (foundIndex >= 0) {
|
|
||||||
mapping[f.key as string] = foundIndex;
|
|
||||||
used.add(foundIndex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onFile(e: Event) {
|
|
||||||
const target = e.target as HTMLInputElement;
|
|
||||||
const file = target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
fileName.value = file.name;
|
|
||||||
result.value = null;
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = () => {
|
|
||||||
const text = String(reader.result ?? '');
|
|
||||||
const parsed = parseCsv(text);
|
|
||||||
if (parsed.length === 0) {
|
|
||||||
toast.error('Файл пустой');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
rawHeaders.value = parsed[0].map((h) => h.trim());
|
|
||||||
rawRows.value = parsed.slice(1);
|
|
||||||
// Reset mapping then auto-detect.
|
|
||||||
for (const k of Object.keys(mapping)) mapping[k] = null;
|
|
||||||
autoMap(rawHeaders.value);
|
|
||||||
};
|
|
||||||
reader.onerror = () => toast.error('Не удалось прочитать файл');
|
|
||||||
reader.readAsText(file, 'utf-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
const previewRows = computed(() => rawRows.value.slice(0, 5));
|
|
||||||
|
|
||||||
function rowToImport(row: string[]): ImportRow {
|
|
||||||
const out: ImportRow = {};
|
|
||||||
for (const f of FIELDS) {
|
|
||||||
const idx = mapping[f.key as string];
|
|
||||||
if (idx === null || idx === undefined) continue;
|
|
||||||
const v = row[idx];
|
|
||||||
if (v === undefined) continue;
|
|
||||||
(out as Record<string, string>)[f.key as string] = v;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
if (!allRequiredMapped.value) {
|
|
||||||
toast.error('Сопоставь обязательные колонки');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (rawRows.value.length === 0) {
|
|
||||||
toast.error('Нет строк для импорта');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
importing.value = true;
|
|
||||||
result.value = null;
|
|
||||||
try {
|
|
||||||
const payload = rawRows.value
|
|
||||||
.map((r) => rowToImport(r))
|
|
||||||
.filter((r) =>
|
|
||||||
// Drop entirely empty rows (whitespace-only export from Excel).
|
|
||||||
Object.values(r).some((v) => typeof v === 'string' && v.trim() !== ''),
|
|
||||||
);
|
|
||||||
const r = await importLeads(payload);
|
|
||||||
result.value = r;
|
|
||||||
toast.success(
|
|
||||||
`Импортировано: ${r.imported} · дублей: ${r.skipped_duplicate} · ошибок: ${r.errors.length}`,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
toast.error((e as Error).message);
|
|
||||||
} finally {
|
|
||||||
importing.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
fileName.value = null;
|
|
||||||
rawHeaders.value = [];
|
|
||||||
rawRows.value = [];
|
|
||||||
for (const k of Object.keys(mapping)) mapping[k] = null;
|
|
||||||
result.value = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function gotoLeads() {
|
|
||||||
router.push({ name: 'leads' });
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="max-w-4xl mx-auto space-y-6">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-2xl font-semibold tracking-tight">Импорт лидов из CSV</h1>
|
|
||||||
<p class="text-sm text-gray-500 mt-1">
|
|
||||||
Загрузи CSV (запятая или точка-с-запятой как разделитель, UTF-8).
|
|
||||||
Дубли по email / телефону пропускаются автоматически.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="rawHeaders.length === 0" class="card-pad">
|
|
||||||
<label class="block">
|
|
||||||
<span class="text-sm font-medium">Файл CSV</span>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".csv,text/csv"
|
|
||||||
class="mt-2 block text-sm"
|
|
||||||
@change="onFile"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<p class="text-xs text-gray-500 mt-3">
|
|
||||||
Первая строка должна содержать заголовки колонок (имя, телефон,
|
|
||||||
компания и т.п.). Сопоставление подберётся автоматически — поправь
|
|
||||||
вручную, если надо.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="space-y-4">
|
|
||||||
<div class="card-pad flex items-center justify-between gap-3">
|
|
||||||
<div class="text-sm">
|
|
||||||
<span class="font-medium">{{ fileName }}</span>
|
|
||||||
<span class="text-gray-500 ml-2">{{ rawRows.length }} строк</span>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn-ghost text-sm" @click="reset">
|
|
||||||
Сменить файл
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-pad space-y-3">
|
|
||||||
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest">
|
|
||||||
Сопоставь колонки
|
|
||||||
</h2>
|
|
||||||
<p v-if="!allRequiredMapped" class="text-xs text-amber-700">
|
|
||||||
Обязательные поля помечены ★ — сопоставь их, чтобы запустить импорт.
|
|
||||||
</p>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
<label
|
|
||||||
v-for="f in FIELDS"
|
|
||||||
:key="f.key"
|
|
||||||
class="flex items-center gap-3 text-sm"
|
|
||||||
>
|
|
||||||
<span class="w-44 shrink-0">
|
|
||||||
{{ f.label }}<span v-if="f.required" class="text-red-600">★</span>
|
|
||||||
</span>
|
|
||||||
<select
|
|
||||||
v-model.number="mapping[f.key as string]"
|
|
||||||
class="input-base !py-1 !px-2 text-xs flex-1"
|
|
||||||
>
|
|
||||||
<option :value="null">— нет —</option>
|
|
||||||
<option
|
|
||||||
v-for="(h, i) in rawHeaders"
|
|
||||||
:key="i"
|
|
||||||
:value="i"
|
|
||||||
>
|
|
||||||
{{ h || `Колонка ${i + 1}` }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<span
|
|
||||||
v-if="f.hint"
|
|
||||||
class="text-[11px] text-gray-400 hidden md:inline whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{{ f.hint }}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card overflow-hidden">
|
|
||||||
<div class="px-4 py-3 border-b border-gray-100">
|
|
||||||
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest">
|
|
||||||
Превью (первые 5 строк)
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="table-base">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th v-for="(h, i) in rawHeaders" :key="i" class="whitespace-nowrap">
|
|
||||||
{{ h || `Колонка ${i + 1}` }}
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="(row, ri) in previewRows" :key="ri">
|
|
||||||
<td
|
|
||||||
v-for="(_, ci) in rawHeaders"
|
|
||||||
:key="ci"
|
|
||||||
class="px-4 py-2 text-xs whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{{ row[ci] ?? '' }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-primary text-sm"
|
|
||||||
:disabled="!allRequiredMapped || importing"
|
|
||||||
@click="submit"
|
|
||||||
>
|
|
||||||
{{ importing ? 'Импортируем…' : `Импортировать ${rawRows.length}` }}
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn-ghost text-sm" @click="reset">
|
|
||||||
Отмена
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="result" class="card-pad space-y-3">
|
|
||||||
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest">
|
|
||||||
Результат
|
|
||||||
</h2>
|
|
||||||
<div class="grid grid-cols-3 gap-3 text-center">
|
|
||||||
<div class="rounded-lg bg-emerald-50 py-3">
|
|
||||||
<div class="text-2xl font-bold text-emerald-700 tabular-nums">
|
|
||||||
{{ result.imported }}
|
|
||||||
</div>
|
|
||||||
<div class="text-[11px] text-emerald-700">Импортировано</div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-lg bg-amber-50 py-3">
|
|
||||||
<div class="text-2xl font-bold text-amber-700 tabular-nums">
|
|
||||||
{{ result.skipped_duplicate }}
|
|
||||||
</div>
|
|
||||||
<div class="text-[11px] text-amber-700">Дублей пропущено</div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-lg bg-red-50 py-3">
|
|
||||||
<div class="text-2xl font-bold text-red-700 tabular-nums">
|
|
||||||
{{ result.errors.length }}
|
|
||||||
</div>
|
|
||||||
<div class="text-[11px] text-red-700">Ошибок</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<details v-if="result.errors.length > 0" class="text-xs">
|
|
||||||
<summary class="cursor-pointer text-gray-500 hover:text-gray-900">
|
|
||||||
Показать ошибки
|
|
||||||
</summary>
|
|
||||||
<ul class="mt-2 space-y-1 max-h-60 overflow-y-auto">
|
|
||||||
<li
|
|
||||||
v-for="err in result.errors"
|
|
||||||
:key="err.row"
|
|
||||||
class="text-red-700"
|
|
||||||
>
|
|
||||||
Строка {{ err.row }}: {{ err.reason }}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<button type="button" class="btn-primary text-sm" @click="gotoLeads">
|
|
||||||
К списку лидов
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -7,7 +7,6 @@ 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,
|
||||||
@ -16,7 +15,6 @@ 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,
|
||||||
@ -80,30 +78,6 @@ 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();
|
||||||
|
|
||||||
@ -211,11 +185,7 @@ function validateFields(): Record<string, string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const liveErrors = computed(() => validateFields());
|
const liveErrors = computed(() => validateFields());
|
||||||
const canSubmit = computed(
|
const canSubmit = computed(() => Object.keys(liveErrors.value).length === 0);
|
||||||
() =>
|
|
||||||
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>>(() => {
|
||||||
@ -301,19 +271,8 @@ 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!;
|
||||||
|
|
||||||
try {
|
await createLead(body);
|
||||||
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;
|
||||||
@ -366,37 +325,6 @@ 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>
|
||||||
@ -422,7 +350,6 @@ 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 }}
|
||||||
@ -445,7 +372,6 @@ 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