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:
@@ -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); }
|
||||
|
||||
|
||||
64
backend/src/modules/vendors/vendors.service.ts
vendored
64
backend/src/modules/vendors/vendors.service.ts
vendored
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user