Merge pull request #246 from iiamabhishek/main

feat(contacts): CSV import tag assignment and preview improvements
This commit is contained in:
Arnas Donauskas 2026-06-16 11:31:58 +03:00 committed by GitHub
commit 53ba8c104e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 753 additions and 140 deletions

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useRef } from 'react'; import { useMemo, useRef, useState } from 'react';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
import { useAuth } from '@/hooks/use-auth'; import { useAuth } from '@/hooks/use-auth';
import { import {
@ -8,6 +8,16 @@ import {
isUniqueViolation, isUniqueViolation,
normalizeKey, normalizeKey,
} from '@/lib/contacts/dedupe'; } from '@/lib/contacts/dedupe';
import {
parseContactCsv,
type ParsedContactRow,
} from '@/lib/contacts/parse-contact-csv';
import {
assignImportedContactTags,
resolveImportTagIds,
type ContactTagAssignment,
} from '@/lib/contacts/resolve-import-tags';
import { cn } from '@/lib/utils';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
Dialog, Dialog,
@ -18,7 +28,89 @@ import {
DialogFooter, DialogFooter,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; 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 (
<span
className={cn(
'block truncate',
maxWidth,
mono && 'font-mono text-[11px]'
)}
title={value}
>
{value}
</span>
);
}
function ImportPreviewTags({
tagNames,
tagColorByKey,
}: {
tagNames: string[];
tagColorByKey: Map<string, string>;
}) {
if (tagNames.length === 0) {
return <span className="text-slate-600">—</span>;
}
return (
<div className="flex min-w-[4.5rem] flex-wrap gap-1">
{tagNames.map((name) => {
const color =
tagColorByKey.get(name.trim().toLowerCase()) ?? DEFAULT_TAG_COLOR;
const isKnown = tagColorByKey.has(name.trim().toLowerCase());
return (
<span
key={name}
className="inline-flex max-w-full items-center gap-1 rounded-full px-2 py-0.5 text-[10px] leading-none font-medium"
style={{
backgroundColor: `${color}18`,
color,
border: `1px solid ${color}${isKnown ? '55' : '30'}`,
}}
title={isKnown ? name : `${name} (will be created on import)`}
>
<span
className="size-1.5 shrink-0 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="truncate">{name}</span>
</span>
);
})}
</div>
);
}
interface ImportModalProps { interface ImportModalProps {
open: boolean; open: boolean;
@ -26,87 +118,43 @@ interface ImportModalProps {
onImported: () => void; onImported: () => void;
} }
interface ParsedRow { export function ImportModal({
phone: string; open,
name?: string; onOpenChange,
email?: string; onImported,
company?: string; }: ImportModalProps) {
}
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 supabase = createClient();
const { accountId } = useAuth(); const { accountId, canEditSettings } = useAuth();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [parsedRows, setParsedRows] = useState<ParsedRow[]>([]); const [parsedRows, setParsedRows] = useState<ParsedContactRow[]>([]);
const [hasTagsColumn, setHasTagsColumn] = useState(false);
const [hasCompanyColumn, setHasCompanyColumn] = useState(false);
const [tagColorByKey, setTagColorByKey] = useState<Map<string, string>>(
new Map()
);
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
const [result, setResult] = useState<{ const [result, setResult] = useState<{
imported: number; imported: number;
skipped: number; skipped: number;
failed: number; failed: number;
tagsAssigned: number;
} | null>(null); } | null>(null);
function reset() { function reset() {
setFile(null); setFile(null);
setParsedRows([]); setParsedRows([]);
setHasTagsColumn(false);
setHasCompanyColumn(false);
setTagColorByKey(new Map());
setResult(null); setResult(null);
if (fileInputRef.current) fileInputRef.current.value = ''; if (fileInputRef.current) fileInputRef.current.value = '';
} }
function handleOpenChange(open: boolean) { function handleOpenChange(next: boolean) {
if (!open) reset(); if (!next) reset();
onOpenChange(open); onOpenChange(next);
} }
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) { async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
@ -117,15 +165,42 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
setResult(null); setResult(null);
const text = await selected.text(); const text = await selected.text();
const rows = parseCSV(text); const {
rows,
hasTagsColumn: csvHasTags,
hasCompanyColumn: csvHasCompany,
} = parseContactCsv(text);
if (rows.length === 0) { if (rows.length === 0) {
toast.error('No valid rows found. Ensure CSV has a "phone" column header.'); toast.error(
'No valid rows found. Ensure CSV has a "phone" column header.'
);
setParsedRows([]); setParsedRows([]);
setHasTagsColumn(false);
setHasCompanyColumn(false);
setTagColorByKey(new Map());
return; return;
} }
setParsedRows(rows); setParsedRows(rows);
setHasTagsColumn(csvHasTags);
setHasCompanyColumn(csvHasCompany);
if (csvHasTags && accountId) {
const { data: tags } = await supabase
.from('tags')
.select('name, color')
.eq('account_id', accountId);
const colors = new Map<string, string>();
for (const tag of tags ?? []) {
const key = tag.name.trim().toLowerCase();
if (!colors.has(key)) colors.set(key, tag.color);
}
setTagColorByKey(colors);
} else {
setTagColorByKey(new Map());
}
} }
async function handleImport() { async function handleImport() {
@ -138,7 +213,8 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
} = await supabase.auth.getSession(); } = await supabase.auth.getSession();
const user = session?.user; const user = session?.user;
if (!user) throw new Error('Not authenticated'); if (!user) throw new Error('Not authenticated');
if (!accountId) throw new Error('Your profile is not linked to an account.'); if (!accountId)
throw new Error('Your profile is not linked to an account.');
let imported = 0; let imported = 0;
let skipped = 0; let skipped = 0;
@ -156,8 +232,10 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
.eq('account_id', accountId); .eq('account_id', accountId);
const existing = new Set( const existing = new Set(
(existingRows ?? []) (existingRows ?? [])
.map((r) => (r as { phone_normalized: string | null }).phone_normalized) .map(
.filter((p): p is string => !!p), (r) => (r as { phone_normalized: string | null }).phone_normalized
)
.filter((p): p is string => !!p)
); );
const toInsert = unique.filter((row) => { const toInsert = unique.filter((row) => {
@ -168,10 +246,27 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
return true; return true;
}); });
// 3) Batch insert the genuinely-new rows in chunks of 50. The DB // 3) Resolve tag names → ids (admin+ may auto-create missing tags).
// Skip the round-trip when the import carries no tag names.
const allTagNames = toInsert.flatMap((row) => row.tagNames);
let tagIdByKey = new Map<string, string>();
let skippedNames: string[] = [];
if (allTagNames.length > 0) {
({ tagIdByKey, skippedNames } = await resolveImportTagIds(supabase, {
accountId,
userId: user.id,
tagNames: allTagNames,
canCreateTags: canEditSettings,
}));
}
const tagAssignments: ContactTagAssignment[] = [];
// 4) Batch insert the genuinely-new rows in chunks of 50. The DB
// unique index is the backstop: a 23505 (race, or a format // unique index is the backstop: a 23505 (race, or a format
// that normalizes equal) counts as skipped, not failed. // that normalizes equal) counts as skipped, not failed.
const chunkSize = 50; const chunkSize = 50;
for (let i = 0; i < toInsert.length; i += chunkSize) { for (let i = 0; i < toInsert.length; i += chunkSize) {
const chunk = toInsert.slice(i, i + chunkSize); const chunk = toInsert.slice(i, i + chunkSize);
const rows = chunk.map((row) => ({ const rows = chunk.map((row) => ({
@ -191,10 +286,23 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
if (error) { if (error) {
// Retry individually so one bad/duplicate row doesn't sink // Retry individually so one bad/duplicate row doesn't sink
// the whole chunk. // the whole chunk.
for (const row of rows) { for (let j = 0; j < rows.length; j++) {
const { error: singleErr } = await supabase.from('contacts').insert(row); const row = rows[j];
if (!singleErr) { const source = chunk[j];
const { data: singleData, error: singleErr } = await supabase
.from('contacts')
.insert(row)
.select('id')
.single();
if (!singleErr && singleData) {
imported++; imported++;
if (source.tagNames.length > 0) {
tagAssignments.push({
contactId: singleData.id,
tagNames: source.tagNames,
});
}
} else if (isUniqueViolation(singleErr)) { } else if (isUniqueViolation(singleErr)) {
skipped++; skipped++;
} else { } else {
@ -202,20 +310,62 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
} }
} }
} else { } else {
imported += data?.length ?? chunk.length; const inserted = data ?? [];
imported += inserted.length;
// inserted[j] ↔ chunk[j] only holds because a single INSERT
// preserves RETURNING order. If this path is ever split into
// parallel inserts, zip by phone or returned id instead.
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. Failure here must
// not mask a successful contact import.
let tagsAssigned = 0;
try {
tagsAssigned = await assignImportedContactTags(
supabase,
tagAssignments,
tagIdByKey
);
} catch {
toast.warning('Contacts imported, but some tag assignments failed.');
}
setResult({ imported, skipped, failed, tagsAssigned });
if (imported > 0) { if (imported > 0) {
toast.success(`${imported} contact${imported !== 1 ? 's' : ''} imported`); toast.success(
`${imported} contact${imported !== 1 ? 's' : ''} imported`
);
onImported(); 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) { if (skipped > 0) {
toast.info(`${skipped} duplicate${skipped !== 1 ? 's' : ''} skipped`); toast.info(`${skipped} duplicate${skipped !== 1 ? 's' : ''} skipped`);
} }
if (failed > 0) { if (failed > 0) {
toast.error(`${failed} contact${failed !== 1 ? 's' : ''} failed to import`); toast.error(
`${failed} contact${failed !== 1 ? 's' : ''} failed to import`
);
} }
} catch (err: unknown) { } catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Import failed'; const message = err instanceof Error ? err.message : 'Import failed';
@ -225,41 +375,101 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
} }
} }
const preview = parsedRows.slice(0, 5); const preview = parsedRows.slice(0, PREVIEW_LIMIT);
// Tags: OR — show when the CSV declares a column or preview rows carry
// values, so an all-empty tags column still renders for validation.
const previewHasTags =
hasTagsColumn || preview.some((row) => row.tagNames.length > 0);
// Company: AND — hide unless the CSV declares it and preview has data,
// avoiding an all-dash column that wastes horizontal space.
const previewHasCompany =
hasCompanyColumn && preview.some((row) => row.company?.trim());
const tagStats = useMemo(() => {
const names = new Set<string>();
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 ( return (
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="bg-slate-900 border-slate-700 text-slate-200 sm:max-w-lg"> <DialogContent className="flex max-h-[min(90vh,720px)] flex-col gap-0 overflow-hidden border-slate-700/80 bg-slate-900 p-0 text-slate-200 sm:max-w-2xl">
<DialogHeader> <div className="shrink-0 space-y-4 border-b border-slate-800/80 px-6 pt-6 pb-5">
<DialogTitle className="text-white">Import Contacts</DialogTitle> <DialogHeader className="gap-1.5">
<DialogDescription className="text-slate-400"> <DialogTitle className="text-lg text-white">
Upload a CSV file with a &quot;phone&quot; column (required). Optional columns: Import Contacts
name, email, company. </DialogTitle>
</DialogDescription> <DialogDescription className="leading-relaxed text-slate-400">
</DialogHeader> Upload a CSV with a required{' '}
<code className="rounded bg-slate-800 px-1 py-0.5 text-[11px] text-slate-300">
phone
</code>{' '}
column. Optional:{' '}
<code className="rounded bg-slate-800 px-1 py-0.5 text-[11px] text-slate-300">
name
</code>
,{' '}
<code className="rounded bg-slate-800 px-1 py-0.5 text-[11px] text-slate-300">
email
</code>
,{' '}
<code className="rounded bg-slate-800 px-1 py-0.5 text-[11px] text-slate-300">
company
</code>
,{' '}
<code className="rounded bg-slate-800 px-1 py-0.5 text-[11px] text-slate-300">
tags
</code>{' '}
(comma-separated; quote multi-tag cells).
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Upload area */}
<div <div
role="button"
tabIndex={0}
onClick={() => fileInputRef.current?.click()} onClick={() => 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]'
: 'hover:border-primary/40 border-slate-700/80 bg-slate-950/40 hover:bg-slate-950/70'
)}
> >
{file ? ( {file ? (
<> <>
<FileText className="size-8 text-primary" /> <div className="bg-primary/15 ring-primary/25 flex size-10 items-center justify-center rounded-lg ring-1">
<p className="text-sm text-slate-300">{file.name}</p> <FileText className="text-primary size-5" />
<p className="text-xs text-slate-500"> </div>
{parsedRows.length} row{parsedRows.length !== 1 ? 's' : ''} detected <p
className="max-w-full truncate px-2 text-sm font-medium text-slate-200"
title={file.name}
>
{truncateFilename(file.name)}
</p> </p>
<span className="rounded-full bg-slate-800 px-2.5 py-0.5 text-[11px] font-medium text-slate-400">
{parsedRows.length} row{parsedRows.length !== 1 ? 's' : ''}{' '}
ready
</span>
</> </>
) : ( ) : (
<> <>
<Upload className="size-8 text-slate-500" /> <div className="flex size-10 items-center justify-center rounded-lg bg-slate-800/80 ring-1 ring-slate-700/80 transition-colors group-hover:bg-slate-800">
<Upload className="size-5 text-slate-500 group-hover:text-slate-400" />
</div>
<p className="text-sm text-slate-400"> <p className="text-sm text-slate-400">
Click to upload CSV file Click to choose a CSV file
</p> </p>
<p className="text-xs text-slate-500"> <p className="text-[11px] text-slate-600">
CSV with &quot;phone&quot; column required .csv up to your browser limit
</p> </p>
</> </>
)} )}
@ -272,63 +482,136 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
onChange={handleFileChange} onChange={handleFileChange}
className="hidden" className="hidden"
/> />
</div>
{/* Preview table */} <div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
{preview.length > 0 && !result && ( {preview.length > 0 && !result && (
<div className="space-y-2"> <div className="space-y-3">
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider"> <div className="flex flex-wrap items-center justify-between gap-2">
Preview (first {preview.length} rows) <p className="text-[11px] font-semibold tracking-[0.14em] text-slate-500 uppercase">
</p> Preview · first {preview.length}
<div className="rounded-lg border border-slate-700 overflow-hidden"> </p>
<table className="w-full text-xs"> <div className="flex flex-wrap items-center gap-1.5">
<thead> {tagStats.rowsWithTags > 0 && (
<tr className="bg-slate-800"> <span className="inline-flex items-center gap-1 rounded-md bg-slate-800/90 px-2 py-0.5 text-[11px] text-slate-400">
<th className="px-3 py-1.5 text-left text-slate-400 font-medium">Phone</th> <Tag className="text-primary/80 size-3" />
<th className="px-3 py-1.5 text-left text-slate-400 font-medium">Name</th> {tagStats.unique} tag{tagStats.unique !== 1 ? 's' : ''} ·{' '}
<th className="px-3 py-1.5 text-left text-slate-400 font-medium">Email</th> {tagStats.rowsWithTags} contact
<th className="px-3 py-1.5 text-left text-slate-400 font-medium">Company</th> {tagStats.rowsWithTags !== 1 ? 's' : ''}
</tr> </span>
</thead> )}
<tbody> </div>
{preview.map((row, i) => (
<tr key={i} className="border-t border-slate-700/50">
<td className="px-3 py-1.5 text-slate-300">{row.phone}</td>
<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>
</tr>
))}
</tbody>
</table>
</div> </div>
{parsedRows.length > 5 && (
<p className="text-xs text-slate-500"> <div className="overflow-hidden rounded-xl border border-slate-800 ring-1 ring-slate-800/50">
...and {parsedRows.length - 5} more rows <div className="overflow-x-auto">
<table className="w-full min-w-[32rem] text-xs">
<thead>
<tr className="border-b border-slate-800 bg-slate-950/60">
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-slate-500">
Phone
</th>
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-slate-500">
Name
</th>
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-slate-500">
Email
</th>
{previewHasCompany && (
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-slate-500">
Company
</th>
)}
{previewHasTags && (
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-slate-500">
Tags
</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-slate-800/70">
{preview.map((row, i) => (
<tr
key={i}
className="bg-slate-900/40 transition-colors hover:bg-slate-800/30"
>
<td className="px-3 py-2 whitespace-nowrap text-slate-300">
<PreviewCell
value={row.phone}
mono
maxWidth="max-w-[7.5rem]"
/>
</td>
<td className="px-3 py-2 text-slate-200">
<PreviewCell
value={row.name || '—'}
maxWidth="max-w-[8.5rem]"
/>
</td>
<td className="px-3 py-2 text-slate-400">
<PreviewCell
value={row.email || '—'}
maxWidth="max-w-[10rem]"
/>
</td>
{previewHasCompany && (
<td className="px-3 py-2 text-slate-400">
<PreviewCell
value={row.company || '—'}
maxWidth="max-w-[7rem]"
/>
</td>
)}
{previewHasTags && (
<td className="px-3 py-2 align-top">
<ImportPreviewTags
tagNames={row.tagNames}
tagColorByKey={tagColorByKey}
/>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
{parsedRows.length > PREVIEW_LIMIT && (
<p className="text-center text-[11px] text-slate-600">
+ {parsedRows.length - PREVIEW_LIMIT} more row
{parsedRows.length - PREVIEW_LIMIT !== 1 ? 's' : ''} not shown
</p> </p>
)} )}
</div> </div>
)} )}
{/* Results */}
{result && ( {result && (
<div className="rounded-lg border border-slate-700 p-4 space-y-2"> <div className="rounded-xl border border-slate-800 bg-slate-950/50 p-4">
<p className="text-sm font-medium text-white">Import Complete</p> <p className="text-sm font-medium text-white">Import complete</p>
<div className="flex flex-wrap items-center gap-4"> <div className="mt-3 flex flex-wrap gap-3">
{result.imported > 0 && ( {result.imported > 0 && (
<div className="flex items-center gap-1.5 text-primary text-sm"> <div className="text-primary flex items-center gap-1.5 text-sm">
<CheckCircle className="size-4" /> <CheckCircle className="size-4 shrink-0" />
{result.imported} imported {result.imported} imported
</div> </div>
)} )}
{result.tagsAssigned > 0 && (
<div className="flex items-center gap-1.5 text-sm text-cyan-400">
<CheckCircle className="size-4 shrink-0" />
{result.tagsAssigned} tag
{result.tagsAssigned !== 1 ? 's' : ''} assigned
</div>
)}
{result.skipped > 0 && ( {result.skipped > 0 && (
<div className="flex items-center gap-1.5 text-amber-400 text-sm"> <div className="flex items-center gap-1.5 text-sm text-amber-400">
<AlertTriangle className="size-4" /> <AlertTriangle className="size-4 shrink-0" />
{result.skipped} duplicate{result.skipped !== 1 ? 's' : ''} skipped {result.skipped} skipped
</div> </div>
)} )}
{result.failed > 0 && ( {result.failed > 0 && (
<div className="flex items-center gap-1.5 text-red-400 text-sm"> <div className="flex items-center gap-1.5 text-sm text-red-400">
<XCircle className="size-4" /> <XCircle className="size-4 shrink-0" />
{result.failed} failed {result.failed} failed
</div> </div>
)} )}
@ -337,7 +620,7 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
)} )}
</div> </div>
<DialogFooter className="bg-slate-900 border-slate-700"> <DialogFooter className="mt-0 shrink-0 gap-2 border-t border-slate-800/80 bg-slate-950/50 px-6 py-4 sm:justify-end">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@ -354,7 +637,8 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
className="bg-primary hover:bg-primary/90 text-primary-foreground" className="bg-primary hover:bg-primary/90 text-primary-foreground"
> >
{importing && <Loader2 className="size-4 animate-spin" />} {importing && <Loader2 className="size-4 animate-spin" />}
Import {parsedRows.length > 0 ? `${parsedRows.length} Contacts` : ''} Import {parsedRows.length > 0 ? parsedRows.length : ''} contact
{parsedRows.length !== 1 ? 's' : ''}
</Button> </Button>
)} )}
</DialogFooter> </DialogFooter>

View File

@ -0,0 +1,73 @@
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({
hasTagsColumn: true,
hasCompanyColumn: false,
rows: [
{
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({
hasTagsColumn: false,
hasCompanyColumn: false,
rows: [
{
phone: '+15551234567',
name: 'Alice',
email: undefined,
company: undefined,
tagNames: [],
},
],
});
});
});

View File

@ -0,0 +1,116 @@
/**
* 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 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, 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, hasCompanyColumn: false };
}
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,
hasTagsColumn: tagsIdx >= 0,
hasCompanyColumn: companyIdx >= 0,
};
}
/** 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;
}

View File

@ -0,0 +1,140 @@
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`.
*
* Unlike the manual contact form (existing tags only), import may
* auto-create missing tag definitions for admin+ callers.
*/
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);
}
if (uniqueNames.length === 0) {
return { tagIdByKey: new Map(), skippedNames: [] };
}
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).
*
* Returns the number of contact–tag pairs *requested* for upsert, not
* rows actually inserted — `ignoreDuplicates` can drop pairs that already
* exist without changing the returned count.
*/
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;
}