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, Query, UseGuards } from '@nestjs/common';
import { Controller, Get, Post, Put, Body, Param, Query, Res, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { Response } from 'express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { VendorsService } from './vendors.service';
@@ -13,6 +14,13 @@ export class VendorsController {
@Get()
findAll() { return this.vendorsService.findAll(); }
@Get('export')
async exportCSV(@Res() res: Response) {
const csv = await this.vendorsService.exportCSV();
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="vendors.csv"' });
res.send(csv);
}
@Get('1099-data')
get1099Data(@Query('year') year: string) {
return this.vendorsService.get1099Data(parseInt(year) || new Date().getFullYear());
@@ -21,6 +29,9 @@ export class VendorsController {
@Get(':id')
findOne(@Param('id') id: string) { return this.vendorsService.findOne(id); }
@Post('import')
importCSV(@Body() rows: any[]) { return this.vendorsService.importCSV(rows); }
@Post()
create(@Body() dto: any) { return this.vendorsService.create(dto); }

View File

@@ -40,6 +40,70 @@ export class VendorsService {
return rows[0];
}
async exportCSV(): Promise<string> {
const rows = await this.tenant.query(
`SELECT name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible
FROM vendors WHERE is_active = true ORDER BY name`,
);
const headers = ['name', 'contact_name', 'email', 'phone', 'address_line1', 'city', 'state', 'zip_code', 'tax_id', 'is_1099_eligible'];
const lines = [headers.join(',')];
for (const r of rows) {
lines.push(headers.map((h) => {
const v = r[h] ?? '';
const s = String(v);
return s.includes(',') || s.includes('"') ? `"${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; }
try {
const existing = await this.tenant.query('SELECT id FROM vendors WHERE name = $1 AND is_active = true', [name]);
if (existing.length) {
await this.tenant.query(
`UPDATE vendors SET
contact_name = COALESCE(NULLIF($2, ''), contact_name),
email = COALESCE(NULLIF($3, ''), email),
phone = COALESCE(NULLIF($4, ''), phone),
address_line1 = COALESCE(NULLIF($5, ''), address_line1),
city = COALESCE(NULLIF($6, ''), city),
state = COALESCE(NULLIF($7, ''), state),
zip_code = COALESCE(NULLIF($8, ''), zip_code),
tax_id = COALESCE(NULLIF($9, ''), tax_id),
is_1099_eligible = COALESCE(NULLIF($10, '')::boolean, is_1099_eligible),
updated_at = NOW()
WHERE id = $1`,
[existing[0].id, row.contact_name, row.email, row.phone, row.address_line1,
row.city, row.state, row.zip_code, row.tax_id, row.is_1099_eligible],
);
updated++;
} else {
await this.tenant.query(
`INSERT INTO vendors (name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[name, row.contact_name || null, row.email || null, row.phone || null,
row.address_line1 || null, row.city || null, row.state || null,
row.zip_code || null, row.tax_id || null,
row.is_1099_eligible === 'true' || row.is_1099_eligible === true || false],
);
created++;
}
} catch (err: any) {
errors.push(`Row ${i + 1} (${name}): ${err.message}`);
}
}
return { imported: created + updated, created, updated, errors };
}
async get1099Data(year: number) {
return this.tenant.query(`
SELECT v.*, COALESCE(SUM(p_amounts.amount), 0) as total_paid