Generated by Spark: Add export functionality to AdvancedDataTable for CSV/Excel downloads

This commit is contained in:
2026-02-05 16:00:00 +00:00
committed by GitHub
parent 244eb967a0
commit 2192acdde9
4 changed files with 172 additions and 29 deletions

View File

@@ -3,8 +3,11 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { useAdvancedTable, TableColumn } from '@/hooks/use-advanced-table'
import { CaretUp, CaretDown, CaretUpDown, MagnifyingGlass } from '@phosphor-icons/react'
import { useDataExport } from '@/hooks/use-data-export'
import { CaretUp, CaretDown, CaretUpDown, MagnifyingGlass, Export, FileCsv, FileXls, FileCode } from '@phosphor-icons/react'
import { toast } from 'sonner'
interface AdvancedDataTableProps<T> {
data: T[]
@@ -12,6 +15,8 @@ interface AdvancedDataTableProps<T> {
initialPageSize?: number
showSearch?: boolean
showPagination?: boolean
showExport?: boolean
exportFilename?: string
emptyMessage?: string
rowKey: keyof T
onRowClick?: (row: T) => void
@@ -24,6 +29,8 @@ export function AdvancedDataTable<T>({
initialPageSize = 20,
showSearch = true,
showPagination = true,
showExport = true,
exportFilename = 'export',
emptyMessage = 'No data available',
rowKey,
onRowClick,
@@ -44,6 +51,49 @@ export function AdvancedDataTable<T>({
filteredCount,
} = useAdvancedTable(data, columns, initialPageSize)
const { exportToCSV, exportToExcel, exportToJSON } = useDataExport()
const handleExport = (format: 'csv' | 'xlsx' | 'json') => {
try {
const exportData = items.length > 0 ? items : data
if (exportData.length === 0) {
toast.error('No data to export')
return
}
const exportColumns = columns.map(col => String(col.key))
const formattedData = exportData.map(row => {
const formattedRow: any = {}
columns.forEach(col => {
const key = String(col.key)
const value = row[col.key]
formattedRow[col.label] = value
})
return formattedRow
})
const options = {
filename: exportFilename,
includeHeaders: true,
}
if (format === 'csv') {
exportToCSV(formattedData, options)
toast.success(`Exported ${formattedData.length} rows to CSV`)
} else if (format === 'xlsx') {
exportToExcel(formattedData, options)
toast.success(`Exported ${formattedData.length} rows to Excel`)
} else if (format === 'json') {
exportToJSON(formattedData, options)
toast.success(`Exported ${formattedData.length} rows to JSON`)
}
} catch (error) {
toast.error('Failed to export data')
console.error('Export error:', error)
}
}
const getSortIcon = (columnKey: keyof T) => {
if (!state.sortConfig || state.sortConfig.key !== columnKey) {
return <CaretUpDown size={16} className="text-muted-foreground" />
@@ -56,27 +106,56 @@ export function AdvancedDataTable<T>({
return (
<div className="space-y-4">
{showSearch && (
{(showSearch || showExport) && (
<div className="flex items-center gap-4">
<div className="relative flex-1">
<MagnifyingGlass className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={18} />
<Input
type="text"
placeholder="Search..."
value={state.searchQuery}
onChange={(e) => actions.setSearch(e.target.value)}
className="pl-10"
/>
</div>
{state.searchQuery && (
<Button
variant="outline"
onClick={() => actions.setSearch('')}
size="sm"
>
Clear Search
</Button>
{showSearch && (
<>
<div className="relative flex-1">
<MagnifyingGlass className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={18} />
<Input
type="text"
placeholder="Search..."
value={state.searchQuery}
onChange={(e) => actions.setSearch(e.target.value)}
className="pl-10"
/>
</div>
{state.searchQuery && (
<Button
variant="outline"
onClick={() => actions.setSearch('')}
size="sm"
>
Clear Search
</Button>
)}
</>
)}
{showExport && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Export className="mr-2" size={18} />
Export
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleExport('csv')}>
<FileCsv className="mr-2" size={18} />
Export as CSV
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport('xlsx')}>
<FileXls className="mr-2" size={18} />
Export as Excel
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport('json')}>
<FileCode className="mr-2" size={18} />
Export as JSON
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)}

View File

@@ -337,13 +337,6 @@ export function BillingView({
placeholder={t('billing.searchPlaceholder')}
/>
<Stack direction="horizontal" spacing={4}>
<Button variant="outline">
<Download size={18} className="mr-2" />
{t('billing.export')}
</Button>
</Stack>
<AdvancedDataTable
data={filteredInvoices}
columns={invoiceColumns}
@@ -352,6 +345,8 @@ export function BillingView({
emptyMessage={t('billing.noInvoicesFound')}
showSearch={true}
showPagination={true}
showExport={true}
exportFilename={`invoices-${new Date().toISOString().split('T')[0]}`}
initialPageSize={20}
/>

View File

@@ -454,6 +454,8 @@ export function PayrollView({ timesheets, workers }: PayrollViewProps) {
emptyMessage={t('payroll.noPayrollRunsYet')}
showSearch={true}
showPagination={true}
showExport={true}
exportFilename={`payroll-runs-${new Date().toISOString().split('T')[0]}`}
initialPageSize={20}
/>
</TabsContent>

View File

@@ -67,6 +67,60 @@ export function useDataExport() {
[]
)
const exportToExcel = useCallback(
(data: any[], options: ExportOptions = {}) => {
const {
filename = 'export',
columns,
includeHeaders = true,
} = options
if (data.length === 0) {
throw new Error('No data to export')
}
const keys = columns || Object.keys(data[0])
let xml = '<?xml version="1.0"?><?mso-application progid="Excel.Sheet"?>'
xml += '<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet" '
xml += 'xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">'
xml += '<Worksheet ss:Name="Sheet1"><Table>'
if (includeHeaders) {
xml += '<Row>'
keys.forEach((key) => {
xml += `<Cell><Data ss:Type="String">${escapeXml(String(key))}</Data></Cell>`
})
xml += '</Row>'
}
data.forEach((row) => {
xml += '<Row>'
keys.forEach((key) => {
const value = row[key]
if (value === null || value === undefined) {
xml += '<Cell><Data ss:Type="String"></Data></Cell>'
} else if (typeof value === 'number') {
xml += `<Cell><Data ss:Type="Number">${value}</Data></Cell>`
} else {
xml += `<Cell><Data ss:Type="String">${escapeXml(String(value))}</Data></Cell>`
}
})
xml += '</Row>'
})
xml += '</Table></Worksheet></Workbook>'
const blob = new Blob([xml], { type: 'application/vnd.ms-excel' })
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = `${filename}.xls`
link.click()
URL.revokeObjectURL(link.href)
},
[]
)
const exportData = useCallback(
(data: any[], options: ExportOptions = {}) => {
const { format = 'csv' } = options
@@ -78,16 +132,29 @@ export function useDataExport() {
case 'json':
exportToJSON(data, options)
break
case 'xlsx':
exportToExcel(data, options)
break
default:
throw new Error(`Unsupported export format: ${format}`)
}
},
[exportToCSV, exportToJSON]
[exportToCSV, exportToJSON, exportToExcel]
)
return {
exportToCSV,
exportToJSON,
exportToExcel,
exportData,
}
}
function escapeXml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}