From b780fddb3bb4d0e5edbbbdbef973f3ba5b1f9c2e Mon Sep 17 00:00:00 2001 From: Abhishek Sonwal Date: Mon, 15 Jun 2026 13:27:56 +0530 Subject: [PATCH] feat(contacts): enhance import modal with company column support and improved CSV parsing - Updated `parseContactCsv` to include a flag for the presence of a company column in the CSV. - Modified the import modal state management to handle the new company column, improving user experience during contact import. - Enhanced the `ImportPreviewTags` component to conditionally render based on the presence of company data. - Adjusted the UI to better display tag and company information in the import preview. --- src/components/contacts/import-modal.tsx | 335 +++++++++++++++------ src/lib/contacts/parse-contact-csv.test.ts | 2 + src/lib/contacts/parse-contact-csv.ts | 16 +- 3 files changed, 251 insertions(+), 102 deletions(-) diff --git a/src/components/contacts/import-modal.tsx b/src/components/contacts/import-modal.tsx index 876064b..8450b0f 100644 --- a/src/components/contacts/import-modal.tsx +++ b/src/components/contacts/import-modal.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useRef } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { createClient } from '@/lib/supabase/client'; import { useAuth } from '@/hooks/use-auth'; import { @@ -14,6 +14,7 @@ import { resolveImportTagIds, type ContactTagAssignment, } from '@/lib/contacts/resolve-import-tags'; +import { cn } from '@/lib/utils'; import { toast } from 'sonner'; import { Dialog, @@ -24,9 +25,45 @@ import { DialogFooter, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; -import { Upload, FileText, Loader2, CheckCircle, XCircle, AlertTriangle } from 'lucide-react'; +import { + Upload, + FileText, + Loader2, + CheckCircle, + XCircle, + AlertTriangle, + Tag, +} from 'lucide-react'; const DEFAULT_TAG_COLOR = '#3b82f6'; +const PREVIEW_LIMIT = 5; + +function truncateFilename(name: string, max = 48): string { + if (name.length <= max) return name; + const ext = name.includes('.') ? name.slice(name.lastIndexOf('.')) : ''; + const base = name.slice(0, name.length - ext.length); + const keep = max - ext.length - 1; + return `${base.slice(0, Math.max(keep, 12))}…${ext}`; +} + +function PreviewCell({ + value, + mono, + maxWidth = 'max-w-[9rem]', +}: { + value: string; + mono?: boolean; + maxWidth?: string; +}) { + return ( + + {value} + + ); +} function ImportPreviewTags({ tagNames, @@ -36,24 +73,30 @@ function ImportPreviewTags({ tagColorByKey: Map; }) { if (tagNames.length === 0) { - return -; + return —; } return ( -
+
{tagNames.map((name) => { const color = tagColorByKey.get(name.trim().toLowerCase()) ?? DEFAULT_TAG_COLOR; + const isKnown = tagColorByKey.has(name.trim().toLowerCase()); return ( - {name} + + {name} ); })} @@ -75,6 +118,7 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps const [file, setFile] = useState(null); const [parsedRows, setParsedRows] = useState([]); const [hasTagsColumn, setHasTagsColumn] = useState(false); + const [hasCompanyColumn, setHasCompanyColumn] = useState(false); const [tagColorByKey, setTagColorByKey] = useState>(new Map()); const [importing, setImporting] = useState(false); const [result, setResult] = useState<{ @@ -88,14 +132,15 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps setFile(null); setParsedRows([]); setHasTagsColumn(false); + setHasCompanyColumn(false); setTagColorByKey(new Map()); setResult(null); if (fileInputRef.current) fileInputRef.current.value = ''; } - function handleOpenChange(open: boolean) { - if (!open) reset(); - onOpenChange(open); + function handleOpenChange(next: boolean) { + if (!next) reset(); + onOpenChange(next); } async function handleFileChange(e: React.ChangeEvent) { @@ -106,18 +151,21 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps setResult(null); const text = await selected.text(); - const { rows, hasTagsColumn: csvHasTags } = parseContactCsv(text); + const { rows, hasTagsColumn: csvHasTags, hasCompanyColumn: csvHasCompany } = + parseContactCsv(text); if (rows.length === 0) { toast.error('No valid rows found. Ensure CSV has a "phone" column header.'); setParsedRows([]); setHasTagsColumn(false); + setHasCompanyColumn(false); setTagColorByKey(new Map()); return; } setParsedRows(rows); setHasTagsColumn(csvHasTags); + setHasCompanyColumn(csvHasCompany); if (csvHasTags && accountId) { const { data: tags } = await supabase @@ -152,12 +200,9 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps let skipped = 0; let failed = 0; - // 1) De-dupe within the file by normalized phone (keep first). const { unique, duplicates: inFileDupes } = dedupeByPhone(parsedRows); skipped += inFileDupes; - // 2) Skip numbers already in this account. One read of the - // generated `phone_normalized` column (migration 022) → Set. const { data: existingRows } = await supabase .from('contacts') .select('phone_normalized') @@ -176,7 +221,6 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps return true; }); - // 3) Resolve tag names → ids (create missing tags for admin+). const allTagNames = toInsert.flatMap((row) => row.tagNames); const { tagIdByKey, skippedNames } = await resolveImportTagIds(supabase, { accountId, @@ -186,10 +230,8 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps }); 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); const rows = chunk.map((row) => ({ @@ -207,8 +249,6 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps .select('id'); if (error) { - // Retry individually so one bad/duplicate row doesn't sink - // the whole chunk. for (let j = 0; j < rows.length; j++) { const row = rows[j]; const source = chunk[j]; @@ -246,7 +286,6 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps } } - // 5) Wire tags onto the contacts we just created. const tagsAssigned = await assignImportedContactTags( supabase, tagAssignments, @@ -285,44 +324,90 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps } } - const preview = parsedRows.slice(0, 5); - const previewHasTags = hasTagsColumn || preview.some((row) => row.tagNames.length > 0); + const preview = parsedRows.slice(0, PREVIEW_LIMIT); + const previewHasTags = + hasTagsColumn || preview.some((row) => row.tagNames.length > 0); + const previewHasCompany = + hasCompanyColumn && preview.some((row) => row.company?.trim()); + + const tagStats = useMemo(() => { + const names = new Set(); + let rowsWithTags = 0; + for (const row of parsedRows) { + if (row.tagNames.length === 0) continue; + rowsWithTags++; + for (const name of row.tagNames) names.add(name.trim().toLowerCase()); + } + return { unique: names.size, rowsWithTags }; + }, [parsedRows]); return ( - - - Import Contacts - - Upload a CSV file with a "phone" column (required). Optional columns: - name, email, company, tags (comma-separated tag names — quote the - cell if a tag list contains commas, e.g. "VIP, Lead"). - - + +
+ + Import Contacts + + Upload a CSV with a required{' '} + + phone + {' '} + column. Optional:{' '} + + name + + ,{' '} + + email + + ,{' '} + + company + + ,{' '} + + tags + {' '} + (comma-separated; quote multi-tag cells). + + -
- {/* Upload area */}
fileInputRef.current?.click()} - className="flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-slate-700 p-6 cursor-pointer hover:border-primary/50 transition-colors" + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') fileInputRef.current?.click(); + }} + className={cn( + 'group flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border border-dashed p-5 transition-all', + file + ? 'border-primary/35 bg-primary/[0.04]' + : 'border-slate-700/80 bg-slate-950/40 hover:border-primary/40 hover:bg-slate-950/70', + )} > {file ? ( <> - -

{file.name}

-

- {parsedRows.length} row{parsedRows.length !== 1 ? 's' : ''} detected +

+ +
+

+ {truncateFilename(file.name)}

+ + {parsedRows.length} row{parsedRows.length !== 1 ? 's' : ''} ready + ) : ( <> - -

- Click to upload CSV file -

-

- CSV with "phone" column required -

+
+ +
+

Click to choose a CSV file

+

.csv up to your browser limit

)}
@@ -334,80 +419,131 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps onChange={handleFileChange} className="hidden" /> +
- {/* Preview table */} +
{preview.length > 0 && !result && ( -
-

- Preview (first {preview.length} rows) -

-
- - - - - - - - {previewHasTags && ( - - )} - - - - {preview.map((row, i) => ( - - - - - +
+
+

+ Preview · first {preview.length} +

+
+ {tagStats.rowsWithTags > 0 && ( + + + {tagStats.unique} tag{tagStats.unique !== 1 ? 's' : ''} ·{' '} + {tagStats.rowsWithTags} contact + {tagStats.rowsWithTags !== 1 ? 's' : ''} + + )} +
+
+ +
+
+
PhoneNameEmailCompanyTags
{row.phone}{row.name || '-'}{row.email || '-'}{row.company || '-'}
+ + + + + + {previewHasCompany && ( + + )} {previewHasTags && ( - + )} - ))} - -
+ Phone + + Name + + Email + + Company + - - + Tags +
+ + + {preview.map((row, i) => ( + + + + + + + + + + + {previewHasCompany && ( + + + + )} + {previewHasTags && ( + + + + )} + + ))} + + +
- {parsedRows.length > 5 && ( -

- ...and {parsedRows.length - 5} more rows + + {parsedRows.length > PREVIEW_LIMIT && ( +

+ + {parsedRows.length - PREVIEW_LIMIT} more row + {parsedRows.length - PREVIEW_LIMIT !== 1 ? 's' : ''} not shown

)}
)} - {/* Results */} {result && ( -
-

Import Complete

-
+
+

Import complete

+
{result.imported > 0 && ( -
- +
+ {result.imported} imported
)} {result.tagsAssigned > 0 && ( -
- +
+ {result.tagsAssigned} tag{result.tagsAssigned !== 1 ? 's' : ''} assigned
)} {result.skipped > 0 && ( -
- - {result.skipped} duplicate{result.skipped !== 1 ? 's' : ''} skipped +
+ + {result.skipped} skipped
)} {result.failed > 0 && ( -
- +
+ {result.failed} failed
)} @@ -416,7 +552,7 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps )}
- + )} diff --git a/src/lib/contacts/parse-contact-csv.test.ts b/src/lib/contacts/parse-contact-csv.test.ts index 5fcf4a1..ac9a92e 100644 --- a/src/lib/contacts/parse-contact-csv.test.ts +++ b/src/lib/contacts/parse-contact-csv.test.ts @@ -32,6 +32,7 @@ describe("parseContactCsv", () => { expect(parseContactCsv(csv)).toEqual({ hasTagsColumn: true, + hasCompanyColumn: false, rows: [ { phone: "+15551234567", @@ -57,6 +58,7 @@ describe("parseContactCsv", () => { expect(parseContactCsv(csv)).toEqual({ hasTagsColumn: false, + hasCompanyColumn: false, rows: [ { phone: "+15551234567", diff --git a/src/lib/contacts/parse-contact-csv.ts b/src/lib/contacts/parse-contact-csv.ts index 9a7b9e3..199b42c 100644 --- a/src/lib/contacts/parse-contact-csv.ts +++ b/src/lib/contacts/parse-contact-csv.ts @@ -35,18 +35,24 @@ export interface ParseContactCsvResult { rows: ParsedContactRow[]; /** True when the CSV header includes a `tags` column. */ hasTagsColumn: boolean; + /** True when the CSV header includes a `company` column. */ + hasCompanyColumn: boolean; } export function parseContactCsv(text: string): ParseContactCsvResult { const lines = text.trim().split(/\r?\n/); - if (lines.length < 2) return { rows: [], hasTagsColumn: false }; + if (lines.length < 2) { + return { rows: [], hasTagsColumn: false, hasCompanyColumn: false }; + } const headers = lines[0] .split(",") .map((h) => h.trim().toLowerCase().replace(/["']/g, "")); const phoneIdx = headers.indexOf("phone"); - if (phoneIdx === -1) return { rows: [], hasTagsColumn: false }; + if (phoneIdx === -1) { + return { rows: [], hasTagsColumn: false, hasCompanyColumn: false }; + } const nameIdx = headers.indexOf("name"); const emailIdx = headers.indexOf("email"); @@ -82,7 +88,11 @@ export function parseContactCsv(text: string): ParseContactCsvResult { }); } - return { rows, hasTagsColumn: tagsIdx >= 0 }; + return { + rows, + hasTagsColumn: tagsIdx >= 0, + hasCompanyColumn: companyIdx >= 0, + }; } /** Simple CSV line parse (handles quoted fields). */