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:
2026-02-25 09:13:51 -05:00
parent 32af961173
commit 45a267d787
21 changed files with 1015 additions and 128 deletions

84
frontend/src/utils/csv.ts Normal file
View 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);
}