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:
@@ -176,6 +176,87 @@ export class ProjectsService {
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async exportCSV(): Promise<string> {
|
||||
const rows = await this.tenant.query(
|
||||
`SELECT name, description, category, estimated_cost, actual_cost, fund_source,
|
||||
useful_life_years, remaining_life_years, condition_rating,
|
||||
last_replacement_date, next_replacement_date, planned_date,
|
||||
target_year, target_month, status, priority, notes
|
||||
FROM projects WHERE is_active = true ORDER BY name`,
|
||||
);
|
||||
const headers = ['*name', 'description', '*category', '*estimated_cost', 'actual_cost', 'fund_source',
|
||||
'useful_life_years', 'remaining_life_years', 'condition_rating',
|
||||
'last_replacement_date', 'next_replacement_date', 'planned_date',
|
||||
'target_year', 'target_month', 'status', 'priority', 'notes'];
|
||||
const keys = headers.map(h => h.replace(/^\*/, ''));
|
||||
const lines = [headers.join(',')];
|
||||
for (const r of rows) {
|
||||
lines.push(keys.map((k) => {
|
||||
let v = r[k] ?? '';
|
||||
if (v instanceof Date) v = v.toISOString().split('T')[0];
|
||||
const s = String(v);
|
||||
return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s;
|
||||
}).join(','));
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async importCSV(rows: any[]) {
|
||||
let created = 0, updated = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
const name = (row.name || '').trim();
|
||||
if (!name) { errors.push(`Row ${i + 1}: missing name (required)`); continue; }
|
||||
if (!row.category) { errors.push(`Row ${i + 1}: missing category (required)`); continue; }
|
||||
if (!row.estimated_cost) { errors.push(`Row ${i + 1}: missing estimated_cost (required)`); continue; }
|
||||
|
||||
try {
|
||||
const existing = await this.tenant.query('SELECT id FROM projects WHERE name = $1 AND is_active = true', [name]);
|
||||
if (existing.length) {
|
||||
const sets: string[] = [];
|
||||
const params: any[] = [existing[0].id];
|
||||
let idx = 2;
|
||||
const fields = ['description', 'category', 'estimated_cost', 'actual_cost', 'fund_source',
|
||||
'useful_life_years', 'remaining_life_years', 'condition_rating',
|
||||
'last_replacement_date', 'next_replacement_date', 'planned_date',
|
||||
'target_year', 'target_month', 'status', 'priority', 'notes'];
|
||||
for (const f of fields) {
|
||||
if (row[f] !== undefined && row[f] !== '') {
|
||||
sets.push(`${f} = $${idx++}`);
|
||||
params.push(row[f]);
|
||||
}
|
||||
}
|
||||
if (sets.length) {
|
||||
sets.push('updated_at = NOW()');
|
||||
await this.tenant.query(`UPDATE projects SET ${sets.join(', ')} WHERE id = $1`, params);
|
||||
}
|
||||
updated++;
|
||||
} else {
|
||||
await this.tenant.query(
|
||||
`INSERT INTO projects (name, description, category, estimated_cost, actual_cost, fund_source,
|
||||
useful_life_years, remaining_life_years, condition_rating,
|
||||
last_replacement_date, next_replacement_date, planned_date,
|
||||
target_year, target_month, status, priority, notes)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)`,
|
||||
[name, row.description || null, row.category, parseFloat(row.estimated_cost) || 0,
|
||||
row.actual_cost || null, row.fund_source || 'reserve',
|
||||
row.useful_life_years || null, row.remaining_life_years || null,
|
||||
row.condition_rating || null, row.last_replacement_date || null,
|
||||
row.next_replacement_date || null, row.planned_date || null,
|
||||
row.target_year || null, row.target_month || null,
|
||||
row.status || 'planned', row.priority || 3, row.notes || null],
|
||||
);
|
||||
created++;
|
||||
}
|
||||
} catch (err: any) {
|
||||
errors.push(`Row ${i + 1} (${name}): ${err.message}`);
|
||||
}
|
||||
}
|
||||
return { imported: created + updated, created, updated, errors };
|
||||
}
|
||||
|
||||
async updatePlannedDate(id: string, planned_date: string) {
|
||||
await this.findOne(id);
|
||||
const rows = await this.tenant.query(
|
||||
|
||||
Reference in New Issue
Block a user