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

@@ -88,6 +88,7 @@
"nodemailer": "^8.0.7", "nodemailer": "^8.0.7",
"openai": "^6.37.0", "openai": "^6.37.0",
"p-limit": "^7.3.0", "p-limit": "^7.3.0",
"papaparse": "^5.5.3",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pdfkit": "^0.18.0", "pdfkit": "^0.18.0",
"pino": "^10.3.1", "pino": "^10.3.1",
@@ -130,6 +131,7 @@
"@types/mailparser": "^3.4.6", "@types/mailparser": "^3.4.6",
"@types/node": "^20.19.0", "@types/node": "^20.19.0",
"@types/nodemailer": "^8.0.0", "@types/nodemailer": "^8.0.0",
"@types/papaparse": "^5.5.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",

18
pnpm-lock.yaml generated
View File

@@ -190,6 +190,9 @@ importers:
p-limit: p-limit:
specifier: ^7.3.0 specifier: ^7.3.0
version: 7.3.0 version: 7.3.0
papaparse:
specifier: ^5.5.3
version: 5.5.3
pdf-lib: pdf-lib:
specifier: ^1.17.1 specifier: ^1.17.1
version: 1.17.1 version: 1.17.1
@@ -311,6 +314,9 @@ importers:
'@types/nodemailer': '@types/nodemailer':
specifier: ^8.0.0 specifier: ^8.0.0
version: 8.0.0 version: 8.0.0
'@types/papaparse':
specifier: ^5.5.2
version: 5.5.2
'@types/react': '@types/react':
specifier: ^19.2.14 specifier: ^19.2.14
version: 19.2.14 version: 19.2.14
@@ -3147,6 +3153,9 @@ packages:
'@types/nodemailer@8.0.0': '@types/nodemailer@8.0.0':
resolution: {integrity: sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==} resolution: {integrity: sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==}
'@types/papaparse@5.5.2':
resolution: {integrity: sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==}
'@types/parse-json@4.0.2': '@types/parse-json@4.0.2':
resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==}
@@ -5776,6 +5785,9 @@ packages:
pako@1.0.11: pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
papaparse@5.5.3:
resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==}
parent-module@1.0.1: parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -9797,6 +9809,10 @@ snapshots:
dependencies: dependencies:
'@types/node': 20.19.41 '@types/node': 20.19.41
'@types/papaparse@5.5.2':
dependencies:
'@types/node': 20.19.41
'@types/parse-json@4.0.2': {} '@types/parse-json@4.0.2': {}
'@types/pdfkit@0.17.6': '@types/pdfkit@0.17.6':
@@ -12516,6 +12532,8 @@ snapshots:
pako@1.0.11: {} pako@1.0.11: {}
papaparse@5.5.3: {}
parent-module@1.0.1: parent-module@1.0.1:
dependencies: dependencies:
callsites: 3.1.0 callsites: 3.1.0

View File

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