Quality-of-life enhancements: CSV import/export, opening balances, interest rates, mobile UX
- CSV import/export for Units, Projects, and Vendors with match-on-name/number upsert - Cash Flow report toggle for Cash Only vs Cash + Investments - Per-account and bulk opening balance setting with as-of date - Interest rate field on normal accounts with estimated monthly/annual interest display - Mobile sidebar auto-close on navigation - Shared CSV parsing/export utility extracted to frontend/src/utils/csv.ts DB migration needed for existing tenants: ALTER TABLE accounts ADD COLUMN IF NOT EXISTS interest_rate DECIMAL(6,4); Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
84
frontend/src/utils/csv.ts
Normal file
84
frontend/src/utils/csv.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Shared CSV parsing and export utilities.
|
||||
*/
|
||||
|
||||
/** Parse CSV text into an array of objects keyed by lowercase header names. */
|
||||
export function parseCSV(text: string): Record<string, string>[] {
|
||||
const lines = text.trim().split('\n');
|
||||
if (lines.length < 2) return [];
|
||||
|
||||
// Strip leading * from headers (used to mark required fields)
|
||||
const headers = lines[0].split(',').map((h) => h.trim().replace(/^\*/, '').toLowerCase());
|
||||
const rows: Record<string, string>[] = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
|
||||
// Handle quoted fields containing commas
|
||||
const values: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
for (let j = 0; j < line.length; j++) {
|
||||
const ch = line[j];
|
||||
if (ch === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (ch === ',' && !inQuotes) {
|
||||
values.push(current.trim());
|
||||
current = '';
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
values.push(current.trim());
|
||||
|
||||
const row: Record<string, string> = {};
|
||||
headers.forEach((h, idx) => {
|
||||
row[h] = values[idx] || '';
|
||||
});
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Convert an array of objects to CSV text and trigger a browser download. */
|
||||
export function downloadCSV(rows: Record<string, any>[], headers: string[], filename: string) {
|
||||
const csvLines = [headers.join(',')];
|
||||
for (const row of rows) {
|
||||
const values = headers.map((h) => {
|
||||
const key = h.replace(/^\*/, '').toLowerCase();
|
||||
const val = row[key] ?? '';
|
||||
const str = String(val);
|
||||
// Quote values that contain commas or quotes
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
});
|
||||
csvLines.push(values.join(','));
|
||||
}
|
||||
|
||||
const blob = new Blob([csvLines.join('\n')], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** Download a blob response from the API as a file. */
|
||||
export function downloadBlob(data: Blob, filename: string, type = 'text/csv') {
|
||||
const blob = new Blob([data], { type });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
Reference in New Issue
Block a user