mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
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:
@@ -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[];
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user