feat(deps): papaparse for expense CSV export

Replaces the hand-rolled `[fields].map(v => \`"\${v}"\`).join(',')`
pattern in expense-export.tsx with papaparse's Papa.unparse.

The previous version didn't handle:
- commas inside fields (would split rows mid-record)
- newlines inside fields (would terminate rows early)
- BOM for Excel-friendly encoding
- numeric/null normalization

Papa.unparse handles all of those + accepts a keyed-object row shape
that lets us define column order and get matching headers for free.

Verified: tsc clean, vitest 1315/1315.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 22:49:20 +02:00
parent 3aa1275ed7
commit 100beb9974
3 changed files with 51 additions and 30 deletions

View File

@@ -1,3 +1,4 @@
import Papa from 'papaparse';
import { eq, and, gte, lte, isNull, or, ilike } from 'drizzle-orm';
import { db } from '@/lib/db';
@@ -58,36 +59,36 @@ async function fetchAllExpenses(portId: string, query: ListExpensesInput) {
export async function exportCsv(portId: string, query: ListExpensesInput): Promise<string> {
const rows = await fetchAllExpenses(portId, query);
const headers = [
'Date',
'Establishment',
'Category',
'Amount',
'Currency',
'Amount USD',
'Payment Status',
'Payment Method',
'Description',
];
const csvRows = rows.map((r) => {
const date = r.expenseDate ? new Date(r.expenseDate).toISOString().split('T')[0] : '';
return [
date,
r.establishmentName ?? '',
r.category ?? '',
r.amount,
r.currency,
r.amountUsd ?? 'N/A',
r.paymentStatus ?? '',
r.paymentMethod ?? '',
(r.description ?? '').replace(/"/g, '""'),
]
.map((v) => `"${v}"`)
.join(',');
});
return [headers.join(','), ...csvRows].join('\n');
// papaparse handles all the CSV edge cases (commas in fields, embedded
// quotes, newlines, BOM) that the hand-rolled escape-and-quote version
// missed. Keyed objects let us define column order via `columns` and
// get matching headers for free.
return Papa.unparse(
rows.map((r) => ({
Date: r.expenseDate ? new Date(r.expenseDate).toISOString().split('T')[0] : '',
Establishment: r.establishmentName ?? '',
Category: r.category ?? '',
Amount: r.amount,
Currency: r.currency,
'Amount USD': r.amountUsd ?? 'N/A',
'Payment Status': r.paymentStatus ?? '',
'Payment Method': r.paymentMethod ?? '',
Description: r.description ?? '',
})),
{
columns: [
'Date',
'Establishment',
'Category',
'Amount',
'Currency',
'Amount USD',
'Payment Status',
'Payment Method',
'Description',
],
},
);
}
/**