feat(all-leads): remove stray Back button + wire Export CSV

- drop the header Back button (cosmetic; useNavigate + ArrowLeft icon
  removed with it)
- Export CSV now downloads the currently-filtered list — respects tab,
  search, campaign filter and active sort order. Headers: Phone /
  First/Last Name / Email / Source / Status / Campaign / Assigned
  Agent / First Contact / Last Contact / Created / Age (days).
- csv-utils: rowsToCsv + downloadCsv helpers. Values quoted, embedded
  quotes escaped, leading =/+/-/@ prefixed with a single quote to
  defeat CSV injection when opened in Excel. UTF-8 BOM on the blob
  so Excel recognises non-ASCII names/addresses.
This commit is contained in:
2026-04-15 18:56:47 +05:30
parent 9d09662f16
commit ab8b1b8463
2 changed files with 74 additions and 12 deletions

View File

@@ -1,5 +1,36 @@
export type CSVRow = Record<string, string>;
// 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<Record<string, unknown>>): 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[];

View File

@@ -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 }) => <FontAwesomeIcon icon={faArrowLeft} className={className} />;
const Download01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowDownToLine} className={className} />;
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={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<TabKey>('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 */}
<div className="flex shrink-0 items-center justify-between px-6 py-3 border-b border-secondary">
<div className="flex items-center gap-3">
<Button
onClick={() => navigate(-1)}
color="secondary"
size="sm"
iconLeading={ArrowLeft}
aria-label="Back"
/>
<Tabs selectedKey={tab} onSelectionChange={handleTabChange}>
<TabList items={tabItems} type="button-gray" size="sm">
{(item) => (
@@ -267,6 +297,7 @@ export const AllLeadsPage = () => {
size="sm"
color="secondary"
iconLeading={Download01}
onClick={handleExportCsv}
>
Export CSV
</Button>