mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
feat(contacts): enhance CSV import functionality with tag assignment
- Introduced `parseContactCsv` for improved CSV parsing, replacing the previous inline parsing logic. - Added support for importing tags alongside contacts, allowing users to assign tags during the import process. - Implemented `assignImportedContactTags` to handle tag assignments after contact creation. - Updated the import result state to include the number of tags assigned and added relevant success and info notifications for users. - Enhanced the import modal description to clarify the format for tag input in CSV files.
This commit is contained in:
parent
5b6fe3d677
commit
3a416832a7
@ -8,6 +8,12 @@ import {
|
||||
isUniqueViolation,
|
||||
normalizeKey,
|
||||
} from '@/lib/contacts/dedupe';
|
||||
import { parseContactCsv } from '@/lib/contacts/parse-contact-csv';
|
||||
import {
|
||||
assignImportedContactTags,
|
||||
resolveImportTagIds,
|
||||
type ContactTagAssignment,
|
||||
} from '@/lib/contacts/resolve-import-tags';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Dialog,
|
||||
@ -26,75 +32,19 @@ interface ImportModalProps {
|
||||
onImported: () => void;
|
||||
}
|
||||
|
||||
interface ParsedRow {
|
||||
phone: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
company?: string;
|
||||
}
|
||||
|
||||
function parseCSV(text: string): ParsedRow[] {
|
||||
const lines = text.trim().split(/\r?\n/);
|
||||
if (lines.length < 2) return [];
|
||||
|
||||
const headerLine = lines[0];
|
||||
const headers = headerLine.split(',').map((h) => h.trim().toLowerCase().replace(/["']/g, ''));
|
||||
|
||||
const phoneIdx = headers.indexOf('phone');
|
||||
if (phoneIdx === -1) return [];
|
||||
|
||||
const nameIdx = headers.indexOf('name');
|
||||
const emailIdx = headers.indexOf('email');
|
||||
const companyIdx = headers.indexOf('company');
|
||||
|
||||
const rows: ParsedRow[] = [];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
|
||||
// Simple CSV parse (handles quoted fields)
|
||||
const values: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
for (const char of line) {
|
||||
if (char === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
values.push(current.trim());
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
values.push(current.trim());
|
||||
|
||||
const phone = values[phoneIdx]?.replace(/["']/g, '').trim();
|
||||
if (!phone) continue;
|
||||
|
||||
rows.push({
|
||||
phone,
|
||||
name: nameIdx >= 0 ? values[nameIdx]?.replace(/["']/g, '').trim() || undefined : undefined,
|
||||
email: emailIdx >= 0 ? values[emailIdx]?.replace(/["']/g, '').trim() || undefined : undefined,
|
||||
company:
|
||||
companyIdx >= 0 ? values[companyIdx]?.replace(/["']/g, '').trim() || undefined : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps) {
|
||||
const supabase = createClient();
|
||||
const { accountId } = useAuth();
|
||||
const { accountId, canEditSettings } = useAuth();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [parsedRows, setParsedRows] = useState<ParsedRow[]>([]);
|
||||
const [parsedRows, setParsedRows] = useState<ReturnType<typeof parseContactCsv>>([]);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [result, setResult] = useState<{
|
||||
imported: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
tagsAssigned: number;
|
||||
} | null>(null);
|
||||
|
||||
function reset() {
|
||||
@ -117,7 +67,7 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
||||
setResult(null);
|
||||
|
||||
const text = await selected.text();
|
||||
const rows = parseCSV(text);
|
||||
const rows = parseContactCsv(text);
|
||||
|
||||
if (rows.length === 0) {
|
||||
toast.error('No valid rows found. Ensure CSV has a "phone" column header.');
|
||||
@ -168,9 +118,19 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
||||
return true;
|
||||
});
|
||||
|
||||
// 3) Batch insert the genuinely-new rows in chunks of 50. The DB
|
||||
// unique index is the backstop: a 23505 (race, or a format
|
||||
// that normalizes equal) counts as skipped, not failed.
|
||||
// 3) Resolve tag names → ids (create missing tags for admin+).
|
||||
const allTagNames = toInsert.flatMap((row) => row.tagNames);
|
||||
const { tagIdByKey, skippedNames } = await resolveImportTagIds(supabase, {
|
||||
accountId,
|
||||
userId: user.id,
|
||||
tagNames: allTagNames,
|
||||
canCreateTags: canEditSettings,
|
||||
});
|
||||
|
||||
const tagAssignments: ContactTagAssignment[] = [];
|
||||
|
||||
// 4) Batch insert contacts in chunks of 50. The DB unique index is
|
||||
// the backstop: a 23505 counts as skipped, not failed.
|
||||
const chunkSize = 50;
|
||||
for (let i = 0; i < toInsert.length; i += chunkSize) {
|
||||
const chunk = toInsert.slice(i, i + chunkSize);
|
||||
@ -191,10 +151,23 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
||||
if (error) {
|
||||
// Retry individually so one bad/duplicate row doesn't sink
|
||||
// the whole chunk.
|
||||
for (const row of rows) {
|
||||
const { error: singleErr } = await supabase.from('contacts').insert(row);
|
||||
if (!singleErr) {
|
||||
for (let j = 0; j < rows.length; j++) {
|
||||
const row = rows[j];
|
||||
const source = chunk[j];
|
||||
const { data: singleData, error: singleErr } = await supabase
|
||||
.from('contacts')
|
||||
.insert(row)
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (!singleErr && singleData) {
|
||||
imported++;
|
||||
if (source.tagNames.length > 0) {
|
||||
tagAssignments.push({
|
||||
contactId: singleData.id,
|
||||
tagNames: source.tagNames,
|
||||
});
|
||||
}
|
||||
} else if (isUniqueViolation(singleErr)) {
|
||||
skipped++;
|
||||
} else {
|
||||
@ -202,15 +175,44 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
||||
}
|
||||
}
|
||||
} else {
|
||||
imported += data?.length ?? chunk.length;
|
||||
const inserted = data ?? [];
|
||||
imported += inserted.length;
|
||||
for (let j = 0; j < inserted.length; j++) {
|
||||
const source = chunk[j];
|
||||
if (!source || source.tagNames.length === 0) continue;
|
||||
tagAssignments.push({
|
||||
contactId: inserted[j].id,
|
||||
tagNames: source.tagNames,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setResult({ imported, skipped, failed });
|
||||
// 5) Wire tags onto the contacts we just created.
|
||||
const tagsAssigned = await assignImportedContactTags(
|
||||
supabase,
|
||||
tagAssignments,
|
||||
tagIdByKey,
|
||||
);
|
||||
|
||||
setResult({ imported, skipped, failed, tagsAssigned });
|
||||
if (imported > 0) {
|
||||
toast.success(`${imported} contact${imported !== 1 ? 's' : ''} imported`);
|
||||
onImported();
|
||||
}
|
||||
if (tagsAssigned > 0) {
|
||||
toast.success(
|
||||
`${tagsAssigned} tag assignment${tagsAssigned !== 1 ? 's' : ''} applied`,
|
||||
);
|
||||
}
|
||||
if (skippedNames.length > 0) {
|
||||
const sample = skippedNames.slice(0, 3).join(', ');
|
||||
const more =
|
||||
skippedNames.length > 3 ? ` (+${skippedNames.length - 3} more)` : '';
|
||||
toast.info(
|
||||
`Unknown tags skipped (create them in Settings first): ${sample}${more}`,
|
||||
);
|
||||
}
|
||||
if (skipped > 0) {
|
||||
toast.info(`${skipped} duplicate${skipped !== 1 ? 's' : ''} skipped`);
|
||||
}
|
||||
@ -226,6 +228,7 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
||||
}
|
||||
|
||||
const preview = parsedRows.slice(0, 5);
|
||||
const hasTagsColumn = parsedRows.some((row) => row.tagNames.length > 0);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
@ -234,7 +237,8 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
||||
<DialogTitle className="text-white">Import Contacts</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
Upload a CSV file with a "phone" column (required). Optional columns:
|
||||
name, email, company.
|
||||
name, email, company, tags (comma-separated tag names — quote the
|
||||
cell if a tag list contains commas, e.g. "VIP, Lead").
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@ -287,6 +291,9 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
||||
<th className="px-3 py-1.5 text-left text-slate-400 font-medium">Name</th>
|
||||
<th className="px-3 py-1.5 text-left text-slate-400 font-medium">Email</th>
|
||||
<th className="px-3 py-1.5 text-left text-slate-400 font-medium">Company</th>
|
||||
{hasTagsColumn && (
|
||||
<th className="px-3 py-1.5 text-left text-slate-400 font-medium">Tags</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -296,6 +303,11 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
||||
<td className="px-3 py-1.5 text-slate-300">{row.name || '-'}</td>
|
||||
<td className="px-3 py-1.5 text-slate-300">{row.email || '-'}</td>
|
||||
<td className="px-3 py-1.5 text-slate-300">{row.company || '-'}</td>
|
||||
{hasTagsColumn && (
|
||||
<td className="px-3 py-1.5 text-slate-300">
|
||||
{row.tagNames.length > 0 ? row.tagNames.join(', ') : '-'}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@ -320,6 +332,12 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
||||
{result.imported} imported
|
||||
</div>
|
||||
)}
|
||||
{result.tagsAssigned > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-cyan-400 text-sm">
|
||||
<CheckCircle className="size-4" />
|
||||
{result.tagsAssigned} tag{result.tagsAssigned !== 1 ? 's' : ''} assigned
|
||||
</div>
|
||||
)}
|
||||
{result.skipped > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-amber-400 text-sm">
|
||||
<AlertTriangle className="size-4" />
|
||||
|
||||
65
src/lib/contacts/parse-contact-csv.test.ts
Normal file
65
src/lib/contacts/parse-contact-csv.test.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseContactCsv, parseTagCell } from "./parse-contact-csv";
|
||||
|
||||
describe("parseTagCell", () => {
|
||||
it("splits comma-separated tags and trims whitespace", () => {
|
||||
expect(parseTagCell(" VIP , Lead , ")).toEqual(["VIP", "Lead"]);
|
||||
});
|
||||
|
||||
it("splits semicolon-separated tags", () => {
|
||||
expect(parseTagCell("VIP; Lead; Customer")).toEqual([
|
||||
"VIP",
|
||||
"Lead",
|
||||
"Customer",
|
||||
]);
|
||||
});
|
||||
|
||||
it("de-dupes case-insensitively", () => {
|
||||
expect(parseTagCell("vip, VIP, Lead")).toEqual(["vip", "Lead"]);
|
||||
});
|
||||
|
||||
it("returns empty for blank values", () => {
|
||||
expect(parseTagCell("")).toEqual([]);
|
||||
expect(parseTagCell(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseContactCsv", () => {
|
||||
it("parses optional tags column", () => {
|
||||
const csv = `phone,name,tags
|
||||
+15551234567,Alice,"VIP, Lead"
|
||||
+15559876543,Bob,Customer`;
|
||||
|
||||
expect(parseContactCsv(csv)).toEqual([
|
||||
{
|
||||
phone: "+15551234567",
|
||||
name: "Alice",
|
||||
email: undefined,
|
||||
company: undefined,
|
||||
tagNames: ["VIP", "Lead"],
|
||||
},
|
||||
{
|
||||
phone: "+15559876543",
|
||||
name: "Bob",
|
||||
email: undefined,
|
||||
company: undefined,
|
||||
tagNames: ["Customer"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty tagNames when tags column is absent", () => {
|
||||
const csv = `phone,name
|
||||
+15551234567,Alice`;
|
||||
|
||||
expect(parseContactCsv(csv)).toEqual([
|
||||
{
|
||||
phone: "+15551234567",
|
||||
name: "Alice",
|
||||
email: undefined,
|
||||
company: undefined,
|
||||
tagNames: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
100
src/lib/contacts/parse-contact-csv.ts
Normal file
100
src/lib/contacts/parse-contact-csv.ts
Normal file
@ -0,0 +1,100 @@
|
||||
/**
|
||||
* CSV parsing for the contacts import modal. Shared + unit-tested so
|
||||
* tag-column handling stays aligned with phone/name/email/company.
|
||||
*/
|
||||
|
||||
export interface ParsedContactRow {
|
||||
phone: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
company?: string;
|
||||
/** Tag names from the optional `tags` column (comma/semicolon separated). */
|
||||
tagNames: string[];
|
||||
}
|
||||
|
||||
/** Split a CSV cell into unique tag names (case-insensitive de-dupe). */
|
||||
export function parseTagCell(value: string | undefined): string[] {
|
||||
if (!value?.trim()) return [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const names: string[] = [];
|
||||
|
||||
for (const part of value.split(/[,;]/)) {
|
||||
const name = part.trim();
|
||||
if (!name) continue;
|
||||
const key = name.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
names.push(name);
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
export function parseContactCsv(text: string): ParsedContactRow[] {
|
||||
const lines = text.trim().split(/\r?\n/);
|
||||
if (lines.length < 2) return [];
|
||||
|
||||
const headers = lines[0]
|
||||
.split(",")
|
||||
.map((h) => h.trim().toLowerCase().replace(/["']/g, ""));
|
||||
|
||||
const phoneIdx = headers.indexOf("phone");
|
||||
if (phoneIdx === -1) return [];
|
||||
|
||||
const nameIdx = headers.indexOf("name");
|
||||
const emailIdx = headers.indexOf("email");
|
||||
const companyIdx = headers.indexOf("company");
|
||||
const tagsIdx = headers.indexOf("tags");
|
||||
|
||||
const rows: ParsedContactRow[] = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
|
||||
const values = parseCsvLine(line);
|
||||
const phone = values[phoneIdx]?.replace(/["']/g, "").trim();
|
||||
if (!phone) continue;
|
||||
|
||||
rows.push({
|
||||
phone,
|
||||
name:
|
||||
nameIdx >= 0
|
||||
? values[nameIdx]?.replace(/["']/g, "").trim() || undefined
|
||||
: undefined,
|
||||
email:
|
||||
emailIdx >= 0
|
||||
? values[emailIdx]?.replace(/["']/g, "").trim() || undefined
|
||||
: undefined,
|
||||
company:
|
||||
companyIdx >= 0
|
||||
? values[companyIdx]?.replace(/["']/g, "").trim() || undefined
|
||||
: undefined,
|
||||
tagNames:
|
||||
tagsIdx >= 0 ? parseTagCell(values[tagsIdx]?.replace(/["']/g, "")) : [],
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Simple CSV line parse (handles quoted fields). */
|
||||
function parseCsvLine(line: string): string[] {
|
||||
const values: string[] = [];
|
||||
let current = "";
|
||||
let inQuotes = false;
|
||||
|
||||
for (const char of line) {
|
||||
if (char === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (char === "," && !inQuotes) {
|
||||
values.push(current.trim());
|
||||
current = "";
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
values.push(current.trim());
|
||||
return values;
|
||||
}
|
||||
127
src/lib/contacts/resolve-import-tags.ts
Normal file
127
src/lib/contacts/resolve-import-tags.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
|
||||
const DEFAULT_TAG_COLOR = "#3b82f6";
|
||||
|
||||
export interface ResolveImportTagsResult {
|
||||
/** Lowercase tag name → tag id. */
|
||||
tagIdByKey: Map<string, string>;
|
||||
/** Names that could not be matched and were not created. */
|
||||
skippedNames: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve tag names from a CSV import to tag ids. Existing account tags
|
||||
* are matched case-insensitively. Missing names are created when
|
||||
* `canCreateTags` is true (admin+); otherwise they are reported in
|
||||
* `skippedNames`.
|
||||
*/
|
||||
export async function resolveImportTagIds(
|
||||
supabase: SupabaseClient,
|
||||
params: {
|
||||
accountId: string;
|
||||
userId: string;
|
||||
tagNames: string[];
|
||||
canCreateTags: boolean;
|
||||
defaultColor?: string;
|
||||
},
|
||||
): Promise<ResolveImportTagsResult> {
|
||||
const { accountId, userId, tagNames, canCreateTags } = params;
|
||||
const defaultColor = params.defaultColor ?? DEFAULT_TAG_COLOR;
|
||||
|
||||
const uniqueNames: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const raw of tagNames) {
|
||||
const name = raw.trim();
|
||||
if (!name) continue;
|
||||
const key = name.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
uniqueNames.push(name);
|
||||
}
|
||||
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
.from("tags")
|
||||
.select("id, name")
|
||||
.eq("account_id", accountId);
|
||||
|
||||
if (fetchError) throw fetchError;
|
||||
|
||||
const tagIdByKey = new Map<string, string>();
|
||||
for (const tag of existing ?? []) {
|
||||
const key = tag.name.trim().toLowerCase();
|
||||
if (!tagIdByKey.has(key)) tagIdByKey.set(key, tag.id);
|
||||
}
|
||||
|
||||
const skippedNames: string[] = [];
|
||||
const toCreate: string[] = [];
|
||||
|
||||
for (const name of uniqueNames) {
|
||||
const key = name.toLowerCase();
|
||||
if (tagIdByKey.has(key)) continue;
|
||||
if (canCreateTags) toCreate.push(name);
|
||||
else skippedNames.push(name);
|
||||
}
|
||||
|
||||
if (toCreate.length > 0) {
|
||||
const { data: created, error: createError } = await supabase
|
||||
.from("tags")
|
||||
.insert(
|
||||
toCreate.map((name) => ({
|
||||
user_id: userId,
|
||||
account_id: accountId,
|
||||
name,
|
||||
color: defaultColor,
|
||||
})),
|
||||
)
|
||||
.select("id, name");
|
||||
|
||||
if (createError) throw createError;
|
||||
|
||||
for (const tag of created ?? []) {
|
||||
tagIdByKey.set(tag.name.trim().toLowerCase(), tag.id);
|
||||
}
|
||||
}
|
||||
|
||||
return { tagIdByKey, skippedNames };
|
||||
}
|
||||
|
||||
export interface ContactTagAssignment {
|
||||
contactId: string;
|
||||
tagNames: string[];
|
||||
}
|
||||
|
||||
/** Insert contact_tags rows for imported contacts (ignores duplicates). */
|
||||
export async function assignImportedContactTags(
|
||||
supabase: SupabaseClient,
|
||||
assignments: ContactTagAssignment[],
|
||||
tagIdByKey: Map<string, string>,
|
||||
): Promise<number> {
|
||||
const rows: { contact_id: string; tag_id: string }[] = [];
|
||||
|
||||
for (const { contactId, tagNames } of assignments) {
|
||||
const assignedTagIds = new Set<string>();
|
||||
for (const name of tagNames) {
|
||||
const tagId = tagIdByKey.get(name.trim().toLowerCase());
|
||||
if (!tagId || assignedTagIds.has(tagId)) continue;
|
||||
assignedTagIds.add(tagId);
|
||||
rows.push({ contact_id: contactId, tag_id: tagId });
|
||||
}
|
||||
}
|
||||
|
||||
if (rows.length === 0) return 0;
|
||||
|
||||
const chunkSize = 100;
|
||||
let assigned = 0;
|
||||
|
||||
for (let i = 0; i < rows.length; i += chunkSize) {
|
||||
const chunk = rows.slice(i, i + chunkSize);
|
||||
const { error } = await supabase.from("contact_tags").upsert(chunk, {
|
||||
onConflict: "contact_id,tag_id",
|
||||
ignoreDuplicates: true,
|
||||
});
|
||||
if (error) throw error;
|
||||
assigned += chunk.length;
|
||||
}
|
||||
|
||||
return assigned;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user