7 Commits

Author SHA1 Message Date
9d137a40d3 fix: enforce read-only restrictions for viewer role across 5 pages
Audit and fix viewer (read-only) user permissions:
- Dashboard: hide health score refresh buttons
- Accounts: hide investment edit icons
- Invoices: hide Apply Late Fees and Generate Invoices buttons
- Capital Planning: disable drag-and-drop, hide grip handles and edit buttons
- Investment Planning: hide AI Recommendations refresh button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 09:59:20 -04:00
3bf6b8c6c9 fix: update password when adding existing user to new org
When an existing user was added to a new organization via the member
management UI, the password entered in the form was silently ignored.
This caused the user to be unable to log in with the password they
were given, since the hash in the database was from their original
account creation for a different org.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:49:23 -04:00
4759374883 feat: add dark mode with persistent user preference
Add dark mode support using Mantine's built-in color scheme system,
persisted via a new Zustand preferences store. Includes a quick toggle
in the app header and an enabled switch in User Preferences. Also
removes the "AI Health Scores" title from the dashboard to reclaim
vertical space.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:36:11 -04:00
cb6e34d5ce feat: add password reset utility script
Usage: ./scripts/reset-password.sh <email> <new-password>
Generates bcrypt hash via bcryptjs in the backend container,
updates the database, and verifies the hash matches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:19:22 -05:00
2b72951e66 chore: bump version to 2026.3.7 (Beta)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:01:57 -05:00
69dad7cc74 fix: resolve 5 invoice/payment issues from user feedback
- Replace misleading 'sent' status with 'pending' (no email capability)
- Show assessment group name instead of raw 'regular_assessment' type
- Add owner last name to invoice table
- Fix payment creation Internal Server Error (PostgreSQL $2 type cast)
- Add edit/delete capability for payment records with invoice recalc

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:01:57 -05:00
efa5aca35f feat: add flexible billing frequency support for invoices
Assessment groups can now define billing frequency (monthly, quarterly,
annual) with configurable due months and due day. Invoice generation
respects each group's schedule - only generating invoices when the
selected month is a billing month for that group. Adds a generation
preview showing which groups will be billed, period tracking on
invoices, and billing period context in the payments UI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:01:57 -05:00
28 changed files with 1138 additions and 157 deletions

View File

