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:
Telecom 2026-05-22 15:04:08 +05:00
parent 8990edc281
commit 762b2e4525
5 changed files with 770 additions and 1 deletions

View File

@ -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,
};
},
);
} }

View File

@ -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">

View File

@ -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 }),
});
}

View File

@ -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 },
},
], ],
}); });

View 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>