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>;
|
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 = {
|
export type CSVParseResult = {
|
||||||
headers: string[];
|
headers: string[];
|
||||||
rows: CSVRow[];
|
rows: CSVRow[];
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useMemo, useState } 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 { 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 Download01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowDownToLine} className={className} />;
|
||||||
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
||||||
import { Button } from '@/components/base/buttons/button';
|
import { Button } from '@/components/base/buttons/button';
|
||||||
@@ -24,6 +23,8 @@ import { useLeads } from '@/hooks/use-leads';
|
|||||||
import { useAuth } from '@/providers/auth-provider';
|
import { useAuth } from '@/providers/auth-provider';
|
||||||
import { useData } from '@/providers/data-provider';
|
import { useData } from '@/providers/data-provider';
|
||||||
import { cx } from '@/utils/cx';
|
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';
|
import type { Lead, LeadSource, LeadStatus } from '@/types/entities';
|
||||||
|
|
||||||
type TabKey = 'new' | 'my-leads' | 'all';
|
type TabKey = 'new' | 'my-leads' | 'all';
|
||||||
@@ -38,7 +39,6 @@ const PAGE_SIZE = 15;
|
|||||||
|
|
||||||
export const AllLeadsPage = () => {
|
export const AllLeadsPage = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const initialSource = searchParams.get('source') as LeadSource | null;
|
const initialSource = searchParams.get('source') as LeadSource | null;
|
||||||
const [tab, setTab] = useState<TabKey>('new');
|
const [tab, setTab] = useState<TabKey>('new');
|
||||||
@@ -166,6 +166,44 @@ export const AllLeadsPage = () => {
|
|||||||
setSelectedIds([]);
|
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) => {
|
const handlePageChange = (page: number) => {
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
setSelectedIds([]);
|
setSelectedIds([]);
|
||||||
@@ -231,14 +269,6 @@ export const AllLeadsPage = () => {
|
|||||||
{/* Tabs + Controls row */}
|
{/* Tabs + Controls row */}
|
||||||
<div className="flex shrink-0 items-center justify-between px-6 py-3 border-b border-secondary">
|
<div className="flex shrink-0 items-center justify-between px-6 py-3 border-b border-secondary">
|
||||||
<div className="flex items-center gap-3">
|
<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}>
|
<Tabs selectedKey={tab} onSelectionChange={handleTabChange}>
|
||||||
<TabList items={tabItems} type="button-gray" size="sm">
|
<TabList items={tabItems} type="button-gray" size="sm">
|
||||||
{(item) => (
|
{(item) => (
|
||||||
@@ -267,6 +297,7 @@ export const AllLeadsPage = () => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
iconLeading={Download01}
|
iconLeading={Download01}
|
||||||
|
onClick={handleExportCsv}
|
||||||
>
|
>
|
||||||
Export CSV
|
Export CSV
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user