@@ -1,12 +1,12 @@
{ {
"name": "hoa-ledgeriq-backend", "name": "hoa-ledgeriq-backend",
"version": "2026.3.2-beta", "version": "2026.3.7-beta",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "hoa-ledgeriq-backend", "name": "hoa-ledgeriq-backend",
"version": "2026.3.2-beta", "version": "2026.3.7-beta",
"dependencies": { "dependencies": {
"@nestjs/common": "^10.4.15", "@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0", "@nestjs/config": "^3.3.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "hoa-ledgeriq-backend", "name": "hoa-ledgeriq-backend",
"version": "2026.3.2-beta", "version": "2026.3.7-beta",
"description": "HOA LedgerIQ - Backend API", "description": "HOA LedgerIQ - Backend API",
"private": true, "private": true,
"scripts": { "scripts": {

View File

@@ -112,6 +112,8 @@ export class TenantSchemaService {
special_assessment DECIMAL(10,2) DEFAULT 0.00, special_assessment DECIMAL(10,2) DEFAULT 0.00,
unit_count INTEGER DEFAULT 0, unit_count INTEGER DEFAULT 0,
frequency VARCHAR(20) DEFAULT 'monthly' CHECK (frequency IN ('monthly', 'quarterly', 'annual')), frequency VARCHAR(20) DEFAULT 'monthly' CHECK (frequency IN ('monthly', 'quarterly', 'annual')),
due_months INTEGER[] DEFAULT '{1,2,3,4,5,6,7,8,9,10,11,12}',
due_day INTEGER DEFAULT 1,
is_default BOOLEAN DEFAULT FALSE, is_default BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE, is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(),
@@ -155,8 +157,11 @@ export class TenantSchemaService {
amount DECIMAL(10,2) NOT NULL, amount DECIMAL(10,2) NOT NULL,
amount_paid DECIMAL(10,2) DEFAULT 0.00, amount_paid DECIMAL(10,2) DEFAULT 0.00,
status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ( status VARCHAR(20) DEFAULT 'draft' CHECK (status IN (
'draft', 'sent', 'paid', 'partial', 'overdue', 'void', 'written_off' 'draft', 'pending', 'sent', 'paid', 'partial', 'overdue', 'void', 'written_off'
)), )),
period_start DATE,
period_end DATE,
assessment_group_id UUID REFERENCES "${s}".assessment_groups(id),
journal_entry_id UUID REFERENCES "${s}".journal_entries(id), journal_entry_id UUID REFERENCES "${s}".journal_entries(id),
sent_at TIMESTAMPTZ, sent_at TIMESTAMPTZ,
paid_at TIMESTAMPTZ, paid_at TIMESTAMPTZ,

View File

@@ -67,7 +67,7 @@ async function bootstrap() {
const config = new DocumentBuilder() const config = new DocumentBuilder()
.setTitle('HOA LedgerIQ API') .setTitle('HOA LedgerIQ API')
.setDescription('API for the HOA LedgerIQ') .setDescription('API for the HOA LedgerIQ')
.setVersion('2026.3.2') .setVersion('2026.3.7')
.addBearerAuth() .addBearerAuth()
.build(); .build();
const document = SwaggerModule.createDocument(app, config); const document = SwaggerModule.createDocument(app, config);

View File

@@ -1,6 +1,12 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { TenantService } from '../../database/tenant.service'; import { TenantService } from '../../database/tenant.service';
const DEFAULT_DUE_MONTHS: Record<string, number[]> = {
monthly: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
quarterly: [1, 4, 7, 10],
annual: [1],
};
@Injectable() @Injectable()
export class AssessmentGroupsService { export class AssessmentGroupsService {
constructor(private tenant: TenantService) {} constructor(private tenant: TenantService) {}
@@ -42,6 +48,33 @@ export class AssessmentGroupsService {
return rows.length ? rows[0] : null; return rows.length ? rows[0] : null;
} }
private validateDueMonths(frequency: string, dueMonths: number[]) {
if (!dueMonths || !dueMonths.length) {
throw new BadRequestException('Due months are required');
}
// Validate all values are 1-12
if (dueMonths.some((m) => m < 1 || m > 12 || !Number.isInteger(m))) {
throw new BadRequestException('Due months must be integers between 1 and 12');
}
switch (frequency) {
case 'monthly':
if (dueMonths.length !== 12) {
throw new BadRequestException('Monthly frequency must include all 12 months');
}
break;
case 'quarterly':
if (dueMonths.length !== 4) {
throw new BadRequestException('Quarterly frequency must have exactly 4 due months');
}
break;
case 'annual':
if (dueMonths.length !== 1) {
throw new BadRequestException('Annual frequency must have exactly 1 due month');
}
break;
}
}
async create(dto: any) { async create(dto: any) {
const existingGroups = await this.tenant.query('SELECT COUNT(*) as cnt FROM assessment_groups'); const existingGroups = await this.tenant.query('SELECT COUNT(*) as cnt FROM assessment_groups');
const isFirstGroup = parseInt(existingGroups[0].cnt) === 0; const isFirstGroup = parseInt(existingGroups[0].cnt) === 0;
@@ -51,17 +84,23 @@ export class AssessmentGroupsService {
await this.tenant.query('UPDATE assessment_groups SET is_default = false WHERE is_default = true'); await this.tenant.query('UPDATE assessment_groups SET is_default = false WHERE is_default = true');
} }
const frequency = dto.frequency || 'monthly';
const dueMonths = dto.dueMonths || DEFAULT_DUE_MONTHS[frequency] || DEFAULT_DUE_MONTHS.monthly;
const dueDay = Math.min(Math.max(dto.dueDay || 1, 1), 28);
this.validateDueMonths(frequency, dueMonths);
const rows = await this.tenant.query( const rows = await this.tenant.query(
`INSERT INTO assessment_groups (name, description, regular_assessment, special_assessment, unit_count, frequency, is_default) `INSERT INTO assessment_groups (name, description, regular_assessment, special_assessment, unit_count, frequency, due_months, due_day, is_default)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`,
[dto.name, dto.description || null, dto.regularAssessment || 0, dto.specialAssessment || 0, [dto.name, dto.description || null, dto.regularAssessment || 0, dto.specialAssessment || 0,
dto.unitCount || 0, dto.frequency || 'monthly', shouldBeDefault], dto.unitCount || 0, frequency, dueMonths, dueDay, shouldBeDefault],
); );
return rows[0]; return rows[0];
} }
async update(id: string, dto: any) { async update(id: string, dto: any) {
await this.findOne(id); const existing = await this.findOne(id);
if (dto.isDefault === true) { if (dto.isDefault === true) {
await this.tenant.query('UPDATE assessment_groups SET is_default = false WHERE is_default = true'); await this.tenant.query('UPDATE assessment_groups SET is_default = false WHERE is_default = true');
@@ -80,6 +119,24 @@ export class AssessmentGroupsService {
if (dto.frequency !== undefined) { sets.push(`frequency = $${idx++}`); params.push(dto.frequency); } if (dto.frequency !== undefined) { sets.push(`frequency = $${idx++}`); params.push(dto.frequency); }
if (dto.isDefault !== undefined) { sets.push(`is_default = $${idx++}`); params.push(dto.isDefault); } if (dto.isDefault !== undefined) { sets.push(`is_default = $${idx++}`); params.push(dto.isDefault); }
// Handle due_months: if frequency changed and no explicit dueMonths, auto-populate defaults
const effectiveFrequency = dto.frequency || existing.frequency;
if (dto.dueMonths !== undefined) {
this.validateDueMonths(effectiveFrequency, dto.dueMonths);
sets.push(`due_months = $${idx++}`);
params.push(dto.dueMonths);
} else if (dto.frequency !== undefined && dto.frequency !== existing.frequency) {
// Frequency changed, auto-populate due_months
const newDueMonths = DEFAULT_DUE_MONTHS[dto.frequency] || DEFAULT_DUE_MONTHS.monthly;
sets.push(`due_months = $${idx++}`);
params.push(newDueMonths);
}
if (dto.dueDay !== undefined) {
sets.push(`due_day = $${idx++}`);
params.push(Math.min(Math.max(dto.dueDay, 1), 28));
}
if (!sets.length) return this.findOne(id); if (!sets.length) return this.findOne(id);
sets.push('updated_at = NOW()'); sets.push('updated_at = NOW()');

View File

@@ -16,6 +16,11 @@ export class InvoicesController {
@Get(':id') @Get(':id')
findOne(@Param('id') id: string) { return this.invoicesService.findOne(id); } findOne(@Param('id') id: string) { return this.invoicesService.findOne(id); }
@Post('generate-preview')
generatePreview(@Body() dto: { month: number; year: number }) {
return this.invoicesService.generatePreview(dto);
}
@Post('generate-bulk') @Post('generate-bulk')
generateBulk(@Body() dto: { month: number; year: number }, @Request() req: any) { generateBulk(@Body() dto: { month: number; year: number }, @Request() req: any) {
return this.invoicesService.generateBulk(dto, req.user.sub); return this.invoicesService.generateBulk(dto, req.user.sub);

View File

@@ -1,33 +1,135 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { TenantService } from '../../database/tenant.service'; import { TenantService } from '../../database/tenant.service';
const MONTH_NAMES = [
'', 'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
const MONTH_ABBREV = [
'', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
];
@Injectable() @Injectable()
export class InvoicesService { export class InvoicesService {
constructor(private tenant: TenantService) {} constructor(private tenant: TenantService) {}
async findAll() { async findAll() {
return this.tenant.query(` return this.tenant.query(`
SELECT i.*, u.unit_number, SELECT i.*, u.unit_number, u.owner_name, ag.name as assessment_group_name, ag.frequency,
(i.amount - i.amount_paid) as balance_due (i.amount - i.amount_paid) as balance_due
FROM invoices i FROM invoices i
JOIN units u ON u.id = i.unit_id JOIN units u ON u.id = i.unit_id
LEFT JOIN assessment_groups ag ON ag.id = i.assessment_group_id
ORDER BY i.invoice_date DESC, i.invoice_number DESC ORDER BY i.invoice_date DESC, i.invoice_number DESC
`); `);
} }
async findOne(id: string) { async findOne(id: string) {
const rows = await this.tenant.query(` const rows = await this.tenant.query(`
SELECT i.*, u.unit_number FROM invoices i SELECT i.*, u.unit_number, u.owner_name FROM invoices i
JOIN units u ON u.id = i.unit_id WHERE i.id = $1`, [id]); JOIN units u ON u.id = i.unit_id WHERE i.id = $1`, [id]);
if (!rows.length) throw new NotFoundException('Invoice not found'); if (!rows.length) throw new NotFoundException('Invoice not found');
return rows[0]; return rows[0];
} }
async generateBulk(dto: { month: number; year: number }, userId: string) { /**
const units = await this.tenant.query( * Calculate billing period based on frequency and the billing month.
`SELECT * FROM units WHERE status = 'active' AND monthly_assessment > 0`, */
private calculatePeriod(frequency: string, month: number, year: number): { start: string; end: string; description: string } {
switch (frequency) {
case 'quarterly': {
// Period covers 3 months starting from the billing month
const startDate = new Date(year, month - 1, 1);
const endDate = new Date(year, month + 2, 0); // last day of month+2
const endMonth = month + 2 > 12 ? month + 2 - 12 : month + 2;
const quarter = Math.ceil(month / 3);
return {
start: startDate.toISOString().split('T')[0],
end: endDate.toISOString().split('T')[0],
description: `Q${quarter} ${year} Assessment (${MONTH_ABBREV[month]}-${MONTH_ABBREV[endMonth]})`,
};
}
case 'annual': {
const startDate = new Date(year, 0, 1);
const endDate = new Date(year, 11, 31);
return {
start: startDate.toISOString().split('T')[0],
end: endDate.toISOString().split('T')[0],
description: `Annual Assessment ${year}`,
};
}
default: { // monthly
const startDate = new Date(year, month - 1, 1);
const endDate = new Date(year, month, 0); // last day of month
return {
start: startDate.toISOString().split('T')[0],
end: endDate.toISOString().split('T')[0],
description: `Monthly Assessment - ${MONTH_NAMES[month]} ${year}`,
};
}
}
}
/**
* Preview which groups/units will be billed for a given month/year.
*/
async generatePreview(dto: { month: number; year: number }) {
const allGroups = await this.tenant.query(
`SELECT ag.*, (SELECT COUNT(*) FROM units u WHERE u.assessment_group_id = ag.id AND u.status = 'active') as active_units
FROM assessment_groups ag WHERE ag.is_active = true ORDER BY ag.name`,
); );
if (!units.length) throw new BadRequestException('No active units with assessments found');
const groups = allGroups.map((g: any) => {
const dueMonths: number[] = g.due_months || [1,2,3,4,5,6,7,8,9,10,11,12];
const isBillingMonth = dueMonths.includes(dto.month);
const activeUnits = parseInt(g.active_units || '0');
const totalAmount = isBillingMonth
? (parseFloat(g.regular_assessment) + parseFloat(g.special_assessment || '0')) * activeUnits
: 0;
const period = this.calculatePeriod(g.frequency || 'monthly', dto.month, dto.year);
return {
id: g.id,
name: g.name,
frequency: g.frequency || 'monthly',
due_months: dueMonths,
active_units: activeUnits,
regular_assessment: g.regular_assessment,
special_assessment: g.special_assessment,
is_billing_month: isBillingMonth,
total_amount: totalAmount,
period_description: period.description,
};
});
const billableGroups = groups.filter((g: any) => g.is_billing_month && g.active_units > 0);
const totalInvoices = billableGroups.reduce((sum: number, g: any) => sum + g.active_units, 0);
const totalAmount = billableGroups.reduce((sum: number, g: any) => sum + g.total_amount, 0);
return {
month: dto.month,
year: dto.year,
month_name: MONTH_NAMES[dto.month],
groups,
summary: { total_groups_billing: billableGroups.length, total_invoices: totalInvoices, total_amount: totalAmount },
};
}
/**
* Generate invoices for all assessment groups where the given month is a billing month.
*/
async generateBulk(dto: { month: number; year: number }, userId: string) {
// Get assessment groups where this month is a billing month
const groups = await this.tenant.query(
`SELECT * FROM assessment_groups WHERE is_active = true AND $1 = ANY(due_months)`,
[dto.month],
);
if (!groups.length) {
throw new BadRequestException(`No assessment groups have billing scheduled for ${MONTH_NAMES[dto.month]}`);
}
// Get or create fiscal period // Get or create fiscal period
let fp = await this.tenant.query( let fp = await this.tenant.query(
@@ -41,50 +143,87 @@ export class InvoicesService {
} }
const fiscalPeriodId = fp[0].id; const fiscalPeriodId = fp[0].id;
const invoiceDate = new Date(dto.year, dto.month - 1, 1); // Look up GL accounts once
const dueDate = new Date(dto.year, dto.month - 1, 15); const arAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = '1200'`);
const incomeAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = '4000'`);
let created = 0; let created = 0;
const groupResults: any[] = [];
for (const unit of units) { for (const group of groups) {
const invNum = `INV-${dto.year}${String(dto.month).padStart(2, '0')}-${unit.unit_number}`; // Get active units in this assessment group
const units = await this.tenant.query(
// Check if already generated `SELECT * FROM units WHERE status = 'active' AND assessment_group_id = $1`,
const existing = await this.tenant.query( [group.id],
'SELECT id FROM invoices WHERE invoice_number = $1', [invNum],
);
if (existing.length) continue;
// Create the invoice
const inv = await this.tenant.query(
`INSERT INTO invoices (invoice_number, unit_id, invoice_date, due_date, invoice_type, description, amount, status)
VALUES ($1, $2, $3, $4, 'regular_assessment', $5, $6, 'sent') RETURNING id`,
[invNum, unit.id, invoiceDate.toISOString().split('T')[0], dueDate.toISOString().split('T')[0],
`Monthly assessment - ${new Date(dto.year, dto.month - 1).toLocaleString('default', { month: 'long', year: 'numeric' })}`,
unit.monthly_assessment],
); );
// Create journal entry: DR Accounts Receivable, CR Assessment Income if (!units.length) continue;
const arAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = '1200'`);
const incomeAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = '4000'`);
if (arAccount.length && incomeAccount.length) { const frequency = group.frequency || 'monthly';
const je = await this.tenant.query( const period = this.calculatePeriod(frequency, dto.month, dto.year);
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, source_type, source_id, is_posted, posted_at, created_by) const dueDay = Math.min(group.due_day || 1, 28);
VALUES ($1, $2, 'assessment', $3, 'invoice', $4, true, NOW(), $5) RETURNING id`, const invoiceDate = new Date(dto.year, dto.month - 1, 1);
[invoiceDate.toISOString().split('T')[0], `Assessment - Unit ${unit.unit_number}`, fiscalPeriodId, inv[0].id, userId], const dueDate = new Date(dto.year, dto.month - 1, dueDay);
// Use the group's assessment amount (full period amount, not monthly equivalent)
const assessmentAmount = parseFloat(group.regular_assessment) + parseFloat(group.special_assessment || '0');
let groupCreated = 0;
for (const unit of units) {
const invNum = `INV-${dto.year}${String(dto.month).padStart(2, '0')}-${unit.unit_number}`;
// Check if already generated
const existing = await this.tenant.query(
'SELECT id FROM invoices WHERE invoice_number = $1', [invNum],
); );
await this.tenant.query( if (existing.length) continue;
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, $3, 0), ($1, $4, 0, $3)`,
[je[0].id, arAccount[0].id, unit.monthly_assessment, incomeAccount[0].id], // Use unit-level override if set, otherwise use group amount
); const unitAmount = unit.monthly_assessment && parseFloat(unit.monthly_assessment) > 0
await this.tenant.query( ? (frequency === 'monthly'
`UPDATE invoices SET journal_entry_id = $1 WHERE id = $2`, [je[0].id, inv[0].id], ? parseFloat(unit.monthly_assessment)
: frequency === 'quarterly'
? parseFloat(unit.monthly_assessment) * 3
: parseFloat(unit.monthly_assessment) * 12)
: assessmentAmount;
// Create the invoice with status 'pending' (no email sending capability)
const inv = await this.tenant.query(
`INSERT INTO invoices (invoice_number, unit_id, invoice_date, due_date, invoice_type, description, amount, status, period_start, period_end, assessment_group_id)
VALUES ($1, $2, $3, $4, 'regular_assessment', $5, $6, 'pending', $7, $8, $9) RETURNING id`,
[invNum, unit.id, invoiceDate.toISOString().split('T')[0], dueDate.toISOString().split('T')[0],
period.description, unitAmount, period.start, period.end, group.id],
); );
// Create journal entry: DR Accounts Receivable, CR Assessment Income
if (arAccount.length && incomeAccount.length) {
const je = await this.tenant.query(
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, source_type, source_id, is_posted, posted_at, created_by)
VALUES ($1, $2, 'assessment', $3, 'invoice', $4, true, NOW(), $5) RETURNING id`,
[invoiceDate.toISOString().split('T')[0], `Assessment - Unit ${unit.unit_number}`, fiscalPeriodId, inv[0].id, userId],
);
await this.tenant.query(
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, $3, 0), ($1, $4, 0, $3)`,
[je[0].id, arAccount[0].id, unitAmount, incomeAccount[0].id],
);
await this.tenant.query(
`UPDATE invoices SET journal_entry_id = $1 WHERE id = $2`, [je[0].id, inv[0].id],
);
}
created++;
groupCreated++;
} }
created++;
groupResults.push({
group_name: group.name,
frequency,
period: period.description,
invoices_created: groupCreated,
});
} }
return { created, month: dto.month, year: dto.year }; return { created, month: dto.month, year: dto.year, groups: groupResults };
} }
async applyLateFees(dto: { grace_period_days: number; late_fee_amount: number }, userId: string) { async applyLateFees(dto: { grace_period_days: number; late_fee_amount: number }, userId: string) {
@@ -95,7 +234,7 @@ export class InvoicesService {
const overdue = await this.tenant.query(` const overdue = await this.tenant.query(`
SELECT i.*, u.unit_number FROM invoices i SELECT i.*, u.unit_number FROM invoices i
JOIN units u ON u.id = i.unit_id JOIN units u ON u.id = i.unit_id
WHERE i.status IN ('sent', 'partial') AND i.due_date < $1 WHERE i.status IN ('pending', 'partial') AND i.due_date < $1
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM invoices lf WHERE lf.unit_id = i.unit_id SELECT 1 FROM invoices lf WHERE lf.unit_id = i.unit_id
AND lf.invoice_type = 'late_fee' AND lf.description LIKE '%' || i.invoice_number || '%' AND lf.invoice_type = 'late_fee' AND lf.description LIKE '%' || i.invoice_number || '%'
@@ -109,7 +248,7 @@ export class InvoicesService {
const lfNum = `LF-${inv.invoice_number}`; const lfNum = `LF-${inv.invoice_number}`;
await this.tenant.query( await this.tenant.query(
`INSERT INTO invoices (invoice_number, unit_id, invoice_date, due_date, invoice_type, description, amount, status) `INSERT INTO invoices (invoice_number, unit_id, invoice_date, due_date, invoice_type, description, amount, status)
VALUES ($1, $2, CURRENT_DATE, CURRENT_DATE + INTERVAL '15 days', 'late_fee', $3, $4, 'sent')`, VALUES ($1, $2, CURRENT_DATE, CURRENT_DATE + INTERVAL '15 days', 'late_fee', $3, $4, 'pending')`,
[lfNum, inv.unit_id, `Late fee for invoice ${inv.invoice_number}`, dto.late_fee_amount], [lfNum, inv.unit_id, `Late fee for invoice ${inv.invoice_number}`, dto.late_fee_amount],
); );
applied++; applied++;

View File

@@ -153,6 +153,14 @@ export class OrganizationsService {
existing.role = data.role; existing.role = data.role;
return this.userOrgRepository.save(existing); return this.userOrgRepository.save(existing);
} }
// Update password for existing user being added to a new org
if (data.password) {
const passwordHash = await bcrypt.hash(data.password, 12);
await dataSource.query(
`UPDATE shared.users SET password_hash = $1 WHERE id = $2`,
[passwordHash, userId],
);
}
} else { } else {
// Create new user // Create new user
const passwordHash = await bcrypt.hash(data.password, 12); const passwordHash = await bcrypt.hash(data.password, 12);

View File

@@ -1,4 +1,4 @@
import { Controller, Get, Post, Body, Param, UseGuards, Request } from '@nestjs/common'; import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards, Request } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { PaymentsService } from './payments.service'; import { PaymentsService } from './payments.service';
@@ -18,4 +18,12 @@ export class PaymentsController {
@Post() @Post()
create(@Body() dto: any, @Request() req: any) { return this.paymentsService.create(dto, req.user.sub); } create(@Body() dto: any, @Request() req: any) { return this.paymentsService.create(dto, req.user.sub); }
@Put(':id')
update(@Param('id') id: string, @Body() dto: any, @Request() req: any) {
return this.paymentsService.update(id, dto, req.user.sub);
}
@Delete(':id')
delete(@Param('id') id: string) { return this.paymentsService.delete(id); }
} }

View File

@@ -74,17 +74,95 @@ export class PaymentsService {
await this.tenant.query(`UPDATE payments SET journal_entry_id = $1 WHERE id = $2`, [je[0].id, payment[0].id]); await this.tenant.query(`UPDATE payments SET journal_entry_id = $1 WHERE id = $2`, [je[0].id, payment[0].id]);
} }
// Update invoice if linked // Update invoice if linked — use explicit cast to avoid PostgreSQL type inference error
if (invoice) { if (invoice) {
const newPaid = parseFloat(invoice.amount_paid) + parseFloat(dto.amount); const newPaid = parseFloat(invoice.amount_paid) + parseFloat(dto.amount);
const invoiceAmt = parseFloat(invoice.amount); const invoiceAmt = parseFloat(invoice.amount);
const newStatus = newPaid >= invoiceAmt ? 'paid' : 'partial'; const newStatus = newPaid >= invoiceAmt ? 'paid' : 'partial';
await this.tenant.query( await this.tenant.query(
`UPDATE invoices SET amount_paid = $1, status = $2, paid_at = CASE WHEN $2 = 'paid' THEN NOW() ELSE paid_at END, updated_at = NOW() WHERE id = $3`, `UPDATE invoices SET amount_paid = $1, status = $2::VARCHAR, paid_at = CASE WHEN $3::VARCHAR = 'paid' THEN NOW() ELSE paid_at END, updated_at = NOW() WHERE id = $4`,
[newPaid, newStatus, invoice.id], [newPaid, newStatus, newStatus, invoice.id],
); );
} }
return payment[0]; return payment[0];
} }
async update(id: string, dto: any, userId: string) {
const existing = await this.findOne(id);
const sets: string[] = [];
const params: any[] = [];
let idx = 1;
if (dto.payment_date !== undefined) { sets.push(`payment_date = $${idx++}`); params.push(dto.payment_date); }
if (dto.amount !== undefined) { sets.push(`amount = $${idx++}`); params.push(dto.amount); }
if (dto.payment_method !== undefined) { sets.push(`payment_method = $${idx++}`); params.push(dto.payment_method); }
if (dto.reference_number !== undefined) { sets.push(`reference_number = $${idx++}`); params.push(dto.reference_number); }
if (dto.notes !== undefined) { sets.push(`notes = $${idx++}`); params.push(dto.notes); }
if (!sets.length) return this.findOne(id);
params.push(id);
await this.tenant.query(
`UPDATE payments SET ${sets.join(', ')} WHERE id = $${idx} RETURNING *`,
params,
);
// If amount changed and payment is linked to an invoice, recalculate invoice totals
if (dto.amount !== undefined && existing.invoice_id) {
await this.recalculateInvoice(existing.invoice_id);
}
return this.findOne(id);
}
async delete(id: string) {
const payment = await this.findOne(id);
const invoiceId = payment.invoice_id;
// Delete associated journal entry lines and journal entry
if (payment.journal_entry_id) {
await this.tenant.query('DELETE FROM journal_entry_lines WHERE journal_entry_id = $1', [payment.journal_entry_id]);
await this.tenant.query('DELETE FROM journal_entries WHERE id = $1', [payment.journal_entry_id]);
}
// Delete the payment
await this.tenant.query('DELETE FROM payments WHERE id = $1', [id]);
// Recalculate invoice totals if payment was linked
if (invoiceId) {
await this.recalculateInvoice(invoiceId);
}
return { success: true };
}
private async recalculateInvoice(invoiceId: string) {
// Sum all remaining payments for this invoice
const result = await this.tenant.query(
'SELECT COALESCE(SUM(amount), 0) as total_paid FROM payments WHERE invoice_id = $1',
[invoiceId],
);
const totalPaid = parseFloat(result[0].total_paid);
// Get the invoice amount
const inv = await this.tenant.query('SELECT amount FROM invoices WHERE id = $1', [invoiceId]);
if (!inv.length) return;
const invoiceAmt = parseFloat(inv[0].amount);
let newStatus: string;
if (totalPaid >= invoiceAmt) {
newStatus = 'paid';
} else if (totalPaid > 0) {
newStatus = 'partial';
} else {
newStatus = 'pending';
}
await this.tenant.query(
`UPDATE invoices SET amount_paid = $1, status = $2::VARCHAR, paid_at = CASE WHEN $3::VARCHAR = 'paid' THEN NOW() ELSE NULL END, updated_at = NOW() WHERE id = $4`,
[totalPaid, newStatus, newStatus, invoiceId],
);
}
} }

View File

@@ -0,0 +1,57 @@
-- Migration 011: Add billing frequency support to invoices
-- Adds due_months and due_day to assessment_groups
-- Adds period_start, period_end, assessment_group_id to invoices
DO $$
DECLARE
v_schema TEXT;
BEGIN
FOR v_schema IN
SELECT schema_name FROM information_schema.schemata
WHERE schema_name LIKE 'tenant_%'
LOOP
-- Add due_months and due_day to assessment_groups
EXECUTE format('
ALTER TABLE %I.assessment_groups
ADD COLUMN IF NOT EXISTS due_months INTEGER[] DEFAULT ''{1,2,3,4,5,6,7,8,9,10,11,12}'',
ADD COLUMN IF NOT EXISTS due_day INTEGER DEFAULT 1
', v_schema);
-- Add period tracking and assessment group link to invoices
EXECUTE format('
ALTER TABLE %I.invoices
ADD COLUMN IF NOT EXISTS period_start DATE,
ADD COLUMN IF NOT EXISTS period_end DATE,
ADD COLUMN IF NOT EXISTS assessment_group_id UUID
', v_schema);
-- Backfill due_months based on existing frequency values
EXECUTE format('
UPDATE %I.assessment_groups
SET due_months = CASE frequency
WHEN ''quarterly'' THEN ''{1,4,7,10}''::INTEGER[]
WHEN ''annual'' THEN ''{1}''::INTEGER[]
ELSE ''{1,2,3,4,5,6,7,8,9,10,11,12}''::INTEGER[]
END
WHERE due_months IS NULL OR due_months = ''{1,2,3,4,5,6,7,8,9,10,11,12}''
AND frequency != ''monthly''
', v_schema);
-- Backfill period_start/period_end for existing invoices (all monthly)
EXECUTE format('
UPDATE %I.invoices
SET period_start = invoice_date,
period_end = (invoice_date + INTERVAL ''1 month'' - INTERVAL ''1 day'')::DATE
WHERE period_start IS NULL AND invoice_type = ''regular_assessment''
', v_schema);
-- Backfill assessment_group_id on existing invoices from units
EXECUTE format('
UPDATE %I.invoices i
SET assessment_group_id = u.assessment_group_id
FROM %I.units u
WHERE i.unit_id = u.id AND i.assessment_group_id IS NULL
', v_schema, v_schema);
END LOOP;
END $$;

View File

@@ -0,0 +1,33 @@
-- Migration 012: Replace 'sent' status with 'pending' for invoices
-- 'sent' implied email delivery which doesn't exist; 'pending' is more accurate
DO $$
DECLARE
v_schema TEXT;
v_constraint TEXT;
BEGIN
FOR v_schema IN
SELECT schema_name FROM information_schema.schemata
WHERE schema_name LIKE 'tenant_%'
LOOP
-- Find and drop the existing status check constraint
SELECT constraint_name INTO v_constraint
FROM information_schema.table_constraints
WHERE table_schema = v_schema
AND table_name = 'invoices'
AND constraint_type = 'CHECK'
AND constraint_name LIKE '%status%';
IF v_constraint IS NOT NULL THEN
EXECUTE format('ALTER TABLE %I.invoices DROP CONSTRAINT %I', v_schema, v_constraint);
END IF;
-- Add new constraint that includes 'pending'
EXECUTE format('ALTER TABLE %I.invoices ADD CONSTRAINT invoices_status_check CHECK (status IN (
''draft'', ''pending'', ''sent'', ''paid'', ''partial'', ''overdue'', ''void'', ''written_off''
))', v_schema);
-- Convert existing 'sent' invoices to 'pending'
EXECUTE format('UPDATE %I.invoices SET status = ''pending'' WHERE status = ''sent''', v_schema);
END LOOP;
END $$;

View File

@@ -204,7 +204,10 @@ CREATE TABLE IF NOT EXISTS %I.assessment_groups (
special_assessment DECIMAL(10,2) DEFAULT 0.00, special_assessment DECIMAL(10,2) DEFAULT 0.00,
unit_count INTEGER DEFAULT 0, unit_count INTEGER DEFAULT 0,
frequency VARCHAR(20) DEFAULT ''monthly'', frequency VARCHAR(20) DEFAULT ''monthly'',
due_months INTEGER[] DEFAULT ''{1,2,3,4,5,6,7,8,9,10,11,12}'',
due_day INTEGER DEFAULT 1,
is_active BOOLEAN DEFAULT TRUE, is_active BOOLEAN DEFAULT TRUE,
is_default BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW() updated_at TIMESTAMPTZ DEFAULT NOW()
)', v_schema); )', v_schema);
@@ -244,6 +247,9 @@ CREATE TABLE IF NOT EXISTS %I.invoices (
amount DECIMAL(10,2) NOT NULL, amount DECIMAL(10,2) NOT NULL,
amount_paid DECIMAL(10,2) DEFAULT 0.00, amount_paid DECIMAL(10,2) DEFAULT 0.00,
status VARCHAR(20) DEFAULT ''draft'', status VARCHAR(20) DEFAULT ''draft'',
period_start DATE,
period_end DATE,
assessment_group_id UUID,
journal_entry_id UUID, journal_entry_id UUID,
sent_at TIMESTAMPTZ, sent_at TIMESTAMPTZ,
paid_at TIMESTAMPTZ, paid_at TIMESTAMPTZ,
@@ -443,10 +449,10 @@ END LOOP;
-- ============================================================ -- ============================================================
-- 4b. Seed Assessment Groups -- 4b. Seed Assessment Groups
-- ============================================================ -- ============================================================
EXECUTE format('INSERT INTO %I.assessment_groups (name, description, regular_assessment, special_assessment, unit_count) VALUES EXECUTE format('INSERT INTO %I.assessment_groups (name, description, regular_assessment, special_assessment, unit_count, frequency, due_months, due_day) VALUES
(''Single Family Homes'', ''Standard single family detached homes (Units 1-20)'', 350.00, 0.00, 20), (''Single Family Homes'', ''Standard single family detached homes (Units 1-20)'', 350.00, 0.00, 20, ''monthly'', ''{1,2,3,4,5,6,7,8,9,10,11,12}'', 15),
(''Patio Homes'', ''Medium-sized patio homes (Units 21-35)'', 425.00, 0.00, 15), (''Patio Homes'', ''Medium-sized patio homes (Units 21-35)'', 1275.00, 0.00, 15, ''quarterly'', ''{1,4,7,10}'', 1),
(''Estate Lots'', ''Large estate lots (Units 36-50)'', 500.00, 75.00, 15) (''Estate Lots'', ''Large estate lots (Units 36-50)'', 6000.00, 900.00, 15, ''annual'', ''{3}'', 1)
', v_schema); ', v_schema);
-- ============================================================ -- ============================================================

View File

@@ -1,12 +1,12 @@
{ {
"name": "hoa-ledgeriq-frontend", "name": "hoa-ledgeriq-frontend",
"version": "2026.3.2-beta", "version": "2026.3.7-beta",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "hoa-ledgeriq-frontend", "name": "hoa-ledgeriq-frontend",
"version": "2026.3.2-beta", "version": "2026.3.7-beta",
"dependencies": { "dependencies": {
"@mantine/core": "^7.15.3", "@mantine/core": "^7.15.3",
"@mantine/dates": "^7.15.3", "@mantine/dates": "^7.15.3",

View File

@@ -1,6 +1,6 @@
{ {
"name": "hoa-ledgeriq-frontend", "name": "hoa-ledgeriq-frontend",
"version": "2026.3.2-beta", "version": "2026.3.7-beta",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar, Alert, Button } from '@mantine/core'; import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar, Alert, Button, ActionIcon, Tooltip } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { import {
IconLogout, IconLogout,
@@ -9,9 +9,12 @@ import {
IconUserCog, IconUserCog,
IconUsersGroup, IconUsersGroup,
IconEyeOff, IconEyeOff,
IconSun,
IconMoon,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
import { usePreferencesStore } from '../../stores/preferencesStore';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
import { AppTour } from '../onboarding/AppTour'; import { AppTour } from '../onboarding/AppTour';
import { OnboardingWizard } from '../onboarding/OnboardingWizard'; import { OnboardingWizard } from '../onboarding/OnboardingWizard';
@@ -20,6 +23,7 @@ import logoSrc from '../../assets/logo.svg';
export function AppLayout() { export function AppLayout() {
const [opened, { toggle, close }] = useDisclosure(); const [opened, { toggle, close }] = useDisclosure();
const { user, currentOrg, logout, impersonationOriginal, stopImpersonation } = useAuthStore(); const { user, currentOrg, logout, impersonationOriginal, stopImpersonation } = useAuthStore();
const { colorScheme, toggleColorScheme } = usePreferencesStore();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const isImpersonating = !!impersonationOriginal; const isImpersonating = !!impersonationOriginal;
@@ -108,6 +112,16 @@ export function AppLayout() {
{currentOrg && ( {currentOrg && (
<Text size="sm" c="dimmed">{currentOrg.name}</Text> <Text size="sm" c="dimmed">{currentOrg.name}</Text>
)} )}
<Tooltip label={colorScheme === 'dark' ? 'Light mode' : 'Dark mode'}>
<ActionIcon
variant="default"
size="lg"
onClick={toggleColorScheme}
aria-label="Toggle color scheme"
>
{colorScheme === 'dark' ? <IconSun size={18} /> : <IconMoon size={18} />}
</ActionIcon>
</Tooltip>
<Menu shadow="md" width={220}> <Menu shadow="md" width={220}>
<Menu.Target> <Menu.Target>
<UnstyledButton> <UnstyledButton>

View File

@@ -10,6 +10,7 @@ import '@mantine/dates/styles.css';
import '@mantine/notifications/styles.css'; import '@mantine/notifications/styles.css';
import { App } from './App'; import { App } from './App';
import { theme } from './theme/theme'; import { theme } from './theme/theme';
import { usePreferencesStore } from './stores/preferencesStore';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@@ -21,9 +22,11 @@ const queryClient = new QueryClient({
}, },
}); });
ReactDOM.createRoot(document.getElementById('root')!).render( function Root() {
<React.StrictMode> const colorScheme = usePreferencesStore((s) => s.colorScheme);
<MantineProvider theme={theme}>
return (
<MantineProvider theme={theme} forceColorScheme={colorScheme}>
<Notifications position="top-right" /> <Notifications position="top-right" />
<ModalsProvider> <ModalsProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
@@ -33,5 +36,11 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
</QueryClientProvider> </QueryClientProvider>
</ModalsProvider> </ModalsProvider>
</MantineProvider> </MantineProvider>
);
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Root />
</React.StrictMode>, </React.StrictMode>,
); );

View File

@@ -587,7 +587,7 @@ export function AccountsPage() {
{investments.filter(i => i.is_active).length > 0 && ( {investments.filter(i => i.is_active).length > 0 && (
<> <>
<Divider label="Investment Accounts" labelPosition="center" my="xs" /> <Divider label="Investment Accounts" labelPosition="center" my="xs" />
<InvestmentMiniTable investments={investments.filter(i => i.is_active)} onEdit={handleEditInvestment} /> <InvestmentMiniTable investments={investments.filter(i => i.is_active)} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
</> </>
)} )}
</Stack> </Stack>
@@ -605,7 +605,7 @@ export function AccountsPage() {
{operatingInvestments.length > 0 && ( {operatingInvestments.length > 0 && (
<> <>
<Divider label="Operating Investment Accounts" labelPosition="center" my="xs" /> <Divider label="Operating Investment Accounts" labelPosition="center" my="xs" />
<InvestmentMiniTable investments={operatingInvestments} onEdit={handleEditInvestment} /> <InvestmentMiniTable investments={operatingInvestments} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
</> </>
)} )}
</Stack> </Stack>
@@ -623,7 +623,7 @@ export function AccountsPage() {
{reserveInvestments.length > 0 && ( {reserveInvestments.length > 0 && (
<> <>
<Divider label="Reserve Investment Accounts" labelPosition="center" my="xs" /> <Divider label="Reserve Investment Accounts" labelPosition="center" my="xs" />
<InvestmentMiniTable investments={reserveInvestments} onEdit={handleEditInvestment} /> <InvestmentMiniTable investments={reserveInvestments} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
</> </>
)} )}
</Stack> </Stack>
@@ -1087,9 +1087,11 @@ function AccountTable({
function InvestmentMiniTable({ function InvestmentMiniTable({
investments, investments,
onEdit, onEdit,
isReadOnly = false,
}: { }: {
investments: Investment[]; investments: Investment[];
onEdit: (inv: Investment) => void; onEdit: (inv: Investment) => void;
isReadOnly?: boolean;
}) { }) {
const totalPrincipal = investments.reduce((s, i) => s + parseFloat(i.principal || '0'), 0); const totalPrincipal = investments.reduce((s, i) => s + parseFloat(i.principal || '0'), 0);
const totalValue = investments.reduce( const totalValue = investments.reduce(
@@ -1132,7 +1134,7 @@ function InvestmentMiniTable({
<Table.Th ta="right">Maturity Value</Table.Th> <Table.Th ta="right">Maturity Value</Table.Th>
<Table.Th>Maturity Date</Table.Th> <Table.Th>Maturity Date</Table.Th>
<Table.Th ta="right">Days Remaining</Table.Th> <Table.Th ta="right">Days Remaining</Table.Th>
<Table.Th></Table.Th> {!isReadOnly && <Table.Th></Table.Th>}
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
@@ -1182,13 +1184,15 @@ function InvestmentMiniTable({
'-' '-'
)} )}
</Table.Td> </Table.Td>
<Table.Td> {!isReadOnly && (
<Tooltip label="Edit investment"> <Table.Td>
<ActionIcon variant="subtle" onClick={() => onEdit(inv)}> <Tooltip label="Edit investment">
<IconEdit size={16} /> <ActionIcon variant="subtle" onClick={() => onEdit(inv)}>
</ActionIcon> <IconEdit size={16} />
</Tooltip> </ActionIcon>
</Table.Td> </Tooltip>
</Table.Td>
)}
</Table.Tr> </Table.Tr>
))} ))}
</Table.Tbody> </Table.Tbody>

View File

@@ -2,6 +2,7 @@ import { useState } from 'react';
import { import {
Title, Text, Card, Table, SimpleGrid, Group, Stack, Badge, Loader, Center, Title, Text, Card, Table, SimpleGrid, Group, Stack, Badge, Loader, Center,
ThemeIcon, Button, Modal, TextInput, NumberInput, Textarea, Select, ActionIcon, Tooltip, ThemeIcon, Button, Modal, TextInput, NumberInput, Textarea, Select, ActionIcon, Tooltip,
MultiSelect,
} from '@mantine/core'; } from '@mantine/core';
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
@@ -21,6 +22,8 @@ interface AssessmentGroup {
special_assessment: string; special_assessment: string;
unit_count: number; unit_count: number;
frequency: string; frequency: string;
due_months: number[];
due_day: number;
actual_unit_count: string; actual_unit_count: string;
monthly_operating_income: string; monthly_operating_income: string;
monthly_reserve_income: string; monthly_reserve_income: string;
@@ -49,6 +52,29 @@ const frequencyColors: Record<string, string> = {
annual: 'violet', annual: 'violet',
}; };
const MONTH_OPTIONS = [
{ value: '1', label: 'January' },
{ value: '2', label: 'February' },
{ value: '3', label: 'March' },
{ value: '4', label: 'April' },
{ value: '5', label: 'May' },
{ value: '6', label: 'June' },
{ value: '7', label: 'July' },
{ value: '8', label: 'August' },
{ value: '9', label: 'September' },
{ value: '10', label: 'October' },
{ value: '11', label: 'November' },
{ value: '12', label: 'December' },
];
const MONTH_ABBREV = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const DEFAULT_DUE_MONTHS: Record<string, string[]> = {
monthly: ['1','2','3','4','5','6','7','8','9','10','11','12'],
quarterly: ['1','4','7','10'],
annual: ['1'],
};
export function AssessmentGroupsPage() { export function AssessmentGroupsPage() {
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<AssessmentGroup | null>(null); const [editing, setEditing] = useState<AssessmentGroup | null>(null);
@@ -73,18 +99,31 @@ export function AssessmentGroupsPage() {
specialAssessment: 0, specialAssessment: 0,
unitCount: 0, unitCount: 0,
frequency: 'monthly', frequency: 'monthly',
dueMonths: DEFAULT_DUE_MONTHS.monthly,
dueDay: 1,
}, },
validate: { validate: {
name: (v) => (v.length > 0 ? null : 'Required'), name: (v) => (v.length > 0 ? null : 'Required'),
regularAssessment: (v) => (v >= 0 ? null : 'Must be >= 0'), regularAssessment: (v) => (v >= 0 ? null : 'Must be >= 0'),
dueMonths: (v, values) => {
if (values.frequency === 'quarterly' && v.length !== 4) return 'Quarterly requires exactly 4 months';
if (values.frequency === 'annual' && v.length !== 1) return 'Annual requires exactly 1 month';
return null;
},
dueDay: (v) => (v >= 1 && v <= 28 ? null : 'Must be 1-28'),
}, },
}); });
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: (values: any) => mutationFn: (values: any) => {
editing const payload = {
? api.put(`/assessment-groups/${editing.id}`, values) ...values,
: api.post('/assessment-groups', values), dueMonths: values.dueMonths.map(Number),
};
return editing
? api.put(`/assessment-groups/${editing.id}`, payload)
: api.post('/assessment-groups', payload);
},
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['assessment-groups'] }); queryClient.invalidateQueries({ queryKey: ['assessment-groups'] });
queryClient.invalidateQueries({ queryKey: ['assessment-groups-summary'] }); queryClient.invalidateQueries({ queryKey: ['assessment-groups-summary'] });
@@ -121,6 +160,9 @@ export function AssessmentGroupsPage() {
const handleEdit = (group: AssessmentGroup) => { const handleEdit = (group: AssessmentGroup) => {
setEditing(group); setEditing(group);
const dueMonths = group.due_months
? group.due_months.map(String)
: DEFAULT_DUE_MONTHS[group.frequency] || DEFAULT_DUE_MONTHS.monthly;
form.setValues({ form.setValues({
name: group.name, name: group.name,
description: group.description || '', description: group.description || '',
@@ -128,6 +170,8 @@ export function AssessmentGroupsPage() {
specialAssessment: parseFloat(group.special_assessment || '0'), specialAssessment: parseFloat(group.special_assessment || '0'),
unitCount: group.unit_count || 0, unitCount: group.unit_count || 0,
frequency: group.frequency || 'monthly', frequency: group.frequency || 'monthly',
dueMonths,
dueDay: group.due_day || 1,
}); });
open(); open();
}; };
@@ -138,6 +182,12 @@ export function AssessmentGroupsPage() {
open(); open();
}; };
const handleFrequencyChange = (value: string | null) => {
if (!value) return;
form.setFieldValue('frequency', value);
form.setFieldValue('dueMonths', DEFAULT_DUE_MONTHS[value] || DEFAULT_DUE_MONTHS.monthly);
};
const fmt = (v: string | number) => const fmt = (v: string | number) =>
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' }); parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
@@ -149,6 +199,11 @@ export function AssessmentGroupsPage() {
} }
}; };
const formatDueMonths = (months: number[], frequency: string) => {
if (!months || frequency === 'monthly') return 'Every month';
return months.map((m) => MONTH_ABBREV[m]).join(', ');
};
if (isLoading) return <Center h={300}><Loader /></Center>; if (isLoading) return <Center h={300}><Loader /></Center>;
return ( return (
@@ -219,6 +274,7 @@ export function AssessmentGroupsPage() {
<Table.Th>Group Name</Table.Th> <Table.Th>Group Name</Table.Th>
<Table.Th ta="center">Units</Table.Th> <Table.Th ta="center">Units</Table.Th>
<Table.Th>Frequency</Table.Th> <Table.Th>Frequency</Table.Th>
<Table.Th>Due Months</Table.Th>
<Table.Th ta="right">Regular Assessment</Table.Th> <Table.Th ta="right">Regular Assessment</Table.Th>
<Table.Th ta="right">Special Assessment</Table.Th> <Table.Th ta="right">Special Assessment</Table.Th>
<Table.Th ta="right">Monthly Equiv.</Table.Th> <Table.Th ta="right">Monthly Equiv.</Table.Th>
@@ -229,7 +285,7 @@ export function AssessmentGroupsPage() {
<Table.Tbody> <Table.Tbody>
{groups.length === 0 && ( {groups.length === 0 && (
<Table.Tr> <Table.Tr>
<Table.Td colSpan={8}> <Table.Td colSpan={9}>
<Text ta="center" c="dimmed" py="lg"> <Text ta="center" c="dimmed" py="lg">
No assessment groups yet. Create groups like "Single Family Homes", "Condos", etc. No assessment groups yet. Create groups like "Single Family Homes", "Condos", etc.
</Text> </Text>
@@ -263,6 +319,9 @@ export function AssessmentGroupsPage() {
{frequencyLabels[g.frequency] || 'Monthly'} {frequencyLabels[g.frequency] || 'Monthly'}
</Badge> </Badge>
</Table.Td> </Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{formatDueMonths(g.due_months, g.frequency)}</Text>
</Table.Td>
<Table.Td ta="right" ff="monospace"> <Table.Td ta="right" ff="monospace">
{fmt(g.regular_assessment)}{freqSuffix(g.frequency)} {fmt(g.regular_assessment)}{freqSuffix(g.frequency)}
</Table.Td> </Table.Td>
@@ -322,8 +381,22 @@ export function AssessmentGroupsPage() {
{ value: 'quarterly', label: 'Quarterly' }, { value: 'quarterly', label: 'Quarterly' },
{ value: 'annual', label: 'Annual' }, { value: 'annual', label: 'Annual' },
]} ]}
{...form.getInputProps('frequency')} value={form.values.frequency}
onChange={handleFrequencyChange}
/> />
{form.values.frequency !== 'monthly' && (
<MultiSelect
label={form.values.frequency === 'quarterly' ? 'Billing Quarters (select 4 months)' : 'Due Month'}
description={form.values.frequency === 'quarterly'
? 'Select the first month of each quarter when assessments are due'
: 'Select the month when the annual assessment is due'}
data={MONTH_OPTIONS}
value={form.values.dueMonths}
onChange={(v) => form.setFieldValue('dueMonths', v)}
error={form.errors.dueMonths}
maxValues={form.values.frequency === 'annual' ? 1 : 4}
/>
)}
<Group grow> <Group grow>
<NumberInput <NumberInput
label={`Regular Assessment (per unit${freqSuffix(form.values.frequency)})`} label={`Regular Assessment (per unit${freqSuffix(form.values.frequency)})`}
@@ -340,7 +413,16 @@ export function AssessmentGroupsPage() {
{...form.getInputProps('specialAssessment')} {...form.getInputProps('specialAssessment')}
/> />
</Group> </Group>
<NumberInput label="Expected Unit Count" min={0} {...form.getInputProps('unitCount')} /> <Group grow>
<NumberInput label="Expected Unit Count" min={0} {...form.getInputProps('unitCount')} />
<NumberInput
label="Due Day of Month"
description="Day invoices are due (1-28)"
min={1}
max={28}
{...form.getInputProps('dueDay')}
/>
</Group>
<Button type="submit" loading={saveMutation.isPending}> <Button type="submit" loading={saveMutation.isPending}>
{editing ? 'Update' : 'Create'} {editing ? 'Update' : 'Create'}
</Button> </Button>

View File

@@ -72,9 +72,10 @@ interface KanbanCardProps {
project: Project; project: Project;
onEdit: (p: Project) => void; onEdit: (p: Project) => void;
onDragStart: (e: DragEvent<HTMLDivElement>, project: Project) => void; onDragStart: (e: DragEvent<HTMLDivElement>, project: Project) => void;
isReadOnly?: boolean;
} }
function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) { function KanbanCard({ project, onEdit, onDragStart, isReadOnly }: KanbanCardProps) {
const plannedLabel = formatPlannedDate(project.planned_date); const plannedLabel = formatPlannedDate(project.planned_date);
// For projects in the Future bucket with a specific year, show the year // For projects in the Future bucket with a specific year, show the year
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
@@ -86,21 +87,23 @@ function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
padding="sm" padding="sm"
radius="md" radius="md"
withBorder withBorder
draggable draggable={!isReadOnly}
onDragStart={(e) => onDragStart(e, project)} onDragStart={!isReadOnly ? (e) => onDragStart(e, project) : undefined}
style={{ cursor: 'grab', userSelect: 'none' }} style={{ cursor: isReadOnly ? 'default' : 'grab', userSelect: 'none' }}
mb="xs" mb="xs"
> >
<Group justify="space-between" wrap="nowrap" mb={4}> <Group justify="space-between" wrap="nowrap" mb={4}>
<Group gap={6} wrap="nowrap" style={{ overflow: 'hidden' }}> <Group gap={6} wrap="nowrap" style={{ overflow: 'hidden' }}>
<IconGripVertical size={14} style={{ flexShrink: 0, color: 'var(--mantine-color-dimmed)' }} /> {!isReadOnly && <IconGripVertical size={14} style={{ flexShrink: 0, color: 'var(--mantine-color-dimmed)' }} />}
<Text fw={600} size="sm" truncate> <Text fw={600} size="sm" truncate>
{project.name} {project.name}
</Text> </Text>
</Group> </Group>
<ActionIcon variant="subtle" size="sm" onClick={() => onEdit(project)}> {!isReadOnly && (
<IconEdit size={14} /> <ActionIcon variant="subtle" size="sm" onClick={() => onEdit(project)}>
</ActionIcon> <IconEdit size={14} />
</ActionIcon>
)}
</Group> </Group>
<Group gap={6} mb={6}> <Group gap={6} mb={6}>
@@ -148,11 +151,12 @@ interface KanbanColumnProps {
isDragOver: boolean; isDragOver: boolean;
onDragOverHandler: (e: DragEvent<HTMLDivElement>, year: number) => void; onDragOverHandler: (e: DragEvent<HTMLDivElement>, year: number) => void;
onDragLeave: () => void; onDragLeave: () => void;
isReadOnly?: boolean;
} }
function KanbanColumn({ function KanbanColumn({
year, projects, onEdit, onDragStart, onDrop, year, projects, onEdit, onDragStart, onDrop,
isDragOver, onDragOverHandler, onDragLeave, isDragOver, onDragOverHandler, onDragLeave, isReadOnly,
}: KanbanColumnProps) { }: KanbanColumnProps) {
const totalEst = projects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0); const totalEst = projects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
const isFuture = year === FUTURE_YEAR; const isFuture = year === FUTURE_YEAR;
@@ -178,9 +182,9 @@ function KanbanColumn({
border: isDragOver ? '2px dashed var(--mantine-color-blue-4)' : undefined, border: isDragOver ? '2px dashed var(--mantine-color-blue-4)' : undefined,
transition: 'background-color 150ms ease, border 150ms ease', transition: 'background-color 150ms ease, border 150ms ease',
}} }}
onDragOver={(e) => onDragOverHandler(e, year)} onDragOver={!isReadOnly ? (e) => onDragOverHandler(e, year) : undefined}
onDragLeave={onDragLeave} onDragLeave={!isReadOnly ? onDragLeave : undefined}
onDrop={(e) => onDrop(e, year)} onDrop={!isReadOnly ? (e) => onDrop(e, year) : undefined}
> >
<Group justify="space-between" mb="sm"> <Group justify="space-between" mb="sm">
<Title order={5}>{yearLabel(year)}</Title> <Title order={5}>{yearLabel(year)}</Title>
@@ -199,7 +203,7 @@ function KanbanColumn({
<Box style={{ flex: 1, minHeight: 60 }}> <Box style={{ flex: 1, minHeight: 60 }}>
{projects.length === 0 ? ( {projects.length === 0 ? (
<Text size="xs" c="dimmed" ta="center" py="lg"> <Text size="xs" c="dimmed" ta="center" py="lg">
Drop projects here {isReadOnly ? 'No projects' : 'Drop projects here'}
</Text> </Text>
) : useWideLayout ? ( ) : useWideLayout ? (
<div style={{ <div style={{
@@ -208,12 +212,12 @@ function KanbanColumn({
gap: 'var(--mantine-spacing-xs)', gap: 'var(--mantine-spacing-xs)',
}}> }}>
{projects.map((p) => ( {projects.map((p) => (
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} /> <KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} isReadOnly={isReadOnly} />
))} ))}
</div> </div>
) : ( ) : (
projects.map((p) => ( projects.map((p) => (
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} /> <KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} isReadOnly={isReadOnly} />
)) ))
)} )}
</Box> </Box>
@@ -595,6 +599,7 @@ export function CapitalProjectsPage() {
isDragOver={dragOverYear === year} isDragOver={dragOverYear === year}
onDragOverHandler={handleDragOver} onDragOverHandler={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
isReadOnly={isReadOnly}
/> />
); );
})} })}

View File

@@ -18,7 +18,7 @@ import {
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore, useIsReadOnly } from '../../stores/authStore';
import api from '../../services/api'; import api from '../../services/api';
interface HealthScore { interface HealthScore {
@@ -311,6 +311,7 @@ interface DashboardData {
export function DashboardPage() { export function DashboardPage() {
const currentOrg = useAuthStore((s) => s.currentOrg); const currentOrg = useAuthStore((s) => s.currentOrg);
const isReadOnly = useIsReadOnly();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Track whether a refresh is in progress (per score type) for async polling // Track whether a refresh is in progress (per score type) for async polling
@@ -414,7 +415,6 @@ export function DashboardPage() {
<Center h={200}><Loader /></Center> <Center h={200}><Loader /></Center>
) : ( ) : (
<> <>
<Text size="sm" fw={600} c="dimmed">AI Health Scores</Text>
<SimpleGrid cols={{ base: 1, md: 2 }}> <SimpleGrid cols={{ base: 1, md: 2 }}>
<HealthScoreCard <HealthScoreCard
score={healthScores?.operating || null} score={healthScores?.operating || null}
@@ -425,7 +425,7 @@ export function DashboardPage() {
</ThemeIcon> </ThemeIcon>
} }
isRefreshing={operatingRefreshing} isRefreshing={operatingRefreshing}
onRefresh={handleRefreshOperating} onRefresh={!isReadOnly ? handleRefreshOperating : undefined}
lastFailed={!!healthScores?.operating_last_failed} lastFailed={!!healthScores?.operating_last_failed}
/> />
<HealthScoreCard <HealthScoreCard
@@ -437,7 +437,7 @@ export function DashboardPage() {
</ThemeIcon> </ThemeIcon>
} }
isRefreshing={reserveRefreshing} isRefreshing={reserveRefreshing}
onRefresh={handleRefreshReserve} onRefresh={!isReadOnly ? handleRefreshReserve : undefined}
lastFailed={!!healthScores?.reserve_last_failed} lastFailed={!!healthScores?.reserve_last_failed}
/> />
</SimpleGrid> </SimpleGrid>

View File

@@ -36,6 +36,7 @@ import {
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import api from '../../services/api'; import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
// ── Types ── // ── Types ──
@@ -347,6 +348,7 @@ function RecommendationsDisplay({
export function InvestmentPlanningPage() { export function InvestmentPlanningPage() {
const [ratesExpanded, setRatesExpanded] = useState(true); const [ratesExpanded, setRatesExpanded] = useState(true);
const [isTriggering, setIsTriggering] = useState(false); const [isTriggering, setIsTriggering] = useState(false);
const isReadOnly = useIsReadOnly();
// Load financial snapshot on mount // Load financial snapshot on mount
const { data: snapshot, isLoading: snapshotLoading } = useQuery<FinancialSnapshot>({ const { data: snapshot, isLoading: snapshotLoading } = useQuery<FinancialSnapshot>({
@@ -696,15 +698,17 @@ export function InvestmentPlanningPage() {
</Text> </Text>
</div> </div>
</Group> </Group>
<Button {!isReadOnly && (
leftSection={<IconSparkles size={16} />} <Button
onClick={handleTriggerAI} leftSection={<IconSparkles size={16} />}
loading={isProcessing} onClick={handleTriggerAI}
variant="gradient" loading={isProcessing}
gradient={{ from: 'grape', to: 'violet' }} variant="gradient"
> gradient={{ from: 'grape', to: 'violet' }}
{aiResult ? 'Refresh Recommendations' : 'Get AI Recommendations'} >
</Button> {aiResult ? 'Refresh Recommendations' : 'Get AI Recommendations'}
</Button>
)}
</Group> </Group>
{/* Processing State */} {/* Processing State */}

View File

@@ -1,30 +1,71 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { import {
Title, Table, Group, Button, Stack, Text, Badge, Modal, Title, Table, Group, Button, Stack, Text, Badge, Modal,
NumberInput, Select, Loader, Center, Card, NumberInput, Select, Loader, Center, Card, Alert,
} from '@mantine/core'; } from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { IconFileInvoice, IconSend } from '@tabler/icons-react'; import { IconSend, IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api'; import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
interface Invoice { interface Invoice {
id: string; invoice_number: string; unit_number: string; unit_id: string; id: string; invoice_number: string; unit_number: string; unit_id: string;
invoice_date: string; due_date: string; invoice_type: string; invoice_date: string; due_date: string; invoice_type: string;
description: string; amount: string; amount_paid: string; balance_due: string; description: string; amount: string; amount_paid: string; balance_due: string;
status: string; status: string; period_start: string; period_end: string;
assessment_group_name: string; frequency: string; owner_name: string;
}
interface PreviewGroup {
id: string;
name: string;
frequency: string;
active_units: number;
regular_assessment: string;
special_assessment: string;
is_billing_month: boolean;
total_amount: number;
period_description: string;
}
interface Preview {
month: number;
year: number;
month_name: string;
groups: PreviewGroup[];
summary: {
total_groups_billing: number;
total_invoices: number;
total_amount: number;
};
} }
const statusColors: Record<string, string> = { const statusColors: Record<string, string> = {
draft: 'gray', sent: 'blue', paid: 'green', partial: 'yellow', overdue: 'red', void: 'dark', draft: 'gray', pending: 'blue', paid: 'green', partial: 'yellow', overdue: 'red', void: 'dark',
};
const frequencyColors: Record<string, string> = {
monthly: 'blue', quarterly: 'teal', annual: 'violet',
};
const fmt = (v: string | number) => parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
/** Extract last name from "First Last" format */
const getLastName = (ownerName: string | null) => {
if (!ownerName) return '-';
const parts = ownerName.trim().split(/\s+/);
return parts.length > 1 ? parts[parts.length - 1] : ownerName;
}; };
export function InvoicesPage() { export function InvoicesPage() {
const [bulkOpened, { open: openBulk, close: closeBulk }] = useDisclosure(false); const [bulkOpened, { open: openBulk, close: closeBulk }] = useDisclosure(false);
const [preview, setPreview] = useState<Preview | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({ const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
queryKey: ['invoices'], queryKey: ['invoices'],
@@ -35,13 +76,36 @@ export function InvoicesPage() {
initialValues: { month: new Date().getMonth() + 1, year: new Date().getFullYear() }, initialValues: { month: new Date().getMonth() + 1, year: new Date().getFullYear() },
}); });
// Fetch preview when month/year changes
const fetchPreview = async (month: number, year: number) => {
setPreviewLoading(true);
try {
const { data } = await api.post('/invoices/generate-preview', { month, year });
setPreview(data);
} catch {
setPreview(null);
}
setPreviewLoading(false);
};
useEffect(() => {
if (bulkOpened) {
fetchPreview(bulkForm.values.month, bulkForm.values.year);
}
}, [bulkOpened, bulkForm.values.month, bulkForm.values.year]);
const bulkMutation = useMutation({ const bulkMutation = useMutation({
mutationFn: (values: any) => api.post('/invoices/generate-bulk', values), mutationFn: (values: any) => api.post('/invoices/generate-bulk', values),
onSuccess: (res) => { onSuccess: (res) => {
queryClient.invalidateQueries({ queryKey: ['invoices'] }); queryClient.invalidateQueries({ queryKey: ['invoices'] });
queryClient.invalidateQueries({ queryKey: ['journal-entries'] }); queryClient.invalidateQueries({ queryKey: ['journal-entries'] });
notifications.show({ message: `Generated ${res.data.created} invoices`, color: 'green' }); const groupInfo = res.data.groups?.map((g: any) => `${g.group_name}: ${g.invoices_created}`).join(', ') || '';
notifications.show({
message: `Generated ${res.data.created} invoices${groupInfo ? ` (${groupInfo})` : ''}`,
color: 'green',
});
closeBulk(); closeBulk();
setPreview(null);
}, },
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); }, onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
}); });
@@ -54,8 +118,6 @@ export function InvoicesPage() {
}, },
}); });
const fmt = (v: string) => parseFloat(v || '0').toLocaleString('en-US', { style: 'currency', currency: 'USD' });
if (isLoading) return <Center h={300}><Loader /></Center>; if (isLoading) return <Center h={300}><Loader /></Center>;
const totalOutstanding = invoices.filter(i => i.status !== 'paid' && i.status !== 'void').reduce((s, i) => s + parseFloat(i.balance_due || '0'), 0); const totalOutstanding = invoices.filter(i => i.status !== 'paid' && i.status !== 'void').reduce((s, i) => s + parseFloat(i.balance_due || '0'), 0);
@@ -64,20 +126,24 @@ export function InvoicesPage() {
<Stack> <Stack>
<Group justify="space-between"> <Group justify="space-between">
<Title order={2}>Invoices</Title> <Title order={2}>Invoices</Title>
<Group> {!isReadOnly && (
<Button variant="outline" onClick={() => lateFeesMutation.mutate()} loading={lateFeesMutation.isPending}>Apply Late Fees</Button> <Group>
<Button leftSection={<IconSend size={16} />} onClick={openBulk}>Generate Monthly Invoices</Button> <Button variant="outline" onClick={() => lateFeesMutation.mutate()} loading={lateFeesMutation.isPending}>Apply Late Fees</Button>
</Group> <Button leftSection={<IconSend size={16} />} onClick={openBulk}>Generate Invoices</Button>
</Group>
)}
</Group> </Group>
<Group> <Group>
<Card withBorder p="sm"><Text size="xs" c="dimmed">Total Invoices</Text><Text fw={700}>{invoices.length}</Text></Card> <Card withBorder p="sm"><Text size="xs" c="dimmed">Total Invoices</Text><Text fw={700}>{invoices.length}</Text></Card>
<Card withBorder p="sm"><Text size="xs" c="dimmed">Outstanding</Text><Text fw={700} c="red">{fmt(String(totalOutstanding))}</Text></Card> <Card withBorder p="sm"><Text size="xs" c="dimmed">Outstanding</Text><Text fw={700} c="red">{fmt(totalOutstanding)}</Text></Card>
</Group> </Group>
<Table striped highlightOnHover> <Table striped highlightOnHover>
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th>Invoice #</Table.Th><Table.Th>Unit</Table.Th><Table.Th>Date</Table.Th> <Table.Th>Invoice #</Table.Th><Table.Th>Unit</Table.Th><Table.Th>Owner</Table.Th>
<Table.Th>Due</Table.Th><Table.Th>Type</Table.Th><Table.Th ta="right">Amount</Table.Th> <Table.Th>Group</Table.Th><Table.Th>Date</Table.Th>
<Table.Th>Due</Table.Th><Table.Th>Period</Table.Th>
<Table.Th ta="right">Amount</Table.Th>
<Table.Th ta="right">Paid</Table.Th><Table.Th ta="right">Balance</Table.Th><Table.Th>Status</Table.Th> <Table.Th ta="right">Paid</Table.Th><Table.Th ta="right">Balance</Table.Th><Table.Th>Status</Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
@@ -86,27 +152,104 @@ export function InvoicesPage() {
<Table.Tr key={i.id}> <Table.Tr key={i.id}>
<Table.Td fw={500}>{i.invoice_number}</Table.Td> <Table.Td fw={500}>{i.invoice_number}</Table.Td>
<Table.Td>{i.unit_number}</Table.Td> <Table.Td>{i.unit_number}</Table.Td>
<Table.Td>{getLastName(i.owner_name)}</Table.Td>
<Table.Td>
{i.assessment_group_name ? (
<Badge size="sm" variant="light" color={frequencyColors[i.frequency] || 'gray'}>
{i.assessment_group_name}
</Badge>
) : (
<Badge size="sm" variant="light">{i.invoice_type}</Badge>
)}
</Table.Td>
<Table.Td>{new Date(i.invoice_date).toLocaleDateString()}</Table.Td> <Table.Td>{new Date(i.invoice_date).toLocaleDateString()}</Table.Td>
<Table.Td>{new Date(i.due_date).toLocaleDateString()}</Table.Td> <Table.Td>{new Date(i.due_date).toLocaleDateString()}</Table.Td>
<Table.Td><Badge size="sm" variant="light">{i.invoice_type}</Badge></Table.Td> <Table.Td>
{i.period_start && i.period_end ? (
<Text size="xs" c="dimmed">
{new Date(i.period_start).toLocaleDateString(undefined, { month: 'short', year: 'numeric' })}
{i.period_start !== i.period_end && (
<> - {new Date(i.period_end).toLocaleDateString(undefined, { month: 'short', year: 'numeric' })}</>
)}
</Text>
) : (
<Text size="xs" c="dimmed">-</Text>
)}
</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(i.amount)}</Table.Td> <Table.Td ta="right" ff="monospace">{fmt(i.amount)}</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(i.amount_paid)}</Table.Td> <Table.Td ta="right" ff="monospace">{fmt(i.amount_paid)}</Table.Td>
<Table.Td ta="right" ff="monospace" fw={500}>{fmt(i.balance_due)}</Table.Td> <Table.Td ta="right" ff="monospace" fw={500}>{fmt(i.balance_due)}</Table.Td>
<Table.Td><Badge color={statusColors[i.status] || 'gray'} size="sm">{i.status}</Badge></Table.Td> <Table.Td><Badge color={statusColors[i.status] || 'gray'} size="sm">{i.status}</Badge></Table.Td>
</Table.Tr> </Table.Tr>
))} ))}
{invoices.length === 0 && <Table.Tr><Table.Td colSpan={9}><Text ta="center" c="dimmed" py="lg">No invoices yet</Text></Table.Td></Table.Tr>} {invoices.length === 0 && <Table.Tr><Table.Td colSpan={11}><Text ta="center" c="dimmed" py="lg">No invoices yet</Text></Table.Td></Table.Tr>}
</Table.Tbody> </Table.Tbody>
</Table> </Table>
<Modal opened={bulkOpened} onClose={closeBulk} title="Generate Monthly Assessments">
<Modal opened={bulkOpened} onClose={() => { closeBulk(); setPreview(null); }} title="Generate Assessments" size="lg">
<form onSubmit={bulkForm.onSubmit((v) => bulkMutation.mutate(v))}> <form onSubmit={bulkForm.onSubmit((v) => bulkMutation.mutate(v))}>
<Stack> <Stack>
<Group grow> <Group grow>
<Select label="Month" data={Array.from({length:12},(_,i)=>({value:String(i+1),label:new Date(2026,i).toLocaleString('default',{month:'long'})}))} value={String(bulkForm.values.month)} onChange={(v)=>bulkForm.setFieldValue('month',Number(v))} /> <Select label="Month" data={Array.from({length:12},(_,i)=>({value:String(i+1),label:new Date(2026,i).toLocaleString('default',{month:'long'})}))} value={String(bulkForm.values.month)} onChange={(v)=>bulkForm.setFieldValue('month',Number(v))} />
<NumberInput label="Year" {...bulkForm.getInputProps('year')} /> <NumberInput label="Year" {...bulkForm.getInputProps('year')} />
</Group> </Group>
<Text size="sm" c="dimmed">This will generate invoices for all active units based on their monthly assessment amount.</Text>
<Button type="submit" loading={bulkMutation.isPending}>Generate Invoices</Button> {previewLoading && <Center py="md"><Loader size="sm" /></Center>}
{preview && !previewLoading && (
<Stack gap="xs">
<Text size="sm" fw={600}>Billing Preview for {preview.month_name} {preview.year}</Text>
{preview.groups.map((g) => (
<Card key={g.id} withBorder p="xs" style={{ opacity: g.is_billing_month ? 1 : 0.5 }}>
<Group justify="space-between">
<Group gap="xs">
{g.is_billing_month && g.active_units > 0
? <IconCheck size={16} color="green" />
: <IconX size={16} color="gray" />
}
<div>
<Group gap={6}>
<Text size="sm" fw={500}>{g.name}</Text>
<Badge size="xs" color={frequencyColors[g.frequency]} variant="light">
{g.frequency}
</Badge>
</Group>
<Text size="xs" c="dimmed">
{g.is_billing_month
? `${g.active_units} units - ${g.period_description}`
: `Not a billing month for this group`
}
</Text>
</div>
</Group>
{g.is_billing_month && (
<Text size="sm" fw={500} ff="monospace">{fmt(g.total_amount)}</Text>
)}
</Group>
</Card>
))}
{preview.summary.total_invoices > 0 ? (
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
Will generate {preview.summary.total_invoices} invoices across{' '}
{preview.summary.total_groups_billing} group(s) totaling {fmt(preview.summary.total_amount)}
</Alert>
) : (
<Alert icon={<IconInfoCircle size={16} />} color="yellow" variant="light">
No assessment groups have billing scheduled for {preview.month_name}. No invoices will be generated.
</Alert>
)}
</Stack>
)}
<Button
type="submit"
loading={bulkMutation.isPending}
disabled={!preview || preview.summary.total_invoices === 0}
>
Generate {preview?.summary.total_invoices || 0} Invoices
</Button>
</Stack> </Stack>
</form> </form>
</Modal> </Modal>

View File

@@ -1,13 +1,13 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Title, Table, Group, Button, Stack, Text, Badge, Modal, Title, Table, Group, Button, Stack, Text, Badge, Modal,
NumberInput, Select, TextInput, Loader, Center, NumberInput, Select, TextInput, Loader, Center, ActionIcon, Tooltip,
} from '@mantine/core'; } from '@mantine/core';
import { DateInput } from '@mantine/dates'; import { DateInput } from '@mantine/dates';
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { IconPlus } from '@tabler/icons-react'; import { IconPlus, IconEdit, IconTrash } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api'; import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore'; import { useIsReadOnly } from '../../stores/authStore';
@@ -15,11 +15,13 @@ import { useIsReadOnly } from '../../stores/authStore';
interface Payment { interface Payment {
id: string; unit_id: string; unit_number: string; invoice_id: string; id: string; unit_id: string; unit_number: string; invoice_id: string;
invoice_number: string; payment_date: string; amount: string; invoice_number: string; payment_date: string; amount: string;
payment_method: string; reference_number: string; status: string; payment_method: string; reference_number: string; status: string; notes: string;
} }
export function PaymentsPage() { export function PaymentsPage() {
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<Payment | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<Payment | null>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly(); const isReadOnly = useIsReadOnly();
@@ -39,10 +41,18 @@ export function PaymentsPage() {
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
invoice_id: '', amount: 0, payment_method: 'check', invoice_id: '', amount: 0, payment_method: 'check',
reference_number: '', payment_date: new Date(), reference_number: '', payment_date: new Date(), notes: '',
}, },
}); });
const invalidateAll = () => {
queryClient.invalidateQueries({ queryKey: ['payments'] });
queryClient.invalidateQueries({ queryKey: ['invoices'] });
queryClient.invalidateQueries({ queryKey: ['invoices-unpaid'] });
queryClient.invalidateQueries({ queryKey: ['accounts'] });
queryClient.invalidateQueries({ queryKey: ['journal-entries'] });
};
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (values: any) => { mutationFn: (values: any) => {
const inv = invoices.find((i: any) => i.id === values.invoice_id); const inv = invoices.find((i: any) => i.id === values.invoice_id);
@@ -53,22 +63,88 @@ export function PaymentsPage() {
}); });
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['payments'] }); invalidateAll();
queryClient.invalidateQueries({ queryKey: ['invoices'] });
queryClient.invalidateQueries({ queryKey: ['invoices-unpaid'] });
queryClient.invalidateQueries({ queryKey: ['accounts'] });
notifications.show({ message: 'Payment recorded', color: 'green' }); notifications.show({ message: 'Payment recorded', color: 'green' });
close(); form.reset(); close(); setEditing(null); form.reset();
}, },
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); }, onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
}); });
const updateMutation = useMutation({
mutationFn: (values: any) => {
return api.put(`/payments/${editing!.id}`, {
payment_date: values.payment_date.toISOString().split('T')[0],
amount: values.amount,
payment_method: values.payment_method,
reference_number: values.reference_number,
notes: values.notes,
});
},
onSuccess: () => {
invalidateAll();
notifications.show({ message: 'Payment updated', color: 'green' });
close(); setEditing(null); form.reset();
},
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
});
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/payments/${id}`),
onSuccess: () => {
invalidateAll();
notifications.show({ message: 'Payment deleted', color: 'orange' });
setDeleteConfirm(null);
close(); setEditing(null); form.reset();
},
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
});
const handleEdit = (payment: Payment) => {
setEditing(payment);
form.setValues({
invoice_id: payment.invoice_id || '',
amount: parseFloat(payment.amount || '0'),
payment_method: payment.payment_method || 'check',
reference_number: payment.reference_number || '',
payment_date: new Date(payment.payment_date),
notes: payment.notes || '',
});
open();
};
const handleNew = () => {
setEditing(null);
form.reset();
open();
};
const handleSubmit = (values: any) => {
if (editing) {
updateMutation.mutate(values);
} else {
createMutation.mutate(values);
}
};
const fmt = (v: string) => parseFloat(v || '0').toLocaleString('en-US', { style: 'currency', currency: 'USD' }); const fmt = (v: string) => parseFloat(v || '0').toLocaleString('en-US', { style: 'currency', currency: 'USD' });
const invoiceOptions = invoices.map((i: any) => ({ const formatPeriod = (inv: any) => {
value: i.id, if (inv.period_start && inv.period_end) {
label: `${i.invoice_number} - ${i.unit_number || 'Unit'} - Balance: $${parseFloat(i.balance_due || i.amount).toFixed(2)}`, const start = new Date(inv.period_start).toLocaleDateString(undefined, { month: 'short' });
})); const end = new Date(inv.period_end).toLocaleDateString(undefined, { month: 'short', year: 'numeric' });
return inv.period_start === inv.period_end ? start : `${start}-${end}`;
}
return '';
};
const invoiceOptions = invoices.map((i: any) => {
const period = formatPeriod(i);
const periodStr = period ? ` - ${period}` : '';
return {
value: i.id,
label: `${i.invoice_number} - ${i.unit_number || 'Unit'}${periodStr} - Balance: $${parseFloat(i.balance_due || i.amount).toFixed(2)}`,
};
});
if (isLoading) return <Center h={300}><Loader /></Center>; if (isLoading) return <Center h={300}><Loader /></Center>;
@@ -76,7 +152,7 @@ export function PaymentsPage() {
<Stack> <Stack>
<Group justify="space-between"> <Group justify="space-between">
<Title order={2}>Payments</Title> <Title order={2}>Payments</Title>
{!isReadOnly && <Button leftSection={<IconPlus size={16} />} onClick={open}>Record Payment</Button>} {!isReadOnly && <Button leftSection={<IconPlus size={16} />} onClick={handleNew}>Record Payment</Button>}
</Group> </Group>
<Table striped highlightOnHover> <Table striped highlightOnHover>
<Table.Thead> <Table.Thead>
@@ -84,6 +160,7 @@ export function PaymentsPage() {
<Table.Th>Date</Table.Th><Table.Th>Unit</Table.Th><Table.Th>Invoice</Table.Th> <Table.Th>Date</Table.Th><Table.Th>Unit</Table.Th><Table.Th>Invoice</Table.Th>
<Table.Th ta="right">Amount</Table.Th><Table.Th>Method</Table.Th> <Table.Th ta="right">Amount</Table.Th><Table.Th>Method</Table.Th>
<Table.Th>Reference</Table.Th><Table.Th>Status</Table.Th> <Table.Th>Reference</Table.Th><Table.Th>Status</Table.Th>
{!isReadOnly && <Table.Th></Table.Th>}
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
@@ -96,18 +173,34 @@ export function PaymentsPage() {
<Table.Td><Badge size="sm" variant="light">{p.payment_method}</Badge></Table.Td> <Table.Td><Badge size="sm" variant="light">{p.payment_method}</Badge></Table.Td>
<Table.Td>{p.reference_number}</Table.Td> <Table.Td>{p.reference_number}</Table.Td>
<Table.Td><Badge color={p.status === 'completed' ? 'green' : 'yellow'} size="sm">{p.status}</Badge></Table.Td> <Table.Td><Badge color={p.status === 'completed' ? 'green' : 'yellow'} size="sm">{p.status}</Badge></Table.Td>
{!isReadOnly && (
<Table.Td>
<Tooltip label="Edit payment">
<ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
</Table.Td>
)}
</Table.Tr> </Table.Tr>
))} ))}
{payments.length === 0 && ( {payments.length === 0 && (
<Table.Tr><Table.Td colSpan={7}><Text ta="center" c="dimmed" py="lg">No payments recorded yet</Text></Table.Td></Table.Tr> <Table.Tr><Table.Td colSpan={isReadOnly ? 7 : 8}><Text ta="center" c="dimmed" py="lg">No payments recorded yet</Text></Table.Td></Table.Tr>
)} )}
</Table.Tbody> </Table.Tbody>
</Table> </Table>
<Modal opened={opened} onClose={close} title="Record Payment">
<form onSubmit={form.onSubmit((v) => createMutation.mutate(v))}> {/* Create / Edit Payment Modal */}
<Modal opened={opened} onClose={() => { close(); setEditing(null); form.reset(); }} title={editing ? 'Edit Payment' : 'Record Payment'}>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack> <Stack>
<Select label="Invoice" required data={invoiceOptions} searchable {!editing && (
{...form.getInputProps('invoice_id')} /> <Select label="Invoice" required data={invoiceOptions} searchable
{...form.getInputProps('invoice_id')} />
)}
{editing && (
<TextInput label="Invoice" value={editing.invoice_number || 'N/A'} disabled />
)}
<DateInput label="Payment Date" required {...form.getInputProps('payment_date')} /> <DateInput label="Payment Date" required {...form.getInputProps('payment_date')} />
<NumberInput label="Amount" required prefix="$" decimalScale={2} min={0.01} <NumberInput label="Amount" required prefix="$" decimalScale={2} min={0.01}
{...form.getInputProps('amount')} /> {...form.getInputProps('amount')} />
@@ -118,10 +211,60 @@ export function PaymentsPage() {
]} {...form.getInputProps('payment_method')} /> ]} {...form.getInputProps('payment_method')} />
<TextInput label="Reference Number" placeholder="Check # or transaction ID" <TextInput label="Reference Number" placeholder="Check # or transaction ID"
{...form.getInputProps('reference_number')} /> {...form.getInputProps('reference_number')} />
<Button type="submit" loading={createMutation.isPending}>Record Payment</Button> <TextInput label="Notes" placeholder="Optional notes"
{...form.getInputProps('notes')} />
<Group justify="space-between">
{editing ? (
<>
<Button
variant="outline"
color="red"
leftSection={<IconTrash size={16} />}
onClick={() => setDeleteConfirm(editing)}
>
Delete Payment
</Button>
<Button type="submit" loading={updateMutation.isPending}>
Update Payment
</Button>
</>
) : (
<Button type="submit" fullWidth loading={createMutation.isPending}>Record Payment</Button>
)}
</Group>
</Stack> </Stack>
</form> </form>
</Modal> </Modal>
{/* Delete Confirmation Modal */}
<Modal
opened={!!deleteConfirm}
onClose={() => setDeleteConfirm(null)}
title="Delete Payment"
size="sm"
>
<Stack>
<Text size="sm">
Are you sure you want to delete this payment of{' '}
<Text span fw={700}>{deleteConfirm ? fmt(deleteConfirm.amount) : ''}</Text>{' '}
for unit {deleteConfirm?.unit_number}?
</Text>
<Text size="xs" c="dimmed">
This will also remove the associated journal entry and recalculate the invoice balance.
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={() => setDeleteConfirm(null)}>Cancel</Button>
<Button
color="red"
loading={deleteMutation.isPending}
onClick={() => deleteConfirm && deleteMutation.mutate(deleteConfirm.id)}
>
Delete Payment
</Button>
</Group>
</Stack>
</Modal>
</Stack> </Stack>
); );
} }

