feat(contacts): enhance import modal with tag preview and CSV parsing improvements

- Added `ImportPreviewTags` component to display tags with color coding in the import modal.
- Updated `parseContactCsv` to return an object containing both parsed rows and a flag indicating the presence of a tags column.
- Modified state management in the import modal to handle tags and their colors, improving user experience during contact import.
- Adjusted the import modal to conditionally render the tags column based on the presence of tags in the preview.
This commit is contained in:
Abhishek Sonwal 2026-06-15 13:12:00 +05:30
parent 3a416832a7
commit 73062cf06d
3 changed files with 109 additions and 36 deletions

View File

@ -8,7 +8,7 @@ import {
isUniqueViolation, isUniqueViolation,
normalizeKey, normalizeKey,
} from '@/lib/contacts/dedupe'; } from '@/lib/contacts/dedupe';
import { parseContactCsv } from '@/lib/contacts/parse-contact-csv'; import { parseContactCsv, type ParsedContactRow } from '@/lib/contacts/parse-contact-csv';
import { import {
assignImportedContactTags, assignImportedContactTags,
resolveImportTagIds, resolveImportTagIds,
@ -26,6 +26,41 @@ import {
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 } from 'lucide-react';
const DEFAULT_TAG_COLOR = '#3b82f6';
function ImportPreviewTags({
tagNames,
tagColorByKey,
}: {
tagNames: string[];
tagColorByKey: Map<string, string>;
}) {
if (tagNames.length === 0) {
return <span className="text-slate-500">-</span>;
}
return (
<div className="flex flex-wrap gap-1">
{tagNames.map((name) => {
const color = tagColorByKey.get(name.trim().toLowerCase()) ?? DEFAULT_TAG_COLOR;
return (
<span
key={name}
className="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium"
style={{
backgroundColor: `${color}20`,
color,
border: `1px solid ${color}40`,
}}
>
{name}
</span>
);
})}
</div>
);
}
interface ImportModalProps { interface ImportModalProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
@ -38,7 +73,9 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
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<ReturnType<typeof parseContactCsv>>([]); const [parsedRows, setParsedRows] = useState<ParsedContactRow[]>([]);
const [hasTagsColumn, setHasTagsColumn] = 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;
@ -50,6 +87,8 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
function reset() { function reset() {
setFile(null); setFile(null);
setParsedRows([]); setParsedRows([]);
setHasTagsColumn(false);
setTagColorByKey(new Map());
setResult(null); setResult(null);
if (fileInputRef.current) fileInputRef.current.value = ''; if (fileInputRef.current) fileInputRef.current.value = '';
} }
@ -67,15 +106,34 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
setResult(null); setResult(null);
const text = await selected.text(); const text = await selected.text();
const rows = parseContactCsv(text); const { rows, hasTagsColumn: csvHasTags } = 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);
setTagColorByKey(new Map());
return; return;
} }
setParsedRows(rows); setParsedRows(rows);
setHasTagsColumn(csvHasTags);
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() {
@ -228,7 +286,7 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
} }
const preview = parsedRows.slice(0, 5); const preview = parsedRows.slice(0, 5);
const hasTagsColumn = parsedRows.some((row) => row.tagNames.length > 0); const previewHasTags = hasTagsColumn || preview.some((row) => row.tagNames.length > 0);
return ( return (
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
@ -291,7 +349,7 @@ 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">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">Email</th>
<th className="px-3 py-1.5 text-left text-slate-400 font-medium">Company</th> <th className="px-3 py-1.5 text-left text-slate-400 font-medium">Company</th>
{hasTagsColumn && ( {previewHasTags && (
<th className="px-3 py-1.5 text-left text-slate-400 font-medium">Tags</th> <th className="px-3 py-1.5 text-left text-slate-400 font-medium">Tags</th>
)} )}
</tr> </tr>
@ -303,9 +361,12 @@ 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.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.email || '-'}</td>
<td className="px-3 py-1.5 text-slate-300">{row.company || '-'}</td> <td className="px-3 py-1.5 text-slate-300">{row.company || '-'}</td>
{hasTagsColumn && ( {previewHasTags && (
<td className="px-3 py-1.5 text-slate-300"> <td className="px-3 py-1.5 text-slate-300">
{row.tagNames.length > 0 ? row.tagNames.join(', ') : '-'} <ImportPreviewTags
tagNames={row.tagNames}
tagColorByKey={tagColorByKey}
/>
</td> </td>
)} )}
</tr> </tr>

View File

@ -30,7 +30,9 @@ describe("parseContactCsv", () => {
+15551234567,Alice,"VIP, Lead" +15551234567,Alice,"VIP, Lead"
+15559876543,Bob,Customer`; +15559876543,Bob,Customer`;
expect(parseContactCsv(csv)).toEqual([ expect(parseContactCsv(csv)).toEqual({
hasTagsColumn: true,
rows: [
{ {
phone: "+15551234567", phone: "+15551234567",
name: "Alice", name: "Alice",
@ -45,14 +47,17 @@ describe("parseContactCsv", () => {
company: undefined, company: undefined,
tagNames: ["Customer"], tagNames: ["Customer"],
}, },
]); ],
});
}); });
it("returns empty tagNames when tags column is absent", () => { it("returns empty tagNames when tags column is absent", () => {
const csv = `phone,name const csv = `phone,name
+15551234567,Alice`; +15551234567,Alice`;
expect(parseContactCsv(csv)).toEqual([ expect(parseContactCsv(csv)).toEqual({
hasTagsColumn: false,
rows: [
{ {
phone: "+15551234567", phone: "+15551234567",
name: "Alice", name: "Alice",
@ -60,6 +65,7 @@ describe("parseContactCsv", () => {
company: undefined, company: undefined,
tagNames: [], tagNames: [],
}, },
]); ],
});
}); });
}); });

View File

@ -31,16 +31,22 @@ export function parseTagCell(value: string | undefined): string[] {
return names; return names;
} }
export function parseContactCsv(text: string): ParsedContactRow[] { export interface ParseContactCsvResult {
rows: ParsedContactRow[];
/** True when the CSV header includes a `tags` column. */
hasTagsColumn: boolean;
}
export function parseContactCsv(text: string): ParseContactCsvResult {
const lines = text.trim().split(/\r?\n/); const lines = text.trim().split(/\r?\n/);
if (lines.length < 2) return []; if (lines.length < 2) return { rows: [], hasTagsColumn: false };
const headers = lines[0] const headers = lines[0]
.split(",") .split(",")
.map((h) => h.trim().toLowerCase().replace(/["']/g, "")); .map((h) => h.trim().toLowerCase().replace(/["']/g, ""));
const phoneIdx = headers.indexOf("phone"); const phoneIdx = headers.indexOf("phone");
if (phoneIdx === -1) return []; if (phoneIdx === -1) return { rows: [], hasTagsColumn: false };
const nameIdx = headers.indexOf("name"); const nameIdx = headers.indexOf("name");
const emailIdx = headers.indexOf("email"); const emailIdx = headers.indexOf("email");
@ -76,7 +82,7 @@ export function parseContactCsv(text: string): ParsedContactRow[] {
}); });
} }
return rows; return { rows, hasTagsColumn: tagsIdx >= 0 };
} }
/** Simple CSV line parse (handles quoted fields). */ /** Simple CSV line parse (handles quoted fields). */