diff --git a/src/lib/csv-utils.ts b/src/lib/csv-utils.ts index 4484735..a75cce9 100644 --- a/src/lib/csv-utils.ts +++ b/src/lib/csv-utils.ts @@ -1,5 +1,36 @@ export type CSVRow = Record; +// CSV write-side. Quote every value and escape embedded quotes. Prefix +// ="+-@ with a single quote so Excel doesn't interpret them as formulas +// (classic CSV-injection vector on exports opened in spreadsheet apps). +const escapeCsvCell = (raw: unknown): string => { + const value = raw == null ? '' : String(raw); + const sanitized = /^[=+\-@]/.test(value) ? `'${value}` : value; + return `"${sanitized.replace(/"/g, '""')}"`; +}; + +export const rowsToCsv = (headers: string[], rows: Array>): string => { + const lines = [headers.map(escapeCsvCell).join(',')]; + for (const row of rows) { + lines.push(headers.map((h) => escapeCsvCell(row[h])).join(',')); + } + return lines.join('\r\n'); +}; + +export const downloadCsv = (filename: string, csv: string): void => { + // BOM prefix so Excel recognises UTF-8 for non-ASCII names/addresses. + const blob = new Blob(['\ufeff', csv], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +}; + + export type CSVParseResult = { headers: string[]; rows: CSVRow[]; diff --git a/src/pages/all-leads.tsx b/src/pages/all-leads.tsx index da9e30d..328da63 100644 --- a/src/pages/all-leads.tsx +++ b/src/pages/all-leads.tsx @@ -1,10 +1,9 @@ import type { FC } from 'react'; import { useMemo, useState } from 'react'; -import { useSearchParams, useNavigate } from 'react-router'; +import { useSearchParams } from 'react-router'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faArrowLeft, faArrowDownToLine, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons'; +import { faArrowDownToLine, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons'; -const ArrowLeft: FC<{ className?: string }> = ({ className }) => ; const Download01: FC<{ className?: string }> = ({ className }) => ; const SearchLg: FC<{ className?: string }> = ({ className }) => ; import { Button } from '@/components/base/buttons/button'; @@ -24,6 +23,8 @@ import { useLeads } from '@/hooks/use-leads'; import { useAuth } from '@/providers/auth-provider'; import { useData } from '@/providers/data-provider'; import { cx } from '@/utils/cx'; +import { rowsToCsv, downloadCsv } from '@/lib/csv-utils'; +import { notify } from '@/lib/toast'; import type { Lead, LeadSource, LeadStatus } from '@/types/entities'; type TabKey = 'new' | 'my-leads' | 'all'; @@ -38,7 +39,6 @@ const PAGE_SIZE = 15; export const AllLeadsPage = () => { const { user } = useAuth(); - const navigate = useNavigate(); const [searchParams] = useSearchParams(); const initialSource = searchParams.get('source') as LeadSource | null; const [tab, setTab] = useState('new'); @@ -166,6 +166,44 @@ export const AllLeadsPage = () => { setSelectedIds([]); }; + const handleExportCsv = () => { + // Export exactly what the user currently sees — same filters, same + // sort, same tab/campaign scope. Ignores pagination so the file + // contains every matching row, not just the current page. + if (displayLeads.length === 0) { + notify.error('Export CSV', 'No leads to export'); + return; + } + const headers = [ + 'Phone', 'First Name', 'Last Name', 'Email', + 'Source', 'Status', 'Campaign', 'Assigned Agent', + 'First Contact', 'Last Contact', 'Created', 'Age (days)', + ]; + const campaignNameById = new Map(campaigns.map((c) => [c.id, c.campaignName])); + const now = Date.now(); + const rows = displayLeads.map((l) => { + const createdMs = l.createdAt ? new Date(l.createdAt).getTime() : null; + return { + 'Phone': l.contactPhone?.[0]?.number ?? '', + 'First Name': l.contactName?.firstName ?? '', + 'Last Name': l.contactName?.lastName ?? '', + 'Email': l.contactEmail?.[0]?.address ?? '', + 'Source': l.leadSource ?? '', + 'Status': l.leadStatus ?? '', + 'Campaign': l.campaignId ? (campaignNameById.get(l.campaignId) ?? '') : '', + 'Assigned Agent': l.assignedAgent ?? '', + 'First Contact': l.firstContactedAt ?? '', + 'Last Contact': l.lastContactedAt ?? '', + 'Created': l.createdAt ?? '', + 'Age (days)': createdMs ? String(Math.floor((now - createdMs) / 86400000)) : '', + }; + }); + const csv = rowsToCsv(headers, rows); + const today = new Date().toISOString().slice(0, 10); + downloadCsv(`leads-${tab}-${today}.csv`, csv); + notify.success('Export CSV', `${rows.length} lead${rows.length === 1 ? '' : 's'} exported`); + }; + const handlePageChange = (page: number) => { setCurrentPage(page); setSelectedIds([]); @@ -231,14 +269,6 @@ export const AllLeadsPage = () => { {/* Tabs + Controls row */}
-