diff --git a/backend/src/database/tenant-schema.service.ts b/backend/src/database/tenant-schema.service.ts index 161bd3b..ac4d4f4 100644 --- a/backend/src/database/tenant-schema.service.ts +++ b/backend/src/database/tenant-schema.service.ts @@ -45,6 +45,7 @@ export class TenantSchemaService { is_active BOOLEAN DEFAULT TRUE, is_system BOOLEAN DEFAULT FALSE, is_primary BOOLEAN DEFAULT FALSE, + interest_rate DECIMAL(6,4), balance DECIMAL(15,2) DEFAULT 0.00, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), diff --git a/backend/src/modules/accounts/accounts.controller.ts b/backend/src/modules/accounts/accounts.controller.ts index 6366553..038ce4f 100644 --- a/backend/src/modules/accounts/accounts.controller.ts +++ b/backend/src/modules/accounts/accounts.controller.ts @@ -32,6 +32,23 @@ export class AccountsController { return this.accountsService.setPrimary(id); } + @Post('bulk-opening-balances') + @ApiOperation({ summary: 'Set opening balances for multiple accounts' }) + bulkSetOpeningBalances( + @Body() dto: { asOfDate: string; entries: { accountId: string; targetBalance: number }[] }, + ) { + return this.accountsService.bulkSetOpeningBalances(dto); + } + + @Post(':id/opening-balance') + @ApiOperation({ summary: 'Set opening balance for an account at a specific date' }) + setOpeningBalance( + @Param('id') id: string, + @Body() dto: { targetBalance: number; asOfDate: string; memo?: string }, + ) { + return this.accountsService.setOpeningBalance(id, dto); + } + @Post(':id/adjust-balance') @ApiOperation({ summary: 'Adjust account balance to a target amount' }) adjustBalance( diff --git a/backend/src/modules/accounts/accounts.service.ts b/backend/src/modules/accounts/accounts.service.ts index 02901b6..02dccc6 100644 --- a/backend/src/modules/accounts/accounts.service.ts +++ b/backend/src/modules/accounts/accounts.service.ts @@ -55,8 +55,8 @@ export class AccountsService { } const insertResult = await this.tenant.query( - `INSERT INTO accounts (account_number, name, description, account_type, fund_type, parent_account_id, is_1099_reportable) - VALUES ($1, $2, $3, $4, $5, $6, $7) + `INSERT INTO accounts (account_number, name, description, account_type, fund_type, parent_account_id, is_1099_reportable, interest_rate) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`, [ dto.accountNumber, @@ -66,6 +66,7 @@ export class AccountsService { dto.fundType, dto.parentAccountId || null, dto.is1099Reportable || false, + dto.interestRate || null, ], ); const accountId = Array.isArray(insertResult[0]) ? insertResult[0][0].id : insertResult[0].id; @@ -172,6 +173,7 @@ export class AccountsService { if (dto.is1099Reportable !== undefined) { sets.push(`is_1099_reportable = $${idx++}`); params.push(dto.is1099Reportable); } if (dto.isActive !== undefined) { sets.push(`is_active = $${idx++}`); params.push(dto.isActive); } if (dto.isPrimary !== undefined) { sets.push(`is_primary = $${idx++}`); params.push(dto.isPrimary); } + if (dto.interestRate !== undefined) { sets.push(`interest_rate = $${idx++}`); params.push(dto.interestRate); } if (!sets.length) return account; @@ -204,7 +206,30 @@ export class AccountsService { return this.findOne(id); } - async adjustBalance(id: string, dto: { targetBalance: number; asOfDate: string; memo?: string }) { + async setOpeningBalance(id: string, dto: { targetBalance: number; asOfDate: string; memo?: string }) { + return this.adjustBalance(id, dto, 'opening_balance'); + } + + async bulkSetOpeningBalances(dto: { asOfDate: string; entries: { accountId: string; targetBalance: number }[] }) { + let processed = 0, skipped = 0; + const errors: string[] = []; + + for (const entry of dto.entries) { + try { + const result = await this.setOpeningBalance(entry.accountId, { + targetBalance: entry.targetBalance, + asOfDate: dto.asOfDate, + }); + if (result.message === 'No adjustment needed') skipped++; + else processed++; + } catch (err: any) { + errors.push(`${entry.accountId}: ${err.message}`); + } + } + return { processed, skipped, errors }; + } + + async adjustBalance(id: string, dto: { targetBalance: number; asOfDate: string; memo?: string }, entryType = 'adjustment') { const account = await this.findOne(id); // Get current balance for this account using trial balance logic @@ -282,16 +307,20 @@ export class AccountsService { const equityDebit = targetCredit > 0 ? targetCredit : 0; const equityCredit = targetDebit > 0 ? targetDebit : 0; - const memo = dto.memo || `Balance adjustment to ${dto.targetBalance}`; + const defaultMemo = entryType === 'opening_balance' + ? `Opening balance for ${account.name}` + : `Balance adjustment to ${dto.targetBalance}`; + const memo = dto.memo || defaultMemo; // Create journal entry const jeRows = await this.tenant.query( `INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by) - VALUES ($1, $2, 'adjustment', $3, true, NOW(), $4) + VALUES ($1, $2, $3, $4, true, NOW(), $5) RETURNING *`, [ dto.asOfDate, memo, + entryType, fiscalPeriodId, '00000000-0000-0000-0000-000000000000', ], diff --git a/backend/src/modules/accounts/dto/create-account.dto.ts b/backend/src/modules/accounts/dto/create-account.dto.ts index 2ad013e..b2455f7 100644 --- a/backend/src/modules/accounts/dto/create-account.dto.ts +++ b/backend/src/modules/accounts/dto/create-account.dto.ts @@ -36,4 +36,8 @@ export class CreateAccountDto { @ApiProperty({ required: false, default: 0 }) @IsOptional() initialBalance?: number; + + @ApiProperty({ required: false, description: 'Annual interest rate as a percentage' }) + @IsOptional() + interestRate?: number; } diff --git a/backend/src/modules/accounts/dto/update-account.dto.ts b/backend/src/modules/accounts/dto/update-account.dto.ts index 1c3b8d7..6d60919 100644 --- a/backend/src/modules/accounts/dto/update-account.dto.ts +++ b/backend/src/modules/accounts/dto/update-account.dto.ts @@ -41,4 +41,8 @@ export class UpdateAccountDto { @IsBoolean() @IsOptional() isPrimary?: boolean; + + @ApiProperty({ required: false, description: 'Annual interest rate as a percentage' }) + @IsOptional() + interestRate?: number; } diff --git a/backend/src/modules/projects/projects.controller.ts b/backend/src/modules/projects/projects.controller.ts index 29c1e85..2d8c2c9 100644 --- a/backend/src/modules/projects/projects.controller.ts +++ b/backend/src/modules/projects/projects.controller.ts @@ -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); } diff --git a/backend/src/modules/projects/projects.service.ts b/backend/src/modules/projects/projects.service.ts index 90f412e..f90fb61 100644 --- a/backend/src/modules/projects/projects.service.ts +++ b/backend/src/modules/projects/projects.service.ts @@ -176,6 +176,87 @@ export class ProjectsService { return rows[0]; } + async exportCSV(): Promise { + 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( diff --git a/backend/src/modules/reports/reports.controller.ts b/backend/src/modules/reports/reports.controller.ts index d427fe0..cedeadc 100644 --- a/backend/src/modules/reports/reports.controller.ts +++ b/backend/src/modules/reports/reports.controller.ts @@ -29,11 +29,17 @@ export class ReportsController { } @Get('cash-flow') - getCashFlowStatement(@Query('from') from?: string, @Query('to') to?: string) { + getCashFlowStatement( + @Query('from') from?: string, + @Query('to') to?: string, + @Query('includeInvestments') includeInvestments?: string, + ) { const now = new Date(); const defaultFrom = `${now.getFullYear()}-01-01`; const defaultTo = now.toISOString().split('T')[0]; - return this.reportsService.getCashFlowStatement(from || defaultFrom, to || defaultTo); + return this.reportsService.getCashFlowStatement( + from || defaultFrom, to || defaultTo, includeInvestments === 'true', + ); } @Get('aging') diff --git a/backend/src/modules/reports/reports.service.ts b/backend/src/modules/reports/reports.service.ts index 0222971..f8addc1 100644 --- a/backend/src/modules/reports/reports.service.ts +++ b/backend/src/modules/reports/reports.service.ts @@ -178,7 +178,7 @@ export class ReportsService { return { nodes, links, total_income: totalIncome, total_expenses: totalExpenses, net_cash_flow: netFlow }; } - async getCashFlowStatement(from: string, to: string) { + async getCashFlowStatement(from: string, to: string, includeInvestments = false) { // Operating activities: income minus expenses from journal entries const operating = await this.tenant.query(` SELECT a.name, a.account_type, @@ -222,6 +222,11 @@ export class ReportsService { ORDER BY a.name `, [from, to]); + // Asset filter: cash-only vs cash + investment accounts + const assetFilter = includeInvestments + ? `a.account_type = 'asset'` + : `a.account_type = 'asset' AND a.name LIKE '%Cash%'`; + // Cash beginning and ending balances const beginCash = await this.tenant.query(` SELECT COALESCE(SUM(sub.bal), 0) as balance FROM ( @@ -231,7 +236,7 @@ export class ReportsService { LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false AND je.entry_date < $1 - WHERE a.account_type = 'asset' AND a.name LIKE '%Cash%' AND a.is_active = true + WHERE ${assetFilter} AND a.is_active = true GROUP BY a.id ) sub `, [from]); @@ -244,11 +249,20 @@ export class ReportsService { LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false AND je.entry_date <= $1 - WHERE a.account_type = 'asset' AND a.name LIKE '%Cash%' AND a.is_active = true + WHERE ${assetFilter} AND a.is_active = true GROUP BY a.id ) sub `, [to]); + // Include investment_accounts table balances when requested + let investmentBalance = 0; + if (includeInvestments) { + const inv = await this.tenant.query( + `SELECT COALESCE(SUM(current_value), 0) as total FROM investment_accounts WHERE is_active = true`, + ); + investmentBalance = parseFloat(inv[0]?.total || '0'); + } + const operatingItems = operating.map((r: any) => ({ name: r.name, type: r.account_type, amount: parseFloat(r.amount), })); @@ -258,11 +272,12 @@ export class ReportsService { const totalOperating = operatingItems.reduce((s: number, r: any) => s + r.amount, 0); const totalReserve = reserveItems.reduce((s: number, r: any) => s + r.amount, 0); - const beginningBalance = parseFloat(beginCash[0]?.balance || '0'); - const endingBalance = parseFloat(endCash[0]?.balance || '0'); + const beginningBalance = parseFloat(beginCash[0]?.balance || '0') + (includeInvestments ? investmentBalance : 0); + const endingBalance = parseFloat(endCash[0]?.balance || '0') + investmentBalance; return { from, to, + include_investments: includeInvestments, operating_activities: operatingItems, reserve_activities: reserveItems, total_operating: totalOperating.toFixed(2), @@ -270,6 +285,7 @@ export class ReportsService { net_cash_change: (totalOperating + totalReserve).toFixed(2), beginning_cash: beginningBalance.toFixed(2), ending_cash: endingBalance.toFixed(2), + investment_balance: investmentBalance.toFixed(2), }; } diff --git a/backend/src/modules/units/units.controller.ts b/backend/src/modules/units/units.controller.ts index c4db83c..68f7961 100644 --- a/backend/src/modules/units/units.controller.ts +++ b/backend/src/modules/units/units.controller.ts @@ -1,5 +1,6 @@ -import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common'; +import { Controller, Get, Post, Put, Delete, 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 { UnitsService } from './units.service'; @@ -13,9 +14,19 @@ export class UnitsController { @Get() findAll() { return this.unitsService.findAll(); } + @Get('export') + async exportCSV(@Res() res: Response) { + const csv = await this.unitsService.exportCSV(); + res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="units.csv"' }); + res.send(csv); + } + @Get(':id') findOne(@Param('id') id: string) { return this.unitsService.findOne(id); } + @Post('import') + importCSV(@Body() rows: any[]) { return this.unitsService.importCSV(rows); } + @Post() create(@Body() dto: any) { return this.unitsService.create(dto); } diff --git a/backend/src/modules/units/units.service.ts b/backend/src/modules/units/units.service.ts index 9074e9f..842d9df 100644 --- a/backend/src/modules/units/units.service.ts +++ b/backend/src/modules/units/units.service.ts @@ -73,6 +73,90 @@ export class UnitsService { return rows[0]; } + async exportCSV(): Promise { + const rows = await this.tenant.query( + `SELECT unit_number, address_line1, city, state, zip_code, square_footage, lot_size, + owner_name, owner_email, owner_phone, is_rented, monthly_assessment, status + FROM units WHERE status != 'inactive' ORDER BY unit_number`, + ); + const headers = ['unit_number', 'address_line1', 'city', 'state', 'zip_code', 'square_footage', 'lot_size', + 'owner_name', 'owner_email', 'owner_phone', 'is_rented', 'monthly_assessment', 'status']; + 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[] = []; + + // Resolve default assessment group for new units + let defaultGroupId: string | null = null; + const defaultGroup = await this.tenant.query( + 'SELECT id FROM assessment_groups WHERE is_default = true AND is_active = true LIMIT 1', + ); + if (defaultGroup.length) defaultGroupId = defaultGroup[0].id; + else { + const anyGroup = await this.tenant.query('SELECT id FROM assessment_groups WHERE is_active = true LIMIT 1'); + if (anyGroup.length) defaultGroupId = anyGroup[0].id; + } + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const unitNumber = (row.unit_number || '').trim(); + if (!unitNumber) { errors.push(`Row ${i + 1}: missing unit_number`); continue; } + + try { + const existing = await this.tenant.query('SELECT id FROM units WHERE unit_number = $1', [unitNumber]); + if (existing.length) { + await this.tenant.query( + `UPDATE units SET + address_line1 = COALESCE(NULLIF($2, ''), address_line1), + city = COALESCE(NULLIF($3, ''), city), + state = COALESCE(NULLIF($4, ''), state), + zip_code = COALESCE(NULLIF($5, ''), zip_code), + square_footage = COALESCE(NULLIF($6, '')::integer, square_footage), + lot_size = COALESCE(NULLIF($7, '')::decimal, lot_size), + owner_name = COALESCE(NULLIF($8, ''), owner_name), + owner_email = COALESCE(NULLIF($9, ''), owner_email), + owner_phone = COALESCE(NULLIF($10, ''), owner_phone), + is_rented = COALESCE(NULLIF($11, '')::boolean, is_rented), + monthly_assessment = COALESCE(NULLIF($12, '')::decimal, monthly_assessment), + status = COALESCE(NULLIF($13, ''), status), + updated_at = NOW() + WHERE id = $1`, + [existing[0].id, row.address_line1, row.city, row.state, row.zip_code, + row.square_footage, row.lot_size, row.owner_name, row.owner_email, + row.owner_phone, row.is_rented, row.monthly_assessment, row.status], + ); + updated++; + } else { + if (!defaultGroupId) { errors.push(`Row ${i + 1}: no assessment group available for new unit`); continue; } + await this.tenant.query( + `INSERT INTO units (unit_number, address_line1, city, state, zip_code, square_footage, lot_size, + owner_name, owner_email, owner_phone, is_rented, monthly_assessment, status, assessment_group_id) + VALUES ($1, $2, $3, $4, $5, NULLIF($6, '')::integer, NULLIF($7, '')::decimal, + $8, $9, $10, COALESCE(NULLIF($11, '')::boolean, false), COALESCE(NULLIF($12, '')::decimal, 0), COALESCE(NULLIF($13, ''), 'active'), $14)`, + [unitNumber, row.address_line1 || null, row.city || null, row.state || null, row.zip_code || null, + row.square_footage || '', row.lot_size || '', row.owner_name || null, row.owner_email || null, + row.owner_phone || null, row.is_rented || '', row.monthly_assessment || '', row.status || '', defaultGroupId], + ); + created++; + } + } catch (err: any) { + errors.push(`Row ${i + 1} (${unitNumber}): ${err.message}`); + } + } + + return { imported: created + updated, created, updated, errors }; + } + async delete(id: string) { await this.findOne(id); diff --git a/backend/src/modules/vendors/vendors.controller.ts b/backend/src/modules/vendors/vendors.controller.ts index 42fd1dd..f1a1d97 100644 --- a/backend/src/modules/vendors/vendors.controller.ts +++ b/backend/src/modules/vendors/vendors.controller.ts @@ -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); } diff --git a/backend/src/modules/vendors/vendors.service.ts b/backend/src/modules/vendors/vendors.service.ts index 84ff8da..643444b 100644 --- a/backend/src/modules/vendors/vendors.service.ts +++ b/backend/src/modules/vendors/vendors.service.ts @@ -40,6 +40,70 @@ export class VendorsService { return rows[0]; } + async exportCSV(): Promise { + 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 diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 4c3ff14..92d7dfc 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -14,7 +14,7 @@ import { Sidebar } from './Sidebar'; import logoSrc from '../../assets/logo.svg'; export function AppLayout() { - const [opened, { toggle }] = useDisclosure(); + const [opened, { toggle, close }] = useDisclosure(); const { user, currentOrg, logout } = useAuthStore(); const navigate = useNavigate(); @@ -98,7 +98,7 @@ export function AppLayout() { - + diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 0f98f1e..830ef43 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -77,11 +77,20 @@ const navSections = [ }, ]; -export function Sidebar() { +interface SidebarProps { + onNavigate?: () => void; +} + +export function Sidebar({ onNavigate }: SidebarProps) { const navigate = useNavigate(); const location = useLocation(); const user = useAuthStore((s) => s.user); + const go = (path: string) => { + navigate(path); + onNavigate?.(); + }; + return ( {navSections.map((section, sIdx) => ( @@ -109,7 +118,7 @@ export function Sidebar() { key={child.path} label={child.label} active={location.pathname === child.path} - onClick={() => navigate(child.path)} + onClick={() => go(child.path)} /> ))} @@ -119,7 +128,7 @@ export function Sidebar() { label={item.label} leftSection={} active={location.pathname === item.path} - onClick={() => navigate(item.path!)} + onClick={() => go(item.path!)} /> ), )} @@ -136,7 +145,7 @@ export function Sidebar() { label="Admin Panel" leftSection={} active={location.pathname === '/admin'} - onClick={() => navigate('/admin')} + onClick={() => go('/admin')} color="red" /> diff --git a/frontend/src/pages/accounts/AccountsPage.tsx b/frontend/src/pages/accounts/AccountsPage.tsx index baa4cb9..c2a8321 100644 --- a/frontend/src/pages/accounts/AccountsPage.tsx +++ b/frontend/src/pages/accounts/AccountsPage.tsx @@ -37,6 +37,7 @@ import { IconStarFilled, IconAdjustments, IconInfoCircle, + IconCurrencyDollar, } from '@tabler/icons-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '../../services/api'; @@ -71,6 +72,7 @@ interface Account { is_system: boolean; is_primary: boolean; balance: string; + interest_rate: string | null; } interface Investment { @@ -281,6 +283,63 @@ export function AccountsPage() { }, }); + // ── Opening balance state + mutations ── + const [obOpened, { open: openOB, close: closeOB }] = useDisclosure(false); + const [bulkOBOpened, { open: openBulkOB, close: closeBulkOB }] = useDisclosure(false); + const [obAccount, setOBAccount] = useState(null); + const [bulkOBDate, setBulkOBDate] = useState(new Date()); + const [bulkOBEntries, setBulkOBEntries] = useState>({}); + + const obForm = useForm({ + initialValues: { + targetBalance: 0, + asOfDate: new Date() as Date | null, + memo: '', + }, + validate: { + targetBalance: (v) => (v !== undefined && v !== null ? null : 'Required'), + asOfDate: (v) => (v ? null : 'Required'), + }, + }); + + const openingBalanceMutation = useMutation({ + mutationFn: (values: { accountId: string; targetBalance: number; asOfDate: string; memo: string }) => + api.post(`/accounts/${values.accountId}/opening-balance`, { + targetBalance: values.targetBalance, + asOfDate: values.asOfDate, + memo: values.memo, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['accounts'] }); + queryClient.invalidateQueries({ queryKey: ['trial-balance'] }); + notifications.show({ message: 'Opening balance set successfully', color: 'green' }); + closeOB(); + setOBAccount(null); + obForm.reset(); + }, + onError: (err: any) => { + notifications.show({ message: err.response?.data?.message || 'Error setting opening balance', color: 'red' }); + }, + }); + + const bulkOBMutation = useMutation({ + mutationFn: (dto: { asOfDate: string; entries: { accountId: string; targetBalance: number }[] }) => + api.post('/accounts/bulk-opening-balances', dto), + onSuccess: (res: any) => { + queryClient.invalidateQueries({ queryKey: ['accounts'] }); + queryClient.invalidateQueries({ queryKey: ['trial-balance'] }); + const d = res.data; + let msg = `Opening balances: ${d.processed} updated, ${d.skipped} unchanged`; + if (d.errors?.length) msg += `. ${d.errors.length} error(s)`; + notifications.show({ message: msg, color: d.errors?.length ? 'yellow' : 'green', autoClose: 10000 }); + closeBulkOB(); + setBulkOBEntries({}); + }, + onError: (err: any) => { + notifications.show({ message: err.response?.data?.message || 'Error setting opening balances', color: 'red' }); + }, + }); + // ── Investment edit form ── const invForm = useForm({ initialValues: { @@ -358,6 +417,7 @@ export function AccountsPage() { fundType: account.fund_type, is1099Reportable: account.is_1099_reportable, initialBalance: 0, + interestRate: parseFloat(account.interest_rate || '0'), }); open(); }; @@ -389,6 +449,51 @@ export function AccountsPage() { }); }; + // ── Opening balance handlers ── + const handleSetOpeningBalance = (account: Account) => { + setOBAccount(account); + const tbEntry = trialBalance.find((tb) => tb.id === account.id); + obForm.setValues({ + targetBalance: parseFloat(tbEntry?.balance || account.balance || '0'), + asOfDate: new Date(), + memo: '', + }); + openOB(); + }; + + const handleOBSubmit = (values: { targetBalance: number; asOfDate: Date | null; memo: string }) => { + if (!obAccount || !values.asOfDate) return; + openingBalanceMutation.mutate({ + accountId: obAccount.id, + targetBalance: values.targetBalance, + asOfDate: values.asOfDate.toISOString().split('T')[0], + memo: values.memo, + }); + }; + + const handleOpenBulkOB = () => { + const entries: Record = {}; + for (const a of accounts.filter((acc) => ['asset', 'liability'].includes(acc.account_type) && acc.is_active && !acc.is_system)) { + const tb = trialBalance.find((t) => t.id === a.id); + entries[a.id] = parseFloat(tb?.balance || a.balance || '0'); + } + setBulkOBEntries(entries); + setBulkOBDate(new Date()); + openBulkOB(); + }; + + const handleBulkOBSubmit = () => { + if (!bulkOBDate) return; + const entries = Object.entries(bulkOBEntries).map(([accountId, targetBalance]) => ({ + accountId, + targetBalance, + })); + bulkOBMutation.mutate({ + asOfDate: bulkOBDate.toISOString().split('T')[0], + entries, + }); + }; + // ── Filtering ── // Only show asset and liability accounts — these represent real cash positions. // Income, expense, and equity accounts are internal bookkeeping managed via @@ -434,6 +539,21 @@ export function AccountsPage() { // Net position = assets + investments - liabilities const netPosition = (totalsByType['asset'] || 0) + investmentTotal - (totalsByType['liability'] || 0); + // ── Estimated monthly interest across all accounts with rates ── + const estMonthlyInterest = accounts + .filter((a) => a.is_active && !a.is_system && a.interest_rate && parseFloat(a.interest_rate) > 0) + .reduce((sum, a) => { + const bal = parseFloat(a.balance || '0'); + const rate = parseFloat(a.interest_rate || '0'); + return sum + (bal * (rate / 100) / 12); + }, 0); + + // ── Opening balance modal: current balance ── + const obCurrentBalance = obAccount + ? parseFloat(trialBalance.find((tb) => tb.id === obAccount.id)?.balance || obAccount.balance || '0') + : 0; + const obAdjustmentAmount = (obForm.values.targetBalance || 0) - obCurrentBalance; + // ── Adjust modal: current balance from trial balance ── const adjustCurrentBalance = adjustingAccount ? parseFloat( @@ -463,6 +583,9 @@ export function AccountsPage() { onChange={(e) => setShowArchived(e.currentTarget.checked)} size="sm" /> + @@ -490,6 +613,12 @@ export function AccountsPage() { Net Position = 0 ? 'green' : 'red'}>{fmt(netPosition)} + {estMonthlyInterest > 0 && ( + + Est. Monthly Interest + {fmt(estMonthlyInterest)} + + )} @@ -545,6 +674,7 @@ export function AccountsPage() { onArchive={archiveMutation.mutate} onSetPrimary={(id) => setPrimaryMutation.mutate(id)} onAdjustBalance={handleAdjustBalance} + onSetOpeningBalance={handleSetOpeningBalance} /> {investments.filter(i => i.is_active).length > 0 && ( <> @@ -562,6 +692,7 @@ export function AccountsPage() { onArchive={archiveMutation.mutate} onSetPrimary={(id) => setPrimaryMutation.mutate(id)} onAdjustBalance={handleAdjustBalance} + onSetOpeningBalance={handleSetOpeningBalance} /> {operatingInvestments.length > 0 && ( <> @@ -579,6 +710,7 @@ export function AccountsPage() { onArchive={archiveMutation.mutate} onSetPrimary={(id) => setPrimaryMutation.mutate(id)} onAdjustBalance={handleAdjustBalance} + onSetOpeningBalance={handleSetOpeningBalance} /> {reserveInvestments.length > 0 && ( <> @@ -596,6 +728,7 @@ export function AccountsPage() { onArchive={archiveMutation.mutate} onSetPrimary={(id) => setPrimaryMutation.mutate(id)} onAdjustBalance={handleAdjustBalance} + onSetOpeningBalance={handleSetOpeningBalance} isArchivedView /> @@ -729,6 +862,15 @@ export function AccountsPage() { {/* Regular account fields */} {!isInvestmentType(form.values.accountType) && ( <> + {!editing && ( + {/* Opening Balance Modal */} + + {obAccount && ( +
+ + + Account: {obAccount.account_number} - {obAccount.name} + + + + + + + + + + + } color={obAdjustmentAmount >= 0 ? 'blue' : 'orange'} variant="light"> + + Adjustment: {fmt(obAdjustmentAmount)} + {obAdjustmentAmount > 0 && ' (increase)'} + {obAdjustmentAmount < 0 && ' (decrease)'} + {obAdjustmentAmount === 0 && ' (no change)'} + + + + + +
+ )} +
+ + {/* Bulk Opening Balance Modal */} + + + + + + + + Acct # + Name + Type + Fund + Current Balance + Target Balance + + + + {accounts + .filter((a) => ['asset', 'liability'].includes(a.account_type) && a.is_active && !a.is_system) + .map((a) => { + const currentBal = parseFloat(trialBalance.find((t) => t.id === a.id)?.balance || a.balance || '0'); + return ( + + {a.account_number} + {a.name} + + {a.account_type} + + + {a.fund_type} + + {fmt(currentBal)} + + setBulkOBEntries((prev) => ({ ...prev, [a.id]: Number(v) || 0 }))} + styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }} + /> + + + ); + })} + +
+ + +
+
+ {/* Investment Edit Modal */} {editingInvestment && ( @@ -888,6 +1150,7 @@ function AccountTable({ onArchive, onSetPrimary, onAdjustBalance, + onSetOpeningBalance, isArchivedView = false, }: { accounts: Account[]; @@ -895,8 +1158,11 @@ function AccountTable({ onArchive: (a: Account) => void; onSetPrimary: (id: string) => void; onAdjustBalance: (a: Account) => void; + onSetOpeningBalance: (a: Account) => void; isArchivedView?: boolean; }) { + const hasRates = accounts.some((a) => a.interest_rate && parseFloat(a.interest_rate) > 0); + return ( @@ -907,6 +1173,9 @@ function AccountTable({ Type Fund Balance + {hasRates && Rate} + {hasRates && Est. Monthly} + {hasRates && Est. Annual} 1099 @@ -914,89 +1183,117 @@ function AccountTable({ {accounts.length === 0 && ( - + {isArchivedView ? 'No archived accounts' : 'No accounts found'} )} - {accounts.map((a) => ( - - - {a.is_primary && ( - - - + {accounts.map((a) => { + const rate = parseFloat(a.interest_rate || '0'); + const balance = parseFloat(a.balance || '0'); + const estAnnual = rate > 0 ? balance * (rate / 100) : 0; + const estMonthly = estAnnual / 12; + return ( + + + {a.is_primary && ( + + + + )} + + {a.account_number} + +
+ {a.name} + {a.description && ( + + {a.description} + + )} +
+
+ + + {a.account_type} + + + + + {a.fund_type} + + + + {fmt(a.balance)} + + {hasRates && ( + + {rate > 0 ? `${rate.toFixed(2)}%` : '-'} + )} -
- {a.account_number} - -
- {a.name} - {a.description && ( - - {a.description} - - )} -
-
- - - {a.account_type} - - - - - {a.fund_type} - - - - {fmt(a.balance)} - - - {a.is_1099_reportable ? 1099 : ''} - - - - {!a.is_system && ( - - onSetPrimary(a.id)} - > - {a.is_primary ? : } + {hasRates && ( + + {rate > 0 ? fmt(estMonthly) : '-'} + + )} + {hasRates && ( + + {rate > 0 ? fmt(estAnnual) : '-'} + + )} + + {a.is_1099_reportable ? 1099 : ''} + + + + {!a.is_system && ( + + onSetPrimary(a.id)} + > + {a.is_primary ? : } + + + )} + {!a.is_system && ( + + onSetOpeningBalance(a)}> + + + + )} + {!a.is_system && ( + + onAdjustBalance(a)}> + + + + )} + + onEdit(a)}> + - )} - {!a.is_system && ( - - onAdjustBalance(a)}> - - - - )} - - onEdit(a)}> - - - - {!a.is_system && ( - - onArchive(a)} - > - {a.is_active ? : } - - - )} - - -
- ))} + {!a.is_system && ( + + onArchive(a)} + > + {a.is_active ? : } + + + )} + + + + ); + })}
); diff --git a/frontend/src/pages/projects/ProjectsPage.tsx b/frontend/src/pages/projects/ProjectsPage.tsx index c3097ae..69e68d0 100644 --- a/frontend/src/pages/projects/ProjectsPage.tsx +++ b/frontend/src/pages/projects/ProjectsPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useRef } from 'react'; import { Title, Table, Group, Button, Stack, Text, Modal, TextInput, NumberInput, Select, Textarea, Badge, ActionIcon, Loader, Center, @@ -8,9 +8,10 @@ import { DateInput } from '@mantine/dates'; import { useForm } from '@mantine/form'; import { useDisclosure } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; -import { IconPlus, IconEdit } from '@tabler/icons-react'; +import { IconPlus, IconEdit, IconUpload, IconDownload } from '@tabler/icons-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '../../services/api'; +import { parseCSV, downloadBlob } from '../../utils/csv'; // --------------------------------------------------------------------------- // Types & constants @@ -75,6 +76,7 @@ export function ProjectsPage() { const [opened, { open, close }] = useDisclosure(false); const [editing, setEditing] = useState(null); const queryClient = useQueryClient(); + const fileInputRef = useRef(null); // ---- Data fetching ---- @@ -191,6 +193,42 @@ export function ProjectsPage() { }, }); + const importMutation = useMutation({ + mutationFn: async (rows: Record[]) => { + const { data } = await api.post('/projects/import', rows); + return data; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['projects'] }); + let msg = `Imported: ${data.created} created, ${data.updated} updated`; + if (data.errors?.length) msg += `. ${data.errors.length} error(s): ${data.errors.slice(0, 3).join('; ')}`; + notifications.show({ message: msg, color: data.errors?.length ? 'yellow' : 'green', autoClose: 10000 }); + }, + onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Import failed', color: 'red' }); }, + }); + + const handleExport = async () => { + try { + const response = await api.get('/projects/export', { responseType: 'blob' }); + downloadBlob(response.data, 'projects.csv'); + } catch { notifications.show({ message: 'Export failed', color: 'red' }); } + }; + + const handleFileChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (e) => { + const text = e.target?.result as string; + if (!text) { notifications.show({ message: 'Could not read file', color: 'red' }); return; } + const rows = parseCSV(text); + if (!rows.length) { notifications.show({ message: 'No data rows found', color: 'red' }); return; } + importMutation.mutate(rows); + }; + reader.readAsText(file); + event.target.value = ''; + }; + // ---- Handlers ---- const handleEdit = (p: Project) => { @@ -279,9 +317,19 @@ export function ProjectsPage() { {/* Header */} Projects - + + + + + + {/* Summary Cards */} diff --git a/frontend/src/pages/reports/CashFlowPage.tsx b/frontend/src/pages/reports/CashFlowPage.tsx index 13750e2..31983a8 100644 --- a/frontend/src/pages/reports/CashFlowPage.tsx +++ b/frontend/src/pages/reports/CashFlowPage.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { Title, Table, Group, Stack, Text, Card, Loader, Center, Divider, - Badge, SimpleGrid, TextInput, Button, ThemeIcon, + Badge, SimpleGrid, TextInput, Button, ThemeIcon, SegmentedControl, } from '@mantine/core'; import { useQuery } from '@tanstack/react-query'; import { @@ -24,6 +24,7 @@ interface ReserveActivity { interface CashFlowData { from: string; to: string; + include_investments: boolean; operating_activities: OperatingActivity[]; reserve_activities: ReserveActivity[]; total_operating: string; @@ -31,6 +32,7 @@ interface CashFlowData { net_cash_change: string; beginning_cash: string; ending_cash: string; + investment_balance: string; } export function CashFlowPage() { @@ -42,11 +44,16 @@ export function CashFlowPage() { const [toDate, setToDate] = useState(todayStr); const [queryFrom, setQueryFrom] = useState(yearStart); const [queryTo, setQueryTo] = useState(todayStr); + const [balanceMode, setBalanceMode] = useState('cash'); + + const includeInvestments = balanceMode === 'all'; const { data, isLoading } = useQuery({ - queryKey: ['cash-flow', queryFrom, queryTo], + queryKey: ['cash-flow', queryFrom, queryTo, includeInvestments], queryFn: async () => { - const { data } = await api.get(`/reports/cash-flow?from=${queryFrom}&to=${queryTo}`); + const params = new URLSearchParams({ from: queryFrom, to: queryTo }); + if (includeInvestments) params.set('includeInvestments', 'true'); + const { data } = await api.get(`/reports/cash-flow?${params}`); return data; }, }); @@ -63,6 +70,7 @@ export function CashFlowPage() { const totalReserve = parseFloat(data?.total_reserve || '0'); const beginningCash = parseFloat(data?.beginning_cash || '0'); const endingCash = parseFloat(data?.ending_cash || '0'); + const balanceLabel = includeInvestments ? 'Cash + Investments' : 'Cash'; if (isLoading) return
; @@ -95,6 +103,19 @@ export function CashFlowPage() {
+ + Balance view: + + + {/* Summary Cards */} @@ -102,7 +123,7 @@ export function CashFlowPage() { - Beginning Cash + Beginning {balanceLabel} {fmt(beginningCash)} @@ -133,7 +154,7 @@ export function CashFlowPage() { - Ending Cash + Ending {balanceLabel} {fmt(endingCash)} @@ -162,11 +183,7 @@ export function CashFlowPage() { {a.name} - + {a.type} @@ -241,11 +258,11 @@ export function CashFlowPage() { - Beginning Cash + Beginning {balanceLabel} {fmt(data?.beginning_cash || '0')} - Ending Cash + Ending {balanceLabel} {fmt(data?.ending_cash || '0')} diff --git a/frontend/src/pages/units/UnitsPage.tsx b/frontend/src/pages/units/UnitsPage.tsx index 36f7a3f..963306e 100644 --- a/frontend/src/pages/units/UnitsPage.tsx +++ b/frontend/src/pages/units/UnitsPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useRef } from 'react'; import { Title, Table, Group, Button, Stack, TextInput, Modal, Select, Badge, ActionIcon, Text, Loader, Center, Tooltip, Alert, @@ -6,9 +6,10 @@ import { import { useForm } from '@mantine/form'; import { useDisclosure } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; -import { IconPlus, IconEdit, IconSearch, IconTrash, IconInfoCircle } from '@tabler/icons-react'; +import { IconPlus, IconEdit, IconSearch, IconTrash, IconInfoCircle, IconUpload, IconDownload } from '@tabler/icons-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '../../services/api'; +import { parseCSV, downloadBlob } from '../../utils/csv'; interface Unit { id: string; @@ -39,6 +40,7 @@ export function UnitsPage() { const [search, setSearch] = useState(''); const [deleteConfirm, setDeleteConfirm] = useState(null); const queryClient = useQueryClient(); + const fileInputRef = useRef(null); const { data: units = [], isLoading } = useQuery({ queryKey: ['units'], @@ -91,6 +93,20 @@ export function UnitsPage() { }, }); + const importMutation = useMutation({ + mutationFn: async (rows: Record[]) => { + const { data } = await api.post('/units/import', rows); + return data; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['units'] }); + let msg = `Imported: ${data.created} created, ${data.updated} updated`; + if (data.errors?.length) msg += `. ${data.errors.length} error(s): ${data.errors.slice(0, 3).join('; ')}`; + notifications.show({ message: msg, color: data.errors?.length ? 'yellow' : 'green', autoClose: 10000 }); + }, + onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Import failed', color: 'red' }); }, + }); + const handleEdit = (u: Unit) => { setEditing(u); form.setValues({ @@ -105,13 +121,32 @@ export function UnitsPage() { const handleNew = () => { setEditing(null); form.reset(); - // Pre-populate with default group - if (defaultGroup) { - form.setFieldValue('assessment_group_id', defaultGroup.id); - } + if (defaultGroup) form.setFieldValue('assessment_group_id', defaultGroup.id); open(); }; + const handleExport = async () => { + try { + const response = await api.get('/units/export', { responseType: 'blob' }); + downloadBlob(response.data, 'units.csv'); + } catch { notifications.show({ message: 'Export failed', color: 'red' }); } + }; + + const handleFileChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (e) => { + const text = e.target?.result as string; + if (!text) { notifications.show({ message: 'Could not read file', color: 'red' }); return; } + const rows = parseCSV(text); + if (!rows.length) { notifications.show({ message: 'No data rows found', color: 'red' }); return; } + importMutation.mutate(rows); + }; + reader.readAsText(file); + event.target.value = ''; + }; + const filtered = units.filter((u) => !search || u.unit_number.toLowerCase().includes(search.toLowerCase()) || (u.owner_name || '').toLowerCase().includes(search.toLowerCase()) @@ -123,13 +158,23 @@ export function UnitsPage() { Units / Homeowners - {hasGroups ? ( - - ) : ( - - - - )} + + + + + {hasGroups ? ( + + ) : ( + + + + )} + {!hasGroups && ( diff --git a/frontend/src/pages/vendors/VendorsPage.tsx b/frontend/src/pages/vendors/VendorsPage.tsx index c66fb50..41dcb4a 100644 --- a/frontend/src/pages/vendors/VendorsPage.tsx +++ b/frontend/src/pages/vendors/VendorsPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useRef } from 'react'; import { Title, Table, Group, Button, Stack, TextInput, Modal, Switch, Badge, ActionIcon, Text, Loader, Center, @@ -6,9 +6,10 @@ import { import { useForm } from '@mantine/form'; import { useDisclosure } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; -import { IconPlus, IconEdit, IconSearch } from '@tabler/icons-react'; +import { IconPlus, IconEdit, IconSearch, IconUpload, IconDownload } from '@tabler/icons-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '../../services/api'; +import { parseCSV, downloadBlob } from '../../utils/csv'; interface Vendor { id: string; name: string; contact_name: string; email: string; phone: string; @@ -21,6 +22,7 @@ export function VendorsPage() { const [editing, setEditing] = useState(null); const [search, setSearch] = useState(''); const queryClient = useQueryClient(); + const fileInputRef = useRef(null); const { data: vendors = [], isLoading } = useQuery({ queryKey: ['vendors'], @@ -46,6 +48,42 @@ export function VendorsPage() { onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); }, }); + const importMutation = useMutation({ + mutationFn: async (rows: Record[]) => { + const { data } = await api.post('/vendors/import', rows); + return data; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['vendors'] }); + let msg = `Imported: ${data.created} created, ${data.updated} updated`; + if (data.errors?.length) msg += `. ${data.errors.length} error(s): ${data.errors.slice(0, 3).join('; ')}`; + notifications.show({ message: msg, color: data.errors?.length ? 'yellow' : 'green', autoClose: 10000 }); + }, + onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Import failed', color: 'red' }); }, + }); + + const handleExport = async () => { + try { + const response = await api.get('/vendors/export', { responseType: 'blob' }); + downloadBlob(response.data, 'vendors.csv'); + } catch { notifications.show({ message: 'Export failed', color: 'red' }); } + }; + + const handleFileChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (e) => { + const text = e.target?.result as string; + if (!text) { notifications.show({ message: 'Could not read file', color: 'red' }); return; } + const rows = parseCSV(text); + if (!rows.length) { notifications.show({ message: 'No data rows found', color: 'red' }); return; } + importMutation.mutate(rows); + }; + reader.readAsText(file); + event.target.value = ''; + }; + const handleEdit = (v: Vendor) => { setEditing(v); form.setValues({ @@ -65,7 +103,17 @@ export function VendorsPage() { Vendors - + + + + + + } value={search} onChange={(e) => setSearch(e.currentTarget.value)} /> diff --git a/frontend/src/utils/csv.ts b/frontend/src/utils/csv.ts new file mode 100644 index 0000000..d1026e4 --- /dev/null +++ b/frontend/src/utils/csv.ts @@ -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[] { + 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[] = []; + + 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 = {}; + 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[], 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); +}