View File

@@ -6,9 +6,11 @@ import {
IconUser, IconPalette, IconClock, IconBell, IconEye, IconUser, IconPalette, IconClock, IconBell, IconEye,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
import { usePreferencesStore } from '../../stores/preferencesStore';
export function UserPreferencesPage() { export function UserPreferencesPage() {
const { user, currentOrg } = useAuthStore(); const { user, currentOrg } = useAuthStore();
const { colorScheme, toggleColorScheme } = usePreferencesStore();
return ( return (
<Stack> <Stack>
@@ -66,7 +68,10 @@ export function UserPreferencesPage() {
<Text size="sm">Dark Mode</Text> <Text size="sm">Dark Mode</Text>
<Text size="xs" c="dimmed">Switch to dark color theme</Text> <Text size="xs" c="dimmed">Switch to dark color theme</Text>
</div> </div>
<Switch disabled /> <Switch
checked={colorScheme === 'dark'}
onChange={toggleColorScheme}
/>
</Group> </Group>
<Group justify="space-between"> <Group justify="space-between">
<div> <div>
@@ -76,7 +81,7 @@ export function UserPreferencesPage() {
<Switch disabled /> <Switch disabled />
</Group> </Group>
<Divider /> <Divider />
<Text size="xs" c="dimmed" ta="center">Display preferences coming in a future release</Text> <Text size="xs" c="dimmed" ta="center">More display preferences coming in a future release</Text>
</Stack> </Stack>
</Card> </Card>

View File

@@ -117,7 +117,7 @@ export function SettingsPage() {
</Group> </Group>
<Group justify="space-between"> <Group justify="space-between">
<Text size="sm" c="dimmed">Version</Text> <Text size="sm" c="dimmed">Version</Text>
<Badge variant="light">2026.3.2 (beta)</Badge> <Badge variant="light">2026.3.7 (Beta)</Badge>
</Group> </Group>
<Group justify="space-between"> <Group justify="space-between">
<Text size="sm" c="dimmed">API</Text> <Text size="sm" c="dimmed">API</Text>

View File

@@ -0,0 +1,26 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type ColorScheme = 'light' | 'dark';
interface PreferencesState {
colorScheme: ColorScheme;
toggleColorScheme: () => void;
setColorScheme: (scheme: ColorScheme) => void;
}
export const usePreferencesStore = create<PreferencesState>()(
persist(
(set) => ({
colorScheme: 'light',
toggleColorScheme: () =>
set((state) => ({
colorScheme: state.colorScheme === 'light' ? 'dark' : 'light',
})),
setColorScheme: (scheme) => set({ colorScheme: scheme }),
}),
{
name: 'ledgeriq-preferences',
},
),
);

150
scripts/reset-password.sh Executable file
View File

@@ -0,0 +1,150 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# reset-password.sh — Reset a user's password in HOA LedgerIQ
#
# Usage:
# ./scripts/reset-password.sh <email> <new-password>
#
# Examples:
# ./scripts/reset-password.sh admin@hoaledgeriq.com MyNewPassword123
# ./scripts/reset-password.sh admin@sunrisevalley.org SecurePass!
# ---------------------------------------------------------------------------
set -euo pipefail
# ---- Defaults ----
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
DB_USER="${POSTGRES_USER:-hoafinance}"
DB_NAME="${POSTGRES_DB:-hoafinance}"
COMPOSE_CMD="docker compose"
# If running with the SSL override, detect it
if [ -f "$PROJECT_DIR/docker-compose.ssl.yml" ] && \
docker compose -f "$PROJECT_DIR/docker-compose.yml" \
-f "$PROJECT_DIR/docker-compose.ssl.yml" ps --quiet 2>/dev/null | head -1 | grep -q .; then
COMPOSE_CMD="docker compose -f $PROJECT_DIR/docker-compose.yml -f $PROJECT_DIR/docker-compose.ssl.yml"
fi
# ---- Colors ----
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
info() { echo -e "${CYAN}[INFO]${NC} $*"; }
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
err() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
die() { err "$@"; exit 1; }
# ---- Helpers ----
ensure_containers_running() {
if ! $COMPOSE_CMD ps postgres 2>/dev/null | grep -q "running\|Up"; then
die "PostgreSQL container is not running. Start it with: docker compose up -d postgres"
fi
if ! $COMPOSE_CMD ps backend 2>/dev/null | grep -q "running\|Up"; then
die "Backend container is not running. Start it with: docker compose up -d backend"
fi
}
# ---- CLI ----
usage() {
cat <<EOF
HOA LedgerIQ Password Reset
Usage:
$(basename "$0") <email> <new-password>
Examples:
$(basename "$0") admin@hoaledgeriq.com MyNewPassword123
$(basename "$0") admin@sunrisevalley.org SecurePass!
This script:
1. Verifies the user exists in the database
2. Generates a bcrypt hash using bcryptjs (same library the app uses)
3. Updates the password in the database
4. Verifies the new hash works
EOF
exit 0
}
# Parse args
case "${1:-}" in
-h|--help|help|"") usage ;;
esac
[ $# -lt 2 ] && die "Usage: $(basename "$0") <email> <new-password>"
EMAIL="$1"
NEW_PASSWORD="$2"
# Load .env if present
if [ -f "$PROJECT_DIR/.env" ]; then
set -a
# shellcheck disable=SC1091
source "$PROJECT_DIR/.env"
set +a
DB_USER="${POSTGRES_USER:-hoafinance}"
DB_NAME="${POSTGRES_DB:-hoafinance}"
fi
# Ensure containers are running
info "Checking containers ..."
ensure_containers_running
# Verify user exists
info "Looking up user: ${EMAIL} ..."
USER_RECORD=$($COMPOSE_CMD exec -T postgres psql -U "$DB_USER" -d "$DB_NAME" \
-t -A -c "SELECT id, email, first_name, last_name, is_superadmin FROM shared.users WHERE email = '${EMAIL}';" 2>/dev/null)
if [ -z "$USER_RECORD" ]; then
die "No user found with email: ${EMAIL}"
fi
# Parse user info for display
IFS='|' read -r USER_ID USER_EMAIL FIRST_NAME LAST_NAME IS_SUPER <<< "$USER_RECORD"
info "Found user: ${FIRST_NAME} ${LAST_NAME} (${USER_EMAIL})"
if [ "$IS_SUPER" = "t" ]; then
warn "This is a superadmin account"
fi
# Generate bcrypt hash using bcryptjs inside the backend container
info "Generating bcrypt hash ..."
HASH=$($COMPOSE_CMD exec -T backend node -e "
const bcrypt = require('bcryptjs');
bcrypt.hash(process.argv[1], 12).then(h => process.stdout.write(h));
" "$NEW_PASSWORD" 2>/dev/null)
if [ -z "$HASH" ] || [ ${#HASH} -lt 50 ]; then
die "Failed to generate bcrypt hash. Is the backend container running?"
fi
# Update the password using a heredoc to avoid shell escaping issues with $ in hashes
info "Updating password ..."
UPDATE_RESULT=$($COMPOSE_CMD exec -T postgres psql -U "$DB_USER" -d "$DB_NAME" -t -A <<EOSQL
UPDATE shared.users SET password_hash = '${HASH}', updated_at = NOW() WHERE email = '${EMAIL}';
EOSQL
)
if [[ "$UPDATE_RESULT" != *"UPDATE 1"* ]]; then
die "Password update failed. Result: ${UPDATE_RESULT}"
fi
# Verify the new hash works
info "Verifying new password ..."
VERIFY=$($COMPOSE_CMD exec -T backend node -e "
const bcrypt = require('bcryptjs');
bcrypt.compare(process.argv[1], process.argv[2]).then(r => process.stdout.write(String(r)));
" "$NEW_PASSWORD" "$HASH" 2>/dev/null)
if [ "$VERIFY" != "true" ]; then
die "Verification failed — the hash does not match the password. Something went wrong."
fi
echo ""
ok "Password reset successful!"
echo ""
info " User: ${FIRST_NAME} ${LAST_NAME} (${USER_EMAIL})"
info " Login: ${EMAIL}"
echo ""