mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
refactor(contacts): improve code readability and formatting in import modal and CSV parsing
- Reformatted code in `import-modal.tsx` for better readability, including consistent indentation and line breaks. - Enhanced `parse-contact-csv.ts` and `parse-contact-csv.test.ts` with consistent string delimiters and improved whitespace handling. - Updated comments for clarity and consistency across the import functionality. - Ensured consistent use of single quotes for strings throughout the files.
This commit is contained in:
parent
a3ce8fcf9b
commit
f39ca60629
@ -8,7 +8,10 @@ import {
|
|||||||
isUniqueViolation,
|
isUniqueViolation,
|
||||||
normalizeKey,
|
normalizeKey,
|
||||||
} from '@/lib/contacts/dedupe';
|
} from '@/lib/contacts/dedupe';
|
||||||
import { parseContactCsv, type ParsedContactRow } from '@/lib/contacts/parse-contact-csv';
|
import {
|
||||||
|
parseContactCsv,
|
||||||
|
type ParsedContactRow,
|
||||||
|
} from '@/lib/contacts/parse-contact-csv';
|
||||||
import {
|
import {
|
||||||
assignImportedContactTags,
|
assignImportedContactTags,
|
||||||
resolveImportTagIds,
|
resolveImportTagIds,
|
||||||
@ -57,7 +60,11 @@ function PreviewCell({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn('block truncate', maxWidth, mono && 'font-mono text-[11px]')}
|
className={cn(
|
||||||
|
'block truncate',
|
||||||
|
maxWidth,
|
||||||
|
mono && 'font-mono text-[11px]'
|
||||||
|
)}
|
||||||
title={value}
|
title={value}
|
||||||
>
|
>
|
||||||
{value}
|
{value}
|
||||||
@ -77,14 +84,15 @@ function ImportPreviewTags({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1 min-w-[4.5rem]">
|
<div className="flex min-w-[4.5rem] flex-wrap gap-1">
|
||||||
{tagNames.map((name) => {
|
{tagNames.map((name) => {
|
||||||
const color = tagColorByKey.get(name.trim().toLowerCase()) ?? DEFAULT_TAG_COLOR;
|
const color =
|
||||||
|
tagColorByKey.get(name.trim().toLowerCase()) ?? DEFAULT_TAG_COLOR;
|
||||||
const isKnown = tagColorByKey.has(name.trim().toLowerCase());
|
const isKnown = tagColorByKey.has(name.trim().toLowerCase());
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={name}
|
key={name}
|
||||||
className="inline-flex max-w-full items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium leading-none"
|
className="inline-flex max-w-full items-center gap-1 rounded-full px-2 py-0.5 text-[10px] leading-none font-medium"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `${color}18`,
|
backgroundColor: `${color}18`,
|
||||||
color,
|
color,
|
||||||
@ -110,7 +118,11 @@ interface ImportModalProps {
|
|||||||
onImported: () => void;
|
onImported: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps) {
|
export function ImportModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onImported,
|
||||||
|
}: ImportModalProps) {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const { accountId, canEditSettings } = useAuth();
|
const { accountId, canEditSettings } = useAuth();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@ -119,7 +131,9 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
|||||||
const [parsedRows, setParsedRows] = useState<ParsedContactRow[]>([]);
|
const [parsedRows, setParsedRows] = useState<ParsedContactRow[]>([]);
|
||||||
const [hasTagsColumn, setHasTagsColumn] = useState(false);
|
const [hasTagsColumn, setHasTagsColumn] = useState(false);
|
||||||
const [hasCompanyColumn, setHasCompanyColumn] = useState(false);
|
const [hasCompanyColumn, setHasCompanyColumn] = useState(false);
|
||||||
const [tagColorByKey, setTagColorByKey] = useState<Map<string, string>>(new Map());
|
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;
|
||||||
@ -151,11 +165,16 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
|||||||
setResult(null);
|
setResult(null);
|
||||||
|
|
||||||
const text = await selected.text();
|
const text = await selected.text();
|
||||||
const { rows, hasTagsColumn: csvHasTags, hasCompanyColumn: csvHasCompany } =
|
const {
|
||||||
parseContactCsv(text);
|
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);
|
setHasTagsColumn(false);
|
||||||
setHasCompanyColumn(false);
|
setHasCompanyColumn(false);
|
||||||
@ -194,23 +213,29 @@ 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;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
|
||||||
|
// 1) De-dupe within the file by normalized phone (keep first).
|
||||||
const { unique, duplicates: inFileDupes } = dedupeByPhone(parsedRows);
|
const { unique, duplicates: inFileDupes } = dedupeByPhone(parsedRows);
|
||||||
skipped += inFileDupes;
|
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
|
const { data: existingRows } = await supabase
|
||||||
.from('contacts')
|
.from('contacts')
|
||||||
.select('phone_normalized')
|
.select('phone_normalized')
|
||||||
.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) => {
|
||||||
@ -221,15 +246,25 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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);
|
const allTagNames = toInsert.flatMap((row) => row.tagNames);
|
||||||
const { tagIdByKey, skippedNames } = await resolveImportTagIds(supabase, {
|
let tagIdByKey = new Map<string, string>();
|
||||||
accountId,
|
let skippedNames: string[] = [];
|
||||||
userId: user.id,
|
if (allTagNames.length > 0) {
|
||||||
tagNames: allTagNames,
|
({ tagIdByKey, skippedNames } = await resolveImportTagIds(supabase, {
|
||||||
canCreateTags: canEditSettings,
|
accountId,
|
||||||
});
|
userId: user.id,
|
||||||
|
tagNames: allTagNames,
|
||||||
|
canCreateTags: canEditSettings,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
const tagAssignments: ContactTagAssignment[] = [];
|
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
|
||||||
|
// 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) {
|
||||||
@ -249,6 +284,8 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
|||||||
.select('id');
|
.select('id');
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
// Retry individually so one bad/duplicate row doesn't sink
|
||||||
|
// the whole chunk.
|
||||||
for (let j = 0; j < rows.length; j++) {
|
for (let j = 0; j < rows.length; j++) {
|
||||||
const row = rows[j];
|
const row = rows[j];
|
||||||
const source = chunk[j];
|
const source = chunk[j];
|
||||||
@ -275,6 +312,9 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
|||||||
} else {
|
} else {
|
||||||
const inserted = data ?? [];
|
const inserted = data ?? [];
|
||||||
imported += inserted.length;
|
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++) {
|
for (let j = 0; j < inserted.length; j++) {
|
||||||
const source = chunk[j];
|
const source = chunk[j];
|
||||||
if (!source || source.tagNames.length === 0) continue;
|
if (!source || source.tagNames.length === 0) continue;
|
||||||
@ -286,20 +326,29 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagsAssigned = await assignImportedContactTags(
|
// 5) Wire tags onto the contacts we just created. Failure here must
|
||||||
supabase,
|
// not mask a successful contact import.
|
||||||
tagAssignments,
|
let tagsAssigned = 0;
|
||||||
tagIdByKey,
|
try {
|
||||||
);
|
tagsAssigned = await assignImportedContactTags(
|
||||||
|
supabase,
|
||||||
|
tagAssignments,
|
||||||
|
tagIdByKey
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
toast.warning('Contacts imported, but some tag assignments failed.');
|
||||||
|
}
|
||||||
|
|
||||||
setResult({ imported, skipped, failed, tagsAssigned });
|
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) {
|
if (tagsAssigned > 0) {
|
||||||
toast.success(
|
toast.success(
|
||||||
`${tagsAssigned} tag assignment${tagsAssigned !== 1 ? 's' : ''} applied`,
|
`${tagsAssigned} tag assignment${tagsAssigned !== 1 ? 's' : ''} applied`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (skippedNames.length > 0) {
|
if (skippedNames.length > 0) {
|
||||||
@ -307,14 +356,16 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
|||||||
const more =
|
const more =
|
||||||
skippedNames.length > 3 ? ` (+${skippedNames.length - 3} more)` : '';
|
skippedNames.length > 3 ? ` (+${skippedNames.length - 3} more)` : '';
|
||||||
toast.info(
|
toast.info(
|
||||||
`Unknown tags skipped (create them in Settings first): ${sample}${more}`,
|
`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';
|
||||||
@ -325,8 +376,12 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
|||||||
}
|
}
|
||||||
|
|
||||||
const preview = parsedRows.slice(0, PREVIEW_LIMIT);
|
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 =
|
const previewHasTags =
|
||||||
hasTagsColumn || preview.some((row) => row.tagNames.length > 0);
|
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 =
|
const previewHasCompany =
|
||||||
hasCompanyColumn && preview.some((row) => row.company?.trim());
|
hasCompanyColumn && preview.some((row) => row.company?.trim());
|
||||||
|
|
||||||
@ -346,8 +401,10 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
|||||||
<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">
|
<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">
|
||||||
<div className="shrink-0 space-y-4 border-b border-slate-800/80 px-6 pt-6 pb-5">
|
<div className="shrink-0 space-y-4 border-b border-slate-800/80 px-6 pt-6 pb-5">
|
||||||
<DialogHeader className="gap-1.5">
|
<DialogHeader className="gap-1.5">
|
||||||
<DialogTitle className="text-lg text-white">Import Contacts</DialogTitle>
|
<DialogTitle className="text-lg text-white">
|
||||||
<DialogDescription className="text-slate-400 leading-relaxed">
|
Import Contacts
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="leading-relaxed text-slate-400">
|
||||||
Upload a CSV with a required{' '}
|
Upload a CSV with a required{' '}
|
||||||
<code className="rounded bg-slate-800 px-1 py-0.5 text-[11px] text-slate-300">
|
<code className="rounded bg-slate-800 px-1 py-0.5 text-[11px] text-slate-300">
|
||||||
phone
|
phone
|
||||||
@ -377,19 +434,20 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') fileInputRef.current?.click();
|
if (e.key === 'Enter' || e.key === ' ')
|
||||||
|
fileInputRef.current?.click();
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border border-dashed p-5 transition-all',
|
'group flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border border-dashed p-5 transition-all',
|
||||||
file
|
file
|
||||||
? 'border-primary/35 bg-primary/[0.04]'
|
? 'border-primary/35 bg-primary/[0.04]'
|
||||||
: 'border-slate-700/80 bg-slate-950/40 hover:border-primary/40 hover:bg-slate-950/70',
|
: 'hover:border-primary/40 border-slate-700/80 bg-slate-950/40 hover:bg-slate-950/70'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{file ? (
|
{file ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex size-10 items-center justify-center rounded-lg bg-primary/15 ring-1 ring-primary/25">
|
<div className="bg-primary/15 ring-primary/25 flex size-10 items-center justify-center rounded-lg ring-1">
|
||||||
<FileText className="size-5 text-primary" />
|
<FileText className="text-primary size-5" />
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
className="max-w-full truncate px-2 text-sm font-medium text-slate-200"
|
className="max-w-full truncate px-2 text-sm font-medium text-slate-200"
|
||||||
@ -398,7 +456,8 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
|||||||
{truncateFilename(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">
|
<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
|
{parsedRows.length} row{parsedRows.length !== 1 ? 's' : ''}{' '}
|
||||||
|
ready
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@ -406,8 +465,12 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
|||||||
<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">
|
<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" />
|
<Upload className="size-5 text-slate-500 group-hover:text-slate-400" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-slate-400">Click to choose a CSV file</p>
|
<p className="text-sm text-slate-400">
|
||||||
<p className="text-[11px] text-slate-600">.csv up to your browser limit</p>
|
Click to choose a CSV file
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-slate-600">
|
||||||
|
.csv up to your browser limit
|
||||||
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -425,13 +488,13 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
|||||||
{preview.length > 0 && !result && (
|
{preview.length > 0 && !result && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
|
<p className="text-[11px] font-semibold tracking-[0.14em] text-slate-500 uppercase">
|
||||||
Preview · first {preview.length}
|
Preview · first {preview.length}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
{tagStats.rowsWithTags > 0 && (
|
{tagStats.rowsWithTags > 0 && (
|
||||||
<span className="inline-flex items-center gap-1 rounded-md bg-slate-800/90 px-2 py-0.5 text-[11px] text-slate-400">
|
<span className="inline-flex items-center gap-1 rounded-md bg-slate-800/90 px-2 py-0.5 text-[11px] text-slate-400">
|
||||||
<Tag className="size-3 text-primary/80" />
|
<Tag className="text-primary/80 size-3" />
|
||||||
{tagStats.unique} tag{tagStats.unique !== 1 ? 's' : ''} ·{' '}
|
{tagStats.unique} tag{tagStats.unique !== 1 ? 's' : ''} ·{' '}
|
||||||
{tagStats.rowsWithTags} contact
|
{tagStats.rowsWithTags} contact
|
||||||
{tagStats.rowsWithTags !== 1 ? 's' : ''}
|
{tagStats.rowsWithTags !== 1 ? 's' : ''}
|
||||||
@ -445,22 +508,22 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
|||||||
<table className="w-full min-w-[32rem] text-xs">
|
<table className="w-full min-w-[32rem] text-xs">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-slate-800 bg-slate-950/60">
|
<tr className="border-b border-slate-800 bg-slate-950/60">
|
||||||
<th className="whitespace-nowrap px-3 py-2 text-left font-medium text-slate-500">
|
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-slate-500">
|
||||||
Phone
|
Phone
|
||||||
</th>
|
</th>
|
||||||
<th className="whitespace-nowrap px-3 py-2 text-left font-medium text-slate-500">
|
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-slate-500">
|
||||||
Name
|
Name
|
||||||
</th>
|
</th>
|
||||||
<th className="whitespace-nowrap px-3 py-2 text-left font-medium text-slate-500">
|
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-slate-500">
|
||||||
Email
|
Email
|
||||||
</th>
|
</th>
|
||||||
{previewHasCompany && (
|
{previewHasCompany && (
|
||||||
<th className="whitespace-nowrap px-3 py-2 text-left font-medium text-slate-500">
|
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-slate-500">
|
||||||
Company
|
Company
|
||||||
</th>
|
</th>
|
||||||
)}
|
)}
|
||||||
{previewHasTags && (
|
{previewHasTags && (
|
||||||
<th className="whitespace-nowrap px-3 py-2 text-left font-medium text-slate-500">
|
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-slate-500">
|
||||||
Tags
|
Tags
|
||||||
</th>
|
</th>
|
||||||
)}
|
)}
|
||||||
@ -472,8 +535,12 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
|||||||
key={i}
|
key={i}
|
||||||
className="bg-slate-900/40 transition-colors hover:bg-slate-800/30"
|
className="bg-slate-900/40 transition-colors hover:bg-slate-800/30"
|
||||||
>
|
>
|
||||||
<td className="whitespace-nowrap px-3 py-2 text-slate-300">
|
<td className="px-3 py-2 whitespace-nowrap text-slate-300">
|
||||||
<PreviewCell value={row.phone} mono maxWidth="max-w-[7.5rem]" />
|
<PreviewCell
|
||||||
|
value={row.phone}
|
||||||
|
mono
|
||||||
|
maxWidth="max-w-[7.5rem]"
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-slate-200">
|
<td className="px-3 py-2 text-slate-200">
|
||||||
<PreviewCell
|
<PreviewCell
|
||||||
@ -524,7 +591,7 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
|||||||
<p className="text-sm font-medium text-white">Import complete</p>
|
<p className="text-sm font-medium text-white">Import complete</p>
|
||||||
<div className="mt-3 flex flex-wrap gap-3">
|
<div className="mt-3 flex flex-wrap gap-3">
|
||||||
{result.imported > 0 && (
|
{result.imported > 0 && (
|
||||||
<div className="flex items-center gap-1.5 text-sm text-primary">
|
<div className="text-primary flex items-center gap-1.5 text-sm">
|
||||||
<CheckCircle className="size-4 shrink-0" />
|
<CheckCircle className="size-4 shrink-0" />
|
||||||
{result.imported} imported
|
{result.imported} imported
|
||||||
</div>
|
</div>
|
||||||
@ -532,7 +599,8 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
|||||||
{result.tagsAssigned > 0 && (
|
{result.tagsAssigned > 0 && (
|
||||||
<div className="flex items-center gap-1.5 text-sm text-cyan-400">
|
<div className="flex items-center gap-1.5 text-sm text-cyan-400">
|
||||||
<CheckCircle className="size-4 shrink-0" />
|
<CheckCircle className="size-4 shrink-0" />
|
||||||
{result.tagsAssigned} tag{result.tagsAssigned !== 1 ? 's' : ''} assigned
|
{result.tagsAssigned} tag
|
||||||
|
{result.tagsAssigned !== 1 ? 's' : ''} assigned
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{result.skipped > 0 && (
|
{result.skipped > 0 && (
|
||||||
|
|||||||
@ -1,31 +1,31 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from 'vitest';
|
||||||
import { parseContactCsv, parseTagCell } from "./parse-contact-csv";
|
import { parseContactCsv, parseTagCell } from './parse-contact-csv';
|
||||||
|
|
||||||
describe("parseTagCell", () => {
|
describe('parseTagCell', () => {
|
||||||
it("splits comma-separated tags and trims whitespace", () => {
|
it('splits comma-separated tags and trims whitespace', () => {
|
||||||
expect(parseTagCell(" VIP , Lead , ")).toEqual(["VIP", "Lead"]);
|
expect(parseTagCell(' VIP , Lead , ')).toEqual(['VIP', 'Lead']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("splits semicolon-separated tags", () => {
|
it('splits semicolon-separated tags', () => {
|
||||||
expect(parseTagCell("VIP; Lead; Customer")).toEqual([
|
expect(parseTagCell('VIP; Lead; Customer')).toEqual([
|
||||||
"VIP",
|
'VIP',
|
||||||
"Lead",
|
'Lead',
|
||||||
"Customer",
|
'Customer',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("de-dupes case-insensitively", () => {
|
it('de-dupes case-insensitively', () => {
|
||||||
expect(parseTagCell("vip, VIP, Lead")).toEqual(["vip", "Lead"]);
|
expect(parseTagCell('vip, VIP, Lead')).toEqual(['vip', 'Lead']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns empty for blank values", () => {
|
it('returns empty for blank values', () => {
|
||||||
expect(parseTagCell("")).toEqual([]);
|
expect(parseTagCell('')).toEqual([]);
|
||||||
expect(parseTagCell(undefined)).toEqual([]);
|
expect(parseTagCell(undefined)).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("parseContactCsv", () => {
|
describe('parseContactCsv', () => {
|
||||||
it("parses optional tags column", () => {
|
it('parses optional tags column', () => {
|
||||||
const csv = `phone,name,tags
|
const csv = `phone,name,tags
|
||||||
+15551234567,Alice,"VIP, Lead"
|
+15551234567,Alice,"VIP, Lead"
|
||||||
+15559876543,Bob,Customer`;
|
+15559876543,Bob,Customer`;
|
||||||
@ -35,24 +35,24 @@ describe("parseContactCsv", () => {
|
|||||||
hasCompanyColumn: false,
|
hasCompanyColumn: false,
|
||||||
rows: [
|
rows: [
|
||||||
{
|
{
|
||||||
phone: "+15551234567",
|
phone: '+15551234567',
|
||||||
name: "Alice",
|
name: 'Alice',
|
||||||
email: undefined,
|
email: undefined,
|
||||||
company: undefined,
|
company: undefined,
|
||||||
tagNames: ["VIP", "Lead"],
|
tagNames: ['VIP', 'Lead'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
phone: "+15559876543",
|
phone: '+15559876543',
|
||||||
name: "Bob",
|
name: 'Bob',
|
||||||
email: undefined,
|
email: undefined,
|
||||||
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`;
|
||||||
|
|
||||||
@ -61,8 +61,8 @@ describe("parseContactCsv", () => {
|
|||||||
hasCompanyColumn: false,
|
hasCompanyColumn: false,
|
||||||
rows: [
|
rows: [
|
||||||
{
|
{
|
||||||
phone: "+15551234567",
|
phone: '+15551234567',
|
||||||
name: "Alice",
|
name: 'Alice',
|
||||||
email: undefined,
|
email: undefined,
|
||||||
company: undefined,
|
company: undefined,
|
||||||
tagNames: [],
|
tagNames: [],
|
||||||
|
|||||||
@ -46,18 +46,18 @@ export function parseContactCsv(text: string): ParseContactCsvResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
if (phoneIdx === -1) {
|
||||||
return { rows: [], hasTagsColumn: false, hasCompanyColumn: false };
|
return { rows: [], hasTagsColumn: false, hasCompanyColumn: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const nameIdx = headers.indexOf("name");
|
const nameIdx = headers.indexOf('name');
|
||||||
const emailIdx = headers.indexOf("email");
|
const emailIdx = headers.indexOf('email');
|
||||||
const companyIdx = headers.indexOf("company");
|
const companyIdx = headers.indexOf('company');
|
||||||
const tagsIdx = headers.indexOf("tags");
|
const tagsIdx = headers.indexOf('tags');
|
||||||
|
|
||||||
const rows: ParsedContactRow[] = [];
|
const rows: ParsedContactRow[] = [];
|
||||||
|
|
||||||
@ -66,25 +66,25 @@ export function parseContactCsv(text: string): ParseContactCsvResult {
|
|||||||
if (!line) continue;
|
if (!line) continue;
|
||||||
|
|
||||||
const values = parseCsvLine(line);
|
const values = parseCsvLine(line);
|
||||||
const phone = values[phoneIdx]?.replace(/["']/g, "").trim();
|
const phone = values[phoneIdx]?.replace(/["']/g, '').trim();
|
||||||
if (!phone) continue;
|
if (!phone) continue;
|
||||||
|
|
||||||
rows.push({
|
rows.push({
|
||||||
phone,
|
phone,
|
||||||
name:
|
name:
|
||||||
nameIdx >= 0
|
nameIdx >= 0
|
||||||
? values[nameIdx]?.replace(/["']/g, "").trim() || undefined
|
? values[nameIdx]?.replace(/["']/g, '').trim() || undefined
|
||||||
: undefined,
|
: undefined,
|
||||||
email:
|
email:
|
||||||
emailIdx >= 0
|
emailIdx >= 0
|
||||||
? values[emailIdx]?.replace(/["']/g, "").trim() || undefined
|
? values[emailIdx]?.replace(/["']/g, '').trim() || undefined
|
||||||
: undefined,
|
: undefined,
|
||||||
company:
|
company:
|
||||||
companyIdx >= 0
|
companyIdx >= 0
|
||||||
? values[companyIdx]?.replace(/["']/g, "").trim() || undefined
|
? values[companyIdx]?.replace(/["']/g, '').trim() || undefined
|
||||||
: undefined,
|
: undefined,
|
||||||
tagNames:
|
tagNames:
|
||||||
tagsIdx >= 0 ? parseTagCell(values[tagsIdx]?.replace(/["']/g, "")) : [],
|
tagsIdx >= 0 ? parseTagCell(values[tagsIdx]?.replace(/["']/g, '')) : [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,15 +98,15 @@ export function parseContactCsv(text: string): ParseContactCsvResult {
|
|||||||
/** Simple CSV line parse (handles quoted fields). */
|
/** Simple CSV line parse (handles quoted fields). */
|
||||||
function parseCsvLine(line: string): string[] {
|
function parseCsvLine(line: string): string[] {
|
||||||
const values: string[] = [];
|
const values: string[] = [];
|
||||||
let current = "";
|
let current = '';
|
||||||
let inQuotes = false;
|
let inQuotes = false;
|
||||||
|
|
||||||
for (const char of line) {
|
for (const char of line) {
|
||||||
if (char === '"') {
|
if (char === '"') {
|
||||||
inQuotes = !inQuotes;
|
inQuotes = !inQuotes;
|
||||||
} else if (char === "," && !inQuotes) {
|
} else if (char === ',' && !inQuotes) {
|
||||||
values.push(current.trim());
|
values.push(current.trim());
|
||||||
current = "";
|
current = '';
|
||||||
} else {
|
} else {
|
||||||
current += char;
|
current += char;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
const DEFAULT_TAG_COLOR = "#3b82f6";
|
const DEFAULT_TAG_COLOR = '#3b82f6';
|
||||||
|
|
||||||
export interface ResolveImportTagsResult {
|
export interface ResolveImportTagsResult {
|
||||||
/** Lowercase tag name → tag id. */
|
/** Lowercase tag name → tag id. */
|
||||||
@ -14,6 +14,9 @@ export interface ResolveImportTagsResult {
|
|||||||
* are matched case-insensitively. Missing names are created when
|
* are matched case-insensitively. Missing names are created when
|
||||||
* `canCreateTags` is true (admin+); otherwise they are reported in
|
* `canCreateTags` is true (admin+); otherwise they are reported in
|
||||||
* `skippedNames`.
|
* `skippedNames`.
|
||||||
|
*
|
||||||
|
* Unlike the manual contact form (existing tags only), import may
|
||||||
|
* auto-create missing tag definitions for admin+ callers.
|
||||||
*/
|
*/
|
||||||
export async function resolveImportTagIds(
|
export async function resolveImportTagIds(
|
||||||
supabase: SupabaseClient,
|
supabase: SupabaseClient,
|
||||||
@ -23,7 +26,7 @@ export async function resolveImportTagIds(
|
|||||||
tagNames: string[];
|
tagNames: string[];
|
||||||
canCreateTags: boolean;
|
canCreateTags: boolean;
|
||||||
defaultColor?: string;
|
defaultColor?: string;
|
||||||
},
|
}
|
||||||
): Promise<ResolveImportTagsResult> {
|
): Promise<ResolveImportTagsResult> {
|
||||||
const { accountId, userId, tagNames, canCreateTags } = params;
|
const { accountId, userId, tagNames, canCreateTags } = params;
|
||||||
const defaultColor = params.defaultColor ?? DEFAULT_TAG_COLOR;
|
const defaultColor = params.defaultColor ?? DEFAULT_TAG_COLOR;
|
||||||
@ -39,10 +42,14 @@ export async function resolveImportTagIds(
|
|||||||
uniqueNames.push(name);
|
uniqueNames.push(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (uniqueNames.length === 0) {
|
||||||
|
return { tagIdByKey: new Map(), skippedNames: [] };
|
||||||
|
}
|
||||||
|
|
||||||
const { data: existing, error: fetchError } = await supabase
|
const { data: existing, error: fetchError } = await supabase
|
||||||
.from("tags")
|
.from('tags')
|
||||||
.select("id, name")
|
.select('id, name')
|
||||||
.eq("account_id", accountId);
|
.eq('account_id', accountId);
|
||||||
|
|
||||||
if (fetchError) throw fetchError;
|
if (fetchError) throw fetchError;
|
||||||
|
|
||||||
@ -64,16 +71,16 @@ export async function resolveImportTagIds(
|
|||||||
|
|
||||||
if (toCreate.length > 0) {
|
if (toCreate.length > 0) {
|
||||||
const { data: created, error: createError } = await supabase
|
const { data: created, error: createError } = await supabase
|
||||||
.from("tags")
|
.from('tags')
|
||||||
.insert(
|
.insert(
|
||||||
toCreate.map((name) => ({
|
toCreate.map((name) => ({
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
account_id: accountId,
|
account_id: accountId,
|
||||||
name,
|
name,
|
||||||
color: defaultColor,
|
color: defaultColor,
|
||||||
})),
|
}))
|
||||||
)
|
)
|
||||||
.select("id, name");
|
.select('id, name');
|
||||||
|
|
||||||
if (createError) throw createError;
|
if (createError) throw createError;
|
||||||
|
|
||||||
@ -90,11 +97,17 @@ export interface ContactTagAssignment {
|
|||||||
tagNames: string[];
|
tagNames: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Insert contact_tags rows for imported contacts (ignores duplicates). */
|
/**
|
||||||
|
* 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(
|
export async function assignImportedContactTags(
|
||||||
supabase: SupabaseClient,
|
supabase: SupabaseClient,
|
||||||
assignments: ContactTagAssignment[],
|
assignments: ContactTagAssignment[],
|
||||||
tagIdByKey: Map<string, string>,
|
tagIdByKey: Map<string, string>
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const rows: { contact_id: string; tag_id: string }[] = [];
|
const rows: { contact_id: string; tag_id: string }[] = [];
|
||||||
|
|
||||||
@ -115,8 +128,8 @@ export async function assignImportedContactTags(
|
|||||||
|
|
||||||
for (let i = 0; i < rows.length; i += chunkSize) {
|
for (let i = 0; i < rows.length; i += chunkSize) {
|
||||||
const chunk = rows.slice(i, i + chunkSize);
|
const chunk = rows.slice(i, i + chunkSize);
|
||||||
const { error } = await supabase.from("contact_tags").upsert(chunk, {
|
const { error } = await supabase.from('contact_tags').upsert(chunk, {
|
||||||
onConflict: "contact_id,tag_id",
|
onConflict: 'contact_id,tag_id',
|
||||||
ignoreDuplicates: true,
|
ignoreDuplicates: true,
|
||||||
});
|
});
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user