feat(import): CSV bulk import (rop only)
Backend:
- POST /admin/leads/import accepts { rows: ImportRow[] } (capped at
5000). For each row:
- Required fields: contact_name, contact_phone, company_name —
missing produces a per-row error in the response.
- Dedup against existing leads by LOWER(email) and digits-only
contact_phone (leading 8 normalised to 7). Match → skipped.
- Enum fields (lead_type, company_size, budget, urgency,
dm_contact, source, prev_contact, deal_stage) fall back to safe
defaults when value missing or unrecognised.
- expected_amount parsed defensively (strips spaces/currency).
- assigned_to honoured only when the username exists in
listManagers(); otherwise the lead lands on the importing rop.
- calculateScore() + score_history insert with formula_version.
- Single audit_log entry per import run (kind=csv_import).
- Returns { total, imported, skipped_duplicate, errors[] }.
Frontend:
- New /admin/import view (rop-only, nav link "Импорт").
- Client-side CSV parser: handles ',' or ';' delimiters, BOM,
quoted cells with escaped "" and embedded newlines.
- Column-mapping wizard: auto-matches headers using Russian + English
aliases, user can override via per-field dropdown. Required fields
are starred and block submit until mapped.
- Preview shows first 5 rows so the user can sanity-check the parse.
- Empty rows are dropped before send.
- Result panel: three colour-coded counters (imported / duplicates /
errors) and a collapsible per-row error log.
This commit is contained in:
parent
8990edc281
commit
762b2e4525
@ -3,6 +3,7 @@ import {
|
|||||||
createUser,
|
createUser,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
listAllUsers,
|
listAllUsers,
|
||||||
|
listManagers,
|
||||||
requireAuth,
|
requireAuth,
|
||||||
requireRole,
|
requireRole,
|
||||||
updateUserPassword,
|
updateUserPassword,
|
||||||
@ -12,7 +13,18 @@ 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 { LeadRow, ScoringInput } from '../types.js';
|
import type {
|
||||||
|
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;
|
||||||
@ -124,4 +136,235 @@ 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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -64,6 +64,13 @@ 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,3 +36,39 @@ 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 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -51,6 +51,12 @@ 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 },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
477
leadscore/frontend/src/views/AdminImportView.vue
Normal file
477
leadscore/frontend/src/views/AdminImportView.vue
Normal file
@ -0,0 +1,477 @@
|
|||||||
|
<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>
|
||||||
Reference in New Issue
Block a user