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

View File

@@ -1,5 +1,6 @@
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
import { Controller, Get, Post, Put, Body, Param, Res, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { Response } from 'express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { ProjectsService } from './projects.service';
@@ -13,12 +14,22 @@ export class ProjectsController {
@Get()
findAll() { return this.service.findAll(); }
@Get('export')
async exportCSV(@Res() res: Response) {
const csv = await this.service.exportCSV();
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="projects.csv"' });
res.send(csv);
}
@Get('planning')
findForPlanning() { return this.service.findForPlanning(); }
@Get(':id')
findOne(@Param('id') id: string) { return this.service.findOne(id); }
@Post('import')
importCSV(@Body() rows: any[]) { return this.service.importCSV(rows); }
@Post()
create(@Body() dto: any) { return this.service.create(dto); }

View File

@@ -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(