mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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:
parent
3a416832a7
commit
73062cf06d
@ -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>
|
||||||
|
|||||||
@ -30,36 +30,42 @@ 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,
|
||||||
phone: "+15551234567",
|
rows: [
|
||||||
name: "Alice",
|
{
|
||||||
email: undefined,
|
phone: "+15551234567",
|
||||||
company: undefined,
|
name: "Alice",
|
||||||
tagNames: ["VIP", "Lead"],
|
email: undefined,
|
||||||
},
|
company: undefined,
|
||||||
{
|
tagNames: ["VIP", "Lead"],
|
||||||
phone: "+15559876543",
|
},
|
||||||
name: "Bob",
|
{
|
||||||
email: undefined,
|
phone: "+15559876543",
|
||||||
company: undefined,
|
name: "Bob",
|
||||||
tagNames: ["Customer"],
|
email: undefined,
|
||||||
},
|
company: undefined,
|
||||||
]);
|
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,
|
||||||
phone: "+15551234567",
|
rows: [
|
||||||
name: "Alice",
|
{
|
||||||
email: undefined,
|
phone: "+15551234567",
|
||||||
company: undefined,
|
name: "Alice",
|
||||||
tagNames: [],
|
email: undefined,
|
||||||
},
|
company: undefined,
|
||||||
]);
|
tagNames: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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). */
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user